Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84c566412 | ||
|
|
35278bf2f5 | ||
|
|
b5c1f60464 | ||
|
|
2798977867 | ||
|
|
b259e22458 | ||
|
|
12d572757e | ||
|
|
15f7fc7ee8 | ||
|
|
12e5d5aa33 | ||
|
|
b49bb1ef31 | ||
|
|
191cfdbabb | ||
|
|
c3eca0b68c | ||
|
|
8ce7bdaaa7 | ||
|
|
67f5cf6ce7 | ||
|
|
24f7c38a5b | ||
|
|
820b17842b | ||
|
|
08d3417b05 | ||
|
|
d2e868293f | ||
|
|
0270462fe4 | ||
|
|
7e77d56879 | ||
|
|
8f162729ea | ||
|
|
417e61da00 | ||
|
|
a71ca6e3fb | ||
|
|
080ef9ec8e | ||
|
|
2aa28c7e18 | ||
|
|
705d795e6d | ||
|
|
60fd71bc92 | ||
|
|
8601a2af6f | ||
|
|
55810bb1e2 | ||
|
|
ce0d441a2e | ||
|
|
cf07aaf8fb | ||
|
|
2ba47cb4ee | ||
|
|
4d602b2acb | ||
|
|
706a094b20 | ||
|
|
9edd62634f | ||
|
|
b40ee8b61c | ||
|
|
26e1679e4e | ||
|
|
56bd80f7bb | ||
|
|
ff9e59a310 | ||
|
|
79b440f6f7 | ||
|
|
394e78ec6a | ||
|
|
fbf33e076b | ||
|
|
a4ec9ad71e | ||
|
|
0ccd02b3c5 | ||
|
|
dd6f862d61 | ||
|
|
b3275f6a81 | ||
|
|
108541af91 | ||
|
|
dc82b0b95d | ||
|
|
37f86aaf75 | ||
|
|
938980f1e1 | ||
|
|
cd5786ba78 | ||
|
|
7582504268 | ||
|
|
ebbb3cd178 | ||
|
|
a7765c5079 | ||
|
|
c073e29b97 |
@@ -5,3 +5,5 @@ config.json
|
|||||||
!/dev/config.json
|
!/dev/config.json
|
||||||
*.test
|
*.test
|
||||||
/dev/data/
|
/dev/data/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|||||||
@@ -1,5 +1,89 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.4.1
|
||||||
|
|
||||||
|
- The README shows screenshots of every view (a made-up library). No changes to the program.
|
||||||
|
|
||||||
|
## v0.4.0
|
||||||
|
|
||||||
|
New:
|
||||||
|
|
||||||
|
- **First start without a config file:** arr-tui creates the file (private, with its directory)
|
||||||
|
and opens the Config view to enter the services, instead of refusing to start.
|
||||||
|
- **Services may be left out:** one without address and key is not used (e.g. no SABnzbd); its
|
||||||
|
indicator stays grey and its lists say "not configured" instead of showing errors.
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Library posters shown as real images (kitty, Ghostty) were soft: they used Radarr's/Sonarr's
|
||||||
|
250×375 copy, enlarged to up to 640 pixels. Real images now use the 500×750 copy; block-character
|
||||||
|
posters keep the small one.
|
||||||
|
|
||||||
|
## v0.3.0
|
||||||
|
|
||||||
|
New:
|
||||||
|
|
||||||
|
- **Config (F6):** the default quality profile and root folder are picked from the lists Radarr and
|
||||||
|
Sonarr have, loaded with the address and API key in the fields (when the view opens, and again
|
||||||
|
shortly after either is changed, before saving). A configured value the server does not have
|
||||||
|
stays selectable and is marked "(not on the server)".
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- **Library:** long titles are cut with "…" to the width of the list, so the movies and series
|
||||||
|
lists never scroll sideways and status and size stay in view; the cut follows the window size.
|
||||||
|
- **Library:** after removing a title the selection stays at its place (on the title that moved up
|
||||||
|
into it, or the new last one) instead of jumping to the top of the list.
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Help over the Config view: after moving through the fields with the arrow keys, F9 left the field
|
||||||
|
in use focused behind the help, so its cursor showed through the window, the help did not scroll,
|
||||||
|
and closing it returned to the wrong field.
|
||||||
|
- Library titles with square brackets (e.g. "[Rec]") were read as color tags.
|
||||||
|
|
||||||
|
## v0.2.0
|
||||||
|
|
||||||
|
New:
|
||||||
|
|
||||||
|
- **Calendar (F5):** two weeks of movie releases and airing episodes grouped by day, with poster
|
||||||
|
and plot; move by weeks with `[` and `]`, include unmonitored items, release search and automatic
|
||||||
|
search.
|
||||||
|
- **Config (F6):** edit the config file inside the program (addresses, API keys, add-form defaults,
|
||||||
|
refresh interval, posters, theme, notifications) in one list you move through with the arrow
|
||||||
|
keys, test the connections, and save; everything takes effect at once. Values from `ARR_TUI_*`
|
||||||
|
environment variables are never written to the file.
|
||||||
|
- **Dark and light themes** with fixed colors that look the same in every terminal, whatever its
|
||||||
|
color theme (`ui.theme`: `auto`, `dark`, `light`; switchable live in the Config view). `auto`
|
||||||
|
asks the terminal for its background color. Before, the panes used the terminal's palette color
|
||||||
|
0, which many color themes set to a grey that is not their background.
|
||||||
|
- **Posters as real images** in kitty and Ghostty (Kitty graphics protocol with Unicode
|
||||||
|
placeholders) at the terminal's full resolution, used only when the terminal confirms the
|
||||||
|
protocol at startup. Elsewhere the block-character posters remain; `auto` now also picks sextants
|
||||||
|
when fontconfig finds a font with them, and plain colored cells in macOS Terminal, whose half
|
||||||
|
blocks do not line up. `ui.cover_style` gains `kitty` and `cells`.
|
||||||
|
- **Series drill-down** shows the series poster and the details of the selected season or episode,
|
||||||
|
including its plot.
|
||||||
|
- **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the
|
||||||
|
background (`ui.notify_downloads`, optional `ui.bell`).
|
||||||
|
- **Remove from the library** (`d`), with separate checkboxes for deleting the files and for the
|
||||||
|
import exclusion; both off by default.
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Views: F5 Calendar, F6 Config, F7 Log (the Log was F5).
|
||||||
|
- **F9** is the help key; `?` is gone. **Ctrl+C** quits; `q` no longer does.
|
||||||
|
- Space presses buttons (Save, Revert, the buttons of dialogs), as the help says.
|
||||||
|
- On narrow terminals the connection indicators shrink to initials.
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Posters that Sonarr/Radarr store as WebP (or GIF) under a `.jpg` name failed with "unknown
|
||||||
|
format"; posters from instances behind a login page are fetched with the API key as a query
|
||||||
|
parameter, or from the metadata provider. The Log says why a poster could not be read.
|
||||||
|
- Moving through a list with a held arrow key lagged on very large terminals (zoomed far out): the
|
||||||
|
poster now waits until the selection rests.
|
||||||
|
|
||||||
## v0.1.0
|
## v0.1.0
|
||||||
|
|
||||||
First release.
|
First release.
|
||||||
@@ -17,7 +101,7 @@ First release.
|
|||||||
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
|
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
|
||||||
retry, remove and blocklist.
|
retry, remove and blocklist.
|
||||||
- **Posters:** a thumbnail at the top right of the detail panes with the text flowing around it (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`).
|
- **Posters:** a thumbnail at the top right of the detail panes with the text flowing around it (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`).
|
||||||
- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history.
|
- **Log (F7) and popups:** feedback appears as fading popups; the log keeps the history.
|
||||||
- Connection indicators for all three services, context-sensitive key hints, `?` help.
|
- Connection indicators for all three services, context-sensitive key hints, a help screen (F9).
|
||||||
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
|
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
|
||||||
(`make dist`).
|
(`make dist`).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
|||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
|
TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
|
||||||
|
|
||||||
.PHONY: build test dist clean
|
.PHONY: build test dist release clean
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui
|
go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui
|
||||||
@@ -10,6 +10,7 @@ build:
|
|||||||
test:
|
test:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./...
|
go test ./...
|
||||||
|
@if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh dev/*.sh; else echo "shellcheck not installed, skipping shell script checks"; fi
|
||||||
|
|
||||||
# Cross-compiled binaries and checksums in dist/.
|
# Cross-compiled binaries and checksums in dist/.
|
||||||
dist:
|
dist:
|
||||||
@@ -22,5 +23,9 @@ dist:
|
|||||||
done
|
done
|
||||||
cd dist && sha256sum arr-tui-* > SHA256SUMS
|
cd dist && sha256sum arr-tui-* > SHA256SUMS
|
||||||
|
|
||||||
|
# Publish the release for the tag at HEAD on Gitea (see scripts/release.sh).
|
||||||
|
release:
|
||||||
|
scripts/release.sh
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist arr-tui
|
rm -rf dist arr-tui
|
||||||
|
|||||||
@@ -4,30 +4,66 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
[Sonarr](https://sonarr.tv), pick releases by hand, and follow the downloads in
|
[Sonarr](https://sonarr.tv), pick releases by hand, and follow the downloads in
|
||||||
[SABnzbd](https://sabnzbd.org).
|
[SABnzbd](https://sabnzbd.org).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
- **Movies / Series**: search as you type, add titles with a quality profile and root folder,
|
- **Movies / Series**: search as you type, add titles with a quality profile and root folder,
|
||||||
optionally start the automatic search.
|
optionally start the automatic search.
|
||||||
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
|
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
|
||||||
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
|
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
|
||||||
- **Posters**: the detail panes show the poster at the top right; title, facts, path and plot
|
- **Posters**: the detail panes show the poster at the top right; title, facts, path and plot
|
||||||
flow along its left side and continue at full width below it. It is drawn with colored block characters (works over SSH, needs a terminal
|
flow along its left side and continue at full width below it. In kitty and Ghostty it is a real
|
||||||
with colors). Library items use the copy stored in Radarr/Sonarr; search results are downloaded
|
image at the terminal's full resolution (Kitty graphics protocol with Unicode placeholders, so
|
||||||
from the metadata provider's image host (TMDb, TheTVDB). Turn it off with
|
popups and dialogs cover it like text). Everywhere else it is drawn with colored block characters,
|
||||||
`"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the characters: `quadrant` (2x2
|
which works over SSH and in any terminal with colors. Library items use the copy stored in
|
||||||
pixels per cell, every font has them), `sextant` (2x3, sharper, needs a font with the Symbols for
|
Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb,
|
||||||
Legacy Computing block, e.g. kitty, WezTerm, foot, Ghostty) or `auto` (default: sextant on
|
TheTVDB). Turn it off with `"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the
|
||||||
terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the
|
drawing: `auto` (default: real images where the terminal is known to support them, otherwise
|
||||||
poster, set `quadrant`.
|
plain cells in macOS Terminal, sextants on terminals known to have the glyphs or when fontconfig
|
||||||
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
|
finds an installed font with them (not over SSH), else quadrants), `kitty` (force real images),
|
||||||
monitoring, trigger searches. Series open into seasons and episodes with per-season and
|
`sextant` (2x3 pixels per cell, needs a font with the Symbols for Legacy Computing block) or
|
||||||
per-episode monitoring, automatic search and release search.
|
`quadrant` (2x2, every font has them) or `cells` (one colored cell per pixel, no glyphs; for
|
||||||
|
terminals whose block characters come out uneven, like macOS Terminal). Inside tmux or screen the
|
||||||
|
block styles are used. If a poster looks wrong, set `quadrant`, or `cells` if the rows look shifted.
|
||||||
|
- **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes
|
||||||
|
in one list grouped by day, with poster and plot. Move by weeks, include unmonitored items, search
|
||||||
|
for releases or start an automatic search from there.
|
||||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||||
retry failed downloads, remove from the arr queue and blocklist.
|
retry failed downloads, remove from the arr queue and blocklist.
|
||||||
- **Log (F5)**: actions report through popups that fade away; the log keeps the history.
|
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move
|
||||||
- Connection indicators for all three services in the top right; `?` shows every key.
|
through with the arrow keys: service addresses and API keys, the add-form defaults (quality profile and root folder, picked from the lists the server has), refresh interval, posters, theme, notifications. *Test*
|
||||||
|
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a
|
||||||
|
new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*`
|
||||||
|
environment variables are greyed out and are never written to the file.
|
||||||
|
- **Log (F7)**: actions report through popups that fade away; the log keeps the history.
|
||||||
|
- **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails,
|
||||||
|
whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished
|
||||||
|
before arr-tui started are not announced. `"ui": {"notify_downloads": false}` turns it off,
|
||||||
|
`"bell": true` also rings the terminal bell.
|
||||||
|
- **Themes**: fixed dark and light color sets that look the same in every terminal, whatever its
|
||||||
|
color theme. `"ui": {"theme": ...}` is `auto` (default: light when the terminal reports a light
|
||||||
|
background, else dark), `dark` or `light`; it can also be switched in the Config view, which
|
||||||
|
recolors everything at once.
|
||||||
|
- Connection indicators for all three services in the top right; `F9` shows every key.
|
||||||
|
|
||||||
Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are
|
Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are
|
||||||
shown for grabbing, torrents are hidden.
|
shown for grabbing, torrents are hidden.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
The screenshots show a made-up library (invented titles, generated posters), taken in Ghostty,
|
||||||
|
where posters are real images.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **Movies / Series**: search as you type | **Releases**: pick one by hand |
|
||||||
|
|  |  |
|
||||||
|
| **Seasons and episodes** of a series | **Downloads** from SABnzbd, Radarr and Sonarr |
|
||||||
|
|  |  |
|
||||||
|
| **Calendar** of the next two weeks | **Config** inside the program |
|
||||||
|
|  | |
|
||||||
|
| **Light theme** | |
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -38,16 +74,18 @@ or build from a checkout (`make build`), or `make dist` for cross-compiled binar
|
|||||||
|
|
||||||
## Configure
|
## Configure
|
||||||
|
|
||||||
Copy `config.example.json` to `$XDG_CONFIG_HOME/arr-tui/config.json` (usually
|
Just start `arr-tui`: on the first start it creates `$XDG_CONFIG_HOME/arr-tui/config.json`
|
||||||
`~/.config/arr-tui/config.json`), or pass `--config path`. API keys are in each app under
|
(usually `~/.config/arr-tui/config.json`, or the file given with `--config path`) and opens the
|
||||||
Settings → General.
|
Config view (F6), where you enter the address and API key of each service and save. API keys are in
|
||||||
|
each app under Settings → General. A service left empty is not used (e.g. no SABnzbd), its indicator
|
||||||
|
stays grey. You can also write the file yourself, starting from `config.example.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" },
|
"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" },
|
"sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
|
||||||
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
|
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
|
||||||
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto" }
|
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto", "theme": "auto", "notify_downloads": true, "bell": false }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -59,25 +97,32 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
|||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `F1`–`F5` | switch view (Movies, Series, Library, Downloads, Log) |
|
| `F1`–`F7` | switch view (Movies, Series, Library, Downloads, Calendar, Config, Log) |
|
||||||
| `Tab`, `Shift+Tab` | switch panel within a view |
|
| `Tab`, `Shift+Tab` | switch panel within a view |
|
||||||
| `?` / `F9` | help |
|
| `F9` | help |
|
||||||
| `/` | focus search box |
|
| `/` | focus search box |
|
||||||
| `Enter`, `a` | add selected title |
|
| `Enter`, `a` | add selected title |
|
||||||
| `i` | interactive release search |
|
| `i` | interactive release search |
|
||||||
| `Tab`, `f`, `F`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, releases/episodes, reload |
|
| `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload |
|
||||||
|
| `[`, `]`, `t`, `u`, `Enter`, `s` (calendar) | week back / forward, today, unmonitored, releases, search |
|
||||||
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
|
||||||
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties |
|
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties |
|
||||||
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
|
||||||
| `Esc` | back / close dialog |
|
| `Esc` | back / close dialog |
|
||||||
| `q`, `Ctrl+C` | quit |
|
| `Ctrl+C` | quit |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
`dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer;
|
`dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer;
|
||||||
see [dev/README.md](dev/README.md). `make test` runs vet and the unit tests. The design and
|
see [dev/README.md](dev/README.md).
|
||||||
|
`make test` runs vet and the unit tests. The design and
|
||||||
milestone history are in [SPECS.md](SPECS.md).
|
milestone history are in [SPECS.md](SPECS.md).
|
||||||
|
|
||||||
|
Releases: tag and push, then `scripts/release.sh` (or `make release`) builds the binaries and publishes
|
||||||
|
them on Gitea with the matching `CHANGELOG.md` section. It needs an access token with write access
|
||||||
|
to the repository in `$GITEA_TOKEN` or `~/.config/arr-tui-release-token`; `--check` verifies
|
||||||
|
everything without publishing, `--dry-run` builds without publishing.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT, see [LICENSE](LICENSE).
|
MIT, see [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and
|
|||||||
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
|
||||||
- **License:** MIT
|
- **License:** MIT
|
||||||
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
|
||||||
- **Status:** M9 done
|
- **Status:** M10 done
|
||||||
|
|
||||||
## 1. Goals
|
## 1. Goals
|
||||||
|
|
||||||
@@ -62,20 +62,23 @@ Notes:
|
|||||||
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
|
- 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.
|
- 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.
|
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
||||||
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
|
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `F9` opens the full key reference.
|
||||||
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. Two ways to draw the poster, chosen by `ui.cover_style`:
|
||||||
- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
- **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks) and placed virtually with the cell footprint (`a=p,U=1,c=,r=`) right away. Every footprint (crop and cell size) is its own image id, placed once and never changed: Ghostty kept showing the old, small placement when the size of an existing one was changed, so a new size uploads under a new id and deletes the old one. The poster is shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; all images are deleted whenever the window or cell size changes, block characters stand in while the size keeps changing, and real images are uploaded afresh once it has been stable for 300 ms, because uploading and placing for every intermediate size of a window drag raced with the terminal's own resizing) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`, and then asked before the screen starts (a Kitty graphics query and a background-color query followed by a device-attributes request, 300 ms timeout, waiting with select(2) because a read deadline does not work on a terminal device on macOS): only a terminal that answers the query gets real images, also with `cover_style: kitty`, because a terminal without the protocol prints the commands as text. The Log says which way posters are drawn. With `ARR_TUI_DEBUG_GFX=1` the Log (F7) lists every window size, the cell shape, every upload (poster size, crop, cells) and every change of what is drawn (image id, position, cells); with a path (`ARR_TUI_DEBUG_GFX=/tmp/arr-gfx.log`) the same lines also go to that file with millisecond timestamps, so they can be read without leaving the view that shows the problem. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen.
|
||||||
|
- **Block characters** (`quadrant`, `sextant`, `cells`, and the fallback): every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing; `cells`: 1 pixel per cell drawn as background color only, because macOS Terminal draws block elements from the font with the halves off the cell's middle). `auto` picks cells in macOS Terminal (`TERM_PROGRAM=Apple_Terminal`), sextants on kitty, WezTerm, foot and Ghostty, and on other terminals (not on macOS, whose terminals do not use fontconfig's fonts) when fontconfig lists a font with the sextant characters (`fc-list :charset=1fb00`, about 20 ms at startup; terminals draw glyphs their font lacks from fallback fonts; skipped over SSH because the installed fonts are the remote machine's), and quadrants otherwise.
|
||||||
|
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` (250×375; `poster-500.jpg` when posters are real images, which otherwise enlarge the small copy) from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
||||||
|
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
|
||||||
|
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
||||||
|
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
|
||||||
|
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
||||||
|
|
||||||
### 4.2 v0.2 (done, M8)
|
### 4.2 v0.2 (done, M8)
|
||||||
|
|
||||||
- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (status: all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) act only on the focused list, and each list keeps its own status filter, property filter and text; the lists show their active filters in their titles. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series).
|
- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (status: all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) act only on the focused list, and each list keeps its own status filter, property filter and text; the lists show their active filters in their titles. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `d` removes the item from the library (a dialog with "Delete files from disk" and "import exclusion" checkboxes, both off by default and Cancel focused first; `DELETE /movie|series/{id}?deleteFiles=&addImport(List)Exclusion=`), `i`/Enter opens the release search (movies) or the drill-down (series).
|
||||||
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
|
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. A detail pane on the right shows the series poster, the series and the selected season (download count, monitoring) or episode (air date, status, monitoring, its own plot).
|
||||||
|
|
||||||
### 4.3 Later / ideas
|
### 4.3 Later / ideas
|
||||||
|
|
||||||
- Calendar / upcoming view.
|
|
||||||
- Desktop/terminal notification when a download completes.
|
|
||||||
- Multiple instances per service (e.g. 4K Radarr).
|
|
||||||
- Poster preview via sixel / kitty graphics where supported.
|
- Poster preview via sixel / kitty graphics where supported.
|
||||||
|
|
||||||
## 5. UI Design (tview)
|
## 5. UI Design (tview)
|
||||||
@@ -94,11 +97,13 @@ Layout sketch (Library view; the other views follow the same frame):
|
|||||||
│ ● Show One (2008) 12/62 │ │
|
│ ● Show One (2008) 12/62 │ │
|
||||||
│ ○ Show Two (2015) 10/10 │ │
|
│ ○ Show Two (2015) 10/10 │ │
|
||||||
├────────────────────────────────┴───────────────────────────────────┤
|
├────────────────────────────────┴───────────────────────────────────┤
|
||||||
│ Tab movies/series · f filter · / text · t monitor · ? help · q quit │ ← key hints of the current view
|
│ Tab movies/series · f filter · / text · t monitor · F9 help · Ctrl+C quit │ ← key hints of the current view
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
Popups for action feedback appear in the bottom right and fade away; the Log view (F5) keeps the history.
|
Popups for action feedback appear in the bottom right and fade away; the Log view (F7) keeps the history.
|
||||||
|
|
||||||
|
Colors: two fixed themes, dark (near-black background, like k9s) and light, chosen with `ui.theme`: `auto` (default) asks the terminal for its background color at startup (OSC 11, in the same round trip as the graphics query) and takes light for a background with luminance above 0.5, dark otherwise or without an answer. The terminal's palette is not used: color themes often make color 0 ("black", tview's default background) a grey that differs from their background (Catppuccin Mocha: `#45475a` on `#1e1e2e`; Latte: `#5c5f77` on `#eff1f5`), which looked washed out, and on light themes dark grey slabs carried white text. Themes switch while the program runs: widgets copy tview's default styles when they are created, so instead of colors they get role tokens, one unique RGB value per role (background, text, border, field, yellow, ...; `0xa517nn`), set as tview's defaults before any widget exists and as the color names used in texts in tcell's table (`[yellow]` is the yellow role, `white` text and `black` background, so the active tab `[black:white]` follows too). The tcell screen is wrapped (`themedScreen`), and every `SetContent` replaces role tokens with the current theme's colors; tview clears and redraws the whole screen each frame, so switching the theme is a change of that table and one draw. Other colors pass unchanged: poster cells (a poster color that equals a token is nudged by one step) and the image-id colors of real-image placeholders. Every theme color is an entry 16-255 of the 256-color table, so terminals without 24-bit color show it exactly, and never one of the first 16 entries that color themes change. The Log says which theme is used and why.
|
||||||
|
|
||||||
tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive.
|
tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive.
|
||||||
|
|
||||||
@@ -106,16 +111,18 @@ Keybindings (the status bar shows the ones that apply to the current view):
|
|||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `F1-F5` | Switch view (Movies, Series, Library, Downloads, Log); also while typing, and from full-screen views. Dialogs block them. |
|
| `F1-F7` | Switch view (Movies, Series, Library, Downloads, Calendar, Config, Log); also while typing, and from full-screen views. Dialogs block them. |
|
||||||
| `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists |
|
| `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists |
|
||||||
| `?` / `F9` | Help (`F9` also works while typing) |
|
| `F9` | Help (works everywhere, also while typing) |
|
||||||
| `q` / `Ctrl+C` | Quit |
|
| `Ctrl+C` | Quit (there is no single-letter quit key, so typing never quits by accident) |
|
||||||
| `Esc` | Close a dialog or a full-screen view |
|
| `Esc` | Close a dialog or a full-screen view |
|
||||||
| `/` | Movies, Series, Library: focus the search / text filter |
|
| `/` | Movies, Series, Library: focus the search / text filter |
|
||||||
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
|
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
|
||||||
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
|
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
|
||||||
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
|
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
|
||||||
| `f` | Library: cycle the status filter; release list: hide rejected releases |
|
| `f` | Library: cycle the status filter; release list: hide rejected releases |
|
||||||
|
| `[` `]` `t` `u` | Calendar: week back / forward, today, include unmonitored |
|
||||||
|
| `d` | Library: remove the item from the library (asks first) |
|
||||||
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
|
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
|
||||||
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) |
|
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) |
|
||||||
| `Enter` / `g` | Release list: grab the selected release (rejected ones ask for confirmation) |
|
| `Enter` / `g` | Release list: grab the selected release (rejected ones ask for confirmation) |
|
||||||
@@ -198,10 +205,11 @@ The file should be created with mode `0600`; warn if it is more permissive. API
|
|||||||
- [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
|
- [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
|
||||||
- [x] **M5** — Radarr/Sonarr queues merged into the Downloads view.
|
- [x] **M5** — Radarr/Sonarr queues merged into the Downloads view.
|
||||||
- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
|
||||||
- [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`.
|
- [x] **M7** — Polish: help screen (F9), per-service connection indicators, README, Makefile release builds, `--version`.
|
||||||
- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search).
|
- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search).
|
||||||
- [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar.
|
- [x] **M9** — Popup notifications, Log view (F7), context-sensitive key hints in the status bar.
|
||||||
- [ ] **M10+** — ideas: calendar, completion notifications, delete from library, multiple instances.
|
- [x] **M10** — Download completion notifications, remove from library, calendar view (F5; the Log moves to F7, after the Config view on F6). Multiple instances per service are out of scope.
|
||||||
|
- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title.
|
||||||
|
|
||||||
## 10. Open Questions
|
## 10. Open Questions
|
||||||
|
|
||||||
@@ -211,7 +219,7 @@ Open: none.
|
|||||||
Decided:
|
Decided:
|
||||||
|
|
||||||
- Go module path: `git.stinnesbeck.com/nils/arr-tui`.
|
- Go module path: `git.stinnesbeck.com/nils/arr-tui`.
|
||||||
- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is a later idea).
|
- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is out of scope).
|
||||||
- Minimum Go version: latest stable.
|
- 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.
|
- 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.
|
- Config format: JSON.
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||||
@@ -38,6 +40,13 @@ func run() error {
|
|||||||
*path = p
|
*path = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No config yet (first start): create an empty one, the Config view fills it in.
|
||||||
|
if _, err := os.Stat(*path); errors.Is(err, fs.ErrNotExist) {
|
||||||
|
if err := config.Create(*path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cfg, warnings, err := config.Load(*path)
|
cfg, warnings, err := config.Load(*path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -46,5 +55,11 @@ func run() error {
|
|||||||
fmt.Fprintln(os.Stderr, "warning:", w)
|
fmt.Fprintln(os.Stderr, "warning:", w)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ui.New(cfg).Run()
|
raw, err := config.LoadFile(*path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
app := ui.New(cfg)
|
||||||
|
app.SetConfigSource(*path, raw)
|
||||||
|
return app.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
"ui": {
|
"ui": {
|
||||||
"refresh_seconds": 3,
|
"refresh_seconds": 3,
|
||||||
"covers": true,
|
"covers": true,
|
||||||
"cover_style": "auto"
|
"notify_downloads": true,
|
||||||
|
"bell": false,
|
||||||
|
"cover_style": "auto",
|
||||||
|
"theme": "auto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -5,13 +5,14 @@ go 1.26.8
|
|||||||
require (
|
require (
|
||||||
github.com/gdamore/tcell/v2 v2.13.10
|
github.com/gdamore/tcell/v2 v2.13.10
|
||||||
github.com/rivo/tview v0.42.0
|
github.com/rivo/tview v0.42.0
|
||||||
|
golang.org/x/image v0.47.0
|
||||||
|
golang.org/x/sys v0.49.0
|
||||||
|
golang.org/x/term v0.37.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/text v0.43.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
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-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/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
|
||||||
|
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -26,8 +28,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -38,8 +40,8 @@ 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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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.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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -11,16 +11,34 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settings returns the current base URL and API key.
|
||||||
|
func (c *Client) settings() (baseURL, apiKey string) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.baseURL, c.apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconfigure points the client at another instance or key. Requests already
|
||||||
|
// in flight are not affected.
|
||||||
|
func (c *Client) Reconfigure(baseURL, apiKey string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
c.apiKey = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient returns a client for the instance at baseURL (without /api/v3).
|
// NewClient returns a client for the instance at baseURL (without /api/v3).
|
||||||
func NewClient(baseURL, apiKey string) *Client {
|
func NewClient(baseURL, apiKey string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
@@ -30,6 +48,10 @@ func NewClient(baseURL, apiKey string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNotConfigured is returned without a request while the client has no
|
||||||
|
// address: the service is not set up.
|
||||||
|
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
||||||
|
|
||||||
// APIError is returned for non-2xx responses.
|
// APIError is returned for non-2xx responses.
|
||||||
type APIError struct {
|
type APIError struct {
|
||||||
Status int
|
Status int
|
||||||
@@ -46,7 +68,11 @@ func (e *APIError) Error() string {
|
|||||||
// Do sends a request to /api/v3/<path>. body (if non-nil) is JSON-encoded and
|
// Do sends a request to /api/v3/<path>. body (if non-nil) is JSON-encoded and
|
||||||
// the response is decoded into out (if non-nil).
|
// the response is decoded into out (if non-nil).
|
||||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||||
u := c.baseURL + "/api/v3/" + strings.TrimLeft(path, "/")
|
base, apiKey := c.settings()
|
||||||
|
if base == "" {
|
||||||
|
return ErrNotConfigured
|
||||||
|
}
|
||||||
|
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||||
if len(query) > 0 {
|
if len(query) > 0 {
|
||||||
u += "?" + query.Encode()
|
u += "?" + query.Encode()
|
||||||
}
|
}
|
||||||
@@ -64,7 +90,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Api-Key", c.apiKey)
|
req.Header.Set("X-Api-Key", apiKey)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package arr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -16,41 +18,79 @@ const maxImageBytes = 8 << 20
|
|||||||
// function that downloads it, or ok=false when there is none.
|
// function that downloads it, or ok=false when there is none.
|
||||||
//
|
//
|
||||||
// Library items have a copy on the Radarr/Sonarr server, which is fetched
|
// Library items have a copy on the Radarr/Sonarr server, which is fetched
|
||||||
// with the API key (preferring the small -250 variant). Titles that are not
|
// with the API key, preferring a scaled variant: -250 (250×375), or -500
|
||||||
// in the library yet only have the remote URL of the metadata provider; that
|
// (500×750) when fetch is asked for a large poster. Titles that are not in
|
||||||
// is fetched without the API key.
|
// the library yet only have the remote URL of the metadata provider; that is
|
||||||
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) {
|
// fetched without the API key.
|
||||||
|
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context, large bool) ([]byte, error), ok bool) {
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
if img.CoverType != "poster" {
|
if img.CoverType != "poster" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if inLibrary && img.URL != "" {
|
if inLibrary && img.URL != "" {
|
||||||
local := img.URL
|
local, remote := img.URL, img.RemoteURL
|
||||||
return c.baseURL + "|" + local, func(ctx context.Context) ([]byte, error) {
|
base, _ := c.settings()
|
||||||
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original.
|
return base + "|" + local, func(ctx context.Context, large bool) ([]byte, error) {
|
||||||
if small := smallVariant(local); small != local {
|
data, err := c.localPoster(ctx, local, large)
|
||||||
if data, err := c.GetBytes(ctx, small); err == nil {
|
if err != nil && remote != "" {
|
||||||
return data, nil
|
// The instance has no usable copy (not cached, or behind a login):
|
||||||
|
// use the metadata provider's, as for titles not in the library.
|
||||||
|
if remoteData, remoteErr := FetchURL(ctx, remote); remoteErr == nil {
|
||||||
|
return remoteData, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.GetBytes(ctx, local)
|
return data, err
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
if img.RemoteURL != "" {
|
if img.RemoteURL != "" {
|
||||||
remote := img.RemoteURL
|
remote := img.RemoteURL
|
||||||
return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true
|
return remote, func(ctx context.Context, _ bool) ([]byte, error) { return FetchURL(ctx, remote) }, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", nil, false
|
return "", nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x.
|
// localPoster fetches a poster from the instance: the scaled variant (-500
|
||||||
func smallVariant(p string) string {
|
// when large, else -250), then the original. When the instance answers with a page instead of an image (it
|
||||||
|
// wants a login for /MediaCover) the API key is also tried as a query
|
||||||
|
// parameter, which is the other way Radarr and Sonarr accept it.
|
||||||
|
func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]byte, error) {
|
||||||
|
try := func(ref string) ([]byte, error) {
|
||||||
|
data, err := c.GetBytes(ctx, ref)
|
||||||
|
var page *notImageError
|
||||||
|
if errors.As(err, &page) {
|
||||||
|
if data, err2 := c.getBytes(ctx, ref, true); err2 == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
width := 250
|
||||||
|
if large {
|
||||||
|
width = 500
|
||||||
|
}
|
||||||
|
if scaled := scaledVariant(local, width); scaled != local {
|
||||||
|
data, err := try(scaled)
|
||||||
|
if err == nil {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
var page *notImageError
|
||||||
|
if errors.As(err, &page) {
|
||||||
|
return nil, err // the original would be a page as well
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try(local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaledVariant turns /MediaCover/1/poster.jpg?x into
|
||||||
|
// /MediaCover/1/poster-250.jpg?x (for width 250; Radarr and Sonarr keep 250
|
||||||
|
// and 500).
|
||||||
|
func scaledVariant(p string, width int) string {
|
||||||
path, query, hasQuery := strings.Cut(p, "?")
|
path, query, hasQuery := strings.Cut(p, "?")
|
||||||
if !strings.HasSuffix(path, "/poster.jpg") {
|
if !strings.HasSuffix(path, "/poster.jpg") {
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
path = strings.TrimSuffix(path, ".jpg") + "-250.jpg"
|
path = strings.TrimSuffix(path, ".jpg") + "-" + strconv.Itoa(width) + ".jpg"
|
||||||
if hasQuery {
|
if hasQuery {
|
||||||
return path + "?" + query
|
return path + "?" + query
|
||||||
}
|
}
|
||||||
@@ -60,29 +100,60 @@ func smallVariant(p string) string {
|
|||||||
// GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg
|
// GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg
|
||||||
// from the instance, authenticated with the API key.
|
// from the instance, authenticated with the API key.
|
||||||
func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) {
|
func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) {
|
||||||
|
return c.getBytes(ctx, ref, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBytes sends the API key as a header, or with keyInQuery as the apikey
|
||||||
|
// query parameter.
|
||||||
|
func (c *Client) getBytes(ctx context.Context, ref string, keyInQuery bool) ([]byte, error) {
|
||||||
u, err := c.resolve(ref)
|
u, err := c.resolve(ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_, apiKey := c.settings()
|
||||||
|
if keyInQuery {
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := parsed.Query()
|
||||||
|
q.Set("apikey", apiKey)
|
||||||
|
parsed.RawQuery = q.Encode()
|
||||||
|
u = parsed.String()
|
||||||
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Api-Key", c.apiKey)
|
if !keyInQuery {
|
||||||
|
req.Header.Set("X-Api-Key", apiKey)
|
||||||
|
}
|
||||||
return doImage(c.http, req)
|
return doImage(c.http, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notImageError means the server answered 200 with something that is not an
|
||||||
|
// image, typically the login page of an instance that requires authentication.
|
||||||
|
type notImageError struct{ contentType string }
|
||||||
|
|
||||||
|
func (e *notImageError) Error() string {
|
||||||
|
return fmt.Sprintf("image: the server sent %q instead of an image (a login page?)", e.contentType)
|
||||||
|
}
|
||||||
|
|
||||||
// resolve joins a server-relative path to the base URL. Radarr/Sonarr include
|
// resolve joins a server-relative path to the base URL. Radarr/Sonarr include
|
||||||
// their URL base in image paths, so it must not be added twice.
|
// their URL base in image paths, so it must not be added twice.
|
||||||
func (c *Client) resolve(ref string) (string, error) {
|
func (c *Client) resolve(ref string) (string, error) {
|
||||||
base, err := url.Parse(c.baseURL)
|
baseURL, _ := c.settings()
|
||||||
|
if baseURL == "" {
|
||||||
|
return "", ErrNotConfigured
|
||||||
|
}
|
||||||
|
base, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
|
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
|
||||||
return base.Scheme + "://" + base.Host + ref, nil
|
return base.Scheme + "://" + base.Host + ref, nil
|
||||||
}
|
}
|
||||||
return c.baseURL + ref, nil
|
return baseURL + ref, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageClient = &http.Client{Timeout: 20 * time.Second}
|
var imageClient = &http.Client{Timeout: 20 * time.Second}
|
||||||
@@ -109,6 +180,11 @@ func doImage(client *http.Client, req *http.Request) ([]byte, error) {
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("image: HTTP %d", resp.StatusCode)
|
return nil, fmt.Errorf("image: HTTP %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
// Pages and error documents. (Not all of text/*: a plain server may label
|
||||||
|
// image bytes it cannot identify as text/plain.)
|
||||||
|
if ct := resp.Header.Get("Content-Type"); strings.HasPrefix(ct, "text/html") || strings.HasPrefix(ct, "application/xhtml") || strings.HasPrefix(ct, "application/json") {
|
||||||
|
return nil, ¬ImageError{contentType: ct}
|
||||||
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,21 +4,46 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSmallVariant(t *testing.T) {
|
func TestScaledVariant(t *testing.T) {
|
||||||
for in, want := range map[string]string{
|
for _, tc := range []struct {
|
||||||
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5",
|
in string
|
||||||
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg",
|
width int
|
||||||
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg",
|
want string
|
||||||
|
}{
|
||||||
|
{"/MediaCover/1/poster.jpg?lastWrite=5", 250, "/MediaCover/1/poster-250.jpg?lastWrite=5"},
|
||||||
|
{"/MediaCover/1/poster.jpg", 500, "/MediaCover/1/poster-500.jpg"},
|
||||||
|
{"/MediaCover/1/fanart.jpg", 250, "/MediaCover/1/fanart.jpg"},
|
||||||
} {
|
} {
|
||||||
if got := smallVariant(in); got != want {
|
if got := scaledVariant(tc.in, tc.width); got != tc.want {
|
||||||
t.Errorf("smallVariant(%q) = %q, want %q", in, got, want)
|
t.Errorf("scaledVariant(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLargePosterUsesThe500Variant(t *testing.T) {
|
||||||
|
var paths []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
paths = append(paths, r.URL.Path)
|
||||||
|
w.Write([]byte("IMG"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := NewClient(srv.URL, "secret")
|
||||||
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/1/poster.jpg"}}, true)
|
||||||
|
if _, err := fetch(context.Background(), true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fetch(context.Background(), false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(paths, " ") != "/MediaCover/1/poster-500.jpg /MediaCover/1/poster-250.jpg" {
|
||||||
|
t.Errorf("requests = %v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPosterLocalUsesAPIKey(t *testing.T) {
|
func TestPosterLocalUsesAPIKey(t *testing.T) {
|
||||||
var paths []string
|
var paths []string
|
||||||
var keys []string
|
var keys []string
|
||||||
@@ -39,7 +64,7 @@ func TestPosterLocalUsesAPIKey(t *testing.T) {
|
|||||||
if !ok || key == "" {
|
if !ok || key == "" {
|
||||||
t.Fatal("no poster")
|
t.Fatal("no poster")
|
||||||
}
|
}
|
||||||
data, err := fetch(context.Background())
|
data, err := fetch(context.Background(), false)
|
||||||
if err != nil || string(data) != "IMG" {
|
if err != nil || string(data) != "IMG" {
|
||||||
t.Fatalf("data=%q err=%v", data, err)
|
t.Fatalf("data=%q err=%v", data, err)
|
||||||
}
|
}
|
||||||
@@ -66,7 +91,7 @@ func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
|
|||||||
if !ok || key != srv.URL+"/p.jpg" {
|
if !ok || key != srv.URL+"/p.jpg" {
|
||||||
t.Fatalf("key=%q ok=%v", key, ok)
|
t.Fatalf("key=%q ok=%v", key, ok)
|
||||||
}
|
}
|
||||||
if _, err := fetch(context.Background()); err != nil {
|
if _, err := fetch(context.Background(), false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got != "" {
|
if got != "" {
|
||||||
@@ -94,3 +119,99 @@ func TestResolveURLBase(t *testing.T) {
|
|||||||
t.Errorf("no url base: %q", got)
|
t.Errorf("no url base: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
||||||
|
var remoteKey string
|
||||||
|
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remoteKey = r.Header.Get("X-Api-Key")
|
||||||
|
w.Write([]byte("REMOTE"))
|
||||||
|
}))
|
||||||
|
defer remote.Close()
|
||||||
|
instance := httptest.NewServer(http.NotFoundHandler()) // MediaCover has nothing cached
|
||||||
|
defer instance.Close()
|
||||||
|
|
||||||
|
c := NewClient(instance.URL, "secret")
|
||||||
|
_, fetch, ok := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no poster")
|
||||||
|
}
|
||||||
|
data, err := fetch(context.Background(), false)
|
||||||
|
if err != nil || string(data) != "REMOTE" {
|
||||||
|
t.Fatalf("data=%q err=%v", data, err)
|
||||||
|
}
|
||||||
|
if remoteKey != "" {
|
||||||
|
t.Errorf("the API key went to the provider: %q", remoteKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a remote address the local failure is reported.
|
||||||
|
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg"}}, true)
|
||||||
|
if _, err := fetch(context.Background(), false); err == nil {
|
||||||
|
t.Error("expected the local error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginWall answers like an instance that requires a login for /MediaCover:
|
||||||
|
// a 200 with an HTML page, unless the request carries the key as a query parameter.
|
||||||
|
func loginWall(acceptQuery bool, hits *[]string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
*hits = append(*hits, r.URL.RequestURI()+" key-header="+r.Header.Get("X-Api-Key"))
|
||||||
|
if acceptQuery && r.URL.Query().Get("apikey") == "secret" {
|
||||||
|
w.Header().Set("Content-Type", "image/jpeg")
|
||||||
|
w.Write([]byte("IMAGE"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte("<!DOCTYPE html><html><head><title>Login</title>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
|
||||||
|
var hits []string
|
||||||
|
srv := httptest.NewServer(loginWall(true, &hits))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient(srv.URL, "secret")
|
||||||
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
||||||
|
data, err := fetch(context.Background(), false)
|
||||||
|
if err != nil || string(data) != "IMAGE" {
|
||||||
|
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
|
||||||
|
}
|
||||||
|
// Small variant with the header (a page), then the same file with the key in the query.
|
||||||
|
if len(hits) != 2 || !strings.Contains(hits[0], "poster-250.jpg") || !strings.Contains(hits[0], "key-header=secret") ||
|
||||||
|
!strings.Contains(hits[1], "poster-250.jpg?apikey=secret") {
|
||||||
|
t.Errorf("requests = %v", hits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
||||||
|
var hits []string
|
||||||
|
instance := httptest.NewServer(loginWall(false, &hits))
|
||||||
|
defer instance.Close()
|
||||||
|
var remoteKey, remoteQuery string
|
||||||
|
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remoteKey, remoteQuery = r.Header.Get("X-Api-Key"), r.URL.RawQuery
|
||||||
|
w.Header().Set("Content-Type", "image/webp")
|
||||||
|
w.Write([]byte("WEBP"))
|
||||||
|
}))
|
||||||
|
defer remote.Close()
|
||||||
|
|
||||||
|
c := NewClient(instance.URL, "secret")
|
||||||
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
|
||||||
|
data, err := fetch(context.Background(), false)
|
||||||
|
if err != nil || string(data) != "WEBP" {
|
||||||
|
t.Fatalf("data=%q err=%v", data, err)
|
||||||
|
}
|
||||||
|
if remoteKey != "" || remoteQuery != "" {
|
||||||
|
t.Errorf("credentials reached the provider: header %q query %q", remoteKey, remoteQuery)
|
||||||
|
}
|
||||||
|
if len(hits) != 2 { // the small variant with the header and with the query; the original is skipped
|
||||||
|
t.Errorf("instance requests = %v", hits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a provider address the failure says what came back.
|
||||||
|
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
||||||
|
_, err = fetch(context.Background(), false)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
|
||||||
|
t.Errorf("error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,39 @@ func TestSetSeasonMonitoredUnknownSeason(t *testing.T) {
|
|||||||
t.Error("want error")
|
t.Error("want error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconfigureSwitchesInstanceAndKey(t *testing.T) {
|
||||||
|
var gotKey, gotHost string
|
||||||
|
mk := func(host string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey, gotHost = r.Header.Get("X-Api-Key"), host
|
||||||
|
io.WriteString(w, `{"version":"1"}`)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
a, b := mk("a"), mk("b")
|
||||||
|
defer a.Close()
|
||||||
|
defer b.Close()
|
||||||
|
|
||||||
|
c := NewClient(a.URL, "key-a")
|
||||||
|
var out struct{ Version string }
|
||||||
|
if err := c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out); err != nil || gotHost != "a" || gotKey != "key-a" {
|
||||||
|
t.Fatalf("before: host=%q key=%q err=%v", gotHost, gotKey, err)
|
||||||
|
}
|
||||||
|
c.Reconfigure(b.URL+"/", "key-b") // a trailing slash is trimmed like in NewClient
|
||||||
|
if err := c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out); err != nil || gotHost != "b" || gotKey != "key-b" {
|
||||||
|
t.Fatalf("after: host=%q key=%q err=%v", gotHost, gotKey, err)
|
||||||
|
}
|
||||||
|
// Used from many goroutines while being changed (run with -race to see it matter).
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
c.Reconfigure(a.URL, "k")
|
||||||
|
c.Reconfigure(b.URL, "k")
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
_ = c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out)
|
||||||
|
}
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,24 @@ type Service struct {
|
|||||||
APIKey string `json:"apikey"`
|
APIKey string `json:"apikey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured reports whether the service is set up at all (address or key
|
||||||
|
// given). Validate checks that a configured service has both.
|
||||||
|
func (s Service) Configured() bool { return s.URL != "" || s.APIKey != "" }
|
||||||
|
|
||||||
|
// AnyConfigured reports whether at least one service is set up.
|
||||||
|
func (c *Config) AnyConfigured() bool {
|
||||||
|
return c.Radarr.Configured() || c.Sonarr.Configured() || c.SABnzbd.Configured()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create writes an empty configuration to path, creating its directory
|
||||||
|
// (private, like the file). Every service is not set up yet.
|
||||||
|
func Create(path string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return fmt.Errorf("creating config directory: %w", err)
|
||||||
|
}
|
||||||
|
return Save(path, &Config{})
|
||||||
|
}
|
||||||
|
|
||||||
// Arr is a Radarr or Sonarr instance, with optional defaults for the add form.
|
// Arr is a Radarr or Sonarr instance, with optional defaults for the add form.
|
||||||
type Arr struct {
|
type Arr struct {
|
||||||
Service
|
Service
|
||||||
@@ -32,14 +50,30 @@ type UI struct {
|
|||||||
// Posters of titles that are not in the library yet are downloaded from
|
// Posters of titles that are not in the library yet are downloaded from
|
||||||
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
|
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
|
||||||
Covers *bool `json:"covers,omitempty"`
|
Covers *bool `json:"covers,omitempty"`
|
||||||
|
// NotifyDownloads shows a popup (and logs it) when a SABnzbd download
|
||||||
|
// finishes or fails, whichever view is open (default true).
|
||||||
|
NotifyDownloads *bool `json:"notify_downloads,omitempty"`
|
||||||
|
// Bell also rings the terminal bell for those notifications (default false).
|
||||||
|
Bell bool `json:"bell,omitempty"`
|
||||||
// CoverStyle selects the block characters posters are drawn with:
|
// CoverStyle selects the block characters posters are drawn with:
|
||||||
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant"
|
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "cells" (1
|
||||||
|
// pixel per cell, background colors only, for terminals that draw block
|
||||||
|
// glyphs unevenly such as macOS Terminal), "sextant"
|
||||||
// (2x3, sharper, needs a font with the Symbols for Legacy Computing
|
// (2x3, sharper, needs a font with the Symbols for Legacy Computing
|
||||||
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant
|
// block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
|
||||||
// on terminals known to draw them, quadrant elsewhere).
|
// the Kitty graphics protocol with Unicode placeholders: kitty and
|
||||||
|
// Ghostty) or "auto" (default: the Kitty protocol where the terminal is
|
||||||
|
// known to support it, else cells in macOS Terminal, sextant on terminals known to draw them or when
|
||||||
|
// fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty".
|
||||||
CoverStyle string `json:"cover_style,omitempty"`
|
CoverStyle string `json:"cover_style,omitempty"`
|
||||||
|
// Theme selects the colors: "dark", "light" or "auto" (default: light when
|
||||||
|
// the terminal reports a light background, else dark).
|
||||||
|
Theme string `json:"theme,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShouldNotifyDownloads reports whether finished downloads are announced.
|
||||||
|
func (u UI) ShouldNotifyDownloads() bool { return u.NotifyDownloads == nil || *u.NotifyDownloads }
|
||||||
|
|
||||||
// ShowCovers reports whether poster thumbnails are enabled.
|
// ShowCovers reports whether poster thumbnails are enabled.
|
||||||
func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers }
|
func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers }
|
||||||
|
|
||||||
@@ -92,14 +126,54 @@ func Load(path string) (*Config, []string, error) {
|
|||||||
// Parse decodes config JSON, applies environment overrides and defaults, and
|
// Parse decodes config JSON, applies environment overrides and defaults, and
|
||||||
// validates the result. Unknown fields are rejected to catch typos.
|
// validates the result. Unknown fields are rejected to catch typos.
|
||||||
func Parse(data []byte) (*Config, error) {
|
func Parse(data []byte) (*Config, error) {
|
||||||
|
raw, err := parseRaw(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return raw.Effective(os.Getenv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRaw decodes config JSON as it stands in the file: no environment
|
||||||
|
// overrides, no defaults, no validation.
|
||||||
|
func parseRaw(data []byte) (*Config, error) {
|
||||||
var cfg Config
|
var cfg Config
|
||||||
dec := json.NewDecoder(bytes.NewReader(data))
|
dec := json.NewDecoder(bytes.NewReader(data))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&cfg); err != nil {
|
if err := dec.Decode(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg.applyEnv(os.Getenv)
|
// LoadFile reads the config at path exactly as written: no environment
|
||||||
|
// overrides, no defaults, no validation. It is what an editor of the file must
|
||||||
|
// start from, so values that only came from the environment are not written
|
||||||
|
// back to disk.
|
||||||
|
func LoadFile(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
cfg, err := parseRaw(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective returns the configuration the program runs with: a copy with the
|
||||||
|
// environment overrides and defaults applied, validated.
|
||||||
|
func (c *Config) Effective(env func(string) string) (*Config, error) {
|
||||||
|
cfg := *c
|
||||||
|
if c.UI.Covers != nil {
|
||||||
|
v := *c.UI.Covers
|
||||||
|
cfg.UI.Covers = &v
|
||||||
|
}
|
||||||
|
if c.UI.NotifyDownloads != nil {
|
||||||
|
v := *c.UI.NotifyDownloads
|
||||||
|
cfg.UI.NotifyDownloads = &v
|
||||||
|
}
|
||||||
|
cfg.applyEnv(env)
|
||||||
cfg.applyDefaults()
|
cfg.applyDefaults()
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -107,6 +181,66 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envVars maps the settings that can be overridden by the environment to the
|
||||||
|
// variable that does it.
|
||||||
|
var envVars = map[string]string{
|
||||||
|
"radarr.url": "ARR_TUI_RADARR_URL",
|
||||||
|
"radarr.apikey": "ARR_TUI_RADARR_APIKEY",
|
||||||
|
"sonarr.url": "ARR_TUI_SONARR_URL",
|
||||||
|
"sonarr.apikey": "ARR_TUI_SONARR_APIKEY",
|
||||||
|
"sabnzbd.url": "ARR_TUI_SABNZBD_URL",
|
||||||
|
"sabnzbd.apikey": "ARR_TUI_SABNZBD_APIKEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overrides returns, for every setting that the environment currently
|
||||||
|
// overrides, the name of the variable. Keys look like "radarr.url".
|
||||||
|
func Overrides(env func(string) string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for key, name := range envVars {
|
||||||
|
if env(name) != "" {
|
||||||
|
out[key] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the config to path as indented JSON. The file is replaced
|
||||||
|
// atomically (a temporary file in the same directory, then a rename) and keeps
|
||||||
|
// the permissions of the existing file, or is private (0600) when new: it
|
||||||
|
// holds API keys.
|
||||||
|
func Save(path string, cfg *Config) error {
|
||||||
|
data, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
mode := os.FileMode(0o600)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("saving config: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||||
|
if err := tmp.Chmod(mode); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("saving config: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return fmt.Errorf("saving config: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("saving config: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||||
|
return fmt.Errorf("saving config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) applyEnv(get func(string) string) {
|
func (c *Config) applyEnv(get func(string) string) {
|
||||||
override := func(dst *string, key string) {
|
override := func(dst *string, key string) {
|
||||||
if v := get(key); v != "" {
|
if v := get(key); v != "" {
|
||||||
@@ -130,7 +264,9 @@ func (c *Config) applyDefaults() {
|
|||||||
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
|
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks that every service has a usable URL and API key.
|
// Validate checks that every service that is set up has a usable URL and API
|
||||||
|
// key. A service with neither is not set up, which is allowed: the program
|
||||||
|
// starts without it and says so, and the Config view can fill it in.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
var errs []error
|
var errs []error
|
||||||
for _, s := range []struct {
|
for _, s := range []struct {
|
||||||
@@ -141,6 +277,9 @@ func (c *Config) Validate() error {
|
|||||||
{"sonarr", c.Sonarr.Service},
|
{"sonarr", c.Sonarr.Service},
|
||||||
{"sabnzbd", c.SABnzbd},
|
{"sabnzbd", c.SABnzbd},
|
||||||
} {
|
} {
|
||||||
|
if !s.svc.Configured() {
|
||||||
|
continue // not set up (yet); the program says so where it is needed
|
||||||
|
}
|
||||||
if s.svc.APIKey == "" {
|
if s.svc.APIKey == "" {
|
||||||
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
|
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
|
||||||
}
|
}
|
||||||
@@ -153,9 +292,14 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch c.UI.CoverStyle {
|
switch c.UI.CoverStyle {
|
||||||
case "", "auto", "quadrant", "sextant":
|
case "", "auto", "quadrant", "sextant", "cells", "kitty":
|
||||||
default:
|
default:
|
||||||
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "quadrant" or "sextant"`, c.UI.CoverStyle))
|
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant", "quadrant" or "cells"`, c.UI.CoverStyle))
|
||||||
|
}
|
||||||
|
switch c.UI.Theme {
|
||||||
|
case "", "auto", "dark", "light":
|
||||||
|
default:
|
||||||
|
errs = append(errs, fmt.Errorf(`ui.theme: %q must be "auto", "dark" or "light"`, c.UI.Theme))
|
||||||
}
|
}
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func TestParseErrors(t *testing.T) {
|
|||||||
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
|
"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"},
|
"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"},
|
"invalid json": {`{`, "parsing config"},
|
||||||
|
"key only": {`{"radarr":{"apikey":"x"}}`, "radarr: url is required"},
|
||||||
}
|
}
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
@@ -100,12 +101,135 @@ func TestShowCovers(t *testing.T) {
|
|||||||
|
|
||||||
func TestCoverStyleValidation(t *testing.T) {
|
func TestCoverStyleValidation(t *testing.T) {
|
||||||
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
|
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
|
||||||
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} {
|
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"cells"`, `"kitty"`, `""`} {
|
||||||
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
||||||
t.Errorf("%s rejected: %v", ok, err)
|
t.Errorf("%s rejected: %v", ok, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err := Parse([]byte(base + `"braille"}}`)); err == nil {
|
if _, err := Parse([]byte(base + `"sixel"}}`)); err == nil {
|
||||||
t.Error("unknown style accepted")
|
t.Error("unknown style accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestThemeValidation(t *testing.T) {
|
||||||
|
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"theme":`
|
||||||
|
for _, ok := range []string{`"auto"`, `"dark"`, `"light"`, `""`} {
|
||||||
|
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
|
||||||
|
t.Errorf("%s rejected: %v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Parse([]byte(base + `"solarized"}}`)); err == nil {
|
||||||
|
t.Error("unknown theme accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveRoundTripAndPermissions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.json")
|
||||||
|
covers := false
|
||||||
|
cfg := &Config{
|
||||||
|
Radarr: Arr{Service: Service{URL: "http://r:7878", APIKey: "rk"}, DefaultQualityProfile: "HD-1080p"},
|
||||||
|
Sonarr: Arr{Service: Service{URL: "http://s:8989", APIKey: "sk"}},
|
||||||
|
SABnzbd: Service{URL: "http://a:8080", APIKey: "ak"},
|
||||||
|
UI: UI{RefreshSeconds: 5, Covers: &covers, CoverStyle: "sextant", Bell: true},
|
||||||
|
}
|
||||||
|
if err := Save(path, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("new file mode = %v, want 0600 (it holds API keys): %v", info.Mode().Perm(), err)
|
||||||
|
}
|
||||||
|
got, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Radarr.DefaultQualityProfile != "HD-1080p" || got.UI.RefreshSeconds != 5 || got.UI.Covers == nil || *got.UI.Covers || got.UI.CoverStyle != "sextant" || !got.UI.Bell {
|
||||||
|
t.Errorf("round trip lost values: %+v", got)
|
||||||
|
}
|
||||||
|
// Unset optional settings stay out of the file.
|
||||||
|
if data, _ := os.ReadFile(path); strings.Contains(string(data), "notify_downloads") || strings.Contains(string(data), "default_root_folder") {
|
||||||
|
t.Errorf("unset settings written:\n%s", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An existing file keeps its permissions and no temporary file is left behind.
|
||||||
|
if err := os.Chmod(path, 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Save(path, cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
|
||||||
|
t.Errorf("existing mode changed to %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Errorf("leftover files in the config directory: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadFileIgnoresEnvironmentAndDefaults(t *testing.T) {
|
||||||
|
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
os.WriteFile(path, []byte(`{"radarr":{"url":"http://r/","apikey":"from-file"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}}`), 0o600)
|
||||||
|
|
||||||
|
raw, err := LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if raw.Radarr.APIKey != "from-file" || raw.Radarr.URL != "http://r/" || raw.UI.RefreshSeconds != 0 {
|
||||||
|
t.Errorf("raw = %+v: environment, trailing slash trimming and defaults must not be applied", raw)
|
||||||
|
}
|
||||||
|
eff, err := raw.Effective(os.Getenv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if eff.Radarr.APIKey != "from-env" || eff.Radarr.URL != "http://r" || eff.UI.RefreshSeconds != 3 {
|
||||||
|
t.Errorf("effective = %+v", eff)
|
||||||
|
}
|
||||||
|
if raw.Radarr.APIKey != "from-file" {
|
||||||
|
t.Error("Effective must not modify the raw config")
|
||||||
|
}
|
||||||
|
if got := Overrides(os.Getenv); len(got) != 1 || got["radarr.apikey"] != "ARR_TUI_RADARR_APIKEY" {
|
||||||
|
t.Errorf("overrides = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveValidates(t *testing.T) {
|
||||||
|
raw := &Config{Radarr: Arr{Service: Service{URL: "ftp://x", APIKey: "k"}}}
|
||||||
|
if _, err := raw.Effective(func(string) string { return "" }); err == nil {
|
||||||
|
t.Error("an invalid config must not become effective")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicesMayBeLeftOut(t *testing.T) {
|
||||||
|
cfg, err := Parse([]byte(`{"radarr":{"url":"http://r","apikey":"k"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a config with only Radarr must load: %v", err)
|
||||||
|
}
|
||||||
|
if !cfg.Radarr.Configured() || cfg.Sonarr.Configured() || cfg.SABnzbd.Configured() || !cfg.AnyConfigured() {
|
||||||
|
t.Errorf("configured: radarr %v sonarr %v sab %v", cfg.Radarr.Configured(), cfg.Sonarr.Configured(), cfg.SABnzbd.Configured())
|
||||||
|
}
|
||||||
|
empty, err := Parse([]byte(`{}`))
|
||||||
|
if err != nil || empty.AnyConfigured() {
|
||||||
|
t.Errorf("an empty config must load with nothing set up: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "new", "dir", "config.json")
|
||||||
|
if err := Create(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("file: %v %v", info, err)
|
||||||
|
}
|
||||||
|
if dir, _ := os.Stat(filepath.Dir(path)); dir.Mode().Perm() != 0o700 {
|
||||||
|
t.Errorf("directory mode %v", dir.Mode().Perm())
|
||||||
|
}
|
||||||
|
cfg, warnings, err := Load(path)
|
||||||
|
if err != nil || len(warnings) > 0 || cfg.AnyConfigured() {
|
||||||
|
t.Errorf("the new file must load cleanly: %v %v", warnings, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
)
|
)
|
||||||
@@ -56,6 +57,9 @@ type Movie struct {
|
|||||||
Monitored bool `json:"monitored"`
|
Monitored bool `json:"monitored"`
|
||||||
HasFile bool `json:"hasFile"`
|
HasFile bool `json:"hasFile"`
|
||||||
QualityProfileID int `json:"qualityProfileId"`
|
QualityProfileID int `json:"qualityProfileId"`
|
||||||
|
InCinemas string `json:"inCinemas,omitempty"` // ISO timestamps, empty if unknown
|
||||||
|
PhysicalRelease string `json:"physicalRelease,omitempty"`
|
||||||
|
DigitalRelease string `json:"digitalRelease,omitempty"`
|
||||||
SizeOnDisk int64 `json:"sizeOnDisk"`
|
SizeOnDisk int64 `json:"sizeOnDisk"`
|
||||||
IsAvailable bool `json:"isAvailable"`
|
IsAvailable bool `json:"isAvailable"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
@@ -170,3 +174,28 @@ func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool)
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteMovie removes a movie from the library. With deleteFiles the movie's
|
||||||
|
// files are deleted from disk too; addExclusion keeps it from coming back
|
||||||
|
// through import lists.
|
||||||
|
func (c *Client) DeleteMovie(ctx context.Context, id int, deleteFiles, addExclusion bool) error {
|
||||||
|
q := url.Values{
|
||||||
|
"deleteFiles": {strconv.FormatBool(deleteFiles)},
|
||||||
|
"addImportExclusion": {strconv.FormatBool(addExclusion)},
|
||||||
|
}
|
||||||
|
return c.Do(ctx, http.MethodDelete, "movie/"+strconv.Itoa(id), q, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar lists the library movies with a release date (cinemas, digital or
|
||||||
|
// physical) between start and end, both inclusive. Unmonitored movies are
|
||||||
|
// only included when asked for.
|
||||||
|
func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Movie, error) {
|
||||||
|
q := url.Values{
|
||||||
|
"start": {start.Format("2006-01-02")},
|
||||||
|
"end": {end.Format("2006-01-02")},
|
||||||
|
"unmonitored": {strconv.FormatBool(unmonitored)},
|
||||||
|
}
|
||||||
|
var movies []Movie
|
||||||
|
err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &movies)
|
||||||
|
return movies, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
)
|
)
|
||||||
@@ -161,3 +162,36 @@ func TestReleases(t *testing.T) {
|
|||||||
t.Fatalf("releases = %+v, %v", rels, err)
|
t.Fatalf("releases = %+v, %v", rels, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteMovie(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/movie/12" {
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("deleteFiles") != "true" || q.Get("addImportExclusion") != "false" {
|
||||||
|
t.Errorf("query = %v", q)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := c.DeleteMovie(context.Background(), 12, true, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalendar(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if r.URL.Path != "/api/v3/calendar" || q.Get("start") != "2026-10-20" || q.Get("end") != "2026-11-02" || q.Get("unmonitored") != "true" {
|
||||||
|
t.Errorf("unexpected request %s", r.URL)
|
||||||
|
}
|
||||||
|
w.Write([]byte(`[{"id":4,"title":"Dune","year":2021,"monitored":true,"hasFile":false,"inCinemas":"2026-10-22T00:00:00Z","digitalRelease":"2026-11-01T00:00:00Z"}]`))
|
||||||
|
})
|
||||||
|
start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
|
||||||
|
movies, err := c.Calendar(context.Background(), start, start.AddDate(0, 0, 13), true)
|
||||||
|
if err != nil || len(movies) != 1 {
|
||||||
|
t.Fatalf("movies=%v err=%v", movies, err)
|
||||||
|
}
|
||||||
|
if m := movies[0]; m.InCinemas != "2026-10-22T00:00:00Z" || m.DigitalRelease == "" || m.PhysicalRelease != "" {
|
||||||
|
t.Errorf("dates not decoded: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,16 +11,26 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client talks to a SABnzbd instance.
|
// Client talks to a SABnzbd instance.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconfigure points the client at another instance or key.
|
||||||
|
func (c *Client) Reconfigure(baseURL, apiKey string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
c.apiKey = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
// NewClient returns a client for the instance at baseURL.
|
// NewClient returns a client for the instance at baseURL.
|
||||||
func NewClient(baseURL, apiKey string) *Client {
|
func NewClient(baseURL, apiKey string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
@@ -30,6 +40,10 @@ func NewClient(baseURL, apiKey string) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrNotConfigured is returned without a request while the client has no
|
||||||
|
// address: the service is not set up.
|
||||||
|
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
||||||
|
|
||||||
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
|
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
|
||||||
type Number float64
|
type Number float64
|
||||||
|
|
||||||
@@ -159,9 +173,15 @@ func boolParam(b bool) string {
|
|||||||
// {"status": false, "error": "..."}, which is turned into an error here.
|
// {"status": false, "error": "..."}, which is turned into an error here.
|
||||||
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
||||||
params.Set("output", "json")
|
params.Set("output", "json")
|
||||||
params.Set("apikey", c.apiKey)
|
c.mu.RLock()
|
||||||
|
baseURL, apiKey := c.baseURL, c.apiKey
|
||||||
|
c.mu.RUnlock()
|
||||||
|
if baseURL == "" {
|
||||||
|
return ErrNotConfigured
|
||||||
|
}
|
||||||
|
params.Set("apikey", apiKey)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api?"+params.Encode(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api?"+params.Encode(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return stripURL(err)
|
return stripURL(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,3 +122,22 @@ func TestTransportErrorHidesAPIKey(t *testing.T) {
|
|||||||
t.Errorf("error leaks API key: %v", err)
|
t.Errorf("error leaks API key: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReconfigure(t *testing.T) {
|
||||||
|
var gotKey string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey = r.URL.Query().Get("apikey")
|
||||||
|
w.Write([]byte(`{"version":"5.1"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
c := NewClient("http://127.0.0.1:1", "old") // unreachable until reconfigured
|
||||||
|
if _, err := c.Version(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected a connection error")
|
||||||
|
}
|
||||||
|
c.Reconfigure(srv.URL+"/", "new")
|
||||||
|
v, err := c.Version(context.Background())
|
||||||
|
if err != nil || v != "5.1" || gotKey != "new" {
|
||||||
|
t.Errorf("version=%q key=%q err=%v", v, gotKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
)
|
)
|
||||||
@@ -216,8 +217,11 @@ type Episode struct {
|
|||||||
EpisodeNumber int `json:"episodeNumber"`
|
EpisodeNumber int `json:"episodeNumber"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
AirDate string `json:"airDate"`
|
AirDate string `json:"airDate"`
|
||||||
|
AirDateUTC string `json:"airDateUtc"`
|
||||||
|
Overview string `json:"overview,omitempty"`
|
||||||
HasFile bool `json:"hasFile"`
|
HasFile bool `json:"hasFile"`
|
||||||
Monitored bool `json:"monitored"`
|
Monitored bool `json:"monitored"`
|
||||||
|
Series *Series `json:"series,omitempty"` // only with includeSeries
|
||||||
}
|
}
|
||||||
|
|
||||||
// Episodes lists all episodes of a library series.
|
// Episodes lists all episodes of a library series.
|
||||||
@@ -246,3 +250,28 @@ func (c *Client) EpisodeFiles(ctx context.Context, seriesID int) ([]arr.MediaFil
|
|||||||
err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files)
|
err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files)
|
||||||
return files, err
|
return files, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteSeries removes a series from the library. With deleteFiles all its
|
||||||
|
// episode files are deleted from disk too; addExclusion keeps it from coming
|
||||||
|
// back through import lists.
|
||||||
|
func (c *Client) DeleteSeries(ctx context.Context, id int, deleteFiles, addExclusion bool) error {
|
||||||
|
q := url.Values{
|
||||||
|
"deleteFiles": {strconv.FormatBool(deleteFiles)},
|
||||||
|
"addImportListExclusion": {strconv.FormatBool(addExclusion)},
|
||||||
|
}
|
||||||
|
return c.Do(ctx, http.MethodDelete, "series/"+strconv.Itoa(id), q, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar lists the episodes airing between start and end, both inclusive,
|
||||||
|
// with their series. Unmonitored episodes are only included when asked for.
|
||||||
|
func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Episode, error) {
|
||||||
|
q := url.Values{
|
||||||
|
"start": {start.Format("2006-01-02")},
|
||||||
|
"end": {end.Format("2006-01-02")},
|
||||||
|
"unmonitored": {strconv.FormatBool(unmonitored)},
|
||||||
|
"includeSeries": {"true"},
|
||||||
|
}
|
||||||
|
var episodes []Episode
|
||||||
|
err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &episodes)
|
||||||
|
return episodes, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const lookupJSON = `[
|
const lookupJSON = `[
|
||||||
@@ -164,3 +165,36 @@ func TestEpisodeFiles(t *testing.T) {
|
|||||||
t.Errorf("attrs = %+v", a)
|
t.Errorf("attrs = %+v", a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteSeries(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/series/3" {
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL)
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("deleteFiles") != "false" || q.Get("addImportListExclusion") != "true" {
|
||||||
|
t.Errorf("query = %v", q)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := c.DeleteSeries(context.Background(), 3, false, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalendar(t *testing.T) {
|
||||||
|
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
if r.URL.Path != "/api/v3/calendar" || q.Get("includeSeries") != "true" || q.Get("unmonitored") != "false" || q.Get("start") != "2026-10-20" {
|
||||||
|
t.Errorf("unexpected request %s", r.URL)
|
||||||
|
}
|
||||||
|
w.Write([]byte(`[{"id":9,"seriesId":2,"seasonNumber":2,"episodeNumber":5,"title":"Welcome","airDate":"2026-10-21","airDateUtc":"2026-10-22T01:00:00Z","overview":"Pilot.","monitored":true,"series":{"id":2,"title":"Andor","images":[{"coverType":"poster","url":"/p.jpg"}]}}]`))
|
||||||
|
})
|
||||||
|
start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
|
||||||
|
eps, err := c.Calendar(context.Background(), start, start.AddDate(0, 0, 13), false)
|
||||||
|
if err != nil || len(eps) != 1 {
|
||||||
|
t.Fatalf("eps=%v err=%v", eps, err)
|
||||||
|
}
|
||||||
|
if e := eps[0]; e.Series == nil || e.Series.Title != "Andor" || e.AirDateUTC != "2026-10-22T01:00:00Z" || e.Overview != "Pilot." || len(e.Series.Images) != 1 {
|
||||||
|
t.Errorf("episode not decoded: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||||
@@ -30,6 +32,8 @@ type view struct {
|
|||||||
// App is the top-level arr-tui application.
|
// App is the top-level arr-tui application.
|
||||||
type App struct {
|
type App struct {
|
||||||
app *tview.Application
|
app *tview.Application
|
||||||
|
theme theme // the colors in use
|
||||||
|
themed *themedScreen // draws with them; nil until Run
|
||||||
tabs *tview.TextView
|
tabs *tview.TextView
|
||||||
pages *tview.Pages
|
pages *tview.Pages
|
||||||
status *tview.TextView
|
status *tview.TextView
|
||||||
@@ -41,11 +45,30 @@ type App struct {
|
|||||||
overlays []overlay // dialogs and full-screen views stacked above the pages
|
overlays []overlay // dialogs and full-screen views stacked above the pages
|
||||||
|
|
||||||
services *tview.TextView
|
services *tview.TextView
|
||||||
|
topBar *tview.Flex // tab bar and connection indicators
|
||||||
|
narrow bool // terminal too narrow for full service names
|
||||||
state map[string]serviceState // keyed by service name
|
state map[string]serviceState // keyed by service name
|
||||||
probes []probe
|
probes []probe
|
||||||
|
|
||||||
|
screen tcell.Screen // remembered at the first draw, for the bell
|
||||||
|
cfg *config.Config // the settings in use (file, environment and defaults combined)
|
||||||
|
cfgPath string // the config file, empty if unknown
|
||||||
|
cfgFile *config.Config // the file's contents as written, for saving
|
||||||
|
radarr *radarr.Client
|
||||||
|
sonarr *sonarr.Client
|
||||||
|
sab *sabnzbd.Client
|
||||||
|
downloads *downloadsView
|
||||||
|
settings *settingsView
|
||||||
|
panes []*detailPane // every detail pane, to restyle posters when settings change
|
||||||
|
reconnect []func() // run after the service addresses or keys changed
|
||||||
|
watchStop context.CancelFunc
|
||||||
|
notifyDone bool // announce finished downloads
|
||||||
|
bell bool
|
||||||
covers bool // poster thumbnails enabled
|
covers bool // poster thumbnails enabled
|
||||||
coverStyle coverStyle
|
coverStyle coverStyle
|
||||||
|
gfx *kittyGraphics // real-image posters, nil when not used
|
||||||
|
lastWindow [3]int // columns, rows and cell shape at the last draw
|
||||||
|
cellAspect float64 // height of a terminal cell divided by its width
|
||||||
coverCache *coverCache
|
coverCache *coverCache
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,12 +98,16 @@ type overlay struct {
|
|||||||
page string
|
page string
|
||||||
focus tview.Primitive
|
focus tview.Primitive
|
||||||
hints string
|
hints string
|
||||||
full bool // full-screen view (can be left with F1-F5), as opposed to a dialog
|
full bool // full-screen view (can be left with F1-F7), as opposed to a dialog
|
||||||
close func() // optional cleanup when the overlay is removed
|
close func() // optional cleanup when the overlay is removed
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the application shell: a tab bar, one page per view and a status bar.
|
// New builds the application shell: a tab bar, one page per view and a status bar.
|
||||||
func New(cfg *config.Config) *App {
|
func New(cfg *config.Config) *App {
|
||||||
|
// Ask the terminal before the screen takes it over, and set the colors
|
||||||
|
// before any widget copies them.
|
||||||
|
th, themeNote := chooseTheme(cfg.UI.Theme, terminalAnswers())
|
||||||
|
useTokens()
|
||||||
a := &App{
|
a := &App{
|
||||||
app: tview.NewApplication(),
|
app: tview.NewApplication(),
|
||||||
tabs: tview.NewTextView().SetDynamicColors(true),
|
tabs: tview.NewTextView().SetDynamicColors(true),
|
||||||
@@ -90,15 +117,37 @@ func New(cfg *config.Config) *App {
|
|||||||
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
|
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
|
||||||
state: map[string]serviceState{},
|
state: map[string]serviceState{},
|
||||||
|
|
||||||
|
notifyDone: cfg.UI.ShouldNotifyDownloads(),
|
||||||
|
bell: cfg.UI.Bell,
|
||||||
covers: cfg.UI.ShowCovers(),
|
covers: cfg.UI.ShowCovers(),
|
||||||
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv),
|
cellAspect: 2,
|
||||||
coverCache: newCoverCache(),
|
coverCache: newCoverCache(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
|
||||||
|
coverNote := "Posters: " + blocks.name + " block characters"
|
||||||
|
if graphics {
|
||||||
|
coverNote = "Posters: real images (the terminal confirmed the Kitty graphics protocol)"
|
||||||
|
} else if cfg.UI.CoverStyle == "kitty" || kittyTerminal(os.Getenv) {
|
||||||
|
coverNote += " (the terminal did not confirm the Kitty graphics protocol)"
|
||||||
|
}
|
||||||
|
a.coverStyle = blocks
|
||||||
|
if graphics {
|
||||||
|
a.gfx = a.newGraphics()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.cfg = cfg
|
||||||
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
||||||
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
||||||
|
a.radarr, a.sonarr = radarrClient, sonarrClient
|
||||||
a.logs = newLogView(a)
|
a.logs = newLogView(a)
|
||||||
|
if a.covers {
|
||||||
|
a.logs.add(levelInfo, coverNote)
|
||||||
|
}
|
||||||
|
a.theme = th
|
||||||
|
a.logs.add(levelInfo, themeNote)
|
||||||
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
|
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
|
||||||
|
a.sab = sabClient
|
||||||
a.probes = []probe{
|
a.probes = []probe{
|
||||||
{"Radarr", func(ctx context.Context) (string, error) {
|
{"Radarr", func(ctx context.Context) (string, error) {
|
||||||
st, err := radarrClient.SystemStatus(ctx)
|
st, err := radarrClient.SystemStatus(ctx)
|
||||||
@@ -110,6 +159,9 @@ func New(cfg *config.Config) *App {
|
|||||||
}},
|
}},
|
||||||
{"SABnzbd", sabClient.Version},
|
{"SABnzbd", sabClient.Version},
|
||||||
}
|
}
|
||||||
|
settings := newSettingsView(a)
|
||||||
|
a.settings = settings
|
||||||
|
calendar := newCalendarView(a, radarrClient, sonarrClient)
|
||||||
library := newLibraryView(a, radarrClient, sonarrClient)
|
library := newLibraryView(a, radarrClient, sonarrClient)
|
||||||
downloads := newDownloadsView(a, sabClient,
|
downloads := newDownloadsView(a, sabClient,
|
||||||
[]arrSource{
|
[]arrSource{
|
||||||
@@ -117,31 +169,148 @@ func New(cfg *config.Config) *App {
|
|||||||
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
||||||
},
|
},
|
||||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||||
|
a.downloads = downloads
|
||||||
a.views = []view{
|
a.views = []view{
|
||||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
|
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
|
||||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
|
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
|
||||||
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
||||||
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
||||||
{key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
|
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
|
||||||
|
{key: '6', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints},
|
||||||
|
{key: '7', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
|
||||||
}
|
}
|
||||||
for i, v := range a.views {
|
for i, v := range a.views {
|
||||||
a.pages.AddPage(v.name, v.body, true, i == 0)
|
a.pages.AddPage(v.name, v.body, true, i == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
a.topBar = tview.NewFlex().
|
||||||
AddItem(tview.NewFlex().
|
|
||||||
AddItem(a.tabs, 0, 1, false).
|
AddItem(a.tabs, 0, 1, false).
|
||||||
AddItem(a.services, 25, 0, false), 1, 0, false).
|
AddItem(a.services, servicesWidth(false), 0, false)
|
||||||
|
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(a.topBar, 1, 0, false).
|
||||||
AddItem(a.pages, 0, 1, true).
|
AddItem(a.pages, 0, 1, true).
|
||||||
AddItem(a.status, 1, 0, false)
|
AddItem(a.status, 1, 0, false)
|
||||||
|
|
||||||
|
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
|
||||||
|
// A focused text field shows the cursor again while it is drawn. Hiding it
|
||||||
|
// first covers focus moves that bypass tview (arrow keys in forms), which
|
||||||
|
// would leave the cursor behind in the field that was left.
|
||||||
|
s.HideCursor()
|
||||||
|
a.screen = s
|
||||||
|
a.cellAspect = cellAspectOf(s)
|
||||||
|
a.noteWindow(s)
|
||||||
|
return false
|
||||||
|
})
|
||||||
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
|
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
|
||||||
|
syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) }
|
||||||
a.app.SetInputCapture(a.handleKey)
|
a.app.SetInputCapture(a.handleKey)
|
||||||
a.switchTo(0)
|
a.switchTo(0)
|
||||||
|
if !cfg.AnyConfigured() {
|
||||||
|
// First start (or nothing set up): go where that is done.
|
||||||
|
a.switchTo(a.viewIndex("Config"))
|
||||||
|
a.Notify(levelInfo, "Welcome! Enter the addresses and API keys of your services, then Save")
|
||||||
|
}
|
||||||
a.renderServices()
|
a.renderServices()
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newGraphics creates the real-image drawer. With ARR_TUI_DEBUG_GFX set it also
|
||||||
|
// writes what it does (window size, cell shape, every upload) to the Log, which
|
||||||
|
// helps to find out why a poster is drawn wrongly in some terminal.
|
||||||
|
func (a *App) newGraphics() *kittyGraphics {
|
||||||
|
k := newKittyGraphics(a.writeTerminal)
|
||||||
|
switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); {
|
||||||
|
case v == "":
|
||||||
|
case strings.Contains(v, "/"):
|
||||||
|
// A path: also append to that file, so the lines can be read without
|
||||||
|
// leaving the view that shows the problem.
|
||||||
|
f, err := os.OpenFile(v, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
a.logs.add(levelWarn, "ARR_TUI_DEBUG_GFX: "+err.Error())
|
||||||
|
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
k.debug = func(msg string) {
|
||||||
|
a.logs.add(levelInfo, msg)
|
||||||
|
fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), msg)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteWindow notices when the terminal's size or cell size changed. The
|
||||||
|
// real-image posters are then uploaded and placed again from scratch instead of
|
||||||
|
// relying on the terminal to rescale what is there.
|
||||||
|
func (a *App) noteWindow(s tcell.Screen) {
|
||||||
|
w, h := s.Size()
|
||||||
|
win := [3]int{w, h, int(a.cellAspect * 1000)}
|
||||||
|
if win == a.lastWindow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
first := a.lastWindow == [3]int{}
|
||||||
|
a.lastWindow = win
|
||||||
|
if !first && a.gfx != nil {
|
||||||
|
a.gfx.deleteAll()
|
||||||
|
a.gfx.hold() // blocks while the size is changing, real images when it has settled
|
||||||
|
time.AfterFunc(settleDelay+30*time.Millisecond, func() { a.post(func() {}) })
|
||||||
|
}
|
||||||
|
if a.gfx != nil && a.gfx.debug != nil {
|
||||||
|
a.gfx.debug(fmt.Sprintf("window: %d×%d cells, cell shape %.3f", w, h, a.cellAspect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTerminal sends raw bytes (graphics commands) to the terminal through the
|
||||||
|
// screen's own connection. UI goroutine only, so it stays in step with drawing.
|
||||||
|
func (a *App) writeTerminal(b []byte) error {
|
||||||
|
if a.screen == nil {
|
||||||
|
return fmt.Errorf("no screen yet")
|
||||||
|
}
|
||||||
|
tty, ok := a.screen.Tty()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("the screen has no terminal")
|
||||||
|
}
|
||||||
|
_, err := tty.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellAspectOf measures the height of a terminal cell divided by its width in
|
||||||
|
// pixels; 2 when the terminal does not report pixel sizes.
|
||||||
|
func cellAspectOf(s tcell.Screen) float64 {
|
||||||
|
if tty, ok := s.Tty(); ok {
|
||||||
|
if ws, err := tty.WindowSize(); err == nil {
|
||||||
|
return cellAspectFromWindow(ws.Width, ws.Height, ws.PixelWidth, ws.PixelHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// cellAspectFromWindow derives the cell shape from the window size in cells
|
||||||
|
// and pixels. The pixel size usually includes some padding around the cell
|
||||||
|
// grid, which would skew a plain division (more with a small grid, and it
|
||||||
|
// changes while the window is resized); cell sizes are whole pixels, so the
|
||||||
|
// division is rounded down, which drops that padding.
|
||||||
|
func cellAspectFromWindow(cols, rows, pixelW, pixelH int) float64 {
|
||||||
|
if cols <= 0 || rows <= 0 || pixelW <= 0 || pixelH <= 0 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
cw, ch := pixelW/cols, pixelH/rows
|
||||||
|
if cw <= 0 || ch <= 0 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return float64(ch) / float64(cw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverMaxHeight is how tall the stored poster copies are: real images keep
|
||||||
|
// more detail than block characters need.
|
||||||
|
func (a *App) coverMaxHeight() int {
|
||||||
|
if a.gfx != nil {
|
||||||
|
return graphicsCoverHeight
|
||||||
|
}
|
||||||
|
return storedCoverHeight
|
||||||
|
}
|
||||||
|
|
||||||
// SetService records the outcome of a request to a service and updates the
|
// SetService records the outcome of a request to a service and updates the
|
||||||
// connection indicators. UI goroutine only. A nil err means reachable.
|
// connection indicators. UI goroutine only. A nil err means reachable.
|
||||||
func (a *App) SetService(name, version string, err error) {
|
func (a *App) SetService(name, version string, err error) {
|
||||||
@@ -152,7 +321,11 @@ func (a *App) SetService(name, version string, err error) {
|
|||||||
a.state[name] = serviceState{known: true, version: version, err: err}
|
a.state[name] = serviceState{known: true, version: version, err: err}
|
||||||
// Log connection changes only, so background polling does not flood the log.
|
// Log connection changes only, so background polling does not flood the log.
|
||||||
switch {
|
switch {
|
||||||
case err != nil && (!prev.known || prev.err == nil):
|
case notConfigured(err):
|
||||||
|
if !prev.known || !notConfigured(prev.err) {
|
||||||
|
a.logs.add(levelInfo, name+" is not configured; set it up in the Config view (F6)")
|
||||||
|
}
|
||||||
|
case err != nil && (!prev.known || prev.err == nil || notConfigured(prev.err)):
|
||||||
a.logs.add(levelError, name+" unreachable: "+err.Error())
|
a.logs.add(levelError, name+" unreachable: "+err.Error())
|
||||||
case err == nil && (!prev.known || prev.err != nil):
|
case err == nil && (!prev.known || prev.err != nil):
|
||||||
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
|
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
|
||||||
@@ -160,13 +333,39 @@ func (a *App) SetService(name, version string, err error) {
|
|||||||
a.renderServices()
|
a.renderServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// servicesWidth is the width of the connection indicators.
|
||||||
|
func servicesWidth(narrow bool) int {
|
||||||
|
if narrow {
|
||||||
|
return 14
|
||||||
|
}
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNarrow switches the indicators between full names and initials; called
|
||||||
|
// from the draw handler when the terminal width crosses the threshold.
|
||||||
|
func (a *App) setNarrow(narrow bool) {
|
||||||
|
if narrow == a.narrow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.narrow = narrow
|
||||||
|
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
|
||||||
|
a.renderServices()
|
||||||
|
a.renderTabs()
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) renderServices() {
|
func (a *App) renderServices() {
|
||||||
var s string
|
var s string
|
||||||
for _, n := range serviceNames {
|
for _, n := range serviceNames {
|
||||||
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
|
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
|
||||||
|
if a.narrow {
|
||||||
|
short = n[:1]
|
||||||
|
if n == "SABnzbd" {
|
||||||
|
short = "SAB"
|
||||||
|
}
|
||||||
|
}
|
||||||
st := a.state[n]
|
st := a.state[n]
|
||||||
switch {
|
switch {
|
||||||
case !st.known:
|
case !st.known, notConfigured(st.err):
|
||||||
s += "[gray]○ " + short + "[-] "
|
s += "[gray]○ " + short + "[-] "
|
||||||
case st.err != nil:
|
case st.err != nil:
|
||||||
s += "[red]● " + short + "[-] "
|
s += "[red]● " + short + "[-] "
|
||||||
@@ -185,6 +384,8 @@ func (a *App) serviceSummary() string {
|
|||||||
switch {
|
switch {
|
||||||
case !st.known:
|
case !st.known:
|
||||||
fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n)
|
fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n)
|
||||||
|
case notConfigured(st.err):
|
||||||
|
fmt.Fprintf(&b, "[gray]○ %s: not configured (Config view, F6)[-]\n", n)
|
||||||
case st.err != nil:
|
case st.err != nil:
|
||||||
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
|
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
|
||||||
case st.version != "":
|
case st.version != "":
|
||||||
@@ -196,6 +397,21 @@ func (a *App) serviceSummary() string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// viewIndex returns the position of the view with the given name.
|
||||||
|
func (a *App) viewIndex(name string) int {
|
||||||
|
for i, v := range a.views {
|
||||||
|
if v.name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// notConfigured reports whether err says a service is not set up.
|
||||||
|
func notConfigured(err error) bool {
|
||||||
|
return errors.Is(err, arr.ErrNotConfigured) || errors.Is(err, sabnzbd.ErrNotConfigured)
|
||||||
|
}
|
||||||
|
|
||||||
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
|
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
|
||||||
func (a *App) post(f func()) {
|
func (a *App) post(f func()) {
|
||||||
a.app.QueueUpdateDraw(f)
|
a.app.QueueUpdateDraw(f)
|
||||||
@@ -205,23 +421,59 @@ func (a *App) post(f func()) {
|
|||||||
func (a *App) Run() error {
|
func (a *App) Run() error {
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
defer close(stop)
|
defer close(stop)
|
||||||
|
defer func() {
|
||||||
|
if a.gfx != nil {
|
||||||
|
a.gfx.deleteAll()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// The screen draws through the theme (see themedScreen).
|
||||||
|
s, err := tcell.NewScreen()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.themed = newThemedScreen(s, a.theme)
|
||||||
|
a.app.SetScreen(a.themed) // initializes it
|
||||||
|
if a.themed.initErr != nil {
|
||||||
|
return a.themed.initErr
|
||||||
|
}
|
||||||
|
a.themed.DisableMouse()
|
||||||
|
a.themed.DisablePaste()
|
||||||
|
|
||||||
go a.watchServices(stop)
|
go a.watchServices(stop)
|
||||||
|
a.setNotifyDownloads(a.notifyDone)
|
||||||
|
defer a.setNotifyDownloads(false)
|
||||||
return a.app.Run()
|
return a.app.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setTheme switches the colors while the program runs.
|
||||||
|
func (a *App) setTheme(t theme, note string) {
|
||||||
|
a.theme = t
|
||||||
|
if a.themed != nil {
|
||||||
|
a.themed.setTheme(t)
|
||||||
|
}
|
||||||
|
a.logs.add(levelInfo, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNotifyDownloads starts or stops the background watch for finished downloads.
|
||||||
|
func (a *App) setNotifyDownloads(on bool) {
|
||||||
|
a.notifyDone = on
|
||||||
|
if a.watchStop != nil {
|
||||||
|
a.watchStop()
|
||||||
|
a.watchStop = nil
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
a.watchStop = cancel
|
||||||
|
go a.watchDownloads(ctx, a.sab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// watchServices checks all services now and then every heartbeat until stop is closed.
|
// watchServices checks all services now and then every heartbeat until stop is closed.
|
||||||
func (a *App) watchServices(stop <-chan struct{}) {
|
func (a *App) watchServices(stop <-chan struct{}) {
|
||||||
tick := time.NewTicker(heartbeat)
|
tick := time.NewTicker(heartbeat)
|
||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
for {
|
for {
|
||||||
for _, p := range a.probes {
|
a.probeAll()
|
||||||
go func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
version, err := p.check(ctx)
|
|
||||||
a.post(func() { a.SetService(p.name, version, err) })
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
case <-stop:
|
case <-stop:
|
||||||
@@ -230,15 +482,30 @@ func (a *App) watchServices(stop <-chan struct{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// probeAll checks every service in the background and updates the indicators.
|
||||||
|
func (a *App) probeAll() {
|
||||||
|
for _, p := range a.probes {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
version, err := p.check(ctx)
|
||||||
|
a.post(func() { a.SetService(p.name, version, err) })
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) renderStatus() {
|
func (a *App) renderStatus() {
|
||||||
var hints string
|
var hints string
|
||||||
if n := len(a.overlays); n > 0 {
|
if n := len(a.overlays); n > 0 {
|
||||||
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
||||||
} else {
|
} else {
|
||||||
if h := a.views[a.active].hints; h != nil {
|
v := a.views[a.active]
|
||||||
hints = h() + " · "
|
var parts []string
|
||||||
|
if v.hints != nil {
|
||||||
|
parts = append(parts, v.hints())
|
||||||
}
|
}
|
||||||
hints += "? help · F1-F5 · q quit"
|
parts = append(parts, "F9 help · F1-F7 · Ctrl+C quit")
|
||||||
|
hints = strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
a.status.SetText("[gray]" + hints)
|
a.status.SetText("[gray]" + hints)
|
||||||
}
|
}
|
||||||
@@ -254,6 +521,11 @@ func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onC
|
|||||||
|
|
||||||
// openModal shows p centered above the current view.
|
// openModal shows p centered above the current view.
|
||||||
func (a *App) openModal(p tview.Primitive, width, height int) {
|
func (a *App) openModal(p tview.Primitive, width, height int) {
|
||||||
|
a.openModalHints(p, width, height, "↑↓ move · ←→ change · Enter select · Esc cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openModalHints is openModal with its own status bar hints.
|
||||||
|
func (a *App) openModalHints(p tview.Primitive, width, height int, hints string) {
|
||||||
box := tview.NewFlex().
|
box := tview.NewFlex().
|
||||||
AddItem(nil, 0, 1, false).
|
AddItem(nil, 0, 1, false).
|
||||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
@@ -261,7 +533,7 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
|
|||||||
AddItem(p, height, 0, true).
|
AddItem(p, height, 0, true).
|
||||||
AddItem(nil, 0, 1, false), width, 0, true).
|
AddItem(nil, 0, 1, false), width, 0, true).
|
||||||
AddItem(nil, 0, 1, false)
|
AddItem(nil, 0, 1, false)
|
||||||
a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil)
|
a.pushOverlay(box, p, hints, false, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openSidePanel shows p at the top right so most of the view behind it stays
|
// openSidePanel shows p at the top right so most of the view behind it stays
|
||||||
@@ -322,16 +594,6 @@ func (a *App) onlyFullOverlays() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// typing reports whether keystrokes belong to a text field or dialog and must
|
|
||||||
// not trigger global shortcuts.
|
|
||||||
func (a *App) typing() bool {
|
|
||||||
if len(a.overlays) > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, isInput := a.app.GetFocus().(*tview.InputField)
|
|
||||||
return isInput
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
if ev.Key() == tcell.KeyCtrlC {
|
if ev.Key() == tcell.KeyCtrlC {
|
||||||
a.app.Stop()
|
a.app.Stop()
|
||||||
@@ -352,20 +614,10 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.typing() {
|
|
||||||
return ev
|
|
||||||
}
|
|
||||||
switch ev.Key() {
|
|
||||||
case tcell.KeyRune:
|
|
||||||
if ev.Rune() == 'q' {
|
|
||||||
a.app.Stop()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewShortcut returns the view index for F1-F5, or -1.
|
// viewShortcut returns the view index for F1-F7, or -1.
|
||||||
func (a *App) viewShortcut(ev *tcell.EventKey) int {
|
func (a *App) viewShortcut(ev *tcell.EventKey) int {
|
||||||
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
|
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
|
||||||
return int(ev.Key() - tcell.KeyF1)
|
return int(ev.Key() - tcell.KeyF1)
|
||||||
@@ -389,9 +641,14 @@ func (a *App) switchTo(i int) {
|
|||||||
func (a *App) renderTabs() {
|
func (a *App) renderTabs() {
|
||||||
var s string
|
var s string
|
||||||
for i, v := range a.views {
|
for i, v := range a.views {
|
||||||
if i == a.active {
|
switch {
|
||||||
|
case a.narrow && i == a.active:
|
||||||
|
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
|
||||||
|
case a.narrow:
|
||||||
|
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
|
||||||
|
case i == a.active:
|
||||||
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
||||||
} else {
|
default:
|
||||||
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,7 +660,11 @@ func searchHints() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func libraryHints() string {
|
func libraryHints() string {
|
||||||
return "Tab list · f status · F file props · / text · t monitor · s search"
|
return "Tab list · f status · F props · / text · t monitor · s search · d remove"
|
||||||
|
}
|
||||||
|
|
||||||
|
func calendarHints() string {
|
||||||
|
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
|
||||||
}
|
}
|
||||||
|
|
||||||
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestApp(t *testing.T) *App {
|
func newTestApp(t *testing.T) *App {
|
||||||
@@ -22,7 +26,7 @@ func newTestApp(t *testing.T) *App {
|
|||||||
|
|
||||||
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
for _, r := range "12345\t" {
|
for _, r := range "1234567\t" {
|
||||||
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
|
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
|
||||||
if r == '\t' {
|
if r == '\t' {
|
||||||
key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
|
key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
|
||||||
@@ -32,9 +36,48 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
|
|||||||
t.Fatalf("key %q switched to view %d", r, a.active)
|
t.Fatalf("key %q switched to view %d", r, a.active)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5} {
|
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6, tcell.KeyF7} {
|
||||||
if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i {
|
if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i {
|
||||||
t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
|
t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQDoesNotQuit(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ev := tcell.NewEventKey(tcell.KeyRune, 'q', 0)
|
||||||
|
if got := a.handleKey(ev); got != ev {
|
||||||
|
t.Error("q must be passed on as an ordinary key, not taken as quit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartsWithoutServices(t *testing.T) {
|
||||||
|
// The config a first start creates: nothing set up.
|
||||||
|
a := New(&config.Config{UI: config.UI{RefreshSeconds: 3}})
|
||||||
|
if a.views[a.active].name != "Config" {
|
||||||
|
t.Errorf("first start opens %q, want the Config view", a.views[a.active].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clients do not try to connect without an address.
|
||||||
|
_, err := a.radarr.SystemStatus(t.Context())
|
||||||
|
if !errors.Is(err, arr.ErrNotConfigured) {
|
||||||
|
t.Fatalf("radarr without address: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := a.sab.Version(t.Context()); !errors.Is(err, sabnzbd.ErrNotConfigured) {
|
||||||
|
t.Fatalf("sabnzbd without address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not configured is grey, not red, and logged once as information.
|
||||||
|
a.SetService("Radarr", "", err)
|
||||||
|
a.SetService("Radarr", "", err)
|
||||||
|
if txt := a.services.GetText(false); !strings.Contains(txt, "○ Radarr") {
|
||||||
|
t.Errorf("indicator: %q", txt)
|
||||||
|
}
|
||||||
|
log := a.logs.root.GetText(true)
|
||||||
|
if strings.Count(log, "Radarr is not configured") != 1 || strings.Contains(log, "unreachable") {
|
||||||
|
t.Errorf("log: %q", log)
|
||||||
|
}
|
||||||
|
if !strings.Contains(a.serviceSummary(), "Radarr: not configured") {
|
||||||
|
t.Errorf("help summary: %q", a.serviceSummary())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
calendarDays = 14 // length of the window
|
||||||
|
calendarStep = 7 // `[` and `]` move by this many days
|
||||||
|
)
|
||||||
|
|
||||||
|
// calEntry is one line of the calendar: a movie release or an episode airing.
|
||||||
|
type calEntry struct {
|
||||||
|
day time.Time // local midnight of the day
|
||||||
|
at time.Time // time of day, zero for date-only entries (movie releases)
|
||||||
|
movie bool
|
||||||
|
title string // plain title, for sorting
|
||||||
|
label string // escaped title for display
|
||||||
|
sub string // "S02E05 Welcome" or "Digital release"
|
||||||
|
have bool
|
||||||
|
monitored bool
|
||||||
|
missing bool // aired already and not downloaded
|
||||||
|
details detailText
|
||||||
|
cover coverRef
|
||||||
|
|
||||||
|
movieID, episodeID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e calEntry) key() string {
|
||||||
|
if e.movie {
|
||||||
|
return fmt.Sprintf("m%d/%s/%s", e.movieID, e.sub, e.day.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("e%d", e.episodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localDay returns midnight of the date of t in the local zone.
|
||||||
|
func localDay(t time.Time) time.Time {
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseDay reads a movie release date. Radarr sends these as midnight UTC
|
||||||
|
// timestamps meaning "this date", so the date is taken as written and not
|
||||||
|
// shifted into the local zone.
|
||||||
|
func releaseDay(s string) (time.Time, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// movieEntries returns an entry for every release date of the movie that falls
|
||||||
|
// into [from, to] (local days).
|
||||||
|
func movieEntries(m radarr.Movie, from, to time.Time, cover coverRef, now time.Time) []calEntry {
|
||||||
|
var out []calEntry
|
||||||
|
for _, rel := range []struct{ name, date string }{
|
||||||
|
{"In cinemas", m.InCinemas}, {"Digital release", m.DigitalRelease}, {"Physical release", m.PhysicalRelease},
|
||||||
|
} {
|
||||||
|
day, ok := releaseDay(rel.date)
|
||||||
|
if !ok || day.Before(from) || day.After(to) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := tview.Escape(m.Title)
|
||||||
|
if m.Year > 0 {
|
||||||
|
label += fmt.Sprintf(" (%d)", m.Year)
|
||||||
|
}
|
||||||
|
d := movieDetails(m)
|
||||||
|
d.head += "Release: " + rel.name + ", " + day.Format("Mon 2 Jan 2006") + "\n"
|
||||||
|
out = append(out, calEntry{
|
||||||
|
day: day, movie: true, title: m.Title, label: label, sub: rel.name,
|
||||||
|
have: m.HasFile, monitored: m.Monitored, missing: m.Monitored && !m.HasFile && !day.After(now),
|
||||||
|
details: d, cover: cover, movieID: m.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// episodeEntry converts an airing episode, or reports false if it has no usable date.
|
||||||
|
func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time.Time) (calEntry, bool) {
|
||||||
|
var day, at time.Time
|
||||||
|
if t, err := time.Parse(time.RFC3339, e.AirDateUTC); err == nil {
|
||||||
|
at = t.Local()
|
||||||
|
day = localDay(at)
|
||||||
|
} else if t, err := time.Parse("2006-01-02", e.AirDate); err == nil {
|
||||||
|
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||||
|
} else {
|
||||||
|
return calEntry{}, false
|
||||||
|
}
|
||||||
|
if day.Before(from) || day.After(to) {
|
||||||
|
return calEntry{}, false
|
||||||
|
}
|
||||||
|
series := "Unknown series"
|
||||||
|
var overview string
|
||||||
|
if e.Series != nil {
|
||||||
|
series = e.Series.Title
|
||||||
|
overview = e.Series.Overview
|
||||||
|
}
|
||||||
|
if e.Overview != "" {
|
||||||
|
overview = e.Overview
|
||||||
|
}
|
||||||
|
sub := fmt.Sprintf("S%02dE%02d %s", e.SeasonNumber, e.EpisodeNumber, e.Title)
|
||||||
|
|
||||||
|
head := "[::b]" + tview.Escape(series) + "[::-]\n" + tview.Escape(sub) + "\n"
|
||||||
|
if !at.IsZero() {
|
||||||
|
head += "Airs " + at.Format("Mon 2 Jan 15:04") + "\n"
|
||||||
|
} else {
|
||||||
|
head += "Airs " + day.Format("Mon 2 Jan") + "\n"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case e.HasFile:
|
||||||
|
head += "[green]Downloaded[-]\n"
|
||||||
|
case !e.Monitored:
|
||||||
|
head += "[gray]Not monitored[-]\n"
|
||||||
|
default:
|
||||||
|
head += "[yellow]Not downloaded[-]\n"
|
||||||
|
}
|
||||||
|
aired := !at.IsZero() && at.Before(now) || at.IsZero() && day.Before(localDay(now))
|
||||||
|
return calEntry{
|
||||||
|
day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
|
||||||
|
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired,
|
||||||
|
details: detailText{head: head, plot: tview.Escape(overview)}, cover: cover,
|
||||||
|
episodeID: e.ID,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortEntries orders by day, then time of day (date-only first), then title.
|
||||||
|
func sortEntries(es []calEntry) {
|
||||||
|
slices.SortStableFunc(es, func(a, b calEntry) int {
|
||||||
|
return cmp.Or(
|
||||||
|
a.day.Compare(b.day),
|
||||||
|
a.at.Compare(b.at),
|
||||||
|
strings.Compare(strings.ToLower(a.title), strings.ToLower(b.title)),
|
||||||
|
strings.Compare(a.sub, b.sub),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayLabel returns the date of a day and, for days near today, its relative
|
||||||
|
// name: ("Wed 21 Oct", "Tomorrow").
|
||||||
|
func dayLabel(day, today time.Time) (date, relative string) {
|
||||||
|
date = day.Format("Mon 2 Jan")
|
||||||
|
switch int(day.Sub(today).Round(24*time.Hour).Hours() / 24) { // Round: days are 23 or 25 hours long when the clocks change
|
||||||
|
case 0:
|
||||||
|
return date, "Today"
|
||||||
|
case 1:
|
||||||
|
return date, "Tomorrow"
|
||||||
|
case -1:
|
||||||
|
return date, "Yesterday"
|
||||||
|
}
|
||||||
|
return date, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarView lists the movies and episodes coming up in a two-week window.
|
||||||
|
type calendarView struct {
|
||||||
|
app *App
|
||||||
|
radarr *radarr.Client
|
||||||
|
sonarr *sonarr.Client
|
||||||
|
|
||||||
|
root *tview.Flex
|
||||||
|
header *tview.TextView
|
||||||
|
table *tview.Table
|
||||||
|
details *detailPane
|
||||||
|
|
||||||
|
// UI-goroutine state.
|
||||||
|
offset int // days from today to the first day shown; moved with [ and ]
|
||||||
|
unmonitored bool
|
||||||
|
entries []calEntry
|
||||||
|
rows []calRow
|
||||||
|
loaded bool
|
||||||
|
gen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// calRow is a table row: a day header (entry == nil) or an entry.
|
||||||
|
type calRow struct {
|
||||||
|
day time.Time
|
||||||
|
entry *calEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView {
|
||||||
|
v := &calendarView{
|
||||||
|
app: app, radarr: r, sonarr: s,
|
||||||
|
header: tview.NewTextView().SetDynamicColors(true),
|
||||||
|
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
|
||||||
|
details: newDetailPane(app),
|
||||||
|
}
|
||||||
|
v.table.SetBorder(true).SetTitle(" Upcoming ")
|
||||||
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(v.header, 1, 0, false).
|
||||||
|
AddItem(tview.NewFlex().
|
||||||
|
AddItem(v.table, 0, 3, true).
|
||||||
|
AddItem(v.details, 0, 2, false), 0, 1, true)
|
||||||
|
|
||||||
|
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
|
||||||
|
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
||||||
|
v.table.SetInputCapture(v.handleKey)
|
||||||
|
app.reconnect = append(app.reconnect, func() { v.entries, v.loaded = nil, false })
|
||||||
|
v.render()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if ev.Key() != tcell.KeyRune {
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
switch ev.Rune() {
|
||||||
|
case '[':
|
||||||
|
v.offset -= calendarStep
|
||||||
|
v.reload()
|
||||||
|
case ']':
|
||||||
|
v.offset += calendarStep
|
||||||
|
v.reload()
|
||||||
|
case 't':
|
||||||
|
v.offset = 0
|
||||||
|
v.reload()
|
||||||
|
case 'u':
|
||||||
|
v.unmonitored = !v.unmonitored
|
||||||
|
v.reload()
|
||||||
|
case 'r':
|
||||||
|
v.reload()
|
||||||
|
case 'i':
|
||||||
|
v.open()
|
||||||
|
case 's':
|
||||||
|
v.search()
|
||||||
|
default:
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// window returns the first and last day shown.
|
||||||
|
func (v *calendarView) window(now time.Time) (from, to time.Time) {
|
||||||
|
from = localDay(now).AddDate(0, 0, v.offset)
|
||||||
|
return from, from.AddDate(0, 0, calendarDays-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *calendarView) activate() { v.reload() }
|
||||||
|
|
||||||
|
// reload fetches both calendars in parallel.
|
||||||
|
func (v *calendarView) reload() {
|
||||||
|
v.gen++
|
||||||
|
gen, unmonitored := v.gen, v.unmonitored
|
||||||
|
now := time.Now()
|
||||||
|
from, to := v.window(now)
|
||||||
|
v.render()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
movies []radarr.Movie
|
||||||
|
episodes []sonarr.Episode
|
||||||
|
movieErr, tvErr error
|
||||||
|
)
|
||||||
|
wg.Add(2)
|
||||||
|
go func() { defer wg.Done(); movies, movieErr = v.radarr.Calendar(ctx, from, to, unmonitored) }()
|
||||||
|
go func() { defer wg.Done(); episodes, tvErr = v.sonarr.Calendar(ctx, from, to, unmonitored) }()
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
var entries []calEntry
|
||||||
|
for _, m := range movies {
|
||||||
|
entries = append(entries, movieEntries(m, from, to, coverFor(v.radarr.Client, m.Images, true), now)...)
|
||||||
|
}
|
||||||
|
for _, e := range episodes {
|
||||||
|
var cover coverRef
|
||||||
|
if e.Series != nil {
|
||||||
|
cover = coverFor(v.sonarr.Client, e.Series.Images, true)
|
||||||
|
}
|
||||||
|
if entry, ok := episodeEntry(e, from, to, cover, now); ok {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortEntries(entries)
|
||||||
|
|
||||||
|
v.app.post(func() {
|
||||||
|
v.app.SetService("Radarr", "", movieErr)
|
||||||
|
v.app.SetService("Sonarr", "", tvErr)
|
||||||
|
if gen != v.gen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A service that is not set up just contributes nothing.
|
||||||
|
usable := func(err error) bool { return err == nil || notConfigured(err) }
|
||||||
|
for _, err := range []error{movieErr, tvErr} {
|
||||||
|
if !usable(err) {
|
||||||
|
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usable(movieErr) || usable(tvErr) { // show what we got
|
||||||
|
v.entries, v.loaded = entries, true
|
||||||
|
}
|
||||||
|
v.render()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *calendarView) selected() (*calEntry, bool) {
|
||||||
|
r, _ := v.table.GetSelection()
|
||||||
|
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return v.rows[r].entry, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *calendarView) render() {
|
||||||
|
selKey := ""
|
||||||
|
if e, ok := v.selected(); ok {
|
||||||
|
selKey = e.key()
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
today := localDay(now)
|
||||||
|
from, to := v.window(now)
|
||||||
|
|
||||||
|
v.rows = v.rows[:0]
|
||||||
|
var lastDay time.Time
|
||||||
|
for i := range v.entries {
|
||||||
|
e := &v.entries[i]
|
||||||
|
if !e.day.Equal(lastDay) {
|
||||||
|
v.rows = append(v.rows, calRow{day: e.day})
|
||||||
|
lastDay = e.day
|
||||||
|
}
|
||||||
|
v.rows = append(v.rows, calRow{day: e.day, entry: e})
|
||||||
|
}
|
||||||
|
|
||||||
|
v.table.Clear()
|
||||||
|
selRow, firstEntry := 0, -1
|
||||||
|
for i, r := range v.rows {
|
||||||
|
if r.entry == nil {
|
||||||
|
date, relative := dayLabel(r.day, today)
|
||||||
|
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
|
||||||
|
v.table.SetCell(i, 1, tview.NewTableCell("").SetSelectable(false))
|
||||||
|
v.table.SetCell(i, 2, tview.NewTableCell("[yellow]"+relative).SetSelectable(false))
|
||||||
|
v.table.SetCell(i, 3, tview.NewTableCell("").SetSelectable(false))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := r.entry
|
||||||
|
if firstEntry < 0 {
|
||||||
|
firstEntry = i
|
||||||
|
}
|
||||||
|
when := " "
|
||||||
|
if !e.at.IsZero() {
|
||||||
|
when = e.at.Format("15:04") + " "
|
||||||
|
}
|
||||||
|
kind := "[blue]TV "
|
||||||
|
if e.movie {
|
||||||
|
kind = "[purple]Movie"
|
||||||
|
}
|
||||||
|
v.table.SetCell(i, 0, tview.NewTableCell(" "+when).SetSelectable(true))
|
||||||
|
v.table.SetCell(i, 1, tview.NewTableCell(kind+"[-]"))
|
||||||
|
v.table.SetCell(i, 2, tview.NewTableCell(e.label+" [gray]"+tview.Escape(e.sub)).SetExpansion(1))
|
||||||
|
v.table.SetCell(i, 3, tview.NewTableCell(calStatus(*e)))
|
||||||
|
if selKey != "" && e.key() == selKey {
|
||||||
|
selRow = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " "
|
||||||
|
if v.unmonitored {
|
||||||
|
title += "· incl. unmonitored "
|
||||||
|
}
|
||||||
|
v.table.SetTitle(title)
|
||||||
|
switch {
|
||||||
|
case len(v.entries) == 0:
|
||||||
|
msg := "Loading…"
|
||||||
|
if v.loaded {
|
||||||
|
msg = "Nothing in this period"
|
||||||
|
}
|
||||||
|
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
|
||||||
|
v.table.SetSelectable(false, false)
|
||||||
|
v.table.Select(0, 0)
|
||||||
|
default:
|
||||||
|
v.table.SetSelectable(true, false)
|
||||||
|
if selRow == 0 {
|
||||||
|
selRow = firstEntry
|
||||||
|
}
|
||||||
|
v.table.Select(selRow, 0)
|
||||||
|
if selRow == firstEntry {
|
||||||
|
v.table.ScrollToBeginning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.header.SetText("[::b]Calendar[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search")
|
||||||
|
v.renderDetails()
|
||||||
|
}
|
||||||
|
|
||||||
|
// calStatus is the coloured status text of an entry.
|
||||||
|
func calStatus(e calEntry) string {
|
||||||
|
switch {
|
||||||
|
case e.have:
|
||||||
|
return "[green]✓ downloaded"
|
||||||
|
case !e.monitored:
|
||||||
|
return "[gray]unmonitored"
|
||||||
|
case e.missing:
|
||||||
|
return "[yellow]missing"
|
||||||
|
}
|
||||||
|
return "[gray]upcoming"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *calendarView) renderDetails() {
|
||||||
|
if e, ok := v.selected(); ok {
|
||||||
|
v.details.Show(e.details, e.cover)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.details.Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// open starts the interactive release search for the selected entry.
|
||||||
|
func (v *calendarView) open() {
|
||||||
|
e, ok := v.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e.movie {
|
||||||
|
id, title := e.movieID, tview.Escape(e.title)
|
||||||
|
v.app.openReleases("Releases for "+title,
|
||||||
|
func(ctx context.Context) ([]arr.Release, error) { return v.radarr.Releases(ctx, id) },
|
||||||
|
v.radarr.Grab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := e.episodeID
|
||||||
|
v.app.openReleases("Releases for "+e.label+" "+tview.Escape(e.sub),
|
||||||
|
func(ctx context.Context) ([]arr.Release, error) { return v.sonarr.EpisodeReleases(ctx, id) },
|
||||||
|
v.sonarr.Grab)
|
||||||
|
}
|
||||||
|
|
||||||
|
// search starts an automatic search for the selected entry.
|
||||||
|
func (v *calendarView) search() {
|
||||||
|
e, ok := v.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
what := e.label
|
||||||
|
if !e.movie {
|
||||||
|
what += " " + tview.Escape(e.sub)
|
||||||
|
}
|
||||||
|
v.app.SetMessage("Searching for " + what + "…")
|
||||||
|
movie, movieID, episodeID := e.movie, e.movieID, e.episodeID
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var err error
|
||||||
|
if movie {
|
||||||
|
err = v.radarr.SearchMovie(ctx, movieID)
|
||||||
|
} else {
|
||||||
|
err = v.sonarr.SearchEpisode(ctx, episodeID)
|
||||||
|
}
|
||||||
|
v.app.post(func() {
|
||||||
|
if err != nil {
|
||||||
|
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.app.SetMessage("[green]Search started for " + what)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.Local) }
|
||||||
|
|
||||||
|
func TestMovieEntriesPerReleaseDate(t *testing.T) {
|
||||||
|
from, to := day(2026, 10, 20), day(2026, 11, 2)
|
||||||
|
now := day(2026, 10, 21).Add(10 * time.Hour)
|
||||||
|
m := radarr.Movie{
|
||||||
|
ID: 4, Title: "Dune [Part 2]", Year: 2024, Monitored: true,
|
||||||
|
InCinemas: "2026-10-22T00:00:00Z", // inside
|
||||||
|
DigitalRelease: "2026-11-01T00:00:00Z", // inside
|
||||||
|
// physical release is after the window
|
||||||
|
PhysicalRelease: "2026-12-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
es := movieEntries(m, from, to, coverRef{}, now)
|
||||||
|
if len(es) != 2 {
|
||||||
|
t.Fatalf("%d entries, want 2: %+v", len(es), es)
|
||||||
|
}
|
||||||
|
if es[0].sub != "In cinemas" || !es[0].day.Equal(day(2026, 10, 22)) || es[1].sub != "Digital release" || !es[1].day.Equal(day(2026, 11, 1)) {
|
||||||
|
t.Errorf("entries = %+v", es)
|
||||||
|
}
|
||||||
|
if !es[0].movie || es[0].missing || !strings.Contains(es[0].label, "Dune [Part 2[]") {
|
||||||
|
t.Errorf("flags/label wrong: %+v", es[0])
|
||||||
|
}
|
||||||
|
if es[0].key() == es[1].key() {
|
||||||
|
t.Error("the same movie on two dates must have distinct keys")
|
||||||
|
}
|
||||||
|
if !strings.Contains(es[0].details.head, "Release: In cinemas, Thu 22 Oct 2026") {
|
||||||
|
t.Errorf("details: %q", es[0].details.head)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A release that already happened and is not downloaded counts as missing.
|
||||||
|
past := movieEntries(radarr.Movie{ID: 5, Title: "Old", Monitored: true, DigitalRelease: "2026-10-20T00:00:00Z"}, from, to, coverRef{}, now)
|
||||||
|
if len(past) != 1 || !past[0].missing {
|
||||||
|
t.Errorf("past release: %+v", past)
|
||||||
|
}
|
||||||
|
// Dates are taken as written: midnight UTC stays on that calendar day in every zone.
|
||||||
|
if es := movieEntries(radarr.Movie{ID: 6, Title: "Z", InCinemas: "2026-10-25T00:00:00Z"}, from, to, coverRef{}, now); len(es) != 1 || es[0].day.Day() != 25 {
|
||||||
|
t.Errorf("timezone shifted the release date: %+v", es)
|
||||||
|
}
|
||||||
|
if es := movieEntries(radarr.Movie{ID: 7, Title: "NoDates"}, from, to, coverRef{}, now); len(es) != 0 {
|
||||||
|
t.Errorf("movie without dates: %+v", es)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEpisodeEntry(t *testing.T) {
|
||||||
|
from, to := day(2026, 10, 20), day(2026, 11, 2)
|
||||||
|
now := day(2026, 10, 21).Add(12 * time.Hour)
|
||||||
|
at := time.Date(2026, 10, 22, 19, 30, 0, 0, time.UTC)
|
||||||
|
e := sonarr.Episode{
|
||||||
|
ID: 9, SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome", Monitored: true,
|
||||||
|
AirDateUTC: at.Format(time.RFC3339), Overview: "Episode plot",
|
||||||
|
Series: &sonarr.Series{Title: "Andor", Overview: "Series plot"},
|
||||||
|
}
|
||||||
|
got, ok := episodeEntry(e, from, to, coverRef{}, now)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("episode in the window rejected")
|
||||||
|
}
|
||||||
|
if got.sub != "S02E05 Welcome" || got.title != "Andor" || got.movie || got.episodeID != 9 || got.at.IsZero() {
|
||||||
|
t.Errorf("entry = %+v", got)
|
||||||
|
}
|
||||||
|
if !got.day.Equal(localDay(at.Local())) {
|
||||||
|
t.Errorf("day = %v, want the local date of the airing", got.day)
|
||||||
|
}
|
||||||
|
if got.details.plot != "Episode plot" || !strings.Contains(got.details.head, "Andor") || !strings.Contains(got.details.head, "S02E05") {
|
||||||
|
t.Errorf("details = %+v", got.details)
|
||||||
|
}
|
||||||
|
if got.missing {
|
||||||
|
t.Error("an episode that has not aired yet is not missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls back to the series plot, and to the date-only air date.
|
||||||
|
e2 := sonarr.Episode{ID: 10, Title: "X", AirDate: "2026-10-19", Monitored: true, Series: &sonarr.Series{Title: "S", Overview: "Series plot"}}
|
||||||
|
if _, ok := episodeEntry(e2, from, to, coverRef{}, now); ok {
|
||||||
|
t.Error("an episode before the window must be left out")
|
||||||
|
}
|
||||||
|
e2.AirDate = "2026-10-20"
|
||||||
|
got, ok = episodeEntry(e2, from, to, coverRef{}, now)
|
||||||
|
if !ok || got.details.plot != "Series plot" || !got.at.IsZero() || !got.missing {
|
||||||
|
t.Errorf("date-only episode: ok=%v %+v", ok, got)
|
||||||
|
}
|
||||||
|
if _, ok := episodeEntry(sonarr.Episode{ID: 11}, from, to, coverRef{}, now); ok {
|
||||||
|
t.Error("an episode without any date has no place in the calendar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortEntriesAndDayLabel(t *testing.T) {
|
||||||
|
d1, d2 := day(2026, 10, 21), day(2026, 10, 22)
|
||||||
|
es := []calEntry{
|
||||||
|
{day: d2, title: "b"},
|
||||||
|
{day: d1, at: d1.Add(20 * time.Hour), title: "late"},
|
||||||
|
{day: d1, title: "dateonly"},
|
||||||
|
{day: d1, at: d1.Add(9 * time.Hour), title: "early"},
|
||||||
|
{day: d1, at: d1.Add(9 * time.Hour), title: "Alpha"},
|
||||||
|
}
|
||||||
|
sortEntries(es)
|
||||||
|
var got []string
|
||||||
|
for _, e := range es {
|
||||||
|
got = append(got, e.title)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != "dateonly,Alpha,early,late,b" {
|
||||||
|
t.Errorf("order = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
today := day(2026, 10, 21)
|
||||||
|
type label struct{ date, rel string }
|
||||||
|
for d, want := range map[time.Time]label{
|
||||||
|
today: {"Wed 21 Oct", "Today"},
|
||||||
|
today.AddDate(0, 0, 1): {"Thu 22 Oct", "Tomorrow"},
|
||||||
|
today.AddDate(0, 0, -1): {"Tue 20 Oct", "Yesterday"},
|
||||||
|
today.AddDate(0, 0, 5): {"Mon 26 Oct", ""},
|
||||||
|
} {
|
||||||
|
if date, rel := dayLabel(d, today); (label{date, rel}) != want {
|
||||||
|
t.Errorf("dayLabel(%v) = %q, %q, want %v", d, date, rel, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeriesRowDetail(t *testing.T) {
|
||||||
|
s := sonarr.Series{Title: "Andor", Year: 2022, Network: "Disney+", Status: "ended", Overview: "Series plot"}
|
||||||
|
|
||||||
|
// A season: the summary and the series plot.
|
||||||
|
d := seriesRowDetail(s, nil, 2, seasonSummary{number: 2, total: 12, have: 5}, true, "2026-01-01")
|
||||||
|
for _, want := range []string{"Andor", "(2022)", "Disney+", "Season 2", "5 of 12 episodes", "Monitored: yes"} {
|
||||||
|
if !strings.Contains(d.head, want) {
|
||||||
|
t.Errorf("season head missing %q:\n%s", want, d.head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.plot != "Series plot" {
|
||||||
|
t.Errorf("season plot = %q", d.plot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An episode: its own title, air date, status and plot.
|
||||||
|
ep := &sonarr.Episode{SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome [Home]", AirDate: "2025-05-01", HasFile: true, Monitored: true, Overview: "Episode plot"}
|
||||||
|
d = seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01")
|
||||||
|
for _, want := range []string{"S02E05 Welcome [Home[]", "Aired: 2025-05-01", "downloaded"} {
|
||||||
|
if !strings.Contains(d.head, want) {
|
||||||
|
t.Errorf("episode head missing %q:\n%s", want, d.head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.plot != "Episode plot" {
|
||||||
|
t.Errorf("episode plot = %q", d.plot)
|
||||||
|
}
|
||||||
|
// Without an episode plot the series plot stays.
|
||||||
|
ep.Overview = ""
|
||||||
|
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01"); d.plot != "Series plot" {
|
||||||
|
t.Errorf("fallback plot = %q", d.plot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForgetPane(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
n := len(a.panes)
|
||||||
|
d := newDetailPane(a)
|
||||||
|
if len(a.panes) != n+1 {
|
||||||
|
t.Fatal("a new pane must be registered")
|
||||||
|
}
|
||||||
|
a.forgetPane(d)
|
||||||
|
if len(a.panes) != n {
|
||||||
|
t.Errorf("%d panes after forgetting, want %d", len(a.panes), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetConfigSource tells the app which file the settings came from and what that
|
||||||
|
// file contains, so the Config view can save changes. raw is the file as
|
||||||
|
// written (see config.LoadFile), not the effective settings.
|
||||||
|
func (a *App) SetConfigSource(path string, raw *config.Config) {
|
||||||
|
a.cfgPath, a.cfgFile = path, raw
|
||||||
|
if a.settings != nil {
|
||||||
|
a.settings.describe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyConfig puts new settings into effect without a restart.
|
||||||
|
func (a *App) applyConfig(c *config.Config) {
|
||||||
|
old := a.cfg
|
||||||
|
a.cfg = c
|
||||||
|
|
||||||
|
if old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service || old.SABnzbd != c.SABnzbd {
|
||||||
|
a.radarr.Reconfigure(c.Radarr.URL, c.Radarr.APIKey)
|
||||||
|
a.sonarr.Reconfigure(c.Sonarr.URL, c.Sonarr.APIKey)
|
||||||
|
a.sab.Reconfigure(c.SABnzbd.URL, c.SABnzbd.APIKey)
|
||||||
|
a.probeAll() // the indicators, right away
|
||||||
|
for _, f := range a.reconnect {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if old.UI.RefreshSeconds != c.UI.RefreshSeconds {
|
||||||
|
a.downloads.setInterval(time.Duration(c.UI.RefreshSeconds) * time.Second)
|
||||||
|
}
|
||||||
|
a.bell = c.UI.Bell
|
||||||
|
if old.UI.Theme != c.UI.Theme {
|
||||||
|
a.setTheme(chooseTheme(c.UI.Theme, terminalAnswers()))
|
||||||
|
}
|
||||||
|
if c.UI.ShouldNotifyDownloads() != a.notifyDone {
|
||||||
|
a.setNotifyDownloads(c.UI.ShouldNotifyDownloads())
|
||||||
|
}
|
||||||
|
if old.UI.ShowCovers() != c.UI.ShowCovers() || old.UI.CoverStyle != c.UI.CoverStyle {
|
||||||
|
a.covers = c.UI.ShowCovers()
|
||||||
|
a.restyleCovers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// restyleCovers applies a changed poster style or switch to every detail pane.
|
||||||
|
func (a *App) restyleCovers() {
|
||||||
|
blocks, graphics := resolveCovers(a.cfg.UI.CoverStyle, os.Getenv)
|
||||||
|
if a.gfx != nil {
|
||||||
|
a.gfx.deleteAll() // the terminal's copies belong to the old images
|
||||||
|
}
|
||||||
|
a.coverStyle = blocks
|
||||||
|
switch {
|
||||||
|
case graphics && a.gfx == nil:
|
||||||
|
a.gfx = a.newGraphics()
|
||||||
|
case !graphics:
|
||||||
|
a.gfx = nil
|
||||||
|
}
|
||||||
|
a.coverCache = newCoverCache() // stored copies depend on the style (their height)
|
||||||
|
for _, p := range a.panes {
|
||||||
|
p.cover.style, p.cover.gfx = blocks, a.gfx
|
||||||
|
p.cover.cells = nil
|
||||||
|
p.Show(p.detail, p.lastRef) // fetch the poster again for the new style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetPane drops a detail pane that is no longer shown (a closed drill-down).
|
||||||
|
func (a *App) forgetPane(d *detailPane) {
|
||||||
|
for i, p := range a.panes {
|
||||||
|
if p == d {
|
||||||
|
a.panes = append(a.panes[:i], a.panes[i+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
_ "image/jpeg" // poster formats
|
_ "image/gif" // poster formats
|
||||||
_ "image/png"
|
_ "image/jpeg" // (Radarr and Sonarr store the provider's bytes under a .jpg name,
|
||||||
|
_ "image/png" // whatever format they are in)
|
||||||
|
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,17 +25,19 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
maxCoverPixels = 25_000_000 // refuse decompression bombs
|
maxCoverPixels = 25_000_000 // refuse decompression bombs
|
||||||
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail
|
storedCoverHeight = 160 // a small copy is plenty for block characters
|
||||||
|
graphicsCoverHeight = 640 // posters shown as real images keep more detail
|
||||||
coverFailureTTL = 2 * time.Minute
|
coverFailureTTL = 2 * time.Minute
|
||||||
maxCoverCache = 300
|
maxCoverCache = 120
|
||||||
coverFetchLimit = 4
|
coverFetchLimit = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
|
// decodeCover decodes a JPEG or PNG poster and keeps a copy at most
|
||||||
func decodeCover(data []byte) (*image.RGBA, error) {
|
// maxHeight pixels tall.
|
||||||
|
func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) {
|
||||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("not a supported image: %w", err)
|
return nil, fmt.Errorf("not a supported image (%s): %w", describeBytes(data), err)
|
||||||
}
|
}
|
||||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxCoverPixels {
|
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxCoverPixels {
|
||||||
return nil, fmt.Errorf("image dimensions %dx%d not supported", cfg.Width, cfg.Height)
|
return nil, fmt.Errorf("image dimensions %dx%d not supported", cfg.Width, cfg.Height)
|
||||||
@@ -39,10 +47,10 @@ func decodeCover(data []byte) (*image.RGBA, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
b := img.Bounds()
|
b := img.Bounds()
|
||||||
if b.Dy() <= storedCoverHeight {
|
if b.Dy() <= maxHeight {
|
||||||
return boxScale(img, b.Dx(), b.Dy()), nil
|
return boxScale(img, b.Dx(), b.Dy()), nil
|
||||||
}
|
}
|
||||||
return boxScale(img, max(1, b.Dx()*storedCoverHeight/b.Dy()), storedCoverHeight), nil
|
return boxScale(img, max(1, b.Dx()*maxHeight/b.Dy()), maxHeight), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// boxScale resizes src to w×h by averaging the source pixels under every
|
// boxScale resizes src to w×h by averaging the source pixels under every
|
||||||
@@ -86,6 +94,10 @@ var (
|
|||||||
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
|
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
|
||||||
// for Legacy Computing block, which many fonts lack.
|
// for Legacy Computing block, which many fonts lack.
|
||||||
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
|
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
|
||||||
|
// cellsStyle (1 pixel per cell): only background colors, no glyphs at all.
|
||||||
|
// Coarse, but exact in terminals that draw block elements from the font
|
||||||
|
// with the halves off the cell's middle (macOS Terminal).
|
||||||
|
cellsStyle = coverStyle{"cells", 1, 1, func(int) rune { return ' ' }}
|
||||||
)
|
)
|
||||||
|
|
||||||
// quadrants[mask] is the glyph that fills the quadrants set in mask
|
// quadrants[mask] is the glyph that fills the quadrants set in mask
|
||||||
@@ -117,14 +129,22 @@ func sextantGlyph(mask int) rune {
|
|||||||
return rune(0x1FB00 + idx)
|
return rune(0x1FB00 + idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverStyleFor resolves the configured style. "auto" picks sextants on
|
// coverStyleFor resolves the block-character style for the setting. "auto"
|
||||||
// terminals known to have the glyphs and quadrants everywhere else.
|
// picks sextants on terminals known to have the glyphs, plain cells in macOS
|
||||||
|
// Terminal and quadrants everywhere else.
|
||||||
func coverStyleFor(setting string, env func(string) string) coverStyle {
|
func coverStyleFor(setting string, env func(string) string) coverStyle {
|
||||||
switch setting {
|
switch setting {
|
||||||
case "sextant":
|
case "sextant":
|
||||||
return sextantStyle
|
return sextantStyle
|
||||||
case "quadrant":
|
case "quadrant":
|
||||||
return quadrantStyle
|
return quadrantStyle
|
||||||
|
case "cells":
|
||||||
|
return cellsStyle
|
||||||
|
}
|
||||||
|
// Terminal.app draws block elements from the font, and their halves do
|
||||||
|
// not meet at the cell's middle: rows of a poster come out uneven.
|
||||||
|
if env("TERM_PROGRAM") == "Apple_Terminal" {
|
||||||
|
return cellsStyle
|
||||||
}
|
}
|
||||||
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
|
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
|
||||||
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
|
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
|
||||||
@@ -135,9 +155,53 @@ func coverStyleFor(setting string, env func(string) string) coverStyle {
|
|||||||
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
|
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
|
||||||
return sextantStyle
|
return sextantStyle
|
||||||
}
|
}
|
||||||
|
// Other terminals (GNOME Terminal, Konsole, Alacritty...) draw glyphs their
|
||||||
|
// font lacks from a fallback font, so sextants work if some installed font
|
||||||
|
// has them. Over SSH the installed fonts are the remote machine's, which
|
||||||
|
// says nothing about the terminal, so keep the safe style there.
|
||||||
|
// macOS terminals do not draw missing glyphs from fontconfig's fonts, so a
|
||||||
|
// font found there says nothing about them.
|
||||||
|
if runtime.GOOS != "darwin" && env("SSH_CONNECTION") == "" && env("SSH_TTY") == "" && sextantFontAvailable() {
|
||||||
|
return sextantStyle
|
||||||
|
}
|
||||||
return quadrantStyle
|
return quadrantStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sextantFontAvailable reports whether fontconfig knows a font with the
|
||||||
|
// sextant characters. It is a variable so tests can replace it.
|
||||||
|
var sextantFontAvailable = func() bool {
|
||||||
|
path, err := exec.LookPath("fc-list")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, path, ":charset=1fb00", "family").Output()
|
||||||
|
return err == nil && len(bytes.TrimSpace(out)) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCovers decides how posters are drawn: as real images through the
|
||||||
|
// Kitty graphics protocol (graphics) or with block characters in the given
|
||||||
|
// style. "kitty" asks for the protocol explicitly; "auto" uses it when the
|
||||||
|
// terminal is known to support it. The block style is also the fallback when
|
||||||
|
// the protocol cannot be used at draw time (e.g. no true color).
|
||||||
|
func resolveCovers(setting string, env func(string) string) (blocks coverStyle, graphics bool) {
|
||||||
|
switch setting {
|
||||||
|
case "kitty":
|
||||||
|
// Asked for explicitly, but a terminal without the protocol prints the
|
||||||
|
// commands as text, so it is still confirmed.
|
||||||
|
return coverStyleFor("auto", env), kittyGraphicsSupported()
|
||||||
|
case "auto", "":
|
||||||
|
return coverStyleFor("auto", env), kittyTerminal(env) && kittyGraphicsSupported()
|
||||||
|
}
|
||||||
|
return coverStyleFor(setting, env), false
|
||||||
|
}
|
||||||
|
|
||||||
|
// kittyGraphicsSupported reports whether the terminal confirmed the Kitty
|
||||||
|
// graphics protocol when it was asked at startup (see probeTerminal). It is a
|
||||||
|
// variable so tests can replace it.
|
||||||
|
var kittyGraphicsSupported = func() bool { return terminalAnswers().graphics }
|
||||||
|
|
||||||
// flatPixelError is the squared RGB distance per pixel below which a cell is
|
// flatPixelError is the squared RGB distance per pixel below which a cell is
|
||||||
// drawn flat: smooth areas and noise should not turn into patterns.
|
// drawn flat: smooth areas and noise should not turn into patterns.
|
||||||
const flatPixelError = 150
|
const flatPixelError = 150
|
||||||
@@ -215,6 +279,9 @@ func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
|
|||||||
type coverView struct {
|
type coverView struct {
|
||||||
*tview.Box
|
*tview.Box
|
||||||
style coverStyle
|
style coverStyle
|
||||||
|
gfx *kittyGraphics // real images; nil for block characters only
|
||||||
|
lastDrawn [5]int // id, position and size of the last real image drawn, for the debug log
|
||||||
|
cellAspect func() float64 // height of a terminal cell divided by its width
|
||||||
img *image.RGBA
|
img *image.RGBA
|
||||||
note string // shown instead of the image: "loading…", "no cover"
|
note string // shown instead of the image: "loading…", "no cover"
|
||||||
|
|
||||||
@@ -228,7 +295,18 @@ type styledCell struct {
|
|||||||
fg, bg tcell.Color
|
fg, bg tcell.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} }
|
func newCoverView(style coverStyle, gfx *kittyGraphics, cellAspect func() float64) *coverView {
|
||||||
|
return &coverView{Box: tview.NewBox(), style: style, gfx: gfx, cellAspect: cellAspect}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *coverView) aspectOfCells() float64 {
|
||||||
|
if c.cellAspect != nil {
|
||||||
|
if a := c.cellAspect(); a > 0.5 && a < 8 {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
|
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
|
||||||
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
|
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
|
||||||
@@ -242,16 +320,47 @@ func (c *coverView) aspect() (w, h int) {
|
|||||||
return b.Dx(), b.Dy()
|
return b.Dx(), b.Dy()
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverCells returns how many columns and rows a poster of the given aspect
|
// coverFitTolerance is how far the shape of the cell box may differ from the
|
||||||
// needs inside w×h cells. A cell is about twice as tall as it is wide.
|
// poster's, as a fraction. The box is chosen to match the poster's shape
|
||||||
func coverCells(imgW, imgH, w, h int) (cols, rows int) {
|
// closely; the small remainder is cropped away from the poster before it is
|
||||||
cols = w
|
// shown as a real image, because a real image placed in a box of another shape
|
||||||
rows = cols * imgH / imgW / 2
|
// is letterboxed (visible bars beside or above it).
|
||||||
if rows > h {
|
const coverFitTolerance = 0.03
|
||||||
rows = h
|
|
||||||
cols = rows * 2 * imgW / imgH
|
// coverCells returns how many columns and rows a poster of the given shape
|
||||||
|
// needs inside w×h cells. cellAspect is the height of a cell divided by its
|
||||||
|
// width, about 2 in most terminals. Cell counts are whole numbers, so the
|
||||||
|
// largest size whose box is within coverFitTolerance of the poster's shape is
|
||||||
|
// chosen (or the closest one if none is).
|
||||||
|
func coverCells(imgW, imgH, w, h int, cellAspect float64) (cols, rows int) {
|
||||||
|
if imgW <= 0 || imgH <= 0 || w <= 0 || h <= 0 || cellAspect <= 0 {
|
||||||
|
return 0, 0
|
||||||
}
|
}
|
||||||
return max(cols, 0), max(rows, 0)
|
shape := float64(imgW) / float64(imgH) // width / height of the poster
|
||||||
|
// A box of r rows is r*cellAspect*shape columns wide for the same shape.
|
||||||
|
maxRows := min(h, int(float64(w)/(cellAspect*shape)))
|
||||||
|
if maxRows < 1 {
|
||||||
|
return w, 0
|
||||||
|
}
|
||||||
|
bestErr := 2.0
|
||||||
|
for r := maxRows; r >= max(1, maxRows-6); r-- {
|
||||||
|
c := int(float64(r)*cellAspect*shape + 0.5)
|
||||||
|
if c < 1 || c > w {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boxShape := float64(c) / (float64(r) * cellAspect)
|
||||||
|
err := boxShape/shape - 1
|
||||||
|
if err < 0 {
|
||||||
|
err = -err
|
||||||
|
}
|
||||||
|
if err <= coverFitTolerance {
|
||||||
|
return c, r
|
||||||
|
}
|
||||||
|
if err < bestErr {
|
||||||
|
bestErr, cols, rows = err, c, r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cols, rows
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *coverView) Draw(screen tcell.Screen) {
|
func (c *coverView) Draw(screen tcell.Screen) {
|
||||||
@@ -262,16 +371,19 @@ func (c *coverView) Draw(screen tcell.Screen) {
|
|||||||
}
|
}
|
||||||
if c.img == nil {
|
if c.img == nil {
|
||||||
if c.note != "" {
|
if c.note != "" {
|
||||||
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, tcell.ColorGray)
|
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, named("gray"))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b := c.img.Bounds()
|
b := c.img.Bounds()
|
||||||
cols, rows := coverCells(b.Dx(), b.Dy(), w, h)
|
cols, rows := coverCells(b.Dx(), b.Dy(), w, h, c.aspectOfCells())
|
||||||
if cols < 1 || rows < 1 {
|
if cols < 1 || rows < 1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if c.drawGraphics(screen, x+w-cols, y, cols, rows) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if c.cells == nil || c.cols != cols || c.rows != rows {
|
if c.cells == nil || c.cols != cols || c.rows != rows {
|
||||||
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
|
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
|
||||||
}
|
}
|
||||||
@@ -285,6 +397,51 @@ func (c *coverView) Draw(screen tcell.Screen) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cropToBox returns the part of an image of the given bounds that has the same
|
||||||
|
// shape as a box of cols×rows cells: a few pixels are trimmed from both sides
|
||||||
|
// of the longer dimension (at most the fit tolerance), so the image fills the
|
||||||
|
// box exactly and the terminal does not letterbox it.
|
||||||
|
func cropToBox(b image.Rectangle, cols, rows int, cellAspect float64) image.Rectangle {
|
||||||
|
box := float64(cols) / (float64(rows) * cellAspect) // width / height of the box
|
||||||
|
shape := float64(b.Dx()) / float64(b.Dy())
|
||||||
|
if box < shape { // box is narrower: trim the sides
|
||||||
|
w := int(float64(b.Dy())*box + 0.5)
|
||||||
|
trim := (b.Dx() - w) / 2
|
||||||
|
return image.Rect(b.Min.X+trim, b.Min.Y, b.Min.X+trim+w, b.Max.Y)
|
||||||
|
}
|
||||||
|
h := int(float64(b.Dx())/box + 0.5) // box is wider: trim top and bottom
|
||||||
|
trim := (b.Dy() - h) / 2
|
||||||
|
return image.Rect(b.Min.X, b.Min.Y+trim, b.Max.X, b.Min.Y+trim+h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawGraphics shows the poster as a real image through the Kitty graphics
|
||||||
|
// protocol: the image is uploaded once and the cells get placeholder
|
||||||
|
// characters. It reports false when that is not possible (no protocol, no true
|
||||||
|
// color, too many rows or columns, or the terminal could not be written to), so
|
||||||
|
// the caller can draw block characters instead.
|
||||||
|
func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool {
|
||||||
|
if c.gfx == nil || c.gfx.settling() || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
id, err := c.gfx.show(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()), cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if c.gfx.debug != nil {
|
||||||
|
if drawn := [5]int{int(id), x, y, cols, rows}; drawn != c.lastDrawn {
|
||||||
|
c.lastDrawn = drawn
|
||||||
|
c.gfx.debug(fmt.Sprintf("draw: id %d at column %d, row %d over %dx%d cells", id, x, y, cols, rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for row := 0; row < rows; row++ {
|
||||||
|
for col := 0; col < cols; col++ {
|
||||||
|
ch, comb, style := placeholderCell(id, row, col)
|
||||||
|
screen.SetContent(x+col, y+row, ch, comb, style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// build renders the poster into cols×rows cells.
|
// build renders the poster into cols×rows cells.
|
||||||
func (c *coverView) build(cols, rows int) []styledCell {
|
func (c *coverView) build(cols, rows int) []styledCell {
|
||||||
st := c.style
|
st := c.style
|
||||||
@@ -301,8 +458,8 @@ func (c *coverView) build(cols, rows int) []styledCell {
|
|||||||
}
|
}
|
||||||
ch, fg, bg := st.bestCell(px)
|
ch, fg, bg := st.bestCell(px)
|
||||||
cells = append(cells, styledCell{ch,
|
cells = append(cells, styledCell{ch,
|
||||||
tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)),
|
notToken(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))),
|
||||||
tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))})
|
notToken(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cells
|
return cells
|
||||||
@@ -311,7 +468,7 @@ func (c *coverView) build(cols, rows int) []styledCell {
|
|||||||
// coverRef says where a poster comes from. The zero value means "no poster".
|
// coverRef says where a poster comes from. The zero value means "no poster".
|
||||||
type coverRef struct {
|
type coverRef struct {
|
||||||
key string // identifies the poster in the cache
|
key string // identifies the poster in the cache
|
||||||
fetch func(ctx context.Context) ([]byte, error)
|
fetch func(ctx context.Context, large bool) ([]byte, error) // large: for real images
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverFor builds the poster reference of a title from its image list.
|
// coverFor builds the poster reference of a title from its image list.
|
||||||
@@ -358,6 +515,8 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.waiting[ref.key] = []func(*image.RGBA, error){done}
|
c.waiting[ref.key] = []func(*image.RGBA, error){done}
|
||||||
|
maxHeight := a.coverMaxHeight()
|
||||||
|
large := a.gfx != nil // real images show the poster at full resolution
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
c.slots <- struct{}{}
|
c.slots <- struct{}{}
|
||||||
@@ -365,18 +524,23 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
var img *image.RGBA
|
var img *image.RGBA
|
||||||
data, err := ref.fetch(ctx)
|
data, err := ref.fetch(ctx, large)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
img, err = decodeCover(data)
|
img, err = decodeCover(data, maxHeight)
|
||||||
}
|
}
|
||||||
a.post(func() {
|
a.post(func() {
|
||||||
waiters := c.waiting[ref.key]
|
waiters := c.waiting[ref.key]
|
||||||
delete(c.waiting, ref.key)
|
delete(c.waiting, ref.key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.failed[ref.key] = time.Now()
|
c.failed[ref.key] = time.Now()
|
||||||
|
// Say why in the log: a poster that silently never shows up is hard to diagnose.
|
||||||
|
a.logs.add(levelWarn, "Poster not loaded ("+posterSource(ref.key)+"): "+err.Error())
|
||||||
} else {
|
} else {
|
||||||
if len(c.imgs) >= maxCoverCache {
|
if len(c.imgs) >= maxCoverCache {
|
||||||
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
|
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
|
||||||
|
if a.gfx != nil {
|
||||||
|
a.gfx.deleteAll() // the terminal's copies belong to the dropped images
|
||||||
|
}
|
||||||
}
|
}
|
||||||
c.imgs[ref.key] = img
|
c.imgs[ref.key] = img
|
||||||
}
|
}
|
||||||
@@ -386,3 +550,58 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
|||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// posterSource describes where a poster key points, for the log: the host and
|
||||||
|
// file name of a remote image, or the path on the Radarr/Sonarr instance.
|
||||||
|
func posterSource(key string) string {
|
||||||
|
if _, local, ok := strings.Cut(key, "|"); ok {
|
||||||
|
key = local
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(key); err == nil && u.Host != "" {
|
||||||
|
return u.Host + u.Path
|
||||||
|
}
|
||||||
|
path, _, _ := strings.Cut(key, "?")
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeBytes says what a blob of unknown data looks like, for error
|
||||||
|
// messages: a file Radarr/Sonarr stored as poster.jpg can be any format, or an
|
||||||
|
// error page.
|
||||||
|
func describeBytes(data []byte) string {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "empty"
|
||||||
|
}
|
||||||
|
head := data[:min(len(data), 16)]
|
||||||
|
switch {
|
||||||
|
case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||||
|
return "WebP"
|
||||||
|
case bytes.HasPrefix(data, []byte("GIF8")):
|
||||||
|
return "GIF"
|
||||||
|
case bytes.HasPrefix(data, []byte("\x89PNG")):
|
||||||
|
return "PNG"
|
||||||
|
case bytes.HasPrefix(data, []byte("\xff\xd8")):
|
||||||
|
return "JPEG"
|
||||||
|
case len(data) >= 12 && string(data[4:8]) == "ftyp":
|
||||||
|
return "AVIF/HEIC, " + strings.TrimSpace(string(data[8:12]))
|
||||||
|
case bytes.HasPrefix(data, []byte("BM")):
|
||||||
|
return "BMP"
|
||||||
|
case bytes.HasPrefix(data, []byte("II*\x00")) || bytes.HasPrefix(data, []byte("MM\x00*")):
|
||||||
|
return "TIFF"
|
||||||
|
case bytes.HasPrefix(bytes.TrimSpace(data), []byte("<")):
|
||||||
|
return "text, probably an HTML or SVG page: " + printable(bytes.TrimSpace(data), 40)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d bytes starting % x", len(data), head)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printable returns up to n bytes of data with control characters replaced.
|
||||||
|
func printable(data []byte, n int) string {
|
||||||
|
if len(data) > n {
|
||||||
|
data = data[:n]
|
||||||
|
}
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if r < 32 || r == 127 {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, string(data))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ func TestDecodeCover(t *testing.T) {
|
|||||||
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
|
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
img, err := decodeCover(buf.Bytes())
|
img, err := decodeCover(buf.Bytes(), storedCoverHeight)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -62,17 +66,17 @@ func TestDecodeCover(t *testing.T) {
|
|||||||
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
|
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if img, err := decodeCover(buf.Bytes()); err != nil || img.Bounds().Dy() != 30 {
|
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||||||
t.Errorf("small png kept as is: %v %v", img, err)
|
t.Errorf("small png kept as is: %v %v", img, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := decodeCover([]byte("<html>not an image</html>")); err == nil {
|
if _, err := decodeCover([]byte("<html>not an image</html>"), storedCoverHeight); err == nil {
|
||||||
t.Error("garbage must be rejected")
|
t.Error("garbage must be rejected")
|
||||||
}
|
}
|
||||||
// A header that promises a huge image is refused before decoding it.
|
// A header that promises a huge image is refused before decoding it.
|
||||||
var huge bytes.Buffer
|
var huge bytes.Buffer
|
||||||
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
|
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
|
||||||
if _, err := decodeCover(huge.Bytes()); err == nil {
|
if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil {
|
||||||
t.Error("oversized image must be rejected")
|
t.Error("oversized image must be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,11 +114,12 @@ func TestQuadCell(t *testing.T) {
|
|||||||
func TestCoverCells(t *testing.T) {
|
func TestCoverCells(t *testing.T) {
|
||||||
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
|
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
|
||||||
// width units tall, so 13 columns.
|
// width units tall, so 13 columns.
|
||||||
if c, r := coverCells(40, 60, 30, 10); c != 13 || r != 10 {
|
if c, r := coverCells(40, 60, 30, 10, 2); c != 13 || r != 10 {
|
||||||
t.Errorf("tall limit: %dx%d", c, r)
|
t.Errorf("tall limit: %dx%d", c, r)
|
||||||
}
|
}
|
||||||
// In a narrow box the width limits it.
|
// In a narrow box the width limits it, and the box keeps the poster's shape
|
||||||
if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 {
|
// (8×6 cells is exactly 2:3) instead of 10×7 which would leave bars.
|
||||||
|
if c, r := coverCells(40, 60, 10, 20, 2); c != 8 || r != 6 {
|
||||||
t.Errorf("narrow limit: %dx%d", c, r)
|
t.Errorf("narrow limit: %dx%d", c, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,7 @@ func TestCoverViewDraws(t *testing.T) {
|
|||||||
defer screen.Fini()
|
defer screen.Fini()
|
||||||
screen.SetSize(30, 10)
|
screen.SetSize(30, 10)
|
||||||
|
|
||||||
c := newCoverView(quadrantStyle)
|
c := newCoverView(quadrantStyle, nil, nil)
|
||||||
c.setImage(stripes(40, 60)) // red above blue
|
c.setImage(stripes(40, 60)) // red above blue
|
||||||
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
|
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
|
||||||
|
|
||||||
@@ -157,11 +162,11 @@ func TestCoverViewDraws(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
fetches := 0
|
var fetches atomic.Int32
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
png.Encode(&buf, stripes(10, 20))
|
png.Encode(&buf, stripes(10, 20))
|
||||||
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
ref := coverRef{key: "k", fetch: func(context.Context, bool) ([]byte, error) {
|
||||||
fetches++
|
fetches.Add(1)
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -172,8 +177,8 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
|||||||
if len(a.coverCache.waiting["k"]) != 2 {
|
if len(a.coverCache.waiting["k"]) != 2 {
|
||||||
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
|
||||||
}
|
}
|
||||||
if fetches > 1 {
|
if n := fetches.Load(); n > 1 {
|
||||||
t.Errorf("fetched %d times", fetches)
|
t.Errorf("fetched %d times", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +186,7 @@ func TestLoadCoverRemembersFailures(t *testing.T) {
|
|||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
a.coverCache.failed["bad"] = time.Now()
|
a.coverCache.failed["bad"] = time.Now()
|
||||||
var gotErr error
|
var gotErr error
|
||||||
a.loadCover(coverRef{key: "bad", fetch: func(context.Context) ([]byte, error) {
|
a.loadCover(coverRef{key: "bad", fetch: func(context.Context, bool) ([]byte, error) {
|
||||||
t.Error("a recent failure must not be retried")
|
t.Error("a recent failure must not be retried")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}}, func(_ *image.RGBA, err error) { gotErr = err })
|
}}, func(_ *image.RGBA, err error) { gotErr = err })
|
||||||
@@ -223,7 +228,39 @@ func TestSextantCell(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoverStyleForFontCheck(t *testing.T) {
|
||||||
|
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||||||
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
|
|
||||||
|
sextantFontAvailable = func() bool { return true }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"unknown terminal with a suitable font", map[string]string{"TERM": "xterm-256color"}, "sextant"},
|
||||||
|
{"over ssh the local fonts are unknown", map[string]string{"TERM": "xterm-256color", "SSH_CONNECTION": "1 2 3 4"}, "quadrant"},
|
||||||
|
{"over ssh with a tty", map[string]string{"SSH_TTY": "/dev/pts/1"}, "quadrant"},
|
||||||
|
} {
|
||||||
|
if got := coverStyleFor("auto", env(tc.env)).name; got != tc.want {
|
||||||
|
t.Errorf("%s: %s, want %s", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An explicit setting never asks fontconfig.
|
||||||
|
sextantFontAvailable = func() bool { t.Error("fontconfig consulted for an explicit style"); return true }
|
||||||
|
if coverStyleFor("quadrant", env(nil)).name != "quadrant" || coverStyleFor("sextant", env(nil)).name != "sextant" {
|
||||||
|
t.Error("explicit styles must win")
|
||||||
|
}
|
||||||
|
// No suitable font: quadrants.
|
||||||
|
sextantFontAvailable = func() bool { return false }
|
||||||
|
if got := coverStyleFor("auto", env(map[string]string{"TERM": "xterm-256color"})).name; got != "quadrant" {
|
||||||
|
t.Errorf("no font: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoverStyleFor(t *testing.T) {
|
func TestCoverStyleFor(t *testing.T) {
|
||||||
|
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||||||
|
sextantFontAvailable = func() bool { return false } // the table below is about the terminal checks
|
||||||
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
setting string
|
setting string
|
||||||
@@ -238,9 +275,86 @@ func TestCoverStyleFor(t *testing.T) {
|
|||||||
{"auto", nil, "quadrant"},
|
{"auto", nil, "quadrant"},
|
||||||
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
|
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
|
||||||
{"sextant", nil, "sextant"},
|
{"sextant", nil, "sextant"},
|
||||||
|
{"auto", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"}, "cells"},
|
||||||
|
{"cells", map[string]string{"TERM": "xterm-kitty"}, "cells"},
|
||||||
|
{"quadrant", map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "quadrant"},
|
||||||
} {
|
} {
|
||||||
if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want {
|
if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want {
|
||||||
t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want)
|
t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoverCellsKeepTheShape(t *testing.T) {
|
||||||
|
// For many poster shapes, cell shapes and boxes the chosen box must match
|
||||||
|
// the poster within the tolerance (or be the closest available).
|
||||||
|
for _, img := range [][2]int{{167, 250}, {500, 750}, {2, 3}, {1000, 1500}, {680, 1000}} {
|
||||||
|
for _, aspect := range []float64{1.9, 2, 2.1, 2.25} {
|
||||||
|
for w := 12; w <= 40; w += 7 {
|
||||||
|
for h := 8; h <= 30; h += 11 {
|
||||||
|
c, r := coverCells(img[0], img[1], w, h, aspect)
|
||||||
|
if c < 1 || r < 1 || c > w || r > h {
|
||||||
|
t.Fatalf("%v aspect %v in %dx%d: %dx%d out of bounds", img, aspect, w, h, c, r)
|
||||||
|
}
|
||||||
|
shape := float64(img[0]) / float64(img[1])
|
||||||
|
err := float64(c)/(float64(r)*aspect)/shape - 1
|
||||||
|
if err < 0 {
|
||||||
|
err = -err
|
||||||
|
}
|
||||||
|
if err > 0.10 { // the tolerance is 3%; tiny boxes may only get within a cell
|
||||||
|
t.Errorf("%v aspect %v in %dx%d: box %dx%d is %.0f%% off", img, aspect, w, h, c, r, err*100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCoverOtherFormats(t *testing.T) {
|
||||||
|
// A WebP stored under a .jpg name, as Sonarr keeps what the provider sends.
|
||||||
|
webp, _ := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==")
|
||||||
|
img, err := decodeCover(webp, storedCoverHeight)
|
||||||
|
if err != nil || img.Bounds().Dx() != 1 || img.Bounds().Dy() != 1 {
|
||||||
|
t.Fatalf("webp: %v %v", img, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
pal := image.NewPaletted(image.Rect(0, 0, 20, 30), []color.Color{color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}})
|
||||||
|
if err := gif.Encode(&buf, pal, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
|
||||||
|
t.Errorf("gif: %v %v", img, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeBytesAndTheErrorItGives(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", nil, "empty"},
|
||||||
|
{"avif", []byte("\x00\x00\x00\x1cftypavif\x00\x00"), "AVIF/HEIC, avif"},
|
||||||
|
{"html", []byte(" <html><body>Not Found</body></html>"), "HTML or SVG"},
|
||||||
|
{"bmp", []byte("BM\x00\x00\x00\x00"), "BMP"},
|
||||||
|
{"tiff", []byte("II*\x00\x08\x00"), "TIFF"},
|
||||||
|
{"binary", []byte{1, 2, 3, 4, 5}, "5 bytes starting 01 02 03 04 05"},
|
||||||
|
} {
|
||||||
|
if got := describeBytes(tc.data); !strings.Contains(got, tc.want) {
|
||||||
|
t.Errorf("%s: %q, want it to mention %q", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
_, err := decodeCover(tc.data, storedCoverHeight)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Errorf("%s: the error %v should say what the data is (%q)", tc.name, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCellsStyleIsFlat(t *testing.T) {
|
||||||
|
c := color.RGBA{10, 200, 30, 255}
|
||||||
|
ch, fg, bg := cellsStyle.bestCell([]color.RGBA{c})
|
||||||
|
if ch != ' ' || fg != c || bg != c {
|
||||||
|
t.Errorf("got %q %v %v", ch, fg, bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
@@ -14,6 +15,12 @@ const (
|
|||||||
minCoverRows = 6 // a smaller poster is not worth showing
|
minCoverRows = 6 // a smaller poster is not worth showing
|
||||||
minCoverCols = 8
|
minCoverCols = 8
|
||||||
minTextRows = 6 // the poster never takes these rows from the text
|
minTextRows = 6 // the poster never takes these rows from the text
|
||||||
|
|
||||||
|
// posterSettle: when the selection moves again within this time (a held
|
||||||
|
// arrow key), the poster waits until the selection rests that long. Every
|
||||||
|
// poster rewrites all of its cells, which on a huge terminal (zoomed far
|
||||||
|
// out) is more output than the terminal can draw at key-repeat speed.
|
||||||
|
posterSettle = 120 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// detailPane shows the details of one title as one flowing text with the
|
// detailPane shows the details of one title as one flowing text with the
|
||||||
@@ -27,8 +34,11 @@ type detailPane struct {
|
|||||||
cover *coverView
|
cover *coverView
|
||||||
|
|
||||||
detail detailText
|
detail detailText
|
||||||
|
lastRef coverRef // the poster of the current detail, to fetch it again after a style change
|
||||||
want string // key of the poster that should be shown
|
want string // key of the poster that should be shown
|
||||||
hasCover bool // a poster (or its loading note) is part of the content
|
hasCover bool // a poster (or its loading note) is part of the content
|
||||||
|
lastShow time.Time // when the shown title last changed
|
||||||
|
settle *time.Timer // shows the poster once the selection rests
|
||||||
|
|
||||||
// What the text was last wrapped for.
|
// What the text was last wrapped for.
|
||||||
laidOut struct {
|
laidOut struct {
|
||||||
@@ -43,23 +53,51 @@ func newDetailPane(app *App) *detailPane {
|
|||||||
Flex: tview.NewFlex(),
|
Flex: tview.NewFlex(),
|
||||||
app: app,
|
app: app,
|
||||||
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
|
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
|
||||||
cover: newCoverView(app.coverStyle),
|
cover: newCoverView(app.coverStyle, app.gfx, func() float64 { return app.cellAspect }),
|
||||||
}
|
}
|
||||||
d.AddItem(d.text, 0, 1, false)
|
d.AddItem(d.text, 0, 1, false)
|
||||||
d.SetBorder(true).SetTitle(" Details ")
|
d.SetBorder(true).SetTitle(" Details ")
|
||||||
|
app.panes = append(app.panes, d)
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show displays the text and, if there is one, the poster.
|
// Show displays the text and, if there is one, the poster.
|
||||||
func (d *detailPane) Show(text detailText, ref coverRef) {
|
func (d *detailPane) Show(text detailText, ref coverRef) {
|
||||||
d.detail = text
|
d.detail, d.lastRef = text, ref
|
||||||
d.laidOut.valid = false
|
d.laidOut.valid = false
|
||||||
if !d.app.covers || ref.fetch == nil {
|
if !d.app.covers || ref.fetch == nil {
|
||||||
d.want, d.hasCover = "", false
|
d.want, d.hasCover = "", false
|
||||||
d.cover.setImage(nil)
|
d.cover.setImage(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
moving := ref.key != d.want && now.Sub(d.lastShow) < posterSettle
|
||||||
|
if ref.key != d.want {
|
||||||
|
d.lastShow = now
|
||||||
|
}
|
||||||
d.want, d.hasCover = ref.key, true
|
d.want, d.hasCover = ref.key, true
|
||||||
|
if d.settle != nil {
|
||||||
|
d.settle.Stop()
|
||||||
|
}
|
||||||
|
if moving {
|
||||||
|
// Keep the poster's place (so the text does not reflow) but leave it
|
||||||
|
// empty until the selection stops.
|
||||||
|
d.cover.setNote("")
|
||||||
|
want := ref.key
|
||||||
|
d.settle = time.AfterFunc(posterSettle, func() {
|
||||||
|
d.app.post(func() {
|
||||||
|
if d.want == want {
|
||||||
|
d.loadPoster(ref)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.loadPoster(ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPoster shows the poster of ref, fetching it first if needed.
|
||||||
|
func (d *detailPane) loadPoster(ref coverRef) {
|
||||||
d.cover.setNote("loading cover…")
|
d.cover.setNote("loading cover…")
|
||||||
want := ref.key
|
want := ref.key
|
||||||
d.app.loadCover(ref, func(img *image.RGBA, err error) {
|
d.app.loadCover(ref, func(img *image.RGBA, err error) {
|
||||||
@@ -85,7 +123,7 @@ func (d *detailPane) Draw(screen tcell.Screen) {
|
|||||||
if d.hasCover {
|
if d.hasCover {
|
||||||
aw, ah := d.cover.aspect()
|
aw, ah := d.cover.aspect()
|
||||||
// At most 40% of the width, and the text keeps some rows below.
|
// At most 40% of the width, and the text keeps some rows below.
|
||||||
d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows)
|
d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows, d.cover.aspectOfCells())
|
||||||
if d.coverH < minCoverRows || d.coverW < minCoverCols {
|
if d.coverH < minCoverRows || d.coverW < minCoverCols {
|
||||||
d.coverW, d.coverH = 0, 0
|
d.coverW, d.coverH = 0, 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
@@ -107,3 +110,30 @@ func TestDetailPaneFlowsAroundPoster(t *testing.T) {
|
|||||||
t.Error("a short pane must drop the poster")
|
t.Error("a short pane must drop the poster")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 20, 30))
|
||||||
|
a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img
|
||||||
|
ref := func(key string) coverRef {
|
||||||
|
return coverRef{key: key, fetch: func(context.Context, bool) ([]byte, error) { return nil, nil }}
|
||||||
|
}
|
||||||
|
d := newDetailPane(a)
|
||||||
|
|
||||||
|
d.Show(detailText{head: "One"}, ref("one"))
|
||||||
|
if d.cover.img != img {
|
||||||
|
t.Fatal("the first selection should show its poster at once")
|
||||||
|
}
|
||||||
|
d.Show(detailText{head: "Two"}, ref("two"))
|
||||||
|
if d.cover.img != nil || !d.hasCover {
|
||||||
|
t.Errorf("a quick move should keep the poster's place empty: img %v, hasCover %v", d.cover.img != nil, d.hasCover)
|
||||||
|
}
|
||||||
|
d.settle.Stop() // the test has no event loop to deliver it
|
||||||
|
|
||||||
|
// The same title again (a refresh) is not a move.
|
||||||
|
d.lastShow = time.Now().Add(-time.Second)
|
||||||
|
d.Show(detailText{head: "Two"}, ref("two"))
|
||||||
|
if d.cover.img != img {
|
||||||
|
t.Error("a selection at rest should show its poster")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type downloadsView struct {
|
|||||||
sab *sabnzbd.Client
|
sab *sabnzbd.Client
|
||||||
sources []arrSource
|
sources []arrSource
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
|
sabUnset bool // SABnzbd is not set up: its lists say so instead of "empty"
|
||||||
|
|
||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
summary *tview.TextView
|
summary *tview.TextView
|
||||||
@@ -134,6 +135,14 @@ func (v *downloadsView) nextPanel(dir int) {
|
|||||||
v.app.app.SetFocus(v.panels[0])
|
v.app.app.SetFocus(v.panels[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setInterval changes the polling interval, also while polling.
|
||||||
|
func (v *downloadsView) setInterval(d time.Duration) {
|
||||||
|
v.interval = d
|
||||||
|
if v.cancel != nil {
|
||||||
|
v.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// activate starts polling; called when the view becomes visible.
|
// activate starts polling; called when the view becomes visible.
|
||||||
func (v *downloadsView) activate() {
|
func (v *downloadsView) activate() {
|
||||||
v.deactivate()
|
v.deactivate()
|
||||||
@@ -202,6 +211,12 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
|||||||
|
|
||||||
v.app.post(func() {
|
v.app.post(func() {
|
||||||
v.app.SetService("SABnzbd", "", qerr)
|
v.app.SetService("SABnzbd", "", qerr)
|
||||||
|
if unset := notConfigured(qerr); unset != v.sabUnset {
|
||||||
|
v.sabUnset = unset
|
||||||
|
v.sabQueue, v.slots, v.done = sabnzbd.Queue{}, nil, nil
|
||||||
|
v.renderQueue()
|
||||||
|
v.renderHistory()
|
||||||
|
}
|
||||||
if qerr == nil {
|
if qerr == nil {
|
||||||
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
|
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
|
||||||
v.renderQueue()
|
v.renderQueue()
|
||||||
@@ -222,6 +237,14 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sabPlaceholder is what an empty SABnzbd list says.
|
||||||
|
func (v *downloadsView) sabPlaceholder(empty string) string {
|
||||||
|
if v.sabUnset {
|
||||||
|
return "SABnzbd is not configured (Config view, F6)"
|
||||||
|
}
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
|
||||||
func (v *downloadsView) rebuildArrRows() {
|
func (v *downloadsView) rebuildArrRows() {
|
||||||
v.arrRows = v.arrRows[:0]
|
v.arrRows = v.arrRows[:0]
|
||||||
for _, s := range v.sources {
|
for _, s := range v.sources {
|
||||||
@@ -268,7 +291,7 @@ func (v *downloadsView) renderSummary() {
|
|||||||
// header writes the header row; column expand grows to fill the width.
|
// header writes the header row; column expand grows to fill the width.
|
||||||
func header(t *tview.Table, expand int, cols ...string) {
|
func header(t *tview.Table, expand int, cols ...string) {
|
||||||
for i, c := range cols {
|
for i, c := range cols {
|
||||||
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
|
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
|
||||||
if i == expand {
|
if i == expand {
|
||||||
cell.SetExpansion(1)
|
cell.SetExpansion(1)
|
||||||
}
|
}
|
||||||
@@ -323,7 +346,7 @@ func (v *downloadsView) renderQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fill(v.queue, 0, rows)
|
fill(v.queue, 0, rows)
|
||||||
restoreSelection(v.queue, len(rows), selRow, "Queue is empty")
|
restoreSelection(v.queue, len(rows), selRow, v.sabPlaceholder("Queue is empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *downloadsView) renderArr() {
|
func (v *downloadsView) renderArr() {
|
||||||
@@ -406,7 +429,7 @@ func (v *downloadsView) renderHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fill(v.history, 0, rows)
|
fill(v.history, 0, rows)
|
||||||
restoreSelection(v.history, len(rows), selRow, "No history")
|
restoreSelection(v.history, len(rows), selRow, v.sabPlaceholder("No history"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// act runs an action off the UI goroutine, reports the outcome and refreshes.
|
// act runs an action off the UI goroutine, reports the outcome and refreshes.
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ type seriesView struct {
|
|||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
header *tview.TextView
|
header *tview.TextView
|
||||||
table *tview.Table
|
table *tview.Table
|
||||||
detail *tview.TextView
|
detail *detailPane // poster, series and the selected season or episode
|
||||||
|
cover coverRef
|
||||||
|
|
||||||
episodes []sonarr.Episode
|
episodes []sonarr.Episode
|
||||||
expanded map[int]bool
|
expanded map[int]bool
|
||||||
@@ -51,14 +52,16 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
|||||||
app: a, c: c, series: s, onChange: onChange,
|
app: a, c: c, series: s, onChange: onChange,
|
||||||
header: tview.NewTextView().SetDynamicColors(true),
|
header: tview.NewTextView().SetDynamicColors(true),
|
||||||
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
||||||
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
detail: newDetailPane(a),
|
||||||
expanded: map[int]bool{},
|
expanded: map[int]bool{},
|
||||||
}
|
}
|
||||||
v.table.SetBorder(true)
|
v.table.SetBorder(true)
|
||||||
|
v.cover = coverFor(c.Client, s.Images, true)
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
AddItem(v.table, 0, 1, true).
|
AddItem(tview.NewFlex().
|
||||||
AddItem(v.detail, 2, 0, false)
|
AddItem(v.table, 0, 3, true).
|
||||||
|
AddItem(v.detail, 0, 2, false), 0, 1, true)
|
||||||
|
|
||||||
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
||||||
v.table.SetSelectedFunc(func(int, int) { v.enter() })
|
v.table.SetSelectedFunc(func(int, int) { v.enter() })
|
||||||
@@ -86,7 +89,8 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
v.render()
|
v.render()
|
||||||
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", nil)
|
// The pane goes away with the view; stop restyling it then.
|
||||||
|
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", func() { a.forgetPane(v.detail) })
|
||||||
v.reload()
|
v.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,14 +247,54 @@ func episodeStatus(e sonarr.Episode, today string) string {
|
|||||||
|
|
||||||
func (v *seriesView) renderDetail() {
|
func (v *seriesView) renderDetail() {
|
||||||
r, ok := v.selected()
|
r, ok := v.selected()
|
||||||
switch {
|
if !ok {
|
||||||
case !ok:
|
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, ""), v.cover)
|
||||||
v.detail.SetText("")
|
return
|
||||||
case r.ep == nil:
|
|
||||||
v.detail.SetText("[gray]Enter expands the season · t toggles monitoring for the whole season · i searches for releases")
|
|
||||||
default:
|
|
||||||
v.detail.SetText(fmt.Sprintf("[gray]S%02dE%02d[-] %s", r.season, r.ep.EpisodeNumber, tview.Escape(r.ep.Title)))
|
|
||||||
}
|
}
|
||||||
|
var sum seasonSummary
|
||||||
|
for _, se := range summarizeSeasons(v.episodes) {
|
||||||
|
if se.number == r.season {
|
||||||
|
sum = se
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02")), v.cover)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesRowDetail is the detail text of the drill-down: the series, then the
|
||||||
|
// selected season (ep == nil) or episode. The plot is the episode's, or the
|
||||||
|
// series' while a season is selected.
|
||||||
|
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string) detailText {
|
||||||
|
head := "[::b]" + tview.Escape(s.Title) + "[::-]"
|
||||||
|
if s.Year > 0 {
|
||||||
|
head += fmt.Sprintf(" (%d)", s.Year)
|
||||||
|
}
|
||||||
|
head += "\n"
|
||||||
|
if s.Network != "" {
|
||||||
|
head += tview.Escape(s.Network) + " · "
|
||||||
|
}
|
||||||
|
head += tview.Escape(s.Status) + "\n\n"
|
||||||
|
|
||||||
|
plot := tview.Escape(s.Overview)
|
||||||
|
switch {
|
||||||
|
case ep == nil && sum.total == 0:
|
||||||
|
// Nothing selected yet (still loading).
|
||||||
|
case ep == nil:
|
||||||
|
head += "[::b]" + seasonLabel(season) + "[::-]\n"
|
||||||
|
head += fmt.Sprintf("%d of %d episodes downloaded\n", sum.have, sum.total)
|
||||||
|
head += monitoredLine(seasonMonitored)
|
||||||
|
head += "[gray]Enter expands · t monitors the whole season · i searches for releases[-]\n"
|
||||||
|
default:
|
||||||
|
head += fmt.Sprintf("[::b]S%02dE%02d %s[::-]\n", ep.SeasonNumber, ep.EpisodeNumber, tview.Escape(ep.Title))
|
||||||
|
if ep.AirDate != "" {
|
||||||
|
head += "Aired: " + ep.AirDate + "\n"
|
||||||
|
}
|
||||||
|
head += "Status: " + episodeStatus(*ep, today) + "[-]\n"
|
||||||
|
head += monitoredLine(ep.Monitored)
|
||||||
|
if ep.Overview != "" {
|
||||||
|
plot = tview.Escape(ep.Overview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detailText{head: head, plot: plot}
|
||||||
}
|
}
|
||||||
|
|
||||||
// enter expands or collapses a season, or opens the release search for an episode.
|
// enter expands or collapses a season, or opens the release search for an episode.
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ import (
|
|||||||
// cancel is called on Esc, except when Esc only closes an open dropdown list
|
// cancel is called on Esc, except when Esc only closes an open dropdown list
|
||||||
// (tview would otherwise treat that as cancelling the whole form).
|
// (tview would otherwise treat that as cancelling the whole form).
|
||||||
func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
||||||
|
// Form.SetFocus moves the focus between its items without telling the
|
||||||
|
// application, which then still holds the item that was left. Opening a
|
||||||
|
// dialog would only blur that one: the item actually in use kept its
|
||||||
|
// cursor on screen and, because the form still had the focus, took the
|
||||||
|
// dialog's keys, and closing the dialog returned to the wrong item. So the
|
||||||
|
// application is told after every move.
|
||||||
|
move := func(index int) {
|
||||||
|
f.SetFocus(index)
|
||||||
|
if syncFocus != nil {
|
||||||
|
syncFocus(f) // the form hands the focus on to the item at index
|
||||||
|
}
|
||||||
|
}
|
||||||
var b browser
|
var b browser
|
||||||
var closingList bool
|
var closingList bool
|
||||||
f.SetCancelFunc(func() {
|
f.SetCancelFunc(func() {
|
||||||
@@ -44,16 +56,37 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
|||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
b.dd = nil
|
b.dd = nil
|
||||||
|
if button >= 0 && ev.Key() == tcell.KeyRune && ev.Rune() == ' ' {
|
||||||
|
return tcell.NewEventKey(tcell.KeyEnter, 0, 0) // Space presses a button too
|
||||||
|
}
|
||||||
total := f.GetFormItemCount() + f.GetButtonCount()
|
total := f.GetFormItemCount() + f.GetButtonCount()
|
||||||
pos := item
|
pos := item
|
||||||
if button >= 0 {
|
if button >= 0 {
|
||||||
pos = f.GetFormItemCount() + button
|
pos = f.GetFormItemCount() + button
|
||||||
}
|
}
|
||||||
switch ev.Key() {
|
switch ev.Key() {
|
||||||
case tcell.KeyDown:
|
case tcell.KeyDown, tcell.KeyUp:
|
||||||
f.SetFocus(min(pos+1, total-1))
|
dir := 1
|
||||||
case tcell.KeyUp:
|
if ev.Key() == tcell.KeyUp {
|
||||||
f.SetFocus(max(pos-1, 0))
|
dir = -1
|
||||||
|
}
|
||||||
|
next := neighbour(f, pos, dir)
|
||||||
|
if next == pos { // nothing further in this direction
|
||||||
|
if hooks != nil && hooks.edge != nil && hooks.edge(dir > 0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
move(next)
|
||||||
|
case tcell.KeyTab, tcell.KeyBacktab:
|
||||||
|
dir := 1
|
||||||
|
if ev.Key() == tcell.KeyBacktab {
|
||||||
|
dir = -1
|
||||||
|
}
|
||||||
|
if hooks != nil && hooks.tab != nil && hooks.tab(dir) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ev
|
||||||
case tcell.KeyLeft, tcell.KeyRight:
|
case tcell.KeyLeft, tcell.KeyRight:
|
||||||
step := 1
|
step := 1
|
||||||
if ev.Key() == tcell.KeyLeft {
|
if ev.Key() == tcell.KeyLeft {
|
||||||
@@ -65,7 +98,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
|||||||
cur, _ := dd.GetCurrentOption()
|
cur, _ := dd.GetCurrentOption()
|
||||||
dd.SetCurrentOption((cur + step + n) % n)
|
dd.SetCurrentOption((cur + step + n) % n)
|
||||||
case button >= 0:
|
case button >= 0:
|
||||||
f.SetFocus(min(max(pos+step, f.GetFormItemCount()), total-1))
|
move(min(max(pos+step, f.GetFormItemCount()), total-1))
|
||||||
default:
|
default:
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
@@ -76,12 +109,20 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syncFocus gives a primitive the application's focus; New sets it.
|
||||||
|
var syncFocus func(p tview.Primitive)
|
||||||
|
|
||||||
func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) {
|
func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) {
|
||||||
if item < 0 {
|
if item < 0 {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
dd, ok := f.GetFormItem(item).(*tview.DropDown)
|
switch it := f.GetFormItem(item).(type) {
|
||||||
return dd, ok
|
case *tview.DropDown:
|
||||||
|
return it, true
|
||||||
|
case *serverChoice: // a dropdown with extras; the keys work the same
|
||||||
|
return it.DropDown, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindSearchNow keeps a "Search now" checkbox consistent with whether a search
|
// bindSearchNow keeps a "Search now" checkbox consistent with whether a search
|
||||||
@@ -109,7 +150,7 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
|
|||||||
box.SetChecked(want)
|
box.SetChecked(want)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
box.SetLabelColor(tcell.ColorGray)
|
box.SetLabelColor(named("gray"))
|
||||||
box.SetChecked(false)
|
box.SetChecked(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +159,11 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
|
|||||||
type browseHooks struct {
|
type browseHooks struct {
|
||||||
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
|
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
|
||||||
abort func() // the list was closed without selecting
|
abort func() // the list was closed without selecting
|
||||||
|
// edge is called when Down is pressed on the last field or Up on the first;
|
||||||
|
// it returns true if it moved the focus elsewhere.
|
||||||
|
edge func(down bool) bool
|
||||||
|
// tab is called for Tab (dir 1) and Shift+Tab (dir -1); true if handled.
|
||||||
|
tab func(dir int) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// browser mirrors the highlight of an open dropdown list, which tview does
|
// browser mirrors the highlight of an open dropdown list, which tview does
|
||||||
@@ -160,3 +206,135 @@ func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks)
|
|||||||
b.known = false // paging, typed prefixes
|
b.known = false // paging, typed prefixes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skipsFocus marks form items that only decorate (section headings): the
|
||||||
|
// arrow keys pass over them.
|
||||||
|
type skipsFocus interface{ skipFocus() bool }
|
||||||
|
|
||||||
|
// neighbour returns the position of the next focusable item or button after
|
||||||
|
// pos in the given direction, or pos itself if there is none.
|
||||||
|
func neighbour(f *tview.Form, pos, dir int) int {
|
||||||
|
total := f.GetFormItemCount() + f.GetButtonCount()
|
||||||
|
for i := pos + dir; i >= 0 && i < total; i += dir {
|
||||||
|
if i < f.GetFormItemCount() {
|
||||||
|
if s, ok := f.GetFormItem(i).(skipsFocus); ok && s.skipFocus() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFocusable returns the position of the first item or button that can
|
||||||
|
// take the focus.
|
||||||
|
func firstFocusable(f *tview.Form) int {
|
||||||
|
if f.GetFormItemCount() == 0 {
|
||||||
|
return 0 // a form of buttons only
|
||||||
|
}
|
||||||
|
if s, ok := f.GetFormItem(0).(skipsFocus); ok && s.skipFocus() {
|
||||||
|
return neighbour(f, 0, 1)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// formHeading is a section heading inside a form: a bold title above the next
|
||||||
|
// group of fields. It never takes the focus.
|
||||||
|
type formHeading struct {
|
||||||
|
*tview.Box
|
||||||
|
title string
|
||||||
|
finished func(tcell.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFormHeading(title string) *formHeading {
|
||||||
|
return &formHeading{Box: tview.NewBox(), title: title}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) skipFocus() bool { return true }
|
||||||
|
|
||||||
|
func (h *formHeading) GetLabel() string { return "" }
|
||||||
|
|
||||||
|
func (h *formHeading) SetFormAttributes(int, tcell.Color, tcell.Color, tcell.Color, tcell.Color) tview.FormItem {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) GetFieldWidth() int { return 0 }
|
||||||
|
|
||||||
|
// GetFieldHeight is 2: an empty line above the title separates the sections.
|
||||||
|
func (h *formHeading) GetFieldHeight() int { return 2 }
|
||||||
|
|
||||||
|
func (h *formHeading) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
|
||||||
|
h.finished = handler
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) SetDisabled(bool) tview.FormItem { return h }
|
||||||
|
|
||||||
|
// Focus passes the focus on, like a disabled input field does, so tview's own
|
||||||
|
// Tab handling skips the heading too.
|
||||||
|
func (h *formHeading) Focus(delegate func(p tview.Primitive)) {
|
||||||
|
if h.finished != nil {
|
||||||
|
h.finished(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Box.Focus(delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) Draw(screen tcell.Screen) {
|
||||||
|
h.Box.DrawForSubclass(screen, h)
|
||||||
|
x, y, w, _ := h.GetInnerRect()
|
||||||
|
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, named("yellow"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// formAction is a button inside a form's list of fields, so it can sit between
|
||||||
|
// sections instead of at the end. Enter or Space presses it.
|
||||||
|
type formAction struct {
|
||||||
|
*tview.Button
|
||||||
|
labelWidth int
|
||||||
|
finished func(tcell.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFormAction(label string, pressed func()) *formAction {
|
||||||
|
a := &formAction{Button: tview.NewButton(label)}
|
||||||
|
a.SetSelectedFunc(pressed)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *formAction) GetLabel() string { return "" } // the button text is its own label
|
||||||
|
|
||||||
|
func (a *formAction) SetFormAttributes(labelWidth int, _, _, _, _ tcell.Color) tview.FormItem {
|
||||||
|
a.labelWidth = labelWidth
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *formAction) GetFieldWidth() int { return tview.TaggedStringWidth(a.GetLabel()) + 4 }
|
||||||
|
|
||||||
|
// GetFieldHeight is 2: an empty line above the button.
|
||||||
|
func (a *formAction) GetFieldHeight() int { return 2 }
|
||||||
|
|
||||||
|
func (a *formAction) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
|
||||||
|
a.SetExitFunc(handler) // Tab, Shift+Tab and Esc
|
||||||
|
a.finished = handler
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *formAction) SetDisabled(disabled bool) tview.FormItem {
|
||||||
|
a.Button.SetDisabled(disabled)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRect places the button under the field column, one line down, as wide as its text.
|
||||||
|
func (a *formAction) SetRect(x, y, width, height int) {
|
||||||
|
w := min(max(width-a.labelWidth, 0), tview.TaggedStringWidth(a.Button.GetLabel())+4)
|
||||||
|
a.Button.SetRect(x+a.labelWidth, y+1, w, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *formAction) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||||
|
inner := a.Button.InputHandler()
|
||||||
|
return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||||
|
if event.Key() == tcell.KeyRune && event.Rune() == ' ' {
|
||||||
|
event = tcell.NewEventKey(tcell.KeyEnter, 0, 0)
|
||||||
|
}
|
||||||
|
inner(event, setFocus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,16 @@ func TestArrowNavigation(t *testing.T) {
|
|||||||
if item, _ := f.GetFocusedItemIndex(); item != 1 {
|
if item, _ := f.GetFocusedItemIndex(); item != 1 {
|
||||||
t.Fatalf("Up from first button: item %d, want 1", item)
|
t.Fatalf("Up from first button: item %d, want 1", item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Space on a button is Enter; on a checkbox it stays Space.
|
||||||
|
send(tcell.KeyDown)
|
||||||
|
if got := cap(tcell.NewEventKey(tcell.KeyRune, ' ', 0)); got == nil || got.Key() != tcell.KeyEnter {
|
||||||
|
t.Errorf("Space on a button: %v", got)
|
||||||
|
}
|
||||||
|
send(tcell.KeyUp)
|
||||||
|
if got := cap(tcell.NewEventKey(tcell.KeyRune, ' ', 0)); got == nil || got.Key() != tcell.KeyRune {
|
||||||
|
t.Errorf("Space on a checkbox: %v", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchNowFollowsMonitored(t *testing.T) {
|
func TestSearchNowFollowsMonitored(t *testing.T) {
|
||||||
|
|||||||
@@ -1,77 +1,149 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
)
|
)
|
||||||
|
|
||||||
const helpText = `[yellow]Global[-]
|
// helpItem is one line of the key reference: the keys and what they do.
|
||||||
F1-F5 switch view (also from the release and episode lists)
|
type helpItem struct{ keys, desc string }
|
||||||
Tab, Shift+Tab switch panel within a view
|
|
||||||
?, F9 this help (F9 works while typing)
|
|
||||||
q, Ctrl+C quit
|
|
||||||
Esc close dialog / go back
|
|
||||||
|
|
||||||
[yellow]Movies and Series[-]
|
// helpSection is a titled group of items, with an optional note above them.
|
||||||
/ focus the search box
|
type helpSection struct {
|
||||||
Enter, a add the selected title (dialog: ↑↓ move, ←→ change a
|
title string
|
||||||
dropdown, Space toggles, Enter/Space opens a list)
|
note string
|
||||||
i interactive release search (library titles)
|
items []helpItem
|
||||||
|
}
|
||||||
|
|
||||||
[yellow]Library[-]
|
var helpSections = []helpSection{
|
||||||
Tab switch between the movies and series lists
|
{title: "Global", items: []helpItem{
|
||||||
f cycle filter (all, missing, downloaded, monitored, unmonitored);
|
{"F1-F7", "switch view (also from the release and episode lists)"},
|
||||||
every list has its own filters
|
{"Tab, Shift+Tab", "switch panel within a view"},
|
||||||
F filter by file properties: quality, codec (x265, x264, AV1),
|
{"F9", "this help (works everywhere, also while typing)"},
|
||||||
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language.
|
{"Ctrl+C", "quit"},
|
||||||
Changes apply live behind the dialog, also while you move through an
|
{"Esc", "close a dialog / go back"},
|
||||||
open list; Esc undoes them. Options that would leave no
|
}},
|
||||||
results are not offered.
|
{title: "Movies and Series", items: []helpItem{
|
||||||
Series are read on demand.
|
{"/", "focus the search box"},
|
||||||
/ text filter for the focused list only; each list keeps its
|
{"Enter, a", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"},
|
||||||
own text (Enter or Esc returns to the list)
|
{"i", "interactive release search (library titles)"},
|
||||||
t toggle monitored
|
}},
|
||||||
s automatic search (series: all missing, asks first)
|
{title: "Library", items: []helpItem{
|
||||||
i, Enter movie: release search; series: seasons and episodes
|
{"Tab", "switch between the movies and series lists"},
|
||||||
r reload
|
{"f", "cycle the status filter (all, missing, downloaded, monitored, unmonitored); every list has its own filters"},
|
||||||
|
{"F", "filter by file properties: quality, codec (x265, x264, AV1), HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. Changes apply live behind the dialog, also while you move through an open list; Esc undoes them. Options that would leave no results are not offered. Series files are read on demand."},
|
||||||
|
{"/", "text filter for the focused list only; each list keeps its own text (Enter or Esc returns to the list)"},
|
||||||
|
{"t", "toggle monitored"},
|
||||||
|
{"d", "remove from the library (files are kept unless you tick \"Delete files\")"},
|
||||||
|
{"s", "automatic search (series: all missing, asks first)"},
|
||||||
|
{"i, Enter", "movie: release search; series: seasons and episodes"},
|
||||||
|
{"r", "reload"},
|
||||||
|
}},
|
||||||
|
{title: "Calendar (F5)", items: []helpItem{
|
||||||
|
{"[ ]", "move the two-week window back / forward by a week"},
|
||||||
|
{"t", "back to today"},
|
||||||
|
{"u", "include unmonitored items"},
|
||||||
|
{"Enter, i", "release search for the selected movie / episode"},
|
||||||
|
{"s", "automatic search"},
|
||||||
|
{"r", "reload"},
|
||||||
|
}},
|
||||||
|
{title: "Seasons and episodes", items: []helpItem{
|
||||||
|
{"Enter", "season: expand / collapse; episode: release search"},
|
||||||
|
{"i, s, t", "release search, automatic search, toggle monitored (on a season they apply to the whole season)"},
|
||||||
|
}},
|
||||||
|
{title: "Release list", items: []helpItem{
|
||||||
|
{"Enter, g", "grab the selected release"},
|
||||||
|
{"s", "cycle the sort (best, size, age)"},
|
||||||
|
{"f", "hide rejected releases"},
|
||||||
|
{"F", "filter by quality, codec, HDR / DV, audio, language (read from the release title, quality and custom formats)"},
|
||||||
|
}},
|
||||||
|
{title: "Downloads", items: []helpItem{
|
||||||
|
{"p", "pause / resume the SABnzbd queue"},
|
||||||
|
{"d", "delete the selected job or entry"},
|
||||||
|
{"b", "remove from the Radarr/Sonarr queue and blocklist"},
|
||||||
|
{"t", "retry a failed download (history)"},
|
||||||
|
{"r", "refresh now"},
|
||||||
|
}},
|
||||||
|
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, theme, notifications.", items: []helpItem{
|
||||||
|
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
|
||||||
|
{"Tab", "jump between the fields and Save / Revert"},
|
||||||
|
{"Space", "toggle a checkbox; press a button"},
|
||||||
|
{"←→, Enter", "the default quality profile and root folder: pick from the lists the server has (loaded with the address and key in the fields)"},
|
||||||
|
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
|
||||||
|
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
|
||||||
|
}},
|
||||||
|
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
|
||||||
|
{"↑↓, PgUp, PgDn", "scroll"},
|
||||||
|
{"G", "follow new entries"},
|
||||||
|
{"c", "clear the log"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
[yellow]Seasons and episodes[-]
|
const helpKeyWidth = 16 // columns for the keys, before the descriptions
|
||||||
Enter season: expand/collapse; episode: release search
|
|
||||||
i, s, t release search, automatic search, toggle monitored
|
|
||||||
(on a season they apply to the whole season)
|
|
||||||
|
|
||||||
[yellow]Release list[-]
|
// renderHelp lays the key reference out for a text area of the given width:
|
||||||
Enter, g grab the selected release
|
// descriptions that do not fit wrap under themselves, not under the keys.
|
||||||
s cycle sort (best, size, age)
|
func renderHelp(width int) string {
|
||||||
f hide rejected releases
|
var b strings.Builder
|
||||||
F filter by quality, codec, HDR / DV, audio, language (read from
|
descWidth := max(width-2-helpKeyWidth, 20)
|
||||||
the release title, quality and custom formats)
|
for i, sec := range helpSections {
|
||||||
|
if i > 0 {
|
||||||
[yellow]Downloads[-]
|
b.WriteByte('\n')
|
||||||
p pause / resume the SABnzbd queue
|
}
|
||||||
d delete the selected job or entry
|
b.WriteString("[yellow]" + sec.title + "[-]\n")
|
||||||
b remove from arr queue + blocklist
|
if sec.note != "" {
|
||||||
t retry a failed download (history)
|
for _, l := range flowWrap(tview.Escape(sec.note), width-2, width-2, 0) {
|
||||||
r refresh now
|
b.WriteString(" " + l + "\n")
|
||||||
|
}
|
||||||
[yellow]Log (F5)[-]
|
}
|
||||||
Popups fade away by themselves; everything they said is kept here.
|
for _, it := range sec.items {
|
||||||
↑↓, PgUp/PgDn scroll G follow new entries c clear
|
keys := tview.Escape(it.keys)
|
||||||
|
pad := strings.Repeat(" ", max(helpKeyWidth-tview.TaggedStringWidth(keys), 1))
|
||||||
[yellow]Connections[-]
|
lines := flowWrap(tview.Escape(it.desc), descWidth, descWidth, 0)
|
||||||
`
|
for j, l := range lines {
|
||||||
|
if j == 0 {
|
||||||
|
b.WriteString(" " + keys + pad + l + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(strings.Repeat(" ", 2+helpKeyWidth) + l + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// showHelp opens the key reference together with the connection state.
|
// showHelp opens the key reference together with the connection state.
|
||||||
func (a *App) showHelp() {
|
func (a *App) showHelp() {
|
||||||
tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true)
|
screenW, screenH := 100, 40
|
||||||
|
if a.screen != nil {
|
||||||
|
screenW, screenH = a.screen.Size()
|
||||||
|
}
|
||||||
|
width := min(100, screenW-4)
|
||||||
|
textWidth := width - 3 // border and a column of air
|
||||||
|
text := renderHelp(textWidth) + "\n[yellow]Connections[-]\n"
|
||||||
|
for _, l := range strings.Split(strings.TrimRight(a.serviceSummary(), "\n"), "\n") {
|
||||||
|
for j, w := range flowWrap(l, textWidth-2, textWidth-2, 0) {
|
||||||
|
if j == 0 {
|
||||||
|
text += " " + w + "\n"
|
||||||
|
} else {
|
||||||
|
text += " " + w + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true).SetWrap(false)
|
||||||
tv.SetBorder(true).SetTitle(" Help — Esc to close ")
|
tv.SetBorder(true).SetTitle(" Help — Esc to close ")
|
||||||
tv.SetText(helpText + a.serviceSummary())
|
tv.SetBorderPadding(0, 0, 1, 0)
|
||||||
|
tv.SetText(text)
|
||||||
tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
||||||
if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) {
|
if ev.Key() == tcell.KeyEsc {
|
||||||
a.closeModal()
|
a.closeModal()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return ev
|
return ev
|
||||||
})
|
})
|
||||||
a.openModal(tv, 80, 40)
|
height := min(screenH-2, strings.Count(text, "\n")+2)
|
||||||
|
a.openModalHints(tv, width, height, "↑↓ PgUp PgDn scroll · Esc close")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderHelpFitsAndAligns(t *testing.T) {
|
||||||
|
for _, width := range []int{40, 60, 76, 96} {
|
||||||
|
text := renderHelp(width)
|
||||||
|
var inItem bool
|
||||||
|
for i, line := range strings.Split(text, "\n") {
|
||||||
|
if w := tview.TaggedStringWidth(line); w > width {
|
||||||
|
t.Errorf("width %d, line %d is %d columns wide: %q", width, i, w, line)
|
||||||
|
}
|
||||||
|
plain := plainText(line)
|
||||||
|
// Descriptions wrap under themselves: a continuation line starts with
|
||||||
|
// spaces up to the description column, never at the left edge.
|
||||||
|
switch {
|
||||||
|
case plain == "":
|
||||||
|
inItem = false
|
||||||
|
case !strings.HasPrefix(plain, " "):
|
||||||
|
inItem = false // a section title
|
||||||
|
case strings.HasPrefix(plain, strings.Repeat(" ", 2+helpKeyWidth)) && strings.TrimSpace(plain) != "":
|
||||||
|
if !inItem {
|
||||||
|
t.Errorf("width %d: continuation without an item before it: %q", width, plain)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
inItem = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nothing is lost.
|
||||||
|
for _, sec := range helpSections {
|
||||||
|
if !strings.Contains(text, sec.title) {
|
||||||
|
t.Errorf("width %d: section %q missing", width, sec.title)
|
||||||
|
}
|
||||||
|
for _, it := range sec.items {
|
||||||
|
first := strings.Fields(it.desc)[0]
|
||||||
|
if !strings.Contains(plainText(text), first) {
|
||||||
|
t.Errorf("width %d: description of %q lost", width, it.keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderHelpKeepsEveryWord(t *testing.T) {
|
||||||
|
desc := "Changes apply live behind the dialog, also while you move through an open list"
|
||||||
|
words := strings.Fields(desc)
|
||||||
|
text := plainText(renderHelp(50))
|
||||||
|
// Join the wrapped lines of the long Library "F" description and look for every word in order.
|
||||||
|
pos := 0
|
||||||
|
for _, w := range words {
|
||||||
|
i := strings.Index(text[pos:], w)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("word %q missing or out of order", w)
|
||||||
|
}
|
||||||
|
pos += i + len(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The Kitty graphics protocol with Unicode placeholders, as supported by
|
||||||
|
// kitty and Ghostty: the poster is uploaded to the terminal once, and then
|
||||||
|
// shown by writing special placeholder characters into ordinary text cells.
|
||||||
|
// Because the picture is made of text cells it behaves like text: popups and
|
||||||
|
// dialogs drawn over it cover it, it vanishes when the view changes, and the
|
||||||
|
// normal screen diffing keeps it in place. The poster is shown at the
|
||||||
|
// terminal's full resolution.
|
||||||
|
|
||||||
|
// placeholderRune marks a cell that shows a piece of an image. It is followed
|
||||||
|
// by combining characters that give the row and column in the image; the
|
||||||
|
// image id is the cell's foreground color.
|
||||||
|
const placeholderRune = '\U0010EEEE'
|
||||||
|
|
||||||
|
const kittyChunk = 4096 // bytes of base64 per escape sequence, the protocol maximum
|
||||||
|
|
||||||
|
// kittyGraphics uploads images to the terminal and keeps track of them.
|
||||||
|
//
|
||||||
|
// Every footprint of a poster (the part of it that is shown and the number of
|
||||||
|
// cells it covers) is uploaded as its own image with its own id and placed once.
|
||||||
|
// An existing placement is never changed: terminals do not reliably replace the
|
||||||
|
// size of a placement that is already there (Ghostty kept showing the old, small
|
||||||
|
// one after the window was enlarged), whereas a new image id always starts clean.
|
||||||
|
type kittyGraphics struct {
|
||||||
|
write func([]byte) error // writes to the terminal, in step with the screen
|
||||||
|
nextID uint32
|
||||||
|
ids map[footprint]uint32
|
||||||
|
latest map[*image.RGBA]footprint // the footprint of each poster that is currently uploaded
|
||||||
|
|
||||||
|
holdUntil time.Time // no real images until then (the window is being resized)
|
||||||
|
debug func(string) // reports what is uploaded, if set
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleDelay is how long the window size must stay the same before real
|
||||||
|
// images are shown again. While a window is dragged to another size, tens of
|
||||||
|
// sizes go by; uploading and placing images for each of them races with the
|
||||||
|
// terminal's own resizing, and the result can be a placement that does not
|
||||||
|
// match the cells (a small poster in a big area, or a corner of a big one).
|
||||||
|
const settleDelay = 300 * time.Millisecond
|
||||||
|
|
||||||
|
// hold stops real images being drawn until the window has been left alone for settleDelay.
|
||||||
|
func (k *kittyGraphics) hold() { k.holdUntil = time.Now().Add(settleDelay) }
|
||||||
|
|
||||||
|
// settling reports whether the window size changed so recently that blocks are drawn instead.
|
||||||
|
func (k *kittyGraphics) settling() bool { return time.Now().Before(k.holdUntil) }
|
||||||
|
|
||||||
|
// footprint identifies an uploaded image: a poster, the part of it that was
|
||||||
|
// sent, and the cells it covers.
|
||||||
|
type footprint struct {
|
||||||
|
img *image.RGBA
|
||||||
|
rect image.Rectangle
|
||||||
|
cols, rows int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
|
||||||
|
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
|
||||||
|
func kittyAPC(keys string, payload []byte) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
b.WriteString("\x1b_G")
|
||||||
|
b.WriteString(keys)
|
||||||
|
if len(payload) > 0 {
|
||||||
|
b.WriteByte(';')
|
||||||
|
b.Write(payload)
|
||||||
|
}
|
||||||
|
b.WriteString("\x1b\\")
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// show makes sure the part rect of the image is uploaded and placed over
|
||||||
|
// cols×rows cells, and returns the id the placeholder cells must carry. The same
|
||||||
|
// footprint is reused; a different one for the same poster replaces the old
|
||||||
|
// upload (which is removed from the terminal).
|
||||||
|
func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows int) (uint32, error) {
|
||||||
|
key := footprint{img, rect, cols, rows}
|
||||||
|
if id, ok := k.ids[key]; ok {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
var data bytes.Buffer
|
||||||
|
enc := png.Encoder{CompressionLevel: png.BestSpeed}
|
||||||
|
if err := enc.Encode(&data, img.SubImage(rect)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
|
||||||
|
id := k.nextID
|
||||||
|
for first, rest := true, encoded; len(rest) > 0; first = false {
|
||||||
|
n := min(len(rest), kittyChunk)
|
||||||
|
more := 0
|
||||||
|
if n < len(rest) {
|
||||||
|
more = 1
|
||||||
|
}
|
||||||
|
keys := fmt.Sprintf("m=%d", more)
|
||||||
|
if first {
|
||||||
|
// Transmit only (a=t), PNG data (f=100) directly in the command (t=d),
|
||||||
|
// no replies (q=2): a reply would arrive as keyboard input.
|
||||||
|
keys = fmt.Sprintf("a=t,f=100,t=d,i=%d,q=2,m=%d", id, more)
|
||||||
|
}
|
||||||
|
if err := k.write(kittyAPC(keys, []byte(rest[:n]))); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rest = rest[n:]
|
||||||
|
}
|
||||||
|
// U=1: a virtual placement, shown wherever placeholder cells refer to the id.
|
||||||
|
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
k.nextID++
|
||||||
|
if old, ok := k.latest[img]; ok {
|
||||||
|
if oldID, ok := k.ids[old]; ok {
|
||||||
|
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", oldID), nil))
|
||||||
|
delete(k.ids, old)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k.ids[key] = id
|
||||||
|
k.latest[img] = key
|
||||||
|
if k.debug != nil {
|
||||||
|
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, cols, rows))
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAll removes every image uploaded so far from the terminal. The next
|
||||||
|
// draw uploads what it needs again, which is also how a resize is handled.
|
||||||
|
func (k *kittyGraphics) deleteAll() {
|
||||||
|
for _, id := range k.ids {
|
||||||
|
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", id), nil))
|
||||||
|
}
|
||||||
|
k.ids = map[footprint]uint32{}
|
||||||
|
k.latest = map[*image.RGBA]footprint{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeholderCell returns what to draw in the cell at (row, col) of the image
|
||||||
|
// with the given id: the placeholder character, its combining characters and
|
||||||
|
// the style that carries the id.
|
||||||
|
func placeholderCell(id uint32, row, col int) (main rune, comb []rune, style tcell.Style) {
|
||||||
|
comb = []rune{kittyDiacritics[row], kittyDiacritics[col]}
|
||||||
|
fg := tcell.NewRGBColor(int32(id>>16&0xFF), int32(id>>8&0xFF), int32(id&0xFF))
|
||||||
|
// The background shows wherever the picture does not cover the cell (a
|
||||||
|
// sliver at the edge): use the panes' own color so it is not visible.
|
||||||
|
return placeholderRune, comb, tcell.StyleDefault.Foreground(fg).Background(tview.Styles.PrimitiveBackgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPlaceholders is how many rows and columns can be numbered.
|
||||||
|
const maxPlaceholders = len(kittyDiacritics)
|
||||||
|
|
||||||
|
// kittyTerminal reports whether the environment looks like a terminal that
|
||||||
|
// supports Kitty graphics with Unicode placeholders: kitty and Ghostty, not
|
||||||
|
// inside tmux or screen (which swallow the graphics commands).
|
||||||
|
func kittyTerminal(env func(string) string) bool {
|
||||||
|
term := strings.ToLower(env("TERM"))
|
||||||
|
if env("TMUX") != "" || env("STY") != "" || strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(term, "kitty"), strings.Contains(term, "ghostty"):
|
||||||
|
return true
|
||||||
|
case strings.EqualFold(env("TERM_PROGRAM"), "ghostty"), env("GHOSTTY_RESOURCES_DIR") != "", env("KITTY_WINDOW_ID") != "":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
// kittyDiacritics are the 297 combining characters the Kitty graphics
|
||||||
|
// protocol uses to number the rows and columns of an image drawn with Unicode
|
||||||
|
// placeholders (rowcolumn-diacritics.txt in the kitty repository, fixed to
|
||||||
|
// Unicode 6.0). Entry i stands for the number i.
|
||||||
|
var kittyDiacritics = [...]rune{
|
||||||
|
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F,
|
||||||
|
0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357,
|
||||||
|
0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
|
||||||
|
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484,
|
||||||
|
0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
|
||||||
|
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
|
||||||
|
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611,
|
||||||
|
0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658,
|
||||||
|
0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
|
||||||
|
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
|
||||||
|
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733,
|
||||||
|
0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
|
||||||
|
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE,
|
||||||
|
0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819,
|
||||||
|
0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
||||||
|
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C,
|
||||||
|
0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87,
|
||||||
|
0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
|
||||||
|
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D,
|
||||||
|
0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
|
||||||
|
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
|
||||||
|
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1,
|
||||||
|
0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9,
|
||||||
|
0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
|
||||||
|
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
|
||||||
|
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7,
|
||||||
|
0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
|
||||||
|
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA,
|
||||||
|
0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2,
|
||||||
|
0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
||||||
|
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D,
|
||||||
|
0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5,
|
||||||
|
0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
|
||||||
|
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7,
|
||||||
|
0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
|
||||||
|
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
|
||||||
|
0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243,
|
||||||
|
0x1D244,
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recorder collects what is written to the terminal.
|
||||||
|
type recorder struct{ cmds []string }
|
||||||
|
|
||||||
|
func (r *recorder) write(b []byte) error { r.cmds = append(r.cmds, string(b)); return nil }
|
||||||
|
|
||||||
|
func noisy(w, h int) *image.RGBA {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
rng := rand.New(rand.NewSource(1))
|
||||||
|
for i := range img.Pix {
|
||||||
|
img.Pix[i] = uint8(rng.Intn(256))
|
||||||
|
}
|
||||||
|
for i := 3; i < len(img.Pix); i += 4 {
|
||||||
|
img.Pix[i] = 255
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKittyDiacriticsTable(t *testing.T) {
|
||||||
|
if len(kittyDiacritics) != 297 {
|
||||||
|
t.Fatalf("%d diacritics, the protocol defines 297", len(kittyDiacritics))
|
||||||
|
}
|
||||||
|
if kittyDiacritics[0] != 0x0305 || kittyDiacritics[296] != 0x1D244 {
|
||||||
|
t.Errorf("table ends are %U and %U", kittyDiacritics[0], kittyDiacritics[296])
|
||||||
|
}
|
||||||
|
for i := 1; i < len(kittyDiacritics); i++ {
|
||||||
|
if kittyDiacritics[i] <= kittyDiacritics[i-1] {
|
||||||
|
t.Fatalf("table not strictly ascending at %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKittyUploadChunksAndEncodes(t *testing.T) {
|
||||||
|
rec := &recorder{}
|
||||||
|
k := newKittyGraphics(rec.write)
|
||||||
|
img := noisy(200, 300) // incompressible: far more than one 4096-byte chunk of base64
|
||||||
|
|
||||||
|
id, err := k.show(img, img.Bounds(), 20, 15)
|
||||||
|
if err != nil || id != 1 {
|
||||||
|
t.Fatalf("id=%d err=%v", id, err)
|
||||||
|
}
|
||||||
|
if len(rec.cmds) < 4 {
|
||||||
|
t.Fatalf("only %d commands for a large image", len(rec.cmds))
|
||||||
|
}
|
||||||
|
// The upload is followed by the placement, once.
|
||||||
|
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=p,U=1,i=1,c=20,r=15,q=2\x1b\\" {
|
||||||
|
t.Errorf("last command = %q", last)
|
||||||
|
}
|
||||||
|
uploads := rec.cmds[:len(rec.cmds)-1]
|
||||||
|
re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`)
|
||||||
|
var payload strings.Builder
|
||||||
|
for i, c := range uploads {
|
||||||
|
m := re.FindStringSubmatch(c)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("command %d is not a graphics command: %q", i, c[:min(40, len(c))])
|
||||||
|
}
|
||||||
|
keys, data := m[1], m[2]
|
||||||
|
if len(data) > kittyChunk {
|
||||||
|
t.Errorf("chunk %d has %d bytes", i, len(data))
|
||||||
|
}
|
||||||
|
last := i == len(uploads)-1
|
||||||
|
switch {
|
||||||
|
case i == 0 && !strings.HasPrefix(keys, "a=t,f=100,t=d,i=1,q=2,m=1"):
|
||||||
|
t.Errorf("first command keys = %q", keys)
|
||||||
|
case i > 0 && !last && keys != "m=1":
|
||||||
|
t.Errorf("middle command keys = %q", keys)
|
||||||
|
case last && !strings.HasSuffix(keys, "m=0"):
|
||||||
|
t.Errorf("last command keys = %q", keys)
|
||||||
|
}
|
||||||
|
payload.WriteString(data)
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(payload.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
decoded, err := png.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil || decoded.Bounds() != img.Bounds() {
|
||||||
|
t.Fatalf("payload is not the image as PNG: %v %v", decoded, err)
|
||||||
|
}
|
||||||
|
r, g, b, _ := decoded.At(17, 23).RGBA()
|
||||||
|
want := img.RGBAAt(17, 23)
|
||||||
|
if uint8(r>>8) != want.R || uint8(g>>8) != want.G || uint8(b>>8) != want.B {
|
||||||
|
t.Error("pixel data changed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same footprint is not uploaded twice; another poster gets the next id.
|
||||||
|
n := len(rec.cmds)
|
||||||
|
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id || len(rec.cmds) != n {
|
||||||
|
t.Error("an uploaded footprint must be reused")
|
||||||
|
}
|
||||||
|
small := noisy(4, 4)
|
||||||
|
if other, _ := k.show(small, small.Bounds(), 3, 2); other != 2 {
|
||||||
|
t.Errorf("second image id = %d", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKittyNewSizeIsANewImage(t *testing.T) {
|
||||||
|
rec := &recorder{}
|
||||||
|
k := newKittyGraphics(rec.write)
|
||||||
|
img := noisy(10, 15)
|
||||||
|
|
||||||
|
id1, _ := k.show(img, img.Bounds(), 20, 15)
|
||||||
|
rec.cmds = nil
|
||||||
|
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id1 || len(rec.cmds) != 0 {
|
||||||
|
t.Fatalf("same footprint: id %d, %d commands", again, len(rec.cmds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different size is never done by changing the placement (a terminal can keep the
|
||||||
|
// old one): the poster is uploaded under a new id, placed, and the old id removed.
|
||||||
|
id2, _ := k.show(img, img.Bounds(), 10, 8)
|
||||||
|
if id2 == id1 {
|
||||||
|
t.Fatal("a new footprint must get a new id")
|
||||||
|
}
|
||||||
|
all := strings.Join(rec.cmds, "")
|
||||||
|
if !strings.Contains(all, "a=t,f=100,t=d,i=2") || !strings.Contains(all, "a=p,U=1,i=2,c=10,r=8") || !strings.Contains(all, "a=d,d=I,i=1") {
|
||||||
|
t.Errorf("resize sent %q", rec.cmds)
|
||||||
|
}
|
||||||
|
if strings.Contains(all, "a=p,U=1,i=1") {
|
||||||
|
t.Error("the old placement must not be touched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After deleteAll (what a window resize triggers) everything is uploaded afresh.
|
||||||
|
rec.cmds = nil
|
||||||
|
k.deleteAll()
|
||||||
|
id3, _ := k.show(img, img.Bounds(), 10, 8)
|
||||||
|
if id3 == id2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=I,i=2") || !strings.Contains(strings.Join(rec.cmds, ""), "a=t,f=100,t=d,i=3") {
|
||||||
|
t.Errorf("after deleteAll: id %d, %q", id3, rec.cmds)
|
||||||
|
}
|
||||||
|
if len(k.ids) != 1 || len(k.latest) != 1 {
|
||||||
|
t.Errorf("bookkeeping: %d ids, %d latest", len(k.ids), len(k.latest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaceholderCell(t *testing.T) {
|
||||||
|
ch, comb, style := placeholderCell(0x010203, 2, 5)
|
||||||
|
if ch != '\U0010EEEE' || len(comb) != 2 || comb[0] != kittyDiacritics[2] || comb[1] != kittyDiacritics[5] {
|
||||||
|
t.Errorf("cell = %U %U", ch, comb)
|
||||||
|
}
|
||||||
|
fg, bg, _ := style.Decompose()
|
||||||
|
if r, g, b := fg.RGB(); r != 1 || g != 2 || b != 3 {
|
||||||
|
t.Errorf("the id must be in the foreground color, got %d,%d,%d", r, g, b)
|
||||||
|
}
|
||||||
|
if bg != tview.Styles.PrimitiveBackgroundColor {
|
||||||
|
t.Errorf("background %v must match the panes (%v), or uncovered slivers show", bg, tview.Styles.PrimitiveBackgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKittyTerminalDetection(t *testing.T) {
|
||||||
|
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
|
||||||
|
sextantFontAvailable = func() bool { return false } // not what these cases are about
|
||||||
|
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
|
||||||
|
kittyGraphicsSupported = func() bool { return true } // the terminal confirms
|
||||||
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ghostty term", map[string]string{"TERM": "xterm-ghostty"}, true},
|
||||||
|
{"ghostty program", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "ghostty"}, true},
|
||||||
|
{"ghostty resources", map[string]string{"GHOSTTY_RESOURCES_DIR": "/x"}, true},
|
||||||
|
{"kitty", map[string]string{"TERM": "xterm-kitty"}, true},
|
||||||
|
{"kitty window", map[string]string{"KITTY_WINDOW_ID": "1"}, true},
|
||||||
|
{"plain xterm", map[string]string{"TERM": "xterm-256color"}, false},
|
||||||
|
{"wezterm has no placeholders", map[string]string{"TERM_PROGRAM": "WezTerm"}, false},
|
||||||
|
{"tmux swallows graphics", map[string]string{"TERM": "xterm-ghostty", "TMUX": "/tmp/x"}, false},
|
||||||
|
{"screen swallows graphics", map[string]string{"TERM": "screen-256color", "TERM_PROGRAM": "ghostty"}, false},
|
||||||
|
{"nothing", nil, false},
|
||||||
|
} {
|
||||||
|
if got := kittyTerminal(env(tc.env)); got != tc.want {
|
||||||
|
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
env map[string]string
|
||||||
|
wantGfx bool
|
||||||
|
wantName string
|
||||||
|
}{
|
||||||
|
{"auto", map[string]string{"TERM": "xterm-ghostty"}, true, "sextant"},
|
||||||
|
{"", map[string]string{"TERM": "xterm-256color"}, false, "quadrant"},
|
||||||
|
{"kitty", map[string]string{"TERM": "xterm-256color"}, true, "quadrant"},
|
||||||
|
{"sextant", map[string]string{"TERM": "xterm-ghostty"}, false, "sextant"},
|
||||||
|
{"quadrant", map[string]string{"TERM": "xterm-ghostty"}, false, "quadrant"},
|
||||||
|
} {
|
||||||
|
blocks, gfx := resolveCovers(tc.setting, env(tc.env))
|
||||||
|
if gfx != tc.wantGfx || blocks.name != tc.wantName {
|
||||||
|
t.Errorf("%q %v = %s/%v, want %s/%v", tc.setting, tc.env, blocks.name, gfx, tc.wantName, tc.wantGfx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trueColor makes a simulation screen report 24-bit color.
|
||||||
|
type trueColor struct{ tcell.SimulationScreen }
|
||||||
|
|
||||||
|
func (trueColor) Colors() int { return 1 << 24 }
|
||||||
|
|
||||||
|
func TestCoverViewGraphicsDrawsPlaceholders(t *testing.T) {
|
||||||
|
sim := tcell.NewSimulationScreen("")
|
||||||
|
if err := sim.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sim.Fini()
|
||||||
|
sim.SetSize(40, 20)
|
||||||
|
|
||||||
|
rec := &recorder{}
|
||||||
|
c := newCoverView(quadrantStyle, newKittyGraphics(rec.write), nil)
|
||||||
|
c.setImage(stripes(40, 60)) // 2:3, cells twice as tall as wide: 13 columns × 10 rows in a 30×10 box
|
||||||
|
c.SetRect(0, 0, 30, 10)
|
||||||
|
|
||||||
|
// Without true color the protocol is not used and blocks are drawn.
|
||||||
|
c.Draw(sim)
|
||||||
|
if len(rec.cmds) != 0 {
|
||||||
|
t.Fatalf("graphics commands sent without true color: %q", rec.cmds)
|
||||||
|
}
|
||||||
|
if ch, _, _, _ := sim.GetContent(29, 0); ch == placeholderRune {
|
||||||
|
t.Error("placeholders drawn without true color")
|
||||||
|
}
|
||||||
|
|
||||||
|
screen := trueColor{sim}
|
||||||
|
c.Draw(screen)
|
||||||
|
if len(rec.cmds) != 2 { // upload (one chunk) + placement
|
||||||
|
t.Fatalf("%d commands: %q", len(rec.cmds), rec.cmds)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.cmds[1], "c=13,r=10") {
|
||||||
|
t.Errorf("placement = %q", rec.cmds[1])
|
||||||
|
}
|
||||||
|
// Right-aligned: columns 17..29, rows 0..9.
|
||||||
|
ch, comb, _, _ := sim.GetContent(17, 0)
|
||||||
|
if ch != placeholderRune || len(comb) < 2 || comb[0] != kittyDiacritics[0] || comb[1] != kittyDiacritics[0] {
|
||||||
|
t.Errorf("top left cell = %U %U", ch, comb)
|
||||||
|
}
|
||||||
|
ch, comb, _, _ = sim.GetContent(29, 9)
|
||||||
|
if ch != placeholderRune || comb[0] != kittyDiacritics[9] || comb[1] != kittyDiacritics[12] {
|
||||||
|
t.Errorf("bottom right cell = %U %U", ch, comb)
|
||||||
|
}
|
||||||
|
c.Draw(screen) // a second frame uploads nothing again
|
||||||
|
if len(rec.cmds) != 2 {
|
||||||
|
t.Errorf("redraw sent %d more commands", len(rec.cmds)-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cell aspect of 2.5 (taller cells) changes the footprint of the same box.
|
||||||
|
c.cellAspect = func() float64 { return 2.5 }
|
||||||
|
c.Draw(screen)
|
||||||
|
// The box is now 17×10 cells, a different shape: a new image id is uploaded and
|
||||||
|
// placed, the old upload is removed.
|
||||||
|
all := strings.Join(rec.cmds[2:], "")
|
||||||
|
if !strings.Contains(all, "a=p,U=1,i=2,c=17,r=10") || !strings.Contains(all, "a=d,d=I,i=1") {
|
||||||
|
t.Errorf("after the cell aspect changed: %q", rec.cmds[2:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCropToBox(t *testing.T) {
|
||||||
|
b := image.Rect(0, 0, 167, 250) // a 2:3 poster
|
||||||
|
// A box slightly narrower than the poster: the sides are trimmed.
|
||||||
|
got := cropToBox(b, 17, 13, 2.1) // box shape 17/(13*2.1) = 0.6227 < 0.668
|
||||||
|
if got.Dy() != 250 || got.Dx() >= 167 || got.Min.X != (167-got.Dx())/2 {
|
||||||
|
t.Errorf("narrow box: %v", got)
|
||||||
|
}
|
||||||
|
if shape := float64(got.Dx()) / float64(got.Dy()); math.Abs(shape-17.0/(13*2.1)) > 0.005 {
|
||||||
|
t.Errorf("cropped shape %.4f, want %.4f", shape, 17.0/(13*2.1))
|
||||||
|
}
|
||||||
|
// A box slightly wider: top and bottom are trimmed.
|
||||||
|
got = cropToBox(b, 18, 12, 2.1) // 18/(12*2.1) = 0.714 > 0.668
|
||||||
|
if got.Dx() != 167 || got.Dy() >= 250 || got.Min.Y != (250-got.Dy())/2 {
|
||||||
|
t.Errorf("wide box: %v", got)
|
||||||
|
}
|
||||||
|
// A box of exactly the poster's shape changes nothing.
|
||||||
|
if got := cropToBox(image.Rect(0, 0, 40, 60), 20, 15, 2); got != image.Rect(0, 0, 40, 60) {
|
||||||
|
t.Errorf("exact box: %v", got)
|
||||||
|
}
|
||||||
|
// Offsets of sub-images are respected.
|
||||||
|
if got := cropToBox(image.Rect(10, 20, 50, 80), 18, 10, 2); got.Min.X != 10 || got.Max.X != 50 || got.Min.Y < 20 || got.Max.Y > 80 || got.Dy() >= 60 {
|
||||||
|
t.Errorf("offset image: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCellAspectFromWindow(t *testing.T) {
|
||||||
|
// 140×50 cells of 9×20 px plus 4 and 11 px of padding: plain division
|
||||||
|
// would say 1264/140=9.03 and 1011/50=20.22 (aspect 2.24), the real shape is 20/9.
|
||||||
|
if got := cellAspectFromWindow(140, 50, 140*9+4, 50*20+11); math.Abs(got-20.0/9) > 1e-9 {
|
||||||
|
t.Errorf("aspect = %v, want %v", got, 20.0/9)
|
||||||
|
}
|
||||||
|
// Padding that grows with the window does not move it.
|
||||||
|
for _, pad := range []int{0, 3, 8} {
|
||||||
|
if got := cellAspectFromWindow(100, 30, 100*10+pad, 30*21+pad); math.Abs(got-2.1) > 1e-9 {
|
||||||
|
t.Errorf("padding %d: aspect = %v", pad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range [][4]int{{0, 10, 100, 100}, {10, 0, 100, 100}, {10, 10, 0, 100}, {10, 10, 100, 0}, {100, 100, 50, 50}} {
|
||||||
|
if got := cellAspectFromWindow(bad[0], bad[1], bad[2], bad[3]); got != 2 {
|
||||||
|
t.Errorf("%v: aspect = %v, want the default 2", bad, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosterSource(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{
|
||||||
|
"https://artworks.thetvdb.com/banners/posters/1-1.jpg": "artworks.thetvdb.com/banners/posters/1-1.jpg",
|
||||||
|
"http://radarr:7878|/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster.jpg",
|
||||||
|
} {
|
||||||
|
if got := posterSource(in); got != want {
|
||||||
|
t.Errorf("posterSource(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphicsAreHeldWhileTheWindowChanges(t *testing.T) {
|
||||||
|
sim := tcell.NewSimulationScreen("")
|
||||||
|
if err := sim.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sim.Fini()
|
||||||
|
sim.SetSize(40, 20)
|
||||||
|
screen := trueColor{sim}
|
||||||
|
|
||||||
|
rec := &recorder{}
|
||||||
|
k := newKittyGraphics(rec.write)
|
||||||
|
c := newCoverView(quadrantStyle, k, nil)
|
||||||
|
c.setImage(stripes(40, 60))
|
||||||
|
c.SetRect(0, 0, 30, 10)
|
||||||
|
|
||||||
|
k.hold()
|
||||||
|
if !k.settling() {
|
||||||
|
t.Fatal("hold must start the settling period")
|
||||||
|
}
|
||||||
|
c.Draw(screen)
|
||||||
|
if len(rec.cmds) != 0 {
|
||||||
|
t.Fatalf("graphics commands while the window changes: %q", rec.cmds)
|
||||||
|
}
|
||||||
|
if ch, _, _, _ := sim.GetContent(29, 9); ch == placeholderRune {
|
||||||
|
t.Error("placeholders drawn while settling; blocks are the stand-in")
|
||||||
|
}
|
||||||
|
|
||||||
|
k.holdUntil = time.Now().Add(-time.Millisecond) // the period is over
|
||||||
|
c.Draw(screen)
|
||||||
|
if len(rec.cmds) != 2 {
|
||||||
|
t.Errorf("after settling: %d commands, want upload + placement", len(rec.cmds))
|
||||||
|
}
|
||||||
|
if ch, _, _, _ := sim.GetContent(29, 9); ch != placeholderRune {
|
||||||
|
t.Error("real image not shown after settling")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphicsDebugReportsUploads(t *testing.T) {
|
||||||
|
rec := &recorder{}
|
||||||
|
k := newKittyGraphics(rec.write)
|
||||||
|
var lines []string
|
||||||
|
k.debug = func(s string) { lines = append(lines, s) }
|
||||||
|
img := noisy(10, 15)
|
||||||
|
k.show(img, img.Bounds(), 12, 8)
|
||||||
|
if len(lines) != 1 || !strings.Contains(lines[0], "id 1") || !strings.Contains(lines[0], "12x8 cells") || !strings.Contains(lines[0], "10x15 px") {
|
||||||
|
t.Errorf("debug lines = %q", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) {
|
||||||
|
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
|
||||||
|
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
|
ghostty := env(map[string]string{"TERM": "xterm-ghostty"})
|
||||||
|
|
||||||
|
kittyGraphicsSupported = func() bool { return false } // e.g. a terminal that claims kitty but prints APC as text
|
||||||
|
if _, gfx := resolveCovers("auto", ghostty); gfx {
|
||||||
|
t.Error("auto must not use real images when the terminal does not answer the query")
|
||||||
|
}
|
||||||
|
if _, gfx := resolveCovers("kitty", ghostty); gfx {
|
||||||
|
t.Error("even an explicit kitty setting must not send graphics a terminal does not understand")
|
||||||
|
}
|
||||||
|
|
||||||
|
asked := false
|
||||||
|
kittyGraphicsSupported = func() bool { asked = true; return true }
|
||||||
|
if _, gfx := resolveCovers("auto", env(map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"})); gfx || asked {
|
||||||
|
t.Error("a terminal not known for the protocol is not even asked (it might print the query)")
|
||||||
|
}
|
||||||
|
if _, gfx := resolveCovers("auto", ghostty); !gfx || !asked {
|
||||||
|
t.Error("a known terminal that answers gets real images")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// confirmDelete asks before removing a title from the library. Deleting the
|
||||||
|
// files is a separate, unticked choice: by default only the library entry goes.
|
||||||
|
func (a *App) confirmDelete(label, summary string, onDelete func(deleteFiles, exclude bool)) {
|
||||||
|
box, _ := a.newDeleteDialog(label, summary, onDelete)
|
||||||
|
a.openModal(box, 66, 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newDeleteDialog(label, summary string, onDelete func(deleteFiles, exclude bool)) (*tview.Flex, *tview.Form) {
|
||||||
|
const (
|
||||||
|
lFiles = "Delete files from disk"
|
||||||
|
lExclude = "Don't let import lists re-add it"
|
||||||
|
)
|
||||||
|
text := tview.NewTextView().SetDynamicColors(true).SetWordWrap(true).
|
||||||
|
SetText("[::b]" + label + "[::-]\n" + summary)
|
||||||
|
form := tview.NewForm()
|
||||||
|
form.AddCheckbox(lFiles, false, nil)
|
||||||
|
form.AddCheckbox(lExclude, false, nil)
|
||||||
|
files := form.GetFormItemByLabel(lFiles).(*tview.Checkbox)
|
||||||
|
exclude := form.GetFormItemByLabel(lExclude).(*tview.Checkbox)
|
||||||
|
// Cancel first so a stray Enter is safe.
|
||||||
|
form.AddButton("Cancel", a.closeModal)
|
||||||
|
form.AddButton("Delete", func() {
|
||||||
|
a.closeModal()
|
||||||
|
onDelete(files.IsChecked(), exclude.IsChecked())
|
||||||
|
})
|
||||||
|
form.SetFocus(form.GetFormItemCount()) // the Cancel button
|
||||||
|
arrowNavigation(form, a.closeModal, nil)
|
||||||
|
|
||||||
|
box := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(text, 4, 0, false).
|
||||||
|
AddItem(form, 0, 1, true)
|
||||||
|
box.SetBorder(true).SetTitle(" Remove from library ")
|
||||||
|
return box, form
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove asks to delete the selected library item.
|
||||||
|
func (v *libraryView) remove() {
|
||||||
|
it, ok := v.selected()
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary := "[gray]Untick “Delete files” to keep the files on disk.[-]"
|
||||||
|
if it.have {
|
||||||
|
summary = "[yellow]" + it.size + " on disk.[-] Files are kept unless you tick “Delete files”."
|
||||||
|
}
|
||||||
|
v.app.confirmDelete(it.label, summary, func(deleteFiles, exclude bool) {
|
||||||
|
what := "Removed " + it.label
|
||||||
|
if deleteFiles {
|
||||||
|
what = "Removed " + it.label + " and its files"
|
||||||
|
}
|
||||||
|
v.run("Removing "+it.label, what, func(ctx context.Context) error {
|
||||||
|
if it.movie != nil {
|
||||||
|
return v.radarr.DeleteMovie(ctx, it.id, deleteFiles, exclude)
|
||||||
|
}
|
||||||
|
return v.sonarr.DeleteSeries(ctx, it.id, deleteFiles, exclude)
|
||||||
|
}, v.reload)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteDialogDefaultsAreSafe(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
var calls [][2]bool
|
||||||
|
_, form := a.newDeleteDialog("Heat (1995)", "3 GB on disk", func(files, exclude bool) { calls = append(calls, [2]bool{files, exclude}) })
|
||||||
|
|
||||||
|
var focus func(p tview.Primitive)
|
||||||
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
|
form.Focus(focus)
|
||||||
|
press := func(k tcell.Key) { form.InputHandler()(tcell.NewEventKey(k, 0, 0), focus) }
|
||||||
|
space := func() { form.InputHandler()(tcell.NewEventKey(tcell.KeyRune, ' ', 0), focus) }
|
||||||
|
|
||||||
|
// Both boxes start unticked and the focus starts on Cancel: a stray Enter does nothing.
|
||||||
|
press(tcell.KeyEnter)
|
||||||
|
if len(calls) != 0 {
|
||||||
|
t.Fatalf("Enter on the initial focus deleted: %v", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moving to Delete and confirming removes the entry only.
|
||||||
|
form.SetFocus(form.GetFormItemCount())
|
||||||
|
press(tcell.KeyRight)
|
||||||
|
press(tcell.KeyEnter)
|
||||||
|
if len(calls) != 1 || calls[0] != [2]bool{false, false} {
|
||||||
|
t.Fatalf("calls = %v, want one call without files and exclusion", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticking the boxes is passed on.
|
||||||
|
form.SetFocus(0)
|
||||||
|
space() // delete files
|
||||||
|
press(tcell.KeyDown)
|
||||||
|
space() // exclusion
|
||||||
|
press(tcell.KeyDown)
|
||||||
|
press(tcell.KeyDown) // Delete button
|
||||||
|
press(tcell.KeyEnter)
|
||||||
|
if len(calls) != 2 || calls[1] != [2]bool{true, true} {
|
||||||
|
t.Errorf("calls = %v, want the second with both ticked", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,12 +244,12 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
|||||||
v.cur = kind
|
v.cur = kind
|
||||||
v.input.SetLabel("Filter " + kind.String() + ": ")
|
v.input.SetLabel("Filter " + kind.String() + ": ")
|
||||||
v.input.SetText(p.text)
|
v.input.SetText(p.text)
|
||||||
p.table.SetBorderColor(tcell.ColorYellow)
|
p.table.SetBorderColor(named("yellow"))
|
||||||
v.renderDetails()
|
v.renderDetails()
|
||||||
})
|
})
|
||||||
p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) })
|
p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) })
|
||||||
v.panes[kind] = p
|
v.panes[kind] = p
|
||||||
lists.AddItem(p.table, 0, 1, kind == kindMovies)
|
lists.AddItem(fitTitles{p.table}, 0, 1, kind == kindMovies)
|
||||||
}
|
}
|
||||||
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.header, 1, 0, false).
|
AddItem(v.header, 1, 0, false).
|
||||||
@@ -263,6 +263,14 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
|||||||
v.render()
|
v.render()
|
||||||
})
|
})
|
||||||
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
|
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
|
||||||
|
app.reconnect = append(app.reconnect, func() {
|
||||||
|
// Another server: forget what the old one said, reload when shown.
|
||||||
|
v.seriesFiles = map[int]seriesFiles{}
|
||||||
|
for _, p := range v.panes {
|
||||||
|
p.all, p.shown, p.loaded, p.err = nil, nil, false, nil
|
||||||
|
}
|
||||||
|
v.render()
|
||||||
|
})
|
||||||
v.render()
|
v.render()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
@@ -293,6 +301,8 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|||||||
v.search()
|
v.search()
|
||||||
case 't':
|
case 't':
|
||||||
v.toggleMonitored()
|
v.toggleMonitored()
|
||||||
|
case 'd':
|
||||||
|
v.remove()
|
||||||
default:
|
default:
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
@@ -353,8 +363,11 @@ func (v *libraryView) reloadPane(p *libPane) {
|
|||||||
defer v.loadSeriesFiles(false)
|
defer v.loadSeriesFiles(false)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Keep showing the last good list; say why it is stale.
|
// Keep showing the last good list; say why it is stale. A service
|
||||||
|
// that is not set up says so in its list instead.
|
||||||
|
if !notConfigured(err) {
|
||||||
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
|
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
p.all, p.loaded = items, true
|
p.all, p.loaded = items, true
|
||||||
}
|
}
|
||||||
@@ -381,9 +394,9 @@ func (v *libraryView) render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *libraryView) renderPane(p *libPane) {
|
func (v *libraryView) renderPane(p *libPane) {
|
||||||
selID := -1
|
selID, prevRow := -1, 0
|
||||||
if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) {
|
if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) {
|
||||||
selID = p.shown[r-1].id
|
selID, prevRow = p.shown[r-1].id, r
|
||||||
}
|
}
|
||||||
p.shown = filterItems(p.all, p.filter, p.text, p.attr)
|
p.shown = filterItems(p.all, p.filter, p.text, p.attr)
|
||||||
|
|
||||||
@@ -391,15 +404,25 @@ func (v *libraryView) renderPane(p *libPane) {
|
|||||||
header(p.table, 0, "Title", "Status", "Size")
|
header(p.table, 0, "Title", "Status", "Size")
|
||||||
selRow := 0
|
selRow := 0
|
||||||
for i, it := range p.shown {
|
for i, it := range p.shown {
|
||||||
p.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1))
|
title := titleCell{prefix: monMark(it.monitored) + " ", title: it.label}
|
||||||
|
p.table.SetCell(i+1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title))
|
||||||
p.table.SetCell(i+1, 1, tview.NewTableCell(it.status))
|
p.table.SetCell(i+1, 1, tview.NewTableCell(it.status))
|
||||||
p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
|
p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
|
||||||
if it.id == selID {
|
if it.id == selID {
|
||||||
selRow = i + 1
|
selRow = i + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if selRow == 0 && selID != -1 && !containsItem(p.all, selID) {
|
||||||
|
// The selected title is gone from the library (removed): stay at its
|
||||||
|
// place, on the title that moved up into it (or the new last one),
|
||||||
|
// instead of jumping to the top. A title that is only filtered out
|
||||||
|
// still starts the list from the top.
|
||||||
|
selRow = min(prevRow, len(p.shown))
|
||||||
|
}
|
||||||
placeholder := "Nothing matches the filter"
|
placeholder := "Nothing matches the filter"
|
||||||
switch {
|
switch {
|
||||||
|
case notConfigured(p.err):
|
||||||
|
placeholder = p.kind.service() + " is not configured (Config view, F6)"
|
||||||
case p.err != nil && !p.loaded:
|
case p.err != nil && !p.loaded:
|
||||||
placeholder = "Could not load, press r to retry"
|
placeholder = "Could not load, press r to retry"
|
||||||
case !p.loaded:
|
case !p.loaded:
|
||||||
@@ -427,6 +450,86 @@ func (v *libraryView) renderPane(p *libPane) {
|
|||||||
p.table.SetTitle(title + " ")
|
p.table.SetTitle(title + " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fitTitles draws a table whose first column (the title) is cut to the width
|
||||||
|
// the other columns leave, with an ellipsis, so long titles never make the
|
||||||
|
// list scroll sideways. The cut is worked out on every draw, so it follows
|
||||||
|
// the window size.
|
||||||
|
type fitTitles struct{ *tview.Table }
|
||||||
|
|
||||||
|
func (f fitTitles) Draw(screen tcell.Screen) {
|
||||||
|
fitFirstColumn(f.Table)
|
||||||
|
f.Table.Draw(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fitFirstColumn cuts the title cells (those with a titleCell reference) to
|
||||||
|
// the width the table leaves after the other columns and their separators.
|
||||||
|
// The cut is done here rather than with the cell's maximum width, because tview
|
||||||
|
// only marks a cut with an ellipsis for text without color tags, and every
|
||||||
|
// title starts with the colored monitored mark.
|
||||||
|
func fitFirstColumn(t *tview.Table) {
|
||||||
|
_, _, width, _ := t.GetInnerRect()
|
||||||
|
rows, cols := t.GetRowCount(), t.GetColumnCount()
|
||||||
|
rest := 0
|
||||||
|
for c := 1; c < cols; c++ {
|
||||||
|
w := 0
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
if cell := t.GetCell(r, c); cell != nil {
|
||||||
|
w = max(w, tview.TaggedStringWidth(cell.Text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest += w + 1 // the column and the separator before it
|
||||||
|
}
|
||||||
|
limit := max(width-rest, 8)
|
||||||
|
for r := 0; r < rows; r++ {
|
||||||
|
cell := t.GetCell(r, 0)
|
||||||
|
if title, ok := cell.GetReference().(titleCell); ok {
|
||||||
|
cell.SetText(title.fit(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleCell is the text of a title cell: a tagged prefix (the monitored mark)
|
||||||
|
// and the plain title, kept whole so it can be cut again for another width.
|
||||||
|
type titleCell struct{ prefix, title string }
|
||||||
|
|
||||||
|
// fit returns the cell text at most width columns wide, the title cut with an
|
||||||
|
// ellipsis if needed; width 0 means no limit.
|
||||||
|
func (c titleCell) fit(width int) string {
|
||||||
|
title := c.title
|
||||||
|
if width > 0 {
|
||||||
|
title = cutToWidth(title, width-tview.TaggedStringWidth(c.prefix))
|
||||||
|
}
|
||||||
|
return c.prefix + tview.Escape(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cutToWidth shortens s to at most w screen columns, ending with "…" when
|
||||||
|
// anything was cut.
|
||||||
|
func cutToWidth(s string, w int) string {
|
||||||
|
width := func(s string) int { return tview.TaggedStringWidth(tview.Escape(s)) }
|
||||||
|
if width(s) <= w {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if w < 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
for n := len(runes); n > 0; n-- {
|
||||||
|
if cut := string(runes[:n]) + "…"; width(cut) <= w {
|
||||||
|
return cut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsItem(items []libItem, id int) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if it.id == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (v *libraryView) renderDetails() {
|
func (v *libraryView) renderDetails() {
|
||||||
if it, ok := v.selected(); ok {
|
if it, ok := v.selected(); ok {
|
||||||
v.details.Show(it.details, it.cover)
|
v.details.Show(it.details, it.cover)
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||||
@@ -209,3 +212,99 @@ func TestListedValues(t *testing.T) {
|
|||||||
t.Errorf("empty pool: %v", got)
|
t.Errorf("empty pool: %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemovingATitleKeepsThePlace(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
v := newLibraryView(a, a.radarr, a.sonarr)
|
||||||
|
p := v.panes[0]
|
||||||
|
items := func(ids ...int) []libItem {
|
||||||
|
var out []libItem
|
||||||
|
for _, id := range ids {
|
||||||
|
out = append(out, libItem{id: id, title: string(rune('A' + id)), label: string(rune('A' + id))})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
selectedID := func() int {
|
||||||
|
r, _ := p.table.GetSelection()
|
||||||
|
return p.shown[r-1].id
|
||||||
|
}
|
||||||
|
p.all, p.loaded = items(1, 2, 3, 4, 5), true
|
||||||
|
v.renderPane(p)
|
||||||
|
|
||||||
|
// Remove the third title: the fourth moves up into its place and is selected.
|
||||||
|
p.table.Select(3, 0)
|
||||||
|
p.all = items(1, 2, 4, 5)
|
||||||
|
v.renderPane(p)
|
||||||
|
if r, _ := p.table.GetSelection(); r != 3 || selectedID() != 4 {
|
||||||
|
t.Errorf("after removing the third: row %d, title %d", r, selectedID())
|
||||||
|
}
|
||||||
|
// Remove the last title: the new last one is selected.
|
||||||
|
p.table.Select(4, 0)
|
||||||
|
p.all = items(1, 2, 4)
|
||||||
|
v.renderPane(p)
|
||||||
|
if r, _ := p.table.GetSelection(); r != 3 || selectedID() != 4 {
|
||||||
|
t.Errorf("after removing the last: row %d, title %d", r, selectedID())
|
||||||
|
}
|
||||||
|
// A reload that keeps the title keeps it selected, wherever it is now.
|
||||||
|
p.table.Select(2, 0) // title 2
|
||||||
|
p.all = items(0, 1, 2, 4)
|
||||||
|
v.renderPane(p)
|
||||||
|
if selectedID() != 2 {
|
||||||
|
t.Errorf("kept title not followed: %d", selectedID())
|
||||||
|
}
|
||||||
|
// A title that is only filtered out still starts the list from the top.
|
||||||
|
p.table.Select(4, 0) // title 4
|
||||||
|
p.text = "B" // title 1 only: 4 is filtered out, not removed
|
||||||
|
v.renderPane(p)
|
||||||
|
if r, _ := p.table.GetSelection(); r != 1 {
|
||||||
|
t.Errorf("filtered out: row %d, want the top", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongTitlesAreCutToTheList(t *testing.T) {
|
||||||
|
sim := tcell.NewSimulationScreen("")
|
||||||
|
if err := sim.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sim.Fini()
|
||||||
|
sim.SetSize(40, 5)
|
||||||
|
|
||||||
|
tbl := tview.NewTable().SetSelectable(true, false).SetFixed(1, 0)
|
||||||
|
header(tbl, 0, "Title", "Status", "Size")
|
||||||
|
title := titleCell{prefix: monMark(true) + " ", title: "Neon Genesis Evangelion: The End of Evangelion (1997)"}
|
||||||
|
tbl.SetCell(1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title))
|
||||||
|
tbl.SetCell(1, 1, tview.NewTableCell("missing"))
|
||||||
|
tbl.SetCell(1, 2, tview.NewTableCell("12.3 GB").SetAlign(tview.AlignRight))
|
||||||
|
tbl.SetRect(0, 0, 40, 5)
|
||||||
|
fitTitles{tbl}.Draw(sim)
|
||||||
|
|
||||||
|
row := rowText(sim, 1, 0, 40)
|
||||||
|
if !strings.HasSuffix(strings.TrimRight(row, " "), "12.3 GB") || !strings.Contains(row, "missing") {
|
||||||
|
t.Errorf("status and size must stay in view: %q", row)
|
||||||
|
}
|
||||||
|
if !strings.Contains(row, "…") {
|
||||||
|
t.Errorf("the cut title should end in an ellipsis: %q", row)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(row, "● Neon Genesis") {
|
||||||
|
t.Errorf("the title should start at the left edge: %q", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCutToWidth(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in string
|
||||||
|
w int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Dune (2021)", 20, "Dune (2021)"},
|
||||||
|
{"Dune (2021)", 11, "Dune (2021)"},
|
||||||
|
{"Dune (2021)", 8, "Dune (2…"},
|
||||||
|
{"千と千尋の神隠し", 7, "千と千…"}, // wide characters count twice
|
||||||
|
{"[Rec] (2007)", 6, "[Rec]…"}, // brackets are text, not color tags
|
||||||
|
{"Dune", 0, ""},
|
||||||
|
} {
|
||||||
|
if got := cutToWidth(tc.in, tc.w); got != tc.want {
|
||||||
|
t.Errorf("cutToWidth(%q, %d) = %q, want %q", tc.in, tc.w, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain keeps the tests from asking the real terminal about graphics
|
||||||
|
// support and its colors (the probe writes to and reads from /dev/tty).
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
terminalAnswers = func() terminalInfo { return terminalInfo{} }
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func (l level) String() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (l level) color() tcell.Color {
|
func (l level) color() tcell.Color {
|
||||||
return [...]tcell.Color{tcell.ColorWhite, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorRed}[l]
|
return named([...]string{"white", "green", "yellow", "red"}[l])
|
||||||
}
|
}
|
||||||
|
|
||||||
// how long a toast stays on screen, and how long the end of that time fades.
|
// how long a toast stays on screen, and how long the end of that time fades.
|
||||||
@@ -115,7 +115,13 @@ type toastRoot struct {
|
|||||||
app *App
|
app *App
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// narrowWidth is the terminal width below which the six tabs and the full
|
||||||
|
// service names no longer fit on one line.
|
||||||
|
const narrowWidth = 92
|
||||||
|
|
||||||
func (r *toastRoot) Draw(screen tcell.Screen) {
|
func (r *toastRoot) Draw(screen tcell.Screen) {
|
||||||
|
w, _ := screen.Size()
|
||||||
|
r.app.setNarrow(w < narrowWidth)
|
||||||
r.Flex.Draw(screen)
|
r.Flex.Draw(screen)
|
||||||
a := r.app
|
a := r.app
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -136,9 +142,9 @@ func (r *toastRoot) Draw(screen tcell.Screen) {
|
|||||||
x := w - width - 1
|
x := w - width - 1
|
||||||
fg := t.level.color()
|
fg := t.level.color()
|
||||||
if t.expires.Sub(now) < toastFade {
|
if t.expires.Sub(now) < toastFade {
|
||||||
fg = tcell.ColorGray // fade out
|
fg = named("gray") // fade out
|
||||||
}
|
}
|
||||||
style := tcell.StyleDefault.Background(tcell.NewRGBColor(30, 30, 40)).Foreground(fg)
|
style := tcell.StyleDefault.Background(tokens.toast).Foreground(fg)
|
||||||
for cx := x; cx < x+width; cx++ {
|
for cx := x; cx < x+width; cx++ {
|
||||||
screen.SetContent(cx, y, ' ', nil, style)
|
screen.SetContent(cx, y, ' ', nil, style)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
@@ -39,3 +40,39 @@ func TestOnlyFullOverlays(t *testing.T) {
|
|||||||
t.Error("a dialog must block view shortcuts")
|
t.Error("a dialog must block view shortcuts")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiffHistory(t *testing.T) {
|
||||||
|
slot := func(id, name, status string) sabnzbd.HistorySlot {
|
||||||
|
return sabnzbd.HistorySlot{ID: id, Name: name, Status: status, FailMessage: map[bool]string{true: "disk full"}[status == "Failed"]}
|
||||||
|
}
|
||||||
|
seen := map[string]string{}
|
||||||
|
|
||||||
|
// The first poll only records what is already there.
|
||||||
|
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("1", "old", "Completed"), slot("2", "old fail", "Failed")}, true); len(ev) != 0 {
|
||||||
|
t.Fatalf("baseline produced %v", ev)
|
||||||
|
}
|
||||||
|
// Nothing new.
|
||||||
|
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("1", "old", "Completed")}, false); len(ev) != 0 {
|
||||||
|
t.Fatalf("unchanged history produced %v", ev)
|
||||||
|
}
|
||||||
|
// A new job that is still being processed is not final yet.
|
||||||
|
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("3", "movie", "Extracting")}, false); len(ev) != 0 {
|
||||||
|
t.Fatalf("extracting produced %v", ev)
|
||||||
|
}
|
||||||
|
// It completes, a second one fails, a third appears already completed.
|
||||||
|
ev := diffHistory(seen, []sabnzbd.HistorySlot{
|
||||||
|
slot("3", "movie", "Completed"), slot("4", "show", "Failed"), slot("5", "quick", "Completed"), slot("1", "old", "Completed"),
|
||||||
|
}, false)
|
||||||
|
if len(ev) != 3 || ev[0].name != "movie" || ev[0].failed || ev[1].name != "show" || !ev[1].failed || ev[1].message != "disk full" || ev[2].name != "quick" {
|
||||||
|
t.Fatalf("events = %+v", ev)
|
||||||
|
}
|
||||||
|
// Reported once only.
|
||||||
|
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("3", "movie", "Completed"), slot("4", "show", "Failed")}, false); len(ev) != 0 {
|
||||||
|
t.Errorf("repeated: %+v", ev)
|
||||||
|
}
|
||||||
|
// A retried job that completes later is reported again.
|
||||||
|
diffHistory(seen, []sabnzbd.HistorySlot{slot("4", "show", "Queued")}, false)
|
||||||
|
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("4", "show", "Completed")}, false); len(ev) != 1 {
|
||||||
|
t.Errorf("retry: %+v", ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// probeTerminal is not implemented here: block characters and the default
|
||||||
|
// colors are used.
|
||||||
|
func probeTerminal(time.Duration) terminalInfo { return terminalInfo{} }
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// probeTerminal asks the terminal whether it understands the Kitty graphics
|
||||||
|
// protocol (a tiny query image, which a capable terminal answers with "OK" and
|
||||||
|
// does not show) and for its background color (OSC 11), which picks the
|
||||||
|
// theme. A Primary Device
|
||||||
|
// Attributes request follows, which every terminal answers, and answers come
|
||||||
|
// in order: once the DA answer is in, whatever was not answered before it is
|
||||||
|
// not supported. Runs before the screen is set up, on the controlling terminal.
|
||||||
|
func probeTerminal(timeout time.Duration) (info terminalInfo) {
|
||||||
|
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
defer tty.Close()
|
||||||
|
fd := int(tty.Fd())
|
||||||
|
state, err := term.MakeRaw(fd)
|
||||||
|
if err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
defer term.Restore(fd, state)
|
||||||
|
|
||||||
|
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b]11;?\x1b\\" + "\x1b[c"
|
||||||
|
if _, err := tty.WriteString(query); err != nil {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
// A terminal that does not know APC sequences may print the query; erase it.
|
||||||
|
defer tty.WriteString("\r\x1b[2K")
|
||||||
|
|
||||||
|
// The wait uses select(2): a read deadline does not work on a terminal
|
||||||
|
// device on macOS (kqueue does not support them), which made the probe
|
||||||
|
// give up at once there.
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
var got []byte
|
||||||
|
buf := make([]byte, 256)
|
||||||
|
for {
|
||||||
|
left := time.Until(deadline)
|
||||||
|
if left <= 0 {
|
||||||
|
break // no complete answer in time: keep what arrived
|
||||||
|
}
|
||||||
|
var fds unix.FdSet
|
||||||
|
fds.Set(fd)
|
||||||
|
tv := unix.NsecToTimeval(left.Nanoseconds())
|
||||||
|
n, err := unix.Select(fd+1, &fds, nil, nil, &tv)
|
||||||
|
if errors.Is(err, unix.EINTR) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue // timed out; the loop ends above
|
||||||
|
}
|
||||||
|
n, err = unix.Read(fd, buf)
|
||||||
|
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
got = append(got, buf[:n]...)
|
||||||
|
if da := bytes.Index(got, []byte("\x1b[?")); da >= 0 && bytes.IndexByte(got[da:], 'c') >= 0 {
|
||||||
|
break // device attributes answered: everything else came before
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseTerminalAnswers(got)
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ func (v *releasesView) render() {
|
|||||||
|
|
||||||
v.table.Clear()
|
v.table.Clear()
|
||||||
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
|
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
|
||||||
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
|
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
|
||||||
if c == "Title" {
|
if c == "Title" {
|
||||||
cell.SetExpansion(1)
|
cell.SetExpansion(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const (
|
|||||||
type searchView struct {
|
type searchView struct {
|
||||||
app *App
|
app *App
|
||||||
be backend
|
be backend
|
||||||
cfg config.Arr
|
|
||||||
|
|
||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
input *tview.InputField
|
input *tview.InputField
|
||||||
@@ -46,11 +45,10 @@ type searchView struct {
|
|||||||
loaded bool
|
loaded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
func newSearchView(app *App, be backend) *searchView {
|
||||||
v := &searchView{
|
v := &searchView{
|
||||||
app: app,
|
app: app,
|
||||||
be: be,
|
be: be,
|
||||||
cfg: cfg,
|
|
||||||
input: tview.NewInputField().SetLabel("Search: "),
|
input: tview.NewInputField().SetLabel("Search: "),
|
||||||
list: tview.NewList().ShowSecondaryText(false),
|
list: tview.NewList().ShowSecondaryText(false),
|
||||||
details: newDetailPane(app),
|
details: newDetailPane(app),
|
||||||
@@ -106,10 +104,27 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
|
|||||||
return ev
|
return ev
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.reconnect = append(app.reconnect, v.reloadOptions)
|
||||||
go v.loadOptions()
|
go v.loadOptions()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaults returns the add-form defaults of the service, as currently configured.
|
||||||
|
func (v *searchView) defaults() config.Arr {
|
||||||
|
if v.be.name() == "Radarr" {
|
||||||
|
return v.app.cfg.Radarr
|
||||||
|
}
|
||||||
|
return v.app.cfg.Sonarr
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadOptions fetches the profiles and folders again, after the service changed.
|
||||||
|
func (v *searchView) reloadOptions() {
|
||||||
|
v.mu.Lock()
|
||||||
|
v.loaded = false
|
||||||
|
v.mu.Unlock()
|
||||||
|
go v.loadOptions()
|
||||||
|
}
|
||||||
|
|
||||||
// loadOptions checks the connection and fetches the data the add form needs.
|
// loadOptions checks the connection and fetches the data the add form needs.
|
||||||
func (v *searchView) loadOptions() {
|
func (v *searchView) loadOptions() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
@@ -301,8 +316,8 @@ func (v *searchView) add(i int) {
|
|||||||
lSearch = "Search now"
|
lSearch = "Search now"
|
||||||
)
|
)
|
||||||
form := tview.NewForm().
|
form := tview.NewForm().
|
||||||
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
|
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
|
||||||
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil)
|
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
|
||||||
height := 13
|
height := 13
|
||||||
if len(modes) > 0 {
|
if len(modes) > 0 {
|
||||||
form.AddDropDown(lEpisode, modeNames, 0, nil)
|
form.AddDropDown(lEpisode, modeNames, 0, nil)
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsValues are the settings as shown in the Config view.
|
||||||
|
type settingsValues struct {
|
||||||
|
radarrURL, radarrKey, radarrProfile, radarrFolder string
|
||||||
|
sonarrURL, sonarrKey, sonarrProfile, sonarrFolder string
|
||||||
|
sabURL, sabKey string
|
||||||
|
refresh string // seconds, as typed
|
||||||
|
covers bool
|
||||||
|
coverStyle string
|
||||||
|
notify, bell bool
|
||||||
|
theme string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
coverStyles = []string{"auto", "kitty", "sextant", "quadrant", "cells"}
|
||||||
|
themes = []string{"auto", "dark", "light"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsFrom reads the settings in use.
|
||||||
|
func settingsFrom(c *config.Config) settingsValues {
|
||||||
|
style, th := c.UI.CoverStyle, c.UI.Theme
|
||||||
|
if style == "" {
|
||||||
|
style = "auto"
|
||||||
|
}
|
||||||
|
if th == "" {
|
||||||
|
th = "auto"
|
||||||
|
}
|
||||||
|
return settingsValues{
|
||||||
|
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
|
||||||
|
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
|
||||||
|
sonarrURL: c.Sonarr.URL, sonarrKey: c.Sonarr.APIKey,
|
||||||
|
sonarrProfile: c.Sonarr.DefaultQualityProfile, sonarrFolder: c.Sonarr.DefaultRootFolder,
|
||||||
|
sabURL: c.SABnzbd.URL, sabKey: c.SABnzbd.APIKey,
|
||||||
|
refresh: strconv.Itoa(c.UI.RefreshSeconds),
|
||||||
|
covers: c.UI.ShowCovers(), coverStyle: style,
|
||||||
|
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
|
||||||
|
theme: th,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applySettings returns raw (the config file's contents) with the edited
|
||||||
|
// values. Settings the environment overrides are left as the file has them, so
|
||||||
|
// a value that only came from the environment is never written to disk. The
|
||||||
|
// result is checked the way the program checks its configuration.
|
||||||
|
func applySettings(raw config.Config, v settingsValues, overridden map[string]string) (config.Config, error) {
|
||||||
|
set := func(key string, dst *string, value string) {
|
||||||
|
if _, env := overridden[key]; !env {
|
||||||
|
*dst = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set("radarr.url", &raw.Radarr.URL, v.radarrURL)
|
||||||
|
set("radarr.apikey", &raw.Radarr.APIKey, v.radarrKey)
|
||||||
|
set("sonarr.url", &raw.Sonarr.URL, v.sonarrURL)
|
||||||
|
set("sonarr.apikey", &raw.Sonarr.APIKey, v.sonarrKey)
|
||||||
|
set("sabnzbd.url", &raw.SABnzbd.URL, v.sabURL)
|
||||||
|
set("sabnzbd.apikey", &raw.SABnzbd.APIKey, v.sabKey)
|
||||||
|
raw.Radarr.DefaultQualityProfile = strings.TrimSpace(v.radarrProfile)
|
||||||
|
raw.Radarr.DefaultRootFolder = strings.TrimSpace(v.radarrFolder)
|
||||||
|
raw.Sonarr.DefaultQualityProfile = strings.TrimSpace(v.sonarrProfile)
|
||||||
|
raw.Sonarr.DefaultRootFolder = strings.TrimSpace(v.sonarrFolder)
|
||||||
|
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(v.refresh))
|
||||||
|
if err != nil || n < 1 || n > 3600 {
|
||||||
|
return raw, fmt.Errorf("refresh interval: %q is not a number of seconds between 1 and 3600", v.refresh)
|
||||||
|
}
|
||||||
|
raw.UI.RefreshSeconds = n
|
||||||
|
// Defaults stay out of the file unless the file already named them.
|
||||||
|
raw.UI.Covers = boolSetting(raw.UI.Covers, v.covers, true)
|
||||||
|
raw.UI.NotifyDownloads = boolSetting(raw.UI.NotifyDownloads, v.notify, true)
|
||||||
|
raw.UI.Bell = v.bell
|
||||||
|
raw.UI.CoverStyle = v.coverStyle
|
||||||
|
if v.coverStyle == "auto" {
|
||||||
|
raw.UI.CoverStyle = ""
|
||||||
|
}
|
||||||
|
raw.UI.Theme = v.theme
|
||||||
|
if v.theme == "auto" {
|
||||||
|
raw.UI.Theme = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := raw.Effective(os.Getenv); err != nil {
|
||||||
|
return raw, err
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolSetting keeps an optional boolean out of the file while it equals its
|
||||||
|
// default and was not in the file before.
|
||||||
|
func boolSetting(old *bool, value, def bool) *bool {
|
||||||
|
if old == nil && value == def {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsView edits the configuration file's settings. Everything applies
|
||||||
|
// right after saving.
|
||||||
|
type settingsView struct {
|
||||||
|
app *App
|
||||||
|
root *tview.Flex
|
||||||
|
|
||||||
|
form *tview.Form // every field, one below the other, with section headings
|
||||||
|
testItem *formAction // the Test button, below the service settings
|
||||||
|
actions *tview.Form // the buttons below it
|
||||||
|
groups []*tview.Form // keyboard order: the fields, then the buttons
|
||||||
|
status *tview.TextView
|
||||||
|
|
||||||
|
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
||||||
|
choices map[string]*serverChoice // "radarr.profile", "radarr.folder", ...
|
||||||
|
|
||||||
|
// Loading the choices from the servers, per service.
|
||||||
|
choiceTimers map[string]*time.Timer
|
||||||
|
choiceGen map[string]int
|
||||||
|
choiceErr map[string]string // last error reported, so it is not repeated
|
||||||
|
refresh *tview.InputField
|
||||||
|
covers *tview.Checkbox
|
||||||
|
style *tview.DropDown
|
||||||
|
theme *tview.DropDown
|
||||||
|
notify *tview.Checkbox
|
||||||
|
bell *tview.Checkbox
|
||||||
|
showKeys *tview.Checkbox
|
||||||
|
overridden map[string]string
|
||||||
|
dirty bool
|
||||||
|
filling bool // set while the fields are filled, so that does not count as an edit
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsHints() string {
|
||||||
|
return "↑↓ move through the fields · Space toggles or presses a button · Tab jumps between fields and Save/Revert"
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSettingsView(app *App) *settingsView {
|
||||||
|
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv),
|
||||||
|
choices: map[string]*serverChoice{}, choiceTimers: map[string]*time.Timer{}, choiceGen: map[string]int{}, choiceErr: map[string]string{}}
|
||||||
|
quiet := func() func() {
|
||||||
|
was := v.filling
|
||||||
|
v.filling = true
|
||||||
|
return func() { v.filling = was }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One form with every field below the other: ↑↓ moves through all of them
|
||||||
|
// and the form scrolls to keep the focused field in view.
|
||||||
|
f := tview.NewForm().SetItemPadding(0)
|
||||||
|
f.SetBorder(true).SetTitle(" Settings ")
|
||||||
|
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
||||||
|
v.form = f
|
||||||
|
|
||||||
|
input := func(label, key string) {
|
||||||
|
in := tview.NewInputField().SetLabel(label)
|
||||||
|
if strings.HasSuffix(key, ".apikey") {
|
||||||
|
in.SetMaskCharacter('*')
|
||||||
|
}
|
||||||
|
if _, ok := v.overridden[key]; ok {
|
||||||
|
in.SetLabel(label + " (env)")
|
||||||
|
in.SetDisabled(true)
|
||||||
|
}
|
||||||
|
in.SetChangedFunc(func(string) {
|
||||||
|
v.edited()
|
||||||
|
// New address or key: ask that server for its profiles and folders.
|
||||||
|
if service, field, _ := strings.Cut(key, "."); service != "sabnzbd" && !v.filling && (field == "url" || field == "apikey") {
|
||||||
|
v.scheduleChoices(service)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
f.AddFormItem(in)
|
||||||
|
v.inputs[key] = in
|
||||||
|
}
|
||||||
|
choice := func(label, key string) {
|
||||||
|
c := newServerChoice(label, quiet, v.edited)
|
||||||
|
f.AddFormItem(c)
|
||||||
|
v.choices[key] = c
|
||||||
|
}
|
||||||
|
service := func(title, name string, withDefaults bool) {
|
||||||
|
f.AddFormItem(newFormHeading(title))
|
||||||
|
input("Address", name+".url")
|
||||||
|
input("API key", name+".apikey")
|
||||||
|
if withDefaults {
|
||||||
|
choice("Default quality profile", name+".profile")
|
||||||
|
choice("Default root folder", name+".folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service("Radarr", "radarr", true)
|
||||||
|
service("Sonarr", "sonarr", true)
|
||||||
|
service("SABnzbd", "sabnzbd", false)
|
||||||
|
// Right below the connection settings it checks.
|
||||||
|
v.testItem = newFormAction("Test", v.test)
|
||||||
|
f.AddFormItem(v.testItem)
|
||||||
|
|
||||||
|
f.AddFormItem(newFormHeading("Interface"))
|
||||||
|
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
||||||
|
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
||||||
|
f.AddFormItem(v.refresh)
|
||||||
|
v.covers = tview.NewCheckbox().SetLabel("Posters").SetChangedFunc(func(bool) { v.edited() })
|
||||||
|
f.AddFormItem(v.covers)
|
||||||
|
v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() })
|
||||||
|
f.AddFormItem(v.style)
|
||||||
|
v.theme = tview.NewDropDown().SetLabel("Theme").SetOptions(themes, func(string, int) { v.edited() })
|
||||||
|
f.AddFormItem(v.theme)
|
||||||
|
v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() })
|
||||||
|
f.AddFormItem(v.notify)
|
||||||
|
v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() })
|
||||||
|
f.AddFormItem(v.bell)
|
||||||
|
v.showKeys = tview.NewCheckbox().SetLabel("Show API keys").SetChangedFunc(func(shown bool) { v.maskKeys(!shown) })
|
||||||
|
f.AddFormItem(v.showKeys)
|
||||||
|
|
||||||
|
v.actions = tview.NewForm().SetItemPadding(0)
|
||||||
|
v.actions.SetBorder(true).SetTitle(" Actions ")
|
||||||
|
v.actions.SetBorderPadding(0, 0, 1, 1)
|
||||||
|
v.actions.SetButtonsAlign(tview.AlignCenter)
|
||||||
|
v.actions.AddButton("Save", v.save)
|
||||||
|
v.actions.AddButton("Revert", v.revert)
|
||||||
|
|
||||||
|
v.status = tview.NewTextView().SetDynamicColors(true).SetWordWrap(true)
|
||||||
|
v.status.SetBorder(true).SetTitle(" Result ")
|
||||||
|
|
||||||
|
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
|
AddItem(v.form, 0, 1, true).
|
||||||
|
AddItem(v.actions, 4, 0, false)
|
||||||
|
v.root = tview.NewFlex().AddItem(left, 0, 3, true).AddItem(v.status, 0, 2, false)
|
||||||
|
|
||||||
|
v.groups = []*tview.Form{v.form, v.actions}
|
||||||
|
for i, g := range v.groups {
|
||||||
|
i := i
|
||||||
|
arrowNavigation(g, func() {}, &browseHooks{
|
||||||
|
// ↓ past the last field reaches the buttons, ↑ from them goes back.
|
||||||
|
edge: func(down bool) bool {
|
||||||
|
if down && i+1 < len(v.groups) {
|
||||||
|
v.focusGroup(i+1, false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !down && i > 0 {
|
||||||
|
v.focusGroup(i-1, true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
tab: func(dir int) bool {
|
||||||
|
v.focusGroup((i+dir+len(v.groups))%len(v.groups), false)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
v.fill(settingsFrom(app.cfg))
|
||||||
|
v.maskKeys(true)
|
||||||
|
v.describe()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusGroup moves the focus to a group, to its first or last field.
|
||||||
|
func (v *settingsView) focusGroup(i int, last bool) {
|
||||||
|
// tview only blurs the leaf that had the focus, not the other items of a
|
||||||
|
// form or the form itself; a form that keeps thinking it has focus would
|
||||||
|
// take the key presses.
|
||||||
|
for j, other := range v.groups {
|
||||||
|
if j != i {
|
||||||
|
blurForm(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g := v.groups[i]
|
||||||
|
idx := firstFocusable(g)
|
||||||
|
if last {
|
||||||
|
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
|
||||||
|
}
|
||||||
|
g.SetFocus(idx)
|
||||||
|
v.app.app.SetFocus(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// blurForm takes the focus away from a form and everything in it.
|
||||||
|
func blurForm(f *tview.Form) {
|
||||||
|
for i := 0; i < f.GetFormItemCount(); i++ {
|
||||||
|
f.GetFormItem(i).Blur()
|
||||||
|
}
|
||||||
|
for i := 0; i < f.GetButtonCount(); i++ {
|
||||||
|
f.GetButton(i).Blur()
|
||||||
|
}
|
||||||
|
f.Blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *settingsView) edited() {
|
||||||
|
if !v.filling {
|
||||||
|
v.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskKeys hides or shows the API keys.
|
||||||
|
func (v *settingsView) maskKeys(hide bool) {
|
||||||
|
for key, in := range v.inputs {
|
||||||
|
if strings.HasSuffix(key, ".apikey") {
|
||||||
|
if hide {
|
||||||
|
in.SetMaskCharacter('*')
|
||||||
|
} else {
|
||||||
|
in.SetMaskCharacter(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fill puts the values into the fields.
|
||||||
|
func (v *settingsView) fill(s settingsValues) {
|
||||||
|
v.filling = true
|
||||||
|
defer func() { v.filling = false }()
|
||||||
|
set := map[string]string{
|
||||||
|
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey,
|
||||||
|
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey,
|
||||||
|
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
||||||
|
}
|
||||||
|
for key, value := range set {
|
||||||
|
v.inputs[key].SetText(value)
|
||||||
|
}
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
|
||||||
|
"sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
|
||||||
|
} {
|
||||||
|
v.choices[key].set(value)
|
||||||
|
}
|
||||||
|
v.refresh.SetText(s.refresh)
|
||||||
|
v.covers.SetChecked(s.covers)
|
||||||
|
for i, name := range coverStyles {
|
||||||
|
if name == s.coverStyle {
|
||||||
|
v.style.SetCurrentOption(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, name := range themes {
|
||||||
|
if name == s.theme {
|
||||||
|
v.theme.SetCurrentOption(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.notify.SetChecked(s.notify)
|
||||||
|
v.bell.SetChecked(s.bell)
|
||||||
|
v.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// read collects the values from the fields.
|
||||||
|
func (v *settingsView) read() settingsValues {
|
||||||
|
text := func(key string) string {
|
||||||
|
if c, ok := v.choices[key]; ok {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
return v.inputs[key].GetText()
|
||||||
|
}
|
||||||
|
i, _ := v.style.GetCurrentOption()
|
||||||
|
style := "auto"
|
||||||
|
if i >= 0 && i < len(coverStyles) {
|
||||||
|
style = coverStyles[i]
|
||||||
|
}
|
||||||
|
th := "auto"
|
||||||
|
if i, _ := v.theme.GetCurrentOption(); i >= 0 && i < len(themes) {
|
||||||
|
th = themes[i]
|
||||||
|
}
|
||||||
|
return settingsValues{
|
||||||
|
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
|
||||||
|
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
|
||||||
|
sonarrURL: text("sonarr.url"), sonarrKey: text("sonarr.apikey"),
|
||||||
|
sonarrProfile: text("sonarr.profile"), sonarrFolder: text("sonarr.folder"),
|
||||||
|
sabURL: text("sabnzbd.url"), sabKey: text("sabnzbd.apikey"),
|
||||||
|
refresh: v.refresh.GetText(),
|
||||||
|
covers: v.covers.IsChecked(), coverStyle: style,
|
||||||
|
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
|
||||||
|
theme: th,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// describe writes the standing information into the result box.
|
||||||
|
func (v *settingsView) describe() {
|
||||||
|
var b strings.Builder
|
||||||
|
if v.app.cfgPath == "" {
|
||||||
|
b.WriteString("[yellow]No config file is known, changes cannot be saved.[-]\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
|
||||||
|
}
|
||||||
|
b.WriteString("Changes take effect when you save; nothing needs a restart.\n")
|
||||||
|
if !v.app.cfg.AnyConfigured() {
|
||||||
|
b.WriteString("[yellow]No service is set up yet.[-] Enter the address and API key of Radarr, Sonarr and/or SABnzbd (Settings → General in each app), then Save. A service left empty is simply not used.\n")
|
||||||
|
}
|
||||||
|
if len(v.overridden) > 0 {
|
||||||
|
var names []string
|
||||||
|
for _, env := range v.overridden {
|
||||||
|
names = append(names, env)
|
||||||
|
}
|
||||||
|
b.WriteString("[gray]Greyed-out fields come from the environment (" + strings.Join(names, ", ") + ") and are not written to the file.[-]\n")
|
||||||
|
}
|
||||||
|
v.status.SetText(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// activate refreshes the fields when the view is shown, unless they hold unsaved edits.
|
||||||
|
func (v *settingsView) activate() {
|
||||||
|
if !v.dirty {
|
||||||
|
v.fill(settingsFrom(v.app.cfg))
|
||||||
|
}
|
||||||
|
v.loadChoices("radarr")
|
||||||
|
v.loadChoices("sonarr")
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// report appends a line to the result box.
|
||||||
|
func (v *settingsView) report(line string) {
|
||||||
|
fmt.Fprintln(v.status, line)
|
||||||
|
v.status.ScrollToEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *settingsView) revert() {
|
||||||
|
v.fill(settingsFrom(v.app.cfg))
|
||||||
|
v.loadChoices("radarr")
|
||||||
|
v.loadChoices("sonarr")
|
||||||
|
v.describe()
|
||||||
|
v.report("[green]Reverted to the saved settings.[-]")
|
||||||
|
}
|
||||||
|
|
||||||
|
// test checks the three services with the values in the fields, without saving.
|
||||||
|
func (v *settingsView) test() {
|
||||||
|
s := v.read()
|
||||||
|
v.describe()
|
||||||
|
v.report("Testing the connections…")
|
||||||
|
type probeFn func(ctx context.Context) (string, error)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run probeFn
|
||||||
|
}{
|
||||||
|
{"Radarr", func(ctx context.Context) (string, error) {
|
||||||
|
st, err := arr.NewClient(strings.TrimSpace(s.radarrURL), strings.TrimSpace(s.radarrKey)).SystemStatus(ctx)
|
||||||
|
return st.Version, err
|
||||||
|
}},
|
||||||
|
{"Sonarr", func(ctx context.Context) (string, error) {
|
||||||
|
st, err := arr.NewClient(strings.TrimSpace(s.sonarrURL), strings.TrimSpace(s.sonarrKey)).SystemStatus(ctx)
|
||||||
|
return st.Version, err
|
||||||
|
}},
|
||||||
|
{"SABnzbd", func(ctx context.Context) (string, error) {
|
||||||
|
return sabnzbd.NewClient(strings.TrimSpace(s.sabURL), strings.TrimSpace(s.sabKey)).Version(ctx)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, t := range tests {
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
version, err := t.run(ctx)
|
||||||
|
v.app.post(func() {
|
||||||
|
if err != nil {
|
||||||
|
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// save writes the config file and applies the settings.
|
||||||
|
func (v *settingsView) save() {
|
||||||
|
v.describe()
|
||||||
|
if v.app.cfgPath == "" || v.app.cfgFile == nil {
|
||||||
|
v.report("[red]There is no config file to save to.[-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := applySettings(*v.app.cfgFile, v.read(), v.overridden)
|
||||||
|
if err != nil {
|
||||||
|
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := config.Save(v.app.cfgPath, &raw); err != nil {
|
||||||
|
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
effective, err := raw.Effective(os.Getenv)
|
||||||
|
if err != nil { // cannot happen: applySettings validated it
|
||||||
|
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.app.cfgFile = &raw
|
||||||
|
v.app.applyConfig(effective)
|
||||||
|
v.dirty = false
|
||||||
|
v.describe() // what it says may depend on the settings (e.g. nothing set up yet)
|
||||||
|
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
|
||||||
|
v.app.Notify(levelSuccess, "Settings saved")
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverChoice is a setting whose values come from the server: a dropdown of
|
||||||
|
// the names Radarr/Sonarr know (quality profiles, root folders), with "(none)"
|
||||||
|
// first. The value in use is kept as an option even when the server does not
|
||||||
|
// list it (or cannot be asked), so nothing is lost by opening the view.
|
||||||
|
type serverChoice struct {
|
||||||
|
*tview.DropDown
|
||||||
|
value string // the selected value; "" = none
|
||||||
|
names []string // from the server, in its order
|
||||||
|
loaded bool // names came from the server
|
||||||
|
quiet func() func()
|
||||||
|
picked func(text string, index int) // SetOptions replaces it, so it is kept here
|
||||||
|
}
|
||||||
|
|
||||||
|
const noChoice = "(none)"
|
||||||
|
|
||||||
|
// newServerChoice makes the dropdown. quiet marks changes as not made by the
|
||||||
|
// user while the options are rebuilt; it returns the function that ends that.
|
||||||
|
func newServerChoice(label string, quiet func() func(), changed func()) *serverChoice {
|
||||||
|
c := &serverChoice{DropDown: tview.NewDropDown().SetLabel(label), quiet: quiet}
|
||||||
|
c.picked = func(_ string, i int) {
|
||||||
|
c.value = c.valueAt(i)
|
||||||
|
changed()
|
||||||
|
}
|
||||||
|
c.rebuild()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// options lists the values in the order shown: none, the server's names, and
|
||||||
|
// the value in use when the server does not have it.
|
||||||
|
func (c *serverChoice) options() []string {
|
||||||
|
opts := append([]string{""}, c.names...)
|
||||||
|
if c.value != "" && !contains(c.names, c.value) {
|
||||||
|
opts = append(opts, c.value)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *serverChoice) valueAt(i int) string {
|
||||||
|
opts := c.options()
|
||||||
|
if i < 0 || i >= len(opts) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return opts[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuild puts the options into the dropdown and selects the value.
|
||||||
|
func (c *serverChoice) rebuild() {
|
||||||
|
done := c.quiet()
|
||||||
|
defer done()
|
||||||
|
value := c.value
|
||||||
|
var labels []string
|
||||||
|
sel := 0
|
||||||
|
for i, o := range c.options() {
|
||||||
|
label := o
|
||||||
|
switch {
|
||||||
|
case o == "":
|
||||||
|
label = noChoice
|
||||||
|
case c.loaded && !contains(c.names, o):
|
||||||
|
label = o + " (not on the server)"
|
||||||
|
}
|
||||||
|
labels = append(labels, label)
|
||||||
|
if o == value {
|
||||||
|
sel = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.value = value
|
||||||
|
c.SetOptions(labels, c.picked)
|
||||||
|
c.SetCurrentOption(sel) // reports sel, which is value
|
||||||
|
}
|
||||||
|
|
||||||
|
// set selects a value.
|
||||||
|
func (c *serverChoice) set(value string) {
|
||||||
|
c.value = strings.TrimSpace(value)
|
||||||
|
c.rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNames replaces the server's names; nil with loaded false means they are
|
||||||
|
// not known.
|
||||||
|
func (c *serverChoice) setNames(names []string, loaded bool) {
|
||||||
|
c.names, c.loaded = names, loaded
|
||||||
|
c.rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, s string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// choicesDelay is how long typing in an address or API key pauses before the
|
||||||
|
// server is asked for its profiles and folders.
|
||||||
|
const choicesDelay = 700 * time.Millisecond
|
||||||
|
|
||||||
|
// scheduleChoices asks the service again once its address and key have not
|
||||||
|
// changed for choicesDelay.
|
||||||
|
func (v *settingsView) scheduleChoices(service string) {
|
||||||
|
if t := v.choiceTimers[service]; t != nil {
|
||||||
|
t.Stop()
|
||||||
|
}
|
||||||
|
v.choiceTimers[service] = time.AfterFunc(choicesDelay, func() {
|
||||||
|
v.app.post(func() { v.loadChoices(service) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadChoices fills the profile and folder dropdowns of a service ("radarr" or
|
||||||
|
// "sonarr") from the server, using the address and key typed in the fields.
|
||||||
|
func (v *settingsView) loadChoices(service string) {
|
||||||
|
profile, folder := v.choices[service+".profile"], v.choices[service+".folder"]
|
||||||
|
url := strings.TrimSpace(v.inputs[service+".url"].GetText())
|
||||||
|
key := strings.TrimSpace(v.inputs[service+".apikey"].GetText())
|
||||||
|
v.choiceGen[service]++
|
||||||
|
gen := v.choiceGen[service]
|
||||||
|
if url == "" || key == "" {
|
||||||
|
profile.setNames(nil, false)
|
||||||
|
folder.setNames(nil, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
profiles, folders, err := fetchChoices(ctx, url, key)
|
||||||
|
v.app.post(func() { v.applyChoices(service, gen, profiles, folders, err) })
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchChoices asks a Radarr/Sonarr for its quality profile names and root
|
||||||
|
// folder paths.
|
||||||
|
func fetchChoices(ctx context.Context, url, key string) (profiles, folders []string, err error) {
|
||||||
|
client := arr.NewClient(url, key)
|
||||||
|
qp, err := client.QualityProfiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rf, err := client.RootFolders(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, p := range qp {
|
||||||
|
profiles = append(profiles, p.Name)
|
||||||
|
}
|
||||||
|
for _, f := range rf {
|
||||||
|
folders = append(folders, f.Path)
|
||||||
|
}
|
||||||
|
return profiles, folders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyChoices puts what fetchChoices found into the dropdowns, unless the
|
||||||
|
// address or key changed since it was asked (gen).
|
||||||
|
func (v *settingsView) applyChoices(service string, gen int, profiles, folders []string, err error) {
|
||||||
|
if v.choiceGen[service] != gen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, folder := v.choices[service+".profile"], v.choices[service+".folder"]
|
||||||
|
if err != nil {
|
||||||
|
profile.setNames(nil, false)
|
||||||
|
folder.setNames(nil, false)
|
||||||
|
name := map[string]string{"radarr": "Radarr", "sonarr": "Sonarr"}[service]
|
||||||
|
if msg := err.Error(); v.choiceErr[service] != msg {
|
||||||
|
v.choiceErr[service] = msg
|
||||||
|
v.report("[yellow]" + name + ": quality profiles and root folders not loaded: " + tview.Escape(msg) + "[-]")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(v.choiceErr, service)
|
||||||
|
profile.setNames(profiles, true)
|
||||||
|
folder.setNames(folders, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func baseRaw() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
Radarr: config.Arr{Service: config.Service{URL: "http://r:7878", APIKey: "rk"}},
|
||||||
|
Sonarr: config.Arr{Service: config.Service{URL: "http://s:8989", APIKey: "sk"}},
|
||||||
|
SABnzbd: config.Service{URL: "http://a:8080", APIKey: "ak"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySettings(t *testing.T) {
|
||||||
|
raw := baseRaw()
|
||||||
|
v := settingsFrom(mustEffective(t, raw))
|
||||||
|
if v.refresh != "3" || !v.covers || !v.notify || v.bell || v.coverStyle != "auto" {
|
||||||
|
t.Fatalf("defaults shown wrong: %+v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged settings add nothing but the refresh interval to the file.
|
||||||
|
got, err := applySettings(raw, v, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.UI.Covers != nil || got.UI.NotifyDownloads != nil || got.UI.CoverStyle != "" || got.UI.Bell {
|
||||||
|
t.Errorf("defaults written to the file: %+v", got.UI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edits are applied and whitespace is trimmed.
|
||||||
|
v.radarrURL = " http://new:7878 "
|
||||||
|
v.radarrProfile = " HD-1080p "
|
||||||
|
v.sabKey = "newkey"
|
||||||
|
v.refresh = "10"
|
||||||
|
v.covers = false
|
||||||
|
v.notify = false
|
||||||
|
v.bell = true
|
||||||
|
v.coverStyle = "quadrant"
|
||||||
|
v.theme = "light"
|
||||||
|
got, err = applySettings(raw, v, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" ||
|
||||||
|
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" || got.UI.Theme != "light" {
|
||||||
|
t.Errorf("edits lost: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A setting that was explicit in the file stays explicit, even at its default.
|
||||||
|
yes := true
|
||||||
|
raw.UI.Covers = &yes
|
||||||
|
v.covers = true
|
||||||
|
got, _ = applySettings(raw, v, nil)
|
||||||
|
if got.UI.Covers == nil || !*got.UI.Covers {
|
||||||
|
t.Error("an explicit setting was dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySettingsValidates(t *testing.T) {
|
||||||
|
v := settingsFrom(mustEffective(t, baseRaw()))
|
||||||
|
for name, mutate := range map[string]func(*settingsValues){
|
||||||
|
"refresh not a number": func(v *settingsValues) { v.refresh = "often" },
|
||||||
|
"refresh zero": func(v *settingsValues) { v.refresh = "0" },
|
||||||
|
"refresh too large": func(v *settingsValues) { v.refresh = "99999" },
|
||||||
|
"address not http": func(v *settingsValues) { v.sonarrURL = "ftp://x" },
|
||||||
|
"empty api key": func(v *settingsValues) { v.radarrKey = " " },
|
||||||
|
"empty address": func(v *settingsValues) { v.sabURL = "" },
|
||||||
|
} {
|
||||||
|
w := v
|
||||||
|
mutate(&w)
|
||||||
|
if _, err := applySettings(baseRaw(), w, nil); err == nil {
|
||||||
|
t.Errorf("%s: accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplySettingsKeepsEnvironmentValuesOutOfTheFile(t *testing.T) {
|
||||||
|
raw := baseRaw()
|
||||||
|
// The fields show the key from the environment...
|
||||||
|
shown := settingsFrom(mustEffective(t, raw))
|
||||||
|
shown.radarrKey = "secret-from-env"
|
||||||
|
shown.radarrURL = "http://typed-over-env"
|
||||||
|
got, err := applySettings(raw, shown, map[string]string{"radarr.apikey": "ARR_TUI_RADARR_APIKEY", "radarr.url": "ARR_TUI_RADARR_URL"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// ...but the file keeps what it had.
|
||||||
|
if got.Radarr.APIKey != "rk" || got.Radarr.URL != "http://r:7878" {
|
||||||
|
t.Errorf("environment values leaked into the file: %+v", got.Radarr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustEffective(t *testing.T, raw config.Config) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
eff, err := raw.Effective(func(string) string { return "" })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return eff
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
|
||||||
|
t.Setenv("ARR_TUI_RADARR_APIKEY", "") // make sure the environment does not interfere
|
||||||
|
var gotKey atomic.Value // the app also probes the server in the background
|
||||||
|
radarr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey.Store(r.Header.Get("X-Api-Key"))
|
||||||
|
w.Write([]byte(`{"version":"6.0"}`))
|
||||||
|
}))
|
||||||
|
defer radarr.Close()
|
||||||
|
|
||||||
|
a := newTestApp(t)
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
raw := baseRaw()
|
||||||
|
if err := config.Save(path, &raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, _ := config.LoadFile(path)
|
||||||
|
a.SetConfigSource(path, loaded)
|
||||||
|
v := a.settings
|
||||||
|
v.fill(settingsFrom(a.cfg))
|
||||||
|
|
||||||
|
// Edit the fields like a user would and save.
|
||||||
|
v.inputs["radarr.url"].SetText(radarr.URL)
|
||||||
|
v.inputs["radarr.apikey"].SetText("brand-new-key")
|
||||||
|
v.choices["sonarr.profile"].set("Any")
|
||||||
|
v.edited()
|
||||||
|
v.refresh.SetText("7")
|
||||||
|
v.bell.SetChecked(true)
|
||||||
|
if !v.dirty {
|
||||||
|
t.Error("editing must mark the form as changed")
|
||||||
|
}
|
||||||
|
v.save()
|
||||||
|
|
||||||
|
// 1. The file has the new values and stays private.
|
||||||
|
saved, err := config.LoadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if saved.Radarr.URL != radarr.URL || saved.Radarr.APIKey != "brand-new-key" || saved.Sonarr.DefaultQualityProfile != "Any" || saved.UI.RefreshSeconds != 7 || !saved.UI.Bell {
|
||||||
|
t.Errorf("file = %+v", saved)
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("mode %v", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
// 2. The running app uses them at once.
|
||||||
|
if a.cfg.Radarr.URL != radarr.URL || !a.bell || a.cfg.UI.RefreshSeconds != 7 {
|
||||||
|
t.Errorf("settings not applied: %+v", a.cfg)
|
||||||
|
}
|
||||||
|
if _, err := a.radarr.SystemStatus(t.Context()); err != nil || gotKey.Load() != "brand-new-key" {
|
||||||
|
t.Errorf("the client still talks to the old instance: key=%v err=%v", gotKey.Load(), err)
|
||||||
|
}
|
||||||
|
if got := a.downloads.interval.Seconds(); got != 7 {
|
||||||
|
t.Errorf("downloads interval = %v", got)
|
||||||
|
}
|
||||||
|
if v.dirty {
|
||||||
|
t.Error("saving must clear the changed mark")
|
||||||
|
}
|
||||||
|
if !strings.Contains(v.status.GetText(false), "Saved") {
|
||||||
|
t.Errorf("result box: %q", v.status.GetText(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invalid edit is refused and leaves the file alone.
|
||||||
|
before, _ := os.ReadFile(path)
|
||||||
|
v.refresh.SetText("soon")
|
||||||
|
v.save()
|
||||||
|
after, _ := os.ReadFile(path)
|
||||||
|
if string(before) != string(after) || !strings.Contains(v.status.GetText(false), "Not saved") {
|
||||||
|
t.Errorf("invalid settings were saved: %q", v.status.GetText(false))
|
||||||
|
}
|
||||||
|
// Revert goes back to what is in use.
|
||||||
|
v.revert()
|
||||||
|
if v.refresh.GetText() != "7" || v.dirty {
|
||||||
|
t.Errorf("revert: refresh=%q dirty=%v", v.refresh.GetText(), v.dirty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsViewWithoutConfigFile(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
a.settings.save()
|
||||||
|
if !strings.Contains(a.settings.status.GetText(false), "no config file") {
|
||||||
|
t.Errorf("result box: %q", a.settings.status.GetText(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsViewIsOneListNavigatedWithArrows(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
v := a.settings
|
||||||
|
var focus func(p tview.Primitive)
|
||||||
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
|
|
||||||
|
// Whatever focus flags were left behind, moving to the buttons must leave exactly one group focused.
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
v.form.Focus(focus)
|
||||||
|
v.actions.Focus(focus)
|
||||||
|
v.focusGroup(1, false)
|
||||||
|
if v.form.HasFocus() || !v.actions.HasFocus() {
|
||||||
|
t.Error("only the buttons may have the focus")
|
||||||
|
}
|
||||||
|
|
||||||
|
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||||
|
focusedItem := func() int { i, _ := v.form.GetFocusedItemIndex(); return i }
|
||||||
|
labelAt := func(i int) string { return v.form.GetFormItem(i).GetLabel() }
|
||||||
|
|
||||||
|
// The list starts on the first real field, not on the "Radarr" heading.
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
if i := focusedItem(); labelAt(i) != "Address" || i != 1 {
|
||||||
|
t.Fatalf("starts on item %d (%q)", i, labelAt(i))
|
||||||
|
}
|
||||||
|
// ↓ walks through the fields of a section and over the next heading into the next section.
|
||||||
|
for _, want := range []string{"API key", "Default quality profile", "Default root folder", "Address"} {
|
||||||
|
send(v.form, tcell.KeyDown)
|
||||||
|
if got := labelAt(focusedItem()); got != want {
|
||||||
|
t.Fatalf("after ↓ on %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if focusedItem() != 6 { // 5 is the Sonarr heading
|
||||||
|
t.Errorf("↓ must skip the section heading, now on item %d", focusedItem())
|
||||||
|
}
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
if focusedItem() != 3 && focusedItem() != 4 {
|
||||||
|
t.Errorf("↑ must also skip the heading, now on item %d", focusedItem())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ↓ on the very last field moves to the buttons; ↑ on a button goes back to the last field.
|
||||||
|
v.focusGroup(0, true)
|
||||||
|
if got := labelAt(focusedItem()); got != "Show API keys" {
|
||||||
|
t.Fatalf("last field = %q", got)
|
||||||
|
}
|
||||||
|
send(v.form, tcell.KeyDown)
|
||||||
|
if !v.actions.HasFocus() || v.form.HasFocus() {
|
||||||
|
t.Error("↓ past the last field must reach the buttons")
|
||||||
|
}
|
||||||
|
send(v.actions, tcell.KeyUp)
|
||||||
|
if !v.form.HasFocus() || v.actions.HasFocus() || labelAt(focusedItem()) != "Show API keys" {
|
||||||
|
t.Error("↑ on the buttons must go back to the last field")
|
||||||
|
}
|
||||||
|
// ↑ on the first field has nowhere to go.
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
if focusedItem() != 1 {
|
||||||
|
t.Errorf("↑ on the first field moved to item %d", focusedItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormHeadingNeverTakesFocus(t *testing.T) {
|
||||||
|
f := tview.NewForm()
|
||||||
|
f.AddFormItem(newFormHeading("Section"))
|
||||||
|
f.AddInputField("One", "", 10, nil, nil)
|
||||||
|
f.AddFormItem(newFormHeading("Other"))
|
||||||
|
f.AddInputField("Two", "", 10, nil, nil)
|
||||||
|
if firstFocusable(f) != 1 {
|
||||||
|
t.Errorf("first focusable = %d", firstFocusable(f))
|
||||||
|
}
|
||||||
|
if neighbour(f, 1, 1) != 3 || neighbour(f, 3, -1) != 1 || neighbour(f, 3, 1) != 3 || neighbour(f, 1, -1) != 1 {
|
||||||
|
t.Error("neighbour must skip headings and stay at the ends")
|
||||||
|
}
|
||||||
|
// tview's own Tab handling passes over a heading as well.
|
||||||
|
var focus func(p tview.Primitive)
|
||||||
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
|
f.Focus(focus)
|
||||||
|
if i, _ := f.GetFocusedItemIndex(); i == 0 {
|
||||||
|
t.Error("a heading got the focus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
v := a.settings
|
||||||
|
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||||
|
focusedItem := func() tview.FormItem { i, _ := v.form.GetFocusedItemIndex(); return v.form.GetFormItem(i) }
|
||||||
|
|
||||||
|
// It comes right after the SABnzbd API key and before the Interface section.
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
for i := 0; i < 10; i++ { // Radarr 4, Sonarr 4, SABnzbd 2 fields
|
||||||
|
send(v.form, tcell.KeyDown)
|
||||||
|
}
|
||||||
|
if focusedItem() != tview.FormItem(v.testItem) {
|
||||||
|
t.Fatalf("↓ after the SABnzbd API key reached %T %q, want the Test button", focusedItem(), focusedItem().GetLabel())
|
||||||
|
}
|
||||||
|
send(v.form, tcell.KeyDown)
|
||||||
|
if got := focusedItem().GetLabel(); got != "Downloads refresh (seconds)" {
|
||||||
|
t.Errorf("after the button comes %q", got)
|
||||||
|
}
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
if focusedItem() != tview.FormItem(v.testItem) {
|
||||||
|
t.Error("↑ must come back to the button")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and Revert stay below everything; Test is not among them.
|
||||||
|
if v.actions.GetButtonCount() != 2 || v.actions.GetButton(0).GetLabel() != "Save" || v.actions.GetButton(1).GetLabel() != "Revert" {
|
||||||
|
t.Errorf("actions bar has %d buttons", v.actions.GetButtonCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Space and Enter press it.
|
||||||
|
var focus func(p tview.Primitive)
|
||||||
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
|
for _, ev := range []*tcell.EventKey{tcell.NewEventKey(tcell.KeyRune, ' ', 0), tcell.NewEventKey(tcell.KeyEnter, 0, 0)} {
|
||||||
|
v.describe()
|
||||||
|
v.testItem.InputHandler()(ev, focus)
|
||||||
|
if !strings.Contains(v.status.GetText(false), "Testing the connections") {
|
||||||
|
t.Errorf("key %v did not start the test: %q", ev.Name(), v.status.GetText(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpOverConfigTakesTheFocus(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0))
|
||||||
|
moveDown := a.settings.form.GetInputCapture()
|
||||||
|
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
|
||||||
|
field := a.settings.inputs["radarr.apikey"]
|
||||||
|
if a.app.GetFocus() != field {
|
||||||
|
t.Fatalf("after ↓ the application's focus is %T, not the second field", a.app.GetFocus())
|
||||||
|
}
|
||||||
|
|
||||||
|
a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0))
|
||||||
|
// The field must not keep the focus behind the help: it would draw its
|
||||||
|
// cursor through the window, and the form would take the help's keys.
|
||||||
|
if field.HasFocus() || a.settings.form.HasFocus() {
|
||||||
|
t.Errorf("behind the help: field focused %v, form focused %v", field.HasFocus(), a.settings.form.HasFocus())
|
||||||
|
}
|
||||||
|
if _, ok := a.app.GetFocus().(*tview.TextView); !ok {
|
||||||
|
t.Errorf("help has no focus: %T", a.app.GetFocus())
|
||||||
|
}
|
||||||
|
|
||||||
|
a.closeModal()
|
||||||
|
if a.app.GetFocus() != field || !field.HasFocus() {
|
||||||
|
t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultsAreChosenFromTheServer(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Header.Get("X-Api-Key") != "key":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/qualityprofile"):
|
||||||
|
w.Write([]byte(`[{"id":1,"name":"Any"},{"id":4,"name":"HD-1080p"}]`))
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/rootfolder"):
|
||||||
|
w.Write([]byte(`[{"id":1,"path":"/movies"}]`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
profiles, folders, err := fetchChoices(t.Context(), srv.URL, "key")
|
||||||
|
if err != nil || strings.Join(profiles, ",") != "Any,HD-1080p" || strings.Join(folders, ",") != "/movies" {
|
||||||
|
t.Fatalf("fetched %v %v %v", profiles, folders, err)
|
||||||
|
}
|
||||||
|
if _, _, err := fetchChoices(t.Context(), srv.URL, "wrong"); err == nil {
|
||||||
|
t.Error("a wrong key must fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
a := newTestApp(t)
|
||||||
|
v := a.settings
|
||||||
|
p := v.choices["radarr.profile"]
|
||||||
|
p.set("Old profile") // configured, but not on the server
|
||||||
|
v.dirty = false
|
||||||
|
v.choiceGen["radarr"] = 1
|
||||||
|
v.applyChoices("radarr", 1, profiles, folders, nil)
|
||||||
|
|
||||||
|
n := p.GetOptionCount()
|
||||||
|
var labels []string
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
labels = append(labels, p.valueAt(i))
|
||||||
|
}
|
||||||
|
if strings.Join(labels, "|") != "|Any|HD-1080p|Old profile" {
|
||||||
|
t.Errorf("options %q", labels)
|
||||||
|
}
|
||||||
|
if _, text := p.GetCurrentOption(); text != "Old profile (not on the server)" || p.value != "Old profile" {
|
||||||
|
t.Errorf("selected %q, value %q", text, p.value)
|
||||||
|
}
|
||||||
|
if v.dirty {
|
||||||
|
t.Error("loading the options is not an edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choosing one is an edit and is what gets saved.
|
||||||
|
p.SetCurrentOption(2)
|
||||||
|
if p.value != "HD-1080p" || !v.dirty || v.read().radarrProfile != "HD-1080p" {
|
||||||
|
t.Errorf("chosen %q dirty %v", p.value, v.dirty)
|
||||||
|
}
|
||||||
|
// An answer for an address that has changed since is dropped.
|
||||||
|
v.choiceGen["radarr"] = 2
|
||||||
|
v.applyChoices("radarr", 1, nil, nil, nil)
|
||||||
|
if p.GetOptionCount() != n {
|
||||||
|
t.Error("stale answer applied")
|
||||||
|
}
|
||||||
|
// Typing a new address asks that server again (after a pause); SABnzbd has no such lists.
|
||||||
|
v.inputs["sonarr.url"].SetText("http://elsewhere:8989")
|
||||||
|
v.inputs["sabnzbd.url"].SetText("http://elsewhere:8080")
|
||||||
|
if v.choiceTimers["sonarr"] == nil || v.choiceTimers["sabnzbd"] != nil {
|
||||||
|
t.Errorf("reload scheduled: %v", v.choiceTimers)
|
||||||
|
}
|
||||||
|
v.choiceTimers["sonarr"].Stop() // the test has no event loop to deliver it
|
||||||
|
|
||||||
|
// Without the server only none and the value in use are offered.
|
||||||
|
v.applyChoices("radarr", 2, nil, nil, errors.New("connection refused"))
|
||||||
|
if p.GetOptionCount() != 2 || !strings.Contains(v.status.GetText(false), "connection refused") {
|
||||||
|
t.Errorf("offline: %d options, result %q", p.GetOptionCount(), v.status.GetText(false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerChoiceWorksWithTheArrowKeys(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
v := a.settings
|
||||||
|
p := v.choices["radarr.profile"]
|
||||||
|
v.choiceGen["radarr"] = 1
|
||||||
|
v.applyChoices("radarr", 1, []string{"Any", "HD-1080p"}, nil, nil)
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
keys := v.form.GetInputCapture()
|
||||||
|
send := func(k tcell.Key) { keys(tcell.NewEventKey(k, 0, 0)) }
|
||||||
|
send(tcell.KeyDown) // API key
|
||||||
|
send(tcell.KeyDown) // Default quality profile
|
||||||
|
if item, _ := v.form.GetFocusedItemIndex(); v.form.GetFormItem(item) != p {
|
||||||
|
t.Fatalf("on %q", v.form.GetFormItem(item).GetLabel())
|
||||||
|
}
|
||||||
|
|
||||||
|
// → and ← change the value, like on every other dropdown.
|
||||||
|
send(tcell.KeyRight)
|
||||||
|
send(tcell.KeyRight)
|
||||||
|
if p.value != "HD-1080p" || !v.dirty {
|
||||||
|
t.Errorf("→→: value %q, dirty %v", p.value, v.dirty)
|
||||||
|
}
|
||||||
|
send(tcell.KeyLeft)
|
||||||
|
if p.value != "Any" {
|
||||||
|
t.Errorf("←: value %q", p.value)
|
||||||
|
}
|
||||||
|
// While its list is open, ↑↓ belong to the list, not to the form.
|
||||||
|
var focus func(tview.Primitive)
|
||||||
|
focus = func(q tview.Primitive) { q.Focus(focus) }
|
||||||
|
p.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), focus)
|
||||||
|
if !p.IsOpen() {
|
||||||
|
t.Fatal("Enter does not open the list")
|
||||||
|
}
|
||||||
|
if got := keys(tcell.NewEventKey(tcell.KeyDown, 0, 0)); got == nil {
|
||||||
|
t.Error("↓ in the open list was taken by the form")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image/color"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// terminalInfo is what the terminal said about itself at startup.
|
||||||
|
type terminalInfo struct {
|
||||||
|
graphics bool // the Kitty graphics protocol is supported
|
||||||
|
bg color.RGBA // its background color, if hasBG
|
||||||
|
hasBG bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
terminalOnce sync.Once
|
||||||
|
terminalResult terminalInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
// terminalAnswers asks the terminal once (see probeTerminal) and remembers the
|
||||||
|
// answers. The first call must happen before the screen is set up, which owns
|
||||||
|
// the terminal afterwards; New makes it. It is a variable so tests can replace it.
|
||||||
|
var terminalAnswers = func() terminalInfo {
|
||||||
|
terminalOnce.Do(func() { terminalResult = probeTerminal(300 * time.Millisecond) })
|
||||||
|
return terminalResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTerminalAnswers reads the answers to probeTerminal's questions.
|
||||||
|
func parseTerminalAnswers(got []byte) (info terminalInfo) {
|
||||||
|
info.graphics = bytes.Contains(got, []byte("_Gi=31;OK"))
|
||||||
|
info.bg, info.hasBG = parseColorAnswer(got, "11")
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseColorAnswer finds the answer to an OSC color query such as 11
|
||||||
|
// (background): ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each
|
||||||
|
// component has 1 to 4 hex digits.
|
||||||
|
func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) {
|
||||||
|
prefix := []byte("\x1b]" + osc + ";rgb:")
|
||||||
|
i := bytes.Index(got, prefix)
|
||||||
|
if i < 0 {
|
||||||
|
return color.RGBA{}, false
|
||||||
|
}
|
||||||
|
rest := got[i+len(prefix):]
|
||||||
|
if end := bytes.IndexAny(rest, "\x1b\a"); end >= 0 {
|
||||||
|
rest = rest[:end]
|
||||||
|
}
|
||||||
|
parts := strings.Split(string(rest), "/")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return color.RGBA{}, false
|
||||||
|
}
|
||||||
|
var c [3]uint8
|
||||||
|
for j, p := range parts {
|
||||||
|
v, err := strconv.ParseUint(p, 16, 16)
|
||||||
|
if err != nil || len(p) < 1 || len(p) > 4 {
|
||||||
|
return color.RGBA{}, false
|
||||||
|
}
|
||||||
|
c[j] = uint8(v * 255 / (1<<(4*len(p)) - 1))
|
||||||
|
}
|
||||||
|
return color.RGBA{c[0], c[1], c[2], 255}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// luminance is the relative brightness of c, from 0 (black) to 1 (white).
|
||||||
|
func luminance(c color.RGBA) float64 {
|
||||||
|
return (0.2126*float64(c.R) + 0.7152*float64(c.G) + 0.0722*float64(c.B)) / 255
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTerminalAnswers(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
graphics bool
|
||||||
|
bg color.RGBA
|
||||||
|
hasBG bool
|
||||||
|
}{
|
||||||
|
{"ghostty", "\x1b_Gi=31;OK\x1b\\\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\\x1b[?62;22c", true, color.RGBA{30, 30, 46, 255}, true},
|
||||||
|
{"text color too", "\x1b]10;rgb:4c4c/4f4f/6969\x1b\\\x1b]11;rgb:efef/f1f1/f5f5\x1b\\\x1b[?62c", false, color.RGBA{239, 241, 245, 255}, true},
|
||||||
|
{"bel and two digits", "\x1b]11;rgb:ff/80/00\a\x1b[?1;2c", false, color.RGBA{255, 128, 0, 255}, true},
|
||||||
|
{"one digit", "\x1b]11;rgb:f/0/8\x1b\\\x1b[?6c", false, color.RGBA{255, 0, 136, 255}, true},
|
||||||
|
{"only device attributes", "\x1b[?1;2c", false, color.RGBA{}, false},
|
||||||
|
{"garbage", "\x1b]11;rgb:zz/00/00\x1b\\\x1b[?6c", false, color.RGBA{}, false},
|
||||||
|
{"nothing", "", false, color.RGBA{}, false},
|
||||||
|
} {
|
||||||
|
info := parseTerminalAnswers([]byte(tc.got))
|
||||||
|
if info.graphics != tc.graphics || info.hasBG != tc.hasBG || info.bg != tc.bg {
|
||||||
|
t.Errorf("%s: %+v", tc.name, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooseTheme(t *testing.T) {
|
||||||
|
dark := terminalInfo{bg: color.RGBA{30, 30, 46, 255}, hasBG: true}
|
||||||
|
light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
setting string
|
||||||
|
info terminalInfo
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", light, "light"},
|
||||||
|
{"auto", dark, "dark"},
|
||||||
|
{"auto", terminalInfo{}, "dark"},
|
||||||
|
{"dark", light, "dark"},
|
||||||
|
{"light", dark, "light"},
|
||||||
|
} {
|
||||||
|
if th, note := chooseTheme(tc.setting, tc.info); th.name != tc.want {
|
||||||
|
t.Errorf("%q with %+v: %s (%s)", tc.setting, tc.info, th.name, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUseTokens(t *testing.T) {
|
||||||
|
oldStyles := tview.Styles
|
||||||
|
oldNames := map[string]tcell.Color{}
|
||||||
|
for k, v := range tcell.ColorNames {
|
||||||
|
oldNames[k] = v
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
tview.Styles = oldStyles
|
||||||
|
for k, v := range oldNames {
|
||||||
|
tcell.ColorNames[k] = v
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
useTokens()
|
||||||
|
if tview.Styles.PrimitiveBackgroundColor != tokens.bg || tview.Styles.PrimaryTextColor != tokens.text {
|
||||||
|
t.Error("tview styles not set")
|
||||||
|
}
|
||||||
|
// Color names in texts mean the roles.
|
||||||
|
if named("yellow") != tokens.yellow || tcell.GetColor("black") != tokens.bg || tcell.GetColor("white") != tokens.text {
|
||||||
|
t.Error("color names not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemedScreenSwitchesLive(t *testing.T) {
|
||||||
|
sim := tcell.NewSimulationScreen("")
|
||||||
|
if err := sim.Init(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sim.Fini()
|
||||||
|
sim.SetSize(4, 1)
|
||||||
|
s := newThemedScreen(sim, darkTheme)
|
||||||
|
poster := tcell.NewRGBColor(10, 20, 30)
|
||||||
|
|
||||||
|
draw := func() {
|
||||||
|
s.SetContent(0, 0, 'a', nil, tcell.StyleDefault.Foreground(tokens.yellow).Background(tokens.bg))
|
||||||
|
s.SetContent(1, 0, 'b', nil, tcell.StyleDefault.Foreground(poster).Background(poster))
|
||||||
|
}
|
||||||
|
check := func(th theme) {
|
||||||
|
_, _, st, _ := sim.GetContent(0, 0)
|
||||||
|
if fg, bg, _ := st.Decompose(); fg != th.yellow || bg != th.bg {
|
||||||
|
t.Errorf("%s: role colors drawn as %v on %v", th.name, fg, bg)
|
||||||
|
}
|
||||||
|
_, _, st, _ = sim.GetContent(1, 0)
|
||||||
|
if fg, bg, _ := st.Decompose(); fg != poster || bg != poster {
|
||||||
|
t.Errorf("%s: other colors changed to %v on %v", th.name, fg, bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
check(darkTheme)
|
||||||
|
s.setTheme(lightTheme)
|
||||||
|
draw()
|
||||||
|
check(lightTheme)
|
||||||
|
|
||||||
|
// A poster color that equals a role color is nudged off it.
|
||||||
|
if c := notToken(tokens.red); c == tokens.red {
|
||||||
|
t.Error("role color in a picture not nudged")
|
||||||
|
}
|
||||||
|
if notToken(poster) != poster {
|
||||||
|
t.Error("ordinary color changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThemeColorsSurvive256Colors(t *testing.T) {
|
||||||
|
// Every theme color is its own nearest match in the 256-color palette, so
|
||||||
|
// a terminal without 24-bit color shows it exactly and never picks one of
|
||||||
|
// the first 16 colors, which color themes change.
|
||||||
|
palette := make([]tcell.Color, 256)
|
||||||
|
for i := range palette {
|
||||||
|
palette[i] = tcell.PaletteColor(i)
|
||||||
|
}
|
||||||
|
for _, th := range []theme{darkTheme, lightTheme} {
|
||||||
|
for _, c := range []tcell.Color{th.bg, th.text, th.border, th.field, th.fieldMuted, th.list, th.pressed, th.toast,
|
||||||
|
th.gray, th.yellow, th.green, th.red, th.blue, th.purple} {
|
||||||
|
if got := tcell.FindColor(c, palette); got < tcell.PaletteColor(16) {
|
||||||
|
t.Errorf("%s theme: %v maps to palette color %d", th.name, c, got-tcell.ColorValid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// theme is a complete set of colors, so the program looks the same whatever
|
||||||
|
// palette the terminal's color theme has (themes often make "black" a grey
|
||||||
|
// that is not their background, and light themes put dark grey slabs under
|
||||||
|
// white text). Every color is one of the fixed entries 16-255 of the
|
||||||
|
// 256-color table, so terminals without 24-bit color show exactly these too
|
||||||
|
// (the nearest match of an exact color is itself, and the first 16 entries,
|
||||||
|
// which themes change, never equal them).
|
||||||
|
type theme struct {
|
||||||
|
name string
|
||||||
|
|
||||||
|
bg, text, border tcell.Color
|
||||||
|
field, fieldMuted tcell.Color // input fields and buttons; placeholders on them
|
||||||
|
list tcell.Color // open dropdown lists (drawn with bg-colored text)
|
||||||
|
pressed tcell.Color // text of a pressed button (on the text color)
|
||||||
|
toast tcell.Color // background of the popups
|
||||||
|
|
||||||
|
// The accent colors, used by name in the texts ("[yellow]...").
|
||||||
|
gray, yellow, green, red, blue, purple tcell.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
func c256(i int) tcell.Color { return tcell.PaletteColor(i).TrueColor() }
|
||||||
|
|
||||||
|
var (
|
||||||
|
darkTheme = theme{
|
||||||
|
name: "dark",
|
||||||
|
bg: c256(232), text: c256(253), border: c256(246),
|
||||||
|
field: c256(236), fieldMuted: c256(245),
|
||||||
|
list: c256(110), pressed: c256(25), toast: c256(236),
|
||||||
|
gray: c256(245), yellow: c256(221), green: c256(113), red: c256(203), blue: c256(75), purple: c256(141),
|
||||||
|
}
|
||||||
|
lightTheme = theme{
|
||||||
|
name: "light",
|
||||||
|
bg: c256(255), text: c256(234), border: c256(243),
|
||||||
|
field: c256(252), fieldMuted: c256(245),
|
||||||
|
list: c256(67), pressed: c256(255), toast: c256(252),
|
||||||
|
gray: c256(243), yellow: c256(130), green: c256(28), red: c256(160), blue: c256(26), purple: c256(91),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// chooseTheme resolves the ui.theme setting: "dark", "light", or "auto" (the
|
||||||
|
// default), which follows the background color the terminal reported and is
|
||||||
|
// dark when it did not say. The second value explains the choice for the log.
|
||||||
|
func chooseTheme(setting string, info terminalInfo) (theme, string) {
|
||||||
|
switch setting {
|
||||||
|
case "dark":
|
||||||
|
return darkTheme, "Theme: dark"
|
||||||
|
case "light":
|
||||||
|
return lightTheme, "Theme: light"
|
||||||
|
}
|
||||||
|
if !info.hasBG {
|
||||||
|
return darkTheme, "Theme: dark (auto: the terminal did not report its background color)"
|
||||||
|
}
|
||||||
|
hex := hexColor(info.bg)
|
||||||
|
if luminance(info.bg) > 0.5 {
|
||||||
|
return lightTheme, "Theme: light (auto: the terminal's background " + hex + " is light)"
|
||||||
|
}
|
||||||
|
return darkTheme, "Theme: dark (auto: the terminal's background " + hex + " is dark)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// colors lists the theme's colors in a fixed order of roles.
|
||||||
|
func (t theme) colors() []tcell.Color {
|
||||||
|
return []tcell.Color{t.bg, t.text, t.border, t.field, t.fieldMuted, t.list, t.pressed, t.toast,
|
||||||
|
t.gray, t.yellow, t.green, t.red, t.blue, t.purple}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokens is the theme the widgets are built with: every role has a color of
|
||||||
|
// its own that no theme uses, and themedScreen replaces it with the color of
|
||||||
|
// the current theme while drawing. That is how the theme changes while the
|
||||||
|
// program runs: widgets copy their colors when they are created, but what they
|
||||||
|
// copy is a role, not a color.
|
||||||
|
var tokens = func() theme {
|
||||||
|
var c [14]tcell.Color
|
||||||
|
for i := range c {
|
||||||
|
c[i] = tcell.NewRGBColor(0xa5, 0x17, int32(i))
|
||||||
|
}
|
||||||
|
return theme{name: "tokens", bg: c[0], text: c[1], border: c[2], field: c[3], fieldMuted: c[4], list: c[5], pressed: c[6], toast: c[7],
|
||||||
|
gray: c[8], yellow: c[9], green: c[10], red: c[11], blue: c[12], purple: c[13]}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// useTokens makes the role colors the defaults of everything created
|
||||||
|
// afterwards: tview's default styles (widgets copy them when they are created,
|
||||||
|
// so this must run before any is) and the color names used in the texts,
|
||||||
|
// which tview looks up in tcell's table: "[yellow]" is the theme's yellow,
|
||||||
|
// "white" its text color and "black" its background (the active tab is
|
||||||
|
// "[black:white]").
|
||||||
|
func useTokens() {
|
||||||
|
t := tokens
|
||||||
|
st := &tview.Styles
|
||||||
|
st.PrimitiveBackgroundColor = t.bg
|
||||||
|
st.ContrastBackgroundColor = t.field
|
||||||
|
st.MoreContrastBackgroundColor = t.list
|
||||||
|
st.BorderColor, st.TitleColor, st.GraphicsColor = t.border, t.text, t.border
|
||||||
|
st.PrimaryTextColor = t.text
|
||||||
|
st.SecondaryTextColor = t.yellow
|
||||||
|
st.TertiaryTextColor = t.green
|
||||||
|
st.InverseTextColor = t.pressed
|
||||||
|
st.ContrastSecondaryTextColor = t.fieldMuted
|
||||||
|
|
||||||
|
for name, c := range map[string]tcell.Color{
|
||||||
|
"white": t.text, "black": t.bg,
|
||||||
|
"gray": t.gray, "grey": t.gray,
|
||||||
|
"yellow": t.yellow, "green": t.green, "red": t.red,
|
||||||
|
"blue": t.blue, "purple": t.purple,
|
||||||
|
} {
|
||||||
|
tcell.ColorNames[name] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// themedScreen draws with the current theme: it replaces the role colors of
|
||||||
|
// everything set on the screen by the theme's colors. Other colors (posters,
|
||||||
|
// the image ids of real-image posters) pass unchanged.
|
||||||
|
type themedScreen struct {
|
||||||
|
tcell.Screen
|
||||||
|
theme theme
|
||||||
|
colors map[tcell.Color]tcell.Color
|
||||||
|
initErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThemedScreen(s tcell.Screen, t theme) *themedScreen {
|
||||||
|
ts := &themedScreen{Screen: s}
|
||||||
|
ts.setTheme(t)
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTheme switches the colors; the next draw shows them.
|
||||||
|
func (s *themedScreen) setTheme(t theme) {
|
||||||
|
s.theme = t
|
||||||
|
s.colors = map[tcell.Color]tcell.Color{}
|
||||||
|
actual := t.colors()
|
||||||
|
for i, tok := range tokens.colors() {
|
||||||
|
s.colors[tok] = actual[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *themedScreen) SetContent(x, y int, primary rune, combining []rune, style tcell.Style) {
|
||||||
|
fg, bg, _ := style.Decompose()
|
||||||
|
if c, ok := s.colors[fg]; ok {
|
||||||
|
style = style.Foreground(c)
|
||||||
|
}
|
||||||
|
if c, ok := s.colors[bg]; ok {
|
||||||
|
style = style.Background(c)
|
||||||
|
}
|
||||||
|
s.Screen.SetContent(x, y, primary, combining, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init remembers the error, which tview's SetScreen drops.
|
||||||
|
func (s *themedScreen) Init() error {
|
||||||
|
s.initErr = s.Screen.Init()
|
||||||
|
return s.initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// notToken nudges a picture's color that happens to equal a role color, so
|
||||||
|
// themedScreen does not replace it.
|
||||||
|
func notToken(c tcell.Color) tcell.Color {
|
||||||
|
r, g, b := c.RGB()
|
||||||
|
if r == 0xa5 && g == 0x17 && b < int32(len(tokens.colors())) {
|
||||||
|
return tcell.NewRGBColor(r, g+1, b)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// named returns the role color for a name used in the texts.
|
||||||
|
func named(name string) tcell.Color { return tcell.GetColor(name) }
|
||||||
|
|
||||||
|
func hexColor(c color.RGBA) string {
|
||||||
|
const digits = "0123456789abcdef"
|
||||||
|
b := []byte{'#', 0, 0, 0, 0, 0, 0}
|
||||||
|
for i, v := range []uint8{c.R, c.G, c.B} {
|
||||||
|
b[1+2*i], b[2+2*i] = digits[v>>4], digits[v&15]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// watchInterval is how often SABnzbd's history is checked for finished
|
||||||
|
// downloads in the background, whatever view is showing.
|
||||||
|
const watchInterval = 15 * time.Second
|
||||||
|
|
||||||
|
// downloadEvent is a download that just finished or failed.
|
||||||
|
type downloadEvent struct {
|
||||||
|
name string
|
||||||
|
failed bool
|
||||||
|
message string // failure reason, if any
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffHistory compares SABnzbd's history with what was seen before and
|
||||||
|
// returns the downloads that reached a final state since. seen maps job id to
|
||||||
|
// the last known status and is updated. With baseline set (the first poll) the
|
||||||
|
// current state is only recorded, so downloads that finished before arr-tui
|
||||||
|
// started do not produce notifications.
|
||||||
|
func diffHistory(seen map[string]string, slots []sabnzbd.HistorySlot, baseline bool) []downloadEvent {
|
||||||
|
var events []downloadEvent
|
||||||
|
for _, s := range slots {
|
||||||
|
prev, known := seen[s.ID]
|
||||||
|
seen[s.ID] = s.Status
|
||||||
|
final := s.Status == "Completed" || s.Failed()
|
||||||
|
if baseline || !final || (known && prev == s.Status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
events = append(events, downloadEvent{name: s.Name, failed: s.Failed(), message: s.FailMessage})
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchDownloads polls SABnzbd's history until ctx ends and reports finished
|
||||||
|
// and failed downloads as popups (and a bell, if enabled). Errors are not
|
||||||
|
// reported here: the connection indicator and the log already do that.
|
||||||
|
func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client) {
|
||||||
|
seen := map[string]string{}
|
||||||
|
baseline := true
|
||||||
|
t := time.NewTicker(watchInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
hctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
slots, err := sab.History(hctx, 30)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
events := diffHistory(seen, slots, baseline)
|
||||||
|
baseline = false
|
||||||
|
if len(events) > 0 {
|
||||||
|
a.post(func() { a.announce(events, a.bell) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// announce shows a popup for each event; UI goroutine only.
|
||||||
|
func (a *App) announce(events []downloadEvent, bell bool) {
|
||||||
|
for _, e := range events {
|
||||||
|
if e.failed {
|
||||||
|
text := "Download failed: " + tview.Escape(e.name)
|
||||||
|
if e.message != "" {
|
||||||
|
text += " (" + tview.Escape(truncate(e.message, 60)) + ")"
|
||||||
|
}
|
||||||
|
a.SetMessage("[red]" + text)
|
||||||
|
} else {
|
||||||
|
a.SetMessage("[green]Download finished: " + tview.Escape(e.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bell {
|
||||||
|
if a.screen != nil {
|
||||||
|
a.screen.Beep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish a release on Gitea: create the release for an existing tag from its
|
||||||
|
# CHANGELOG.md section and attach the binaries built by `make dist`.
|
||||||
|
#
|
||||||
|
# scripts/release.sh [--dry-run|--check] [vX.Y.Z]
|
||||||
|
#
|
||||||
|
# The tag defaults to the one at HEAD. It must be pushed already. The token
|
||||||
|
# (Gitea: Settings -> Applications, repository scope with write access) is
|
||||||
|
# read from $GITEA_TOKEN or from the file $GITEA_TOKEN_FILE, which defaults to
|
||||||
|
# ~/.config/arr-tui-release-token. It never appears in the process list or in
|
||||||
|
# any output. The server and repository come from the "origin" remote; set
|
||||||
|
# GITEA_URL (e.g. https://git.example.com) if its web address differs.
|
||||||
|
#
|
||||||
|
# --dry-run build and show what would be published, change nothing
|
||||||
|
# --check only verify the token, the repository and that the release is free
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
mode=release
|
||||||
|
tag=
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--dry-run) mode=dry ;;
|
||||||
|
--check) mode=check ;;
|
||||||
|
-h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
v*) tag=$arg ;;
|
||||||
|
*) echo "unknown argument: $arg" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
die() { echo "release: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- repository and server from the origin remote ---------------------------
|
||||||
|
origin=$(git remote get-url origin) || die "no origin remote"
|
||||||
|
if [[ $origin =~ ^ssh://[^@]+@([^:/]+)(:[0-9]+)?/(.+)$ ]]; then
|
||||||
|
host=${BASH_REMATCH[1]} path=${BASH_REMATCH[3]}
|
||||||
|
elif [[ $origin =~ ^[^@]+@([^:]+):(.+)$ ]]; then
|
||||||
|
host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]}
|
||||||
|
elif [[ $origin =~ ^https?://([^/]+)/(.+)$ ]]; then
|
||||||
|
host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]}
|
||||||
|
else
|
||||||
|
die "cannot parse the origin remote: $origin"
|
||||||
|
fi
|
||||||
|
repo=${path%.git}
|
||||||
|
base=${GITEA_URL:-https://$host}
|
||||||
|
api=$base/api/v1/repos/$repo
|
||||||
|
|
||||||
|
# --- tag --------------------------------------------------------------------
|
||||||
|
[[ -n $tag ]] || tag=$(git tag --points-at HEAD | grep '^v' | head -n1) || true
|
||||||
|
[[ -n $tag ]] || die "no tag given and none at HEAD"
|
||||||
|
git rev-parse -q --verify "refs/tags/$tag" >/dev/null || die "tag $tag does not exist"
|
||||||
|
git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 || die "tag $tag is not pushed to origin"
|
||||||
|
[[ $(git rev-parse "$tag^{commit}") == "$(git ls-remote origin "refs/tags/$tag^{}" | cut -f1)" ]] \
|
||||||
|
|| die "tag $tag differs from the one on origin"
|
||||||
|
|
||||||
|
# --- token ------------------------------------------------------------------
|
||||||
|
token=${GITEA_TOKEN:-}
|
||||||
|
if [[ -z $token ]]; then
|
||||||
|
file=${GITEA_TOKEN_FILE:-$HOME/.config/arr-tui-release-token}
|
||||||
|
[[ -r $file ]] || die "no token: set GITEA_TOKEN or put it in $file"
|
||||||
|
[[ $(stat -c %a "$file" 2>/dev/null || stat -f %Lp "$file") =~ ^[0-7]00$ ]] \
|
||||||
|
|| echo "release: warning: $file is readable by other users, run chmod 600" >&2
|
||||||
|
token=$(tr -d '[:space:]' < "$file")
|
||||||
|
fi
|
||||||
|
[[ -n $token ]] || die "the token is empty"
|
||||||
|
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
umask 077
|
||||||
|
# curl reads the header from a file so the token is not visible in `ps`.
|
||||||
|
printf 'Authorization: token %s\n' "$token" > "$tmp/auth"
|
||||||
|
unset token
|
||||||
|
call() { curl -sS -H "@$tmp/auth" "$@"; }
|
||||||
|
|
||||||
|
# --- checks against the server ----------------------------------------------
|
||||||
|
info=$(call "$api") || die "cannot reach $base"
|
||||||
|
python3 - "$info" "$repo" <<'PY' || die "no access to $repo (token or repository wrong)"
|
||||||
|
import json, sys
|
||||||
|
d = json.loads(sys.argv[1])
|
||||||
|
if d.get("full_name", "").lower() != sys.argv[2].lower() or not d.get("permissions", {}).get("push"):
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
existing=$(call -o /dev/null -w '%{http_code}' "$api/releases/tags/$tag")
|
||||||
|
[[ $existing == 404 ]] || die "a release for $tag already exists (HTTP $existing)"
|
||||||
|
echo "release: $repo on $base, $tag is free"
|
||||||
|
[[ $mode == check ]] && exit 0
|
||||||
|
|
||||||
|
# --- release text from the changelog ------------------------------------------
|
||||||
|
python3 - "$tag" "$tmp/release.json" <<'PY' || die "CHANGELOG.md has no section for $tag"
|
||||||
|
import json, re, sys
|
||||||
|
tag, out = sys.argv[1], sys.argv[2]
|
||||||
|
text = open("CHANGELOG.md").read()
|
||||||
|
m = re.search(r"^## " + re.escape(tag) + r"\b[^\n]*\n(.*?)(?=^## |\Z)", text, re.S | re.M)
|
||||||
|
if not m or not m.group(1).strip():
|
||||||
|
sys.exit(1)
|
||||||
|
body = m.group(1).strip() + (
|
||||||
|
"\n\nBinaries are attached for linux, macOS (amd64 and arm64) and windows; "
|
||||||
|
"verify them with `SHA256SUMS`. Build from source with `make dist`.")
|
||||||
|
json.dump({"tag_name": tag, "name": "arr-tui " + tag, "body": body,
|
||||||
|
"draft": False, "prerelease": "-" in tag}, open(out, "w"))
|
||||||
|
PY
|
||||||
|
|
||||||
|
# --- build ----------------------------------------------------------------------
|
||||||
|
make test >/dev/null || die "tests failed"
|
||||||
|
make dist VERSION="$tag" >/dev/null
|
||||||
|
assets=(dist/*)
|
||||||
|
echo "release: built ${#assets[@]} files:"
|
||||||
|
for a in "${assets[@]}"; do echo " $(basename "$a")"; done
|
||||||
|
[[ $(./dist/arr-tui-linux-amd64 --version 2>/dev/null || true) == "arr-tui $tag" ]] \
|
||||||
|
|| echo "release: note: ./dist/arr-tui-linux-amd64 --version does not report $tag (not runnable here?)" >&2
|
||||||
|
|
||||||
|
if [[ $mode == dry ]]; then
|
||||||
|
echo "release: dry run, nothing published. Release text:"
|
||||||
|
python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["body"])' "$tmp/release.json"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- publish --------------------------------------------------------------------
|
||||||
|
id=$(call -H 'Content-Type: application/json' -d "@$tmp/release.json" "$api/releases" \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("id") or ""); sys.exit(0 if d.get("id") else 1)') \
|
||||||
|
|| die "creating the release failed"
|
||||||
|
for a in "${assets[@]}"; do
|
||||||
|
name=$(basename "$a")
|
||||||
|
call -F "attachment=@$a" "$api/releases/$id/assets?name=$name" \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("name") else 1)' \
|
||||||
|
|| die "uploading $name failed (the release $id exists without it)"
|
||||||
|
echo "release: uploaded $name"
|
||||||
|
done
|
||||||
|
echo "release: $base/$repo/releases/tag/$tag"
|
||||||