54 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 d84c566412 Release v0.4.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 16:59:05 +02:00
nilsandClaude Opus 5.5 35278bf2f5 README: screenshots
A library screenshot at the top and a gallery of the other views (search,
releases, episodes, downloads, calendar, config, light theme). They show a
made-up library with generated posters, served by a fake Radarr, Sonarr
and SABnzbd, taken in Ghostty with real-image posters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 16:55:19 +02:00
nilsandClaude Opus 5.5 b5c1f60464 Release v0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 16:28:14 +02:00
nilsandClaude Opus 5.5 2798977867 Real-image posters of library titles: use the 500 px copy
Library titles fetched Radarr's/Sonarr's 250×375 poster copy, which
real images show at up to 640 pixels, so movie posters looked soft next
to series posters that came from the provider's full-size original
(Sonarr behind a login). With real images the 500×750 copy is used;
block-character posters keep the small one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 15:31:56 +02:00
nilsandClaude Opus 5.5 b259e22458 First start: create the config file and open the Config view
Without a config file arr-tui refused to start. It now creates an empty
one (private, with its directory) and opens the Config view. A service
with neither address nor key is "not configured", which validation
allows: its client returns ErrNotConfigured without a request, its
indicator stays grey, it is logged once as information, and its lists
say so instead of showing errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 15:16:08 +02:00
nilsandClaude Opus 5.5 12d572757e Release v0.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 14:41:58 +02:00
nilsandClaude Opus 5.5 15f7fc7ee8 Library: cut long titles to the width of the list
Long titles made the movies and series tables wider than their pane, so
they scrolled sideways and status and size left the view. The title
column is now cut on every draw to what the other columns leave, ending
in an ellipsis (done by hand: tview only draws its ellipsis for untagged
text, and every title starts with the colored monitored mark). Titles are
now escaped, so square brackets in them are not taken as color tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 14:26:09 +02:00
nilsandClaude Opus 5.5 12e5d5aa33 Library: keep the place after removing a title
The list is reloaded after a removal and the selection follows the
selected title by id; the removed title is gone, so it fell back to the
top. Now a title that has left the library hands its row to the one that
moved up into it (or the new last one). A title that is only filtered out
still starts the list from the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:35:49 +02:00
nilsandClaude Opus 5.5 b49bb1ef31 Arrow keys on the Config view's server dropdowns
The form navigation recognised dropdowns only as *tview.DropDown, so on
the new profile and folder dropdowns ←→ did nothing and ↑↓ left an open
list instead of moving through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:31:40 +02:00
nilsandClaude Opus 5.5 191cfdbabb Config: pick the default quality profile and root folder from the server
The two defaults of Radarr and Sonarr are dropdowns filled from the
server with the address and key in the fields: when the view opens, on
Revert, and shortly after either is changed. A configured value the
server does not list stays selectable, marked "(not on the server)".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:29:09 +02:00
nilsandClaude Opus 5.5 c3eca0b68c Keep the application's focus in step with arrow-key moves in forms
Form.SetFocus moves between items without telling the application, which
kept the item that was left. Opening the help from the Config view then
blurred only that one: the field in use kept drawing its cursor through
the help window, the form still had the focus and took the help's keys
(no scrolling), and closing the help returned to the wrong field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:23:00 +02:00
nilsandClaude Opus 5.5 8ce7bdaaa7 Release v0.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:17:00 +02:00
nilsandClaude Opus 5.5 67f5cf6ce7 Switch the theme live
Widgets copy their colors when they are created, so they now get role
tokens (one unique RGB value per role) instead of colors, and a wrapper
around the tcell screen replaces the tokens with the current theme's
colors in SetContent. Changing the theme in the Config view swaps that
table; the next draw recolors everything. Poster colors that equal a
token are nudged off it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:11:19 +02:00
nilsandClaude Opus 5.5 24f7c38a5b Space presses form buttons, as the help says
Only the Test button turned Space into Enter; Save, Revert and the
buttons of the dialogs ignored it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:11:13 +02:00
nilsandClaude Opus 5.5 820b17842b Fixed dark and light themes, ui.theme auto/dark/light
Colors no longer come from the terminal's palette, whose color 0 many
color themes set to a grey that is not their background. A theme sets
tview's default styles and the color names used in texts, all chosen from
entries 16-255 of the 256-color table so terminals without 24-bit color
show them exactly. "auto" picks light or dark from the background color
the terminal reports. Settable in the Config view, applied at the next
start (widgets copy their colors when created). Replaces using the
terminal's own background and text colors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:05:00 +02:00
nilsandClaude Opus 5.5 08d3417b05 Light terminal themes: use the terminal's text color too
On a light background (Catppuccin Latte in Ghostty on macOS) the panes
kept palette color 0, a dark grey slab with white text. The probe now also
asks for the text color (OSC 10). Light backgrounds are used as well, with
text, borders and titles in the terminal's text color, input fields and
buttons in a light shade of it, and the active tab drawn in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:59:44 +02:00
nilsandClaude Opus 5.5 d2e868293f Terminal background: decide 24-bit color like tcell, and log why
The check only looked at COLORTERM, but tcell also draws 24-bit color for
terminals whose description has it (xterm-ghostty, xterm-kitty), so in
Ghostty on macOS without COLORTERM the background was never used. Use
tcell's rules, and always log which background is used and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:53:47 +02:00
nilsandClaude Opus 5.5 0270462fe4 Use the terminal's own background color
The panes used palette color 0, which color themes such as Catppuccin set
to a grey lighter than their background: everything looked washed out and
grey text almost disappeared (seen in Ghostty on macOS). The startup probe
now also asks for the background color (OSC 11), and with 24-bit color and
a dark background that color is used. The probe always runs at startup, so
a later switch to real images in the Config view never has to ask the
terminal while the screen owns it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:47:27 +02:00
nilsandClaude Opus 5.5 7e77d56879 Remove the "halves" cover style again
Tried in macOS Terminal: it does not look good there either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:44:52 +02:00
nilsandClaude Opus 5.5 8f162729ea Cover style "halves": left and right half blocks only
Twice the horizontal detail of "cells", for terminals such as macOS
Terminal whose upper and lower half blocks do not meet at the middle of
the cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:38:04 +02:00
nilsandClaude Opus 5.5 417e61da00 Kitty graphics probe that works on macOS
Go cannot set a read deadline on a terminal device on macOS (kqueue does
not support them), so the probe failed at once and Ghostty and kitty got
block posters there. Wait for the answer with select(2) instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:38:04 +02:00
nilsandClaude Opus 5.5 a71ca6e3fb Posters wait while the selection moves quickly
On a terminal zoomed far out a poster is tens of thousands of cells, and
redrawing it on every step of a held arrow key was more output than the
terminal could draw (about 100 KB per step at 600x180), so the list
lagged. A selection that changes again within 120 ms keeps the poster's
place empty and shows the poster once it rests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:31:51 +02:00
nilsandClaude Opus 5.5 080ef9ec8e Posters in macOS Terminal as plain colored cells
Terminal.app draws block elements from the font, with the halves off the
cell's middle, so quadrant posters came out as rows of shifted dots. The
new cover style "cells" uses one background color per cell and no glyph;
auto picks it when TERM_PROGRAM is Apple_Terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:18:03 +02:00
nilsandClaude Opus 5.5 2aa28c7e18 Ask the terminal before using Kitty graphics; no fontconfig-based sextants on macOS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 11:08:36 +02:00
nilsandClaude Opus 5.5 705d795e6d Quit with Ctrl+C only; q is an ordinary key everywhere
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 10:20:18 +02:00
nilsandClaude Opus 5.5 60fd71bc92 Series drill-down: poster and details of the selected season or episode
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 08:28:49 +02:00
nilsandClaude Opus 5.5 8601a2af6f Graphics debug log: also log what is drawn, optionally to a file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 01:25:40 +02:00
nilsandClaude Sonnet 5.5 55810bb1e2 Real-image posters: blocks while the window is being resized; ARR_TUI_DEBUG_GFX log
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:35:19 +02:00
nilsandClaude Sonnet 5.5 ce0d441a2e SPECS: describe one image id per poster footprint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:29:23 +02:00
nilsandClaude Sonnet 5.5 cf07aaf8fb Real-image posters: a new size is a new image id, never a changed placement
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:29:16 +02:00
nilsandClaude Sonnet 5.5 2ba47cb4ee Posters: recognise a login page instead of an image; try the key in the query, then the provider
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:24:17 +02:00
nilsandClaude Sonnet 5.5 4d602b2acb Posters: decode WebP and GIF; say what unreadable poster data looks like
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:20:14 +02:00
nilsandClaude Sonnet 5.5 706a094b20 Posters: fall back to the provider's image when the instance has no copy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:17:34 +02:00
nilsandClaude Sonnet 5.5 9edd62634f Posters: re-place after a resize, measure cell size without padding, log failed posters
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:14:50 +02:00
nilsandClaude Sonnet 5.5 b40ee8b61c Config view: rename Test connections to Test; center Save and Revert
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:01:17 +02:00
nilsandClaude Sonnet 5.5 26e1679e4e Config view: Test connections below the service settings; hide a stale cursor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:59:02 +02:00
nilsandClaude Sonnet 5.5 56bd80f7bb Config view: one list with section headings, navigated with the arrow keys
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:53:00 +02:00
nilsandClaude Sonnet 5.5 ff9e59a310 Remove the '?' help key from the docs and help: F9 is the help key
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:42:07 +02:00
nilsandClaude Sonnet 5.5 79b440f6f7 Help screen: wrap descriptions under themselves, size to the terminal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:40:25 +02:00
nilsandClaude Sonnet 5.5 394e78ec6a Swap the Config and Log views (F6 Config, F7 Log)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:37:17 +02:00
nilsandClaude Sonnet 5.5 fbf33e076b Status bar: advertise F9 for help, which works everywhere
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:35:36 +02:00
nilsandClaude Sonnet 5.5 a4ec9ad71e Add the Config view: edit and save the settings, applied without a restart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:31:13 +02:00
nilsandClaude Sonnet 5.5 0ccd02b3c5 Real-image posters: draw placeholder cells on the panes' background color
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:17:40 +02:00
nilsandClaude Sonnet 5.5 dd6f862d61 Real-image posters: match the box to the poster's shape and crop the remainder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:13:47 +02:00
nilsandClaude Sonnet 5.5 b3275f6a81 auto cover style: use sextants when fontconfig has a font with the glyphs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:08:52 +02:00
nilsandClaude Sonnet 5.5 108541af91 Show posters as real images in kitty and Ghostty (Kitty graphics with Unicode placeholders)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:03:29 +02:00
nilsandClaude Sonnet 5.5 dc82b0b95d Stop tracking an editor swap file; ignore *.swp
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:49:36 +02:00
nilsandClaude Sonnet 5.5 37f86aaf75 Revert the scrolling calendar: back to moving the two-week window with [ and ]
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:49:12 +02:00
nilsandClaude Sonnet 5.5 938980f1e1 Calendar: one scrolling list that loads more days at either end
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:47:51 +02:00
nilsandClaude Sonnet 5.5 cd5786ba78 Add the calendar view (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:39:39 +02:00
nilsandClaude Sonnet 5.5 7582504268 Remove titles from the library with a confirmation (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:36:19 +02:00
nilsandClaude Sonnet 5.5 ebbb3cd178 Announce finished and failed downloads in the background (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:34:55 +02:00
nilsandClaude Sonnet 5.5 a7765c5079 release.sh: drop unused variable; run shellcheck in make test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:32:30 +02:00
nilsandClaude Sonnet 5.5 c073e29b97 Add release script for Gitea
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 22:26:17 +02:00
66 changed files with 5774 additions and 295 deletions
+2
View File
@@ -5,3 +5,5 @@ config.json
!/dev/config.json !/dev/config.json
*.test *.test
/dev/data/ /dev/data/
*.swp
*.swo
+86 -2
View File
@@ -1,5 +1,89 @@
# Changelog # Changelog
## v0.4.1
- The README shows screenshots of every view (a made-up library). No changes to the program.
## v0.4.0
New:
- **First start without a config file:** arr-tui creates the file (private, with its directory)
and opens the Config view to enter the services, instead of refusing to start.
- **Services may be left out:** one without address and key is not used (e.g. no SABnzbd); its
indicator stays grey and its lists say "not configured" instead of showing errors.
Fixed:
- Library posters shown as real images (kitty, Ghostty) were soft: they used Radarr's/Sonarr's
250×375 copy, enlarged to up to 640 pixels. Real images now use the 500×750 copy; block-character
posters keep the small one.
## v0.3.0
New:
- **Config (F6):** the default quality profile and root folder are picked from the lists Radarr and
Sonarr have, loaded with the address and API key in the fields (when the view opens, and again
shortly after either is changed, before saving). A configured value the server does not have
stays selectable and is marked "(not on the server)".
Changed:
- **Library:** long titles are cut with "…" to the width of the list, so the movies and series
lists never scroll sideways and status and size stay in view; the cut follows the window size.
- **Library:** after removing a title the selection stays at its place (on the title that moved up
into it, or the new last one) instead of jumping to the top of the list.
Fixed:
- Help over the Config view: after moving through the fields with the arrow keys, F9 left the field
in use focused behind the help, so its cursor showed through the window, the help did not scroll,
and closing it returned to the wrong field.
- Library titles with square brackets (e.g. "[Rec]") were read as color tags.
## v0.2.0
New:
- **Calendar (F5):** two weeks of movie releases and airing episodes grouped by day, with poster
and plot; move by weeks with `[` and `]`, include unmonitored items, release search and automatic
search.
- **Config (F6):** edit the config file inside the program (addresses, API keys, add-form defaults,
refresh interval, posters, theme, notifications) in one list you move through with the arrow
keys, test the connections, and save; everything takes effect at once. Values from `ARR_TUI_*`
environment variables are never written to the file.
- **Dark and light themes** with fixed colors that look the same in every terminal, whatever its
color theme (`ui.theme`: `auto`, `dark`, `light`; switchable live in the Config view). `auto`
asks the terminal for its background color. Before, the panes used the terminal's palette color
0, which many color themes set to a grey that is not their background.
- **Posters as real images** in kitty and Ghostty (Kitty graphics protocol with Unicode
placeholders) at the terminal's full resolution, used only when the terminal confirms the
protocol at startup. Elsewhere the block-character posters remain; `auto` now also picks sextants
when fontconfig finds a font with them, and plain colored cells in macOS Terminal, whose half
blocks do not line up. `ui.cover_style` gains `kitty` and `cells`.
- **Series drill-down** shows the series poster and the details of the selected season or episode,
including its plot.
- **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the
background (`ui.notify_downloads`, optional `ui.bell`).
- **Remove from the library** (`d`), with separate checkboxes for deleting the files and for the
import exclusion; both off by default.
Changed:
- Views: F5 Calendar, F6 Config, F7 Log (the Log was F5).
- **F9** is the help key; `?` is gone. **Ctrl+C** quits; `q` no longer does.
- Space presses buttons (Save, Revert, the buttons of dialogs), as the help says.
- On narrow terminals the connection indicators shrink to initials.
Fixed:
- Posters that Sonarr/Radarr store as WebP (or GIF) under a `.jpg` name failed with "unknown
format"; posters from instances behind a login page are fetched with the API key as a query
parameter, or from the metadata provider. The Log says why a poster could not be read.
- Moving through a list with a held arrow key lagged on very large terminals (zoomed far out): the
poster now waits until the selection rests.
## v0.1.0 ## v0.1.0
First release. First release.
@@ -17,7 +101,7 @@ First release.
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete, - **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
retry, remove and blocklist. retry, remove and blocklist.
- **Posters:** a thumbnail at the top right of the detail panes with the text flowing around it (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`). - **Posters:** a thumbnail at the top right of the detail panes with the text flowing around it (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`).
- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history. - **Log (F7) and popups:** feedback appears as fading popups; the log keeps the history.
- Connection indicators for all three services, context-sensitive key hints, `?` help. - Connection indicators for all three services, context-sensitive key hints, a help screen (F9).
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds - JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
(`make dist`). (`make dist`).
+6 -1
View File
@@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION) LDFLAGS := -s -w -X main.version=$(VERSION)
TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
.PHONY: build test dist clean .PHONY: build test dist release clean
build: build:
go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui
@@ -10,6 +10,7 @@ build:
test: test:
go vet ./... go vet ./...
go test ./... go test ./...
@if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh dev/*.sh; else echo "shellcheck not installed, skipping shell script checks"; fi
# Cross-compiled binaries and checksums in dist/. # Cross-compiled binaries and checksums in dist/.
dist: dist:
@@ -22,5 +23,9 @@ dist:
done done
cd dist && sha256sum arr-tui-* > SHA256SUMS cd dist && sha256sum arr-tui-* > SHA256SUMS
# Publish the release for the tag at HEAD on Gitea (see scripts/release.sh).
release:
scripts/release.sh
clean: clean:
rm -rf dist arr-tui rm -rf dist arr-tui
+67 -22
View File
@@ -4,30 +4,66 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
[Sonarr](https://sonarr.tv), pick releases by hand, and follow the downloads in [Sonarr](https://sonarr.tv), pick releases by hand, and follow the downloads in
[SABnzbd](https://sabnzbd.org). [SABnzbd](https://sabnzbd.org).
![The library: movies and series with their status, and the details of the selected title with its poster](docs/screenshots/library.png)
- **Movies / Series**: search as you type, add titles with a quality profile and root folder, - **Movies / Series**: search as you type, add titles with a quality profile and root folder,
optionally start the automatic search. optionally start the automatic search.
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons), - **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first. sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
- **Posters**: the detail panes show the poster at the top right; title, facts, path and plot - **Posters**: the detail panes show the poster at the top right; title, facts, path and plot
flow along its left side and continue at full width below it. It is drawn with colored block characters (works over SSH, needs a terminal flow along its left side and continue at full width below it. In kitty and Ghostty it is a real
with colors). Library items use the copy stored in Radarr/Sonarr; search results are downloaded image at the terminal's full resolution (Kitty graphics protocol with Unicode placeholders, so
from the metadata provider's image host (TMDb, TheTVDB). Turn it off with popups and dialogs cover it like text). Everywhere else it is drawn with colored block characters,
`"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the characters: `quadrant` (2x2 which works over SSH and in any terminal with colors. Library items use the copy stored in
pixels per cell, every font has them), `sextant` (2x3, sharper, needs a font with the Symbols for Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb,
Legacy Computing block, e.g. kitty, WezTerm, foot, Ghostty) or `auto` (default: sextant on TheTVDB). Turn it off with `"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the
terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the drawing: `auto` (default: real images where the terminal is known to support them, otherwise
poster, set `quadrant`. plain cells in macOS Terminal, sextants on terminals known to have the glyphs or when fontconfig
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle finds an installed font with them (not over SSH), else quadrants), `kitty` (force real images),
monitoring, trigger searches. Series open into seasons and episodes with per-season and `sextant` (2x3 pixels per cell, needs a font with the Symbols for Legacy Computing block) or
per-episode monitoring, automatic search and release search. `quadrant` (2x2, every font has them) or `cells` (one colored cell per pixel, no glyphs; for
terminals whose block characters come out uneven, like macOS Terminal). Inside tmux or screen the
block styles are used. If a poster looks wrong, set `quadrant`, or `cells` if the rows look shifted.
- **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes
in one list grouped by day, with poster and plot. Move by weeks, include unmonitored items, search
for releases or start an automatic search from there.
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
retry failed downloads, remove from the arr queue and blocklist. retry failed downloads, remove from the arr queue and blocklist.
- **Log (F5)**: actions report through popups that fade away; the log keeps the history. - **Config (F6)**: edit everything in the config file from inside the program, in one list you move
- Connection indicators for all three services in the top right; `?` shows every key. through with the arrow keys: service addresses and API keys, the add-form defaults (quality profile and root folder, picked from the lists the server has), refresh interval, posters, theme, notifications. *Test*
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a
new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*`
environment variables are greyed out and are never written to the file.
- **Log (F7)**: actions report through popups that fade away; the log keeps the history.
- **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails,
whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished
before arr-tui started are not announced. `"ui": {"notify_downloads": false}` turns it off,
`"bell": true` also rings the terminal bell.
- **Themes**: fixed dark and light color sets that look the same in every terminal, whatever its
color theme. `"ui": {"theme": ...}` is `auto` (default: light when the terminal reports a light
background, else dark), `dark` or `light`; it can also be switched in the Config view, which
recolors everything at once.
- Connection indicators for all three services in the top right; `F9` shows every key.
Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are
shown for grabbing, torrents are hidden. shown for grabbing, torrents are hidden.
## Screenshots
The screenshots show a made-up library (invented titles, generated posters), taken in Ghostty,
where posters are real images.
| | |
|---|---|
| ![Search as you type, with the details and poster of the selected result](docs/screenshots/search.png) | ![Interactive search: the releases the indexers found, approved and rejected, sortable and filterable](docs/screenshots/releases.png) |
| **Movies / Series**: search as you type | **Releases**: pick one by hand |
| ![A series' seasons and episodes, with the poster and the selected season's details](docs/screenshots/episodes.png) | ![Downloads: the SABnzbd queue, the Radarr/Sonarr queues with problems, and the history](docs/screenshots/downloads.png) |
| **Seasons and episodes** of a series | **Downloads** from SABnzbd, Radarr and Sonarr |
| ![Calendar: the next two weeks of releases and episodes, grouped by day](docs/screenshots/calendar.png) | ![Config view: every setting in one list, with defaults loaded from the servers](docs/screenshots/config.png) |
| **Calendar** of the next two weeks | **Config** inside the program |
| ![The library in the light theme](docs/screenshots/library-light.png) | |
| **Light theme** | |
## Install ## Install
```sh ```sh
@@ -38,16 +74,18 @@ or build from a checkout (`make build`), or `make dist` for cross-compiled binar
## Configure ## Configure
Copy `config.example.json` to `$XDG_CONFIG_HOME/arr-tui/config.json` (usually Just start `arr-tui`: on the first start it creates `$XDG_CONFIG_HOME/arr-tui/config.json`
`~/.config/arr-tui/config.json`), or pass `--config path`. API keys are in each app under (usually `~/.config/arr-tui/config.json`, or the file given with `--config path`) and opens the
Settings → General. Config view (F6), where you enter the address and API key of each service and save. API keys are in
each app under Settings → General. A service left empty is not used (e.g. no SABnzbd), its indicator
stays grey. You can also write the file yourself, starting from `config.example.json`:
```json ```json
{ {
"radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" }, "radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" },
"sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" }, "sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." }, "sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto" } "ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto", "theme": "auto", "notify_downloads": true, "bell": false }
} }
``` ```
@@ -59,25 +97,32 @@ Keep the file private (`chmod 600`); it holds your API keys.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `F1`–`F5` | switch view (Movies, Series, Library, Downloads, Log) | | `F1`–`F7` | switch view (Movies, Series, Library, Downloads, Calendar, Config, Log) |
| `Tab`, `Shift+Tab` | switch panel within a view | | `Tab`, `Shift+Tab` | switch panel within a view |
| `?` / `F9` | help | | `F9` | help |
| `/` | focus search box | | `/` | focus search box |
| `Enter`, `a` | add selected title | | `Enter`, `a` | add selected title |
| `i` | interactive release search | | `i` | interactive release search |
| `Tab`, `f`, `F`, `/`, `t`, `s`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, releases/episodes, reload | | `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload |
| `[`, `]`, `t`, `u`, `Enter`, `s` (calendar) | week back / forward, today, unmonitored, releases, search |
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor | | `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor |
| `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties | | `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties |
| `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh | | `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh |
| `Esc` | back / close dialog | | `Esc` | back / close dialog |
| `q`, `Ctrl+C` | quit | | `Ctrl+C` | quit |
## Development ## Development
`dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer; `dev/` has a compose file with pinned Radarr, Sonarr and SABnzbd containers plus a fake indexer;
see [dev/README.md](dev/README.md). `make test` runs vet and the unit tests. The design and see [dev/README.md](dev/README.md).
`make test` runs vet and the unit tests. The design and
milestone history are in [SPECS.md](SPECS.md). milestone history are in [SPECS.md](SPECS.md).
Releases: tag and push, then `scripts/release.sh` (or `make release`) builds the binaries and publishes
them on Gitea with the matching `CHANGELOG.md` section. It needs an access token with write access
to the repository in `$GITEA_TOKEN` or `~/.config/arr-tui-release-token`; `--check` verifies
everything without publishing, `--dry-run` builds without publishing.
## License ## License
MIT, see [LICENSE](LICENSE). MIT, see [LICENSE](LICENSE).
+26 -18
View File
@@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and
- **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell) - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
- **License:** MIT - **License:** MIT
- **Module:** `git.stinnesbeck.com/nils/arr-tui` - **Module:** `git.stinnesbeck.com/nils/arr-tui`
- **Status:** M9 done - **Status:** M10 done
## 1. Goals ## 1. Goals
@@ -62,20 +62,23 @@ Notes:
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry). - SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible. - Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference. - **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `F9` opens the full key reference.
- **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. - **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. Two ways to draw the poster, chosen by `ui.cover_style`:
- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. - **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is uploaded as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks) and placed virtually with the cell footprint (`a=p,U=1,c=,r=`) right away. Every footprint (crop and cell size) is its own image id, placed once and never changed: Ghostty kept showing the old, small placement when the size of an existing one was changed, so a new size uploads under a new id and deletes the old one. The poster is shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; all images are deleted whenever the window or cell size changes, block characters stand in while the size keeps changing, and real images are uploaded afresh once it has been stable for 300 ms, because uploading and placing for every intermediate size of a window drag raced with the terminal's own resizing) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`, and then asked before the screen starts (a Kitty graphics query and a background-color query followed by a device-attributes request, 300 ms timeout, waiting with select(2) because a read deadline does not work on a terminal device on macOS): only a terminal that answers the query gets real images, also with `cover_style: kitty`, because a terminal without the protocol prints the commands as text. The Log says which way posters are drawn. With `ARR_TUI_DEBUG_GFX=1` the Log (F7) lists every window size, the cell shape, every upload (poster size, crop, cells) and every change of what is drawn (image id, position, cells); with a path (`ARR_TUI_DEBUG_GFX=/tmp/arr-gfx.log`) the same lines also go to that file with millisecond timestamps, so they can be read without leaving the view that shows the problem. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen.
- **Block characters** (`quadrant`, `sextant`, `cells`, and the fallback): every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing; `cells`: 1 pixel per cell drawn as background color only, because macOS Terminal draws block elements from the font with the halves off the cell's middle). `auto` picks cells in macOS Terminal (`TERM_PROGRAM=Apple_Terminal`), sextants on kitty, WezTerm, foot and Ghostty, and on other terminals (not on macOS, whose terminals do not use fontconfig's fonts) when fontconfig lists a font with the sextant characters (`fc-list :charset=1fb00`, about 20 ms at startup; terminals draw glyphs their font lacks from fallback fonts; skipped over SSH because the installed fonts are the remote machine's), and quadrants otherwise.
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` (250×375; `poster-500.jpg` when posters are real images, which otherwise enlarge the small copy) from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
### 4.2 v0.2 (done, M8) ### 4.2 v0.2 (done, M8)
- **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (status: all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) act only on the focused list, and each list keeps its own status filter, property filter and text; the lists show their active filters in their titles. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `i`/Enter opens the release search (movies) or the drill-down (series). - **Library view:** movies (top) and series (below) with the details of the selected item on the right; Tab switches between the two lists. Filters (status: all, missing, downloaded, monitored, unmonitored), a file-property filter (`F`: quality 2160p/1080p/720p/SD, codec x265/x264/AV1, HDR/DV/SDR, audio format Atmos/TrueHD/DTS:X/DTS-HD/DTS/DD+/DD/AAC/FLAC/Opus, audio language; changes apply live to the list behind the dialog, including while moving through an open dropdown, and Esc restores the previous filter; each dropdown only lists options that, together with the other chosen criteria, still match at least one item (series files are read when the dialog opens so this is accurate)) act only on the focused list, and each list keeps its own status filter, property filter and text; the lists show their active filters in their titles. Movie properties come from the movie list; series properties are read per series from `GET /episodefile?seriesId=` only while a property filter is active, cached while a series' file count and size stay the same, and a series matches when any of its files does. `t` toggles monitoring, `s` starts an automatic search, `d` removes the item from the library (a dialog with "Delete files from disk" and "import exclusion" checkboxes, both off by default and Cancel focused first; `DELETE /movie|series/{id}?deleteFiles=&addImport(List)Exclusion=`), `i`/Enter opens the release search (movies) or the drill-down (series).
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. - **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search. A detail pane on the right shows the series poster, the series and the selected season (download count, monitoring) or episode (air date, status, monitoring, its own plot).
### 4.3 Later / ideas ### 4.3 Later / ideas
- Calendar / upcoming view.
- Desktop/terminal notification when a download completes.
- Multiple instances per service (e.g. 4K Radarr).
- Poster preview via sixel / kitty graphics where supported. - Poster preview via sixel / kitty graphics where supported.
## 5. UI Design (tview) ## 5. UI Design (tview)
@@ -94,11 +97,13 @@ Layout sketch (Library view; the other views follow the same frame):
│ ● Show One (2008) 12/62 │ │ │ ● Show One (2008) 12/62 │ │
│ ○ Show Two (2015) 10/10 │ │ │ ○ Show Two (2015) 10/10 │ │
├────────────────────────────────┴───────────────────────────────────┤ ├────────────────────────────────┴───────────────────────────────────┤
│ Tab movies/series · f filter · / text · t monitor · ? help · q quit │ ← key hints of the current view │ Tab movies/series · f filter · / text · t monitor · F9 help · Ctrl+C quit │ ← key hints of the current view
└────────────────────────────────────────────────────────────────────┘ └────────────────────────────────────────────────────────────────────┘
``` ```
Popups for action feedback appear in the bottom right and fade away; the Log view (F5) keeps the history. Popups for action feedback appear in the bottom right and fade away; the Log view (F7) keeps the history.
Colors: two fixed themes, dark (near-black background, like k9s) and light, chosen with `ui.theme`: `auto` (default) asks the terminal for its background color at startup (OSC 11, in the same round trip as the graphics query) and takes light for a background with luminance above 0.5, dark otherwise or without an answer. The terminal's palette is not used: color themes often make color 0 ("black", tview's default background) a grey that differs from their background (Catppuccin Mocha: `#45475a` on `#1e1e2e`; Latte: `#5c5f77` on `#eff1f5`), which looked washed out, and on light themes dark grey slabs carried white text. Themes switch while the program runs: widgets copy tview's default styles when they are created, so instead of colors they get role tokens, one unique RGB value per role (background, text, border, field, yellow, ...; `0xa517nn`), set as tview's defaults before any widget exists and as the color names used in texts in tcell's table (`[yellow]` is the yellow role, `white` text and `black` background, so the active tab `[black:white]` follows too). The tcell screen is wrapped (`themedScreen`), and every `SetContent` replaces role tokens with the current theme's colors; tview clears and redraws the whole screen each frame, so switching the theme is a change of that table and one draw. Other colors pass unchanged: poster cells (a poster color that equals a token is nudged by one step) and the image-id colors of real-image placeholders. Every theme color is an entry 16-255 of the 256-color table, so terminals without 24-bit color show it exactly, and never one of the first 16 entries that color themes change. The Log says which theme is used and why.
tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive. tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive.
@@ -106,16 +111,18 @@ Keybindings (the status bar shows the ones that apply to the current view):
| Key | Action | | Key | Action |
|---|---| |---|---|
| `F1-F5` | Switch view (Movies, Series, Library, Downloads, Log); also while typing, and from full-screen views. Dialogs block them. | | `F1-F7` | Switch view (Movies, Series, Library, Downloads, Calendar, Config, Log); also while typing, and from full-screen views. Dialogs block them. |
| `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists | | `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists |
| `?` / `F9` | Help (`F9` also works while typing) | | `F9` | Help (works everywhere, also while typing) |
| `q` / `Ctrl+C` | Quit | | `Ctrl+C` | Quit (there is no single-letter quit key, so typing never quits by accident) |
| `Esc` | Close a dialog or a full-screen view | | `Esc` | Close a dialog or a full-screen view |
| `/` | Movies, Series, Library: focus the search / text filter | | `/` | Movies, Series, Library: focus the search / text filter |
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) | | `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
| `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) | | `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) |
| `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) | | `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
| `f` | Library: cycle the status filter; release list: hide rejected releases | | `f` | Library: cycle the status filter; release list: hide rejected releases |
| `[` `]` `t` `u` | Calendar: week back / forward, today, include unmonitored |
| `d` | Library: remove the item from the library (asks first) |
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | | `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) |
| `Enter` / `g` | Release list: grab the selected release (rejected ones ask for confirmation) | | `Enter` / `g` | Release list: grab the selected release (rejected ones ask for confirmation) |
@@ -198,10 +205,11 @@ The file should be created with mode `0600`; warn if it is more permissive. API
- [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete. - [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete.
- [x] **M5** — Radarr/Sonarr queues merged into the Downloads view. - [x] **M5** — Radarr/Sonarr queues merged into the Downloads view.
- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. - [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab.
- [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [x] **M7** — Polish: help screen (F9), per-service connection indicators, README, Makefile release builds, `--version`.
- [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). - [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search).
- [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar. - [x] **M9** — Popup notifications, Log view (F7), context-sensitive key hints in the status bar.
- [ ] **M10+** — ideas: calendar, completion notifications, delete from library, multiple instances. - [x] **M10** — Download completion notifications, remove from library, calendar view (F5; the Log moves to F7, after the Config view on F6). Multiple instances per service are out of scope.
- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title.
## 10. Open Questions ## 10. Open Questions
@@ -211,7 +219,7 @@ Open: none.
Decided: Decided:
- Go module path: `git.stinnesbeck.com/nils/arr-tui`. - Go module path: `git.stinnesbeck.com/nils/arr-tui`.
- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is a later idea). - One instance each of Radarr, Sonarr and SABnzbd (multi-instance is out of scope).
- Minimum Go version: latest stable. - Minimum Go version: latest stable.
- License: MIT (Nils Stinnesbeck). The tool will be published, so keep secrets out of the repo, include a `config.example.json`, and write a proper README. - License: MIT (Nils Stinnesbeck). The tool will be published, so keep secrets out of the repo, include a `config.example.json`, and write a proper README.
- Config format: JSON. - Config format: JSON.
+16 -1
View File
@@ -2,8 +2,10 @@
package main package main
import ( import (
"errors"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"os" "os"
"git.stinnesbeck.com/nils/arr-tui/internal/config" "git.stinnesbeck.com/nils/arr-tui/internal/config"
@@ -38,6 +40,13 @@ func run() error {
*path = p *path = p
} }
// No config yet (first start): create an empty one, the Config view fills it in.
if _, err := os.Stat(*path); errors.Is(err, fs.ErrNotExist) {
if err := config.Create(*path); err != nil {
return err
}
}
cfg, warnings, err := config.Load(*path) cfg, warnings, err := config.Load(*path)
if err != nil { if err != nil {
return err return err
@@ -46,5 +55,11 @@ func run() error {
fmt.Fprintln(os.Stderr, "warning:", w) fmt.Fprintln(os.Stderr, "warning:", w)
} }
return ui.New(cfg).Run() raw, err := config.LoadFile(*path)
if err != nil {
return err
}
app := ui.New(cfg)
app.SetConfigSource(*path, raw)
return app.Run()
} }
+4 -1
View File
@@ -18,6 +18,9 @@
"ui": { "ui": {
"refresh_seconds": 3, "refresh_seconds": 3,
"covers": true, "covers": true,
"cover_style": "auto" "notify_downloads": true,
"bell": false,
"cover_style": "auto",
"theme": "auto"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+4 -3
View File
@@ -5,13 +5,14 @@ go 1.26.8
require ( require (
github.com/gdamore/tcell/v2 v2.13.10 github.com/gdamore/tcell/v2 v2.13.10
github.com/rivo/tview v0.42.0 github.com/rivo/tview v0.42.0
golang.org/x/image v0.47.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.37.0
) )
require ( require (
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.43.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
) )
+6 -4
View File
@@ -11,6 +11,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.47.0 h1:za/8gVz++PPZqmZJBNSISICN5Rrj5zixP/8K++fgmqo=
golang.org/x/image v0.47.0/go.mod h1:IkN5o4A7vq55vXN8FWjjnhg9Rs2GOtffqsSi9mWAKYE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -26,8 +28,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -38,8 +40,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+28 -2
View File
@@ -11,16 +11,34 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"sync"
"time" "time"
) )
// Client is a minimal JSON client for the Radarr/Sonarr v3 API. // Client is a minimal JSON client for the Radarr/Sonarr v3 API.
type Client struct { type Client struct {
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
baseURL string baseURL string
apiKey string apiKey string
http *http.Client http *http.Client
} }
// settings returns the current base URL and API key.
func (c *Client) settings() (baseURL, apiKey string) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseURL, c.apiKey
}
// Reconfigure points the client at another instance or key. Requests already
// in flight are not affected.
func (c *Client) Reconfigure(baseURL, apiKey string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimRight(baseURL, "/")
c.apiKey = apiKey
}
// NewClient returns a client for the instance at baseURL (without /api/v3). // NewClient returns a client for the instance at baseURL (without /api/v3).
func NewClient(baseURL, apiKey string) *Client { func NewClient(baseURL, apiKey string) *Client {
return &Client{ return &Client{
@@ -30,6 +48,10 @@ func NewClient(baseURL, apiKey string) *Client {
} }
} }
// ErrNotConfigured is returned without a request while the client has no
// address: the service is not set up.
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
// APIError is returned for non-2xx responses. // APIError is returned for non-2xx responses.
type APIError struct { type APIError struct {
Status int Status int
@@ -46,7 +68,11 @@ func (e *APIError) Error() string {
// Do sends a request to /api/v3/<path>. body (if non-nil) is JSON-encoded and // Do sends a request to /api/v3/<path>. body (if non-nil) is JSON-encoded and
// the response is decoded into out (if non-nil). // the response is decoded into out (if non-nil).
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error { func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
u := c.baseURL + "/api/v3/" + strings.TrimLeft(path, "/") base, apiKey := c.settings()
if base == "" {
return ErrNotConfigured
}
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
if len(query) > 0 { if len(query) > 0 {
u += "?" + query.Encode() u += "?" + query.Encode()
} }
@@ -64,7 +90,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
if err != nil { if err != nil {
return err return err
} }
req.Header.Set("X-Api-Key", c.apiKey) req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
if body != nil { if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
+94 -18
View File
@@ -2,10 +2,12 @@ package arr
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -16,41 +18,79 @@ const maxImageBytes = 8 << 20
// function that downloads it, or ok=false when there is none. // function that downloads it, or ok=false when there is none.
// //
// Library items have a copy on the Radarr/Sonarr server, which is fetched // Library items have a copy on the Radarr/Sonarr server, which is fetched
// with the API key (preferring the small -250 variant). Titles that are not // with the API key, preferring a scaled variant: -250 (250×375), or -500
// in the library yet only have the remote URL of the metadata provider; that // (500×750) when fetch is asked for a large poster. Titles that are not in
// is fetched without the API key. // the library yet only have the remote URL of the metadata provider; that is
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) { // fetched without the API key.
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context, large bool) ([]byte, error), ok bool) {
for _, img := range images { for _, img := range images {
if img.CoverType != "poster" { if img.CoverType != "poster" {
continue continue
} }
if inLibrary && img.URL != "" { if inLibrary && img.URL != "" {
local := img.URL local, remote := img.URL, img.RemoteURL
return c.baseURL + "|" + local, func(ctx context.Context) ([]byte, error) { base, _ := c.settings()
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original. return base + "|" + local, func(ctx context.Context, large bool) ([]byte, error) {
if small := smallVariant(local); small != local { data, err := c.localPoster(ctx, local, large)
if data, err := c.GetBytes(ctx, small); err == nil { if err != nil && remote != "" {
return data, nil // The instance has no usable copy (not cached, or behind a login):
// use the metadata provider's, as for titles not in the library.
if remoteData, remoteErr := FetchURL(ctx, remote); remoteErr == nil {
return remoteData, nil
} }
} }
return c.GetBytes(ctx, local) return data, err
}, true }, true
} }
if img.RemoteURL != "" { if img.RemoteURL != "" {
remote := img.RemoteURL remote := img.RemoteURL
return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true return remote, func(ctx context.Context, _ bool) ([]byte, error) { return FetchURL(ctx, remote) }, true
} }
} }
return "", nil, false return "", nil, false
} }
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x. // localPoster fetches a poster from the instance: the scaled variant (-500
func smallVariant(p string) string { // when large, else -250), then the original. When the instance answers with a page instead of an image (it
// wants a login for /MediaCover) the API key is also tried as a query
// parameter, which is the other way Radarr and Sonarr accept it.
func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]byte, error) {
try := func(ref string) ([]byte, error) {
data, err := c.GetBytes(ctx, ref)
var page *notImageError
if errors.As(err, &page) {
if data, err2 := c.getBytes(ctx, ref, true); err2 == nil {
return data, nil
}
}
return data, err
}
width := 250
if large {
width = 500
}
if scaled := scaledVariant(local, width); scaled != local {
data, err := try(scaled)
if err == nil {
return data, nil
}
var page *notImageError
if errors.As(err, &page) {
return nil, err // the original would be a page as well
}
}
return try(local)
}
// scaledVariant turns /MediaCover/1/poster.jpg?x into
// /MediaCover/1/poster-250.jpg?x (for width 250; Radarr and Sonarr keep 250
// and 500).
func scaledVariant(p string, width int) string {
path, query, hasQuery := strings.Cut(p, "?") path, query, hasQuery := strings.Cut(p, "?")
if !strings.HasSuffix(path, "/poster.jpg") { if !strings.HasSuffix(path, "/poster.jpg") {
return p return p
} }
path = strings.TrimSuffix(path, ".jpg") + "-250.jpg" path = strings.TrimSuffix(path, ".jpg") + "-" + strconv.Itoa(width) + ".jpg"
if hasQuery { if hasQuery {
return path + "?" + query return path + "?" + query
} }
@@ -60,29 +100,60 @@ func smallVariant(p string) string {
// GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg // GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg
// from the instance, authenticated with the API key. // from the instance, authenticated with the API key.
func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) { func (c *Client) GetBytes(ctx context.Context, ref string) ([]byte, error) {
return c.getBytes(ctx, ref, false)
}
// getBytes sends the API key as a header, or with keyInQuery as the apikey
// query parameter.
func (c *Client) getBytes(ctx context.Context, ref string, keyInQuery bool) ([]byte, error) {
u, err := c.resolve(ref) u, err := c.resolve(ref)
if err != nil { if err != nil {
return nil, err return nil, err
} }
_, apiKey := c.settings()
if keyInQuery {
parsed, err := url.Parse(u)
if err != nil {
return nil, err
}
q := parsed.Query()
q.Set("apikey", apiKey)
parsed.RawQuery = q.Encode()
u = parsed.String()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("X-Api-Key", c.apiKey) if !keyInQuery {
req.Header.Set("X-Api-Key", apiKey)
}
return doImage(c.http, req) return doImage(c.http, req)
} }
// notImageError means the server answered 200 with something that is not an
// image, typically the login page of an instance that requires authentication.
type notImageError struct{ contentType string }
func (e *notImageError) Error() string {
return fmt.Sprintf("image: the server sent %q instead of an image (a login page?)", e.contentType)
}
// resolve joins a server-relative path to the base URL. Radarr/Sonarr include // resolve joins a server-relative path to the base URL. Radarr/Sonarr include
// their URL base in image paths, so it must not be added twice. // their URL base in image paths, so it must not be added twice.
func (c *Client) resolve(ref string) (string, error) { func (c *Client) resolve(ref string) (string, error) {
base, err := url.Parse(c.baseURL) baseURL, _ := c.settings()
if baseURL == "" {
return "", ErrNotConfigured
}
base, err := url.Parse(baseURL)
if err != nil { if err != nil {
return "", err return "", err
} }
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") { if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
return base.Scheme + "://" + base.Host + ref, nil return base.Scheme + "://" + base.Host + ref, nil
} }
return c.baseURL + ref, nil return baseURL + ref, nil
} }
var imageClient = &http.Client{Timeout: 20 * time.Second} var imageClient = &http.Client{Timeout: 20 * time.Second}
@@ -109,6 +180,11 @@ func doImage(client *http.Client, req *http.Request) ([]byte, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("image: HTTP %d", resp.StatusCode) return nil, fmt.Errorf("image: HTTP %d", resp.StatusCode)
} }
// Pages and error documents. (Not all of text/*: a plain server may label
// image bytes it cannot identify as text/plain.)
if ct := resp.Header.Get("Content-Type"); strings.HasPrefix(ct, "text/html") || strings.HasPrefix(ct, "application/xhtml") || strings.HasPrefix(ct, "application/json") {
return nil, &notImageError{contentType: ct}
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1)) data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
if err != nil { if err != nil {
return nil, err return nil, err
+130 -9
View File
@@ -4,21 +4,46 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
func TestSmallVariant(t *testing.T) { func TestScaledVariant(t *testing.T) {
for in, want := range map[string]string{ for _, tc := range []struct {
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5", in string
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg", width int
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg", want string
}{ }{
if got := smallVariant(in); got != want { {"/MediaCover/1/poster.jpg?lastWrite=5", 250, "/MediaCover/1/poster-250.jpg?lastWrite=5"},
t.Errorf("smallVariant(%q) = %q, want %q", in, got, want) {"/MediaCover/1/poster.jpg", 500, "/MediaCover/1/poster-500.jpg"},
{"/MediaCover/1/fanart.jpg", 250, "/MediaCover/1/fanart.jpg"},
} {
if got := scaledVariant(tc.in, tc.width); got != tc.want {
t.Errorf("scaledVariant(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want)
} }
} }
} }
func TestLargePosterUsesThe500Variant(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Write([]byte("IMG"))
}))
defer srv.Close()
c := NewClient(srv.URL, "secret")
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/1/poster.jpg"}}, true)
if _, err := fetch(context.Background(), true); err != nil {
t.Fatal(err)
}
if _, err := fetch(context.Background(), false); err != nil {
t.Fatal(err)
}
if strings.Join(paths, " ") != "/MediaCover/1/poster-500.jpg /MediaCover/1/poster-250.jpg" {
t.Errorf("requests = %v", paths)
}
}
func TestPosterLocalUsesAPIKey(t *testing.T) { func TestPosterLocalUsesAPIKey(t *testing.T) {
var paths []string var paths []string
var keys []string var keys []string
@@ -39,7 +64,7 @@ func TestPosterLocalUsesAPIKey(t *testing.T) {
if !ok || key == "" { if !ok || key == "" {
t.Fatal("no poster") t.Fatal("no poster")
} }
data, err := fetch(context.Background()) data, err := fetch(context.Background(), false)
if err != nil || string(data) != "IMG" { if err != nil || string(data) != "IMG" {
t.Fatalf("data=%q err=%v", data, err) t.Fatalf("data=%q err=%v", data, err)
} }
@@ -66,7 +91,7 @@ func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
if !ok || key != srv.URL+"/p.jpg" { if !ok || key != srv.URL+"/p.jpg" {
t.Fatalf("key=%q ok=%v", key, ok) t.Fatalf("key=%q ok=%v", key, ok)
} }
if _, err := fetch(context.Background()); err != nil { if _, err := fetch(context.Background(), false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got != "" { if got != "" {
@@ -94,3 +119,99 @@ func TestResolveURLBase(t *testing.T) {
t.Errorf("no url base: %q", got) t.Errorf("no url base: %q", got)
} }
} }
func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
var remoteKey string
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteKey = r.Header.Get("X-Api-Key")
w.Write([]byte("REMOTE"))
}))
defer remote.Close()
instance := httptest.NewServer(http.NotFoundHandler()) // MediaCover has nothing cached
defer instance.Close()
c := NewClient(instance.URL, "secret")
_, fetch, ok := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
if !ok {
t.Fatal("no poster")
}
data, err := fetch(context.Background(), false)
if err != nil || string(data) != "REMOTE" {
t.Fatalf("data=%q err=%v", data, err)
}
if remoteKey != "" {
t.Errorf("the API key went to the provider: %q", remoteKey)
}
// Without a remote address the local failure is reported.
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/9/poster.jpg"}}, true)
if _, err := fetch(context.Background(), false); err == nil {
t.Error("expected the local error")
}
}
// loginWall answers like an instance that requires a login for /MediaCover:
// a 200 with an HTML page, unless the request carries the key as a query parameter.
func loginWall(acceptQuery bool, hits *[]string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
*hits = append(*hits, r.URL.RequestURI()+" key-header="+r.Header.Get("X-Api-Key"))
if acceptQuery && r.URL.Query().Get("apikey") == "secret" {
w.Header().Set("Content-Type", "image/jpeg")
w.Write([]byte("IMAGE"))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("<!DOCTYPE html><html><head><title>Login</title>"))
}
}
func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
var hits []string
srv := httptest.NewServer(loginWall(true, &hits))
defer srv.Close()
c := NewClient(srv.URL, "secret")
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
data, err := fetch(context.Background(), false)
if err != nil || string(data) != "IMAGE" {
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
}
// Small variant with the header (a page), then the same file with the key in the query.
if len(hits) != 2 || !strings.Contains(hits[0], "poster-250.jpg") || !strings.Contains(hits[0], "key-header=secret") ||
!strings.Contains(hits[1], "poster-250.jpg?apikey=secret") {
t.Errorf("requests = %v", hits)
}
}
func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
var hits []string
instance := httptest.NewServer(loginWall(false, &hits))
defer instance.Close()
var remoteKey, remoteQuery string
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteKey, remoteQuery = r.Header.Get("X-Api-Key"), r.URL.RawQuery
w.Header().Set("Content-Type", "image/webp")
w.Write([]byte("WEBP"))
}))
defer remote.Close()
c := NewClient(instance.URL, "secret")
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg", RemoteURL: remote.URL + "/p.jpg"}}, true)
data, err := fetch(context.Background(), false)
if err != nil || string(data) != "WEBP" {
t.Fatalf("data=%q err=%v", data, err)
}
if remoteKey != "" || remoteQuery != "" {
t.Errorf("credentials reached the provider: header %q query %q", remoteKey, remoteQuery)
}
if len(hits) != 2 { // the small variant with the header and with the query; the original is skipped
t.Errorf("instance requests = %v", hits)
}
// Without a provider address the failure says what came back.
_, fetch, _ = c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
_, err = fetch(context.Background(), false)
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
t.Errorf("error = %v", err)
}
}
+36
View File
@@ -46,3 +46,39 @@ func TestSetSeasonMonitoredUnknownSeason(t *testing.T) {
t.Error("want error") t.Error("want error")
} }
} }
func TestReconfigureSwitchesInstanceAndKey(t *testing.T) {
var gotKey, gotHost string
mk := func(host string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey, gotHost = r.Header.Get("X-Api-Key"), host
io.WriteString(w, `{"version":"1"}`)
}))
}
a, b := mk("a"), mk("b")
defer a.Close()
defer b.Close()
c := NewClient(a.URL, "key-a")
var out struct{ Version string }
if err := c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out); err != nil || gotHost != "a" || gotKey != "key-a" {
t.Fatalf("before: host=%q key=%q err=%v", gotHost, gotKey, err)
}
c.Reconfigure(b.URL+"/", "key-b") // a trailing slash is trimmed like in NewClient
if err := c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out); err != nil || gotHost != "b" || gotKey != "key-b" {
t.Fatalf("after: host=%q key=%q err=%v", gotHost, gotKey, err)
}
// Used from many goroutines while being changed (run with -race to see it matter).
done := make(chan bool)
go func() {
for i := 0; i < 50; i++ {
c.Reconfigure(a.URL, "k")
c.Reconfigure(b.URL, "k")
}
done <- true
}()
for i := 0; i < 50; i++ {
_ = c.Do(context.Background(), http.MethodGet, "system/status", nil, nil, &out)
}
<-done
}
+151 -7
View File
@@ -18,6 +18,24 @@ type Service struct {
APIKey string `json:"apikey"` APIKey string `json:"apikey"`
} }
// Configured reports whether the service is set up at all (address or key
// given). Validate checks that a configured service has both.
func (s Service) Configured() bool { return s.URL != "" || s.APIKey != "" }
// AnyConfigured reports whether at least one service is set up.
func (c *Config) AnyConfigured() bool {
return c.Radarr.Configured() || c.Sonarr.Configured() || c.SABnzbd.Configured()
}
// Create writes an empty configuration to path, creating its directory
// (private, like the file). Every service is not set up yet.
func Create(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
return Save(path, &Config{})
}
// Arr is a Radarr or Sonarr instance, with optional defaults for the add form. // Arr is a Radarr or Sonarr instance, with optional defaults for the add form.
type Arr struct { type Arr struct {
Service Service
@@ -32,14 +50,30 @@ type UI struct {
// Posters of titles that are not in the library yet are downloaded from // Posters of titles that are not in the library yet are downloaded from
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB). // the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
Covers *bool `json:"covers,omitempty"` Covers *bool `json:"covers,omitempty"`
// NotifyDownloads shows a popup (and logs it) when a SABnzbd download
// finishes or fails, whichever view is open (default true).
NotifyDownloads *bool `json:"notify_downloads,omitempty"`
// Bell also rings the terminal bell for those notifications (default false).
Bell bool `json:"bell,omitempty"`
// CoverStyle selects the block characters posters are drawn with: // CoverStyle selects the block characters posters are drawn with:
// "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant" // "quadrant" (2x2 pixels per cell, every font has the glyphs), "cells" (1
// pixel per cell, background colors only, for terminals that draw block
// glyphs unevenly such as macOS Terminal), "sextant"
// (2x3, sharper, needs a font with the Symbols for Legacy Computing // (2x3, sharper, needs a font with the Symbols for Legacy Computing
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant // block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
// on terminals known to draw them, quadrant elsewhere). // the Kitty graphics protocol with Unicode placeholders: kitty and
// Ghostty) or "auto" (default: the Kitty protocol where the terminal is
// known to support it, else cells in macOS Terminal, sextant on terminals known to draw them or when
// fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty".
CoverStyle string `json:"cover_style,omitempty"` CoverStyle string `json:"cover_style,omitempty"`
// Theme selects the colors: "dark", "light" or "auto" (default: light when
// the terminal reports a light background, else dark).
Theme string `json:"theme,omitempty"`
} }
// ShouldNotifyDownloads reports whether finished downloads are announced.
func (u UI) ShouldNotifyDownloads() bool { return u.NotifyDownloads == nil || *u.NotifyDownloads }
// ShowCovers reports whether poster thumbnails are enabled. // ShowCovers reports whether poster thumbnails are enabled.
func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers } func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers }
@@ -92,14 +126,54 @@ func Load(path string) (*Config, []string, error) {
// Parse decodes config JSON, applies environment overrides and defaults, and // Parse decodes config JSON, applies environment overrides and defaults, and
// validates the result. Unknown fields are rejected to catch typos. // validates the result. Unknown fields are rejected to catch typos.
func Parse(data []byte) (*Config, error) { func Parse(data []byte) (*Config, error) {
raw, err := parseRaw(data)
if err != nil {
return nil, err
}
return raw.Effective(os.Getenv)
}
// parseRaw decodes config JSON as it stands in the file: no environment
// overrides, no defaults, no validation.
func parseRaw(data []byte) (*Config, error) {
var cfg Config var cfg Config
dec := json.NewDecoder(bytes.NewReader(data)) dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil { if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err) return nil, fmt.Errorf("parsing config: %w", err)
} }
return &cfg, nil
}
cfg.applyEnv(os.Getenv) // LoadFile reads the config at path exactly as written: no environment
// overrides, no defaults, no validation. It is what an editor of the file must
// start from, so values that only came from the environment are not written
// back to disk.
func LoadFile(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
cfg, err := parseRaw(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, nil
}
// Effective returns the configuration the program runs with: a copy with the
// environment overrides and defaults applied, validated.
func (c *Config) Effective(env func(string) string) (*Config, error) {
cfg := *c
if c.UI.Covers != nil {
v := *c.UI.Covers
cfg.UI.Covers = &v
}
if c.UI.NotifyDownloads != nil {
v := *c.UI.NotifyDownloads
cfg.UI.NotifyDownloads = &v
}
cfg.applyEnv(env)
cfg.applyDefaults() cfg.applyDefaults()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, err return nil, err
@@ -107,6 +181,66 @@ func Parse(data []byte) (*Config, error) {
return &cfg, nil return &cfg, nil
} }
// envVars maps the settings that can be overridden by the environment to the
// variable that does it.
var envVars = map[string]string{
"radarr.url": "ARR_TUI_RADARR_URL",
"radarr.apikey": "ARR_TUI_RADARR_APIKEY",
"sonarr.url": "ARR_TUI_SONARR_URL",
"sonarr.apikey": "ARR_TUI_SONARR_APIKEY",
"sabnzbd.url": "ARR_TUI_SABNZBD_URL",
"sabnzbd.apikey": "ARR_TUI_SABNZBD_APIKEY",
}
// Overrides returns, for every setting that the environment currently
// overrides, the name of the variable. Keys look like "radarr.url".
func Overrides(env func(string) string) map[string]string {
out := map[string]string{}
for key, name := range envVars {
if env(name) != "" {
out[key] = name
}
}
return out
}
// Save writes the config to path as indented JSON. The file is replaced
// atomically (a temporary file in the same directory, then a rename) and keeps
// the permissions of the existing file, or is private (0600) when new: it
// holds API keys.
func Save(path string, cfg *Config) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
mode := os.FileMode(0o600)
if info, err := os.Stat(path); err == nil {
mode = info.Mode().Perm()
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*.tmp")
if err != nil {
return fmt.Errorf("saving config: %w", err)
}
defer os.Remove(tmp.Name()) // no-op after a successful rename
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return fmt.Errorf("saving config: %w", err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("saving config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("saving config: %w", err)
}
return nil
}
func (c *Config) applyEnv(get func(string) string) { func (c *Config) applyEnv(get func(string) string) {
override := func(dst *string, key string) { override := func(dst *string, key string) {
if v := get(key); v != "" { if v := get(key); v != "" {
@@ -130,7 +264,9 @@ func (c *Config) applyDefaults() {
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/") c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
} }
// Validate checks that every service has a usable URL and API key. // Validate checks that every service that is set up has a usable URL and API
// key. A service with neither is not set up, which is allowed: the program
// starts without it and says so, and the Config view can fill it in.
func (c *Config) Validate() error { func (c *Config) Validate() error {
var errs []error var errs []error
for _, s := range []struct { for _, s := range []struct {
@@ -141,6 +277,9 @@ func (c *Config) Validate() error {
{"sonarr", c.Sonarr.Service}, {"sonarr", c.Sonarr.Service},
{"sabnzbd", c.SABnzbd}, {"sabnzbd", c.SABnzbd},
} { } {
if !s.svc.Configured() {
continue // not set up (yet); the program says so where it is needed
}
if s.svc.APIKey == "" { if s.svc.APIKey == "" {
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name)) errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
} }
@@ -153,9 +292,14 @@ func (c *Config) Validate() error {
} }
} }
switch c.UI.CoverStyle { switch c.UI.CoverStyle {
case "", "auto", "quadrant", "sextant": case "", "auto", "quadrant", "sextant", "cells", "kitty":
default: default:
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "quadrant" or "sextant"`, c.UI.CoverStyle)) errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant", "quadrant" or "cells"`, c.UI.CoverStyle))
}
switch c.UI.Theme {
case "", "auto", "dark", "light":
default:
errs = append(errs, fmt.Errorf(`ui.theme: %q must be "auto", "dark" or "light"`, c.UI.Theme))
} }
return errors.Join(errs...) return errors.Join(errs...)
} }
+126 -2
View File
@@ -35,6 +35,7 @@ func TestParseErrors(t *testing.T) {
"missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"}, "missing key": {`{"radarr":{"url":"http://a"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "radarr: apikey is required"},
"bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"}, "bad url": {`{"radarr":{"url":"localhost","apikey":"x"},"sonarr":{"url":"http://b","apikey":"x"},"sabnzbd":{"url":"http://c","apikey":"x"}}`, "must be an http(s) URL"},
"invalid json": {`{`, "parsing config"}, "invalid json": {`{`, "parsing config"},
"key only": {`{"radarr":{"apikey":"x"}}`, "radarr: url is required"},
} }
for name, tc := range tests { for name, tc := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@@ -100,12 +101,135 @@ func TestShowCovers(t *testing.T) {
func TestCoverStyleValidation(t *testing.T) { func TestCoverStyleValidation(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":` base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"cover_style":`
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} { for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"cells"`, `"kitty"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil { if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err) t.Errorf("%s rejected: %v", ok, err)
} }
} }
if _, err := Parse([]byte(base + `"braille"}}`)); err == nil { if _, err := Parse([]byte(base + `"sixel"}}`)); err == nil {
t.Error("unknown style accepted") t.Error("unknown style accepted")
} }
} }
func TestThemeValidation(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"theme":`
for _, ok := range []string{`"auto"`, `"dark"`, `"light"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err)
}
}
if _, err := Parse([]byte(base + `"solarized"}}`)); err == nil {
t.Error("unknown theme accepted")
}
}
func TestSaveRoundTripAndPermissions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
covers := false
cfg := &Config{
Radarr: Arr{Service: Service{URL: "http://r:7878", APIKey: "rk"}, DefaultQualityProfile: "HD-1080p"},
Sonarr: Arr{Service: Service{URL: "http://s:8989", APIKey: "sk"}},
SABnzbd: Service{URL: "http://a:8080", APIKey: "ak"},
UI: UI{RefreshSeconds: 5, Covers: &covers, CoverStyle: "sextant", Bell: true},
}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("new file mode = %v, want 0600 (it holds API keys): %v", info.Mode().Perm(), err)
}
got, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if got.Radarr.DefaultQualityProfile != "HD-1080p" || got.UI.RefreshSeconds != 5 || got.UI.Covers == nil || *got.UI.Covers || got.UI.CoverStyle != "sextant" || !got.UI.Bell {
t.Errorf("round trip lost values: %+v", got)
}
// Unset optional settings stay out of the file.
if data, _ := os.ReadFile(path); strings.Contains(string(data), "notify_downloads") || strings.Contains(string(data), "default_root_folder") {
t.Errorf("unset settings written:\n%s", data)
}
// An existing file keeps its permissions and no temporary file is left behind.
if err := os.Chmod(path, 0o640); err != nil {
t.Fatal(err)
}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Errorf("existing mode changed to %v", info.Mode().Perm())
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("leftover files in the config directory: %v", entries)
}
}
func TestLoadFileIgnoresEnvironmentAndDefaults(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "from-env")
path := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(path, []byte(`{"radarr":{"url":"http://r/","apikey":"from-file"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"}}`), 0o600)
raw, err := LoadFile(path)
if err != nil {
t.Fatal(err)
}
if raw.Radarr.APIKey != "from-file" || raw.Radarr.URL != "http://r/" || raw.UI.RefreshSeconds != 0 {
t.Errorf("raw = %+v: environment, trailing slash trimming and defaults must not be applied", raw)
}
eff, err := raw.Effective(os.Getenv)
if err != nil {
t.Fatal(err)
}
if eff.Radarr.APIKey != "from-env" || eff.Radarr.URL != "http://r" || eff.UI.RefreshSeconds != 3 {
t.Errorf("effective = %+v", eff)
}
if raw.Radarr.APIKey != "from-file" {
t.Error("Effective must not modify the raw config")
}
if got := Overrides(os.Getenv); len(got) != 1 || got["radarr.apikey"] != "ARR_TUI_RADARR_APIKEY" {
t.Errorf("overrides = %v", got)
}
}
func TestEffectiveValidates(t *testing.T) {
raw := &Config{Radarr: Arr{Service: Service{URL: "ftp://x", APIKey: "k"}}}
if _, err := raw.Effective(func(string) string { return "" }); err == nil {
t.Error("an invalid config must not become effective")
}
}
func TestServicesMayBeLeftOut(t *testing.T) {
cfg, err := Parse([]byte(`{"radarr":{"url":"http://r","apikey":"k"}}`))
if err != nil {
t.Fatalf("a config with only Radarr must load: %v", err)
}
if !cfg.Radarr.Configured() || cfg.Sonarr.Configured() || cfg.SABnzbd.Configured() || !cfg.AnyConfigured() {
t.Errorf("configured: radarr %v sonarr %v sab %v", cfg.Radarr.Configured(), cfg.Sonarr.Configured(), cfg.SABnzbd.Configured())
}
empty, err := Parse([]byte(`{}`))
if err != nil || empty.AnyConfigured() {
t.Errorf("an empty config must load with nothing set up: %v", err)
}
}
func TestCreate(t *testing.T) {
path := filepath.Join(t.TempDir(), "new", "dir", "config.json")
if err := Create(path); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("file: %v %v", info, err)
}
if dir, _ := os.Stat(filepath.Dir(path)); dir.Mode().Perm() != 0o700 {
t.Errorf("directory mode %v", dir.Mode().Perm())
}
cfg, warnings, err := Load(path)
if err != nil || len(warnings) > 0 || cfg.AnyConfigured() {
t.Errorf("the new file must load cleanly: %v %v", warnings, err)
}
}
+29
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/arr"
) )
@@ -56,6 +57,9 @@ type Movie struct {
Monitored bool `json:"monitored"` Monitored bool `json:"monitored"`
HasFile bool `json:"hasFile"` HasFile bool `json:"hasFile"`
QualityProfileID int `json:"qualityProfileId"` QualityProfileID int `json:"qualityProfileId"`
InCinemas string `json:"inCinemas,omitempty"` // ISO timestamps, empty if unknown
PhysicalRelease string `json:"physicalRelease,omitempty"`
DigitalRelease string `json:"digitalRelease,omitempty"`
SizeOnDisk int64 `json:"sizeOnDisk"` SizeOnDisk int64 `json:"sizeOnDisk"`
IsAvailable bool `json:"isAvailable"` IsAvailable bool `json:"isAvailable"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
@@ -170,3 +174,28 @@ func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool)
return nil return nil
}) })
} }
// DeleteMovie removes a movie from the library. With deleteFiles the movie's
// files are deleted from disk too; addExclusion keeps it from coming back
// through import lists.
func (c *Client) DeleteMovie(ctx context.Context, id int, deleteFiles, addExclusion bool) error {
q := url.Values{
"deleteFiles": {strconv.FormatBool(deleteFiles)},
"addImportExclusion": {strconv.FormatBool(addExclusion)},
}
return c.Do(ctx, http.MethodDelete, "movie/"+strconv.Itoa(id), q, nil, nil)
}
// Calendar lists the library movies with a release date (cinemas, digital or
// physical) between start and end, both inclusive. Unmonitored movies are
// only included when asked for.
func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Movie, error) {
q := url.Values{
"start": {start.Format("2006-01-02")},
"end": {end.Format("2006-01-02")},
"unmonitored": {strconv.FormatBool(unmonitored)},
}
var movies []Movie
err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &movies)
return movies, err
}
+34
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/arr"
) )
@@ -161,3 +162,36 @@ func TestReleases(t *testing.T) {
t.Fatalf("releases = %+v, %v", rels, err) t.Fatalf("releases = %+v, %v", rels, err)
} }
} }
func TestDeleteMovie(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/movie/12" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
q := r.URL.Query()
if q.Get("deleteFiles") != "true" || q.Get("addImportExclusion") != "false" {
t.Errorf("query = %v", q)
}
})
if err := c.DeleteMovie(context.Background(), 12, true, false); err != nil {
t.Fatal(err)
}
}
func TestCalendar(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if r.URL.Path != "/api/v3/calendar" || q.Get("start") != "2026-10-20" || q.Get("end") != "2026-11-02" || q.Get("unmonitored") != "true" {
t.Errorf("unexpected request %s", r.URL)
}
w.Write([]byte(`[{"id":4,"title":"Dune","year":2021,"monitored":true,"hasFile":false,"inCinemas":"2026-10-22T00:00:00Z","digitalRelease":"2026-11-01T00:00:00Z"}]`))
})
start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
movies, err := c.Calendar(context.Background(), start, start.AddDate(0, 0, 13), true)
if err != nil || len(movies) != 1 {
t.Fatalf("movies=%v err=%v", movies, err)
}
if m := movies[0]; m.InCinemas != "2026-10-22T00:00:00Z" || m.DigitalRelease == "" || m.PhysicalRelease != "" {
t.Errorf("dates not decoded: %+v", m)
}
}
+22 -2
View File
@@ -11,16 +11,26 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
// Client talks to a SABnzbd instance. // Client talks to a SABnzbd instance.
type Client struct { type Client struct {
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
baseURL string baseURL string
apiKey string apiKey string
http *http.Client http *http.Client
} }
// Reconfigure points the client at another instance or key.
func (c *Client) Reconfigure(baseURL, apiKey string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimRight(baseURL, "/")
c.apiKey = apiKey
}
// NewClient returns a client for the instance at baseURL. // NewClient returns a client for the instance at baseURL.
func NewClient(baseURL, apiKey string) *Client { func NewClient(baseURL, apiKey string) *Client {
return &Client{ return &Client{
@@ -30,6 +40,10 @@ func NewClient(baseURL, apiKey string) *Client {
} }
} }
// ErrNotConfigured is returned without a request while the client has no
// address: the service is not set up.
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48"). // Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
type Number float64 type Number float64
@@ -159,9 +173,15 @@ func boolParam(b bool) string {
// {"status": false, "error": "..."}, which is turned into an error here. // {"status": false, "error": "..."}, which is turned into an error here.
func (c *Client) call(ctx context.Context, params url.Values, out any) error { func (c *Client) call(ctx context.Context, params url.Values, out any) error {
params.Set("output", "json") params.Set("output", "json")
params.Set("apikey", c.apiKey) c.mu.RLock()
baseURL, apiKey := c.baseURL, c.apiKey
c.mu.RUnlock()
if baseURL == "" {
return ErrNotConfigured
}
params.Set("apikey", apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api?"+params.Encode(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api?"+params.Encode(), nil)
if err != nil { if err != nil {
return stripURL(err) return stripURL(err)
} }
+19
View File
@@ -122,3 +122,22 @@ func TestTransportErrorHidesAPIKey(t *testing.T) {
t.Errorf("error leaks API key: %v", err) t.Errorf("error leaks API key: %v", err)
} }
} }
func TestReconfigure(t *testing.T) {
var gotKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey = r.URL.Query().Get("apikey")
w.Write([]byte(`{"version":"5.1"}`))
}))
defer srv.Close()
c := NewClient("http://127.0.0.1:1", "old") // unreachable until reconfigured
if _, err := c.Version(context.Background()); err == nil {
t.Fatal("expected a connection error")
}
c.Reconfigure(srv.URL+"/", "new")
v, err := c.Version(context.Background())
if err != nil || v != "5.1" || gotKey != "new" {
t.Errorf("version=%q key=%q err=%v", v, gotKey, err)
}
}
+29
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/arr"
) )
@@ -216,8 +217,11 @@ type Episode struct {
EpisodeNumber int `json:"episodeNumber"` EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"` Title string `json:"title"`
AirDate string `json:"airDate"` AirDate string `json:"airDate"`
AirDateUTC string `json:"airDateUtc"`
Overview string `json:"overview,omitempty"`
HasFile bool `json:"hasFile"` HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"` Monitored bool `json:"monitored"`
Series *Series `json:"series,omitempty"` // only with includeSeries
} }
// Episodes lists all episodes of a library series. // Episodes lists all episodes of a library series.
@@ -246,3 +250,28 @@ func (c *Client) EpisodeFiles(ctx context.Context, seriesID int) ([]arr.MediaFil
err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files) err := c.Do(ctx, http.MethodGet, "episodefile", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &files)
return files, err return files, err
} }
// DeleteSeries removes a series from the library. With deleteFiles all its
// episode files are deleted from disk too; addExclusion keeps it from coming
// back through import lists.
func (c *Client) DeleteSeries(ctx context.Context, id int, deleteFiles, addExclusion bool) error {
q := url.Values{
"deleteFiles": {strconv.FormatBool(deleteFiles)},
"addImportListExclusion": {strconv.FormatBool(addExclusion)},
}
return c.Do(ctx, http.MethodDelete, "series/"+strconv.Itoa(id), q, nil, nil)
}
// Calendar lists the episodes airing between start and end, both inclusive,
// with their series. Unmonitored episodes are only included when asked for.
func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Episode, error) {
q := url.Values{
"start": {start.Format("2006-01-02")},
"end": {end.Format("2006-01-02")},
"unmonitored": {strconv.FormatBool(unmonitored)},
"includeSeries": {"true"},
}
var episodes []Episode
err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &episodes)
return episodes, err
}
+34
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
) )
const lookupJSON = `[ const lookupJSON = `[
@@ -164,3 +165,36 @@ func TestEpisodeFiles(t *testing.T) {
t.Errorf("attrs = %+v", a) t.Errorf("attrs = %+v", a)
} }
} }
func TestDeleteSeries(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/series/3" {
t.Errorf("unexpected request %s %s", r.Method, r.URL)
}
q := r.URL.Query()
if q.Get("deleteFiles") != "false" || q.Get("addImportListExclusion") != "true" {
t.Errorf("query = %v", q)
}
})
if err := c.DeleteSeries(context.Background(), 3, false, true); err != nil {
t.Fatal(err)
}
}
func TestCalendar(t *testing.T) {
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if r.URL.Path != "/api/v3/calendar" || q.Get("includeSeries") != "true" || q.Get("unmonitored") != "false" || q.Get("start") != "2026-10-20" {
t.Errorf("unexpected request %s", r.URL)
}
w.Write([]byte(`[{"id":9,"seriesId":2,"seasonNumber":2,"episodeNumber":5,"title":"Welcome","airDate":"2026-10-21","airDateUtc":"2026-10-22T01:00:00Z","overview":"Pilot.","monitored":true,"series":{"id":2,"title":"Andor","images":[{"coverType":"poster","url":"/p.jpg"}]}}]`))
})
start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
eps, err := c.Calendar(context.Background(), start, start.AddDate(0, 0, 13), false)
if err != nil || len(eps) != 1 {
t.Fatalf("eps=%v err=%v", eps, err)
}
if e := eps[0]; e.Series == nil || e.Series.Title != "Andor" || e.AirDateUTC != "2026-10-22T01:00:00Z" || e.Overview != "Pilot." || len(e.Series.Images) != 1 {
t.Errorf("episode not decoded: %+v", e)
}
}
+307 -46
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config" "git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr" "git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd" "git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
@@ -30,6 +32,8 @@ type view struct {
// App is the top-level arr-tui application. // App is the top-level arr-tui application.
type App struct { type App struct {
app *tview.Application app *tview.Application
theme theme // the colors in use
themed *themedScreen // draws with them; nil until Run
tabs *tview.TextView tabs *tview.TextView
pages *tview.Pages pages *tview.Pages
status *tview.TextView status *tview.TextView
@@ -41,11 +45,30 @@ type App struct {
overlays []overlay // dialogs and full-screen views stacked above the pages overlays []overlay // dialogs and full-screen views stacked above the pages
services *tview.TextView services *tview.TextView
topBar *tview.Flex // tab bar and connection indicators
narrow bool // terminal too narrow for full service names
state map[string]serviceState // keyed by service name state map[string]serviceState // keyed by service name
probes []probe probes []probe
screen tcell.Screen // remembered at the first draw, for the bell
cfg *config.Config // the settings in use (file, environment and defaults combined)
cfgPath string // the config file, empty if unknown
cfgFile *config.Config // the file's contents as written, for saving
radarr *radarr.Client
sonarr *sonarr.Client
sab *sabnzbd.Client
downloads *downloadsView
settings *settingsView
panes []*detailPane // every detail pane, to restyle posters when settings change
reconnect []func() // run after the service addresses or keys changed
watchStop context.CancelFunc
notifyDone bool // announce finished downloads
bell bool
covers bool // poster thumbnails enabled covers bool // poster thumbnails enabled
coverStyle coverStyle coverStyle coverStyle
gfx *kittyGraphics // real-image posters, nil when not used
lastWindow [3]int // columns, rows and cell shape at the last draw
cellAspect float64 // height of a terminal cell divided by its width
coverCache *coverCache coverCache *coverCache
} }
@@ -75,12 +98,16 @@ type overlay struct {
page string page string
focus tview.Primitive focus tview.Primitive
hints string hints string
full bool // full-screen view (can be left with F1-F5), as opposed to a dialog full bool // full-screen view (can be left with F1-F7), as opposed to a dialog
close func() // optional cleanup when the overlay is removed close func() // optional cleanup when the overlay is removed
} }
// New builds the application shell: a tab bar, one page per view and a status bar. // New builds the application shell: a tab bar, one page per view and a status bar.
func New(cfg *config.Config) *App { func New(cfg *config.Config) *App {
// Ask the terminal before the screen takes it over, and set the colors
// before any widget copies them.
th, themeNote := chooseTheme(cfg.UI.Theme, terminalAnswers())
useTokens()
a := &App{ a := &App{
app: tview.NewApplication(), app: tview.NewApplication(),
tabs: tview.NewTextView().SetDynamicColors(true), tabs: tview.NewTextView().SetDynamicColors(true),
@@ -90,15 +117,37 @@ func New(cfg *config.Config) *App {
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight), services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
state: map[string]serviceState{}, state: map[string]serviceState{},
notifyDone: cfg.UI.ShouldNotifyDownloads(),
bell: cfg.UI.Bell,
covers: cfg.UI.ShowCovers(), covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv), cellAspect: 2,
coverCache: newCoverCache(), coverCache: newCoverCache(),
} }
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
coverNote := "Posters: " + blocks.name + " block characters"
if graphics {
coverNote = "Posters: real images (the terminal confirmed the Kitty graphics protocol)"
} else if cfg.UI.CoverStyle == "kitty" || kittyTerminal(os.Getenv) {
coverNote += " (the terminal did not confirm the Kitty graphics protocol)"
}
a.coverStyle = blocks
if graphics {
a.gfx = a.newGraphics()
}
a.cfg = cfg
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey) radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.radarr, a.sonarr = radarrClient, sonarrClient
a.logs = newLogView(a) a.logs = newLogView(a)
if a.covers {
a.logs.add(levelInfo, coverNote)
}
a.theme = th
a.logs.add(levelInfo, themeNote)
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey) sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
a.probes = []probe{ a.probes = []probe{
{"Radarr", func(ctx context.Context) (string, error) { {"Radarr", func(ctx context.Context) (string, error) {
st, err := radarrClient.SystemStatus(ctx) st, err := radarrClient.SystemStatus(ctx)
@@ -110,6 +159,9 @@ func New(cfg *config.Config) *App {
}}, }},
{"SABnzbd", sabClient.Version}, {"SABnzbd", sabClient.Version},
} }
settings := newSettingsView(a)
a.settings = settings
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient) library := newLibraryView(a, radarrClient, sonarrClient)
downloads := newDownloadsView(a, sabClient, downloads := newDownloadsView(a, sabClient,
[]arrSource{ []arrSource{
@@ -117,31 +169,148 @@ func New(cfg *config.Config) *App {
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem}, {name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
}, },
time.Duration(cfg.UI.RefreshSeconds)*time.Second) time.Duration(cfg.UI.RefreshSeconds)*time.Second)
a.downloads = downloads
a.views = []view{ a.views = []view{
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints}, {key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints}, {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints}, {key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints}, {key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
{key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints}, {key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
{key: '6', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints},
{key: '7', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
} }
for i, v := range a.views { for i, v := range a.views {
a.pages.AddPage(v.name, v.body, true, i == 0) a.pages.AddPage(v.name, v.body, true, i == 0)
} }
layout := tview.NewFlex().SetDirection(tview.FlexRow). a.topBar = tview.NewFlex().
AddItem(tview.NewFlex().
AddItem(a.tabs, 0, 1, false). AddItem(a.tabs, 0, 1, false).
AddItem(a.services, 25, 0, false), 1, 0, false). AddItem(a.services, servicesWidth(false), 0, false)
layout := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(a.topBar, 1, 0, false).
AddItem(a.pages, 0, 1, true). AddItem(a.pages, 0, 1, true).
AddItem(a.status, 1, 0, false) AddItem(a.status, 1, 0, false)
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
// A focused text field shows the cursor again while it is drawn. Hiding it
// first covers focus moves that bypass tview (arrow keys in forms), which
// would leave the cursor behind in the field that was left.
s.HideCursor()
a.screen = s
a.cellAspect = cellAspectOf(s)
a.noteWindow(s)
return false
})
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) }
a.app.SetInputCapture(a.handleKey) a.app.SetInputCapture(a.handleKey)
a.switchTo(0) a.switchTo(0)
if !cfg.AnyConfigured() {
// First start (or nothing set up): go where that is done.
a.switchTo(a.viewIndex("Config"))
a.Notify(levelInfo, "Welcome! Enter the addresses and API keys of your services, then Save")
}
a.renderServices() a.renderServices()
return a return a
} }
// newGraphics creates the real-image drawer. With ARR_TUI_DEBUG_GFX set it also
// writes what it does (window size, cell shape, every upload) to the Log, which
// helps to find out why a poster is drawn wrongly in some terminal.
func (a *App) newGraphics() *kittyGraphics {
k := newKittyGraphics(a.writeTerminal)
switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); {
case v == "":
case strings.Contains(v, "/"):
// A path: also append to that file, so the lines can be read without
// leaving the view that shows the problem.
f, err := os.OpenFile(v, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
a.logs.add(levelWarn, "ARR_TUI_DEBUG_GFX: "+err.Error())
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
break
}
k.debug = func(msg string) {
a.logs.add(levelInfo, msg)
fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), msg)
}
default:
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
}
return k
}
// noteWindow notices when the terminal's size or cell size changed. The
// real-image posters are then uploaded and placed again from scratch instead of
// relying on the terminal to rescale what is there.
func (a *App) noteWindow(s tcell.Screen) {
w, h := s.Size()
win := [3]int{w, h, int(a.cellAspect * 1000)}
if win == a.lastWindow {
return
}
first := a.lastWindow == [3]int{}
a.lastWindow = win
if !first && a.gfx != nil {
a.gfx.deleteAll()
a.gfx.hold() // blocks while the size is changing, real images when it has settled
time.AfterFunc(settleDelay+30*time.Millisecond, func() { a.post(func() {}) })
}
if a.gfx != nil && a.gfx.debug != nil {
a.gfx.debug(fmt.Sprintf("window: %d×%d cells, cell shape %.3f", w, h, a.cellAspect))
}
}
// writeTerminal sends raw bytes (graphics commands) to the terminal through the
// screen's own connection. UI goroutine only, so it stays in step with drawing.
func (a *App) writeTerminal(b []byte) error {
if a.screen == nil {
return fmt.Errorf("no screen yet")
}
tty, ok := a.screen.Tty()
if !ok {
return fmt.Errorf("the screen has no terminal")
}
_, err := tty.Write(b)
return err
}
// cellAspectOf measures the height of a terminal cell divided by its width in
// pixels; 2 when the terminal does not report pixel sizes.
func cellAspectOf(s tcell.Screen) float64 {
if tty, ok := s.Tty(); ok {
if ws, err := tty.WindowSize(); err == nil {
return cellAspectFromWindow(ws.Width, ws.Height, ws.PixelWidth, ws.PixelHeight)
}
}
return 2
}
// cellAspectFromWindow derives the cell shape from the window size in cells
// and pixels. The pixel size usually includes some padding around the cell
// grid, which would skew a plain division (more with a small grid, and it
// changes while the window is resized); cell sizes are whole pixels, so the
// division is rounded down, which drops that padding.
func cellAspectFromWindow(cols, rows, pixelW, pixelH int) float64 {
if cols <= 0 || rows <= 0 || pixelW <= 0 || pixelH <= 0 {
return 2
}
cw, ch := pixelW/cols, pixelH/rows
if cw <= 0 || ch <= 0 {
return 2
}
return float64(ch) / float64(cw)
}
// coverMaxHeight is how tall the stored poster copies are: real images keep
// more detail than block characters need.
func (a *App) coverMaxHeight() int {
if a.gfx != nil {
return graphicsCoverHeight
}
return storedCoverHeight
}
// SetService records the outcome of a request to a service and updates the // SetService records the outcome of a request to a service and updates the
// connection indicators. UI goroutine only. A nil err means reachable. // connection indicators. UI goroutine only. A nil err means reachable.
func (a *App) SetService(name, version string, err error) { func (a *App) SetService(name, version string, err error) {
@@ -152,7 +321,11 @@ func (a *App) SetService(name, version string, err error) {
a.state[name] = serviceState{known: true, version: version, err: err} a.state[name] = serviceState{known: true, version: version, err: err}
// Log connection changes only, so background polling does not flood the log. // Log connection changes only, so background polling does not flood the log.
switch { switch {
case err != nil && (!prev.known || prev.err == nil): case notConfigured(err):
if !prev.known || !notConfigured(prev.err) {
a.logs.add(levelInfo, name+" is not configured; set it up in the Config view (F6)")
}
case err != nil && (!prev.known || prev.err == nil || notConfigured(prev.err)):
a.logs.add(levelError, name+" unreachable: "+err.Error()) a.logs.add(levelError, name+" unreachable: "+err.Error())
case err == nil && (!prev.known || prev.err != nil): case err == nil && (!prev.known || prev.err != nil):
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected") a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
@@ -160,13 +333,39 @@ func (a *App) SetService(name, version string, err error) {
a.renderServices() a.renderServices()
} }
// servicesWidth is the width of the connection indicators.
func servicesWidth(narrow bool) int {
if narrow {
return 14
}
return 25
}
// setNarrow switches the indicators between full names and initials; called
// from the draw handler when the terminal width crosses the threshold.
func (a *App) setNarrow(narrow bool) {
if narrow == a.narrow {
return
}
a.narrow = narrow
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
a.renderServices()
a.renderTabs()
}
func (a *App) renderServices() { func (a *App) renderServices() {
var s string var s string
for _, n := range serviceNames { for _, n := range serviceNames {
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
if a.narrow {
short = n[:1]
if n == "SABnzbd" {
short = "SAB"
}
}
st := a.state[n] st := a.state[n]
switch { switch {
case !st.known: case !st.known, notConfigured(st.err):
s += "[gray]○ " + short + "[-] " s += "[gray]○ " + short + "[-] "
case st.err != nil: case st.err != nil:
s += "[red]● " + short + "[-] " s += "[red]● " + short + "[-] "
@@ -185,6 +384,8 @@ func (a *App) serviceSummary() string {
switch { switch {
case !st.known: case !st.known:
fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n) fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n)
case notConfigured(st.err):
fmt.Fprintf(&b, "[gray]○ %s: not configured (Config view, F6)[-]\n", n)
case st.err != nil: case st.err != nil:
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error())) fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
case st.version != "": case st.version != "":
@@ -196,6 +397,21 @@ func (a *App) serviceSummary() string {
return b.String() return b.String()
} }
// viewIndex returns the position of the view with the given name.
func (a *App) viewIndex(name string) int {
for i, v := range a.views {
if v.name == name {
return i
}
}
return 0
}
// notConfigured reports whether err says a service is not set up.
func notConfigured(err error) bool {
return errors.Is(err, arr.ErrNotConfigured) || errors.Is(err, sabnzbd.ErrNotConfigured)
}
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine. // post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
func (a *App) post(f func()) { func (a *App) post(f func()) {
a.app.QueueUpdateDraw(f) a.app.QueueUpdateDraw(f)
@@ -205,23 +421,59 @@ func (a *App) post(f func()) {
func (a *App) Run() error { func (a *App) Run() error {
stop := make(chan struct{}) stop := make(chan struct{})
defer close(stop) defer close(stop)
defer func() {
if a.gfx != nil {
a.gfx.deleteAll()
}
}()
// The screen draws through the theme (see themedScreen).
s, err := tcell.NewScreen()
if err != nil {
return err
}
a.themed = newThemedScreen(s, a.theme)
a.app.SetScreen(a.themed) // initializes it
if a.themed.initErr != nil {
return a.themed.initErr
}
a.themed.DisableMouse()
a.themed.DisablePaste()
go a.watchServices(stop) go a.watchServices(stop)
a.setNotifyDownloads(a.notifyDone)
defer a.setNotifyDownloads(false)
return a.app.Run() return a.app.Run()
} }
// setTheme switches the colors while the program runs.
func (a *App) setTheme(t theme, note string) {
a.theme = t
if a.themed != nil {
a.themed.setTheme(t)
}
a.logs.add(levelInfo, note)
}
// setNotifyDownloads starts or stops the background watch for finished downloads.
func (a *App) setNotifyDownloads(on bool) {
a.notifyDone = on
if a.watchStop != nil {
a.watchStop()
a.watchStop = nil
}
if on {
ctx, cancel := context.WithCancel(context.Background())
a.watchStop = cancel
go a.watchDownloads(ctx, a.sab)
}
}
// watchServices checks all services now and then every heartbeat until stop is closed. // watchServices checks all services now and then every heartbeat until stop is closed.
func (a *App) watchServices(stop <-chan struct{}) { func (a *App) watchServices(stop <-chan struct{}) {
tick := time.NewTicker(heartbeat) tick := time.NewTicker(heartbeat)
defer tick.Stop() defer tick.Stop()
for { for {
for _, p := range a.probes { a.probeAll()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version, err := p.check(ctx)
a.post(func() { a.SetService(p.name, version, err) })
}()
}
select { select {
case <-tick.C: case <-tick.C:
case <-stop: case <-stop:
@@ -230,15 +482,30 @@ func (a *App) watchServices(stop <-chan struct{}) {
} }
} }
// probeAll checks every service in the background and updates the indicators.
func (a *App) probeAll() {
for _, p := range a.probes {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version, err := p.check(ctx)
a.post(func() { a.SetService(p.name, version, err) })
}()
}
}
func (a *App) renderStatus() { func (a *App) renderStatus() {
var hints string var hints string
if n := len(a.overlays); n > 0 { if n := len(a.overlays); n > 0 {
hints = a.overlays[n-1].hints + " · Ctrl+C quit" hints = a.overlays[n-1].hints + " · Ctrl+C quit"
} else { } else {
if h := a.views[a.active].hints; h != nil { v := a.views[a.active]
hints = h() + " · " var parts []string
if v.hints != nil {
parts = append(parts, v.hints())
} }
hints += "? help · F1-F5 · q quit" parts = append(parts, "F9 help · F1-F7 · Ctrl+C quit")
hints = strings.Join(parts, " · ")
} }
a.status.SetText("[gray]" + hints) a.status.SetText("[gray]" + hints)
} }
@@ -254,6 +521,11 @@ func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onC
// openModal shows p centered above the current view. // openModal shows p centered above the current view.
func (a *App) openModal(p tview.Primitive, width, height int) { func (a *App) openModal(p tview.Primitive, width, height int) {
a.openModalHints(p, width, height, "↑↓ move · ←→ change · Enter select · Esc cancel")
}
// openModalHints is openModal with its own status bar hints.
func (a *App) openModalHints(p tview.Primitive, width, height int, hints string) {
box := tview.NewFlex(). box := tview.NewFlex().
AddItem(nil, 0, 1, false). AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow). AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
@@ -261,7 +533,7 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
AddItem(p, height, 0, true). AddItem(p, height, 0, true).
AddItem(nil, 0, 1, false), width, 0, true). AddItem(nil, 0, 1, false), width, 0, true).
AddItem(nil, 0, 1, false) AddItem(nil, 0, 1, false)
a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil) a.pushOverlay(box, p, hints, false, nil)
} }
// openSidePanel shows p at the top right so most of the view behind it stays // openSidePanel shows p at the top right so most of the view behind it stays
@@ -322,16 +594,6 @@ func (a *App) onlyFullOverlays() bool {
return true return true
} }
// typing reports whether keystrokes belong to a text field or dialog and must
// not trigger global shortcuts.
func (a *App) typing() bool {
if len(a.overlays) > 0 {
return true
}
_, isInput := a.app.GetFocus().(*tview.InputField)
return isInput
}
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyCtrlC { if ev.Key() == tcell.KeyCtrlC {
a.app.Stop() a.app.Stop()
@@ -352,20 +614,10 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
return nil return nil
} }
} }
if a.typing() {
return ev
}
switch ev.Key() {
case tcell.KeyRune:
if ev.Rune() == 'q' {
a.app.Stop()
return nil
}
}
return ev return ev
} }
// viewShortcut returns the view index for F1-F5, or -1. // viewShortcut returns the view index for F1-F7, or -1.
func (a *App) viewShortcut(ev *tcell.EventKey) int { func (a *App) viewShortcut(ev *tcell.EventKey) int {
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 { if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
return int(ev.Key() - tcell.KeyF1) return int(ev.Key() - tcell.KeyF1)
@@ -389,9 +641,14 @@ func (a *App) switchTo(i int) {
func (a *App) renderTabs() { func (a *App) renderTabs() {
var s string var s string
for i, v := range a.views { for i, v := range a.views {
if i == a.active { switch {
case a.narrow && i == a.active:
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name) s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
} else { case a.narrow:
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
case i == a.active:
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name) s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
} }
} }
@@ -403,7 +660,11 @@ func searchHints() string {
} }
func libraryHints() string { func libraryHints() string {
return "Tab list · f status · F file props · / text · t monitor · s search" return "Tab list · f status · F props · / text · t monitor · s search · d remove"
}
func calendarHints() string {
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
} }
func logHints() string { return "↑↓ scroll · G follow · c clear" } func logHints() string { return "↑↓ scroll · G follow · c clear" }
+45 -2
View File
@@ -1,11 +1,15 @@
package ui package ui
import ( import (
"errors"
"strings"
"testing" "testing"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config" "git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
) )
func newTestApp(t *testing.T) *App { func newTestApp(t *testing.T) *App {
@@ -22,7 +26,7 @@ func newTestApp(t *testing.T) *App {
func TestOnlyFunctionKeysSwitchViews(t *testing.T) { func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
for _, r := range "12345\t" { for _, r := range "1234567\t" {
key := tcell.NewEventKey(tcell.KeyRune, r, 0) key := tcell.NewEventKey(tcell.KeyRune, r, 0)
if r == '\t' { if r == '\t' {
key = tcell.NewEventKey(tcell.KeyTab, 0, 0) key = tcell.NewEventKey(tcell.KeyTab, 0, 0)
@@ -32,9 +36,48 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
t.Fatalf("key %q switched to view %d", r, a.active) t.Fatalf("key %q switched to view %d", r, a.active)
} }
} }
for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5} { for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6, tcell.KeyF7} {
if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i { if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i {
t.Errorf("F%d: active view %d, want %d", i+1, a.active, i) t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
} }
} }
} }
func TestQDoesNotQuit(t *testing.T) {
a := newTestApp(t)
ev := tcell.NewEventKey(tcell.KeyRune, 'q', 0)
if got := a.handleKey(ev); got != ev {
t.Error("q must be passed on as an ordinary key, not taken as quit")
}
}
func TestStartsWithoutServices(t *testing.T) {
// The config a first start creates: nothing set up.
a := New(&config.Config{UI: config.UI{RefreshSeconds: 3}})
if a.views[a.active].name != "Config" {
t.Errorf("first start opens %q, want the Config view", a.views[a.active].name)
}
// The clients do not try to connect without an address.
_, err := a.radarr.SystemStatus(t.Context())
if !errors.Is(err, arr.ErrNotConfigured) {
t.Fatalf("radarr without address: %v", err)
}
if _, err := a.sab.Version(t.Context()); !errors.Is(err, sabnzbd.ErrNotConfigured) {
t.Fatalf("sabnzbd without address: %v", err)
}
// Not configured is grey, not red, and logged once as information.
a.SetService("Radarr", "", err)
a.SetService("Radarr", "", err)
if txt := a.services.GetText(false); !strings.Contains(txt, "○ Radarr") {
t.Errorf("indicator: %q", txt)
}
log := a.logs.root.GetText(true)
if strings.Count(log, "Radarr is not configured") != 1 || strings.Contains(log, "unreachable") {
t.Errorf("log: %q", log)
}
if !strings.Contains(a.serviceSummary(), "Radarr: not configured") {
t.Errorf("help summary: %q", a.serviceSummary())
}
}
+469
View File
@@ -0,0 +1,469 @@
package ui
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
const (
calendarDays = 14 // length of the window
calendarStep = 7 // `[` and `]` move by this many days
)
// calEntry is one line of the calendar: a movie release or an episode airing.
type calEntry struct {
day time.Time // local midnight of the day
at time.Time // time of day, zero for date-only entries (movie releases)
movie bool
title string // plain title, for sorting
label string // escaped title for display
sub string // "S02E05 Welcome" or "Digital release"
have bool
monitored bool
missing bool // aired already and not downloaded
details detailText
cover coverRef
movieID, episodeID int
}
func (e calEntry) key() string {
if e.movie {
return fmt.Sprintf("m%d/%s/%s", e.movieID, e.sub, e.day.Format("2006-01-02"))
}
return fmt.Sprintf("e%d", e.episodeID)
}
// localDay returns midnight of the date of t in the local zone.
func localDay(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
}
// releaseDay reads a movie release date. Radarr sends these as midnight UTC
// timestamps meaning "this date", so the date is taken as written and not
// shifted into the local zone.
func releaseDay(s string) (time.Time, bool) {
if s == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, false
}
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), true
}
// movieEntries returns an entry for every release date of the movie that falls
// into [from, to] (local days).
func movieEntries(m radarr.Movie, from, to time.Time, cover coverRef, now time.Time) []calEntry {
var out []calEntry
for _, rel := range []struct{ name, date string }{
{"In cinemas", m.InCinemas}, {"Digital release", m.DigitalRelease}, {"Physical release", m.PhysicalRelease},
} {
day, ok := releaseDay(rel.date)
if !ok || day.Before(from) || day.After(to) {
continue
}
label := tview.Escape(m.Title)
if m.Year > 0 {
label += fmt.Sprintf(" (%d)", m.Year)
}
d := movieDetails(m)
d.head += "Release: " + rel.name + ", " + day.Format("Mon 2 Jan 2006") + "\n"
out = append(out, calEntry{
day: day, movie: true, title: m.Title, label: label, sub: rel.name,
have: m.HasFile, monitored: m.Monitored, missing: m.Monitored && !m.HasFile && !day.After(now),
details: d, cover: cover, movieID: m.ID,
})
}
return out
}
// episodeEntry converts an airing episode, or reports false if it has no usable date.
func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time.Time) (calEntry, bool) {
var day, at time.Time
if t, err := time.Parse(time.RFC3339, e.AirDateUTC); err == nil {
at = t.Local()
day = localDay(at)
} else if t, err := time.Parse("2006-01-02", e.AirDate); err == nil {
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
} else {
return calEntry{}, false
}
if day.Before(from) || day.After(to) {
return calEntry{}, false
}
series := "Unknown series"
var overview string
if e.Series != nil {
series = e.Series.Title
overview = e.Series.Overview
}
if e.Overview != "" {
overview = e.Overview
}
sub := fmt.Sprintf("S%02dE%02d %s", e.SeasonNumber, e.EpisodeNumber, e.Title)
head := "[::b]" + tview.Escape(series) + "[::-]\n" + tview.Escape(sub) + "\n"
if !at.IsZero() {
head += "Airs " + at.Format("Mon 2 Jan 15:04") + "\n"
} else {
head += "Airs " + day.Format("Mon 2 Jan") + "\n"
}
switch {
case e.HasFile:
head += "[green]Downloaded[-]\n"
case !e.Monitored:
head += "[gray]Not monitored[-]\n"
default:
head += "[yellow]Not downloaded[-]\n"
}
aired := !at.IsZero() && at.Before(now) || at.IsZero() && day.Before(localDay(now))
return calEntry{
day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired,
details: detailText{head: head, plot: tview.Escape(overview)}, cover: cover,
episodeID: e.ID,
}, true
}
// sortEntries orders by day, then time of day (date-only first), then title.
func sortEntries(es []calEntry) {
slices.SortStableFunc(es, func(a, b calEntry) int {
return cmp.Or(
a.day.Compare(b.day),
a.at.Compare(b.at),
strings.Compare(strings.ToLower(a.title), strings.ToLower(b.title)),
strings.Compare(a.sub, b.sub),
)
})
}
// dayLabel returns the date of a day and, for days near today, its relative
// name: ("Wed 21 Oct", "Tomorrow").
func dayLabel(day, today time.Time) (date, relative string) {
date = day.Format("Mon 2 Jan")
switch int(day.Sub(today).Round(24*time.Hour).Hours() / 24) { // Round: days are 23 or 25 hours long when the clocks change
case 0:
return date, "Today"
case 1:
return date, "Tomorrow"
case -1:
return date, "Yesterday"
}
return date, ""
}
// calendarView lists the movies and episodes coming up in a two-week window.
type calendarView struct {
app *App
radarr *radarr.Client
sonarr *sonarr.Client
root *tview.Flex
header *tview.TextView
table *tview.Table
details *detailPane
// UI-goroutine state.
offset int // days from today to the first day shown; moved with [ and ]
unmonitored bool
entries []calEntry
rows []calRow
loaded bool
gen int
}
// calRow is a table row: a day header (entry == nil) or an entry.
type calRow struct {
day time.Time
entry *calEntry
}
func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView {
v := &calendarView{
app: app, radarr: r, sonarr: s,
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
details: newDetailPane(app),
}
v.table.SetBorder(true).SetTitle(" Upcoming ")
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(tview.NewFlex().
AddItem(v.table, 0, 3, true).
AddItem(v.details, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
v.table.SetSelectedFunc(func(int, int) { v.open() })
v.table.SetInputCapture(v.handleKey)
app.reconnect = append(app.reconnect, func() { v.entries, v.loaded = nil, false })
v.render()
return v
}
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() != tcell.KeyRune {
return ev
}
switch ev.Rune() {
case '[':
v.offset -= calendarStep
v.reload()
case ']':
v.offset += calendarStep
v.reload()
case 't':
v.offset = 0
v.reload()
case 'u':
v.unmonitored = !v.unmonitored
v.reload()
case 'r':
v.reload()
case 'i':
v.open()
case 's':
v.search()
default:
return ev
}
return nil
}
// window returns the first and last day shown.
func (v *calendarView) window(now time.Time) (from, to time.Time) {
from = localDay(now).AddDate(0, 0, v.offset)
return from, from.AddDate(0, 0, calendarDays-1)
}
func (v *calendarView) activate() { v.reload() }
// reload fetches both calendars in parallel.
func (v *calendarView) reload() {
v.gen++
gen, unmonitored := v.gen, v.unmonitored
now := time.Now()
from, to := v.window(now)
v.render()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var (
wg sync.WaitGroup
movies []radarr.Movie
episodes []sonarr.Episode
movieErr, tvErr error
)
wg.Add(2)
go func() { defer wg.Done(); movies, movieErr = v.radarr.Calendar(ctx, from, to, unmonitored) }()
go func() { defer wg.Done(); episodes, tvErr = v.sonarr.Calendar(ctx, from, to, unmonitored) }()
wg.Wait()
var entries []calEntry
for _, m := range movies {
entries = append(entries, movieEntries(m, from, to, coverFor(v.radarr.Client, m.Images, true), now)...)
}
for _, e := range episodes {
var cover coverRef
if e.Series != nil {
cover = coverFor(v.sonarr.Client, e.Series.Images, true)
}
if entry, ok := episodeEntry(e, from, to, cover, now); ok {
entries = append(entries, entry)
}
}
sortEntries(entries)
v.app.post(func() {
v.app.SetService("Radarr", "", movieErr)
v.app.SetService("Sonarr", "", tvErr)
if gen != v.gen {
return
}
// A service that is not set up just contributes nothing.
usable := func(err error) bool { return err == nil || notConfigured(err) }
for _, err := range []error{movieErr, tvErr} {
if !usable(err) {
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
}
}
if usable(movieErr) || usable(tvErr) { // show what we got
v.entries, v.loaded = entries, true
}
v.render()
})
}()
}
func (v *calendarView) selected() (*calEntry, bool) {
r, _ := v.table.GetSelection()
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
return nil, false
}
return v.rows[r].entry, true
}
func (v *calendarView) render() {
selKey := ""
if e, ok := v.selected(); ok {
selKey = e.key()
}
now := time.Now()
today := localDay(now)
from, to := v.window(now)
v.rows = v.rows[:0]
var lastDay time.Time
for i := range v.entries {
e := &v.entries[i]
if !e.day.Equal(lastDay) {
v.rows = append(v.rows, calRow{day: e.day})
lastDay = e.day
}
v.rows = append(v.rows, calRow{day: e.day, entry: e})
}
v.table.Clear()
selRow, firstEntry := 0, -1
for i, r := range v.rows {
if r.entry == nil {
date, relative := dayLabel(r.day, today)
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
v.table.SetCell(i, 1, tview.NewTableCell("").SetSelectable(false))
v.table.SetCell(i, 2, tview.NewTableCell("[yellow]"+relative).SetSelectable(false))
v.table.SetCell(i, 3, tview.NewTableCell("").SetSelectable(false))
continue
}
e := r.entry
if firstEntry < 0 {
firstEntry = i
}
when := " "
if !e.at.IsZero() {
when = e.at.Format("15:04") + " "
}
kind := "[blue]TV "
if e.movie {
kind = "[purple]Movie"
}
v.table.SetCell(i, 0, tview.NewTableCell(" "+when).SetSelectable(true))
v.table.SetCell(i, 1, tview.NewTableCell(kind+"[-]"))
v.table.SetCell(i, 2, tview.NewTableCell(e.label+" [gray]"+tview.Escape(e.sub)).SetExpansion(1))
v.table.SetCell(i, 3, tview.NewTableCell(calStatus(*e)))
if selKey != "" && e.key() == selKey {
selRow = i
}
}
title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " "
if v.unmonitored {
title += "· incl. unmonitored "
}
v.table.SetTitle(title)
switch {
case len(v.entries) == 0:
msg := "Loading…"
if v.loaded {
msg = "Nothing in this period"
}
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
v.table.SetSelectable(false, false)
v.table.Select(0, 0)
default:
v.table.SetSelectable(true, false)
if selRow == 0 {
selRow = firstEntry
}
v.table.Select(selRow, 0)
if selRow == firstEntry {
v.table.ScrollToBeginning()
}
}
v.header.SetText("[::b]Calendar[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search")
v.renderDetails()
}
// calStatus is the coloured status text of an entry.
func calStatus(e calEntry) string {
switch {
case e.have:
return "[green]✓ downloaded"
case !e.monitored:
return "[gray]unmonitored"
case e.missing:
return "[yellow]missing"
}
return "[gray]upcoming"
}
func (v *calendarView) renderDetails() {
if e, ok := v.selected(); ok {
v.details.Show(e.details, e.cover)
return
}
v.details.Clear()
}
// open starts the interactive release search for the selected entry.
func (v *calendarView) open() {
e, ok := v.selected()
if !ok {
return
}
if e.movie {
id, title := e.movieID, tview.Escape(e.title)
v.app.openReleases("Releases for "+title,
func(ctx context.Context) ([]arr.Release, error) { return v.radarr.Releases(ctx, id) },
v.radarr.Grab)
return
}
id := e.episodeID
v.app.openReleases("Releases for "+e.label+" "+tview.Escape(e.sub),
func(ctx context.Context) ([]arr.Release, error) { return v.sonarr.EpisodeReleases(ctx, id) },
v.sonarr.Grab)
}
// search starts an automatic search for the selected entry.
func (v *calendarView) search() {
e, ok := v.selected()
if !ok {
return
}
what := e.label
if !e.movie {
what += " " + tview.Escape(e.sub)
}
v.app.SetMessage("Searching for " + what + "…")
movie, movieID, episodeID := e.movie, e.movieID, e.episodeID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var err error
if movie {
err = v.radarr.SearchMovie(ctx, movieID)
} else {
err = v.sonarr.SearchEpisode(ctx, episodeID)
}
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
return
}
v.app.SetMessage("[green]Search started for " + what)
})
}()
}
+171
View File
@@ -0,0 +1,171 @@
package ui
import (
"strings"
"testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.Local) }
func TestMovieEntriesPerReleaseDate(t *testing.T) {
from, to := day(2026, 10, 20), day(2026, 11, 2)
now := day(2026, 10, 21).Add(10 * time.Hour)
m := radarr.Movie{
ID: 4, Title: "Dune [Part 2]", Year: 2024, Monitored: true,
InCinemas: "2026-10-22T00:00:00Z", // inside
DigitalRelease: "2026-11-01T00:00:00Z", // inside
// physical release is after the window
PhysicalRelease: "2026-12-01T00:00:00Z",
}
es := movieEntries(m, from, to, coverRef{}, now)
if len(es) != 2 {
t.Fatalf("%d entries, want 2: %+v", len(es), es)
}
if es[0].sub != "In cinemas" || !es[0].day.Equal(day(2026, 10, 22)) || es[1].sub != "Digital release" || !es[1].day.Equal(day(2026, 11, 1)) {
t.Errorf("entries = %+v", es)
}
if !es[0].movie || es[0].missing || !strings.Contains(es[0].label, "Dune [Part 2[]") {
t.Errorf("flags/label wrong: %+v", es[0])
}
if es[0].key() == es[1].key() {
t.Error("the same movie on two dates must have distinct keys")
}
if !strings.Contains(es[0].details.head, "Release: In cinemas, Thu 22 Oct 2026") {
t.Errorf("details: %q", es[0].details.head)
}
// A release that already happened and is not downloaded counts as missing.
past := movieEntries(radarr.Movie{ID: 5, Title: "Old", Monitored: true, DigitalRelease: "2026-10-20T00:00:00Z"}, from, to, coverRef{}, now)
if len(past) != 1 || !past[0].missing {
t.Errorf("past release: %+v", past)
}
// Dates are taken as written: midnight UTC stays on that calendar day in every zone.
if es := movieEntries(radarr.Movie{ID: 6, Title: "Z", InCinemas: "2026-10-25T00:00:00Z"}, from, to, coverRef{}, now); len(es) != 1 || es[0].day.Day() != 25 {
t.Errorf("timezone shifted the release date: %+v", es)
}
if es := movieEntries(radarr.Movie{ID: 7, Title: "NoDates"}, from, to, coverRef{}, now); len(es) != 0 {
t.Errorf("movie without dates: %+v", es)
}
}
func TestEpisodeEntry(t *testing.T) {
from, to := day(2026, 10, 20), day(2026, 11, 2)
now := day(2026, 10, 21).Add(12 * time.Hour)
at := time.Date(2026, 10, 22, 19, 30, 0, 0, time.UTC)
e := sonarr.Episode{
ID: 9, SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome", Monitored: true,
AirDateUTC: at.Format(time.RFC3339), Overview: "Episode plot",
Series: &sonarr.Series{Title: "Andor", Overview: "Series plot"},
}
got, ok := episodeEntry(e, from, to, coverRef{}, now)
if !ok {
t.Fatal("episode in the window rejected")
}
if got.sub != "S02E05 Welcome" || got.title != "Andor" || got.movie || got.episodeID != 9 || got.at.IsZero() {
t.Errorf("entry = %+v", got)
}
if !got.day.Equal(localDay(at.Local())) {
t.Errorf("day = %v, want the local date of the airing", got.day)
}
if got.details.plot != "Episode plot" || !strings.Contains(got.details.head, "Andor") || !strings.Contains(got.details.head, "S02E05") {
t.Errorf("details = %+v", got.details)
}
if got.missing {
t.Error("an episode that has not aired yet is not missing")
}
// Falls back to the series plot, and to the date-only air date.
e2 := sonarr.Episode{ID: 10, Title: "X", AirDate: "2026-10-19", Monitored: true, Series: &sonarr.Series{Title: "S", Overview: "Series plot"}}
if _, ok := episodeEntry(e2, from, to, coverRef{}, now); ok {
t.Error("an episode before the window must be left out")
}
e2.AirDate = "2026-10-20"
got, ok = episodeEntry(e2, from, to, coverRef{}, now)
if !ok || got.details.plot != "Series plot" || !got.at.IsZero() || !got.missing {
t.Errorf("date-only episode: ok=%v %+v", ok, got)
}
if _, ok := episodeEntry(sonarr.Episode{ID: 11}, from, to, coverRef{}, now); ok {
t.Error("an episode without any date has no place in the calendar")
}
}
func TestSortEntriesAndDayLabel(t *testing.T) {
d1, d2 := day(2026, 10, 21), day(2026, 10, 22)
es := []calEntry{
{day: d2, title: "b"},
{day: d1, at: d1.Add(20 * time.Hour), title: "late"},
{day: d1, title: "dateonly"},
{day: d1, at: d1.Add(9 * time.Hour), title: "early"},
{day: d1, at: d1.Add(9 * time.Hour), title: "Alpha"},
}
sortEntries(es)
var got []string
for _, e := range es {
got = append(got, e.title)
}
if strings.Join(got, ",") != "dateonly,Alpha,early,late,b" {
t.Errorf("order = %v", got)
}
today := day(2026, 10, 21)
type label struct{ date, rel string }
for d, want := range map[time.Time]label{
today: {"Wed 21 Oct", "Today"},
today.AddDate(0, 0, 1): {"Thu 22 Oct", "Tomorrow"},
today.AddDate(0, 0, -1): {"Tue 20 Oct", "Yesterday"},
today.AddDate(0, 0, 5): {"Mon 26 Oct", ""},
} {
if date, rel := dayLabel(d, today); (label{date, rel}) != want {
t.Errorf("dayLabel(%v) = %q, %q, want %v", d, date, rel, want)
}
}
}
func TestSeriesRowDetail(t *testing.T) {
s := sonarr.Series{Title: "Andor", Year: 2022, Network: "Disney+", Status: "ended", Overview: "Series plot"}
// A season: the summary and the series plot.
d := seriesRowDetail(s, nil, 2, seasonSummary{number: 2, total: 12, have: 5}, true, "2026-01-01")
for _, want := range []string{"Andor", "(2022)", "Disney+", "Season 2", "5 of 12 episodes", "Monitored: yes"} {
if !strings.Contains(d.head, want) {
t.Errorf("season head missing %q:\n%s", want, d.head)
}
}
if d.plot != "Series plot" {
t.Errorf("season plot = %q", d.plot)
}
// An episode: its own title, air date, status and plot.
ep := &sonarr.Episode{SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome [Home]", AirDate: "2025-05-01", HasFile: true, Monitored: true, Overview: "Episode plot"}
d = seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01")
for _, want := range []string{"S02E05 Welcome [Home[]", "Aired: 2025-05-01", "downloaded"} {
if !strings.Contains(d.head, want) {
t.Errorf("episode head missing %q:\n%s", want, d.head)
}
}
if d.plot != "Episode plot" {
t.Errorf("episode plot = %q", d.plot)
}
// Without an episode plot the series plot stays.
ep.Overview = ""
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01"); d.plot != "Series plot" {
t.Errorf("fallback plot = %q", d.plot)
}
}
func TestForgetPane(t *testing.T) {
a := newTestApp(t)
n := len(a.panes)
d := newDetailPane(a)
if len(a.panes) != n+1 {
t.Fatal("a new pane must be registered")
}
a.forgetPane(d)
if len(a.panes) != n {
t.Errorf("%d panes after forgetting, want %d", len(a.panes), n)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ui
import (
"os"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
)
// SetConfigSource tells the app which file the settings came from and what that
// file contains, so the Config view can save changes. raw is the file as
// written (see config.LoadFile), not the effective settings.
func (a *App) SetConfigSource(path string, raw *config.Config) {
a.cfgPath, a.cfgFile = path, raw
if a.settings != nil {
a.settings.describe()
}
}
// applyConfig puts new settings into effect without a restart.
func (a *App) applyConfig(c *config.Config) {
old := a.cfg
a.cfg = c
if old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service || old.SABnzbd != c.SABnzbd {
a.radarr.Reconfigure(c.Radarr.URL, c.Radarr.APIKey)
a.sonarr.Reconfigure(c.Sonarr.URL, c.Sonarr.APIKey)
a.sab.Reconfigure(c.SABnzbd.URL, c.SABnzbd.APIKey)
a.probeAll() // the indicators, right away
for _, f := range a.reconnect {
f()
}
}
if old.UI.RefreshSeconds != c.UI.RefreshSeconds {
a.downloads.setInterval(time.Duration(c.UI.RefreshSeconds) * time.Second)
}
a.bell = c.UI.Bell
if old.UI.Theme != c.UI.Theme {
a.setTheme(chooseTheme(c.UI.Theme, terminalAnswers()))
}
if c.UI.ShouldNotifyDownloads() != a.notifyDone {
a.setNotifyDownloads(c.UI.ShouldNotifyDownloads())
}
if old.UI.ShowCovers() != c.UI.ShowCovers() || old.UI.CoverStyle != c.UI.CoverStyle {
a.covers = c.UI.ShowCovers()
a.restyleCovers()
}
}
// restyleCovers applies a changed poster style or switch to every detail pane.
func (a *App) restyleCovers() {
blocks, graphics := resolveCovers(a.cfg.UI.CoverStyle, os.Getenv)
if a.gfx != nil {
a.gfx.deleteAll() // the terminal's copies belong to the old images
}
a.coverStyle = blocks
switch {
case graphics && a.gfx == nil:
a.gfx = a.newGraphics()
case !graphics:
a.gfx = nil
}
a.coverCache = newCoverCache() // stored copies depend on the style (their height)
for _, p := range a.panes {
p.cover.style, p.cover.gfx = blocks, a.gfx
p.cover.cells = nil
p.Show(p.detail, p.lastRef) // fetch the poster again for the new style
}
}
// forgetPane drops a detail pane that is no longer shown (a closed drill-down).
func (a *App) forgetPane(d *detailPane) {
for i, p := range a.panes {
if p == d {
a.panes = append(a.panes[:i], a.panes[i+1:]...)
return
}
}
}
+247 -28
View File
@@ -6,8 +6,14 @@ import (
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
_ "image/jpeg" // poster formats _ "image/gif" // poster formats
_ "image/png" _ "image/jpeg" // (Radarr and Sonarr store the provider's bytes under a .jpg name,
_ "image/png" // whatever format they are in)
_ "golang.org/x/image/webp"
"net/url"
"os/exec"
"runtime"
"strings" "strings"
"time" "time"
@@ -19,17 +25,19 @@ import (
const ( const (
maxCoverPixels = 25_000_000 // refuse decompression bombs maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail storedCoverHeight = 160 // a small copy is plenty for block characters
graphicsCoverHeight = 640 // posters shown as real images keep more detail
coverFailureTTL = 2 * time.Minute coverFailureTTL = 2 * time.Minute
maxCoverCache = 300 maxCoverCache = 120
coverFetchLimit = 4 coverFetchLimit = 4
) )
// decodeCover decodes a JPEG or PNG poster and keeps a small copy. // decodeCover decodes a JPEG or PNG poster and keeps a copy at most
func decodeCover(data []byte) (*image.RGBA, error) { // maxHeight pixels tall.
func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, fmt.Errorf("not a supported image: %w", err) return nil, fmt.Errorf("not a supported image (%s): %w", describeBytes(data), err)
} }
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxCoverPixels { if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxCoverPixels {
return nil, fmt.Errorf("image dimensions %dx%d not supported", cfg.Width, cfg.Height) return nil, fmt.Errorf("image dimensions %dx%d not supported", cfg.Width, cfg.Height)
@@ -39,10 +47,10 @@ func decodeCover(data []byte) (*image.RGBA, error) {
return nil, err return nil, err
} }
b := img.Bounds() b := img.Bounds()
if b.Dy() <= storedCoverHeight { if b.Dy() <= maxHeight {
return boxScale(img, b.Dx(), b.Dy()), nil return boxScale(img, b.Dx(), b.Dy()), nil
} }
return boxScale(img, max(1, b.Dx()*storedCoverHeight/b.Dy()), storedCoverHeight), nil return boxScale(img, max(1, b.Dx()*maxHeight/b.Dy()), maxHeight), nil
} }
// boxScale resizes src to w×h by averaging the source pixels under every // boxScale resizes src to w×h by averaging the source pixels under every
@@ -86,6 +94,10 @@ var (
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols // sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
// for Legacy Computing block, which many fonts lack. // for Legacy Computing block, which many fonts lack.
sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph} sextantStyle = coverStyle{"sextant", 2, 3, sextantGlyph}
// cellsStyle (1 pixel per cell): only background colors, no glyphs at all.
// Coarse, but exact in terminals that draw block elements from the font
// with the halves off the cell's middle (macOS Terminal).
cellsStyle = coverStyle{"cells", 1, 1, func(int) rune { return ' ' }}
) )
// quadrants[mask] is the glyph that fills the quadrants set in mask // quadrants[mask] is the glyph that fills the quadrants set in mask
@@ -117,14 +129,22 @@ func sextantGlyph(mask int) rune {
return rune(0x1FB00 + idx) return rune(0x1FB00 + idx)
} }
// coverStyleFor resolves the configured style. "auto" picks sextants on // coverStyleFor resolves the block-character style for the setting. "auto"
// terminals known to have the glyphs and quadrants everywhere else. // picks sextants on terminals known to have the glyphs, plain cells in macOS
// Terminal and quadrants everywhere else.
func coverStyleFor(setting string, env func(string) string) coverStyle { func coverStyleFor(setting string, env func(string) string) coverStyle {
switch setting { switch setting {
case "sextant": case "sextant":
return sextantStyle return sextantStyle
case "quadrant": case "quadrant":
return quadrantStyle return quadrantStyle
case "cells":
return cellsStyle
}
// Terminal.app draws block elements from the font, and their halves do
// not meet at the cell's middle: rows of a poster come out uneven.
if env("TERM_PROGRAM") == "Apple_Terminal" {
return cellsStyle
} }
term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM")) term, program := strings.ToLower(env("TERM")), strings.ToLower(env("TERM_PROGRAM"))
for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} { for _, known := range []string{"kitty", "foot", "ghostty", "wezterm"} {
@@ -135,9 +155,53 @@ func coverStyleFor(setting string, env func(string) string) coverStyle {
if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" { if env("KITTY_WINDOW_ID") != "" || env("WEZTERM_PANE") != "" {
return sextantStyle return sextantStyle
} }
// Other terminals (GNOME Terminal, Konsole, Alacritty...) draw glyphs their
// font lacks from a fallback font, so sextants work if some installed font
// has them. Over SSH the installed fonts are the remote machine's, which
// says nothing about the terminal, so keep the safe style there.
// macOS terminals do not draw missing glyphs from fontconfig's fonts, so a
// font found there says nothing about them.
if runtime.GOOS != "darwin" && env("SSH_CONNECTION") == "" && env("SSH_TTY") == "" && sextantFontAvailable() {
return sextantStyle
}
return quadrantStyle return quadrantStyle
} }
// sextantFontAvailable reports whether fontconfig knows a font with the
// sextant characters. It is a variable so tests can replace it.
var sextantFontAvailable = func() bool {
path, err := exec.LookPath("fc-list")
if err != nil {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, path, ":charset=1fb00", "family").Output()
return err == nil && len(bytes.TrimSpace(out)) > 0
}
// resolveCovers decides how posters are drawn: as real images through the
// Kitty graphics protocol (graphics) or with block characters in the given
// style. "kitty" asks for the protocol explicitly; "auto" uses it when the
// terminal is known to support it. The block style is also the fallback when
// the protocol cannot be used at draw time (e.g. no true color).
func resolveCovers(setting string, env func(string) string) (blocks coverStyle, graphics bool) {
switch setting {
case "kitty":
// Asked for explicitly, but a terminal without the protocol prints the
// commands as text, so it is still confirmed.
return coverStyleFor("auto", env), kittyGraphicsSupported()
case "auto", "":
return coverStyleFor("auto", env), kittyTerminal(env) && kittyGraphicsSupported()
}
return coverStyleFor(setting, env), false
}
// kittyGraphicsSupported reports whether the terminal confirmed the Kitty
// graphics protocol when it was asked at startup (see probeTerminal). It is a
// variable so tests can replace it.
var kittyGraphicsSupported = func() bool { return terminalAnswers().graphics }
// flatPixelError is the squared RGB distance per pixel below which a cell is // flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns. // drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150 const flatPixelError = 150
@@ -215,6 +279,9 @@ func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
type coverView struct { type coverView struct {
*tview.Box *tview.Box
style coverStyle style coverStyle
gfx *kittyGraphics // real images; nil for block characters only
lastDrawn [5]int // id, position and size of the last real image drawn, for the debug log
cellAspect func() float64 // height of a terminal cell divided by its width
img *image.RGBA img *image.RGBA
note string // shown instead of the image: "loading…", "no cover" note string // shown instead of the image: "loading…", "no cover"
@@ -228,7 +295,18 @@ type styledCell struct {
fg, bg tcell.Color fg, bg tcell.Color
} }
func newCoverView(style coverStyle) *coverView { return &coverView{Box: tview.NewBox(), style: style} } func newCoverView(style coverStyle, gfx *kittyGraphics, cellAspect func() float64) *coverView {
return &coverView{Box: tview.NewBox(), style: style, gfx: gfx, cellAspect: cellAspect}
}
func (c *coverView) aspectOfCells() float64 {
if c.cellAspect != nil {
if a := c.cellAspect(); a > 0.5 && a < 8 {
return a
}
}
return 2
}
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil } func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.cells = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil } func (c *coverView) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
@@ -242,16 +320,47 @@ func (c *coverView) aspect() (w, h int) {
return b.Dx(), b.Dy() return b.Dx(), b.Dy()
} }
// coverCells returns how many columns and rows a poster of the given aspect // coverFitTolerance is how far the shape of the cell box may differ from the
// needs inside w×h cells. A cell is about twice as tall as it is wide. // poster's, as a fraction. The box is chosen to match the poster's shape
func coverCells(imgW, imgH, w, h int) (cols, rows int) { // closely; the small remainder is cropped away from the poster before it is
cols = w // shown as a real image, because a real image placed in a box of another shape
rows = cols * imgH / imgW / 2 // is letterboxed (visible bars beside or above it).
if rows > h { const coverFitTolerance = 0.03
rows = h
cols = rows * 2 * imgW / imgH // coverCells returns how many columns and rows a poster of the given shape
// needs inside w×h cells. cellAspect is the height of a cell divided by its
// width, about 2 in most terminals. Cell counts are whole numbers, so the
// largest size whose box is within coverFitTolerance of the poster's shape is
// chosen (or the closest one if none is).
func coverCells(imgW, imgH, w, h int, cellAspect float64) (cols, rows int) {
if imgW <= 0 || imgH <= 0 || w <= 0 || h <= 0 || cellAspect <= 0 {
return 0, 0
} }
return max(cols, 0), max(rows, 0) shape := float64(imgW) / float64(imgH) // width / height of the poster
// A box of r rows is r*cellAspect*shape columns wide for the same shape.
maxRows := min(h, int(float64(w)/(cellAspect*shape)))
if maxRows < 1 {
return w, 0
}
bestErr := 2.0
for r := maxRows; r >= max(1, maxRows-6); r-- {
c := int(float64(r)*cellAspect*shape + 0.5)
if c < 1 || c > w {
continue
}
boxShape := float64(c) / (float64(r) * cellAspect)
err := boxShape/shape - 1
if err < 0 {
err = -err
}
if err <= coverFitTolerance {
return c, r
}
if err < bestErr {
bestErr, cols, rows = err, c, r
}
}
return cols, rows
} }
func (c *coverView) Draw(screen tcell.Screen) { func (c *coverView) Draw(screen tcell.Screen) {
@@ -262,16 +371,19 @@ func (c *coverView) Draw(screen tcell.Screen) {
} }
if c.img == nil { if c.img == nil {
if c.note != "" { if c.note != "" {
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, tcell.ColorGray) tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, named("gray"))
} }
return return
} }
b := c.img.Bounds() b := c.img.Bounds()
cols, rows := coverCells(b.Dx(), b.Dy(), w, h) cols, rows := coverCells(b.Dx(), b.Dy(), w, h, c.aspectOfCells())
if cols < 1 || rows < 1 { if cols < 1 || rows < 1 {
return return
} }
if c.drawGraphics(screen, x+w-cols, y, cols, rows) {
return
}
if c.cells == nil || c.cols != cols || c.rows != rows { if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows) c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
} }
@@ -285,6 +397,51 @@ func (c *coverView) Draw(screen tcell.Screen) {
} }
} }
// cropToBox returns the part of an image of the given bounds that has the same
// shape as a box of cols×rows cells: a few pixels are trimmed from both sides
// of the longer dimension (at most the fit tolerance), so the image fills the
// box exactly and the terminal does not letterbox it.
func cropToBox(b image.Rectangle, cols, rows int, cellAspect float64) image.Rectangle {
box := float64(cols) / (float64(rows) * cellAspect) // width / height of the box
shape := float64(b.Dx()) / float64(b.Dy())
if box < shape { // box is narrower: trim the sides
w := int(float64(b.Dy())*box + 0.5)
trim := (b.Dx() - w) / 2
return image.Rect(b.Min.X+trim, b.Min.Y, b.Min.X+trim+w, b.Max.Y)
}
h := int(float64(b.Dx())/box + 0.5) // box is wider: trim top and bottom
trim := (b.Dy() - h) / 2
return image.Rect(b.Min.X, b.Min.Y+trim, b.Max.X, b.Min.Y+trim+h)
}
// drawGraphics shows the poster as a real image through the Kitty graphics
// protocol: the image is uploaded once and the cells get placeholder
// characters. It reports false when that is not possible (no protocol, no true
// color, too many rows or columns, or the terminal could not be written to), so
// the caller can draw block characters instead.
func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool {
if c.gfx == nil || c.gfx.settling() || screen.Colors() < 1<<24 || cols > maxPlaceholders || rows > maxPlaceholders {
return false
}
id, err := c.gfx.show(c.img, cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells()), cols, rows)
if err != nil {
return false
}
if c.gfx.debug != nil {
if drawn := [5]int{int(id), x, y, cols, rows}; drawn != c.lastDrawn {
c.lastDrawn = drawn
c.gfx.debug(fmt.Sprintf("draw: id %d at column %d, row %d over %dx%d cells", id, x, y, cols, rows))
}
}
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
ch, comb, style := placeholderCell(id, row, col)
screen.SetContent(x+col, y+row, ch, comb, style)
}
}
return true
}
// build renders the poster into cols×rows cells. // build renders the poster into cols×rows cells.
func (c *coverView) build(cols, rows int) []styledCell { func (c *coverView) build(cols, rows int) []styledCell {
st := c.style st := c.style
@@ -301,8 +458,8 @@ func (c *coverView) build(cols, rows int) []styledCell {
} }
ch, fg, bg := st.bestCell(px) ch, fg, bg := st.bestCell(px)
cells = append(cells, styledCell{ch, cells = append(cells, styledCell{ch,
tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)), notToken(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))),
tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))}) notToken(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))})
} }
} }
return cells return cells
@@ -311,7 +468,7 @@ func (c *coverView) build(cols, rows int) []styledCell {
// coverRef says where a poster comes from. The zero value means "no poster". // coverRef says where a poster comes from. The zero value means "no poster".
type coverRef struct { type coverRef struct {
key string // identifies the poster in the cache key string // identifies the poster in the cache
fetch func(ctx context.Context) ([]byte, error) fetch func(ctx context.Context, large bool) ([]byte, error) // large: for real images
} }
// coverFor builds the poster reference of a title from its image list. // coverFor builds the poster reference of a title from its image list.
@@ -358,6 +515,8 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
return return
} }
c.waiting[ref.key] = []func(*image.RGBA, error){done} c.waiting[ref.key] = []func(*image.RGBA, error){done}
maxHeight := a.coverMaxHeight()
large := a.gfx != nil // real images show the poster at full resolution
go func() { go func() {
c.slots <- struct{}{} c.slots <- struct{}{}
@@ -365,18 +524,23 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel() defer cancel()
var img *image.RGBA var img *image.RGBA
data, err := ref.fetch(ctx) data, err := ref.fetch(ctx, large)
if err == nil { if err == nil {
img, err = decodeCover(data) img, err = decodeCover(data, maxHeight)
} }
a.post(func() { a.post(func() {
waiters := c.waiting[ref.key] waiters := c.waiting[ref.key]
delete(c.waiting, ref.key) delete(c.waiting, ref.key)
if err != nil { if err != nil {
c.failed[ref.key] = time.Now() c.failed[ref.key] = time.Now()
// Say why in the log: a poster that silently never shows up is hard to diagnose.
a.logs.add(levelWarn, "Poster not loaded ("+posterSource(ref.key)+"): "+err.Error())
} else { } else {
if len(c.imgs) >= maxCoverCache { if len(c.imgs) >= maxCoverCache {
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
if a.gfx != nil {
a.gfx.deleteAll() // the terminal's copies belong to the dropped images
}
} }
c.imgs[ref.key] = img c.imgs[ref.key] = img
} }
@@ -386,3 +550,58 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
}) })
}() }()
} }
// posterSource describes where a poster key points, for the log: the host and
// file name of a remote image, or the path on the Radarr/Sonarr instance.
func posterSource(key string) string {
if _, local, ok := strings.Cut(key, "|"); ok {
key = local
}
if u, err := url.Parse(key); err == nil && u.Host != "" {
return u.Host + u.Path
}
path, _, _ := strings.Cut(key, "?")
return path
}
// describeBytes says what a blob of unknown data looks like, for error
// messages: a file Radarr/Sonarr stored as poster.jpg can be any format, or an
// error page.
func describeBytes(data []byte) string {
if len(data) == 0 {
return "empty"
}
head := data[:min(len(data), 16)]
switch {
case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
return "WebP"
case bytes.HasPrefix(data, []byte("GIF8")):
return "GIF"
case bytes.HasPrefix(data, []byte("\x89PNG")):
return "PNG"
case bytes.HasPrefix(data, []byte("\xff\xd8")):
return "JPEG"
case len(data) >= 12 && string(data[4:8]) == "ftyp":
return "AVIF/HEIC, " + strings.TrimSpace(string(data[8:12]))
case bytes.HasPrefix(data, []byte("BM")):
return "BMP"
case bytes.HasPrefix(data, []byte("II*\x00")) || bytes.HasPrefix(data, []byte("MM\x00*")):
return "TIFF"
case bytes.HasPrefix(bytes.TrimSpace(data), []byte("<")):
return "text, probably an HTML or SVG page: " + printable(bytes.TrimSpace(data), 40)
}
return fmt.Sprintf("%d bytes starting % x", len(data), head)
}
// printable returns up to n bytes of data with control characters replaced.
func printable(data []byte, n int) string {
if len(data) > n {
data = data[:n]
}
return strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return ' '
}
return r
}, string(data))
}
+128 -14
View File
@@ -3,10 +3,14 @@ package ui
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"image" "image"
"image/color" "image/color"
"image/gif"
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"strings"
"sync/atomic"
"testing" "testing"
"time" "time"
@@ -50,7 +54,7 @@ func TestDecodeCover(t *testing.T) {
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil { if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
img, err := decodeCover(buf.Bytes()) img, err := decodeCover(buf.Bytes(), storedCoverHeight)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -62,17 +66,17 @@ func TestDecodeCover(t *testing.T) {
if err := png.Encode(&buf, stripes(20, 30)); err != nil { if err := png.Encode(&buf, stripes(20, 30)); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if img, err := decodeCover(buf.Bytes()); err != nil || img.Bounds().Dy() != 30 { if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
t.Errorf("small png kept as is: %v %v", img, err) t.Errorf("small png kept as is: %v %v", img, err)
} }
if _, err := decodeCover([]byte("<html>not an image</html>")); err == nil { if _, err := decodeCover([]byte("<html>not an image</html>"), storedCoverHeight); err == nil {
t.Error("garbage must be rejected") t.Error("garbage must be rejected")
} }
// A header that promises a huge image is refused before decoding it. // A header that promises a huge image is refused before decoding it.
var huge bytes.Buffer var huge bytes.Buffer
huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00")) huge.Write([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x27\x10\x00\x00\x27\x10\x08\x02\x00\x00\x00"))
if _, err := decodeCover(huge.Bytes()); err == nil { if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil {
t.Error("oversized image must be rejected") t.Error("oversized image must be rejected")
} }
} }
@@ -110,11 +114,12 @@ func TestQuadCell(t *testing.T) {
func TestCoverCells(t *testing.T) { func TestCoverCells(t *testing.T) {
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20 // A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
// width units tall, so 13 columns. // width units tall, so 13 columns.
if c, r := coverCells(40, 60, 30, 10); c != 13 || r != 10 { if c, r := coverCells(40, 60, 30, 10, 2); c != 13 || r != 10 {
t.Errorf("tall limit: %dx%d", c, r) t.Errorf("tall limit: %dx%d", c, r)
} }
// In a narrow box the width limits it. // In a narrow box the width limits it, and the box keeps the poster's shape
if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 { // (8×6 cells is exactly 2:3) instead of 10×7 which would leave bars.
if c, r := coverCells(40, 60, 10, 20, 2); c != 8 || r != 6 {
t.Errorf("narrow limit: %dx%d", c, r) t.Errorf("narrow limit: %dx%d", c, r)
} }
} }
@@ -127,7 +132,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini() defer screen.Fini()
screen.SetSize(30, 10) screen.SetSize(30, 10)
c := newCoverView(quadrantStyle) c := newCoverView(quadrantStyle, nil, nil)
c.setImage(stripes(40, 60)) // red above blue c.setImage(stripes(40, 60)) // red above blue
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
@@ -157,11 +162,11 @@ func TestCoverViewDraws(t *testing.T) {
func TestLoadCoverCachesAndDedupes(t *testing.T) { func TestLoadCoverCachesAndDedupes(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
fetches := 0 var fetches atomic.Int32
var buf bytes.Buffer var buf bytes.Buffer
png.Encode(&buf, stripes(10, 20)) png.Encode(&buf, stripes(10, 20))
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) { ref := coverRef{key: "k", fetch: func(context.Context, bool) ([]byte, error) {
fetches++ fetches.Add(1)
return buf.Bytes(), nil return buf.Bytes(), nil
}} }}
@@ -172,8 +177,8 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
if len(a.coverCache.waiting["k"]) != 2 { if len(a.coverCache.waiting["k"]) != 2 {
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"])) t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
} }
if fetches > 1 { if n := fetches.Load(); n > 1 {
t.Errorf("fetched %d times", fetches) t.Errorf("fetched %d times", n)
} }
} }
@@ -181,7 +186,7 @@ func TestLoadCoverRemembersFailures(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
a.coverCache.failed["bad"] = time.Now() a.coverCache.failed["bad"] = time.Now()
var gotErr error var gotErr error
a.loadCover(coverRef{key: "bad", fetch: func(context.Context) ([]byte, error) { a.loadCover(coverRef{key: "bad", fetch: func(context.Context, bool) ([]byte, error) {
t.Error("a recent failure must not be retried") t.Error("a recent failure must not be retried")
return nil, nil return nil, nil
}}, func(_ *image.RGBA, err error) { gotErr = err }) }}, func(_ *image.RGBA, err error) { gotErr = err })
@@ -223,7 +228,39 @@ func TestSextantCell(t *testing.T) {
} }
} }
func TestCoverStyleForFontCheck(t *testing.T) {
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
sextantFontAvailable = func() bool { return true }
for _, tc := range []struct {
name string
env map[string]string
want string
}{
{"unknown terminal with a suitable font", map[string]string{"TERM": "xterm-256color"}, "sextant"},
{"over ssh the local fonts are unknown", map[string]string{"TERM": "xterm-256color", "SSH_CONNECTION": "1 2 3 4"}, "quadrant"},
{"over ssh with a tty", map[string]string{"SSH_TTY": "/dev/pts/1"}, "quadrant"},
} {
if got := coverStyleFor("auto", env(tc.env)).name; got != tc.want {
t.Errorf("%s: %s, want %s", tc.name, got, tc.want)
}
}
// An explicit setting never asks fontconfig.
sextantFontAvailable = func() bool { t.Error("fontconfig consulted for an explicit style"); return true }
if coverStyleFor("quadrant", env(nil)).name != "quadrant" || coverStyleFor("sextant", env(nil)).name != "sextant" {
t.Error("explicit styles must win")
}
// No suitable font: quadrants.
sextantFontAvailable = func() bool { return false }
if got := coverStyleFor("auto", env(map[string]string{"TERM": "xterm-256color"})).name; got != "quadrant" {
t.Errorf("no font: %s", got)
}
}
func TestCoverStyleFor(t *testing.T) { func TestCoverStyleFor(t *testing.T) {
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
sextantFontAvailable = func() bool { return false } // the table below is about the terminal checks
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, tc := range []struct { for _, tc := range []struct {
setting string setting string
@@ -238,9 +275,86 @@ func TestCoverStyleFor(t *testing.T) {
{"auto", nil, "quadrant"}, {"auto", nil, "quadrant"},
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"}, {"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
{"sextant", nil, "sextant"}, {"sextant", nil, "sextant"},
{"auto", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"}, "cells"},
{"cells", map[string]string{"TERM": "xterm-kitty"}, "cells"},
{"quadrant", map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "quadrant"},
} { } {
if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want { if got := coverStyleFor(tc.setting, env(tc.env)).name; got != tc.want {
t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want) t.Errorf("%q %v = %s, want %s", tc.setting, tc.env, got, tc.want)
} }
} }
} }
func TestCoverCellsKeepTheShape(t *testing.T) {
// For many poster shapes, cell shapes and boxes the chosen box must match
// the poster within the tolerance (or be the closest available).
for _, img := range [][2]int{{167, 250}, {500, 750}, {2, 3}, {1000, 1500}, {680, 1000}} {
for _, aspect := range []float64{1.9, 2, 2.1, 2.25} {
for w := 12; w <= 40; w += 7 {
for h := 8; h <= 30; h += 11 {
c, r := coverCells(img[0], img[1], w, h, aspect)
if c < 1 || r < 1 || c > w || r > h {
t.Fatalf("%v aspect %v in %dx%d: %dx%d out of bounds", img, aspect, w, h, c, r)
}
shape := float64(img[0]) / float64(img[1])
err := float64(c)/(float64(r)*aspect)/shape - 1
if err < 0 {
err = -err
}
if err > 0.10 { // the tolerance is 3%; tiny boxes may only get within a cell
t.Errorf("%v aspect %v in %dx%d: box %dx%d is %.0f%% off", img, aspect, w, h, c, r, err*100)
}
}
}
}
}
}
func TestDecodeCoverOtherFormats(t *testing.T) {
// A WebP stored under a .jpg name, as Sonarr keeps what the provider sends.
webp, _ := base64.StdEncoding.DecodeString("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==")
img, err := decodeCover(webp, storedCoverHeight)
if err != nil || img.Bounds().Dx() != 1 || img.Bounds().Dy() != 1 {
t.Fatalf("webp: %v %v", img, err)
}
var buf bytes.Buffer
pal := image.NewPaletted(image.Rect(0, 0, 20, 30), []color.Color{color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}})
if err := gif.Encode(&buf, pal, nil); err != nil {
t.Fatal(err)
}
if img, err := decodeCover(buf.Bytes(), storedCoverHeight); err != nil || img.Bounds().Dy() != 30 {
t.Errorf("gif: %v %v", img, err)
}
}
func TestDescribeBytesAndTheErrorItGives(t *testing.T) {
for _, tc := range []struct {
name string
data []byte
want string
}{
{"empty", nil, "empty"},
{"avif", []byte("\x00\x00\x00\x1cftypavif\x00\x00"), "AVIF/HEIC, avif"},
{"html", []byte(" <html><body>Not Found</body></html>"), "HTML or SVG"},
{"bmp", []byte("BM\x00\x00\x00\x00"), "BMP"},
{"tiff", []byte("II*\x00\x08\x00"), "TIFF"},
{"binary", []byte{1, 2, 3, 4, 5}, "5 bytes starting 01 02 03 04 05"},
} {
if got := describeBytes(tc.data); !strings.Contains(got, tc.want) {
t.Errorf("%s: %q, want it to mention %q", tc.name, got, tc.want)
}
_, err := decodeCover(tc.data, storedCoverHeight)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: the error %v should say what the data is (%q)", tc.name, err, tc.want)
}
}
}
func TestCellsStyleIsFlat(t *testing.T) {
c := color.RGBA{10, 200, 30, 255}
ch, fg, bg := cellsStyle.bestCell([]color.RGBA{c})
if ch != ' ' || fg != c || bg != c {
t.Errorf("got %q %v %v", ch, fg, bg)
}
}
+41 -3
View File
@@ -3,6 +3,7 @@ package ui
import ( import (
"image" "image"
"strings" "strings"
"time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -14,6 +15,12 @@ const (
minCoverRows = 6 // a smaller poster is not worth showing minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8 minCoverCols = 8
minTextRows = 6 // the poster never takes these rows from the text minTextRows = 6 // the poster never takes these rows from the text
// posterSettle: when the selection moves again within this time (a held
// arrow key), the poster waits until the selection rests that long. Every
// poster rewrites all of its cells, which on a huge terminal (zoomed far
// out) is more output than the terminal can draw at key-repeat speed.
posterSettle = 120 * time.Millisecond
) )
// detailPane shows the details of one title as one flowing text with the // detailPane shows the details of one title as one flowing text with the
@@ -27,8 +34,11 @@ type detailPane struct {
cover *coverView cover *coverView
detail detailText detail detailText
lastRef coverRef // the poster of the current detail, to fetch it again after a style change
want string // key of the poster that should be shown want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content hasCover bool // a poster (or its loading note) is part of the content
lastShow time.Time // when the shown title last changed
settle *time.Timer // shows the poster once the selection rests
// What the text was last wrapped for. // What the text was last wrapped for.
laidOut struct { laidOut struct {
@@ -43,23 +53,51 @@ func newDetailPane(app *App) *detailPane {
Flex: tview.NewFlex(), Flex: tview.NewFlex(),
app: app, app: app,
text: tview.NewTextView().SetDynamicColors(true).SetWrap(false), text: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
cover: newCoverView(app.coverStyle), cover: newCoverView(app.coverStyle, app.gfx, func() float64 { return app.cellAspect }),
} }
d.AddItem(d.text, 0, 1, false) d.AddItem(d.text, 0, 1, false)
d.SetBorder(true).SetTitle(" Details ") d.SetBorder(true).SetTitle(" Details ")
app.panes = append(app.panes, d)
return d return d
} }
// Show displays the text and, if there is one, the poster. // Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text detailText, ref coverRef) { func (d *detailPane) Show(text detailText, ref coverRef) {
d.detail = text d.detail, d.lastRef = text, ref
d.laidOut.valid = false d.laidOut.valid = false
if !d.app.covers || ref.fetch == nil { if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false d.want, d.hasCover = "", false
d.cover.setImage(nil) d.cover.setImage(nil)
return return
} }
now := time.Now()
moving := ref.key != d.want && now.Sub(d.lastShow) < posterSettle
if ref.key != d.want {
d.lastShow = now
}
d.want, d.hasCover = ref.key, true d.want, d.hasCover = ref.key, true
if d.settle != nil {
d.settle.Stop()
}
if moving {
// Keep the poster's place (so the text does not reflow) but leave it
// empty until the selection stops.
d.cover.setNote("")
want := ref.key
d.settle = time.AfterFunc(posterSettle, func() {
d.app.post(func() {
if d.want == want {
d.loadPoster(ref)
}
})
})
return
}
d.loadPoster(ref)
}
// loadPoster shows the poster of ref, fetching it first if needed.
func (d *detailPane) loadPoster(ref coverRef) {
d.cover.setNote("loading cover…") d.cover.setNote("loading cover…")
want := ref.key want := ref.key
d.app.loadCover(ref, func(img *image.RGBA, err error) { d.app.loadCover(ref, func(img *image.RGBA, err error) {
@@ -85,7 +123,7 @@ func (d *detailPane) Draw(screen tcell.Screen) {
if d.hasCover { if d.hasCover {
aw, ah := d.cover.aspect() aw, ah := d.cover.aspect()
// At most 40% of the width, and the text keeps some rows below. // At most 40% of the width, and the text keeps some rows below.
d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows) d.coverW, d.coverH = coverCells(aw, ah, w*2/5, h-minTextRows, d.cover.aspectOfCells())
if d.coverH < minCoverRows || d.coverW < minCoverCols { if d.coverH < minCoverRows || d.coverW < minCoverCols {
d.coverW, d.coverH = 0, 0 d.coverW, d.coverH = 0, 0
} }
+30
View File
@@ -1,8 +1,11 @@
package ui package ui
import ( import (
"context"
"image"
"strings" "strings"
"testing" "testing"
"time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -107,3 +110,30 @@ func TestDetailPaneFlowsAroundPoster(t *testing.T) {
t.Error("a short pane must drop the poster") t.Error("a short pane must drop the poster")
} }
} }
func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
a := newTestApp(t)
img := image.NewRGBA(image.Rect(0, 0, 20, 30))
a.coverCache.imgs["one"], a.coverCache.imgs["two"] = img, img
ref := func(key string) coverRef {
return coverRef{key: key, fetch: func(context.Context, bool) ([]byte, error) { return nil, nil }}
}
d := newDetailPane(a)
d.Show(detailText{head: "One"}, ref("one"))
if d.cover.img != img {
t.Fatal("the first selection should show its poster at once")
}
d.Show(detailText{head: "Two"}, ref("two"))
if d.cover.img != nil || !d.hasCover {
t.Errorf("a quick move should keep the poster's place empty: img %v, hasCover %v", d.cover.img != nil, d.hasCover)
}
d.settle.Stop() // the test has no event loop to deliver it
// The same title again (a refresh) is not a move.
d.lastShow = time.Now().Add(-time.Second)
d.Show(detailText{head: "Two"}, ref("two"))
if d.cover.img != img {
t.Error("a selection at rest should show its poster")
}
}
+26 -3
View File
@@ -35,6 +35,7 @@ type downloadsView struct {
sab *sabnzbd.Client sab *sabnzbd.Client
sources []arrSource sources []arrSource
interval time.Duration interval time.Duration
sabUnset bool // SABnzbd is not set up: its lists say so instead of "empty"
root *tview.Flex root *tview.Flex
summary *tview.TextView summary *tview.TextView
@@ -134,6 +135,14 @@ func (v *downloadsView) nextPanel(dir int) {
v.app.app.SetFocus(v.panels[0]) v.app.app.SetFocus(v.panels[0])
} }
// setInterval changes the polling interval, also while polling.
func (v *downloadsView) setInterval(d time.Duration) {
v.interval = d
if v.cancel != nil {
v.activate()
}
}
// activate starts polling; called when the view becomes visible. // activate starts polling; called when the view becomes visible.
func (v *downloadsView) activate() { func (v *downloadsView) activate() {
v.deactivate() v.deactivate()
@@ -202,6 +211,12 @@ func (v *downloadsView) refresh(ctx context.Context) {
v.app.post(func() { v.app.post(func() {
v.app.SetService("SABnzbd", "", qerr) v.app.SetService("SABnzbd", "", qerr)
if unset := notConfigured(qerr); unset != v.sabUnset {
v.sabUnset = unset
v.sabQueue, v.slots, v.done = sabnzbd.Queue{}, nil, nil
v.renderQueue()
v.renderHistory()
}
if qerr == nil { if qerr == nil {
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
v.renderQueue() v.renderQueue()
@@ -222,6 +237,14 @@ func (v *downloadsView) refresh(ctx context.Context) {
}) })
} }
// sabPlaceholder is what an empty SABnzbd list says.
func (v *downloadsView) sabPlaceholder(empty string) string {
if v.sabUnset {
return "SABnzbd is not configured (Config view, F6)"
}
return empty
}
func (v *downloadsView) rebuildArrRows() { func (v *downloadsView) rebuildArrRows() {
v.arrRows = v.arrRows[:0] v.arrRows = v.arrRows[:0]
for _, s := range v.sources { for _, s := range v.sources {
@@ -268,7 +291,7 @@ func (v *downloadsView) renderSummary() {
// header writes the header row; column expand grows to fill the width. // header writes the header row; column expand grows to fill the width.
func header(t *tview.Table, expand int, cols ...string) { func header(t *tview.Table, expand int, cols ...string) {
for i, c := range cols { for i, c := range cols {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false) cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
if i == expand { if i == expand {
cell.SetExpansion(1) cell.SetExpansion(1)
} }
@@ -323,7 +346,7 @@ func (v *downloadsView) renderQueue() {
} }
} }
fill(v.queue, 0, rows) fill(v.queue, 0, rows)
restoreSelection(v.queue, len(rows), selRow, "Queue is empty") restoreSelection(v.queue, len(rows), selRow, v.sabPlaceholder("Queue is empty"))
} }
func (v *downloadsView) renderArr() { func (v *downloadsView) renderArr() {
@@ -406,7 +429,7 @@ func (v *downloadsView) renderHistory() {
} }
} }
fill(v.history, 0, rows) fill(v.history, 0, rows)
restoreSelection(v.history, len(rows), selRow, "No history") restoreSelection(v.history, len(rows), selRow, v.sabPlaceholder("No history"))
} }
// act runs an action off the UI goroutine, reports the outcome and refreshes. // act runs an action off the UI goroutine, reports the outcome and refreshes.
+56 -12
View File
@@ -37,7 +37,8 @@ type seriesView struct {
root *tview.Flex root *tview.Flex
header *tview.TextView header *tview.TextView
table *tview.Table table *tview.Table
detail *tview.TextView detail *detailPane // poster, series and the selected season or episode
cover coverRef
episodes []sonarr.Episode episodes []sonarr.Episode
expanded map[int]bool expanded map[int]bool
@@ -51,14 +52,16 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
app: a, c: c, series: s, onChange: onChange, app: a, c: c, series: s, onChange: onChange,
header: tview.NewTextView().SetDynamicColors(true), header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), detail: newDetailPane(a),
expanded: map[int]bool{}, expanded: map[int]bool{},
} }
v.table.SetBorder(true) v.table.SetBorder(true)
v.cover = coverFor(c.Client, s.Images, true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow). v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false). AddItem(v.header, 1, 0, false).
AddItem(v.table, 0, 1, true). AddItem(tview.NewFlex().
AddItem(v.detail, 2, 0, false) AddItem(v.table, 0, 3, true).
AddItem(v.detail, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() }) v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
v.table.SetSelectedFunc(func(int, int) { v.enter() }) v.table.SetSelectedFunc(func(int, int) { v.enter() })
@@ -86,7 +89,8 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
}) })
v.render() v.render()
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", nil) // The pane goes away with the view; stop restyling it then.
a.openFull(v.root, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back", func() { a.forgetPane(v.detail) })
v.reload() v.reload()
} }
@@ -243,14 +247,54 @@ func episodeStatus(e sonarr.Episode, today string) string {
func (v *seriesView) renderDetail() { func (v *seriesView) renderDetail() {
r, ok := v.selected() r, ok := v.selected()
switch { if !ok {
case !ok: v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, ""), v.cover)
v.detail.SetText("") return
case r.ep == nil:
v.detail.SetText("[gray]Enter expands the season · t toggles monitoring for the whole season · i searches for releases")
default:
v.detail.SetText(fmt.Sprintf("[gray]S%02dE%02d[-] %s", r.season, r.ep.EpisodeNumber, tview.Escape(r.ep.Title)))
} }
var sum seasonSummary
for _, se := range summarizeSeasons(v.episodes) {
if se.number == r.season {
sum = se
}
}
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02")), v.cover)
}
// seriesRowDetail is the detail text of the drill-down: the series, then the
// selected season (ep == nil) or episode. The plot is the episode's, or the
// series' while a season is selected.
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string) detailText {
head := "[::b]" + tview.Escape(s.Title) + "[::-]"
if s.Year > 0 {
head += fmt.Sprintf(" (%d)", s.Year)
}
head += "\n"
if s.Network != "" {
head += tview.Escape(s.Network) + " · "
}
head += tview.Escape(s.Status) + "\n\n"
plot := tview.Escape(s.Overview)
switch {
case ep == nil && sum.total == 0:
// Nothing selected yet (still loading).
case ep == nil:
head += "[::b]" + seasonLabel(season) + "[::-]\n"
head += fmt.Sprintf("%d of %d episodes downloaded\n", sum.have, sum.total)
head += monitoredLine(seasonMonitored)
head += "[gray]Enter expands · t monitors the whole season · i searches for releases[-]\n"
default:
head += fmt.Sprintf("[::b]S%02dE%02d %s[::-]\n", ep.SeasonNumber, ep.EpisodeNumber, tview.Escape(ep.Title))
if ep.AirDate != "" {
head += "Aired: " + ep.AirDate + "\n"
}
head += "Status: " + episodeStatus(*ep, today) + "[-]\n"
head += monitoredLine(ep.Monitored)
if ep.Overview != "" {
plot = tview.Escape(ep.Overview)
}
}
return detailText{head: head, plot: plot}
} }
// enter expands or collapses a season, or opens the release search for an episode. // enter expands or collapses a season, or opens the release search for an episode.
+186 -8
View File
@@ -18,6 +18,18 @@ import (
// cancel is called on Esc, except when Esc only closes an open dropdown list // cancel is called on Esc, except when Esc only closes an open dropdown list
// (tview would otherwise treat that as cancelling the whole form). // (tview would otherwise treat that as cancelling the whole form).
func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) { func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
// Form.SetFocus moves the focus between its items without telling the
// application, which then still holds the item that was left. Opening a
// dialog would only blur that one: the item actually in use kept its
// cursor on screen and, because the form still had the focus, took the
// dialog's keys, and closing the dialog returned to the wrong item. So the
// application is told after every move.
move := func(index int) {
f.SetFocus(index)
if syncFocus != nil {
syncFocus(f) // the form hands the focus on to the item at index
}
}
var b browser var b browser
var closingList bool var closingList bool
f.SetCancelFunc(func() { f.SetCancelFunc(func() {
@@ -44,16 +56,37 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
return ev return ev
} }
b.dd = nil b.dd = nil
if button >= 0 && ev.Key() == tcell.KeyRune && ev.Rune() == ' ' {
return tcell.NewEventKey(tcell.KeyEnter, 0, 0) // Space presses a button too
}
total := f.GetFormItemCount() + f.GetButtonCount() total := f.GetFormItemCount() + f.GetButtonCount()
pos := item pos := item
if button >= 0 { if button >= 0 {
pos = f.GetFormItemCount() + button pos = f.GetFormItemCount() + button
} }
switch ev.Key() { switch ev.Key() {
case tcell.KeyDown: case tcell.KeyDown, tcell.KeyUp:
f.SetFocus(min(pos+1, total-1)) dir := 1
case tcell.KeyUp: if ev.Key() == tcell.KeyUp {
f.SetFocus(max(pos-1, 0)) dir = -1
}
next := neighbour(f, pos, dir)
if next == pos { // nothing further in this direction
if hooks != nil && hooks.edge != nil && hooks.edge(dir > 0) {
return nil
}
return nil
}
move(next)
case tcell.KeyTab, tcell.KeyBacktab:
dir := 1
if ev.Key() == tcell.KeyBacktab {
dir = -1
}
if hooks != nil && hooks.tab != nil && hooks.tab(dir) {
return nil
}
return ev
case tcell.KeyLeft, tcell.KeyRight: case tcell.KeyLeft, tcell.KeyRight:
step := 1 step := 1
if ev.Key() == tcell.KeyLeft { if ev.Key() == tcell.KeyLeft {
@@ -65,7 +98,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
cur, _ := dd.GetCurrentOption() cur, _ := dd.GetCurrentOption()
dd.SetCurrentOption((cur + step + n) % n) dd.SetCurrentOption((cur + step + n) % n)
case button >= 0: case button >= 0:
f.SetFocus(min(max(pos+step, f.GetFormItemCount()), total-1)) move(min(max(pos+step, f.GetFormItemCount()), total-1))
default: default:
return ev return ev
} }
@@ -76,12 +109,20 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
}) })
} }
// syncFocus gives a primitive the application's focus; New sets it.
var syncFocus func(p tview.Primitive)
func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) { func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) {
if item < 0 { if item < 0 {
return nil, false return nil, false
} }
dd, ok := f.GetFormItem(item).(*tview.DropDown) switch it := f.GetFormItem(item).(type) {
return dd, ok case *tview.DropDown:
return it, true
case *serverChoice: // a dropdown with extras; the keys work the same
return it.DropDown, true
}
return nil, false
} }
// bindSearchNow keeps a "Search now" checkbox consistent with whether a search // bindSearchNow keeps a "Search now" checkbox consistent with whether a search
@@ -109,7 +150,7 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
box.SetChecked(want) box.SetChecked(want)
return return
} }
box.SetLabelColor(tcell.ColorGray) box.SetLabelColor(named("gray"))
box.SetChecked(false) box.SetChecked(false)
} }
} }
@@ -118,6 +159,11 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
type browseHooks struct { type browseHooks struct {
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
abort func() // the list was closed without selecting abort func() // the list was closed without selecting
// edge is called when Down is pressed on the last field or Up on the first;
// it returns true if it moved the focus elsewhere.
edge func(down bool) bool
// tab is called for Tab (dir 1) and Shift+Tab (dir -1); true if handled.
tab func(dir int) bool
} }
// browser mirrors the highlight of an open dropdown list, which tview does // browser mirrors the highlight of an open dropdown list, which tview does
@@ -160,3 +206,135 @@ func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks)
b.known = false // paging, typed prefixes b.known = false // paging, typed prefixes
} }
} }
// skipsFocus marks form items that only decorate (section headings): the
// arrow keys pass over them.
type skipsFocus interface{ skipFocus() bool }
// neighbour returns the position of the next focusable item or button after
// pos in the given direction, or pos itself if there is none.
func neighbour(f *tview.Form, pos, dir int) int {
total := f.GetFormItemCount() + f.GetButtonCount()
for i := pos + dir; i >= 0 && i < total; i += dir {
if i < f.GetFormItemCount() {
if s, ok := f.GetFormItem(i).(skipsFocus); ok && s.skipFocus() {
continue
}
}
return i
}
return pos
}
// firstFocusable returns the position of the first item or button that can
// take the focus.
func firstFocusable(f *tview.Form) int {
if f.GetFormItemCount() == 0 {
return 0 // a form of buttons only
}
if s, ok := f.GetFormItem(0).(skipsFocus); ok && s.skipFocus() {
return neighbour(f, 0, 1)
}
return 0
}
// formHeading is a section heading inside a form: a bold title above the next
// group of fields. It never takes the focus.
type formHeading struct {
*tview.Box
title string
finished func(tcell.Key)
}
func newFormHeading(title string) *formHeading {
return &formHeading{Box: tview.NewBox(), title: title}
}
func (h *formHeading) skipFocus() bool { return true }
func (h *formHeading) GetLabel() string { return "" }
func (h *formHeading) SetFormAttributes(int, tcell.Color, tcell.Color, tcell.Color, tcell.Color) tview.FormItem {
return h
}
func (h *formHeading) GetFieldWidth() int { return 0 }
// GetFieldHeight is 2: an empty line above the title separates the sections.
func (h *formHeading) GetFieldHeight() int { return 2 }
func (h *formHeading) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
h.finished = handler
return h
}
func (h *formHeading) SetDisabled(bool) tview.FormItem { return h }
// Focus passes the focus on, like a disabled input field does, so tview's own
// Tab handling skips the heading too.
func (h *formHeading) Focus(delegate func(p tview.Primitive)) {
if h.finished != nil {
h.finished(-1)
return
}
h.Box.Focus(delegate)
}
func (h *formHeading) Draw(screen tcell.Screen) {
h.Box.DrawForSubclass(screen, h)
x, y, w, _ := h.GetInnerRect()
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, named("yellow"))
}
// formAction is a button inside a form's list of fields, so it can sit between
// sections instead of at the end. Enter or Space presses it.
type formAction struct {
*tview.Button
labelWidth int
finished func(tcell.Key)
}
func newFormAction(label string, pressed func()) *formAction {
a := &formAction{Button: tview.NewButton(label)}
a.SetSelectedFunc(pressed)
return a
}
func (a *formAction) GetLabel() string { return "" } // the button text is its own label
func (a *formAction) SetFormAttributes(labelWidth int, _, _, _, _ tcell.Color) tview.FormItem {
a.labelWidth = labelWidth
return a
}
func (a *formAction) GetFieldWidth() int { return tview.TaggedStringWidth(a.GetLabel()) + 4 }
// GetFieldHeight is 2: an empty line above the button.
func (a *formAction) GetFieldHeight() int { return 2 }
func (a *formAction) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
a.SetExitFunc(handler) // Tab, Shift+Tab and Esc
a.finished = handler
return a
}
func (a *formAction) SetDisabled(disabled bool) tview.FormItem {
a.Button.SetDisabled(disabled)
return a
}
// SetRect places the button under the field column, one line down, as wide as its text.
func (a *formAction) SetRect(x, y, width, height int) {
w := min(max(width-a.labelWidth, 0), tview.TaggedStringWidth(a.Button.GetLabel())+4)
a.Button.SetRect(x+a.labelWidth, y+1, w, 1)
}
func (a *formAction) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
inner := a.Button.InputHandler()
return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
if event.Key() == tcell.KeyRune && event.Rune() == ' ' {
event = tcell.NewEventKey(tcell.KeyEnter, 0, 0)
}
inner(event, setFocus)
}
}
+10
View File
@@ -48,6 +48,16 @@ func TestArrowNavigation(t *testing.T) {
if item, _ := f.GetFocusedItemIndex(); item != 1 { if item, _ := f.GetFocusedItemIndex(); item != 1 {
t.Fatalf("Up from first button: item %d, want 1", item) t.Fatalf("Up from first button: item %d, want 1", item)
} }
// Space on a button is Enter; on a checkbox it stays Space.
send(tcell.KeyDown)
if got := cap(tcell.NewEventKey(tcell.KeyRune, ' ', 0)); got == nil || got.Key() != tcell.KeyEnter {
t.Errorf("Space on a button: %v", got)
}
send(tcell.KeyUp)
if got := cap(tcell.NewEventKey(tcell.KeyRune, ' ', 0)); got == nil || got.Key() != tcell.KeyRune {
t.Errorf("Space on a checkbox: %v", got)
}
} }
func TestSearchNowFollowsMonitored(t *testing.T) { func TestSearchNowFollowsMonitored(t *testing.T) {
+127 -55
View File
@@ -1,77 +1,149 @@
package ui package ui
import ( import (
"strings"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
const helpText = `[yellow]Global[-] // helpItem is one line of the key reference: the keys and what they do.
F1-F5 switch view (also from the release and episode lists) type helpItem struct{ keys, desc string }
Tab, Shift+Tab switch panel within a view
?, F9 this help (F9 works while typing)
q, Ctrl+C quit
Esc close dialog / go back
[yellow]Movies and Series[-] // helpSection is a titled group of items, with an optional note above them.
/ focus the search box type helpSection struct {
Enter, a add the selected title (dialog: ↑↓ move, ←→ change a title string
dropdown, Space toggles, Enter/Space opens a list) note string
i interactive release search (library titles) items []helpItem
}
[yellow]Library[-] var helpSections = []helpSection{
Tab switch between the movies and series lists {title: "Global", items: []helpItem{
f cycle filter (all, missing, downloaded, monitored, unmonitored); {"F1-F7", "switch view (also from the release and episode lists)"},
every list has its own filters {"Tab, Shift+Tab", "switch panel within a view"},
F filter by file properties: quality, codec (x265, x264, AV1), {"F9", "this help (works everywhere, also while typing)"},
HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. {"Ctrl+C", "quit"},
Changes apply live behind the dialog, also while you move through an {"Esc", "close a dialog / go back"},
open list; Esc undoes them. Options that would leave no }},
results are not offered. {title: "Movies and Series", items: []helpItem{
Series are read on demand. {"/", "focus the search box"},
/ text filter for the focused list only; each list keeps its {"Enter, a", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"},
own text (Enter or Esc returns to the list) {"i", "interactive release search (library titles)"},
t toggle monitored }},
s automatic search (series: all missing, asks first) {title: "Library", items: []helpItem{
i, Enter movie: release search; series: seasons and episodes {"Tab", "switch between the movies and series lists"},
r reload {"f", "cycle the status filter (all, missing, downloaded, monitored, unmonitored); every list has its own filters"},
{"F", "filter by file properties: quality, codec (x265, x264, AV1), HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. Changes apply live behind the dialog, also while you move through an open list; Esc undoes them. Options that would leave no results are not offered. Series files are read on demand."},
{"/", "text filter for the focused list only; each list keeps its own text (Enter or Esc returns to the list)"},
{"t", "toggle monitored"},
{"d", "remove from the library (files are kept unless you tick \"Delete files\")"},
{"s", "automatic search (series: all missing, asks first)"},
{"i, Enter", "movie: release search; series: seasons and episodes"},
{"r", "reload"},
}},
{title: "Calendar (F5)", items: []helpItem{
{"[ ]", "move the two-week window back / forward by a week"},
{"t", "back to today"},
{"u", "include unmonitored items"},
{"Enter, i", "release search for the selected movie / episode"},
{"s", "automatic search"},
{"r", "reload"},
}},
{title: "Seasons and episodes", items: []helpItem{
{"Enter", "season: expand / collapse; episode: release search"},
{"i, s, t", "release search, automatic search, toggle monitored (on a season they apply to the whole season)"},
}},
{title: "Release list", items: []helpItem{
{"Enter, g", "grab the selected release"},
{"s", "cycle the sort (best, size, age)"},
{"f", "hide rejected releases"},
{"F", "filter by quality, codec, HDR / DV, audio, language (read from the release title, quality and custom formats)"},
}},
{title: "Downloads", items: []helpItem{
{"p", "pause / resume the SABnzbd queue"},
{"d", "delete the selected job or entry"},
{"b", "remove from the Radarr/Sonarr queue and blocklist"},
{"t", "retry a failed download (history)"},
{"r", "refresh now"},
}},
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, theme, notifications.", items: []helpItem{
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
{"Tab", "jump between the fields and Save / Revert"},
{"Space", "toggle a checkbox; press a button"},
{"←→, Enter", "the default quality profile and root folder: pick from the lists the server has (loaded with the address and key in the fields)"},
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
}},
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
{"↑↓, PgUp, PgDn", "scroll"},
{"G", "follow new entries"},
{"c", "clear the log"},
}},
}
[yellow]Seasons and episodes[-] const helpKeyWidth = 16 // columns for the keys, before the descriptions
Enter season: expand/collapse; episode: release search
i, s, t release search, automatic search, toggle monitored
(on a season they apply to the whole season)
[yellow]Release list[-] // renderHelp lays the key reference out for a text area of the given width:
Enter, g grab the selected release // descriptions that do not fit wrap under themselves, not under the keys.
s cycle sort (best, size, age) func renderHelp(width int) string {
f hide rejected releases var b strings.Builder
F filter by quality, codec, HDR / DV, audio, language (read from descWidth := max(width-2-helpKeyWidth, 20)
the release title, quality and custom formats) for i, sec := range helpSections {
if i > 0 {
[yellow]Downloads[-] b.WriteByte('\n')
p pause / resume the SABnzbd queue }
d delete the selected job or entry b.WriteString("[yellow]" + sec.title + "[-]\n")
b remove from arr queue + blocklist if sec.note != "" {
t retry a failed download (history) for _, l := range flowWrap(tview.Escape(sec.note), width-2, width-2, 0) {
r refresh now b.WriteString(" " + l + "\n")
}
[yellow]Log (F5)[-] }
Popups fade away by themselves; everything they said is kept here. for _, it := range sec.items {
↑↓, PgUp/PgDn scroll G follow new entries c clear keys := tview.Escape(it.keys)
pad := strings.Repeat(" ", max(helpKeyWidth-tview.TaggedStringWidth(keys), 1))
[yellow]Connections[-] lines := flowWrap(tview.Escape(it.desc), descWidth, descWidth, 0)
` for j, l := range lines {
if j == 0 {
b.WriteString(" " + keys + pad + l + "\n")
} else {
b.WriteString(strings.Repeat(" ", 2+helpKeyWidth) + l + "\n")
}
}
}
}
return b.String()
}
// showHelp opens the key reference together with the connection state. // showHelp opens the key reference together with the connection state.
func (a *App) showHelp() { func (a *App) showHelp() {
tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true) screenW, screenH := 100, 40
if a.screen != nil {
screenW, screenH = a.screen.Size()
}
width := min(100, screenW-4)
textWidth := width - 3 // border and a column of air
text := renderHelp(textWidth) + "\n[yellow]Connections[-]\n"
for _, l := range strings.Split(strings.TrimRight(a.serviceSummary(), "\n"), "\n") {
for j, w := range flowWrap(l, textWidth-2, textWidth-2, 0) {
if j == 0 {
text += " " + w + "\n"
} else {
text += " " + w + "\n"
}
}
}
tv := tview.NewTextView().SetDynamicColors(true).SetScrollable(true).SetWrap(false)
tv.SetBorder(true).SetTitle(" Help — Esc to close ") tv.SetBorder(true).SetTitle(" Help — Esc to close ")
tv.SetText(helpText + a.serviceSummary()) tv.SetBorderPadding(0, 0, 1, 0)
tv.SetText(text)
tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { tv.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyEsc || (ev.Key() == tcell.KeyRune && (ev.Rune() == 'q' || ev.Rune() == '?')) { if ev.Key() == tcell.KeyEsc {
a.closeModal() a.closeModal()
return nil return nil
} }
return ev return ev
}) })
a.openModal(tv, 80, 40) height := min(screenH-2, strings.Count(text, "\n")+2)
a.openModalHints(tv, width, height, "↑↓ PgUp PgDn scroll · Esc close")
} }
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"strings"
"testing"
"github.com/rivo/tview"
)
func TestRenderHelpFitsAndAligns(t *testing.T) {
for _, width := range []int{40, 60, 76, 96} {
text := renderHelp(width)
var inItem bool
for i, line := range strings.Split(text, "\n") {
if w := tview.TaggedStringWidth(line); w > width {
t.Errorf("width %d, line %d is %d columns wide: %q", width, i, w, line)
}
plain := plainText(line)
// Descriptions wrap under themselves: a continuation line starts with
// spaces up to the description column, never at the left edge.
switch {
case plain == "":
inItem = false
case !strings.HasPrefix(plain, " "):
inItem = false // a section title
case strings.HasPrefix(plain, strings.Repeat(" ", 2+helpKeyWidth)) && strings.TrimSpace(plain) != "":
if !inItem {
t.Errorf("width %d: continuation without an item before it: %q", width, plain)
}
default:
inItem = true
}
}
// Nothing is lost.
for _, sec := range helpSections {
if !strings.Contains(text, sec.title) {
t.Errorf("width %d: section %q missing", width, sec.title)
}
for _, it := range sec.items {
first := strings.Fields(it.desc)[0]
if !strings.Contains(plainText(text), first) {
t.Errorf("width %d: description of %q lost", width, it.keys)
}
}
}
}
}
func TestRenderHelpKeepsEveryWord(t *testing.T) {
desc := "Changes apply live behind the dialog, also while you move through an open list"
words := strings.Fields(desc)
text := plainText(renderHelp(50))
// Join the wrapped lines of the long Library "F" description and look for every word in order.
pos := 0
for _, w := range words {
i := strings.Index(text[pos:], w)
if i < 0 {
t.Fatalf("word %q missing or out of order", w)
}
pos += i + len(w)
}
}
+177
View File
@@ -0,0 +1,177 @@
package ui
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/png"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// The Kitty graphics protocol with Unicode placeholders, as supported by
// kitty and Ghostty: the poster is uploaded to the terminal once, and then
// shown by writing special placeholder characters into ordinary text cells.
// Because the picture is made of text cells it behaves like text: popups and
// dialogs drawn over it cover it, it vanishes when the view changes, and the
// normal screen diffing keeps it in place. The poster is shown at the
// terminal's full resolution.
// placeholderRune marks a cell that shows a piece of an image. It is followed
// by combining characters that give the row and column in the image; the
// image id is the cell's foreground color.
const placeholderRune = '\U0010EEEE'
const kittyChunk = 4096 // bytes of base64 per escape sequence, the protocol maximum
// kittyGraphics uploads images to the terminal and keeps track of them.
//
// Every footprint of a poster (the part of it that is shown and the number of
// cells it covers) is uploaded as its own image with its own id and placed once.
// An existing placement is never changed: terminals do not reliably replace the
// size of a placement that is already there (Ghostty kept showing the old, small
// one after the window was enlarged), whereas a new image id always starts clean.
type kittyGraphics struct {
write func([]byte) error // writes to the terminal, in step with the screen
nextID uint32
ids map[footprint]uint32
latest map[*image.RGBA]footprint // the footprint of each poster that is currently uploaded
holdUntil time.Time // no real images until then (the window is being resized)
debug func(string) // reports what is uploaded, if set
}
// settleDelay is how long the window size must stay the same before real
// images are shown again. While a window is dragged to another size, tens of
// sizes go by; uploading and placing images for each of them races with the
// terminal's own resizing, and the result can be a placement that does not
// match the cells (a small poster in a big area, or a corner of a big one).
const settleDelay = 300 * time.Millisecond
// hold stops real images being drawn until the window has been left alone for settleDelay.
func (k *kittyGraphics) hold() { k.holdUntil = time.Now().Add(settleDelay) }
// settling reports whether the window size changed so recently that blocks are drawn instead.
func (k *kittyGraphics) settling() bool { return time.Now().Before(k.holdUntil) }
// footprint identifies an uploaded image: a poster, the part of it that was
// sent, and the cells it covers.
type footprint struct {
img *image.RGBA
rect image.Rectangle
cols, rows int
}
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
}
// kittyAPC builds one graphics command: ESC _ G <keys> ; <payload> ESC \.
func kittyAPC(keys string, payload []byte) []byte {
var b bytes.Buffer
b.WriteString("\x1b_G")
b.WriteString(keys)
if len(payload) > 0 {
b.WriteByte(';')
b.Write(payload)
}
b.WriteString("\x1b\\")
return b.Bytes()
}
// show makes sure the part rect of the image is uploaded and placed over
// cols×rows cells, and returns the id the placeholder cells must carry. The same
// footprint is reused; a different one for the same poster replaces the old
// upload (which is removed from the terminal).
func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows int) (uint32, error) {
key := footprint{img, rect, cols, rows}
if id, ok := k.ids[key]; ok {
return id, nil
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, img.SubImage(rect)); err != nil {
return 0, err
}
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
id := k.nextID
for first, rest := true, encoded; len(rest) > 0; first = false {
n := min(len(rest), kittyChunk)
more := 0
if n < len(rest) {
more = 1
}
keys := fmt.Sprintf("m=%d", more)
if first {
// Transmit only (a=t), PNG data (f=100) directly in the command (t=d),
// no replies (q=2): a reply would arrive as keyboard input.
keys = fmt.Sprintf("a=t,f=100,t=d,i=%d,q=2,m=%d", id, more)
}
if err := k.write(kittyAPC(keys, []byte(rest[:n]))); err != nil {
return 0, err
}
rest = rest[n:]
}
// U=1: a virtual placement, shown wherever placeholder cells refer to the id.
if err := k.write(kittyAPC(fmt.Sprintf("a=p,U=1,i=%d,c=%d,r=%d,q=2", id, cols, rows), nil)); err != nil {
return 0, err
}
k.nextID++
if old, ok := k.latest[img]; ok {
if oldID, ok := k.ids[old]; ok {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", oldID), nil))
delete(k.ids, old)
}
}
k.ids[key] = id
k.latest[img] = key
if k.debug != nil {
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, cols, rows))
}
return id, nil
}
// deleteAll removes every image uploaded so far from the terminal. The next
// draw uploads what it needs again, which is also how a resize is handled.
func (k *kittyGraphics) deleteAll() {
for _, id := range k.ids {
_ = k.write(kittyAPC(fmt.Sprintf("a=d,d=I,i=%d,q=2", id), nil))
}
k.ids = map[footprint]uint32{}
k.latest = map[*image.RGBA]footprint{}
}
// placeholderCell returns what to draw in the cell at (row, col) of the image
// with the given id: the placeholder character, its combining characters and
// the style that carries the id.
func placeholderCell(id uint32, row, col int) (main rune, comb []rune, style tcell.Style) {
comb = []rune{kittyDiacritics[row], kittyDiacritics[col]}
fg := tcell.NewRGBColor(int32(id>>16&0xFF), int32(id>>8&0xFF), int32(id&0xFF))
// The background shows wherever the picture does not cover the cell (a
// sliver at the edge): use the panes' own color so it is not visible.
return placeholderRune, comb, tcell.StyleDefault.Foreground(fg).Background(tview.Styles.PrimitiveBackgroundColor)
}
// maxPlaceholders is how many rows and columns can be numbered.
const maxPlaceholders = len(kittyDiacritics)
// kittyTerminal reports whether the environment looks like a terminal that
// supports Kitty graphics with Unicode placeholders: kitty and Ghostty, not
// inside tmux or screen (which swallow the graphics commands).
func kittyTerminal(env func(string) string) bool {
term := strings.ToLower(env("TERM"))
if env("TMUX") != "" || env("STY") != "" || strings.HasPrefix(term, "screen") || strings.HasPrefix(term, "tmux") {
return false
}
switch {
case strings.Contains(term, "kitty"), strings.Contains(term, "ghostty"):
return true
case strings.EqualFold(env("TERM_PROGRAM"), "ghostty"), env("GHOSTTY_RESOURCES_DIR") != "", env("KITTY_WINDOW_ID") != "":
return true
}
return false
}
+46
View File
@@ -0,0 +1,46 @@
package ui
// kittyDiacritics are the 297 combining characters the Kitty graphics
// protocol uses to number the rows and columns of an image drawn with Unicode
// placeholders (rowcolumn-diacritics.txt in the kitty repository, fixed to
// Unicode 6.0). Entry i stands for the number i.
var kittyDiacritics = [...]rune{
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F,
0x0346, 0x034A, 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357,
0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369,
0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484,
0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1,
0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611,
0x0612, 0x0613, 0x0614, 0x0615, 0x0616, 0x0617, 0x0657, 0x0658,
0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8,
0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733,
0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743,
0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE,
0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, 0x0818, 0x0819,
0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C,
0x082D, 0x0951, 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87,
0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76,
0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D,
0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4,
0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1,
0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9,
0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1,
0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7,
0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2,
0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA,
0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2,
0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D,
0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5,
0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED,
0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7,
0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187,
0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243,
0x1D244,
}
+395
View File
@@ -0,0 +1,395 @@
package ui
import (
"bytes"
"encoding/base64"
"image"
"image/png"
"math"
"math/rand"
"regexp"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// recorder collects what is written to the terminal.
type recorder struct{ cmds []string }
func (r *recorder) write(b []byte) error { r.cmds = append(r.cmds, string(b)); return nil }
func noisy(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
rng := rand.New(rand.NewSource(1))
for i := range img.Pix {
img.Pix[i] = uint8(rng.Intn(256))
}
for i := 3; i < len(img.Pix); i += 4 {
img.Pix[i] = 255
}
return img
}
func TestKittyDiacriticsTable(t *testing.T) {
if len(kittyDiacritics) != 297 {
t.Fatalf("%d diacritics, the protocol defines 297", len(kittyDiacritics))
}
if kittyDiacritics[0] != 0x0305 || kittyDiacritics[296] != 0x1D244 {
t.Errorf("table ends are %U and %U", kittyDiacritics[0], kittyDiacritics[296])
}
for i := 1; i < len(kittyDiacritics); i++ {
if kittyDiacritics[i] <= kittyDiacritics[i-1] {
t.Fatalf("table not strictly ascending at %d", i)
}
}
}
func TestKittyUploadChunksAndEncodes(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
img := noisy(200, 300) // incompressible: far more than one 4096-byte chunk of base64
id, err := k.show(img, img.Bounds(), 20, 15)
if err != nil || id != 1 {
t.Fatalf("id=%d err=%v", id, err)
}
if len(rec.cmds) < 4 {
t.Fatalf("only %d commands for a large image", len(rec.cmds))
}
// The upload is followed by the placement, once.
if last := rec.cmds[len(rec.cmds)-1]; last != "\x1b_Ga=p,U=1,i=1,c=20,r=15,q=2\x1b\\" {
t.Errorf("last command = %q", last)
}
uploads := rec.cmds[:len(rec.cmds)-1]
re := regexp.MustCompile(`^\x1b_G([^;]*);(.*)\x1b\\$`)
var payload strings.Builder
for i, c := range uploads {
m := re.FindStringSubmatch(c)
if m == nil {
t.Fatalf("command %d is not a graphics command: %q", i, c[:min(40, len(c))])
}
keys, data := m[1], m[2]
if len(data) > kittyChunk {
t.Errorf("chunk %d has %d bytes", i, len(data))
}
last := i == len(uploads)-1
switch {
case i == 0 && !strings.HasPrefix(keys, "a=t,f=100,t=d,i=1,q=2,m=1"):
t.Errorf("first command keys = %q", keys)
case i > 0 && !last && keys != "m=1":
t.Errorf("middle command keys = %q", keys)
case last && !strings.HasSuffix(keys, "m=0"):
t.Errorf("last command keys = %q", keys)
}
payload.WriteString(data)
}
raw, err := base64.StdEncoding.DecodeString(payload.String())
if err != nil {
t.Fatal(err)
}
decoded, err := png.Decode(bytes.NewReader(raw))
if err != nil || decoded.Bounds() != img.Bounds() {
t.Fatalf("payload is not the image as PNG: %v %v", decoded, err)
}
r, g, b, _ := decoded.At(17, 23).RGBA()
want := img.RGBAAt(17, 23)
if uint8(r>>8) != want.R || uint8(g>>8) != want.G || uint8(b>>8) != want.B {
t.Error("pixel data changed")
}
// The same footprint is not uploaded twice; another poster gets the next id.
n := len(rec.cmds)
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id || len(rec.cmds) != n {
t.Error("an uploaded footprint must be reused")
}
small := noisy(4, 4)
if other, _ := k.show(small, small.Bounds(), 3, 2); other != 2 {
t.Errorf("second image id = %d", other)
}
}
func TestKittyNewSizeIsANewImage(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
img := noisy(10, 15)
id1, _ := k.show(img, img.Bounds(), 20, 15)
rec.cmds = nil
if again, _ := k.show(img, img.Bounds(), 20, 15); again != id1 || len(rec.cmds) != 0 {
t.Fatalf("same footprint: id %d, %d commands", again, len(rec.cmds))
}
// A different size is never done by changing the placement (a terminal can keep the
// old one): the poster is uploaded under a new id, placed, and the old id removed.
id2, _ := k.show(img, img.Bounds(), 10, 8)
if id2 == id1 {
t.Fatal("a new footprint must get a new id")
}
all := strings.Join(rec.cmds, "")
if !strings.Contains(all, "a=t,f=100,t=d,i=2") || !strings.Contains(all, "a=p,U=1,i=2,c=10,r=8") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("resize sent %q", rec.cmds)
}
if strings.Contains(all, "a=p,U=1,i=1") {
t.Error("the old placement must not be touched")
}
// After deleteAll (what a window resize triggers) everything is uploaded afresh.
rec.cmds = nil
k.deleteAll()
id3, _ := k.show(img, img.Bounds(), 10, 8)
if id3 == id2 || !strings.Contains(strings.Join(rec.cmds, ""), "a=d,d=I,i=2") || !strings.Contains(strings.Join(rec.cmds, ""), "a=t,f=100,t=d,i=3") {
t.Errorf("after deleteAll: id %d, %q", id3, rec.cmds)
}
if len(k.ids) != 1 || len(k.latest) != 1 {
t.Errorf("bookkeeping: %d ids, %d latest", len(k.ids), len(k.latest))
}
}
func TestPlaceholderCell(t *testing.T) {
ch, comb, style := placeholderCell(0x010203, 2, 5)
if ch != '\U0010EEEE' || len(comb) != 2 || comb[0] != kittyDiacritics[2] || comb[1] != kittyDiacritics[5] {
t.Errorf("cell = %U %U", ch, comb)
}
fg, bg, _ := style.Decompose()
if r, g, b := fg.RGB(); r != 1 || g != 2 || b != 3 {
t.Errorf("the id must be in the foreground color, got %d,%d,%d", r, g, b)
}
if bg != tview.Styles.PrimitiveBackgroundColor {
t.Errorf("background %v must match the panes (%v), or uncovered slivers show", bg, tview.Styles.PrimitiveBackgroundColor)
}
}
func TestKittyTerminalDetection(t *testing.T) {
defer func(old func() bool) { sextantFontAvailable = old }(sextantFontAvailable)
sextantFontAvailable = func() bool { return false } // not what these cases are about
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
kittyGraphicsSupported = func() bool { return true } // the terminal confirms
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
for _, tc := range []struct {
name string
env map[string]string
want bool
}{
{"ghostty term", map[string]string{"TERM": "xterm-ghostty"}, true},
{"ghostty program", map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "ghostty"}, true},
{"ghostty resources", map[string]string{"GHOSTTY_RESOURCES_DIR": "/x"}, true},
{"kitty", map[string]string{"TERM": "xterm-kitty"}, true},
{"kitty window", map[string]string{"KITTY_WINDOW_ID": "1"}, true},
{"plain xterm", map[string]string{"TERM": "xterm-256color"}, false},
{"wezterm has no placeholders", map[string]string{"TERM_PROGRAM": "WezTerm"}, false},
{"tmux swallows graphics", map[string]string{"TERM": "xterm-ghostty", "TMUX": "/tmp/x"}, false},
{"screen swallows graphics", map[string]string{"TERM": "screen-256color", "TERM_PROGRAM": "ghostty"}, false},
{"nothing", nil, false},
} {
if got := kittyTerminal(env(tc.env)); got != tc.want {
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
}
}
for _, tc := range []struct {
setting string
env map[string]string
wantGfx bool
wantName string
}{
{"auto", map[string]string{"TERM": "xterm-ghostty"}, true, "sextant"},
{"", map[string]string{"TERM": "xterm-256color"}, false, "quadrant"},
{"kitty", map[string]string{"TERM": "xterm-256color"}, true, "quadrant"},
{"sextant", map[string]string{"TERM": "xterm-ghostty"}, false, "sextant"},
{"quadrant", map[string]string{"TERM": "xterm-ghostty"}, false, "quadrant"},
} {
blocks, gfx := resolveCovers(tc.setting, env(tc.env))
if gfx != tc.wantGfx || blocks.name != tc.wantName {
t.Errorf("%q %v = %s/%v, want %s/%v", tc.setting, tc.env, blocks.name, gfx, tc.wantName, tc.wantGfx)
}
}
}
// trueColor makes a simulation screen report 24-bit color.
type trueColor struct{ tcell.SimulationScreen }
func (trueColor) Colors() int { return 1 << 24 }
func TestCoverViewGraphicsDrawsPlaceholders(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 20)
rec := &recorder{}
c := newCoverView(quadrantStyle, newKittyGraphics(rec.write), nil)
c.setImage(stripes(40, 60)) // 2:3, cells twice as tall as wide: 13 columns × 10 rows in a 30×10 box
c.SetRect(0, 0, 30, 10)
// Without true color the protocol is not used and blocks are drawn.
c.Draw(sim)
if len(rec.cmds) != 0 {
t.Fatalf("graphics commands sent without true color: %q", rec.cmds)
}
if ch, _, _, _ := sim.GetContent(29, 0); ch == placeholderRune {
t.Error("placeholders drawn without true color")
}
screen := trueColor{sim}
c.Draw(screen)
if len(rec.cmds) != 2 { // upload (one chunk) + placement
t.Fatalf("%d commands: %q", len(rec.cmds), rec.cmds)
}
if !strings.Contains(rec.cmds[1], "c=13,r=10") {
t.Errorf("placement = %q", rec.cmds[1])
}
// Right-aligned: columns 17..29, rows 0..9.
ch, comb, _, _ := sim.GetContent(17, 0)
if ch != placeholderRune || len(comb) < 2 || comb[0] != kittyDiacritics[0] || comb[1] != kittyDiacritics[0] {
t.Errorf("top left cell = %U %U", ch, comb)
}
ch, comb, _, _ = sim.GetContent(29, 9)
if ch != placeholderRune || comb[0] != kittyDiacritics[9] || comb[1] != kittyDiacritics[12] {
t.Errorf("bottom right cell = %U %U", ch, comb)
}
c.Draw(screen) // a second frame uploads nothing again
if len(rec.cmds) != 2 {
t.Errorf("redraw sent %d more commands", len(rec.cmds)-2)
}
// A cell aspect of 2.5 (taller cells) changes the footprint of the same box.
c.cellAspect = func() float64 { return 2.5 }
c.Draw(screen)
// The box is now 17×10 cells, a different shape: a new image id is uploaded and
// placed, the old upload is removed.
all := strings.Join(rec.cmds[2:], "")
if !strings.Contains(all, "a=p,U=1,i=2,c=17,r=10") || !strings.Contains(all, "a=d,d=I,i=1") {
t.Errorf("after the cell aspect changed: %q", rec.cmds[2:])
}
}
func TestCropToBox(t *testing.T) {
b := image.Rect(0, 0, 167, 250) // a 2:3 poster
// A box slightly narrower than the poster: the sides are trimmed.
got := cropToBox(b, 17, 13, 2.1) // box shape 17/(13*2.1) = 0.6227 < 0.668
if got.Dy() != 250 || got.Dx() >= 167 || got.Min.X != (167-got.Dx())/2 {
t.Errorf("narrow box: %v", got)
}
if shape := float64(got.Dx()) / float64(got.Dy()); math.Abs(shape-17.0/(13*2.1)) > 0.005 {
t.Errorf("cropped shape %.4f, want %.4f", shape, 17.0/(13*2.1))
}
// A box slightly wider: top and bottom are trimmed.
got = cropToBox(b, 18, 12, 2.1) // 18/(12*2.1) = 0.714 > 0.668
if got.Dx() != 167 || got.Dy() >= 250 || got.Min.Y != (250-got.Dy())/2 {
t.Errorf("wide box: %v", got)
}
// A box of exactly the poster's shape changes nothing.
if got := cropToBox(image.Rect(0, 0, 40, 60), 20, 15, 2); got != image.Rect(0, 0, 40, 60) {
t.Errorf("exact box: %v", got)
}
// Offsets of sub-images are respected.
if got := cropToBox(image.Rect(10, 20, 50, 80), 18, 10, 2); got.Min.X != 10 || got.Max.X != 50 || got.Min.Y < 20 || got.Max.Y > 80 || got.Dy() >= 60 {
t.Errorf("offset image: %v", got)
}
}
func TestCellAspectFromWindow(t *testing.T) {
// 140×50 cells of 9×20 px plus 4 and 11 px of padding: plain division
// would say 1264/140=9.03 and 1011/50=20.22 (aspect 2.24), the real shape is 20/9.
if got := cellAspectFromWindow(140, 50, 140*9+4, 50*20+11); math.Abs(got-20.0/9) > 1e-9 {
t.Errorf("aspect = %v, want %v", got, 20.0/9)
}
// Padding that grows with the window does not move it.
for _, pad := range []int{0, 3, 8} {
if got := cellAspectFromWindow(100, 30, 100*10+pad, 30*21+pad); math.Abs(got-2.1) > 1e-9 {
t.Errorf("padding %d: aspect = %v", pad, got)
}
}
for _, bad := range [][4]int{{0, 10, 100, 100}, {10, 0, 100, 100}, {10, 10, 0, 100}, {10, 10, 100, 0}, {100, 100, 50, 50}} {
if got := cellAspectFromWindow(bad[0], bad[1], bad[2], bad[3]); got != 2 {
t.Errorf("%v: aspect = %v, want the default 2", bad, got)
}
}
}
func TestPosterSource(t *testing.T) {
for in, want := range map[string]string{
"https://artworks.thetvdb.com/banners/posters/1-1.jpg": "artworks.thetvdb.com/banners/posters/1-1.jpg",
"http://radarr:7878|/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster.jpg",
} {
if got := posterSource(in); got != want {
t.Errorf("posterSource(%q) = %q, want %q", in, got, want)
}
}
}
func TestGraphicsAreHeldWhileTheWindowChanges(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 20)
screen := trueColor{sim}
rec := &recorder{}
k := newKittyGraphics(rec.write)
c := newCoverView(quadrantStyle, k, nil)
c.setImage(stripes(40, 60))
c.SetRect(0, 0, 30, 10)
k.hold()
if !k.settling() {
t.Fatal("hold must start the settling period")
}
c.Draw(screen)
if len(rec.cmds) != 0 {
t.Fatalf("graphics commands while the window changes: %q", rec.cmds)
}
if ch, _, _, _ := sim.GetContent(29, 9); ch == placeholderRune {
t.Error("placeholders drawn while settling; blocks are the stand-in")
}
k.holdUntil = time.Now().Add(-time.Millisecond) // the period is over
c.Draw(screen)
if len(rec.cmds) != 2 {
t.Errorf("after settling: %d commands, want upload + placement", len(rec.cmds))
}
if ch, _, _, _ := sim.GetContent(29, 9); ch != placeholderRune {
t.Error("real image not shown after settling")
}
}
func TestGraphicsDebugReportsUploads(t *testing.T) {
rec := &recorder{}
k := newKittyGraphics(rec.write)
var lines []string
k.debug = func(s string) { lines = append(lines, s) }
img := noisy(10, 15)
k.show(img, img.Bounds(), 12, 8)
if len(lines) != 1 || !strings.Contains(lines[0], "id 1") || !strings.Contains(lines[0], "12x8 cells") || !strings.Contains(lines[0], "10x15 px") {
t.Errorf("debug lines = %q", lines)
}
}
func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) {
defer func(old func() bool) { kittyGraphicsSupported = old }(kittyGraphicsSupported)
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
ghostty := env(map[string]string{"TERM": "xterm-ghostty"})
kittyGraphicsSupported = func() bool { return false } // e.g. a terminal that claims kitty but prints APC as text
if _, gfx := resolveCovers("auto", ghostty); gfx {
t.Error("auto must not use real images when the terminal does not answer the query")
}
if _, gfx := resolveCovers("kitty", ghostty); gfx {
t.Error("even an explicit kitty setting must not send graphics a terminal does not understand")
}
asked := false
kittyGraphicsSupported = func() bool { asked = true; return true }
if _, gfx := resolveCovers("auto", env(map[string]string{"TERM": "xterm-256color", "TERM_PROGRAM": "Apple_Terminal"})); gfx || asked {
t.Error("a terminal not known for the protocol is not even asked (it might print the query)")
}
if _, gfx := resolveCovers("auto", ghostty); !gfx || !asked {
t.Error("a known terminal that answers gets real images")
}
}
+66
View File
@@ -0,0 +1,66 @@
package ui
import (
"context"
"github.com/rivo/tview"
)
// confirmDelete asks before removing a title from the library. Deleting the
// files is a separate, unticked choice: by default only the library entry goes.
func (a *App) confirmDelete(label, summary string, onDelete func(deleteFiles, exclude bool)) {
box, _ := a.newDeleteDialog(label, summary, onDelete)
a.openModal(box, 66, 14)
}
func (a *App) newDeleteDialog(label, summary string, onDelete func(deleteFiles, exclude bool)) (*tview.Flex, *tview.Form) {
const (
lFiles = "Delete files from disk"
lExclude = "Don't let import lists re-add it"
)
text := tview.NewTextView().SetDynamicColors(true).SetWordWrap(true).
SetText("[::b]" + label + "[::-]\n" + summary)
form := tview.NewForm()
form.AddCheckbox(lFiles, false, nil)
form.AddCheckbox(lExclude, false, nil)
files := form.GetFormItemByLabel(lFiles).(*tview.Checkbox)
exclude := form.GetFormItemByLabel(lExclude).(*tview.Checkbox)
// Cancel first so a stray Enter is safe.
form.AddButton("Cancel", a.closeModal)
form.AddButton("Delete", func() {
a.closeModal()
onDelete(files.IsChecked(), exclude.IsChecked())
})
form.SetFocus(form.GetFormItemCount()) // the Cancel button
arrowNavigation(form, a.closeModal, nil)
box := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(text, 4, 0, false).
AddItem(form, 0, 1, true)
box.SetBorder(true).SetTitle(" Remove from library ")
return box, form
}
// remove asks to delete the selected library item.
func (v *libraryView) remove() {
it, ok := v.selected()
if !ok {
return
}
summary := "[gray]Untick “Delete files” to keep the files on disk.[-]"
if it.have {
summary = "[yellow]" + it.size + " on disk.[-] Files are kept unless you tick “Delete files”."
}
v.app.confirmDelete(it.label, summary, func(deleteFiles, exclude bool) {
what := "Removed " + it.label
if deleteFiles {
what = "Removed " + it.label + " and its files"
}
v.run("Removing "+it.label, what, func(ctx context.Context) error {
if it.movie != nil {
return v.radarr.DeleteMovie(ctx, it.id, deleteFiles, exclude)
}
return v.sonarr.DeleteSeries(ctx, it.id, deleteFiles, exclude)
}, v.reload)
})
}
+46
View File
@@ -0,0 +1,46 @@
package ui
import (
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func TestDeleteDialogDefaultsAreSafe(t *testing.T) {
a := newTestApp(t)
var calls [][2]bool
_, form := a.newDeleteDialog("Heat (1995)", "3 GB on disk", func(files, exclude bool) { calls = append(calls, [2]bool{files, exclude}) })
var focus func(p tview.Primitive)
focus = func(p tview.Primitive) { p.Focus(focus) }
form.Focus(focus)
press := func(k tcell.Key) { form.InputHandler()(tcell.NewEventKey(k, 0, 0), focus) }
space := func() { form.InputHandler()(tcell.NewEventKey(tcell.KeyRune, ' ', 0), focus) }
// Both boxes start unticked and the focus starts on Cancel: a stray Enter does nothing.
press(tcell.KeyEnter)
if len(calls) != 0 {
t.Fatalf("Enter on the initial focus deleted: %v", calls)
}
// Moving to Delete and confirming removes the entry only.
form.SetFocus(form.GetFormItemCount())
press(tcell.KeyRight)
press(tcell.KeyEnter)
if len(calls) != 1 || calls[0] != [2]bool{false, false} {
t.Fatalf("calls = %v, want one call without files and exclusion", calls)
}
// Ticking the boxes is passed on.
form.SetFocus(0)
space() // delete files
press(tcell.KeyDown)
space() // exclusion
press(tcell.KeyDown)
press(tcell.KeyDown) // Delete button
press(tcell.KeyEnter)
if len(calls) != 2 || calls[1] != [2]bool{true, true} {
t.Errorf("calls = %v, want the second with both ticked", calls)
}
}
+109 -6
View File
@@ -244,12 +244,12 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
v.cur = kind v.cur = kind
v.input.SetLabel("Filter " + kind.String() + ": ") v.input.SetLabel("Filter " + kind.String() + ": ")
v.input.SetText(p.text) v.input.SetText(p.text)
p.table.SetBorderColor(tcell.ColorYellow) p.table.SetBorderColor(named("yellow"))
v.renderDetails() v.renderDetails()
}) })
p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) }) p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) })
v.panes[kind] = p v.panes[kind] = p
lists.AddItem(p.table, 0, 1, kind == kindMovies) lists.AddItem(fitTitles{p.table}, 0, 1, kind == kindMovies)
} }
v.root = tview.NewFlex().SetDirection(tview.FlexRow). v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false). AddItem(v.header, 1, 0, false).
@@ -263,6 +263,14 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
v.render() v.render()
}) })
v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) }) v.input.SetDoneFunc(func(tcell.Key) { v.app.app.SetFocus(v.pane().table) })
app.reconnect = append(app.reconnect, func() {
// Another server: forget what the old one said, reload when shown.
v.seriesFiles = map[int]seriesFiles{}
for _, p := range v.panes {
p.all, p.shown, p.loaded, p.err = nil, nil, false, nil
}
v.render()
})
v.render() v.render()
return v return v
} }
@@ -293,6 +301,8 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
v.search() v.search()
case 't': case 't':
v.toggleMonitored() v.toggleMonitored()
case 'd':
v.remove()
default: default:
return ev return ev
} }
@@ -353,8 +363,11 @@ func (v *libraryView) reloadPane(p *libPane) {
defer v.loadSeriesFiles(false) defer v.loadSeriesFiles(false)
} }
if err != nil { if err != nil {
// Keep showing the last good list; say why it is stale. // Keep showing the last good list; say why it is stale. A service
// that is not set up says so in its list instead.
if !notConfigured(err) {
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error())) v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
}
} else { } else {
p.all, p.loaded = items, true p.all, p.loaded = items, true
} }
@@ -381,9 +394,9 @@ func (v *libraryView) render() {
} }
func (v *libraryView) renderPane(p *libPane) { func (v *libraryView) renderPane(p *libPane) {
selID := -1 selID, prevRow := -1, 0
if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) { if r, _ := p.table.GetSelection(); r >= 1 && r <= len(p.shown) {
selID = p.shown[r-1].id selID, prevRow = p.shown[r-1].id, r
} }
p.shown = filterItems(p.all, p.filter, p.text, p.attr) p.shown = filterItems(p.all, p.filter, p.text, p.attr)
@@ -391,15 +404,25 @@ func (v *libraryView) renderPane(p *libPane) {
header(p.table, 0, "Title", "Status", "Size") header(p.table, 0, "Title", "Status", "Size")
selRow := 0 selRow := 0
for i, it := range p.shown { for i, it := range p.shown {
p.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1)) title := titleCell{prefix: monMark(it.monitored) + " ", title: it.label}
p.table.SetCell(i+1, 0, tview.NewTableCell(title.fit(0)).SetExpansion(1).SetReference(title))
p.table.SetCell(i+1, 1, tview.NewTableCell(it.status)) p.table.SetCell(i+1, 1, tview.NewTableCell(it.status))
p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight)) p.table.SetCell(i+1, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
if it.id == selID { if it.id == selID {
selRow = i + 1 selRow = i + 1
} }
} }
if selRow == 0 && selID != -1 && !containsItem(p.all, selID) {
// The selected title is gone from the library (removed): stay at its
// place, on the title that moved up into it (or the new last one),
// instead of jumping to the top. A title that is only filtered out
// still starts the list from the top.
selRow = min(prevRow, len(p.shown))
}
placeholder := "Nothing matches the filter" placeholder := "Nothing matches the filter"
switch { switch {
case notConfigured(p.err):
placeholder = p.kind.service() + " is not configured (Config view, F6)"
case p.err != nil && !p.loaded: case p.err != nil && !p.loaded:
placeholder = "Could not load, press r to retry" placeholder = "Could not load, press r to retry"
case !p.loaded: case !p.loaded:
@@ -427,6 +450,86 @@ func (v *libraryView) renderPane(p *libPane) {
p.table.SetTitle(title + " ") p.table.SetTitle(title + " ")
} }
// fitTitles draws a table whose first column (the title) is cut to the width
// the other columns leave, with an ellipsis, so long titles never make the
// list scroll sideways. The cut is worked out on every draw, so it follows
// the window size.
type fitTitles struct{ *tview.Table }
func (f fitTitles) Draw(screen tcell.Screen) {
fitFirstColumn(f.Table)
f.Table.Draw(screen)
}
// fitFirstColumn cuts the title cells (those with a titleCell reference) to
// the width the table leaves after the other columns and their separators.
// The cut is done here rather than with the cell's maximum width, because tview
// only marks a cut with an ellipsis for text without color tags, and every
// title starts with the colored monitored mark.
func fitFirstColumn(t *tview.Table) {
_, _, width, _ := t.GetInnerRect()
rows, cols := t.GetRowCount(), t.GetColumnCount()
rest := 0
for c := 1; c < cols; c++ {
w := 0
for r := 0; r < rows; r++ {
if cell := t.GetCell(r, c); cell != nil {
w = max(w, tview.TaggedStringWidth(cell.Text))
}
}
rest += w + 1 // the column and the separator before it
}
limit := max(width-rest, 8)
for r := 0; r < rows; r++ {
cell := t.GetCell(r, 0)
if title, ok := cell.GetReference().(titleCell); ok {
cell.SetText(title.fit(limit))
}
}
}
// titleCell is the text of a title cell: a tagged prefix (the monitored mark)
// and the plain title, kept whole so it can be cut again for another width.
type titleCell struct{ prefix, title string }
// fit returns the cell text at most width columns wide, the title cut with an
// ellipsis if needed; width 0 means no limit.
func (c titleCell) fit(width int) string {
title := c.title
if width > 0 {
title = cutToWidth(title, width-tview.TaggedStringWidth(c.prefix))
}
return c.prefix + tview.Escape(title)
}
// cutToWidth shortens s to at most w screen columns, ending with "…" when
// anything was cut.
func cutToWidth(s string, w int) string {
width := func(s string) int { return tview.TaggedStringWidth(tview.Escape(s)) }
if width(s) <= w {
return s
}
if w < 1 {
return ""
}
runes := []rune(s)
for n := len(runes); n > 0; n-- {
if cut := string(runes[:n]) + "…"; width(cut) <= w {
return cut
}
}
return "…"
}
func containsItem(items []libItem, id int) bool {
for _, it := range items {
if it.id == id {
return true
}
}
return false
}
func (v *libraryView) renderDetails() { func (v *libraryView) renderDetails() {
if it, ok := v.selected(); ok { if it, ok := v.selected(); ok {
v.details.Show(it.details, it.cover) v.details.Show(it.details, it.cover)
+99
View File
@@ -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)
}
}
}
+13
View File
@@ -0,0 +1,13 @@
package ui
import (
"os"
"testing"
)
// TestMain keeps the tests from asking the real terminal about graphics
// support and its colors (the probe writes to and reads from /dev/tty).
func TestMain(m *testing.M) {
terminalAnswers = func() terminalInfo { return terminalInfo{} }
os.Exit(m.Run())
}
+9 -3
View File
@@ -23,7 +23,7 @@ func (l level) String() string {
} }
func (l level) color() tcell.Color { func (l level) color() tcell.Color {
return [...]tcell.Color{tcell.ColorWhite, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorRed}[l] return named([...]string{"white", "green", "yellow", "red"}[l])
} }
// how long a toast stays on screen, and how long the end of that time fades. // how long a toast stays on screen, and how long the end of that time fades.
@@ -115,7 +115,13 @@ type toastRoot struct {
app *App app *App
} }
// narrowWidth is the terminal width below which the six tabs and the full
// service names no longer fit on one line.
const narrowWidth = 92
func (r *toastRoot) Draw(screen tcell.Screen) { func (r *toastRoot) Draw(screen tcell.Screen) {
w, _ := screen.Size()
r.app.setNarrow(w < narrowWidth)
r.Flex.Draw(screen) r.Flex.Draw(screen)
a := r.app a := r.app
now := time.Now() now := time.Now()
@@ -136,9 +142,9 @@ func (r *toastRoot) Draw(screen tcell.Screen) {
x := w - width - 1 x := w - width - 1
fg := t.level.color() fg := t.level.color()
if t.expires.Sub(now) < toastFade { if t.expires.Sub(now) < toastFade {
fg = tcell.ColorGray // fade out fg = named("gray") // fade out
} }
style := tcell.StyleDefault.Background(tcell.NewRGBColor(30, 30, 40)).Foreground(fg) style := tcell.StyleDefault.Background(tokens.toast).Foreground(fg)
for cx := x; cx < x+width; cx++ { for cx := x; cx < x+width; cx++ {
screen.SetContent(cx, y, ' ', nil, style) screen.SetContent(cx, y, ' ', nil, style)
} }
+37
View File
@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
"testing" "testing"
"github.com/rivo/tview" "github.com/rivo/tview"
@@ -39,3 +40,39 @@ func TestOnlyFullOverlays(t *testing.T) {
t.Error("a dialog must block view shortcuts") t.Error("a dialog must block view shortcuts")
} }
} }
func TestDiffHistory(t *testing.T) {
slot := func(id, name, status string) sabnzbd.HistorySlot {
return sabnzbd.HistorySlot{ID: id, Name: name, Status: status, FailMessage: map[bool]string{true: "disk full"}[status == "Failed"]}
}
seen := map[string]string{}
// The first poll only records what is already there.
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("1", "old", "Completed"), slot("2", "old fail", "Failed")}, true); len(ev) != 0 {
t.Fatalf("baseline produced %v", ev)
}
// Nothing new.
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("1", "old", "Completed")}, false); len(ev) != 0 {
t.Fatalf("unchanged history produced %v", ev)
}
// A new job that is still being processed is not final yet.
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("3", "movie", "Extracting")}, false); len(ev) != 0 {
t.Fatalf("extracting produced %v", ev)
}
// It completes, a second one fails, a third appears already completed.
ev := diffHistory(seen, []sabnzbd.HistorySlot{
slot("3", "movie", "Completed"), slot("4", "show", "Failed"), slot("5", "quick", "Completed"), slot("1", "old", "Completed"),
}, false)
if len(ev) != 3 || ev[0].name != "movie" || ev[0].failed || ev[1].name != "show" || !ev[1].failed || ev[1].message != "disk full" || ev[2].name != "quick" {
t.Fatalf("events = %+v", ev)
}
// Reported once only.
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("3", "movie", "Completed"), slot("4", "show", "Failed")}, false); len(ev) != 0 {
t.Errorf("repeated: %+v", ev)
}
// A retried job that completes later is reported again.
diffHistory(seen, []sabnzbd.HistorySlot{slot("4", "show", "Queued")}, false)
if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("4", "show", "Completed")}, false); len(ev) != 1 {
t.Errorf("retry: %+v", ev)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !unix
package ui
import "time"
// probeTerminal is not implemented here: block characters and the default
// colors are used.
func probeTerminal(time.Duration) terminalInfo { return terminalInfo{} }
+79
View File
@@ -0,0 +1,79 @@
//go:build unix
package ui
import (
"bytes"
"errors"
"os"
"time"
"golang.org/x/sys/unix"
"golang.org/x/term"
)
// probeTerminal asks the terminal whether it understands the Kitty graphics
// protocol (a tiny query image, which a capable terminal answers with "OK" and
// does not show) and for its background color (OSC 11), which picks the
// theme. A Primary Device
// Attributes request follows, which every terminal answers, and answers come
// in order: once the DA answer is in, whatever was not answered before it is
// not supported. Runs before the screen is set up, on the controlling terminal.
func probeTerminal(timeout time.Duration) (info terminalInfo) {
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return info
}
defer tty.Close()
fd := int(tty.Fd())
state, err := term.MakeRaw(fd)
if err != nil {
return info
}
defer term.Restore(fd, state)
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b]11;?\x1b\\" + "\x1b[c"
if _, err := tty.WriteString(query); err != nil {
return info
}
// A terminal that does not know APC sequences may print the query; erase it.
defer tty.WriteString("\r\x1b[2K")
// The wait uses select(2): a read deadline does not work on a terminal
// device on macOS (kqueue does not support them), which made the probe
// give up at once there.
deadline := time.Now().Add(timeout)
var got []byte
buf := make([]byte, 256)
for {
left := time.Until(deadline)
if left <= 0 {
break // no complete answer in time: keep what arrived
}
var fds unix.FdSet
fds.Set(fd)
tv := unix.NsecToTimeval(left.Nanoseconds())
n, err := unix.Select(fd+1, &fds, nil, nil, &tv)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
break
}
if n == 0 {
continue // timed out; the loop ends above
}
n, err = unix.Read(fd, buf)
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
continue
}
if err != nil || n <= 0 {
break
}
got = append(got, buf[:n]...)
if da := bytes.Index(got, []byte("\x1b[?")); da >= 0 && bytes.IndexByte(got[da:], 'c') >= 0 {
break // device attributes answered: everything else came before
}
}
return parseTerminalAnswers(got)
}
+1 -1
View File
@@ -135,7 +135,7 @@ func (v *releasesView) render() {
v.table.Clear() v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} { for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false) cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
if c == "Title" { if c == "Title" {
cell.SetExpansion(1) cell.SetExpansion(1)
} }
+20 -5
View File
@@ -25,7 +25,6 @@ const (
type searchView struct { type searchView struct {
app *App app *App
be backend be backend
cfg config.Arr
root *tview.Flex root *tview.Flex
input *tview.InputField input *tview.InputField
@@ -46,11 +45,10 @@ type searchView struct {
loaded bool loaded bool
} }
func newSearchView(app *App, be backend, cfg config.Arr) *searchView { func newSearchView(app *App, be backend) *searchView {
v := &searchView{ v := &searchView{
app: app, app: app,
be: be, be: be,
cfg: cfg,
input: tview.NewInputField().SetLabel("Search: "), input: tview.NewInputField().SetLabel("Search: "),
list: tview.NewList().ShowSecondaryText(false), list: tview.NewList().ShowSecondaryText(false),
details: newDetailPane(app), details: newDetailPane(app),
@@ -106,10 +104,27 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
return ev return ev
}) })
app.reconnect = append(app.reconnect, v.reloadOptions)
go v.loadOptions() go v.loadOptions()
return v return v
} }
// defaults returns the add-form defaults of the service, as currently configured.
func (v *searchView) defaults() config.Arr {
if v.be.name() == "Radarr" {
return v.app.cfg.Radarr
}
return v.app.cfg.Sonarr
}
// reloadOptions fetches the profiles and folders again, after the service changed.
func (v *searchView) reloadOptions() {
v.mu.Lock()
v.loaded = false
v.mu.Unlock()
go v.loadOptions()
}
// loadOptions checks the connection and fetches the data the add form needs. // loadOptions checks the connection and fetches the data the add form needs.
func (v *searchView) loadOptions() { func (v *searchView) loadOptions() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
@@ -301,8 +316,8 @@ func (v *searchView) add(i int) {
lSearch = "Search now" lSearch = "Search now"
) )
form := tview.NewForm(). form := tview.NewForm().
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil). AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil) AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
height := 13 height := 13
if len(modes) > 0 { if len(modes) > 0 {
form.AddDropDown(lEpisode, modeNames, 0, nil) form.AddDropDown(lEpisode, modeNames, 0, nil)
+487
View File
@@ -0,0 +1,487 @@
package ui
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
)
// settingsValues are the settings as shown in the Config view.
type settingsValues struct {
radarrURL, radarrKey, radarrProfile, radarrFolder string
sonarrURL, sonarrKey, sonarrProfile, sonarrFolder string
sabURL, sabKey string
refresh string // seconds, as typed
covers bool
coverStyle string
notify, bell bool
theme string
}
var (
coverStyles = []string{"auto", "kitty", "sextant", "quadrant", "cells"}
themes = []string{"auto", "dark", "light"}
)
// settingsFrom reads the settings in use.
func settingsFrom(c *config.Config) settingsValues {
style, th := c.UI.CoverStyle, c.UI.Theme
if style == "" {
style = "auto"
}
if th == "" {
th = "auto"
}
return settingsValues{
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
sonarrURL: c.Sonarr.URL, sonarrKey: c.Sonarr.APIKey,
sonarrProfile: c.Sonarr.DefaultQualityProfile, sonarrFolder: c.Sonarr.DefaultRootFolder,
sabURL: c.SABnzbd.URL, sabKey: c.SABnzbd.APIKey,
refresh: strconv.Itoa(c.UI.RefreshSeconds),
covers: c.UI.ShowCovers(), coverStyle: style,
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
theme: th,
}
}
// applySettings returns raw (the config file's contents) with the edited
// values. Settings the environment overrides are left as the file has them, so
// a value that only came from the environment is never written to disk. The
// result is checked the way the program checks its configuration.
func applySettings(raw config.Config, v settingsValues, overridden map[string]string) (config.Config, error) {
set := func(key string, dst *string, value string) {
if _, env := overridden[key]; !env {
*dst = strings.TrimSpace(value)
}
}
set("radarr.url", &raw.Radarr.URL, v.radarrURL)
set("radarr.apikey", &raw.Radarr.APIKey, v.radarrKey)
set("sonarr.url", &raw.Sonarr.URL, v.sonarrURL)
set("sonarr.apikey", &raw.Sonarr.APIKey, v.sonarrKey)
set("sabnzbd.url", &raw.SABnzbd.URL, v.sabURL)
set("sabnzbd.apikey", &raw.SABnzbd.APIKey, v.sabKey)
raw.Radarr.DefaultQualityProfile = strings.TrimSpace(v.radarrProfile)
raw.Radarr.DefaultRootFolder = strings.TrimSpace(v.radarrFolder)
raw.Sonarr.DefaultQualityProfile = strings.TrimSpace(v.sonarrProfile)
raw.Sonarr.DefaultRootFolder = strings.TrimSpace(v.sonarrFolder)
n, err := strconv.Atoi(strings.TrimSpace(v.refresh))
if err != nil || n < 1 || n > 3600 {
return raw, fmt.Errorf("refresh interval: %q is not a number of seconds between 1 and 3600", v.refresh)
}
raw.UI.RefreshSeconds = n
// Defaults stay out of the file unless the file already named them.
raw.UI.Covers = boolSetting(raw.UI.Covers, v.covers, true)
raw.UI.NotifyDownloads = boolSetting(raw.UI.NotifyDownloads, v.notify, true)
raw.UI.Bell = v.bell
raw.UI.CoverStyle = v.coverStyle
if v.coverStyle == "auto" {
raw.UI.CoverStyle = ""
}
raw.UI.Theme = v.theme
if v.theme == "auto" {
raw.UI.Theme = ""
}
if _, err := raw.Effective(os.Getenv); err != nil {
return raw, err
}
return raw, nil
}
// boolSetting keeps an optional boolean out of the file while it equals its
// default and was not in the file before.
func boolSetting(old *bool, value, def bool) *bool {
if old == nil && value == def {
return nil
}
return &value
}
// settingsView edits the configuration file's settings. Everything applies
// right after saving.
type settingsView struct {
app *App
root *tview.Flex
form *tview.Form // every field, one below the other, with section headings
testItem *formAction // the Test button, below the service settings
actions *tview.Form // the buttons below it
groups []*tview.Form // keyboard order: the fields, then the buttons
status *tview.TextView
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
choices map[string]*serverChoice // "radarr.profile", "radarr.folder", ...
// Loading the choices from the servers, per service.
choiceTimers map[string]*time.Timer
choiceGen map[string]int
choiceErr map[string]string // last error reported, so it is not repeated
refresh *tview.InputField
covers *tview.Checkbox
style *tview.DropDown
theme *tview.DropDown
notify *tview.Checkbox
bell *tview.Checkbox
showKeys *tview.Checkbox
overridden map[string]string
dirty bool
filling bool // set while the fields are filled, so that does not count as an edit
}
func settingsHints() string {
return "↑↓ move through the fields · Space toggles or presses a button · Tab jumps between fields and Save/Revert"
}
func newSettingsView(app *App) *settingsView {
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv),
choices: map[string]*serverChoice{}, choiceTimers: map[string]*time.Timer{}, choiceGen: map[string]int{}, choiceErr: map[string]string{}}
quiet := func() func() {
was := v.filling
v.filling = true
return func() { v.filling = was }
}
// One form with every field below the other: ↑↓ moves through all of them
// and the form scrolls to keep the focused field in view.
f := tview.NewForm().SetItemPadding(0)
f.SetBorder(true).SetTitle(" Settings ")
f.SetBorderPadding(0, 0, 1, 1) // one row per field
v.form = f
input := func(label, key string) {
in := tview.NewInputField().SetLabel(label)
if strings.HasSuffix(key, ".apikey") {
in.SetMaskCharacter('*')
}
if _, ok := v.overridden[key]; ok {
in.SetLabel(label + " (env)")
in.SetDisabled(true)
}
in.SetChangedFunc(func(string) {
v.edited()
// New address or key: ask that server for its profiles and folders.
if service, field, _ := strings.Cut(key, "."); service != "sabnzbd" && !v.filling && (field == "url" || field == "apikey") {
v.scheduleChoices(service)
}
})
f.AddFormItem(in)
v.inputs[key] = in
}
choice := func(label, key string) {
c := newServerChoice(label, quiet, v.edited)
f.AddFormItem(c)
v.choices[key] = c
}
service := func(title, name string, withDefaults bool) {
f.AddFormItem(newFormHeading(title))
input("Address", name+".url")
input("API key", name+".apikey")
if withDefaults {
choice("Default quality profile", name+".profile")
choice("Default root folder", name+".folder")
}
}
service("Radarr", "radarr", true)
service("Sonarr", "sonarr", true)
service("SABnzbd", "sabnzbd", false)
// Right below the connection settings it checks.
v.testItem = newFormAction("Test", v.test)
f.AddFormItem(v.testItem)
f.AddFormItem(newFormHeading("Interface"))
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
v.refresh.SetChangedFunc(func(string) { v.edited() })
f.AddFormItem(v.refresh)
v.covers = tview.NewCheckbox().SetLabel("Posters").SetChangedFunc(func(bool) { v.edited() })
f.AddFormItem(v.covers)
v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() })
f.AddFormItem(v.style)
v.theme = tview.NewDropDown().SetLabel("Theme").SetOptions(themes, func(string, int) { v.edited() })
f.AddFormItem(v.theme)
v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() })
f.AddFormItem(v.notify)
v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() })
f.AddFormItem(v.bell)
v.showKeys = tview.NewCheckbox().SetLabel("Show API keys").SetChangedFunc(func(shown bool) { v.maskKeys(!shown) })
f.AddFormItem(v.showKeys)
v.actions = tview.NewForm().SetItemPadding(0)
v.actions.SetBorder(true).SetTitle(" Actions ")
v.actions.SetBorderPadding(0, 0, 1, 1)
v.actions.SetButtonsAlign(tview.AlignCenter)
v.actions.AddButton("Save", v.save)
v.actions.AddButton("Revert", v.revert)
v.status = tview.NewTextView().SetDynamicColors(true).SetWordWrap(true)
v.status.SetBorder(true).SetTitle(" Result ")
left := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.form, 0, 1, true).
AddItem(v.actions, 4, 0, false)
v.root = tview.NewFlex().AddItem(left, 0, 3, true).AddItem(v.status, 0, 2, false)
v.groups = []*tview.Form{v.form, v.actions}
for i, g := range v.groups {
i := i
arrowNavigation(g, func() {}, &browseHooks{
// ↓ past the last field reaches the buttons, ↑ from them goes back.
edge: func(down bool) bool {
if down && i+1 < len(v.groups) {
v.focusGroup(i+1, false)
return true
}
if !down && i > 0 {
v.focusGroup(i-1, true)
return true
}
return false
},
tab: func(dir int) bool {
v.focusGroup((i+dir+len(v.groups))%len(v.groups), false)
return true
},
})
}
v.fill(settingsFrom(app.cfg))
v.maskKeys(true)
v.describe()
return v
}
// focusGroup moves the focus to a group, to its first or last field.
func (v *settingsView) focusGroup(i int, last bool) {
// tview only blurs the leaf that had the focus, not the other items of a
// form or the form itself; a form that keeps thinking it has focus would
// take the key presses.
for j, other := range v.groups {
if j != i {
blurForm(other)
}
}
g := v.groups[i]
idx := firstFocusable(g)
if last {
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
}
g.SetFocus(idx)
v.app.app.SetFocus(g)
}
// blurForm takes the focus away from a form and everything in it.
func blurForm(f *tview.Form) {
for i := 0; i < f.GetFormItemCount(); i++ {
f.GetFormItem(i).Blur()
}
for i := 0; i < f.GetButtonCount(); i++ {
f.GetButton(i).Blur()
}
f.Blur()
}
func (v *settingsView) edited() {
if !v.filling {
v.dirty = true
}
}
// maskKeys hides or shows the API keys.
func (v *settingsView) maskKeys(hide bool) {
for key, in := range v.inputs {
if strings.HasSuffix(key, ".apikey") {
if hide {
in.SetMaskCharacter('*')
} else {
in.SetMaskCharacter(0)
}
}
}
}
// fill puts the values into the fields.
func (v *settingsView) fill(s settingsValues) {
v.filling = true
defer func() { v.filling = false }()
set := map[string]string{
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey,
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey,
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
}
for key, value := range set {
v.inputs[key].SetText(value)
}
for key, value := range map[string]string{
"radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
"sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
} {
v.choices[key].set(value)
}
v.refresh.SetText(s.refresh)
v.covers.SetChecked(s.covers)
for i, name := range coverStyles {
if name == s.coverStyle {
v.style.SetCurrentOption(i)
}
}
for i, name := range themes {
if name == s.theme {
v.theme.SetCurrentOption(i)
}
}
v.notify.SetChecked(s.notify)
v.bell.SetChecked(s.bell)
v.dirty = false
}
// read collects the values from the fields.
func (v *settingsView) read() settingsValues {
text := func(key string) string {
if c, ok := v.choices[key]; ok {
return c.value
}
return v.inputs[key].GetText()
}
i, _ := v.style.GetCurrentOption()
style := "auto"
if i >= 0 && i < len(coverStyles) {
style = coverStyles[i]
}
th := "auto"
if i, _ := v.theme.GetCurrentOption(); i >= 0 && i < len(themes) {
th = themes[i]
}
return settingsValues{
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
sonarrURL: text("sonarr.url"), sonarrKey: text("sonarr.apikey"),
sonarrProfile: text("sonarr.profile"), sonarrFolder: text("sonarr.folder"),
sabURL: text("sabnzbd.url"), sabKey: text("sabnzbd.apikey"),
refresh: v.refresh.GetText(),
covers: v.covers.IsChecked(), coverStyle: style,
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
theme: th,
}
}
// describe writes the standing information into the result box.
func (v *settingsView) describe() {
var b strings.Builder
if v.app.cfgPath == "" {
b.WriteString("[yellow]No config file is known, changes cannot be saved.[-]\n")
} else {
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
}
b.WriteString("Changes take effect when you save; nothing needs a restart.\n")
if !v.app.cfg.AnyConfigured() {
b.WriteString("[yellow]No service is set up yet.[-] Enter the address and API key of Radarr, Sonarr and/or SABnzbd (Settings → General in each app), then Save. A service left empty is simply not used.\n")
}
if len(v.overridden) > 0 {
var names []string
for _, env := range v.overridden {
names = append(names, env)
}
b.WriteString("[gray]Greyed-out fields come from the environment (" + strings.Join(names, ", ") + ") and are not written to the file.[-]\n")
}
v.status.SetText(b.String())
}
// activate refreshes the fields when the view is shown, unless they hold unsaved edits.
func (v *settingsView) activate() {
if !v.dirty {
v.fill(settingsFrom(v.app.cfg))
}
v.loadChoices("radarr")
v.loadChoices("sonarr")
v.focusGroup(0, false)
}
// report appends a line to the result box.
func (v *settingsView) report(line string) {
fmt.Fprintln(v.status, line)
v.status.ScrollToEnd()
}
func (v *settingsView) revert() {
v.fill(settingsFrom(v.app.cfg))
v.loadChoices("radarr")
v.loadChoices("sonarr")
v.describe()
v.report("[green]Reverted to the saved settings.[-]")
}
// test checks the three services with the values in the fields, without saving.
func (v *settingsView) test() {
s := v.read()
v.describe()
v.report("Testing the connections…")
type probeFn func(ctx context.Context) (string, error)
tests := []struct {
name string
run probeFn
}{
{"Radarr", func(ctx context.Context) (string, error) {
st, err := arr.NewClient(strings.TrimSpace(s.radarrURL), strings.TrimSpace(s.radarrKey)).SystemStatus(ctx)
return st.Version, err
}},
{"Sonarr", func(ctx context.Context) (string, error) {
st, err := arr.NewClient(strings.TrimSpace(s.sonarrURL), strings.TrimSpace(s.sonarrKey)).SystemStatus(ctx)
return st.Version, err
}},
{"SABnzbd", func(ctx context.Context) (string, error) {
return sabnzbd.NewClient(strings.TrimSpace(s.sabURL), strings.TrimSpace(s.sabKey)).Version(ctx)
}},
}
for _, t := range tests {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version, err := t.run(ctx)
v.app.post(func() {
if err != nil {
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
return
}
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
})
}()
}
}
// save writes the config file and applies the settings.
func (v *settingsView) save() {
v.describe()
if v.app.cfgPath == "" || v.app.cfgFile == nil {
v.report("[red]There is no config file to save to.[-]")
return
}
raw, err := applySettings(*v.app.cfgFile, v.read(), v.overridden)
if err != nil {
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
return
}
if err := config.Save(v.app.cfgPath, &raw); err != nil {
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
return
}
effective, err := raw.Effective(os.Getenv)
if err != nil { // cannot happen: applySettings validated it
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
return
}
v.app.cfgFile = &raw
v.app.applyConfig(effective)
v.dirty = false
v.describe() // what it says may depend on the settings (e.g. nothing set up yet)
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
v.app.Notify(levelSuccess, "Settings saved")
}
+182
View File
@@ -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)
}
+450
View File
@@ -0,0 +1,450 @@
package ui
import (
"errors"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
)
func baseRaw() config.Config {
return config.Config{
Radarr: config.Arr{Service: config.Service{URL: "http://r:7878", APIKey: "rk"}},
Sonarr: config.Arr{Service: config.Service{URL: "http://s:8989", APIKey: "sk"}},
SABnzbd: config.Service{URL: "http://a:8080", APIKey: "ak"},
}
}
func TestApplySettings(t *testing.T) {
raw := baseRaw()
v := settingsFrom(mustEffective(t, raw))
if v.refresh != "3" || !v.covers || !v.notify || v.bell || v.coverStyle != "auto" {
t.Fatalf("defaults shown wrong: %+v", v)
}
// Unchanged settings add nothing but the refresh interval to the file.
got, err := applySettings(raw, v, nil)
if err != nil {
t.Fatal(err)
}
if got.UI.Covers != nil || got.UI.NotifyDownloads != nil || got.UI.CoverStyle != "" || got.UI.Bell {
t.Errorf("defaults written to the file: %+v", got.UI)
}
// Edits are applied and whitespace is trimmed.
v.radarrURL = " http://new:7878 "
v.radarrProfile = " HD-1080p "
v.sabKey = "newkey"
v.refresh = "10"
v.covers = false
v.notify = false
v.bell = true
v.coverStyle = "quadrant"
v.theme = "light"
got, err = applySettings(raw, v, nil)
if err != nil {
t.Fatal(err)
}
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" ||
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" || got.UI.Theme != "light" {
t.Errorf("edits lost: %+v", got)
}
// A setting that was explicit in the file stays explicit, even at its default.
yes := true
raw.UI.Covers = &yes
v.covers = true
got, _ = applySettings(raw, v, nil)
if got.UI.Covers == nil || !*got.UI.Covers {
t.Error("an explicit setting was dropped")
}
}
func TestApplySettingsValidates(t *testing.T) {
v := settingsFrom(mustEffective(t, baseRaw()))
for name, mutate := range map[string]func(*settingsValues){
"refresh not a number": func(v *settingsValues) { v.refresh = "often" },
"refresh zero": func(v *settingsValues) { v.refresh = "0" },
"refresh too large": func(v *settingsValues) { v.refresh = "99999" },
"address not http": func(v *settingsValues) { v.sonarrURL = "ftp://x" },
"empty api key": func(v *settingsValues) { v.radarrKey = " " },
"empty address": func(v *settingsValues) { v.sabURL = "" },
} {
w := v
mutate(&w)
if _, err := applySettings(baseRaw(), w, nil); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
func TestApplySettingsKeepsEnvironmentValuesOutOfTheFile(t *testing.T) {
raw := baseRaw()
// The fields show the key from the environment...
shown := settingsFrom(mustEffective(t, raw))
shown.radarrKey = "secret-from-env"
shown.radarrURL = "http://typed-over-env"
got, err := applySettings(raw, shown, map[string]string{"radarr.apikey": "ARR_TUI_RADARR_APIKEY", "radarr.url": "ARR_TUI_RADARR_URL"})
if err != nil {
t.Fatal(err)
}
// ...but the file keeps what it had.
if got.Radarr.APIKey != "rk" || got.Radarr.URL != "http://r:7878" {
t.Errorf("environment values leaked into the file: %+v", got.Radarr)
}
}
func mustEffective(t *testing.T, raw config.Config) *config.Config {
t.Helper()
eff, err := raw.Effective(func(string) string { return "" })
if err != nil {
t.Fatal(err)
}
return eff
}
func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
t.Setenv("ARR_TUI_RADARR_APIKEY", "") // make sure the environment does not interfere
var gotKey atomic.Value // the app also probes the server in the background
radarr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotKey.Store(r.Header.Get("X-Api-Key"))
w.Write([]byte(`{"version":"6.0"}`))
}))
defer radarr.Close()
a := newTestApp(t)
path := filepath.Join(t.TempDir(), "config.json")
raw := baseRaw()
if err := config.Save(path, &raw); err != nil {
t.Fatal(err)
}
loaded, _ := config.LoadFile(path)
a.SetConfigSource(path, loaded)
v := a.settings
v.fill(settingsFrom(a.cfg))
// Edit the fields like a user would and save.
v.inputs["radarr.url"].SetText(radarr.URL)
v.inputs["radarr.apikey"].SetText("brand-new-key")
v.choices["sonarr.profile"].set("Any")
v.edited()
v.refresh.SetText("7")
v.bell.SetChecked(true)
if !v.dirty {
t.Error("editing must mark the form as changed")
}
v.save()
// 1. The file has the new values and stays private.
saved, err := config.LoadFile(path)
if err != nil {
t.Fatal(err)
}
if saved.Radarr.URL != radarr.URL || saved.Radarr.APIKey != "brand-new-key" || saved.Sonarr.DefaultQualityProfile != "Any" || saved.UI.RefreshSeconds != 7 || !saved.UI.Bell {
t.Errorf("file = %+v", saved)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
t.Errorf("mode %v", info.Mode().Perm())
}
// 2. The running app uses them at once.
if a.cfg.Radarr.URL != radarr.URL || !a.bell || a.cfg.UI.RefreshSeconds != 7 {
t.Errorf("settings not applied: %+v", a.cfg)
}
if _, err := a.radarr.SystemStatus(t.Context()); err != nil || gotKey.Load() != "brand-new-key" {
t.Errorf("the client still talks to the old instance: key=%v err=%v", gotKey.Load(), err)
}
if got := a.downloads.interval.Seconds(); got != 7 {
t.Errorf("downloads interval = %v", got)
}
if v.dirty {
t.Error("saving must clear the changed mark")
}
if !strings.Contains(v.status.GetText(false), "Saved") {
t.Errorf("result box: %q", v.status.GetText(false))
}
// An invalid edit is refused and leaves the file alone.
before, _ := os.ReadFile(path)
v.refresh.SetText("soon")
v.save()
after, _ := os.ReadFile(path)
if string(before) != string(after) || !strings.Contains(v.status.GetText(false), "Not saved") {
t.Errorf("invalid settings were saved: %q", v.status.GetText(false))
}
// Revert goes back to what is in use.
v.revert()
if v.refresh.GetText() != "7" || v.dirty {
t.Errorf("revert: refresh=%q dirty=%v", v.refresh.GetText(), v.dirty)
}
}
func TestSettingsViewWithoutConfigFile(t *testing.T) {
a := newTestApp(t)
a.settings.save()
if !strings.Contains(a.settings.status.GetText(false), "no config file") {
t.Errorf("result box: %q", a.settings.status.GetText(false))
}
}
func TestSettingsViewIsOneListNavigatedWithArrows(t *testing.T) {
a := newTestApp(t)
v := a.settings
var focus func(p tview.Primitive)
focus = func(p tview.Primitive) { p.Focus(focus) }
// Whatever focus flags were left behind, moving to the buttons must leave exactly one group focused.
v.focusGroup(0, false)
v.form.Focus(focus)
v.actions.Focus(focus)
v.focusGroup(1, false)
if v.form.HasFocus() || !v.actions.HasFocus() {
t.Error("only the buttons may have the focus")
}
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
focusedItem := func() int { i, _ := v.form.GetFocusedItemIndex(); return i }
labelAt := func(i int) string { return v.form.GetFormItem(i).GetLabel() }
// The list starts on the first real field, not on the "Radarr" heading.
v.focusGroup(0, false)
if i := focusedItem(); labelAt(i) != "Address" || i != 1 {
t.Fatalf("starts on item %d (%q)", i, labelAt(i))
}
// ↓ walks through the fields of a section and over the next heading into the next section.
for _, want := range []string{"API key", "Default quality profile", "Default root folder", "Address"} {
send(v.form, tcell.KeyDown)
if got := labelAt(focusedItem()); got != want {
t.Fatalf("after ↓ on %q, want %q", got, want)
}
}
if focusedItem() != 6 { // 5 is the Sonarr heading
t.Errorf("↓ must skip the section heading, now on item %d", focusedItem())
}
send(v.form, tcell.KeyUp)
send(v.form, tcell.KeyUp)
if focusedItem() != 3 && focusedItem() != 4 {
t.Errorf("↑ must also skip the heading, now on item %d", focusedItem())
}
// ↓ on the very last field moves to the buttons; ↑ on a button goes back to the last field.
v.focusGroup(0, true)
if got := labelAt(focusedItem()); got != "Show API keys" {
t.Fatalf("last field = %q", got)
}
send(v.form, tcell.KeyDown)
if !v.actions.HasFocus() || v.form.HasFocus() {
t.Error("↓ past the last field must reach the buttons")
}
send(v.actions, tcell.KeyUp)
if !v.form.HasFocus() || v.actions.HasFocus() || labelAt(focusedItem()) != "Show API keys" {
t.Error("↑ on the buttons must go back to the last field")
}
// ↑ on the first field has nowhere to go.
v.focusGroup(0, false)
send(v.form, tcell.KeyUp)
if focusedItem() != 1 {
t.Errorf("↑ on the first field moved to item %d", focusedItem())
}
}
func TestFormHeadingNeverTakesFocus(t *testing.T) {
f := tview.NewForm()
f.AddFormItem(newFormHeading("Section"))
f.AddInputField("One", "", 10, nil, nil)
f.AddFormItem(newFormHeading("Other"))
f.AddInputField("Two", "", 10, nil, nil)
if firstFocusable(f) != 1 {
t.Errorf("first focusable = %d", firstFocusable(f))
}
if neighbour(f, 1, 1) != 3 || neighbour(f, 3, -1) != 1 || neighbour(f, 3, 1) != 3 || neighbour(f, 1, -1) != 1 {
t.Error("neighbour must skip headings and stay at the ends")
}
// tview's own Tab handling passes over a heading as well.
var focus func(p tview.Primitive)
focus = func(p tview.Primitive) { p.Focus(focus) }
f.Focus(focus)
if i, _ := f.GetFocusedItemIndex(); i == 0 {
t.Error("a heading got the focus")
}
}
func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
a := newTestApp(t)
v := a.settings
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
focusedItem := func() tview.FormItem { i, _ := v.form.GetFocusedItemIndex(); return v.form.GetFormItem(i) }
// It comes right after the SABnzbd API key and before the Interface section.
v.focusGroup(0, false)
for i := 0; i < 10; i++ { // Radarr 4, Sonarr 4, SABnzbd 2 fields
send(v.form, tcell.KeyDown)
}
if focusedItem() != tview.FormItem(v.testItem) {
t.Fatalf("↓ after the SABnzbd API key reached %T %q, want the Test button", focusedItem(), focusedItem().GetLabel())
}
send(v.form, tcell.KeyDown)
if got := focusedItem().GetLabel(); got != "Downloads refresh (seconds)" {
t.Errorf("after the button comes %q", got)
}
send(v.form, tcell.KeyUp)
if focusedItem() != tview.FormItem(v.testItem) {
t.Error("↑ must come back to the button")
}
// Save and Revert stay below everything; Test is not among them.
if v.actions.GetButtonCount() != 2 || v.actions.GetButton(0).GetLabel() != "Save" || v.actions.GetButton(1).GetLabel() != "Revert" {
t.Errorf("actions bar has %d buttons", v.actions.GetButtonCount())
}
// Space and Enter press it.
var focus func(p tview.Primitive)
focus = func(p tview.Primitive) { p.Focus(focus) }
for _, ev := range []*tcell.EventKey{tcell.NewEventKey(tcell.KeyRune, ' ', 0), tcell.NewEventKey(tcell.KeyEnter, 0, 0)} {
v.describe()
v.testItem.InputHandler()(ev, focus)
if !strings.Contains(v.status.GetText(false), "Testing the connections") {
t.Errorf("key %v did not start the test: %q", ev.Name(), v.status.GetText(false))
}
}
}
func TestHelpOverConfigTakesTheFocus(t *testing.T) {
a := newTestApp(t)
a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0))
moveDown := a.settings.form.GetInputCapture()
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
field := a.settings.inputs["radarr.apikey"]
if a.app.GetFocus() != field {
t.Fatalf("after ↓ the application's focus is %T, not the second field", a.app.GetFocus())
}
a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0))
// The field must not keep the focus behind the help: it would draw its
// cursor through the window, and the form would take the help's keys.
if field.HasFocus() || a.settings.form.HasFocus() {
t.Errorf("behind the help: field focused %v, form focused %v", field.HasFocus(), a.settings.form.HasFocus())
}
if _, ok := a.app.GetFocus().(*tview.TextView); !ok {
t.Errorf("help has no focus: %T", a.app.GetFocus())
}
a.closeModal()
if a.app.GetFocus() != field || !field.HasFocus() {
t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus())
}
}
func TestDefaultsAreChosenFromTheServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Header.Get("X-Api-Key") != "key":
w.WriteHeader(http.StatusUnauthorized)
case strings.HasSuffix(r.URL.Path, "/qualityprofile"):
w.Write([]byte(`[{"id":1,"name":"Any"},{"id":4,"name":"HD-1080p"}]`))
case strings.HasSuffix(r.URL.Path, "/rootfolder"):
w.Write([]byte(`[{"id":1,"path":"/movies"}]`))
}
}))
defer srv.Close()
profiles, folders, err := fetchChoices(t.Context(), srv.URL, "key")
if err != nil || strings.Join(profiles, ",") != "Any,HD-1080p" || strings.Join(folders, ",") != "/movies" {
t.Fatalf("fetched %v %v %v", profiles, folders, err)
}
if _, _, err := fetchChoices(t.Context(), srv.URL, "wrong"); err == nil {
t.Error("a wrong key must fail")
}
a := newTestApp(t)
v := a.settings
p := v.choices["radarr.profile"]
p.set("Old profile") // configured, but not on the server
v.dirty = false
v.choiceGen["radarr"] = 1
v.applyChoices("radarr", 1, profiles, folders, nil)
n := p.GetOptionCount()
var labels []string
for i := 0; i < n; i++ {
labels = append(labels, p.valueAt(i))
}
if strings.Join(labels, "|") != "|Any|HD-1080p|Old profile" {
t.Errorf("options %q", labels)
}
if _, text := p.GetCurrentOption(); text != "Old profile (not on the server)" || p.value != "Old profile" {
t.Errorf("selected %q, value %q", text, p.value)
}
if v.dirty {
t.Error("loading the options is not an edit")
}
// Choosing one is an edit and is what gets saved.
p.SetCurrentOption(2)
if p.value != "HD-1080p" || !v.dirty || v.read().radarrProfile != "HD-1080p" {
t.Errorf("chosen %q dirty %v", p.value, v.dirty)
}
// An answer for an address that has changed since is dropped.
v.choiceGen["radarr"] = 2
v.applyChoices("radarr", 1, nil, nil, nil)
if p.GetOptionCount() != n {
t.Error("stale answer applied")
}
// Typing a new address asks that server again (after a pause); SABnzbd has no such lists.
v.inputs["sonarr.url"].SetText("http://elsewhere:8989")
v.inputs["sabnzbd.url"].SetText("http://elsewhere:8080")
if v.choiceTimers["sonarr"] == nil || v.choiceTimers["sabnzbd"] != nil {
t.Errorf("reload scheduled: %v", v.choiceTimers)
}
v.choiceTimers["sonarr"].Stop() // the test has no event loop to deliver it
// Without the server only none and the value in use are offered.
v.applyChoices("radarr", 2, nil, nil, errors.New("connection refused"))
if p.GetOptionCount() != 2 || !strings.Contains(v.status.GetText(false), "connection refused") {
t.Errorf("offline: %d options, result %q", p.GetOptionCount(), v.status.GetText(false))
}
}
func TestServerChoiceWorksWithTheArrowKeys(t *testing.T) {
a := newTestApp(t)
v := a.settings
p := v.choices["radarr.profile"]
v.choiceGen["radarr"] = 1
v.applyChoices("radarr", 1, []string{"Any", "HD-1080p"}, nil, nil)
v.focusGroup(0, false)
keys := v.form.GetInputCapture()
send := func(k tcell.Key) { keys(tcell.NewEventKey(k, 0, 0)) }
send(tcell.KeyDown) // API key
send(tcell.KeyDown) // Default quality profile
if item, _ := v.form.GetFocusedItemIndex(); v.form.GetFormItem(item) != p {
t.Fatalf("on %q", v.form.GetFormItem(item).GetLabel())
}
// → and ← change the value, like on every other dropdown.
send(tcell.KeyRight)
send(tcell.KeyRight)
if p.value != "HD-1080p" || !v.dirty {
t.Errorf("→→: value %q, dirty %v", p.value, v.dirty)
}
send(tcell.KeyLeft)
if p.value != "Any" {
t.Errorf("←: value %q", p.value)
}
// While its list is open, ↑↓ belong to the list, not to the form.
var focus func(tview.Primitive)
focus = func(q tview.Primitive) { q.Focus(focus) }
p.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), focus)
if !p.IsOpen() {
t.Fatal("Enter does not open the list")
}
if got := keys(tcell.NewEventKey(tcell.KeyDown, 0, 0)); got == nil {
t.Error("↓ in the open list was taken by the form")
}
}
+70
View File
@@ -0,0 +1,70 @@
package ui
import (
"bytes"
"image/color"
"strconv"
"strings"
"sync"
"time"
)
// terminalInfo is what the terminal said about itself at startup.
type terminalInfo struct {
graphics bool // the Kitty graphics protocol is supported
bg color.RGBA // its background color, if hasBG
hasBG bool
}
var (
terminalOnce sync.Once
terminalResult terminalInfo
)
// terminalAnswers asks the terminal once (see probeTerminal) and remembers the
// answers. The first call must happen before the screen is set up, which owns
// the terminal afterwards; New makes it. It is a variable so tests can replace it.
var terminalAnswers = func() terminalInfo {
terminalOnce.Do(func() { terminalResult = probeTerminal(300 * time.Millisecond) })
return terminalResult
}
// parseTerminalAnswers reads the answers to probeTerminal's questions.
func parseTerminalAnswers(got []byte) (info terminalInfo) {
info.graphics = bytes.Contains(got, []byte("_Gi=31;OK"))
info.bg, info.hasBG = parseColorAnswer(got, "11")
return info
}
// parseColorAnswer finds the answer to an OSC color query such as 11
// (background): ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each
// component has 1 to 4 hex digits.
func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) {
prefix := []byte("\x1b]" + osc + ";rgb:")
i := bytes.Index(got, prefix)
if i < 0 {
return color.RGBA{}, false
}
rest := got[i+len(prefix):]
if end := bytes.IndexAny(rest, "\x1b\a"); end >= 0 {
rest = rest[:end]
}
parts := strings.Split(string(rest), "/")
if len(parts) != 3 {
return color.RGBA{}, false
}
var c [3]uint8
for j, p := range parts {
v, err := strconv.ParseUint(p, 16, 16)
if err != nil || len(p) < 1 || len(p) > 4 {
return color.RGBA{}, false
}
c[j] = uint8(v * 255 / (1<<(4*len(p)) - 1))
}
return color.RGBA{c[0], c[1], c[2], 255}, true
}
// luminance is the relative brightness of c, from 0 (black) to 1 (white).
func luminance(c color.RGBA) float64 {
return (0.2126*float64(c.R) + 0.7152*float64(c.G) + 0.0722*float64(c.B)) / 255
}
+132
View File
@@ -0,0 +1,132 @@
package ui
import (
"image/color"
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func TestParseTerminalAnswers(t *testing.T) {
for _, tc := range []struct {
name string
got string
graphics bool
bg color.RGBA
hasBG bool
}{
{"ghostty", "\x1b_Gi=31;OK\x1b\\\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\\x1b[?62;22c", true, color.RGBA{30, 30, 46, 255}, true},
{"text color too", "\x1b]10;rgb:4c4c/4f4f/6969\x1b\\\x1b]11;rgb:efef/f1f1/f5f5\x1b\\\x1b[?62c", false, color.RGBA{239, 241, 245, 255}, true},
{"bel and two digits", "\x1b]11;rgb:ff/80/00\a\x1b[?1;2c", false, color.RGBA{255, 128, 0, 255}, true},
{"one digit", "\x1b]11;rgb:f/0/8\x1b\\\x1b[?6c", false, color.RGBA{255, 0, 136, 255}, true},
{"only device attributes", "\x1b[?1;2c", false, color.RGBA{}, false},
{"garbage", "\x1b]11;rgb:zz/00/00\x1b\\\x1b[?6c", false, color.RGBA{}, false},
{"nothing", "", false, color.RGBA{}, false},
} {
info := parseTerminalAnswers([]byte(tc.got))
if info.graphics != tc.graphics || info.hasBG != tc.hasBG || info.bg != tc.bg {
t.Errorf("%s: %+v", tc.name, info)
}
}
}
func TestChooseTheme(t *testing.T) {
dark := terminalInfo{bg: color.RGBA{30, 30, 46, 255}, hasBG: true}
light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true}
for _, tc := range []struct {
setting string
info terminalInfo
want string
}{
{"", light, "light"},
{"auto", dark, "dark"},
{"auto", terminalInfo{}, "dark"},
{"dark", light, "dark"},
{"light", dark, "light"},
} {
if th, note := chooseTheme(tc.setting, tc.info); th.name != tc.want {
t.Errorf("%q with %+v: %s (%s)", tc.setting, tc.info, th.name, note)
}
}
}
func TestUseTokens(t *testing.T) {
oldStyles := tview.Styles
oldNames := map[string]tcell.Color{}
for k, v := range tcell.ColorNames {
oldNames[k] = v
}
defer func() {
tview.Styles = oldStyles
for k, v := range oldNames {
tcell.ColorNames[k] = v
}
}()
useTokens()
if tview.Styles.PrimitiveBackgroundColor != tokens.bg || tview.Styles.PrimaryTextColor != tokens.text {
t.Error("tview styles not set")
}
// Color names in texts mean the roles.
if named("yellow") != tokens.yellow || tcell.GetColor("black") != tokens.bg || tcell.GetColor("white") != tokens.text {
t.Error("color names not set")
}
}
func TestThemedScreenSwitchesLive(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(4, 1)
s := newThemedScreen(sim, darkTheme)
poster := tcell.NewRGBColor(10, 20, 30)
draw := func() {
s.SetContent(0, 0, 'a', nil, tcell.StyleDefault.Foreground(tokens.yellow).Background(tokens.bg))
s.SetContent(1, 0, 'b', nil, tcell.StyleDefault.Foreground(poster).Background(poster))
}
check := func(th theme) {
_, _, st, _ := sim.GetContent(0, 0)
if fg, bg, _ := st.Decompose(); fg != th.yellow || bg != th.bg {
t.Errorf("%s: role colors drawn as %v on %v", th.name, fg, bg)
}
_, _, st, _ = sim.GetContent(1, 0)
if fg, bg, _ := st.Decompose(); fg != poster || bg != poster {
t.Errorf("%s: other colors changed to %v on %v", th.name, fg, bg)
}
}
draw()
check(darkTheme)
s.setTheme(lightTheme)
draw()
check(lightTheme)
// A poster color that equals a role color is nudged off it.
if c := notToken(tokens.red); c == tokens.red {
t.Error("role color in a picture not nudged")
}
if notToken(poster) != poster {
t.Error("ordinary color changed")
}
}
func TestThemeColorsSurvive256Colors(t *testing.T) {
// Every theme color is its own nearest match in the 256-color palette, so
// a terminal without 24-bit color shows it exactly and never picks one of
// the first 16 colors, which color themes change.
palette := make([]tcell.Color, 256)
for i := range palette {
palette[i] = tcell.PaletteColor(i)
}
for _, th := range []theme{darkTheme, lightTheme} {
for _, c := range []tcell.Color{th.bg, th.text, th.border, th.field, th.fieldMuted, th.list, th.pressed, th.toast,
th.gray, th.yellow, th.green, th.red, th.blue, th.purple} {
if got := tcell.FindColor(c, palette); got < tcell.PaletteColor(16) {
t.Errorf("%s theme: %v maps to palette color %d", th.name, c, got-tcell.ColorValid)
}
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package ui
import (
"image/color"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// theme is a complete set of colors, so the program looks the same whatever
// palette the terminal's color theme has (themes often make "black" a grey
// that is not their background, and light themes put dark grey slabs under
// white text). Every color is one of the fixed entries 16-255 of the
// 256-color table, so terminals without 24-bit color show exactly these too
// (the nearest match of an exact color is itself, and the first 16 entries,
// which themes change, never equal them).
type theme struct {
name string
bg, text, border tcell.Color
field, fieldMuted tcell.Color // input fields and buttons; placeholders on them
list tcell.Color // open dropdown lists (drawn with bg-colored text)
pressed tcell.Color // text of a pressed button (on the text color)
toast tcell.Color // background of the popups
// The accent colors, used by name in the texts ("[yellow]...").
gray, yellow, green, red, blue, purple tcell.Color
}
func c256(i int) tcell.Color { return tcell.PaletteColor(i).TrueColor() }
var (
darkTheme = theme{
name: "dark",
bg: c256(232), text: c256(253), border: c256(246),
field: c256(236), fieldMuted: c256(245),
list: c256(110), pressed: c256(25), toast: c256(236),
gray: c256(245), yellow: c256(221), green: c256(113), red: c256(203), blue: c256(75), purple: c256(141),
}
lightTheme = theme{
name: "light",
bg: c256(255), text: c256(234), border: c256(243),
field: c256(252), fieldMuted: c256(245),
list: c256(67), pressed: c256(255), toast: c256(252),
gray: c256(243), yellow: c256(130), green: c256(28), red: c256(160), blue: c256(26), purple: c256(91),
}
)
// chooseTheme resolves the ui.theme setting: "dark", "light", or "auto" (the
// default), which follows the background color the terminal reported and is
// dark when it did not say. The second value explains the choice for the log.
func chooseTheme(setting string, info terminalInfo) (theme, string) {
switch setting {
case "dark":
return darkTheme, "Theme: dark"
case "light":
return lightTheme, "Theme: light"
}
if !info.hasBG {
return darkTheme, "Theme: dark (auto: the terminal did not report its background color)"
}
hex := hexColor(info.bg)
if luminance(info.bg) > 0.5 {
return lightTheme, "Theme: light (auto: the terminal's background " + hex + " is light)"
}
return darkTheme, "Theme: dark (auto: the terminal's background " + hex + " is dark)"
}
// colors lists the theme's colors in a fixed order of roles.
func (t theme) colors() []tcell.Color {
return []tcell.Color{t.bg, t.text, t.border, t.field, t.fieldMuted, t.list, t.pressed, t.toast,
t.gray, t.yellow, t.green, t.red, t.blue, t.purple}
}
// tokens is the theme the widgets are built with: every role has a color of
// its own that no theme uses, and themedScreen replaces it with the color of
// the current theme while drawing. That is how the theme changes while the
// program runs: widgets copy their colors when they are created, but what they
// copy is a role, not a color.
var tokens = func() theme {
var c [14]tcell.Color
for i := range c {
c[i] = tcell.NewRGBColor(0xa5, 0x17, int32(i))
}
return theme{name: "tokens", bg: c[0], text: c[1], border: c[2], field: c[3], fieldMuted: c[4], list: c[5], pressed: c[6], toast: c[7],
gray: c[8], yellow: c[9], green: c[10], red: c[11], blue: c[12], purple: c[13]}
}()
// useTokens makes the role colors the defaults of everything created
// afterwards: tview's default styles (widgets copy them when they are created,
// so this must run before any is) and the color names used in the texts,
// which tview looks up in tcell's table: "[yellow]" is the theme's yellow,
// "white" its text color and "black" its background (the active tab is
// "[black:white]").
func useTokens() {
t := tokens
st := &tview.Styles
st.PrimitiveBackgroundColor = t.bg
st.ContrastBackgroundColor = t.field
st.MoreContrastBackgroundColor = t.list
st.BorderColor, st.TitleColor, st.GraphicsColor = t.border, t.text, t.border
st.PrimaryTextColor = t.text
st.SecondaryTextColor = t.yellow
st.TertiaryTextColor = t.green
st.InverseTextColor = t.pressed
st.ContrastSecondaryTextColor = t.fieldMuted
for name, c := range map[string]tcell.Color{
"white": t.text, "black": t.bg,
"gray": t.gray, "grey": t.gray,
"yellow": t.yellow, "green": t.green, "red": t.red,
"blue": t.blue, "purple": t.purple,
} {
tcell.ColorNames[name] = c
}
}
// themedScreen draws with the current theme: it replaces the role colors of
// everything set on the screen by the theme's colors. Other colors (posters,
// the image ids of real-image posters) pass unchanged.
type themedScreen struct {
tcell.Screen
theme theme
colors map[tcell.Color]tcell.Color
initErr error
}
func newThemedScreen(s tcell.Screen, t theme) *themedScreen {
ts := &themedScreen{Screen: s}
ts.setTheme(t)
return ts
}
// setTheme switches the colors; the next draw shows them.
func (s *themedScreen) setTheme(t theme) {
s.theme = t
s.colors = map[tcell.Color]tcell.Color{}
actual := t.colors()
for i, tok := range tokens.colors() {
s.colors[tok] = actual[i]
}
}
func (s *themedScreen) SetContent(x, y int, primary rune, combining []rune, style tcell.Style) {
fg, bg, _ := style.Decompose()
if c, ok := s.colors[fg]; ok {
style = style.Foreground(c)
}
if c, ok := s.colors[bg]; ok {
style = style.Background(c)
}
s.Screen.SetContent(x, y, primary, combining, style)
}
// Init remembers the error, which tview's SetScreen drops.
func (s *themedScreen) Init() error {
s.initErr = s.Screen.Init()
return s.initErr
}
// notToken nudges a picture's color that happens to equal a role color, so
// themedScreen does not replace it.
func notToken(c tcell.Color) tcell.Color {
r, g, b := c.RGB()
if r == 0xa5 && g == 0x17 && b < int32(len(tokens.colors())) {
return tcell.NewRGBColor(r, g+1, b)
}
return c
}
// named returns the role color for a name used in the texts.
func named(name string) tcell.Color { return tcell.GetColor(name) }
func hexColor(c color.RGBA) string {
const digits = "0123456789abcdef"
b := []byte{'#', 0, 0, 0, 0, 0, 0}
for i, v := range []uint8{c.R, c.G, c.B} {
b[1+2*i], b[2+2*i] = digits[v>>4], digits[v&15]
}
return string(b)
}
+86
View File
@@ -0,0 +1,86 @@
package ui
import (
"context"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
"github.com/rivo/tview"
)
// watchInterval is how often SABnzbd's history is checked for finished
// downloads in the background, whatever view is showing.
const watchInterval = 15 * time.Second
// downloadEvent is a download that just finished or failed.
type downloadEvent struct {
name string
failed bool
message string // failure reason, if any
}
// diffHistory compares SABnzbd's history with what was seen before and
// returns the downloads that reached a final state since. seen maps job id to
// the last known status and is updated. With baseline set (the first poll) the
// current state is only recorded, so downloads that finished before arr-tui
// started do not produce notifications.
func diffHistory(seen map[string]string, slots []sabnzbd.HistorySlot, baseline bool) []downloadEvent {
var events []downloadEvent
for _, s := range slots {
prev, known := seen[s.ID]
seen[s.ID] = s.Status
final := s.Status == "Completed" || s.Failed()
if baseline || !final || (known && prev == s.Status) {
continue
}
events = append(events, downloadEvent{name: s.Name, failed: s.Failed(), message: s.FailMessage})
}
return events
}
// watchDownloads polls SABnzbd's history until ctx ends and reports finished
// and failed downloads as popups (and a bell, if enabled). Errors are not
// reported here: the connection indicator and the log already do that.
func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client) {
seen := map[string]string{}
baseline := true
t := time.NewTicker(watchInterval)
defer t.Stop()
for {
hctx, cancel := context.WithTimeout(ctx, 10*time.Second)
slots, err := sab.History(hctx, 30)
cancel()
if err == nil {
events := diffHistory(seen, slots, baseline)
baseline = false
if len(events) > 0 {
a.post(func() { a.announce(events, a.bell) })
}
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// announce shows a popup for each event; UI goroutine only.
func (a *App) announce(events []downloadEvent, bell bool) {
for _, e := range events {
if e.failed {
text := "Download failed: " + tview.Escape(e.name)
if e.message != "" {
text += " (" + tview.Escape(truncate(e.message, 60)) + ")"
}
a.SetMessage("[red]" + text)
} else {
a.SetMessage("[green]Download finished: " + tview.Escape(e.name))
}
}
if bell {
if a.screen != nil {
a.screen.Beep()
}
}
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Publish a release on Gitea: create the release for an existing tag from its
# CHANGELOG.md section and attach the binaries built by `make dist`.
#
# scripts/release.sh [--dry-run|--check] [vX.Y.Z]
#
# The tag defaults to the one at HEAD. It must be pushed already. The token
# (Gitea: Settings -> Applications, repository scope with write access) is
# read from $GITEA_TOKEN or from the file $GITEA_TOKEN_FILE, which defaults to
# ~/.config/arr-tui-release-token. It never appears in the process list or in
# any output. The server and repository come from the "origin" remote; set
# GITEA_URL (e.g. https://git.example.com) if its web address differs.
#
# --dry-run build and show what would be published, change nothing
# --check only verify the token, the repository and that the release is free
set -euo pipefail
cd "$(dirname "$0")/.."
mode=release
tag=
for arg in "$@"; do
case $arg in
--dry-run) mode=dry ;;
--check) mode=check ;;
-h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
v*) tag=$arg ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
die() { echo "release: $*" >&2; exit 1; }
# --- repository and server from the origin remote ---------------------------
origin=$(git remote get-url origin) || die "no origin remote"
if [[ $origin =~ ^ssh://[^@]+@([^:/]+)(:[0-9]+)?/(.+)$ ]]; then
host=${BASH_REMATCH[1]} path=${BASH_REMATCH[3]}
elif [[ $origin =~ ^[^@]+@([^:]+):(.+)$ ]]; then
host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]}
elif [[ $origin =~ ^https?://([^/]+)/(.+)$ ]]; then
host=${BASH_REMATCH[1]} path=${BASH_REMATCH[2]}
else
die "cannot parse the origin remote: $origin"
fi
repo=${path%.git}
base=${GITEA_URL:-https://$host}
api=$base/api/v1/repos/$repo
# --- tag --------------------------------------------------------------------
[[ -n $tag ]] || tag=$(git tag --points-at HEAD | grep '^v' | head -n1) || true
[[ -n $tag ]] || die "no tag given and none at HEAD"
git rev-parse -q --verify "refs/tags/$tag" >/dev/null || die "tag $tag does not exist"
git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1 || die "tag $tag is not pushed to origin"
[[ $(git rev-parse "$tag^{commit}") == "$(git ls-remote origin "refs/tags/$tag^{}" | cut -f1)" ]] \
|| die "tag $tag differs from the one on origin"
# --- token ------------------------------------------------------------------
token=${GITEA_TOKEN:-}
if [[ -z $token ]]; then
file=${GITEA_TOKEN_FILE:-$HOME/.config/arr-tui-release-token}
[[ -r $file ]] || die "no token: set GITEA_TOKEN or put it in $file"
[[ $(stat -c %a "$file" 2>/dev/null || stat -f %Lp "$file") =~ ^[0-7]00$ ]] \
|| echo "release: warning: $file is readable by other users, run chmod 600" >&2
token=$(tr -d '[:space:]' < "$file")
fi
[[ -n $token ]] || die "the token is empty"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
umask 077
# curl reads the header from a file so the token is not visible in `ps`.
printf 'Authorization: token %s\n' "$token" > "$tmp/auth"
unset token
call() { curl -sS -H "@$tmp/auth" "$@"; }
# --- checks against the server ----------------------------------------------
info=$(call "$api") || die "cannot reach $base"
python3 - "$info" "$repo" <<'PY' || die "no access to $repo (token or repository wrong)"
import json, sys
d = json.loads(sys.argv[1])
if d.get("full_name", "").lower() != sys.argv[2].lower() or not d.get("permissions", {}).get("push"):
sys.exit(1)
PY
existing=$(call -o /dev/null -w '%{http_code}' "$api/releases/tags/$tag")
[[ $existing == 404 ]] || die "a release for $tag already exists (HTTP $existing)"
echo "release: $repo on $base, $tag is free"
[[ $mode == check ]] && exit 0
# --- release text from the changelog ------------------------------------------
python3 - "$tag" "$tmp/release.json" <<'PY' || die "CHANGELOG.md has no section for $tag"
import json, re, sys
tag, out = sys.argv[1], sys.argv[2]
text = open("CHANGELOG.md").read()
m = re.search(r"^## " + re.escape(tag) + r"\b[^\n]*\n(.*?)(?=^## |\Z)", text, re.S | re.M)
if not m or not m.group(1).strip():
sys.exit(1)
body = m.group(1).strip() + (
"\n\nBinaries are attached for linux, macOS (amd64 and arm64) and windows; "
"verify them with `SHA256SUMS`. Build from source with `make dist`.")
json.dump({"tag_name": tag, "name": "arr-tui " + tag, "body": body,
"draft": False, "prerelease": "-" in tag}, open(out, "w"))
PY
# --- build ----------------------------------------------------------------------
make test >/dev/null || die "tests failed"
make dist VERSION="$tag" >/dev/null
assets=(dist/*)
echo "release: built ${#assets[@]} files:"
for a in "${assets[@]}"; do echo " $(basename "$a")"; done
[[ $(./dist/arr-tui-linux-amd64 --version 2>/dev/null || true) == "arr-tui $tag" ]] \
|| echo "release: note: ./dist/arr-tui-linux-amd64 --version does not report $tag (not runnable here?)" >&2
if [[ $mode == dry ]]; then
echo "release: dry run, nothing published. Release text:"
python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["body"])' "$tmp/release.json"
exit 0
fi
# --- publish --------------------------------------------------------------------
id=$(call -H 'Content-Type: application/json' -d "@$tmp/release.json" "$api/releases" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("id") or ""); sys.exit(0 if d.get("id") else 1)') \
|| die "creating the release failed"
for a in "${assets[@]}"; do
name=$(basename "$a")
call -F "attachment=@$a" "$api/releases/$id/assets?name=$name" \
| python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("name") else 1)' \
|| die "uploading $name failed (the release $id exists without it)"
echo "release: uploaded $name"
done
echo "release: $base/$repo/releases/tag/$tag"