Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c1f60464 | ||
|
|
2798977867 | ||
|
|
b259e22458 | ||
|
|
12d572757e | ||
|
|
15f7fc7ee8 | ||
|
|
12e5d5aa33 | ||
|
|
b49bb1ef31 | ||
|
|
191cfdbabb | ||
|
|
c3eca0b68c |
@@ -1,5 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## v0.2.0
|
||||||
|
|
||||||
New:
|
New:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
- **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.
|
||||||
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move
|
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move
|
||||||
through with the arrow keys: service addresses and API keys, the add-form defaults, refresh interval, posters, theme, notifications. *Test*
|
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
|
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_*`
|
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.
|
environment variables are greyed out and are never written to the file.
|
||||||
@@ -56,9 +56,11 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ Notes:
|
|||||||
- **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`:
|
- **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`:
|
||||||
- **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.
|
- **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.
|
- **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` 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.
|
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.
|
- **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.
|
- **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. The fields start from the settings in use; API keys are masked (a checkbox shows them). `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.
|
- **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.
|
- **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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -48,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
|
||||||
@@ -65,6 +69,9 @@ func (e *APIError) Error() string {
|
|||||||
// 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 {
|
||||||
base, apiKey := c.settings()
|
base, apiKey := c.settings()
|
||||||
|
if base == "" {
|
||||||
|
return ErrNotConfigured
|
||||||
|
}
|
||||||
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
|
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||||
if len(query) > 0 {
|
if len(query) > 0 {
|
||||||
u += "?" + query.Encode()
|
u += "?" + query.Encode()
|
||||||
|
|||||||
+26
-15
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -17,10 +18,11 @@ 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
|
||||||
@@ -28,8 +30,8 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
|||||||
if inLibrary && img.URL != "" {
|
if inLibrary && img.URL != "" {
|
||||||
local, remote := img.URL, img.RemoteURL
|
local, remote := img.URL, img.RemoteURL
|
||||||
base, _ := c.settings()
|
base, _ := c.settings()
|
||||||
return base + "|" + local, func(ctx context.Context) ([]byte, error) {
|
return base + "|" + local, func(ctx context.Context, large bool) ([]byte, error) {
|
||||||
data, err := c.localPoster(ctx, local)
|
data, err := c.localPoster(ctx, local, large)
|
||||||
if err != nil && remote != "" {
|
if err != nil && remote != "" {
|
||||||
// The instance has no usable copy (not cached, or behind a login):
|
// The instance has no usable copy (not cached, or behind a login):
|
||||||
// use the metadata provider's, as for titles not in the library.
|
// use the metadata provider's, as for titles not in the library.
|
||||||
@@ -42,17 +44,17 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
|||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// localPoster fetches a poster from the instance: the small variant, then the
|
// localPoster fetches a poster from the instance: the scaled variant (-500
|
||||||
// original. When the instance answers with a page instead of an image (it
|
// 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
|
// 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.
|
// parameter, which is the other way Radarr and Sonarr accept it.
|
||||||
func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error) {
|
func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]byte, error) {
|
||||||
try := func(ref string) ([]byte, error) {
|
try := func(ref string) ([]byte, error) {
|
||||||
data, err := c.GetBytes(ctx, ref)
|
data, err := c.GetBytes(ctx, ref)
|
||||||
var page *notImageError
|
var page *notImageError
|
||||||
@@ -63,8 +65,12 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
|
|||||||
}
|
}
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
if small := smallVariant(local); small != local {
|
width := 250
|
||||||
data, err := try(small)
|
if large {
|
||||||
|
width = 500
|
||||||
|
}
|
||||||
|
if scaled := scaledVariant(local, width); scaled != local {
|
||||||
|
data, err := try(scaled)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -76,13 +82,15 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
|
|||||||
return try(local)
|
return try(local)
|
||||||
}
|
}
|
||||||
|
|
||||||
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x.
|
// scaledVariant turns /MediaCover/1/poster.jpg?x into
|
||||||
func smallVariant(p string) string {
|
// /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
|
||||||
}
|
}
|
||||||
@@ -135,6 +143,9 @@ func (e *notImageError) Error() string {
|
|||||||
// 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) {
|
||||||
baseURL, _ := c.settings()
|
baseURL, _ := c.settings()
|
||||||
|
if baseURL == "" {
|
||||||
|
return "", ErrNotConfigured
|
||||||
|
}
|
||||||
base, err := url.Parse(baseURL)
|
base, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
+38
-14
@@ -8,18 +8,42 @@ import (
|
|||||||
"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
|
||||||
@@ -40,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)
|
||||||
}
|
}
|
||||||
@@ -67,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 != "" {
|
||||||
@@ -111,7 +135,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("no poster")
|
t.Fatal("no poster")
|
||||||
}
|
}
|
||||||
data, err := fetch(context.Background())
|
data, err := fetch(context.Background(), false)
|
||||||
if err != nil || string(data) != "REMOTE" {
|
if err != nil || string(data) != "REMOTE" {
|
||||||
t.Fatalf("data=%q err=%v", data, err)
|
t.Fatalf("data=%q err=%v", data, err)
|
||||||
}
|
}
|
||||||
@@ -121,7 +145,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
|||||||
|
|
||||||
// Without a remote address the local failure is reported.
|
// Without a remote address the local failure is reported.
|
||||||
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg"}}, true)
|
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg"}}, true)
|
||||||
if _, err := fetch(context.Background()); err == nil {
|
if _, err := fetch(context.Background(), false); err == nil {
|
||||||
t.Error("expected the local error")
|
t.Error("expected the local error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +172,7 @@ func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient(srv.URL, "secret")
|
c := NewClient(srv.URL, "secret")
|
||||||
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
||||||
data, err := fetch(context.Background())
|
data, err := fetch(context.Background(), false)
|
||||||
if err != nil || string(data) != "IMAGE" {
|
if err != nil || string(data) != "IMAGE" {
|
||||||
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
|
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
|
||||||
}
|
}
|
||||||
@@ -173,7 +197,7 @@ func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
|||||||
|
|
||||||
c := NewClient(instance.URL, "secret")
|
c := NewClient(instance.URL, "secret")
|
||||||
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
|
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
|
||||||
data, err := fetch(context.Background())
|
data, err := fetch(context.Background(), false)
|
||||||
if err != nil || string(data) != "WEBP" {
|
if err != nil || string(data) != "WEBP" {
|
||||||
t.Fatalf("data=%q err=%v", data, err)
|
t.Fatalf("data=%q err=%v", data, err)
|
||||||
}
|
}
|
||||||
@@ -186,7 +210,7 @@ func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
|||||||
|
|
||||||
// Without a provider address the failure says what came back.
|
// Without a provider address the failure says what came back.
|
||||||
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
||||||
_, err = fetch(context.Background())
|
_, err = fetch(context.Background(), false)
|
||||||
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
|
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
|
||||||
t.Errorf("error = %v", err)
|
t.Errorf("error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -246,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 {
|
||||||
@@ -257,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -200,3 +201,35 @@ func TestEffectiveValidates(t *testing.T) {
|
|||||||
t.Error("an invalid config must not become effective")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,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
|
||||||
|
|
||||||
@@ -172,6 +176,9 @@ func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
|||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
baseURL, apiKey := c.baseURL, c.apiKey
|
baseURL, apiKey := c.baseURL, c.apiKey
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
if baseURL == "" {
|
||||||
|
return ErrNotConfigured
|
||||||
|
}
|
||||||
params.Set("apikey", apiKey)
|
params.Set("apikey", apiKey)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api?"+params.Encode(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api?"+params.Encode(), nil)
|
||||||
|
|||||||
+31
-2
@@ -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"
|
||||||
@@ -200,8 +202,14 @@ func New(cfg *config.Config) *App {
|
|||||||
return false
|
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
|
||||||
}
|
}
|
||||||
@@ -313,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")
|
||||||
@@ -353,7 +365,7 @@ func (a *App) renderServices() {
|
|||||||
}
|
}
|
||||||
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 + "[-] "
|
||||||
@@ -372,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 != "":
|
||||||
@@ -383,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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -46,3 +50,34 @@ func TestQDoesNotQuit(t *testing.T) {
|
|||||||
t.Error("q must be passed on as an ordinary key, not taken as quit")
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -294,12 +294,14 @@ func (v *calendarView) reload() {
|
|||||||
if gen != v.gen {
|
if gen != v.gen {
|
||||||
return
|
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} {
|
for _, err := range []error{movieErr, tvErr} {
|
||||||
if err != nil {
|
if !usable(err) {
|
||||||
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if movieErr == nil || tvErr == nil { // show what we got
|
if usable(movieErr) || usable(tvErr) { // show what we got
|
||||||
v.entries, v.loaded = entries, true
|
v.entries, v.loaded = entries, true
|
||||||
}
|
}
|
||||||
v.render()
|
v.render()
|
||||||
|
|||||||
@@ -468,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.
|
||||||
@@ -516,6 +516,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
|||||||
}
|
}
|
||||||
c.waiting[ref.key] = []func(*image.RGBA, error){done}
|
c.waiting[ref.key] = []func(*image.RGBA, error){done}
|
||||||
maxHeight := a.coverMaxHeight()
|
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{}{}
|
||||||
@@ -523,7 +524,7 @@ 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, maxHeight)
|
img, err = decodeCover(data, maxHeight)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
|||||||
var fetches atomic.Int32
|
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.Add(1)
|
fetches.Add(1)
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}}
|
}}
|
||||||
@@ -186,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 })
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
|
|||||||
img := image.NewRGBA(image.Rect(0, 0, 20, 30))
|
img := image.NewRGBA(image.Rect(0, 0, 20, 30))
|
||||||
a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img
|
a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img
|
||||||
ref := func(key string) coverRef {
|
ref := func(key string) coverRef {
|
||||||
return coverRef{key: key, fetch: func(context.Context) ([]byte, error) { return nil, nil }}
|
return coverRef{key: key, fetch: func(context.Context, bool) ([]byte, error) { return nil, nil }}
|
||||||
}
|
}
|
||||||
d := newDetailPane(a)
|
d := newDetailPane(a)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -210,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()
|
||||||
@@ -230,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 {
|
||||||
@@ -331,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() {
|
||||||
@@ -414,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.
|
||||||
|
|||||||
+24
-4
@@ -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() {
|
||||||
@@ -65,7 +77,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f.SetFocus(next)
|
move(next)
|
||||||
case tcell.KeyTab, tcell.KeyBacktab:
|
case tcell.KeyTab, tcell.KeyBacktab:
|
||||||
dir := 1
|
dir := 1
|
||||||
if ev.Key() == tcell.KeyBacktab {
|
if ev.Key() == tcell.KeyBacktab {
|
||||||
@@ -86,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
|
||||||
}
|
}
|
||||||
@@ -97,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
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ var helpSections = []helpSection{
|
|||||||
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
|
{"↑↓", "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"},
|
{"Tab", "jump between the fields and Save / Revert"},
|
||||||
{"Space", "toggle a checkbox; press a button"},
|
{"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"},
|
{"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"},
|
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
|
||||||
}},
|
}},
|
||||||
|
|||||||
+98
-5
@@ -249,7 +249,7 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
|||||||
})
|
})
|
||||||
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).
|
||||||
@@ -363,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
|
||||||
}
|
}
|
||||||
@@ -391,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)
|
||||||
|
|
||||||
@@ -401,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:
|
||||||
@@ -437,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+49
-7
@@ -121,6 +121,12 @@ type settingsView struct {
|
|||||||
status *tview.TextView
|
status *tview.TextView
|
||||||
|
|
||||||
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
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
|
refresh *tview.InputField
|
||||||
covers *tview.Checkbox
|
covers *tview.Checkbox
|
||||||
style *tview.DropDown
|
style *tview.DropDown
|
||||||
@@ -138,7 +144,13 @@ func settingsHints() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newSettingsView(app *App) *settingsView {
|
func newSettingsView(app *App) *settingsView {
|
||||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
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
|
// One form with every field below the other: ↑↓ moves through all of them
|
||||||
// and the form scrolls to keep the focused field in view.
|
// and the form scrolls to keep the focused field in view.
|
||||||
@@ -156,17 +168,28 @@ func newSettingsView(app *App) *settingsView {
|
|||||||
in.SetLabel(label + " (env)")
|
in.SetLabel(label + " (env)")
|
||||||
in.SetDisabled(true)
|
in.SetDisabled(true)
|
||||||
}
|
}
|
||||||
in.SetChangedFunc(func(string) { v.edited() })
|
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)
|
f.AddFormItem(in)
|
||||||
v.inputs[key] = 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) {
|
service := func(title, name string, withDefaults bool) {
|
||||||
f.AddFormItem(newFormHeading(title))
|
f.AddFormItem(newFormHeading(title))
|
||||||
input("Address", name+".url")
|
input("Address", name+".url")
|
||||||
input("API key", name+".apikey")
|
input("API key", name+".apikey")
|
||||||
if withDefaults {
|
if withDefaults {
|
||||||
input("Default quality profile", name+".profile")
|
choice("Default quality profile", name+".profile")
|
||||||
input("Default root folder", name+".folder")
|
choice("Default root folder", name+".folder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
service("Radarr", "radarr", true)
|
service("Radarr", "radarr", true)
|
||||||
@@ -290,13 +313,19 @@ func (v *settingsView) fill(s settingsValues) {
|
|||||||
v.filling = true
|
v.filling = true
|
||||||
defer func() { v.filling = false }()
|
defer func() { v.filling = false }()
|
||||||
set := map[string]string{
|
set := map[string]string{
|
||||||
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
|
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey,
|
||||||
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
|
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey,
|
||||||
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
||||||
}
|
}
|
||||||
for key, value := range set {
|
for key, value := range set {
|
||||||
v.inputs[key].SetText(value)
|
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.refresh.SetText(s.refresh)
|
||||||
v.covers.SetChecked(s.covers)
|
v.covers.SetChecked(s.covers)
|
||||||
for i, name := range coverStyles {
|
for i, name := range coverStyles {
|
||||||
@@ -316,7 +345,12 @@ func (v *settingsView) fill(s settingsValues) {
|
|||||||
|
|
||||||
// read collects the values from the fields.
|
// read collects the values from the fields.
|
||||||
func (v *settingsView) read() settingsValues {
|
func (v *settingsView) read() settingsValues {
|
||||||
text := func(key string) string { return v.inputs[key].GetText() }
|
text := func(key string) string {
|
||||||
|
if c, ok := v.choices[key]; ok {
|
||||||
|
return c.value
|
||||||
|
}
|
||||||
|
return v.inputs[key].GetText()
|
||||||
|
}
|
||||||
i, _ := v.style.GetCurrentOption()
|
i, _ := v.style.GetCurrentOption()
|
||||||
style := "auto"
|
style := "auto"
|
||||||
if i >= 0 && i < len(coverStyles) {
|
if i >= 0 && i < len(coverStyles) {
|
||||||
@@ -348,6 +382,9 @@ func (v *settingsView) describe() {
|
|||||||
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
|
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
|
||||||
}
|
}
|
||||||
b.WriteString("Changes take effect when you save; nothing needs a restart.\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 {
|
if len(v.overridden) > 0 {
|
||||||
var names []string
|
var names []string
|
||||||
for _, env := range v.overridden {
|
for _, env := range v.overridden {
|
||||||
@@ -363,6 +400,8 @@ func (v *settingsView) activate() {
|
|||||||
if !v.dirty {
|
if !v.dirty {
|
||||||
v.fill(settingsFrom(v.app.cfg))
|
v.fill(settingsFrom(v.app.cfg))
|
||||||
}
|
}
|
||||||
|
v.loadChoices("radarr")
|
||||||
|
v.loadChoices("sonarr")
|
||||||
v.focusGroup(0, false)
|
v.focusGroup(0, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +413,8 @@ func (v *settingsView) report(line string) {
|
|||||||
|
|
||||||
func (v *settingsView) revert() {
|
func (v *settingsView) revert() {
|
||||||
v.fill(settingsFrom(v.app.cfg))
|
v.fill(settingsFrom(v.app.cfg))
|
||||||
|
v.loadChoices("radarr")
|
||||||
|
v.loadChoices("sonarr")
|
||||||
v.describe()
|
v.describe()
|
||||||
v.report("[green]Reverted to the saved settings.[-]")
|
v.report("[green]Reverted to the saved settings.[-]")
|
||||||
}
|
}
|
||||||
@@ -440,6 +481,7 @@ func (v *settingsView) save() {
|
|||||||
v.app.cfgFile = &raw
|
v.app.cfgFile = &raw
|
||||||
v.app.applyConfig(effective)
|
v.app.applyConfig(effective)
|
||||||
v.dirty = false
|
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.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
|
||||||
v.app.Notify(levelSuccess, "Settings saved")
|
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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -133,7 +134,8 @@ func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
|
|||||||
// Edit the fields like a user would and save.
|
// Edit the fields like a user would and save.
|
||||||
v.inputs["radarr.url"].SetText(radarr.URL)
|
v.inputs["radarr.url"].SetText(radarr.URL)
|
||||||
v.inputs["radarr.apikey"].SetText("brand-new-key")
|
v.inputs["radarr.apikey"].SetText("brand-new-key")
|
||||||
v.inputs["sonarr.profile"].SetText("Any")
|
v.choices["sonarr.profile"].set("Any")
|
||||||
|
v.edited()
|
||||||
v.refresh.SetText("7")
|
v.refresh.SetText("7")
|
||||||
v.bell.SetChecked(true)
|
v.bell.SetChecked(true)
|
||||||
if !v.dirty {
|
if !v.dirty {
|
||||||
@@ -313,3 +315,136 @@ func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user