79 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 89541d46f9 Release v0.5.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:09:14 +01:00
nilsandClaude Opus 5.5 de1491617e webOS: open the keyboard only on OK; hovering a tab does not switch views
Text fields are read-only while the focus passes over them (webOS opens its
keyboard for every editable field that gets focus); OK or a click starts
typing, Enter, Back or up/down ends it. The pointer focuses a tab without
switching to it; a click does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:58:23 +01:00
nilsandClaude Opus 5.5 0dedcb3893 Nix flake with a dev shell for everything
nix develop: Go, gopls, shellcheck, make, curl, jq, Python, Node.js,
oxipng and ImageMagick; on Linux also Ghostty, Xvfb, xdotool, DejaVu fonts
and Chromium for the README screenshots and the webOS app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:16:05 +01:00
nilsandClaude Opus 5.5 7f6d4d07b9 webOS: a TV app with the views of arr-tui
A TypeScript web app for LG webOS TVs (webOS 5 / Chromium 68 and newer),
driven by the remote: Movies and Series search with the Trakt lists,
Library with status, title and file-property filters and download marks,
seasons and episodes, release search and grab, Downloads, Calendar,
Settings and Log. It talks to Radarr, Sonarr, SABnzbd and Trakt directly
(they answer cross-origin requests); the arr-tui config is baked in at
build time and can be changed on the TV.

dev/screenshots/server.py answers cross-origin requests and accepts writes
for it; webos/dev/drive.mjs drives the app in Chromium for screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:51:48 +01:00
nilsandClaude Opus 5.5 1015135a4b Filter dialog: Language right after Quality
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:27:12 +01:00
nilsandClaude Opus 5.5 0fcc5fb7fa Series drill-down: mark downloading episodes and seasons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:30:14 +02:00
nilsandClaude Opus 5.5 45e8daaa71 Real-image posters: scale and encode in the background
Done in Draw it took about 200 ms per new poster (BiLinear scale to the
box plus PNG encoding), so each step to another title stalled the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:22:41 +02:00
nilsandClaude Opus 5.5 f0bc284e82 Trakt lists: keep the Radarr/Sonarr lookups of their titles on disk
Library membership is asked every time via movie?tmdbId= / series?tvdbId=;
only the lookups of titles not in the library are stored (3 days).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:48:20 +02:00
nilsandClaude Opus 5.5 a70daaa0a3 Posters: fetch TheMovieDB images at the shown size, not the original
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:00:34 +02:00
nilsandClaude Opus 5.5 ca8c71fa52 Disk cache: version the poster folder
Posters stored by the previous build (the downloaded bytes, for real
images Radarr's 333x500 copy) were reused as they were for 30 days.
Posters now live in posters-v2/; older folders are deleted at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:41:11 +02:00
nilsandClaude Opus 5.5 15e97f4582 Real-image posters: scale to the box's pixel size
Ghostty shows an image smaller than its placement at its own size, so
library posters (Radarr's poster-500 is 333x500, not 500x750) came out
smaller than the 426x640 remote ones. Images are now scaled (bilinear)
to exactly cols*cellWidth x rows*cellHeight pixels before upload, and
library titles use the original poster for real images. The disk cache
stores posters as decoded (JPEG) instead of the possibly large originals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:25:45 +02:00
nilsandClaude Opus 5.5 786e783951 Posters: always a 2:3 box
The poster box followed the image's shape, so images Radarr/Sonarr hand
out in another shape came out smaller than the others in a list. The box
is now always 2:3: an image within 12% of that fills it, trimmed at the
long sides (block characters too, which were stretched before); one
further off is shown whole, centred in the box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:15:26 +02:00
nilsandClaude Opus 5.5 1056846a48 Keep posters and casts between runs
Posters (the downloaded bytes, per size, 30 days, at most 200 MB) and
casts (people.json, 7 days, at most 2000) are stored in the user's cache
directory (os.UserCacheDir()/arr-tui) and used on the next start before
asking the servers. Expired posters are pruned at startup, casts are
written shortly after new ones arrive and on exit. Tests turn the cache
off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:35:02 +02:00
nilsandClaude Opus 5.5 4eace6849e Screenshots: downloading titles in the Library
The fake queues carry movieId / seriesId, so their titles are marked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:04:53 +02:00
nilsandClaude Opus 5.5 2b6cf71967 Library: mark titles that are downloading
While the Library is visible it reads the Radarr and Sonarr queues (on
opening, every 10 s, and shortly after a grab) and matches them to the
titles by movieId / seriesId. Those titles get a blue dot instead of the
green or grey one, and their details say how far the download is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:04:53 +02:00
nilsandClaude Opus 5.5 7261667d72 Release list: close after a successful grab
Back to where the search started (library, drill-down or search), unless
something was opened on top of the list meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:54:15 +02:00
nilsandClaude Opus 5.5 5b8100eb54 Screenshots: season posters
The fake Sonarr sends season posters on request, generated per season.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:42:49 +02:00
nilsandClaude Opus 5.5 ca28a3c9bb Series drill-down: season posters
The drill-down reads the series with includeSeasonImages=true (Sonarr
only sends season posters when asked, as TheTVDB links). A selected
season or episode shows its season's poster; without one, or when it
cannot be loaded, the series poster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:42:49 +02:00
nilsandClaude Opus 5.5 73efe279d4 Screenshots: the cast in the details pane
The fake Trakt answers the id lookup and people requests with invented
names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:31:33 +02:00
nilsandClaude Opus 5.5 12e825cff0 Details pane: director and cast from Trakt
With a Trakt client ID, the details of a movie or series show the
director(s) or creators and up to 8 cast members with their roles below
the plot, in every view. The title is found on Trakt by its TMDb/TVDB
(else IMDb) id, then its people are read. Casts are cached and, like
posters, only loaded once the selection rests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:31:33 +02:00
nilsandClaude Opus 5.5 8775c27aab Movies and Series: Enter adds, a no longer does
The status bar only ever named Enter, while the hint for searching
releases of a title not in the library said (a). Enter is now the one
key for adding, and the hint says Enter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:23:34 +02:00
nilsandClaude Opus 5.5 9b8d59bd48 Trakt lists: right-align the numbers
The grey number of every entry ("1.2k watching", "on 18k lists") stood
right after its title, so they did not line up. The rows are now padded
to the list's width on every draw where it changed, putting the numbers
against the right edge; a title too long for that keeps the number right
after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:20:59 +02:00
nilsandClaude Opus 5.5 68c87bc3cd Screenshots: Trakt lists, Trakt in the Config view
The fake services gain a Trakt API (port 19894) and a sequel that is not
in the library; a new scene shows the Anticipated list. The other
pictures are retaken (Config has the Trakt section, the search status
bar mentions [ ] lists, the calendar is two days on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:35 +02:00
nilsandClaude Opus 5.5 4dba794b26 Trending, most watched and anticipated movies and series from Trakt
With an empty search box, Movies and Series show one of Trakt's public
lists: trending now, most watched this week, anticipated, popular, and
for movies the US box office. [ ] switch lists, r reloads. Each entry is
looked up in Radarr (tmdb:) or Sonarr (tvdb:), so it behaves like a
search result: details, poster, tick when in the library, add, releases.

Needs a Trakt client ID: new optional trakt.client_id setting (Config
view section "Trakt", ARR_TUI_TRAKT_CLIENT_ID), masked like the API keys
and checked by Test. ARR_TUI_TRAKT_URL points at another server for
development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:05:35 +02:00
nilsandClaude Opus 5.5 b71659b357 dev/screenshots: the tooling for the README screenshots
take.sh builds arr-tui, starts a fake Radarr, Sonarr and SABnzbd with a
made-up library (catalog.py, server.py, generated posters), and runs the
scene scripts in Ghostty under Xvfb, writing docs/screenshots/. Run on the
same day it reproduces the committed pictures byte for byte. make test
also shellchecks the scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 17:05:01 +02:00
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
139 changed files with 21422 additions and 350 deletions
+4
View File
@@ -5,3 +5,7 @@ config.json
!/dev/config.json
*.test
/dev/data/
*.swp
*.swo
__pycache__/
/dev/screenshots/posters/
+140 -2
View File
@@ -1,5 +1,143 @@
# Changelog
## v0.5.0
- **arr-tui on the TV:** `webos/` is an app for LG webOS TVs (webOS 5, 2020 models, and newer)
with the same views, driven by the remote: Movies and Series with the Trakt lists, Library with
its filters and download marks, seasons and episodes, release search and grab, Downloads,
Calendar, Settings and Log. It talks to the services directly, is built with your config baked
in and installed in the TV's Developer Mode; see `webos/README.md`. (Service addresses must not
redirect, e.g. from http to https: browsers do not follow that.)
- **`nix develop`:** a Nix flake with every tool the project uses (Go, shellcheck, Node.js,
Python, the screenshot tools, Chromium).
- **Filter dialog** (Library and release list): Language comes right after Quality, so the two
most used filters are at the top.
- **Downloading episodes in a series' seasons and episodes:** like the Library, the drill-down
marks episodes in Sonarr's download queue with a blue dot and "downloading 45%" as their status
(details: "Downloading 45% · 0:01:40 left"), and seasons with such episodes with a blue dot.
When an episode has been imported, its status changes to downloaded by itself.
- **Moving through lists is quick again with real-image posters:** scaling and encoding a poster
for the terminal (about 200 ms since posters are sent at the size of their box) now happens in
the background; the poster appears when it is ready, and the selection never waits for it.
- **Trakt lists show faster after a restart:** the Radarr/Sonarr lookups of their titles are kept
on disk too (3 days, `lookups.json`), so a list takes one request to Trakt and a quick library
check per title instead of a lookup (about a second each) per title. Whether a title is in the
library is always asked afresh.
- **Posters of titles not in the library load faster:** they are fetched from TheMovieDB at the
size shown (780 pixels wide for real images) instead of the original Radarr names, which is often
2000×3000 and over 2 MB: about a tenth of the download and a sixth of the decoding time.
- **Real-image posters fill their box in every terminal:** they are scaled to the exact pixel size
of their cells before they are sent. Ghostty shows an image that is smaller than its place at its
own size, so library posters (Radarr's copy is only 333×500, not 500×750 as assumed in 0.4.0)
came out smaller than the others. Library titles now also use the original poster for real
images. The disk cache keeps posters at the size shown, not as downloaded
(in a new folder; posters stored by the previous way are deleted at startup).
- **Posters all the same size:** the details pane always shows a poster in a 2:3 box. Images
slightly off that shape (some Radarr/Sonarr hand out) fill it, trimmed at the long sides; images
far from it (square, landscape) are shown whole and centred. Before, such posters came out
smaller than the others in the same list.
- **Posters and casts are kept between runs** (in `~/.cache/arr-tui`; posters 30 days and at
most 200 MB, casts 7 days), so they show at once after a restart and are not downloaded again.
- **Downloading titles in the Library:** the dot before a movie or series turns blue while it is in
the Radarr/Sonarr download queue, and its details say how far ("Downloading 45% · 0:06:12 left").
- **After a grab** the release list closes and you are back where the search started.
- **Season posters** in the series drill-down: a season and its episodes show the season's poster
(from TheTVDB, via Sonarr), the series poster where a season has none or it cannot be loaded.
- **Cast in the details pane:** with a Trakt client ID, the details of a movie or series list its
director (or creators) and cast with their roles below the plot, in every view.
- **Enter adds** a title in Movies and Series; `a` no longer does (the status bar only ever named
Enter, and the hint for searching releases of a title not in the library now says Enter too).
- **Trending, most watched and anticipated** movies and series from Trakt: with an empty search
box, Movies and Series show trending now, most watched this week, anticipated, popular and (movies)
the US box office, each title with its number ("1.2k watching", "on 18k lists", "$48M last
weekend") right-aligned in grey; `[` `]` switch lists, `r` reloads. Titles in the library are ticked, the others
can be added and searched like search results. Needs a free Trakt client ID, set in the new Trakt
section of the Config view (`trakt.client_id`, `ARR_TUI_TRAKT_CLIENT_ID`).
## 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
First release.
@@ -17,7 +155,7 @@ First release.
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
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`).
- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history.
- Connection indicators for all three services, context-sensitive key hints, `?` help.
- **Log (F7) and popups:** feedback appears as fading popups; the log keeps the history.
- 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
(`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)
TARGETS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
.PHONY: build test dist clean
.PHONY: build test dist release clean
build:
go build -ldflags '$(LDFLAGS)' -o arr-tui ./cmd/arr-tui
@@ -10,6 +10,7 @@ build:
test:
go vet ./...
go test ./...
@if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh dev/*.sh dev/screenshots/*.sh; else echo "shellcheck not installed, skipping shell script checks"; fi
# Cross-compiled binaries and checksums in dist/.
dist:
@@ -22,5 +23,9 @@ dist:
done
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:
rm -rf dist arr-tui
+98 -23
View File
@@ -4,30 +4,79 @@ 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
[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,
optionally start the automatic search.
- **Trending, most watched, anticipated**: while the search box is empty, Movies and Series show
lists from [Trakt](https://trakt.tv): trending now, most watched this week, anticipated, popular,
and the US box office for movies. `[` and `]` switch lists; titles already in your library are
ticked, the others can be added right there (Enter). Needs a free Trakt client ID (see Configure).
- **Library**: movies and series with their status; the dot before a title turns blue while it
is downloading, and the details say how far. In a series' seasons and episodes too.
- **Cache**: posters (30 days, at most 200 MB), casts (7 days) and the Radarr/Sonarr lookups of
Trakt list titles (3 days) are kept between runs in
`~/.cache/arr-tui` (`$XDG_CACHE_HOME`; `~/Library/Caches/arr-tui` on macOS), so they show at
once after a restart. Deleting the folder is always safe.
- **Season posters**: in a series' seasons and episodes, the poster follows the selected season.
- **Cast**: with a Trakt client ID, the details pane also lists the director (or the creators of a
series) and the cast with their roles, below the plot, in every view.
- **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.
- **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
with colors). Library items use the copy stored in Radarr/Sonarr; search results are downloaded
from the metadata provider's image host (TMDb, TheTVDB). Turn it off with
`"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the characters: `quadrant` (2x2
pixels per cell, every font has them), `sextant` (2x3, sharper, needs a font with the Symbols for
Legacy Computing block, e.g. kitty, WezTerm, foot, Ghostty) or `auto` (default: sextant on
terminals known to support it, quadrant elsewhere). If you see boxes or question marks in the
poster, set `quadrant`.
- **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
monitoring, trigger searches. Series open into seasons and episodes with per-season and
per-episode monitoring, automatic search and release search.
flow along its left side and continue at full width below it. In kitty and Ghostty it is a real
image at the terminal's full resolution (Kitty graphics protocol with Unicode placeholders, so
popups and dialogs cover it like text). Everywhere else it is drawn with colored block characters,
which works over SSH and in any terminal with colors. Library items use the copy stored in
Radarr/Sonarr; search results are downloaded from the metadata provider's image host (TMDb,
TheTVDB). Turn it off with `"ui": {"covers": false}`. `"ui": {"cover_style": ...}` picks the
drawing: `auto` (default: real images where the terminal is known to support them, otherwise
plain cells in macOS Terminal, sextants on terminals known to have the glyphs or when fontconfig
finds an installed font with them (not over SSH), else quadrants), `kitty` (force real images),
`sextant` (2x3 pixels per cell, needs a font with the Symbols for Legacy Computing block) or
`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,
retry failed downloads, remove from the arr queue and blocklist.
- **Log (F5)**: actions report through popups that fade away; the log keeps the history.
- Connection indicators for all three services in the top right; `?` shows every key.
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move
through with the arrow keys: service addresses and API keys, the add-form defaults (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
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 |
| ![Movies view with an empty search: the Trakt list of anticipated movies, with how many lists each is on](docs/screenshots/discover.png) | ![The library in the light theme](docs/screenshots/library-light.png) |
| **Trending, most watched, anticipated** from Trakt | **Light theme** |
## Install
```sh
@@ -38,46 +87,72 @@ or build from a checkout (`make build`), or `make dist` for cross-compiled binar
## Configure
Copy `config.example.json` to `$XDG_CONFIG_HOME/arr-tui/config.json` (usually
`~/.config/arr-tui/config.json`), or pass `--config path`. API keys are in each app under
Settings → General.
Just start `arr-tui`: on the first start it creates `$XDG_CONFIG_HOME/arr-tui/config.json`
(usually `~/.config/arr-tui/config.json`, or the file given with `--config path`) and opens the
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
{
"radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" },
"sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto" }
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto", "theme": "auto", "notify_downloads": true, "bell": false }
}
```
`default_*` are optional preselections in the add dialog. URL and key can also come from the
environment: `ARR_TUI_{RADARR,SONARR,SABNZBD}_{URL,APIKEY}`, which takes precedence over the file.
The trending, most watched and anticipated lists need a Trakt client ID (free, no Trakt account
of yours is touched): sign in at [trakt.tv](https://trakt.tv), create an app under
[Settings → Your API Apps → New Application](https://trakt.tv/oauth/applications/new) (any name;
redirect URI `urn:ietf:wg:oauth:2.0:oob`), and enter its **Client ID** in the Config view under
Trakt, or as `"trakt": {"client_id": "..."}` in the file, or in `ARR_TUI_TRAKT_CLIENT_ID`.
Keep the file private (`chmod 600`); it holds your API keys.
## Keys
| 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 |
| `?` / `F9` | help |
| `F9` | help |
| `/` | focus search box |
| `Enter`, `a` | add selected title |
| `[`, `]`, `r` (Movies / Series, empty search) | previous / next Trakt list, reload it |
| `Enter` | add selected title |
| `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`/`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 |
| `Esc` | back / close dialog |
| `q`, `Ctrl+C` | quit |
| `Ctrl+C` | quit |
## On the TV (LG webOS)
[webos/](webos/README.md) has the same views as a TV app for LG webOS (2020 models and newer),
driven by the remote: search and Trakt lists, Library with filters, seasons and episodes, release
search and grab, Downloads, Calendar. It is built with your config baked in and installed in the
TV's Developer Mode; see [webos/README.md](webos/README.md).
## Development
`nix develop` opens a shell with everything below (Go, shellcheck, Node.js, Python, the screenshot
tools and Chromium; the flake is in [flake.nix](flake.nix)); without Nix, install what you use.
`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 screenshots are made with
`dev/screenshots/take.sh` (see [its README](dev/screenshots/README.md)). The design and
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
MIT, see [LICENSE](LICENSE).
+34 -21
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)
- **License:** MIT
- **Module:** `git.stinnesbeck.com/nils/arr-tui`
- **Status:** M9 done
- **Status:** M10 done
## 1. Goals
@@ -57,25 +57,33 @@ Notes:
- **Config:** load URLs + API keys from a config file; fail with a clear message if missing or unreachable.
- **Search:** a Movies view (Radarr) and a Series view (Sonarr), each with a search box. Results list with title, year, rating, status, and an "in library" marker. Detail pane with overview, runtime, genres, network/studio.
- **Add:** from a result, open a small form: quality profile, root folder, monitored (yes/no), "search now" (yes/no); for series also monitor mode (all / future / first season / none). Submit calls `POST /movie` or `POST /series` with `addOptions.searchForMovie` / `searchForMissingEpisodes`.
- **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation. The list can be filtered by quality, codec (x265/x264/AV1), HDR/DV/SDR, audio format and language; releases carry no media info, so codec and HDR are read from the title, quality and custom formats (a release that does not name its codec never matches a codec filter).
- **Interactive release search:** for a movie (or a series' season/episode) in the library, list releases from indexers (`GET /release`) with title, indexer, size, quality, age, and rejection reasons; sort/filter; grab a chosen one (`POST /release`). Rejected releases can be grabbed only after an explicit confirmation. The list can be filtered by quality, language, codec (x265/x264/AV1), HDR/DV/SDR and audio format; releases carry no media info, so codec and HDR are read from the title, quality and custom formats (a release that does not name its codec never matches a codec filter).
- **Downloads view:** all downloads and queues in one place, auto-refreshing every few seconds:
- SABnzbd queue (name, size, progress, ETA, speed) and history (completed / failed, with retry).
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
- **Status bar / help:** 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.
- **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.
- **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.
- **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. Two ways to draw the poster, chosen by `ui.cover_style`:
- **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is scaled (bilinear) to exactly the pixel size of its cells (cell size from the terminal's window size; terminals do not all enlarge a smaller image: Ghostty shows it at its own size) and 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 2:3 (every poster gets the same 2:3 box: an image within 12% of that shape fills it, trimmed at its long sides, also with block characters; one further off, e.g. square or landscape, is shown whole at the largest size that fits, centred in the box); 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 pixels tall; for real images the original `poster.jpg`, because even Radarr's `poster-500.jpg` is only 333×500) 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.
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
- **Downloading in the Library:** while the Library is visible it reads the Radarr and Sonarr queues (`/queue`, matched to titles by `movieId` / `seriesId`) when it opens, every 10 s, and 3 s after a grab. A title with queue entries gets a blue mark (filled when monitored, hollow when not) instead of the green or grey one, and its details a line "Downloading 45% · 0:06:12 left" (series: "Downloading N episodes"). A queue that cannot be read leaves the marks as they were. A series' seasons and episodes (the drill-down) read the Sonarr queue in the same way while open (when it opens and every 10 s, matched by `seriesId` and `episodeId`): an episode in the queue gets the blue mark, "downloading 45%" as its status (unless it already has a file, i.e. an upgrade) and the "Downloading" line in its details; a season with such episodes gets the blue mark and "Downloading N episode(s)". When an episode leaves the queue the episodes are loaded again, so its status follows. After a successful grab the release list closes (if nothing was opened on top of it).
- **Disk cache:** posters and casts are kept between runs in `os.UserCacheDir()/arr-tui` (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS; directories `0700`, files written atomically). Posters are stored as decoded (at most 640 or 160 pixels tall, JPEG quality 90), one file per poster and size (small copy for block characters, large for real images) under `posters-v2/<hash>.img` (the folder is versioned: folders of earlier storage formats are deleted at startup, so their files are not used as they are); a file is used for 30 days (by its modification time) and dropped when it cannot be decoded; at startup files older than that are deleted, then the oldest until the rest fit 200 MB. Casts are one `people.json` (key = `movie:tmdb:<id>` / `show:tvdb:<id>`, with the time it was fetched); entries are used for 7 days, at most 2000 are kept (the oldest go first), titles Trakt does not know are kept as having no cast; new casts are written 5 s after the last one and when the program quits. Changing the Trakt client ID keeps the casts and only forgets failed lookups. Trakt list titles: whether a title is in the library is asked every time (`GET movie?tmdbId=` / `series?tvdbId=`, a quick query of the server's own database; the answer is checked against the id, as a version that ignores the filter sends the whole library); for titles that are not, the Radarr/Sonarr lookup (which asks the metadata provider, about a second each) is kept in `lookups.json` (key = `tmdb:<id>` / `tvdb:<id>`, the result as the server sent it, with the time it was fetched) for 3 days, at most 2000, written like the casts. The Trakt list itself is fetched again after 15 minutes as before.
- **Season posters:** the series drill-down reads the series with `includeSeasonImages=true` (Sonarr only sends season posters when asked, and keeps no copy of them: they are TheTVDB links, fetched without credentials). A selected season or episode shows its season's poster; seasons without one, or whose poster cannot be loaded, show the series poster.
- **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search/<type>/<id>?type=movie|show`), then `/movies|shows/<trakt id>/people` is read. Casts are cached, also on disk (see Disk cache; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes).
- **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, Trakt 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)
- **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).
- **Series drill-down:** seasons and episodes, toggle monitoring per season/episode, trigger season/episode search and release search.
- **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. 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
- 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.
## 5. UI Design (tview)
@@ -94,11 +102,13 @@ Layout sketch (Library view; the other views follow the same frame):
│ ● Show One (2008) 12/62 │ │
│ ○ 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.
@@ -106,16 +116,18 @@ Keybindings (the status bar shows the ones that apply to the current view):
| 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 |
| `?` / `F9` | Help (`F9` also works while typing) |
| `q` / `Ctrl+C` | Quit |
| `F9` | Help (works everywhere, also while typing) |
| `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 |
| `/` | Movies, Series, Library: focus the search / text filter |
| `Enter` / `a` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
| `Enter` | Movies, Series: add the selected result (dialog: ↑↓ move, ←→ change dropdowns) |
| `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, language, codec, HDR/DV, audio; in this order in the dialog) |
| `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 |
| `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) |
@@ -198,10 +210,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] **M5** — Radarr/Sonarr queues merged into the Downloads view.
- [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] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar.
- [ ] **M10+** — ideas: calendar, completion notifications, delete from library, multiple instances.
- [x] **M9** — Popup notifications, Log view (F7), context-sensitive key hints in the status bar.
- [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
@@ -211,7 +224,7 @@ Open: none.
Decided:
- Go module path: `git.stinnesbeck.com/nils/arr-tui`.
- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is a later idea).
- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is out of scope).
- Minimum Go version: latest stable.
- License: MIT (Nils Stinnesbeck). The tool will be published, so keep secrets out of the repo, include a `config.example.json`, and write a proper README.
- Config format: JSON.
+16 -1
View File
@@ -2,8 +2,10 @@
package main
import (
"errors"
"flag"
"fmt"
"io/fs"
"os"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
@@ -38,6 +40,13 @@ func run() error {
*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)
if err != nil {
return err
@@ -46,5 +55,11 @@ func run() error {
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()
}
+7 -1
View File
@@ -15,9 +15,15 @@
"url": "http://localhost:8080",
"apikey": "YOUR_SABNZBD_API_KEY"
},
"trakt": {
"client_id": "YOUR_TRAKT_CLIENT_ID"
},
"ui": {
"refresh_seconds": 3,
"covers": true,
"cover_style": "auto"
"notify_downloads": true,
"bell": false,
"cover_style": "auto",
"theme": "auto"
}
}
+5
View File
@@ -50,3 +50,8 @@ dev/add-indexer.sh # registers it in the dev Radarr and Sonarr
Add the movie / series to the dev instances first (e.g. from the TUI), then press `i` on it.
To test with a real indexer, add it in the dev Radarr/Sonarr web UI (Settings, Indexers) instead of pasting
credentials anywhere else.
## Screenshots
`dev/screenshots/take.sh` makes the README screenshots from a fake, made-up library; see
[screenshots/README.md](screenshots/README.md).
+27
View File
@@ -0,0 +1,27 @@
# README screenshots
`take.sh` makes the pictures in `docs/screenshots/`: it builds arr-tui, starts a fake Radarr,
Sonarr and SABnzbd with a made-up library, and runs arr-tui in Ghostty on a virtual X display,
following the scene scripts. Nothing real is shown: invented titles, generated posters, dummy API
keys (masked in the Config view anyway).
```sh
dev/screenshots/take.sh
# with Nix, everything from the flake's dev shell:
nix develop --command dev/screenshots/take.sh
```
Needs Go, Python 3, Ghostty, `xvfb-run` (Xvfb), `xdotool`, ImageMagick 7 and fontconfig with the
DejaVu fonts; `oxipng` (optional) compresses the PNGs losslessly. It takes about a minute.
| File | What it is |
|---|---|
| `catalog.py` | The made-up library: movies (with file details) and series (with seasons). |
| `posters.sh` | Draws a poster per title into `posters/` (gradient, title, year); run by `take.sh` when `posters/` is missing. |
| `server.py` | The fake services on 127.0.0.1: Radarr 19891, Sonarr 19892, SABnzbd 19893, Trakt 19894 (`take.sh` points arr-tui there with `ARR_TUI_TRAKT_URL`). Library, search, lists, releases, queues, history, calendar, posters. Like the real services it answers cross-origin requests, and it accepts writes without changing anything, for the webOS app (`webos/`, `npm run demo`). |
| `xshot.sh` | Runs a command in Ghostty under Xvfb and follows a scene script. |
| `scenes/*.txt` | What to press and when to take a picture: `key`, `type`, `sleep`, `shot <name>`. |
To change a picture, edit its scene (the key presses depend on the order of `catalog.py`, e.g.
"The Long Orbit" is the 13th movie alphabetically) and run `take.sh` again. Some views show today's
date (Calendar, Downloads), so every run changes those pictures a little.
+48
View File
@@ -0,0 +1,48 @@
# Fictional library for the README screenshots: invented titles, generated posters.
MOVIES = [
# id, title, year, genres, studio, runtime, rating, file (res, codec, dr, audio, lang, size GB) or None, monitored, overview, colors
(1,"The Long Orbit",2023,["Science Fiction","Drama"],"Halcyon Pictures",142,8.1,(2160,"x265","DV HDR10","TrueHD","Atmos","English",58.2),True,
"When a deep-space freighter loses contact with Earth, its last remaining engineer must decide whether to keep the ship on course for a colony that may no longer exist, or turn back on a journey that will take the rest of her life.",("#0b132b","#5bc0be")),
(2,"Paper Lanterns",2019,["Drama","Romance"],"Northlight",118,7.4,(1080,"x264","","DTS","","English",12.4),True,
"Two strangers meet every year at the same river festival, and over a decade of single nights their lives drift together and apart.",("#3d0c02","#f4a259")),
(3,"Iron Meridian",2021,["Action","Thriller"],"Blackstone",131,6.9,(2160,"x265","HDR10","EAC3","Atmos","English",41.7),True,
"A former border guard is pulled back into service when a smuggling route through the mountains starts moving something far more dangerous than contraband.",("#1b1b1b","#c1121f")),
(4,"The Quiet Archive",2024,["Mystery"],"Fernwood",109,7.8,None,True,
"A librarian cataloguing a dead author's papers finds letters addressed to people who were never born.",("#2b2d42","#8d99ae")),
(5,"Saltwater Kings",2017,["Adventure","Comedy"],"Gull Island",101,6.6,(1080,"x265","","AAC","","English",4.9),True,
"Three washed-up fishermen bet their boats on one last race around the cape.",("#023e8a","#90e0ef")),
(6,"Glass Mountain",2022,["Science Fiction","Thriller"],"Halcyon Pictures",127,7.2,(2160,"x265","DV","TrueHD","Atmos","English",49.0),True,
"An avalanche researcher discovers that the glacier she studies is moving against the slope.",("#03045e","#caf0f8")),
(7,"Midnight at the Odeon",2015,["Comedy","Crime"],"Starling",96,7.0,(1080,"x264","","DTS","","English",9.1),True,
"A cinema projectionist and a pickpocket team up for one heist during the last screening before the theatre closes for good.",("#370617","#f48c06")),
(8,"Borrowed Summers",2020,["Drama"],"Northlight",112,7.6,(1080,"x265","HDR10","EAC3","","French",8.3),True,
"A grandmother teaches her grandson to sail on the lake where she once lost everything.",("#606c38","#fefae0")),
(9,"The Cartographer's Daughter",2025,["Fantasy","Adventure"],"Fernwood",138,0,None,True,
"The daughter of a map-maker sets out to chart the island that her father swore was real.",("#283618","#dda15e")),
(10,"Static",2018,["Horror"],"Blackstone",94,6.1,(1080,"x264","","AC3","","English",6.0),False,
"A late-night radio host starts receiving calls from listeners who say the broadcast has already ended.",("#000000","#6c757d")),
(11,"Neon Harbour",2023,["Crime","Thriller"],"Starling",121,7.3,(2160,"x265","HDR10+","DTS-HD MA","","English",44.5),True,
"A detective in a flooded port city follows a trail of stolen streetlights to a conspiracy at city hall.",("#10002b","#e0aaff")),
(12,"Wild Clover",2016,["Family","Animation"],"Gull Island",88,7.9,(1080,"x265","","AAC","","German",3.2),True,
"A rabbit who can't stop counting sets out to find the last four-leaf clover in the valley.",("#2d6a4f","#b7e4c7")),
(13,"Under the Same Sky",2024,["Drama","War"],"Northlight",146,8.3,(2160,"x265","DV HDR10","TrueHD","Atmos","English",61.8),True,
"Two soldiers on opposite sides of a frozen front share a single radio frequency through one long winter.",("#14213d","#e5e5e5")),
(14,"Hollow Point Road",2021,["Western"],"Blackstone",117,6.8,None,False,
"A retired sheriff escorts a stranger across a county that wants them both gone.",("#7f5539","#ede0d4")),
(15,"The Last Lighthouse",2026,["Drama","Mystery"],"Fernwood",0,0,None,True,
"The keeper of the coast's last manned lighthouse refuses to leave when the automation crew arrives.",("#1d3557","#f1faee")),
(16,"Copper & Sage",2019,["Romance","Comedy"],"Starling",104,6.4,(1080,"x264","","AC3","","English",7.7),True,
"Rival food-truck owners are forced to share a parking spot for a summer.",("#bc6c25","#fefae0")),
]
SERIES = [
# id, title, year, network, status, seasons [(episodes, files)], overview, colors
(1,"Signal Fire",2021,"Aurora","continuing",[(8,8),(8,8),(10,4)],"A volunteer fire crew in a remote mountain town keeps the region safe while hiding a secret about the fire that founded it.",("#6a040f","#ffba08")),
(2,"Low Tide",2019,"Coastline","ended",[(6,6),(6,6)],"A marine biologist returns to her hometown and reopens a case everyone wanted closed.",("#03045e","#48cae4")),
(3,"The Night Shift",2023,"Aurora","continuing",[(10,10),(10,3)],"Stories from the staff of a city hospital between midnight and dawn.",("#240046","#c77dff")),
(4,"Hearth & Home",2018,"Meadow","ended",[(12,12),(12,12),(12,12)],"A family restaurant, three generations, one kitchen.",("#9c6644","#ede0d4")),
(5,"Orbital",2025,"Vector","continuing",[(8,2)],"The crew of a commercial space station tries to stay profitable, and alive.",("#000814","#ffc300")),
(6,"Ferryman",2020,"Coastline","ended",[(6,0)],"A ferry captain becomes the only link between an island and the mainland during a long quarantine.",("#2f3e46","#cad2c5")),
]
# A sequel the Trakt "Anticipated" list has, not in the library: id, title, year, colors.
UPCOMING = (9001, "The Long Orbit: Return", 2027, ("#0b132b", "#e0aaff"))
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Generate a poster for every title in catalog.py: a gradient with the title
# and year, 500x750 JPEG in posters/. Needs ImageMagick 7 and fontconfig.
set -euo pipefail
cd "$(dirname "$0")"
font=$(fc-match -f '%{file}' 'DejaVu Serif:style=Bold')
small=$(fc-match -f '%{file}' 'DejaVu Sans')
mkdir -p posters
python3 -c '
import catalog
for m in catalog.MOVIES: print("m%d|%s|%d|%s|%s" % (m[0], m[1], m[2], m[10][0], m[10][1]))
for s in catalog.SERIES: print("s%d|%s|%d|%s|%s" % (s[0], s[1], s[2], s[7][0], s[7][1]))
for s in catalog.SERIES:
for n in range(1, len(s[5]) + 1): print("s%d-%d|%s|Season %d|%s|%s" % (s[0], n, s[1], n, s[7][1], s[7][0]))
u = catalog.UPCOMING; print("m%d|%s|%d|%s|%s" % (u[0], u[1], u[2], u[3][0], u[3][1]))
' | while IFS='|' read -r id title year c1 c2; do
magick -size 500x750 "gradient:$c1-$c2" \
\( -size 420x320 -background none -fill white -font "$font" -gravity center caption:"$title" \) \
-gravity north -geometry +0+120 -composite \
\( -size 420x60 -background none -fill '#ffffffcc' -font "$small" -pointsize 30 -gravity center caption:"$year" \) \
-gravity south -geometry +0+70 -composite \
-quality 88 "posters/$id.jpg"
done
+5
View File
@@ -0,0 +1,5 @@
# The Config view; the API keys are masked.
sleep 2
key F6
sleep 3
shot config
+6
View File
@@ -0,0 +1,6 @@
# The Trakt lists in the Movies view (empty search): Anticipated, with a title
# that is not in the library yet selected.
sleep 4
key bracketright bracketright
sleep 4
shot discover
+10
View File
@@ -0,0 +1,10 @@
# The seasons and episodes of Signal Fire (5th series).
sleep 2
key F3
sleep 3
key Tab
key Down Down Down Down
sleep 1
key Return
sleep 3
shot episodes
+7
View File
@@ -0,0 +1,7 @@
# The library in the light theme, Neon Harbour (7th movie) selected.
sleep 2
key F3
sleep 3
key Down Down Down Down Down Down
sleep 2
shot library-light
+24
View File
@@ -0,0 +1,24 @@
# Search, library, releases, downloads and calendar, in the dark theme.
sleep 2
key F1
type orbit
sleep 3
shot search
key F3
sleep 3
# The Long Orbit, 13th in the list
key Down Down Down Down Down Down Down Down Down Down Down Down
sleep 2
shot library
key i
sleep 3
shot releases
key Escape
key F4
sleep 4
shot downloads
key F5
sleep 3
key Down
sleep 2
shot calendar
+265
View File
@@ -0,0 +1,265 @@
"""Fake Radarr (19891), Sonarr (19892), SABnzbd (19893) and Trakt (19894) serving the made-up
library in catalog.py, for the README screenshots. Only the API calls arr-tui
makes are answered; posters come from posters/ (see posters.sh)."""
import datetime, json, os, sys, threading, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import catalog
HERE = os.path.dirname(os.path.abspath(__file__))
TODAY = datetime.date.today()
GB = 1 << 30
RADARR, SONARR, SAB, TRAKT = 19891, 19892, 19893, 19894
def day(n): return (TODAY + datetime.timedelta(days=n)).isoformat()
def mediafile(res, codec, dr, audio, extra, lang, gb, name):
return {"size": int(gb * GB), "sceneName": name, "relativePath": name + ".mkv",
"quality": {"quality": {"name": ("Bluray-%dp" % res) if res == 2160 else ("WEBDL-%dp" % res), "resolution": res}},
"mediaInfo": {"videoCodec": codec, "videoDynamicRange": "HDR" if dr else "", "videoDynamicRangeType": dr,
"audioCodec": audio, "audioAdditionalFeatures": extra},
"languages": [{"name": lang}]}
def poster(port, kind, id):
local = "/MediaCover/%d/poster.jpg" % id
return [{"coverType": "poster", "url": local, "remoteUrl": "http://127.0.0.1:%d/remote/%s%d.jpg" % (port, kind, id)}]
def movies():
out = []
for (id, title, year, genres, studio, runtime, rating, f, mon, overview, _c) in catalog.MOVIES:
m = {"id": id, "title": title, "year": year, "genres": genres, "studio": studio, "runtime": runtime,
"overview": overview, "tmdbId": 1000 + id, "titleSlug": title.lower().replace(" ", "-"),
"monitored": mon, "hasFile": f is not None, "isAvailable": year < 2026, "status": "released" if year < 2026 else "announced",
"path": "/movies/%s (%d)" % (title, year), "images": poster(RADARR, "m", id), "qualityProfileId": 4,
"ratings": {"imdb": {"value": rating, "votes": 12000}} if rating else {}}
if f:
res, codec, dr, audio, extra, lang, gb = f
name = "%s.%d.%dp.%s%s" % (title.replace(" ", "."), year, res, (dr.replace(" ", ".") + ".") if dr else "", codec)
m["movieFile"] = mediafile(res, codec, dr, audio, extra, lang, gb, name)
m["sizeOnDisk"] = int(gb * GB)
out.append(m)
return out
def series():
out = []
for (id, title, year, network, status, seasons, overview, _c) in catalog.SERIES:
eps = sum(s[0] for s in seasons); files = sum(s[1] for s in seasons)
out.append({"id": id, "title": title, "year": year, "network": network, "status": status, "overview": overview,
"runtime": 45, "genres": ["Drama"], "tvdbId": 2000 + id, "titleSlug": title.lower().replace(" ", "-"),
"images": poster(SONARR, "s", id), "monitored": True, "ratings": {"value": 8.0, "votes": 900},
"path": "/tv/" + title,
"seasons": [{"seasonNumber": i + 1, "monitored": True,
"statistics": {"episodeFileCount": f, "episodeCount": e, "totalEpisodeCount": e, "sizeOnDisk": f * 2 * GB}}
for i, (e, f) in enumerate(seasons)],
"statistics": {"seasonCount": len(seasons), "episodeFileCount": files, "episodeCount": eps,
"totalEpisodeCount": eps, "sizeOnDisk": files * 2 * GB}})
return out
EPISODE_TITLES = ["Kindling", "The Ridge", "Backdraft", "Smoke Line", "Ember", "Firebreak", "Updraft", "Ash",
"Tinder", "Afterglow", "Spark", "Wildland"]
def episodes(series_id):
s = next(x for x in catalog.SERIES if x[0] == series_id)
out, eid = [], series_id * 1000
for si, (count, files) in enumerate(s[5]):
for e in range(count):
eid += 1
aired = day(-(len(s[5]) - si) * 90 + e * 7)
out.append({"id": eid, "seriesId": series_id, "seasonNumber": si + 1, "episodeNumber": e + 1,
"title": EPISODE_TITLES[e % len(EPISODE_TITLES)], "airDate": aired, "airDateUtc": aired + "T20:00:00Z",
"overview": "The crew faces a long night on the ridge as the wind turns.", "hasFile": e < files, "monitored": True})
return out
def releases(title, year):
base = title.replace(" ", ".")
rows = [
("%s.%d.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FRAMES" % (base, year), "NZBgeek", 68.4, 2160, "Remux-2160p", True, [], 2, ["DV", "HDR10", "Atmos"]),
("%s.%d.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX" % (base, year), "DrunkenSlug", 21.9, 2160, "WEBDL-2160p", True, [], 1, ["DV", "Atmos"]),
("%s.%d.1080p.BluRay.DTS-HD.MA.5.1.x264-SPARKS" % (base, year), "NZBgeek", 14.2, 1080, "Bluray-1080p", True, [], 6, []),
("%s.%d.1080p.WEB-DL.DDP5.1.H.264-NTb" % (base, year), "NZBFinder", 6.8, 1080, "WEBDL-1080p", True, [], 3, []),
("%s.%d.2160p.WEB-DL.DDP5.1.HDR10.HEVC-GERMAN-iNTENSO" % (base, year), "DrunkenSlug", 18.1, 2160, "WEBDL-2160p", False, ["Language is not wanted"], 4, ["HDR10"]),
("%s.%d.720p.WEBRip.x264-ION10" % (base, year), "NZBFinder", 1.9, 720, "WEBRip-720p", False, ["Quality is not wanted in profile"], 9, []),
("%s.%d.1080p.HDTV.x264-CAM" % (base, year), "NZBgeek", 2.4, 1080, "HDTV-1080p", False, ["Release is a CAM", "Quality is not wanted in profile"], 1, []),
("%s.%d.2160p.BluRay.x265.10bit.HDR.DTS-HD.MA-TERMiNAL" % (base, year), "NZBFinder", 34.0, 2160, "Bluray-2160p", True, [], 12, ["HDR10"]),
]
out = []
for i, (name, idx, gb, res, q, ok, rej, days, cfs) in enumerate(rows):
out.append({"guid": "g%d" % i, "title": name, "indexer": idx, "indexerId": 1, "protocol": "usenet", "size": int(gb * GB),
"ageMinutes": days * 1440.0, "qualityWeight": res, "customFormatScore": 100 * len(cfs), "releaseGroup": name.rsplit("-", 1)[-1],
"approved": ok, "rejected": not ok, "rejections": rej,
"quality": {"quality": {"name": q, "resolution": res}},
"languages": [{"name": "German" if "GERMAN" in name else "English"}],
"customFormats": [{"name": c} for c in cfs]})
return out
def radarr(path, q):
if path.endswith("/system/status"): return {"appName": "Radarr", "version": "5.26.2"}
if path.endswith("/movie"): return movies()
if "/movie/" in path and path.rsplit("/", 1)[1].isdigit():
return next(x for x in movies() if x["id"] == int(path.rsplit("/", 1)[1]))
if path.endswith("/movie/lookup"):
term = q.get("term", [""])[0].lower()
if term.startswith("tmdb:"):
return [m for m in movies() + [UPCOMING] if m["tmdbId"] == int(term[5:])]
res = [m for m in movies() if term and term in m["title"].lower()]
return res + ([UPCOMING] if "orbit" in term else [])
if path.endswith("/qualityprofile"): return [{"id": 1, "name": "Any"}, {"id": 4, "name": "HD-1080p"}, {"id": 5, "name": "Ultra-HD"}]
if path.endswith("/rootfolder"): return [{"id": 1, "path": "/movies", "freeSpace": 3 << 40, "accessible": True}]
if path.endswith("/release"):
m = next(x for x in catalog.MOVIES if x[0] == int(q["movieId"][0]))
return releases(m[1], m[2])
if path.endswith("/queue"):
return {"records": [
{"id": 1, "title": "Iron.Meridian.2021.2160p.WEB-DL.DDP5.1.Atmos.HDR.H.265-FLUX", "status": "downloading", "trackedDownloadStatus": "ok",
"trackedDownloadState": "downloading", "size": 21.9 * GB, "sizeleft": 7.2 * GB, "timeleft": "00:06:12", "downloadClient": "SABnzbd",
"downloadId": "SABnzbd_nzo_a1", "protocol": "usenet", "movieId": 3, "movie": {"title": "Iron Meridian", "year": 2021}},
{"id": 2, "title": "Static.2018.1080p.BluRay.x264-AMIABLE", "status": "completed", "trackedDownloadStatus": "warning",
"trackedDownloadState": "importBlocked", "size": 6.0 * GB, "sizeleft": 0, "timeleft": "", "downloadClient": "SABnzbd",
"downloadId": "SABnzbd_nzo_h3", "protocol": "usenet", "statusMessages": [{"title": "Static.2018.1080p", "messages": ["Not an upgrade for existing movie file"]}],
"movieId": 10, "movie": {"title": "Static", "year": 2018}}]}
if path.endswith("/calendar"):
out = []
for off, mid, kind in [(1, 9, "digitalRelease"), (5, 15, "inCinemas"), (9, 4, "physicalRelease")]:
m = dict(next(x for x in movies() if x["id"] == mid)); m[kind] = day(off) + "T00:00:00Z"; out.append(m)
return out
return []
def sonarr(path, q):
if path.endswith("/system/status"): return {"appName": "Sonarr", "version": "4.0.15"}
if path.endswith("/series/lookup"):
term = q.get("term", [""])[0].lower()
if term.startswith("tvdb:"):
return [x for x in series() if x["tvdbId"] == int(term[5:])]
return [x for x in series() if term in x["title"].lower()]
if path.endswith("/series"): return series()
if "/series/" in path and path.rsplit("/", 1)[1].isdigit():
s = next(x for x in series() if x["id"] == int(path.rsplit("/", 1)[1]))
if q.get("includeSeasonImages") == ["true"]: # like Sonarr: season posters only on request, remote only
for se in s["seasons"]:
se["images"] = [{"coverType": "poster", "remoteUrl": "http://127.0.0.1:%d/remote/s%d-%d.jpg" % (SONARR, s["id"], se["seasonNumber"])}]
return s
if path.endswith("/episode"): return episodes(int(q["seriesId"][0]))
if path.endswith("/episodefile"): return []
if path.endswith("/qualityprofile"): return [{"id": 1, "name": "Any"}, {"id": 4, "name": "HD-1080p"}]
if path.endswith("/rootfolder"): return [{"id": 1, "path": "/tv", "freeSpace": 3 << 40, "accessible": True}]
if path.endswith("/queue"):
return {"records": [
{"id": 7, "title": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "status": "downloading", "trackedDownloadStatus": "ok",
"trackedDownloadState": "downloading", "size": 2.1 * GB, "sizeleft": 1.3 * GB, "timeleft": "00:01:40", "downloadClient": "SABnzbd",
"downloadId": "SABnzbd_nzo_a2", "protocol": "usenet", "seriesId": 1, "episodeId": 1021, "series": {"title": "Signal Fire"},
"episode": {"seasonNumber": 3, "episodeNumber": 5, "title": "Ember"}}]}
if path.endswith("/calendar"):
out = []
for off, sid, s, e, t in [(0, 1, 3, 7, "Updraft"), (2, 3, 2, 4, "Code Blue"), (4, 5, 1, 3, "Dock"), (7, 1, 3, 8, "Ash"), (11, 3, 2, 5, "Triage")]:
ser = next(x for x in series() if x["id"] == sid)
out.append({"id": 90000 + off, "seriesId": sid, "seasonNumber": s, "episodeNumber": e, "title": t,
"airDate": day(off), "airDateUtc": day(off) + "T20:00:00Z", "monitored": True, "hasFile": False,
"overview": "A new episode of " + ser["title"] + ".", "series": ser})
return out
return []
# A title Trakt lists that is not in the library yet.
UPCOMING = {"id": 0, "title": catalog.UPCOMING[1], "year": catalog.UPCOMING[2], "overview": "Years after the first voyage, a second crew follows the freighter's last signal.",
"tmdbId": 9001, "titleSlug": "the-long-orbit-return", "genres": ["Science Fiction"], "studio": "Halcyon Pictures", "runtime": 0,
"images": [{"coverType": "poster", "remoteUrl": "http://127.0.0.1:%d/remote/m9001.jpg" % RADARR}], "status": "announced", "ratings": {}}
FIRST = ["Maren", "Theo", "Ilse", "Jonah", "Priya", "Oskar", "Lena", "Rafael", "Wren", "Tobias", "Noor", "Elias", "Saoirse", "Kenji", "Ada", "Milo"]
LAST = ["Halvorsen", "Achebe", "Lindqvist", "Moreau", "Okafor", "Brandt", "Castellano", "Yamada", "Petrov", "Quinn", "Ferreira", "Novak"]
ROLES = ["Captain Ilsa Rhee", "Dr. Amos Kell", "Navigator Juno", "The Engineer", "Mara Voss", "Commander Teague", "Pilot Sev", "Young Ilsa", "Radio Operator", "Station Chief"]
def people(seed, show):
def name(i): return FIRST[(seed * 3 + i * 5) % len(FIRST)] + " " + LAST[(seed * 7 + i * 3) % len(LAST)]
cast = [{"character": ROLES[(seed + i) % len(ROLES)], "characters": [ROLES[(seed + i) % len(ROLES)]], "person": {"name": name(i), "ids": {"trakt": i}}} for i in range(9)]
lead = {"job": "Creator" if show else "Director", "jobs": ["Creator" if show else "Director"], "person": {"name": name(20), "ids": {"trakt": 99}}}
return {"cast": cast, "crew": {("created by" if show else "directing"): [lead]}}
def trakt(path):
parts = path.strip("/").split("/")
if parts[0] == "search": # /search/tmdb/1013?type=movie or /search/tvdb/2001?type=show
id = int(parts[2]); key = "movie" if parts[1] == "tmdb" else "show"
return [{"type": key, "score": 1000, key: {"title": "x", "year": 2020, "ids": {"trakt": id, "slug": "x"}}}]
if len(parts) == 3 and parts[2] == "people":
return people(int(parts[1]), parts[0] == "shows")
def movie(id, title, year): return {"title": title, "year": year, "ids": {"trakt": id, "slug": "x", "tmdb": 1000 + id if id < 9000 else id}}
def show(id): s = next(x for x in catalog.SERIES if x[0] == id); return {"title": s[1], "year": s[2], "ids": {"trakt": id, "slug": "x", "tvdb": 2000 + id}}
by = {m[0]: m for m in catalog.MOVIES}
if path == "/movies/trending":
return [{"watchers": n, "movie": movie(i, by[i][1], by[i][2])} for i, n in [(13, 2841), (1, 2217), (11, 1604), (6, 1186), (3, 902), (8, 655), (16, 412), (12, 371)]]
if path == "/movies/anticipated":
return [{"list_count": 18342, "movie": movie(9001, "The Long Orbit: Return", 2027)}] + \
[{"list_count": n, "movie": movie(i, by[i][1], by[i][2])} for i, n in [(15, 9120), (9, 6204), (4, 3311)]]
if path.startswith("/movies/"):
return [{"watcher_count": n * 10, "movie": movie(i, by[i][1], by[i][2])} for i, n in [(1, 5120), (13, 4870), (3, 3010), (2, 1200)]]
if path == "/shows/trending":
return [{"watchers": n, "show": show(i)} for i, n in [(1, 3022), (5, 2410), (3, 1170), (2, 640)]]
if path.startswith("/shows/"):
return [{"watcher_count": n, "show": show(i)} for i, n in [(3, 51200), (1, 40100), (4, 22000)]]
return []
def sab(q):
mode = q.get("mode", [""])[0]
if mode == "version": return {"version": "4.5.1"}
if mode == "queue":
return {"queue": {"status": "Downloading", "paused": False, "kbpersec": "48213", "speedlimit_abs": "0", "size": "26.4 GB", "sizeleft": "9.1 GB",
"timeleft": "0:08:05", "noofslots_total": 3, "slots": [
{"nzo_id": "nzo_a1", "filename": "Iron.Meridian.2021.2160p.WEB-DL.DDP5.1.Atmos.HDR.H.265-FLUX", "cat": "movies", "status": "Downloading",
"size": "21.9 GB", "sizeleft": "7.2 GB", "mb": "22425", "mbleft": "7372", "percentage": "67", "timeleft": "0:06:12"},
{"nzo_id": "nzo_a2", "filename": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "cat": "tv", "status": "Downloading",
"size": "2.1 GB", "sizeleft": "1.3 GB", "mb": "2150", "mbleft": "1331", "percentage": "38", "timeleft": "0:01:40"},
{"nzo_id": "nzo_a3", "filename": "Hearth.and.Home.S03.1080p.BluRay.x264-ROVERS", "cat": "tv", "status": "Queued",
"size": "2.4 GB", "sizeleft": "2.4 GB", "mb": "2457", "mbleft": "2457", "percentage": "0", "timeleft": "0:00:00"}]}}
if mode == "history":
now = int(datetime.datetime.now().timestamp())
return {"history": {"slots": [
{"nzo_id": "nzo_h1", "name": "The.Long.Orbit.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos-FRAMES", "category": "movies", "status": "Completed", "size": "58.2 GB", "fail_message": "", "completed": now - 3600},
{"nzo_id": "nzo_h2", "name": "Signal.Fire.S03E04.1080p.WEB-DL.DDP5.1.H.264-NTb", "category": "tv", "status": "Completed", "size": "2.0 GB", "fail_message": "", "completed": now - 7200},
{"nzo_id": "nzo_h3", "name": "Static.2018.1080p.BluRay.x264-AMIABLE", "category": "movies", "status": "Completed", "size": "6.0 GB", "fail_message": "", "completed": now - 20000},
{"nzo_id": "nzo_h4", "name": "Low.Tide.S02E06.720p.HDTV.x264-KILLERS", "category": "tv", "status": "Failed", "size": "1.1 GB", "fail_message": "Aborted, cannot be completed - https://sabnzbd.org/not-complete", "completed": now - 90000},
{"nzo_id": "nzo_h5", "name": "Neon.Harbour.2023.2160p.WEB-DL.DTS-HD.MA.HDR10Plus.HEVC-SMURF", "category": "movies", "status": "Completed", "size": "44.5 GB", "fail_message": "", "completed": now - 180000}]}}
return {}
def handler(port):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
# Like the real services, answer cross-origin requests (the webOS app
# runs in a browser).
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
self.send_header("Access-Control-Allow-Headers", self.headers.get("Access-Control-Request-Headers", "*"))
self.end_headers()
# Writes (add, grab, commands, monitoring, removal) are accepted and
# change nothing; an added title comes back with an id.
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
out = dict(body, id=900) if isinstance(body, dict) and self.path.split("?")[0].endswith(("/movie", "/series")) else {}
b = json.dumps(out).encode()
self.send_response(201 if out else 200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
do_PUT = do_POST
def do_DELETE(self):
self.send_response(200); self.send_header("Content-Length", "0"); self.end_headers()
def do_GET(self):
u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query); path = u.path
if path.startswith("/MediaCover/") or path.startswith("/remote/"):
if path.startswith("/MediaCover/"):
id = path.split("/")[2]; f = ("m" if port == RADARR else "s") + id + ".jpg"
else:
f = path.rsplit("/", 1)[1]
p = os.path.join(HERE, "posters", f)
if not os.path.exists(p): self.send_response(404); self.end_headers(); return
data = open(p, "rb").read()
self.send_response(200); self.send_header("Content-Type", "image/jpeg"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data); return
if port == TRAKT:
out = trakt(path)
else:
out = sab(q) if port == SAB else (radarr(path, q) if port == RADARR else sonarr(path, q))
b = json.dumps(out).encode()
self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
return H
for port in (RADARR, SONARR, TRAKT):
threading.Thread(target=ThreadingHTTPServer(("127.0.0.1", port), handler(port)).serve_forever, daemon=True).start()
ThreadingHTTPServer(("127.0.0.1", SAB), handler(SAB)).serve_forever()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Take the README screenshots: build arr-tui, start the fake services with the
# made-up library, and run every scene in Ghostty on a virtual X display. The
# pictures go to docs/screenshots/ (losslessly compressed if oxipng is there).
#
# Needs: go, python3, ghostty, xvfb-run (Xvfb), xdotool, ImageMagick 7,
# fontconfig with DejaVu fonts; oxipng optional. On NixOS:
# nix develop --command dev/screenshots/take.sh
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
out=$repo/docs/screenshots
# The Config view shows the config file's path; keep it neutral.
demo=/tmp/arr-tui-demo
for tool in go python3 ghostty xvfb-run xdotool magick fc-match; do
command -v "$tool" >/dev/null || { echo "take.sh: $tool is missing" >&2; exit 1; }
done
work=$(mktemp -d)
cleanup() {
[[ -n ${server:-} ]] && kill "$server" 2>/dev/null
rm -rf "$work" "$demo"
}
trap cleanup EXIT
echo "building arr-tui"
go build -C "$repo" -o "$work/arr-tui" ./cmd/arr-tui
[[ -d $here/posters ]] || { echo "generating posters"; "$here/posters.sh"; }
echo "starting the fake Radarr, Sonarr, SABnzbd and Trakt"
export ARR_TUI_TRAKT_URL=http://127.0.0.1:19894
python3 "$here/server.py" &
server=$!
sleep 1
# One config per theme, each at $demo/<theme>/.config/arr-tui/config.json.
for theme in dark light; do
mkdir -p "$demo/$theme/.config/arr-tui"
cat >"$demo/$theme/.config/arr-tui/config.json" <<JSON
{
"radarr": {"url": "http://127.0.0.1:19891", "apikey": "0123456789abcdef0123456789abcdef", "default_quality_profile": "Ultra-HD", "default_root_folder": "/movies"},
"sonarr": {"url": "http://127.0.0.1:19892", "apikey": "0123456789abcdef0123456789abcdef", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv"},
"sabnzbd": {"url": "http://127.0.0.1:19893", "apikey": "0123456789abcdef0123456789abcdef"},
"trakt": {"client_id": "0123456789abcdef0123456789abcdef"},
"ui": {"theme": "$theme"}
}
JSON
chmod 600 "$demo/$theme/.config/arr-tui/config.json"
done
mkdir -p "$out"
shoot() { # scene theme [ghostty args]
echo "scene $1"
XDG_CONFIG_HOME=$demo/$2/.config "$here/xshot.sh" "$here/scenes/$1.txt" "$out" "${3:-}" "$work/arr-tui"
}
shoot views dark
shoot discover dark
shoot episodes dark
shoot config dark
shoot light light "--background=eeeeee --foreground=1c1c1c"
if command -v oxipng >/dev/null; then
oxipng -o 4 --strip safe -q "$out"/*.png
fi
echo "screenshots in $out:"
ls "$out"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Run a command in Ghostty on a virtual X display and follow a scene script.
#
# xshot.sh <scene> <outdir> "<extra ghostty args>" <command...>
#
# Scene lines: key <keysyms...> | type <text> | sleep <seconds> | shot <name>
# (shot saves the Ghostty window as <outdir>/<name>.png).
set -euo pipefail
scene=$1 out=$2 extra=$3
shift 3
export GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GSK_RENDERER=gl
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
# shellcheck disable=SC2016 # expanded by the inner shell
xvfb-run -a -s "-screen 0 1800x1200x24" bash -c '
scene=$1 out=$2 extra=$3
shift 3
# shellcheck disable=SC2086 # extra holds several arguments
ghostty --gtk-single-instance=false --class=arrshot --window-width=132 --window-height=38 \
--window-decoration=false --confirm-close-surface=false --font-size=12 \
--window-padding-x=8 --window-padding-y=6 $extra -e "$@" >/dev/null 2>&1 &
ghostty=$!
sleep 7 # Ghostty with software rendering takes a while to show its window
wid=$(xdotool search --onlyvisible --pid "$ghostty" | tail -n1)
while read -r cmd args; do
case $cmd in
key) for k in $args; do xdotool key "$k"; sleep 0.25; done ;;
type) xdotool type --delay 80 "$args"; sleep 0.5 ;;
sleep) sleep "$args" ;;
shot) import -window "$wid" "$out/$args.png" ;;
""|"#"*) ;;
*) echo "unknown scene command: $cmd" >&2; exit 1 ;;
esac
done < "$scene"
kill "$ghostty" 2>/dev/null || true
' _ "$scene" "$out" "$extra" "$@"
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
description = "arr-tui: a terminal UI (and webOS TV app) for Radarr, Sonarr and SABnzbd";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
# nix develop: everything make test, the release script, the README
# screenshots (dev/screenshots) and the webOS app (webos/) need.
# Containers for dev/up.sh (docker or podman) come from the system.
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
# arr-tui
go
gopls
gnumake
shellcheck
git
# scripts/release.sh, dev/up.sh
curl
jq
# dev/screenshots (fake services), webos (app, tests, ares CLI)
python3
nodejs_24
# README pictures: oxipng compresses them
oxipng
imagemagick
] ++ lib.optionals stdenv.hostPlatform.isLinux [
# dev/screenshots/take.sh: Ghostty on a virtual X display
ghostty
xvfb-run
xorg-server
xdotool
fontconfig
dejavu_fonts
# webos/dev/drive.mjs
chromium
];
# posters.sh asks fontconfig for the DejaVu fonts: the system's
# fonts plus DejaVu.
FONTCONFIG_FILE = pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux (pkgs.writeText "fonts.conf" ''
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
<dir>${pkgs.dejavu_fonts}/share/fonts</dir>
</fontconfig>
'');
shellHook = pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux ''
export CHROMIUM=${pkgs.chromium}/bin/chromium
'';
};
});
};
}
+4 -3
View File
@@ -5,13 +5,14 @@ go 1.26.8
require (
github.com/gdamore/tcell/v2 v2.13.10
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 (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/text v0.43.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=
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/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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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/url"
"strings"
"sync"
"time"
)
// Client is a minimal JSON client for the Radarr/Sonarr v3 API.
type Client struct {
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
baseURL string
apiKey string
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).
func NewClient(baseURL, apiKey string) *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.
type APIError struct {
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
// 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 {
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 {
u += "?" + query.Encode()
}
@@ -64,7 +90,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("X-Api-Key", apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
+116 -18
View File
@@ -2,10 +2,12 @@ package arr
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -16,41 +18,101 @@ const maxImageBytes = 8 << 20
// function that downloads it, or ok=false when there is none.
//
// 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
// in the library yet only have the remote URL of the metadata provider; that
// is fetched without the API key.
func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(ctx context.Context) ([]byte, error), ok bool) {
// with the API key: the copy 250 pixels tall (-250), or the original when
// fetch is asked for a large poster. Titles that are not in
// the library yet only have the remote URL of the metadata provider; that is
// 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 {
if img.CoverType != "poster" {
continue
}
if inLibrary && img.URL != "" {
local := img.URL
return c.baseURL + "|" + local, func(ctx context.Context) ([]byte, error) {
// MediaCover has poster-250.jpg next to poster.jpg; fall back to the original.
if small := smallVariant(local); small != local {
if data, err := c.GetBytes(ctx, small); err == nil {
return data, nil
local, remote := img.URL, img.RemoteURL
base, _ := c.settings()
return base + "|" + local, func(ctx context.Context, large bool) ([]byte, error) {
data, err := c.localPoster(ctx, local, large)
if err != nil && remote != "" {
// 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, tmdbSized(remote, large)); remoteErr == nil {
return remoteData, nil
}
}
return c.GetBytes(ctx, local)
return data, err
}, true
}
if img.RemoteURL != "" {
remote := img.RemoteURL
return remote, func(ctx context.Context) ([]byte, error) { return FetchURL(ctx, remote) }, true
return remote, func(ctx context.Context, large bool) ([]byte, error) {
return FetchURL(ctx, tmdbSized(remote, large))
}, true
}
}
return "", nil, false
}
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x.
func smallVariant(p string) string {
// localPoster fetches a poster from the instance: the small variant (-250),
// then the original; when large, only 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
}
// Radarr/Sonarr keep copies 250 and 500 pixels tall; even the larger one
// is smaller than a real image is shown, so those take the original.
if large {
return try(local)
}
if scaled := scaledVariant(local, 250); 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)
}
// tmdbSized asks TheMovieDB for a poster of the size that is shown instead of
// the original Radarr hands out (often 2000×3000 and over 2 MB, which takes
// longer to download and to decode than everything else together): 780
// pixels wide for real images, 342 for block characters. Other URLs are
// returned as they are.
func tmdbSized(rawURL string, large bool) string {
u, err := url.Parse(rawURL)
if err != nil || u.Host != "image.tmdb.org" || !strings.HasPrefix(u.Path, "/t/p/original/") {
return rawURL
}
size := "w342"
if large {
size = "w780"
}
u.Path = "/t/p/" + size + strings.TrimPrefix(u.Path, "/t/p/original")
return u.String()
}
// 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, "?")
if !strings.HasSuffix(path, "/poster.jpg") {
return p
}
path = strings.TrimSuffix(path, ".jpg") + "-250.jpg"
path = strings.TrimSuffix(path, ".jpg") + "-" + strconv.Itoa(width) + ".jpg"
if hasQuery {
return path + "?" + query
}
@@ -60,29 +122,60 @@ func smallVariant(p string) string {
// GetBytes downloads a server-relative path such as /MediaCover/1/poster.jpg
// from the instance, authenticated with the API key.
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)
if err != nil {
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)
if err != nil {
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)
}
// 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
// their URL base in image paths, so it must not be added twice.
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 {
return "", err
}
if base.Path != "" && base.Path != "/" && strings.HasPrefix(ref, strings.TrimRight(base.Path, "/")+"/") {
return base.Scheme + "://" + base.Host + ref, nil
}
return c.baseURL + ref, nil
return baseURL + ref, nil
}
var imageClient = &http.Client{Timeout: 20 * time.Second}
@@ -109,6 +202,11 @@ func doImage(client *http.Client, req *http.Request) ([]byte, error) {
if resp.StatusCode != http.StatusOK {
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))
if err != nil {
return nil, err
+147 -9
View File
@@ -4,21 +4,63 @@ import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSmallVariant(t *testing.T) {
for in, want := range map[string]string{
"/MediaCover/1/poster.jpg?lastWrite=5": "/MediaCover/1/poster-250.jpg?lastWrite=5",
"/MediaCover/1/poster.jpg": "/MediaCover/1/poster-250.jpg",
"/MediaCover/1/fanart.jpg": "/MediaCover/1/fanart.jpg",
func TestTMDBSized(t *testing.T) {
for _, tc := range []struct {
in string
large bool
want string
}{
{"https://image.tmdb.org/t/p/original/abc.jpg", true, "https://image.tmdb.org/t/p/w780/abc.jpg"},
{"https://image.tmdb.org/t/p/original/abc.jpg", false, "https://image.tmdb.org/t/p/w342/abc.jpg"},
{"https://image.tmdb.org/t/p/w500/abc.jpg", true, "https://image.tmdb.org/t/p/w500/abc.jpg"},
{"https://artworks.thetvdb.com/banners/posters/1.jpg", true, "https://artworks.thetvdb.com/banners/posters/1.jpg"},
} {
if got := smallVariant(in); got != want {
t.Errorf("smallVariant(%q) = %q, want %q", in, got, want)
if got := tmdbSized(tc.in, tc.large); got != tc.want {
t.Errorf("tmdbSized(%q, %v) = %q, want %q", tc.in, tc.large, got, tc.want)
}
}
}
func TestScaledVariant(t *testing.T) {
for _, tc := range []struct {
in string
width int
want string
}{
{"/MediaCover/1/poster.jpg?lastWrite=5", 250, "/MediaCover/1/poster-250.jpg?lastWrite=5"},
{"/MediaCover/1/poster.jpg", 500, "/MediaCover/1/poster-500.jpg"},
{"/MediaCover/1/fanart.jpg", 250, "/MediaCover/1/fanart.jpg"},
} {
if got := scaledVariant(tc.in, tc.width); got != tc.want {
t.Errorf("scaledVariant(%q, %d) = %q, want %q", tc.in, tc.width, got, tc.want)
}
}
}
func TestLargePosterUsesTheOriginal(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.jpg /MediaCover/1/poster-250.jpg" {
t.Errorf("requests = %v", paths)
}
}
func TestPosterLocalUsesAPIKey(t *testing.T) {
var paths []string
var keys []string
@@ -39,7 +81,7 @@ func TestPosterLocalUsesAPIKey(t *testing.T) {
if !ok || key == "" {
t.Fatal("no poster")
}
data, err := fetch(context.Background())
data, err := fetch(context.Background(), false)
if err != nil || string(data) != "IMG" {
t.Fatalf("data=%q err=%v", data, err)
}
@@ -66,7 +108,7 @@ func TestPosterRemoteSendsNoAPIKey(t *testing.T) {
if !ok || key != srv.URL+"/p.jpg" {
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)
}
if got != "" {
@@ -94,3 +136,99 @@ func TestResolveURLBase(t *testing.T) {
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)
}
}
+3
View File
@@ -31,6 +31,9 @@ type QueueItem struct {
DownloadID string `json:"downloadId"` // the download client's id, e.g. SABnzbd's nzo_id
Indexer string `json:"indexer"`
Protocol string `json:"protocol"`
MovieID int `json:"movieId,omitempty"` // Radarr
SeriesID int `json:"seriesId,omitempty"` // Sonarr
EpisodeID int `json:"episodeId,omitempty"` // Sonarr; one item per episode
// Present depending on the include* parameters of the request.
Movie *QueueMovie `json:"movie,omitempty"`
+36
View File
@@ -46,3 +46,39 @@ func TestSetSeasonMonitoredUnknownSeason(t *testing.T) {
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
}
+161 -7
View File
@@ -18,6 +18,24 @@ type Service struct {
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.
type Arr struct {
Service
@@ -32,22 +50,45 @@ type UI struct {
// Posters of titles that are not in the library yet are downloaded from
// the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB).
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:
// "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
// block, e.g. kitty, WezTerm, foot, Ghostty) or "auto" (default: sextant
// on terminals known to draw them, quadrant elsewhere).
// block, e.g. kitty, WezTerm, foot, Ghostty), "kitty" (real images through
// 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"`
// 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.
func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers }
// Trakt holds the client ID of a Trakt API app, used for the trending,
// most watched and anticipated lists. Optional.
type Trakt struct {
ClientID string `json:"client_id,omitempty"`
}
// Config is the full application configuration.
type Config struct {
Radarr Arr `json:"radarr"`
Sonarr Arr `json:"sonarr"`
SABnzbd Service `json:"sabnzbd"`
Trakt Trakt `json:"trakt"`
UI UI `json:"ui"`
}
@@ -92,14 +133,54 @@ func Load(path string) (*Config, []string, error) {
// Parse decodes config JSON, applies environment overrides and defaults, and
// validates the result. Unknown fields are rejected to catch typos.
func Parse(data []byte) (*Config, error) {
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
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
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()
if err := cfg.Validate(); err != nil {
return nil, err
@@ -107,6 +188,67 @@ func Parse(data []byte) (*Config, error) {
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",
"trakt.client_id": "ARR_TUI_TRAKT_CLIENT_ID",
}
// 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) {
override := func(dst *string, key string) {
if v := get(key); v != "" {
@@ -119,6 +261,7 @@ func (c *Config) applyEnv(get func(string) string) {
override(&c.Sonarr.APIKey, "ARR_TUI_SONARR_APIKEY")
override(&c.SABnzbd.URL, "ARR_TUI_SABNZBD_URL")
override(&c.SABnzbd.APIKey, "ARR_TUI_SABNZBD_APIKEY")
override(&c.Trakt.ClientID, "ARR_TUI_TRAKT_CLIENT_ID")
}
func (c *Config) applyDefaults() {
@@ -128,9 +271,12 @@ func (c *Config) applyDefaults() {
c.Radarr.URL = strings.TrimRight(c.Radarr.URL, "/")
c.Sonarr.URL = strings.TrimRight(c.Sonarr.URL, "/")
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
c.Trakt.ClientID = strings.TrimSpace(c.Trakt.ClientID)
}
// 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 {
var errs []error
for _, s := range []struct {
@@ -141,6 +287,9 @@ func (c *Config) Validate() error {
{"sonarr", c.Sonarr.Service},
{"sabnzbd", c.SABnzbd},
} {
if !s.svc.Configured() {
continue // not set up (yet); the program says so where it is needed
}
if s.svc.APIKey == "" {
errs = append(errs, fmt.Errorf("%s: apikey is required", s.name))
}
@@ -153,9 +302,14 @@ func (c *Config) Validate() error {
}
}
switch c.UI.CoverStyle {
case "", "auto", "quadrant", "sextant":
case "", "auto", "quadrant", "sextant", "cells", "kitty":
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...)
}
+138 -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"},
"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"},
"key only": {`{"radarr":{"apikey":"x"}}`, "radarr: url is required"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
@@ -100,12 +101,147 @@ func TestShowCovers(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":`
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `""`} {
for _, ok := range []string{`"auto"`, `"quadrant"`, `"sextant"`, `"cells"`, `"kitty"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
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")
}
}
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)
}
}
func TestTraktClientID(t *testing.T) {
cfg, err := Parse([]byte(`{"trakt":{"client_id":" abc "}}`))
if err != nil || cfg.Trakt.ClientID != "abc" {
t.Fatalf("client id %q, %v", cfg.Trakt.ClientID, err)
}
t.Setenv("ARR_TUI_TRAKT_CLIENT_ID", "from-env")
cfg, _ = Parse([]byte(`{}`))
if cfg.Trakt.ClientID != "from-env" || Overrides(os.Getenv)["trakt.client_id"] != "ARR_TUI_TRAKT_CLIENT_ID" {
t.Errorf("env override: %q", cfg.Trakt.ClientID)
}
}
+42
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
@@ -56,6 +57,9 @@ type Movie struct {
Monitored bool `json:"monitored"`
HasFile bool `json:"hasFile"`
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"`
IsAvailable bool `json:"isAvailable"`
Path string `json:"path,omitempty"`
@@ -98,6 +102,19 @@ func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
return movies, err
}
// MovieByTMDB returns the library movie with the TMDb id, ok=false if the
// library has none. Unlike a lookup it does not ask the metadata provider.
func (c *Client) MovieByTMDB(ctx context.Context, tmdbID int) (Movie, bool, error) {
var movies []Movie
err := c.Do(ctx, http.MethodGet, "movie", url.Values{"tmdbId": {strconv.Itoa(tmdbID)}}, nil, &movies)
for _, m := range movies { // checked: a version that ignores the filter sends the whole library
if m.TMDBID == tmdbID && m.InLibrary() {
return m, true, err
}
}
return Movie{}, false, err
}
// AddOptions are the choices from the add form.
type AddOptions struct {
QualityProfileID int
@@ -170,3 +187,28 @@ func (c *Client) SetMonitored(ctx context.Context, movieID int, monitored bool)
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/httptest"
"testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
@@ -161,3 +162,36 @@ func TestReleases(t *testing.T) {
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"
"strconv"
"strings"
"sync"
"time"
)
// Client talks to a SABnzbd instance.
type Client struct {
mu sync.RWMutex // guards baseURL and apiKey, which Reconfigure can change
baseURL string
apiKey string
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.
func NewClient(baseURL, apiKey string) *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").
type Number float64
@@ -159,9 +173,15 @@ func boolParam(b bool) string {
// {"status": false, "error": "..."}, which is turned into an error here.
func (c *Client) call(ctx context.Context, params url.Values, out any) error {
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 {
return stripURL(err)
}
+19
View File
@@ -122,3 +122,22 @@ func TestTransportErrorHidesAPIKey(t *testing.T) {
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)
}
}
+55 -9
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
@@ -45,6 +46,9 @@ type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
Statistics Statistics `json:"statistics"`
// Images are the season's posters, only filled by GetSeries; Sonarr keeps
// no copy of them, so they only have a remote URL (TheTVDB).
Images []arr.Image `json:"images,omitempty"`
}
// Statistics are the episode counts Sonarr reports for library series.
@@ -107,6 +111,19 @@ func (c *Client) Series(ctx context.Context) ([]Series, error) {
return series, err
}
// SeriesByTVDB returns the library series with the TVDB id, ok=false if the
// library has none. Unlike a lookup it does not ask the metadata provider.
func (c *Client) SeriesByTVDB(ctx context.Context, tvdbID int) (Series, bool, error) {
var series []Series
err := c.Do(ctx, http.MethodGet, "series", url.Values{"tvdbId": {strconv.Itoa(tvdbID)}}, nil, &series)
for _, s := range series { // checked: a version that ignores the filter sends the whole library
if s.TVDBID == tvdbID && s.InLibrary() {
return s, true, err
}
}
return Series{}, false, err
}
// AddOptions are the choices from the add form.
type AddOptions struct {
QualityProfileID int
@@ -178,7 +195,8 @@ func (c *Client) SearchEpisode(ctx context.Context, episodeID int) error {
// GetSeries fetches one library series.
func (c *Client) GetSeries(ctx context.Context, id int) (Series, error) {
var s Series
err := c.Do(ctx, http.MethodGet, "series/"+strconv.Itoa(id), nil, nil, &s)
// Season posters are only sent when asked for.
err := c.Do(ctx, http.MethodGet, "series/"+strconv.Itoa(id), url.Values{"includeSeasonImages": {"true"}}, nil, &s)
return s, err
}
@@ -210,14 +228,17 @@ func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) {
// Episode is an episode of a library series.
type Episode struct {
ID int `json:"id"`
SeriesID int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
AirDate string `json:"airDate"`
HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"`
ID int `json:"id"`
SeriesID int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
AirDate string `json:"airDate"`
AirDateUTC string `json:"airDateUtc"`
Overview string `json:"overview,omitempty"`
HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"`
Series *Series `json:"series,omitempty"` // only with includeSeries
}
// Episodes lists all episodes of a library series.
@@ -246,3 +267,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)
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
}
+54
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
const lookupJSON = `[
@@ -164,3 +165,56 @@ func TestEpisodeFiles(t *testing.T) {
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)
}
}
func TestGetSeriesAsksForSeasonPosters(t *testing.T) {
var query string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query = r.URL.RawQuery
w.Write([]byte(`{"id":7,"title":"Andor","seasons":[{"seasonNumber":1,"monitored":true,
"images":[{"coverType":"poster","remoteUrl":"https://artworks.thetvdb.com/banners/seasons/1.jpg"}]}]}`))
}))
defer srv.Close()
s, err := NewClient(srv.URL, "k").GetSeries(t.Context(), 7)
if err != nil {
t.Fatal(err)
}
if query != "includeSeasonImages=true" {
t.Errorf("query %q", query)
}
if len(s.Seasons) != 1 || len(s.Seasons[0].Images) != 1 || s.Seasons[0].Images[0].RemoteURL == "" {
t.Errorf("season images %+v", s.Seasons)
}
}
+386
View File
@@ -0,0 +1,386 @@
// Package trakt reads the public lists of Trakt (trakt.tv): trending, most
// watched, anticipated, popular and box office movies and shows. Only a client
// ID is needed; nothing is written and no user account is involved.
package trakt
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// DefaultBaseURL is the Trakt API.
const DefaultBaseURL = "https://api.trakt.tv"
// ErrNotConfigured is returned without a request while no client ID is set.
var ErrNotConfigured = errors.New("no Trakt client ID: create an API app on trakt.tv and enter its client ID in the Config view (F6)")
// Kind says whether a list holds movies or shows.
type Kind int
const (
Movies Kind = iota
Shows
)
// List is one of Trakt's public lists.
type List struct {
Name string // shown to the user, e.g. "Trending now"
path string // e.g. "/movies/trending"
count string // the field holding the list's number, "" for none
// Note formats the number for display, e.g. "1.2k watching".
note func(n int64) string
kind Kind
}
// Kind returns whether the list holds movies or shows.
func (l List) Kind() Kind { return l.kind }
// Note describes an entry's number in this list, or "" when it has none.
func (l List) Note(n int64) string {
if l.note == nil || n <= 0 {
return ""
}
return l.note(n)
}
func watching(n int64) string { return Compact(n) + " watching" }
func watchers(n int64) string { return Compact(n) + " watched it" }
func lists(n int64) string { return "on " + Compact(n) + " lists" }
func revenue(n int64) string { return "$" + Compact(n) + " last weekend" }
func movieList(name, path, count string, note func(int64) string) List {
return List{Name: name, path: path, count: count, note: note, kind: Movies}
}
func showList(name, path, count string, note func(int64) string) List {
return List{Name: name, path: path, count: count, note: note, kind: Shows}
}
// MovieLists and ShowLists are the lists arr-tui offers, in order.
var (
MovieLists = []List{
movieList("Trending now", "/movies/trending", "watchers", watching),
movieList("Most watched this week", "/movies/watched/weekly", "watcher_count", watchers),
movieList("Anticipated", "/movies/anticipated", "list_count", lists),
movieList("Popular", "/movies/popular", "", nil),
movieList("Box office (US, last weekend)", "/movies/boxoffice", "revenue", revenue),
}
ShowLists = []List{
showList("Trending now", "/shows/trending", "watchers", watching),
showList("Most watched this week", "/shows/watched/weekly", "watcher_count", watchers),
showList("Anticipated", "/shows/anticipated", "list_count", lists),
showList("Popular", "/shows/popular", "", nil),
}
)
// IDs are the identifiers Trakt knows for a title. TVDB is set for shows only.
type IDs struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
IMDB string `json:"imdb"`
TMDB int `json:"tmdb"`
TVDB int `json:"tvdb"`
}
// Entry is one title on a list, with the list's number for it (watchers,
// lists it is on, revenue; 0 for lists without one).
type Entry struct {
Title string `json:"title"`
Year int `json:"year"`
IDs IDs `json:"ids"`
Count int64 `json:"-"`
}
// Client talks to the Trakt API.
type Client struct {
mu sync.RWMutex
baseURL string
clientID string
userAgent string
http *http.Client
}
// NewClient returns a client with the given client ID (may be empty: then
// every call returns ErrNotConfigured).
func NewClient(clientID, userAgent string) *Client {
return &Client{baseURL: DefaultBaseURL, clientID: strings.TrimSpace(clientID), userAgent: userAgent,
http: &http.Client{Timeout: 20 * time.Second}}
}
// Reconfigure sets another client ID.
func (c *Client) Reconfigure(clientID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.clientID = strings.TrimSpace(clientID)
}
// SetBaseURL points the client at another server (tests).
func (c *Client) SetBaseURL(u string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimRight(u, "/")
}
// Configured reports whether a client ID is set.
func (c *Client) Configured() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.clientID != ""
}
// Entries fetches up to limit entries of a list.
func (c *Client) Entries(ctx context.Context, l List, limit int) ([]Entry, error) {
data, err := c.get(ctx, l.path, url.Values{"page": {"1"}, "limit": {strconv.Itoa(limit)}})
if err != nil {
return nil, err
}
entries, err := decode(data, l)
if err != nil {
return nil, fmt.Errorf("trakt: decoding %s: %w", l.path, err)
}
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
// get sends a GET request to the API and returns the body of a successful answer.
func (c *Client) get(ctx context.Context, path string, query url.Values) ([]byte, error) {
c.mu.RLock()
base, id := c.baseURL, c.clientID
c.mu.RUnlock()
if id == "" {
return nil, ErrNotConfigured
}
u := base + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("trakt-api-key", id)
req.Header.Set("trakt-api-version", "2")
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
return nil, fmt.Errorf("trakt: %w", ue.Err)
}
return nil, err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
return nil, fmt.Errorf("trakt: the client ID was not accepted (HTTP %d)", resp.StatusCode)
case resp.StatusCode == http.StatusTooManyRequests:
return nil, errors.New("trakt: too many requests, try again in a few minutes")
case resp.StatusCode == http.StatusNotFound:
return nil, ErrNotFound
case resp.StatusCode < 200 || resp.StatusCode > 299:
return nil, fmt.Errorf("trakt: HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
}
// ErrNotFound is returned when Trakt does not know a title.
var ErrNotFound = errors.New("trakt: not found")
// Ref names a title by an id of another database, as Radarr and Sonarr have
// them: TMDb for movies, TVDB for shows, IMDb for both.
type Ref struct {
Kind Kind
TMDB int
TVDB int
IMDB string
}
// Key identifies the title, e.g. for caching ("" when it has no id).
func (r Ref) Key() string {
k := "movie:"
if r.Kind == Shows {
k = "show:"
}
switch {
case r.Kind == Movies && r.TMDB > 0:
return k + "tmdb:" + strconv.Itoa(r.TMDB)
case r.Kind == Shows && r.TVDB > 0:
return k + "tvdb:" + strconv.Itoa(r.TVDB)
case r.IMDB != "":
return k + "imdb:" + r.IMDB
}
return ""
}
// Person is a member of the cast or crew.
type Person struct {
Name string
Role string // the character played, or the job
}
// People are the cast (in Trakt's order) and the directors (movies) or
// creators (shows) of a title.
type People struct {
Cast []Person
Leaders []Person // directors of a movie, creators of a show
}
// People looks the title up on Trakt and fetches its cast and crew.
func (c *Client) People(ctx context.Context, r Ref) (People, error) {
id, err := c.traktID(ctx, r)
if err != nil {
return People{}, err
}
kind := "movies"
if r.Kind == Shows {
kind = "shows"
}
data, err := c.get(ctx, "/"+kind+"/"+strconv.Itoa(id)+"/people", nil)
if err != nil {
return People{}, err
}
var raw struct {
Cast []struct {
Character string `json:"character"`
Characters []string `json:"characters"`
Person struct {
Name string `json:"name"`
} `json:"person"`
} `json:"cast"`
Crew map[string][]struct {
Job string `json:"job"`
Jobs []string `json:"jobs"`
Person struct {
Name string `json:"name"`
} `json:"person"`
} `json:"crew"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return People{}, fmt.Errorf("trakt: decoding people: %w", err)
}
var p People
for _, m := range raw.Cast {
role := m.Character
if len(m.Characters) > 0 {
role = strings.Join(m.Characters, " / ")
}
p.Cast = append(p.Cast, Person{Name: m.Person.Name, Role: role})
}
// Directors of a movie; for a show the "created by" credits.
key, job := "directing", "Director"
if r.Kind == Shows {
key, job = "created by", ""
}
for _, m := range raw.Crew[key] {
jobs := append([]string{m.Job}, m.Jobs...)
if job == "" || contains(jobs, job) {
p.Leaders = append(p.Leaders, Person{Name: m.Person.Name, Role: m.Job})
}
}
return p, nil
}
// traktID finds Trakt's id of a title from its TMDb, TVDB or IMDb id.
func (c *Client) traktID(ctx context.Context, r Ref) (int, error) {
idType, id := "imdb", r.IMDB
switch {
case r.Kind == Movies && r.TMDB > 0:
idType, id = "tmdb", strconv.Itoa(r.TMDB)
case r.Kind == Shows && r.TVDB > 0:
idType, id = "tvdb", strconv.Itoa(r.TVDB)
case id == "":
return 0, ErrNotFound
}
kind := "movie"
if r.Kind == Shows {
kind = "show"
}
data, err := c.get(ctx, "/search/"+idType+"/"+url.PathEscape(id), url.Values{"type": {kind}})
if err != nil {
return 0, err
}
var results []map[string]json.RawMessage
if err := json.Unmarshal(data, &results); err != nil {
return 0, fmt.Errorf("trakt: decoding search: %w", err)
}
for _, res := range results {
var media struct {
IDs IDs `json:"ids"`
}
if raw, ok := res[kind]; ok && json.Unmarshal(raw, &media) == nil && media.IDs.Trakt > 0 {
return media.IDs.Trakt, nil
}
}
return 0, ErrNotFound
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// decode reads a list response: either bare titles, or items with the title
// under "movie"/"show" next to the list's number.
func decode(data []byte, l List) ([]Entry, error) {
var items []map[string]json.RawMessage
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
key := "movie"
if l.kind == Shows {
key = "show"
}
out := make([]Entry, 0, len(items))
for _, item := range items {
var e Entry
raw, wrapped := item[key]
if !wrapped {
// A bare title: the item is the title itself.
b, _ := json.Marshal(item)
raw = b
}
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
if l.count != "" {
if n, ok := item[l.count]; ok {
_ = json.Unmarshal(n, &e.Count)
}
}
out = append(out, e)
}
return out, nil
}
// Compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
func Compact(n int64) string {
switch {
case n < 1000:
return strconv.FormatInt(n, 10)
case n < 10_000:
return trimZero(fmt.Sprintf("%.1fk", float64(n)/1000))
case n < 1_000_000:
return fmt.Sprintf("%dk", n/1000)
case n < 10_000_000:
return trimZero(fmt.Sprintf("%.1fM", float64(n)/1_000_000))
default:
return fmt.Sprintf("%dM", n/1_000_000)
}
}
func trimZero(s string) string { return strings.Replace(s, ".0", "", 1) }
+139
View File
@@ -0,0 +1,139 @@
package trakt
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestEntries(t *testing.T) {
var got http.Header
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, gotQuery = r.Header, r.URL.RawQuery
switch r.URL.Path {
case "/movies/trending":
w.Write([]byte(`[{"watchers":1234,"movie":{"title":"Dune","year":2021,"ids":{"trakt":1,"slug":"dune-2021","imdb":"tt1160419","tmdb":438631}}},
{"watchers":7,"movie":{"title":"No Year","year":null,"ids":{"trakt":2,"slug":"x","imdb":null,"tmdb":null}}}]`))
case "/movies/popular":
w.Write([]byte(`[{"title":"Heat","year":1995,"ids":{"trakt":3,"slug":"heat-1995","tmdb":949}}]`))
case "/shows/watched/weekly":
w.Write([]byte(`[{"watcher_count":52000,"play_count":1,"collected_count":2,"collector_count":3,"show":{"title":"Andor","year":2022,"ids":{"trakt":4,"slug":"andor","tvdb":393189,"tmdb":83867}}}]`))
case "/movies/boxoffice":
w.Write([]byte(`[{"revenue":48464322,"movie":{"title":"Big","year":2026,"ids":{"trakt":5,"tmdb":5}}}]`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := NewClient(" my-id ", "arr-tui/test")
c.SetBaseURL(srv.URL)
movies, err := c.Entries(context.Background(), MovieLists[0], 20)
if err != nil {
t.Fatal(err)
}
if got.Get("trakt-api-key") != "my-id" || got.Get("trakt-api-version") != "2" || got.Get("User-Agent") != "arr-tui/test" ||
got.Get("Content-Type") != "application/json" {
t.Errorf("headers: %v", got)
}
if gotQuery != "limit=20&page=1" {
t.Errorf("query %q", gotQuery)
}
if len(movies) != 2 || movies[0].Title != "Dune" || movies[0].IDs.TMDB != 438631 || movies[0].Count != 1234 || movies[1].IDs.TMDB != 0 {
t.Errorf("trending: %+v", movies)
}
if note := MovieLists[0].Note(movies[0].Count); note != "1.2k watching" {
t.Errorf("note %q", note)
}
popular, err := c.Entries(context.Background(), MovieLists[3], 20)
if err != nil || len(popular) != 1 || popular[0].Title != "Heat" || popular[0].IDs.TMDB != 949 || MovieLists[3].Note(popular[0].Count) != "" {
t.Errorf("popular (bare items): %+v %v", popular, err)
}
shows, err := c.Entries(context.Background(), ShowLists[1], 20)
if err != nil || len(shows) != 1 || shows[0].IDs.TVDB != 393189 || shows[0].Count != 52000 || ShowLists[1].Note(52000) != "52k watched it" {
t.Errorf("most watched shows: %+v %v", shows, err)
}
box, err := c.Entries(context.Background(), MovieLists[4], 20)
if err != nil || box[0].Count != 48464322 || MovieLists[4].Note(box[0].Count) != "$48M last weekend" {
t.Errorf("box office: %+v %v", box, err)
}
}
func TestEntriesErrors(t *testing.T) {
if _, err := NewClient("", "ua").Entries(context.Background(), MovieLists[0], 5); !errors.Is(err, ErrNotConfigured) {
t.Errorf("no client id: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
c := NewClient("bad", "ua")
c.SetBaseURL(srv.URL)
if _, err := c.Entries(context.Background(), MovieLists[0], 5); err == nil || err.Error() != "trakt: the client ID was not accepted (HTTP 403)" {
t.Errorf("rejected id: %v", err)
}
}
func TestCompact(t *testing.T) {
for n, want := range map[int64]string{0: "0", 950: "950", 1000: "1k", 1234: "1.2k", 34567: "34k", 1500000: "1.5M", 48464322: "48M"} {
if got := Compact(n); got != want {
t.Errorf("Compact(%d) = %q, want %q", n, got, want)
}
}
}
func TestPeople(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
switch r.URL.Path {
case "/search/tmdb/272":
w.Write([]byte(`[{"type":"movie","score":1000,"movie":{"title":"Batman Begins","year":2005,"ids":{"trakt":1,"slug":"batman-begins-2005","tmdb":272}}}]`))
case "/movies/1/people":
w.Write([]byte(`{"cast":[{"character":"Bruce Wayne","characters":["Bruce Wayne","Batman"],"person":{"name":"Christian Bale","ids":{"trakt":1}}},
{"character":"Alfred","characters":["Alfred"],"person":{"name":"Michael Caine","ids":{"trakt":2}}}],
"crew":{"directing":[{"job":"Director","jobs":["Director"],"person":{"name":"Christopher Nolan"}},{"job":"Assistant Director","jobs":["Assistant Director"],"person":{"name":"Someone"}}],
"writing":[{"job":"Screenplay","jobs":["Screenplay"],"person":{"name":"David S. Goyer"}}]}}`))
case "/search/tvdb/81189":
w.Write([]byte(`[{"type":"show","score":1000,"show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"tvdb":81189}}}]`))
case "/shows/1388/people":
w.Write([]byte(`{"cast":[{"character":"Walter White","characters":["Walter White"],"episode_count":62,"person":{"name":"Bryan Cranston"}}],
"crew":{"created by":[{"job":"Creator","jobs":["Creator"],"person":{"name":"Vince Gilligan"}}]}}`))
case "/search/tmdb/5":
w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := NewClient("id", "ua")
c.SetBaseURL(srv.URL)
movie, err := c.People(context.Background(), Ref{Kind: Movies, TMDB: 272, IMDB: "tt0372784"})
if err != nil {
t.Fatal(err)
}
if len(movie.Cast) != 2 || movie.Cast[0].Name != "Christian Bale" || movie.Cast[0].Role != "Bruce Wayne / Batman" {
t.Errorf("cast %+v", movie.Cast)
}
if len(movie.Leaders) != 1 || movie.Leaders[0].Name != "Christopher Nolan" {
t.Errorf("directors %+v", movie.Leaders)
}
if paths[0] != "/search/tmdb/272?type=movie" {
t.Errorf("lookup %q", paths[0])
}
show, err := c.People(context.Background(), Ref{Kind: Shows, TVDB: 81189})
if err != nil || show.Cast[0].Name != "Bryan Cranston" || len(show.Leaders) != 1 || show.Leaders[0].Name != "Vince Gilligan" {
t.Errorf("show %+v %v", show, err)
}
if _, err := c.People(context.Background(), Ref{Kind: Movies, TMDB: 5}); !errors.Is(err, ErrNotFound) {
t.Errorf("unknown title: %v", err)
}
if (Ref{Kind: Shows, TVDB: 81189}).Key() != "show:tvdb:81189" || (Ref{Kind: Movies}).Key() != "" {
t.Error("keys")
}
}
+372 -55
View File
@@ -3,6 +3,7 @@ package ui
import (
"context"
"errors"
"fmt"
"os"
"strings"
@@ -11,10 +12,12 @@ import (
"github.com/gdamore/tcell/v2"
"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/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
type view struct {
@@ -29,23 +32,53 @@ type view struct {
// App is the top-level arr-tui application.
type App struct {
app *tview.Application
tabs *tview.TextView
pages *tview.Pages
status *tview.TextView
views []view
active int
cellW, cellH int // size of a terminal cell in pixels, 0 if unknown
app *tview.Application
theme theme // the colors in use
trakt *trakt.Client // trending, most watched and anticipated lists, casts
people *peopleCache // casts loaded from Trakt
disk *diskCache // posters, casts and lookups kept between runs
lookups *lookupStore // Radarr/Sonarr lookups of Trakt list titles
// Called (on the UI goroutine) a moment after a release was grabbed.
afterGrab []func()
// The Movies and Series views, to refresh their Trakt lists.
searches []*searchView
themed *themedScreen // draws with them; nil until Run
tabs *tview.TextView
pages *tview.Pages
status *tview.TextView
views []view
active int
toasts []toast
logs *logView
overlays []overlay // dialogs and full-screen views stacked above the pages
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
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
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
}
@@ -73,14 +106,19 @@ var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"}
// hints replaces the view's key hints in the status bar while it is open.
type overlay struct {
page string
root tview.Primitive // what was pushed
focus tview.Primitive
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
}
// New builds the application shell: a tab bar, one page per view and a status bar.
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{
app: tview.NewApplication(),
tabs: tview.NewTextView().SetDynamicColors(true),
@@ -90,15 +128,37 @@ func New(cfg *config.Config) *App {
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
state: map[string]serviceState{},
notifyDone: cfg.UI.ShouldNotifyDownloads(),
bell: cfg.UI.Bell,
covers: cfg.UI.ShowCovers(),
coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv),
cellAspect: 2,
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)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.radarr, a.sonarr = radarrClient, sonarrClient
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)
a.sab = sabClient
a.probes = []probe{
{"Radarr", func(ctx context.Context) (string, error) {
st, err := radarrClient.SystemStatus(ctx)
@@ -110,38 +170,190 @@ func New(cfg *config.Config) *App {
}},
{"SABnzbd", sabClient.Version},
}
settings := newSettingsView(a)
a.settings = settings
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient)
a.afterGrab = append(a.afterGrab, library.loadQueue)
downloads := newDownloadsView(a, sabClient,
[]arrSource{
{name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem},
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
},
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
a.downloads = downloads
a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui")
a.disk = newDiskCache()
a.people = newPeopleCache()
a.loadSavedPeople()
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots)
}
a.lookups = newLookupStore(a.disk)
movies := newSearchView(a, radarrBackend{radarrClient, a.lookups})
series := newSearchView(a, sonarrBackend{sonarrClient, a.lookups})
a.searches = []*searchView{movies, series}
a.views = []view{
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
{key: '1', name: "Movies", body: movies.root, onShow: movies.activate, hints: searchHints},
{key: '2', name: "Series", body: series.root, onShow: series.activate, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, onHide: library.deactivate, hints: libraryHints},
{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 {
a.pages.AddPage(v.name, v.body, true, i == 0)
}
a.topBar = tview.NewFlex().
AddItem(a.tabs, 0, 1, false).
AddItem(a.services, servicesWidth(false), 0, false)
layout := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewFlex().
AddItem(a.tabs, 0, 1, false).
AddItem(a.services, 25, 0, false), 1, 0, false).
AddItem(a.topBar, 1, 0, false).
AddItem(a.pages, 0, 1, true).
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.cellW, a.cellH = cellPixelsOf(s)
a.noteWindow(s)
return 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.switchTo(0)
movies.activate() // switchTo does not call onShow for the view already active
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()
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)
k.cellPixels = func() (int, int) { return a.cellW, a.cellH }
k.background = func(prepare func() prepared, ready func(prepared)) {
go func() {
p := prepare()
a.post(func() { ready(p) })
}()
}
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.
// cellPixelsOf returns the size of a terminal cell in pixels, 0, 0 when the
// terminal does not say.
func cellPixelsOf(s tcell.Screen) (w, h int) {
if tty, ok := s.Tty(); ok {
if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 {
return ws.PixelWidth / ws.Width, ws.PixelHeight / ws.Height
}
}
return 0, 0
}
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
// connection indicators. UI goroutine only. A nil err means reachable.
func (a *App) SetService(name, version string, err error) {
@@ -152,7 +364,11 @@ func (a *App) SetService(name, version string, err error) {
a.state[name] = serviceState{known: true, version: version, err: err}
// Log connection changes only, so background polling does not flood the log.
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())
case err == nil && (!prev.known || prev.err != nil):
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
@@ -160,13 +376,39 @@ func (a *App) SetService(name, version string, err error) {
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() {
var s string
for _, n := range serviceNames {
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]
switch {
case !st.known:
case !st.known, notConfigured(st.err):
s += "[gray]○ " + short + "[-] "
case st.err != nil:
s += "[red]● " + short + "[-] "
@@ -185,6 +427,8 @@ func (a *App) serviceSummary() string {
switch {
case !st.known:
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:
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
case st.version != "":
@@ -196,6 +440,21 @@ func (a *App) serviceSummary() 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.
func (a *App) post(f func()) {
a.app.QueueUpdateDraw(f)
@@ -205,23 +464,64 @@ func (a *App) post(f func()) {
func (a *App) Run() error {
stop := make(chan struct{})
defer close(stop)
defer func() {
if a.gfx != nil {
a.gfx.deleteAll()
}
if a.people.dirty { // casts fetched in the last seconds
_ = a.disk.savePeople(a.peopleSnapshot())
}
_ = a.lookups.save()
}()
go a.disk.prunePosters()
// 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)
a.setNotifyDownloads(a.notifyDone)
defer a.setNotifyDownloads(false)
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.
func (a *App) watchServices(stop <-chan struct{}) {
tick := time.NewTicker(heartbeat)
defer tick.Stop()
for {
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) })
}()
}
a.probeAll()
select {
case <-tick.C:
case <-stop:
@@ -230,15 +530,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() {
var hints string
if n := len(a.overlays); n > 0 {
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
} else {
if h := a.views[a.active].hints; h != nil {
hints = h() + " · "
v := a.views[a.active]
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)
}
@@ -246,7 +561,7 @@ func (a *App) renderStatus() {
// pushOverlay stacks p above everything and focuses focus.
func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onClose func()) {
name := fmt.Sprintf("overlay-%d", len(a.overlays))
a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus(), hints: hints, full: full, close: onClose})
a.overlays = append(a.overlays, overlay{page: name, root: p, focus: a.app.GetFocus(), hints: hints, full: full, close: onClose})
a.pages.AddPage(name, p, true, true)
a.app.SetFocus(focus)
a.renderStatus()
@@ -254,6 +569,11 @@ func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onC
// openModal shows p centered above the current view.
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().
AddItem(nil, 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
@@ -261,7 +581,7 @@ func (a *App) openModal(p tview.Primitive, width, height int) {
AddItem(p, height, 0, true).
AddItem(nil, 0, 1, false), width, 0, true).
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
@@ -280,6 +600,14 @@ func (a *App) openFull(p tview.Primitive, hints string, onClose func()) {
a.pushOverlay(p, p, hints, true, onClose)
}
// closeIfTop closes the topmost overlay if it is p (and not something opened
// on top of it since).
func (a *App) closeIfTop(p tview.Primitive) {
if n := len(a.overlays); n > 0 && a.overlays[n-1].root == p {
a.closeModal()
}
}
// closeModal removes the topmost overlay and restores the previous focus.
func (a *App) closeModal() {
if len(a.overlays) == 0 {
@@ -322,16 +650,6 @@ func (a *App) onlyFullOverlays() bool {
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 {
if ev.Key() == tcell.KeyCtrlC {
a.app.Stop()
@@ -352,20 +670,10 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
return nil
}
}
if a.typing() {
return ev
}
switch ev.Key() {
case tcell.KeyRune:
if ev.Rune() == 'q' {
a.app.Stop()
return nil
}
}
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 {
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
return int(ev.Key() - tcell.KeyF1)
@@ -389,9 +697,14 @@ func (a *App) switchTo(i int) {
func (a *App) renderTabs() {
var s string
for i, v := range a.views {
if i == a.active {
switch {
case a.narrow && i == a.active:
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
case a.narrow:
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
case i == a.active:
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
} else {
default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
}
}
@@ -399,11 +712,15 @@ func (a *App) renderTabs() {
}
func searchHints() string {
return "/ search · Tab list/search · Enter add · i releases"
return "/ search · [ ] lists · Tab list/search · Enter add · i releases"
}
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" }
+45 -2
View File
@@ -1,11 +1,15 @@
package ui
import (
"errors"
"strings"
"testing"
"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/sabnzbd"
)
func newTestApp(t *testing.T) *App {
@@ -22,7 +26,7 @@ func newTestApp(t *testing.T) *App {
func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
a := newTestApp(t)
for _, r := range "12345\t" {
for _, r := range "1234567\t" {
key := tcell.NewEventKey(tcell.KeyRune, r, 0)
if r == '\t' {
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)
}
}
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 {
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())
}
}
+71 -2
View File
@@ -9,6 +9,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
// result is one search hit, independent of which *arr produced it.
@@ -47,6 +48,10 @@ type backend interface {
add(ctx context.Context, r result, c addChoice) (id int, err error)
// interactive starts an interactive release search for a library item.
interactive(v *searchView, r result)
// lists are the Trakt lists offered while the search box is empty, and
// resolve looks a list entry up (nil without error if it is not found).
lists() []trakt.List
resolve(ctx context.Context, e trakt.Entry) (result, error)
}
// --- Radarr ---
@@ -67,7 +72,10 @@ func (m movieResult) withID(id int) result {
return m
}
type radarrBackend struct{ c *radarr.Client }
type radarrBackend struct {
c *radarr.Client
lookups *lookupStore
}
func (b radarrBackend) name() string { return "Radarr" }
func (b radarrBackend) noun() string { return "Movies" }
@@ -83,6 +91,35 @@ func (b radarrBackend) search(ctx context.Context, term string) ([]result, error
return out, err
}
func (radarrBackend) lists() []trakt.List { return trakt.MovieLists }
func (b radarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
id := e.IDs.TMDB
if id == 0 {
return nil, errNotListed
}
if m, ok, err := b.c.MovieByTMDB(ctx, id); err != nil {
return nil, err
} else if ok {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := radarr.TMDBTerm(id)
var m radarr.Movie
if b.lookups.get(key, &m) {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, false)}, nil
}
movies, err := b.c.Lookup(ctx, key)
if err != nil || len(movies) == 0 {
return nil, err
}
m = movies[0]
if !m.InLibrary() {
b.lookups.put(key, m)
}
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, m.InLibrary())}, nil
}
func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
created, err := b.c.AddMovie(ctx, r.(movieResult).Movie, radarr.AddOptions{
QualityProfileID: c.profileID,
@@ -118,7 +155,10 @@ func (s seriesResult) withID(id int) result {
return s
}
type sonarrBackend struct{ c *sonarr.Client }
type sonarrBackend struct {
c *sonarr.Client
lookups *lookupStore
}
func (b sonarrBackend) name() string { return "Sonarr" }
func (b sonarrBackend) noun() string { return "Series" }
@@ -144,6 +184,35 @@ func (b sonarrBackend) search(ctx context.Context, term string) ([]result, error
return out, err
}
func (sonarrBackend) lists() []trakt.List { return trakt.ShowLists }
func (b sonarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
id := e.IDs.TVDB
if id == 0 {
return nil, errNotListed
}
if s, ok, err := b.c.SeriesByTVDB(ctx, id); err != nil {
return nil, err
} else if ok {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := "tvdb:" + itoa(id)
var s sonarr.Series
if b.lookups.get(key, &s) {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, false)}, nil
}
series, err := b.c.Lookup(ctx, key)
if err != nil || len(series) == 0 {
return nil, err
}
s = series[0]
if !s.InLibrary() {
b.lookups.put(key, s)
}
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, s.InLibrary())}, nil
}
func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
created, err := b.c.AddSeries(ctx, r.(seriesResult).Series, sonarr.AddOptions{
QualityProfileID: c.profileID,
+478
View File
@@ -0,0 +1,478 @@
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"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
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), people: episodeSeriesRef(e)}, 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)
})
}()
}
// episodeSeriesRef names the series of a calendar episode for its cast.
func episodeSeriesRef(e sonarr.Episode) trakt.Ref {
if e.Series == nil {
return trakt.Ref{}
}
return seriesRef(*e.Series)
}
+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", dlInfo{})
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", dlInfo{})
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", dlInfo{}); 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)
}
}
+91
View File
@@ -0,0 +1,91 @@
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.Trakt.ClientID != c.Trakt.ClientID {
a.trakt.Reconfigure(c.Trakt.ClientID)
a.people.failed = map[string]time.Time{} // casts do not depend on the client ID; failures may
for _, p := range a.panes {
p.castKey = "" // ask again the next time a title is shown
}
}
if old.Trakt.ClientID != c.Trakt.ClientID || old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service {
for _, v := range a.searches {
v.forgetLists()
}
}
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
}
}
}
+310 -45
View File
@@ -6,8 +6,14 @@ import (
"fmt"
"image"
"image/color"
_ "image/jpeg" // poster formats
_ "image/png"
_ "image/gif" // poster formats (Radarr and Sonarr store the provider's bytes
"image/jpeg" // under a .jpg name, whatever format they are in); jpeg also
_ "image/png" // encodes the copies kept on disk
_ "golang.org/x/image/webp"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
@@ -18,18 +24,20 @@ import (
)
const (
maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail
coverFailureTTL = 2 * time.Minute
maxCoverCache = 300
coverFetchLimit = 4
maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for block characters
graphicsCoverHeight = 640 // posters shown as real images keep more detail
coverFailureTTL = 2 * time.Minute
maxCoverCache = 120
coverFetchLimit = 4
)
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
func decodeCover(data []byte) (*image.RGBA, error) {
// decodeCover decodes a JPEG or PNG poster and keeps a copy at most
// maxHeight pixels tall.
func decodeCover(data []byte, maxHeight int) (*image.RGBA, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
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 {
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
}
b := img.Bounds()
if b.Dy() <= storedCoverHeight {
if b.Dy() <= maxHeight {
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
@@ -86,6 +94,10 @@ var (
// sextantStyle (2×3 pixels): sharper, but the glyphs live in the Symbols
// for Legacy Computing block, which many fonts lack.
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
@@ -117,14 +129,22 @@ func sextantGlyph(mask int) rune {
return rune(0x1FB00 + idx)
}
// coverStyleFor resolves the configured style. "auto" picks sextants on
// terminals known to have the glyphs and quadrants everywhere else.
// coverStyleFor resolves the block-character style for the setting. "auto"
// 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 {
switch setting {
case "sextant":
return sextantStyle
case "quadrant":
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"))
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") != "" {
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
}
// 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
// drawn flat: smooth areas and noise should not turn into patterns.
const flatPixelError = 150
@@ -214,9 +278,12 @@ func (st coverStyle) bestCell(p []color.RGBA) (ch rune, fg, bg color.RGBA) {
// colors, also over SSH.
type coverView struct {
*tview.Box
style coverStyle
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
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
note string // shown instead of the image: "loading…", "no cover"
// The cells for the last drawn size.
cols, rows int
@@ -228,30 +295,85 @@ type styledCell struct {
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) setNote(note string) { c.img, c.note, c.cells = nil, note, nil }
// aspect returns the width and height of the poster (2:3 until one is loaded).
func (c *coverView) aspect() (w, h int) {
if c.img == nil {
return 2, 3
// Every poster gets a box of the usual poster shape, 2:3, so all posters of a
// list have the same size whatever image Radarr/Sonarr hand out.
const (
posterW, posterH = 2, 3
// posterShapeTolerance is how far an image's shape may differ from 2:3 and
// still fill the box (trimmed at the long sides); one further off is shown
// whole inside it.
posterShapeTolerance = 0.12
)
// aspect returns the shape of the box the poster is shown in.
func (c *coverView) aspect() (w, h int) { return posterW, posterH }
// nearPosterShape reports whether an image is close enough to 2:3 to fill the box.
func nearPosterShape(b image.Rectangle) bool {
if b.Dx() <= 0 || b.Dy() <= 0 {
return false
}
b := c.img.Bounds()
return b.Dx(), b.Dy()
d := float64(b.Dx())*posterH/(float64(b.Dy())*posterW) - 1
return d >= -posterShapeTolerance && d <= posterShapeTolerance
}
// coverCells returns how many columns and rows a poster of the given aspect
// needs inside w×h cells. A cell is about twice as tall as it is wide.
func coverCells(imgW, imgH, w, h int) (cols, rows int) {
cols = w
rows = cols * imgH / imgW / 2
if rows > h {
rows = h
cols = rows * 2 * imgW / imgH
// coverFitTolerance is how far the shape of the cell box may differ from the
// poster's, as a fraction. The box is chosen to match the poster's shape
// closely; the small remainder is cropped away from the poster before it is
// shown as a real image, because a real image placed in a box of another shape
// is letterboxed (visible bars beside or above it).
const coverFitTolerance = 0.03
// 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) {
@@ -262,21 +384,31 @@ func (c *coverView) Draw(screen tcell.Screen) {
}
if c.img == nil {
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
}
// The 2:3 box, right-aligned in the area. An image close to that shape
// fills it (trimmed); one far from it (square, landscape) is shown whole,
// centred in the box.
b := c.img.Bounds()
cols, rows := coverCells(b.Dx(), b.Dy(), w, h)
boxW, boxH := coverCells(posterW, posterH, w, h, c.aspectOfCells())
cols, rows := boxW, boxH
if !nearPosterShape(b) {
cols, rows = coverCells(b.Dx(), b.Dy(), boxW, boxH, c.aspectOfCells())
}
if cols < 1 || rows < 1 {
return
}
x += w - boxW + (boxW-cols)/2
y += (boxH - rows) / 2
if c.drawGraphics(screen, x, y, cols, rows) {
return
}
if c.cells == nil || c.cols != cols || c.rows != rows {
c.cols, c.rows, c.cells = cols, rows, c.build(cols, rows)
}
// Right-align the poster inside its area, so a narrower poster sits against the edge.
x += w - cols
for row := 0; row < rows; row++ {
for col := 0; col < cols; col++ {
cell := c.cells[row*cols+col]
@@ -285,10 +417,61 @@ 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 id == 0 {
return true // being prepared: the place stays empty until it is ready
}
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.
func (c *coverView) build(cols, rows int) []styledCell {
st := c.style
scaled := boxScale(c.img, cols*st.sw, rows*st.sh)
// Trim the image to the shape of the cells first, as for real images, so
// it is not stretched.
crop := cropToBox(c.img.Bounds(), cols, rows, c.aspectOfCells())
scaled := boxScale(c.img.SubImage(crop), cols*st.sw, rows*st.sh)
cells := make([]styledCell, 0, cols*rows)
px := make([]color.RGBA, st.sw*st.sh)
for row := 0; row < rows; row++ {
@@ -301,8 +484,8 @@ func (c *coverView) build(cols, rows int) []styledCell {
}
ch, fg, bg := st.bestCell(px)
cells = append(cells, styledCell{ch,
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(fg.R), int32(fg.G), int32(fg.B))),
notToken(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))})
}
}
return cells
@@ -310,8 +493,9 @@ func (c *coverView) build(cols, rows int) []styledCell {
// coverRef says where a poster comes from. The zero value means "no poster".
type coverRef struct {
key string // identifies the poster in the cache
fetch func(ctx context.Context) ([]byte, error)
key string // identifies the poster in the cache
// fetch downloads the poster; large asks for a bigger copy (for real images).
fetch func(ctx context.Context, large bool) ([]byte, error)
}
// coverFor builds the poster reference of a title from its image list.
@@ -358,6 +542,8 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
return
}
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() {
c.slots <- struct{}{}
@@ -365,18 +551,42 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
var img *image.RGBA
data, err := ref.fetch(ctx)
if err == nil {
img, err = decodeCover(data)
var err error
// A copy from an earlier run is used while it is fresh enough.
if data, ok := a.disk.readPoster(ref.key, large); ok {
if img, err = decodeCover(data, maxHeight); err != nil {
a.disk.dropPoster(ref.key, large)
img = nil
}
}
if img == nil {
var data []byte
data, err = ref.fetch(ctx, large)
if err == nil {
img, err = decodeCover(data, maxHeight)
}
if err == nil {
// Stored as decoded (at most maxHeight tall), not as downloaded:
// originals can be megabytes.
var shrunk bytes.Buffer
if jpeg.Encode(&shrunk, img, &jpeg.Options{Quality: 90}) == nil {
a.disk.writePoster(ref.key, large, shrunk.Bytes())
}
}
}
a.post(func() {
waiters := c.waiting[ref.key]
delete(c.waiting, ref.key)
if err != nil {
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 {
if len(c.imgs) >= maxCoverCache {
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
}
@@ -386,3 +596,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))
}
+173 -14
View File
@@ -3,10 +3,14 @@ package ui
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"strings"
"sync/atomic"
"testing"
"time"
@@ -50,7 +54,7 @@ func TestDecodeCover(t *testing.T) {
if err := jpeg.Encode(&buf, stripes(300, 450), nil); err != nil {
t.Fatal(err)
}
img, err := decodeCover(buf.Bytes())
img, err := decodeCover(buf.Bytes(), storedCoverHeight)
if err != nil {
t.Fatal(err)
}
@@ -62,17 +66,17 @@ func TestDecodeCover(t *testing.T) {
if err := png.Encode(&buf, stripes(20, 30)); err != nil {
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)
}
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")
}
// A header that promises a huge image is refused before decoding it.
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"))
if _, err := decodeCover(huge.Bytes()); err == nil {
if _, err := decodeCover(huge.Bytes(), storedCoverHeight); err == nil {
t.Error("oversized image must be rejected")
}
}
@@ -110,11 +114,12 @@ func TestQuadCell(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
// 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)
}
// In a narrow box the width limits it.
if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 {
// In a narrow box the width limits it, and the box keeps the poster's shape
// (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)
}
}
@@ -127,7 +132,7 @@ func TestCoverViewDraws(t *testing.T) {
defer screen.Fini()
screen.SetSize(30, 10)
c := newCoverView(quadrantStyle)
c := newCoverView(quadrantStyle, nil, nil)
c.setImage(stripes(40, 60)) // red above blue
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) {
a := newTestApp(t)
fetches := 0
var fetches atomic.Int32
var buf bytes.Buffer
png.Encode(&buf, stripes(10, 20))
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
fetches++
ref := coverRef{key: "k", fetch: func(context.Context, bool) ([]byte, error) {
fetches.Add(1)
return buf.Bytes(), nil
}}
@@ -172,8 +177,8 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
if len(a.coverCache.waiting["k"]) != 2 {
t.Fatalf("waiters = %d, want 2", len(a.coverCache.waiting["k"]))
}
if fetches > 1 {
t.Errorf("fetched %d times", fetches)
if n := fetches.Load(); n > 1 {
t.Errorf("fetched %d times", n)
}
}
@@ -181,7 +186,7 @@ func TestLoadCoverRemembersFailures(t *testing.T) {
a := newTestApp(t)
a.coverCache.failed["bad"] = time.Now()
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")
return nil, nil
}}, 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) {
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] } }
for _, tc := range []struct {
setting string
@@ -238,9 +275,131 @@ func TestCoverStyleFor(t *testing.T) {
{"auto", nil, "quadrant"},
{"quadrant", map[string]string{"TERM": "xterm-kitty"}, "quadrant"},
{"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 {
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)
}
}
func TestPostersShareTheTwoByThreeBox(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(40, 30)
// extent returns the columns and rows covered by poster cells (white).
extent := func(w, h int) (x0, y0, x1, y1 int) {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for i := range img.Pix {
img.Pix[i] = 255
}
sim.Clear()
c := newCoverView(quadrantStyle, nil, func() float64 { return 2 })
c.setImage(img)
c.SetRect(0, 0, 40, 30)
c.Draw(sim)
x0, y0, x1, y1 = 99, 99, -1, -1
for y := 0; y < 30; y++ {
for x := 0; x < 40; x++ {
_, _, st, _ := sim.GetContent(x, y)
if _, bg, _ := st.Decompose(); bg == tcell.NewRGBColor(255, 255, 255) {
x0, y0, x1, y1 = min(x0, x), min(y0, y), max(x1, x), max(y1, y)
}
}
}
return
}
ax0, ay0, ax1, ay1 := extent(2000, 3000) // the usual shape
bx0, by0, bx1, by1 := extent(2100, 3000) // 5% wider: trimmed, same box
if ax0 != bx0 || ay0 != by0 || ax1 != bx1 || ay1 != by1 {
t.Errorf("2:3 at %d,%d-%d,%d but 0.7 at %d,%d-%d,%d", ax0, ay0, ax1, ay1, bx0, by0, bx1, by1)
}
if ay0 != 0 || ax1 != 39 {
t.Errorf("the box sits at the top right: %d,%d-%d,%d", ax0, ay0, ax1, ay1)
}
// A square image is shown whole: as wide as the box, shorter, centred.
sx0, sy0, sx1, sy1 := extent(1000, 1000)
if sx0 != ax0 || sx1 != ax1 || sy1-sy0 >= ay1-ay0 || sy0 <= ay0 || (sy0-ay0)-(ay1-sy1) > 1 || (ay1-sy1)-(sy0-ay0) > 1 {
t.Errorf("square at %d,%d-%d,%d in the box %d,%d-%d,%d", sx0, sy0, sx1, sy1, ax0, ay0, ax1, ay1)
}
}
+55 -6
View File
@@ -3,6 +3,7 @@ package ui
import (
"image"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
@@ -14,6 +15,12 @@ const (
minCoverRows = 6 // a smaller poster is not worth showing
minCoverCols = 8
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
@@ -27,8 +34,17 @@ type detailPane struct {
cover *coverView
detail detailText
want string // key of the poster that should be shown
hasCover bool // a poster (or its loading note) is part of the content
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
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
// The cast from Trakt, below the plot (see people.go).
castKey string // the title it belongs to
cast string // rendered; "" while loading or when there is none
castShown time.Time
castSettle *time.Timer
// What the text was last wrapped for.
laidOut struct {
@@ -43,23 +59,52 @@ func newDetailPane(app *App) *detailPane {
Flex: tview.NewFlex(),
app: app,
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.SetBorder(true).SetTitle(" Details ")
app.panes = append(app.panes, d)
return d
}
// Show displays the text and, if there is one, the poster.
func (d *detailPane) Show(text detailText, ref coverRef) {
d.detail = text
d.detail, d.lastRef = text, ref
d.laidOut.valid = false
d.showPeople(text.people)
if !d.app.covers || ref.fetch == nil {
d.want, d.hasCover = "", false
d.cover.setImage(nil)
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
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…")
want := ref.key
d.app.loadCover(ref, func(img *image.RGBA, err error) {
@@ -85,7 +130,7 @@ func (d *detailPane) Draw(screen tcell.Screen) {
if d.hasCover {
aw, ah := d.cover.aspect()
// 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 {
d.coverW, d.coverH = 0, 0
}
@@ -99,7 +144,11 @@ func (d *detailPane) Draw(screen tcell.Screen) {
}
if !d.laidOut.valid || d.laidOut.width != w || d.laidOut.narrow != narrow || d.laidOut.rows != rows {
d.laidOut.width, d.laidOut.narrow, d.laidOut.rows, d.laidOut.valid = w, narrow, rows, true
d.text.SetText(strings.Join(flowWrap(d.detail.String(), narrow, w, rows), "\n"))
text := d.detail.String()
if d.cast != "" {
text += "\n\n" + d.cast
}
d.text.SetText(strings.Join(flowWrap(text, narrow, w, rows), "\n"))
}
d.Flex.Draw(screen)
+89
View File
@@ -1,11 +1,16 @@
package ui
import (
"context"
"image"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func rowText(screen tcell.SimulationScreen, y, x0, x1 int) string {
@@ -107,3 +112,87 @@ func TestDetailPaneFlowsAroundPoster(t *testing.T) {
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")
}
}
func TestCastBelowThePlot(t *testing.T) {
a := newTestApp(t)
d := newDetailPane(a)
dune := trakt.Ref{Kind: trakt.Movies, TMDB: 438631}
heat := trakt.Ref{Kind: trakt.Movies, TMDB: 949}
a.people.done[dune.Key()] = trakt.People{
Leaders: []trakt.Person{{Name: "Denis Villeneuve", Role: "Director"}},
Cast: []trakt.Person{{Name: "Timothée Chalamet", Role: "Paul Atreides"}, {Name: "Rebecca Ferguson", Role: "Lady Jessica"}},
}
a.people.done[heat.Key()] = trakt.People{}
// Without a Trakt client ID nothing is asked or shown.
d.Show(detailText{head: "Dune", people: dune}, coverRef{})
if d.cast != "" {
t.Fatalf("cast without Trakt: %q", d.cast)
}
a.trakt.Reconfigure("id")
d.castKey, d.castShown = "", time.Time{}
d.Show(detailText{head: "Dune", plot: "Spice.", people: dune}, coverRef{})
if !strings.Contains(d.cast, "Director[::-] Denis Villeneuve") || !strings.Contains(d.cast, "Timothée Chalamet [gray]as Paul Atreides") {
t.Errorf("cast %q", d.cast)
}
screen := tcell.NewSimulationScreen("")
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
screen.SetSize(60, 20)
d.SetRect(0, 0, 60, 20)
d.Draw(screen)
if got := d.text.GetText(true); !strings.Contains(got, "Spice.\n\nDirector Denis Villeneuve\nCast\nTimothée Chalamet as Paul Atreides") {
t.Errorf("pane text %q", got)
}
// A quick move to the next title waits with its cast until the selection rests.
d.Show(detailText{head: "Heat", people: heat}, coverRef{})
if d.cast != "" || d.castSettle == nil {
t.Errorf("moving: cast %q", d.cast)
}
d.castSettle.Stop()
}
func TestPeopleText(t *testing.T) {
var cast []trakt.Person
for i := 0; i < 10; i++ {
cast = append(cast, trakt.Person{Name: "Actor " + string(rune('A'+i))})
}
got := peopleText(trakt.People{Cast: cast, Leaders: []trakt.Person{{Name: "Vince [Gilligan]"}}}, trakt.Shows)
if !strings.HasPrefix(got, "[::b]Created by[::-] Vince [Gilligan[]") || !strings.HasSuffix(got, "[gray]and 2 more[-]") || strings.Contains(got, "Actor I") {
t.Errorf("text %q", got)
}
if peopleText(trakt.People{}, trakt.Movies) != "" {
t.Error("no people, no text")
}
}
+148
View File
@@ -0,0 +1,148 @@
package ui
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
discoverLimit = 20 // titles per Trakt list
discoverTTL = 15 * time.Minute // how long a fetched list is shown again without asking
resolveLimit = 4 // Radarr/Sonarr lookups at the same time
)
// discovered is a Trakt list, resolved into Radarr/Sonarr results.
type discovered struct {
results []result
notes []string // the list's number per result ("1.2k watching"), may be ""
err error
at time.Time
}
// showList shows the current Trakt list (UI goroutine): what the Movies and
// Series views show while the search box is empty.
func (v *searchView) showList() {
v.browsing = true
v.results, v.notes = nil, nil
l := v.lists[v.listIdx]
v.list.SetTitle(fmt.Sprintf(" %s · %s · [yellow][ ][-] other lists ", v.be.noun(), l.Name))
if !v.app.trakt.Configured() {
v.placeholder = "Trending, most watched and anticipated " + lower(v.be.noun()) +
" appear here once a Trakt client ID is set in the Config view (F6)."
v.render(0)
return
}
if d := v.discover[v.listIdx]; d != nil && time.Since(d.at) < discoverTTL {
v.applyList(d)
return
}
v.placeholder = "Loading " + l.Name + "…"
v.render(0)
idx := v.listIdx
if v.loadingList[idx] {
return
}
v.loadingList[idx] = true
go func() {
d := v.fetchList(l)
v.app.post(func() {
v.loadingList[idx] = false
v.discover[idx] = d
if v.browsing && v.listIdx == idx {
v.applyList(d)
}
})
}()
}
// applyList puts a fetched list on screen (UI goroutine).
func (v *searchView) applyList(d *discovered) {
v.results, v.notes, v.placeholder = d.results, d.notes, ""
if d.err != nil {
v.placeholder = "[red]" + tview.Escape(d.err.Error()) + "[-]"
} else if len(d.results) == 0 {
v.placeholder = "Nothing on this list right now."
}
v.render(0)
}
// fetchList reads a Trakt list and looks every title up in Radarr/Sonarr, in
// the list's order (off the UI goroutine). Titles that cannot be found are
// left out; if none can, the reason is kept.
func (v *searchView) fetchList(l trakt.List) *discovered {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
d := &discovered{at: time.Now()}
entries, err := v.app.trakt.Entries(ctx, l, discoverLimit)
if err != nil {
d.err = err
return d
}
found := make([]result, len(entries))
errs := make([]error, len(entries))
var wg sync.WaitGroup
slots := make(chan struct{}, resolveLimit)
for i, e := range entries {
wg.Add(1)
go func() {
defer wg.Done()
slots <- struct{}{}
defer func() { <-slots }()
found[i], errs[i] = v.be.resolve(ctx, e)
}()
}
wg.Wait()
var lastErr error
for i, r := range found {
if r != nil {
d.results = append(d.results, r)
d.notes = append(d.notes, l.Note(entries[i].Count))
} else if errs[i] != nil {
lastErr = errs[i]
}
}
if len(d.results) == 0 && lastErr != nil {
d.err = fmt.Errorf("%s: %w", v.be.name(), lastErr)
}
return d
}
// switchList moves to the next or previous Trakt list.
func (v *searchView) switchList(step int) {
n := len(v.lists)
v.listIdx = (v.listIdx + step + n) % n
v.showList()
}
// reloadList fetches the current list again.
func (v *searchView) reloadList() {
delete(v.discover, v.listIdx)
v.showList()
}
// forgetLists drops the fetched lists (the Trakt client ID or a service
// changed) and shows the current one afresh if it is on screen.
func (v *searchView) forgetLists() {
v.discover = map[int]*discovered{}
if v.browsing {
v.showList()
}
}
// errNotListed is returned for list titles Radarr/Sonarr cannot look up (no
// TMDb or TVDB id).
var errNotListed = errors.New("no id to look the title up with")
func lower(s string) string {
if s == "" {
return s
}
return string(s[0]|0x20) + s[1:]
}
+153
View File
@@ -0,0 +1,153 @@
package ui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
)
func TestTraktListsInTheSearchView(t *testing.T) {
traktSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[
{"watchers":1234,"movie":{"title":"Dune","year":2021,"ids":{"trakt":1,"tmdb":438631}}},
{"watchers":900,"movie":{"title":"Unknown To Radarr","year":2020,"ids":{"trakt":2,"tmdb":1}}},
{"watchers":7,"movie":{"title":"No Ids","year":2019,"ids":{"trakt":3}}},
{"watchers":5,"movie":{"title":"Heat","year":1995,"ids":{"trakt":4,"tmdb":949}}}]`))
}))
defer traktSrv.Close()
radarrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("term") {
case "tmdb:438631":
w.Write([]byte(`[{"id":5,"title":"Dune","year":2021,"tmdbId":438631}]`))
case "tmdb:949":
w.Write([]byte(`[{"title":"Heat","year":1995,"tmdbId":949}]`))
default:
w.Write([]byte(`[]`))
}
}))
defer radarrSrv.Close()
a := newTestApp(t)
v := a.searches[0]
// Without a client ID the list says how to get one.
v.activate()
if !v.browsing || !strings.Contains(v.placeholder, "Trakt client ID") {
t.Fatalf("unconfigured: browsing %v, placeholder %q", v.browsing, v.placeholder)
}
a.radarr.Reconfigure(radarrSrv.URL, "k")
a.trakt.Reconfigure("id")
a.trakt.SetBaseURL(traktSrv.URL)
d := v.fetchList(v.lists[0])
if d.err != nil || len(d.results) != 2 {
t.Fatalf("fetched %d results, err %v", len(d.results), d.err)
}
// In the list's order; titles Radarr does not know, or without ids, are left out.
if d.results[0].title() != "Dune" || d.results[1].title() != "Heat" || !d.results[0].inLibrary() || d.results[1].inLibrary() {
t.Errorf("results %v %v", d.results[0].title(), d.results[1].title())
}
if strings.Join(d.notes, "|") != "1.2k watching|5 watching" {
t.Errorf("notes %q", d.notes)
}
// A fresh list is shown from memory, with its numbers and its name in the title.
v.discover[0] = d
v.showList()
if main, _ := v.list.GetItemText(0); !strings.Contains(main, "Dune") || !strings.Contains(main, "1.2k watching") {
t.Errorf("first row %q", main)
}
if title := v.list.GetTitle(); !strings.Contains(title, "Trending now") {
t.Errorf("title %q", title)
}
// ] and [ switch lists while browsing.
v.discover[1] = &discovered{at: time.Now()}
v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, ']', 0))
if v.listIdx != 1 || !strings.Contains(v.list.GetTitle(), "Most watched") || v.placeholder != "Nothing on this list right now." {
t.Errorf("after ]: list %d, title %q, placeholder %q", v.listIdx, v.list.GetTitle(), v.placeholder)
}
v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, '[', 0))
// From the empty search box too (where the cursor is when the view opens).
v.input.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, '[', 0))
if v.listIdx != len(v.lists)-1 || v.input.GetText() != "" {
t.Errorf("[ wraps around: list %d, search box %q", v.listIdx, v.input.GetText())
}
// Search results replace the list; their rows have no numbers.
v.setResults(d.results)
if v.browsing || v.notes != nil || strings.Contains(v.list.GetTitle(), "lists") {
t.Errorf("search results: browsing %v, title %q", v.browsing, v.list.GetTitle())
}
if main, _ := v.list.GetItemText(0); strings.Contains(main, "watching") {
t.Errorf("search row %q", main)
}
}
func TestListNumbersAreRightAligned(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(60, 6)
a := newTestApp(t)
v := a.searches[0]
a.trakt.Reconfigure("id")
v.discover[0] = &discovered{at: time.Now(),
results: []result{movieResult{Movie: radarr.Movie{Title: "Dune", Year: 2021}}, movieResult{Movie: radarr.Movie{Title: "A Much Longer Title", Year: 2024}}},
notes: []string{"1.2k watching", "5 watching"}}
v.showList()
ends := func(width int) []int {
sim.Clear()
l := notesList{v.list, v}
l.SetRect(0, 0, width, 6)
l.Draw(sim)
var out []int
for y := 1; y <= 2; y++ {
row := strings.TrimRight(rowText(sim, y, 0, width), " │")
out = append(out, len([]rune(row)))
if !strings.HasSuffix(row, "watching") {
t.Errorf("row %d does not end with its number: %q", y, row)
}
}
return out
}
if e := ends(60); e[0] != e[1] {
t.Errorf("numbers end in columns %v, want the same", e)
}
// After a resize they line up at the new edge.
if e := ends(44); e[0] != e[1] || e[0] > 44 {
t.Errorf("after resize: numbers end in columns %v", e)
}
}
func TestEnterAddsAndAIsAnOrdinaryKey(t *testing.T) {
a := newTestApp(t)
v := a.searches[0]
v.setResults([]result{movieResult{Movie: radarr.Movie{Title: "Dune", Year: 2021, TMDBID: 1}}})
// a is no longer a key of the list: it goes on to the list (which ignores it).
if got := v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'a', 0)); got == nil {
t.Error("a is still taken by the list")
}
// The hint for releases of a title not in the library names Enter.
v.interactive(0)
if msg := plainText(a.status.GetText(false)) + toastText(a); !strings.Contains(msg, "first (Enter)") {
t.Errorf("hint: %q", msg)
}
}
func toastText(a *App) string {
var b strings.Builder
for _, t := range a.toasts {
b.WriteString(t.text)
}
return b.String()
}
+227
View File
@@ -0,0 +1,227 @@
package ui
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"slices"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
posterTTL = 30 * 24 * time.Hour // a poster on disk is used this long
posterCacheCap = 200 << 20 // bytes of posters kept on disk
peopleTTL = 7 * 24 * time.Hour // a cast on disk is used this long
maxPeopleSaved = 2000 // casts kept (in memory and on disk)
// postersDir names the poster folder for the current way of storing them
// (v2: as decoded, at the size used). Folders of older ways are deleted by
// prunePosters: their files would otherwise be used as they are (v1 kept
// Radarr's 333×500 copies for real images).
postersDir = "posters-v2"
)
// oldPosterDirs are poster folders of earlier versions.
var oldPosterDirs = []string{"posters"}
// cacheDirFunc returns the directory for the disk cache, "" for none. It is a
// variable so tests can turn the cache off.
var cacheDirFunc = func() string {
dir, err := os.UserCacheDir() // $XDG_CACHE_HOME or ~/.cache, ~/Library/Caches on macOS
if err != nil {
return ""
}
return filepath.Join(dir, "arr-tui")
}
// diskCache keeps posters and casts between runs. With an empty dir it does
// nothing. Safe for use from any goroutine (each file is written atomically).
type diskCache struct{ dir string }
func newDiskCache() *diskCache { return &diskCache{dir: cacheDirFunc()} }
func (d *diskCache) posterPath(key string, large bool) string {
size := "small"
if large {
size = "large"
}
sum := sha256.Sum256([]byte(size + "|" + key))
return filepath.Join(d.dir, postersDir, hex.EncodeToString(sum[:16])+".img")
}
// readPoster returns the stored bytes of a poster, if they are not too old.
func (d *diskCache) readPoster(key string, large bool) ([]byte, bool) {
if d.dir == "" {
return nil, false
}
p := d.posterPath(key, large)
info, err := os.Stat(p)
if err != nil || time.Since(info.ModTime()) > posterTTL {
return nil, false
}
data, err := os.ReadFile(p)
return data, err == nil
}
// writePoster stores the bytes of a poster as downloaded.
func (d *diskCache) writePoster(key string, large bool, data []byte) {
if d.dir == "" {
return
}
_ = writeAtomic(d.posterPath(key, large), data)
}
// dropPoster removes a stored poster (it could not be read).
func (d *diskCache) dropPoster(key string, large bool) {
if d.dir != "" {
_ = os.Remove(d.posterPath(key, large))
}
}
// prunePosters deletes posters older than posterTTL, then the oldest ones
// until the rest fit posterCacheCap.
func (d *diskCache) prunePosters() {
if d.dir == "" {
return
}
type file struct {
path string
size int64
mod time.Time
}
for _, old := range oldPosterDirs {
_ = os.RemoveAll(filepath.Join(d.dir, old))
}
var files []file
var total int64
_ = filepath.WalkDir(filepath.Join(d.dir, postersDir), func(p string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() {
return nil
}
info, err := e.Info()
if err != nil {
return nil
}
if time.Since(info.ModTime()) > posterTTL {
_ = os.Remove(p)
return nil
}
files = append(files, file{p, info.Size(), info.ModTime()})
total += info.Size()
return nil
})
slices.SortFunc(files, func(a, b file) int { return a.mod.Compare(b.mod) })
for _, f := range files {
if total <= posterCacheCap {
break
}
if os.Remove(f.path) == nil {
total -= f.size
}
}
}
// savedPeople is a cast as stored on disk.
type savedPeople struct {
Saved time.Time `json:"saved"`
Cast []trakt.Person `json:"cast,omitempty"`
Leaders []trakt.Person `json:"leaders,omitempty"`
}
func (d *diskCache) peoplePath() string { return filepath.Join(d.dir, "people.json") }
// loadPeople reads the stored casts that are not too old.
func (d *diskCache) loadPeople() map[string]savedPeople {
out := map[string]savedPeople{}
if d.dir == "" {
return out
}
data, err := os.ReadFile(d.peoplePath())
if err != nil {
return out
}
var all map[string]savedPeople
if json.Unmarshal(data, &all) != nil {
return out // unreadable: start afresh, it is only a cache
}
for k, p := range all {
if time.Since(p.Saved) <= peopleTTL {
out[k] = p
}
}
return out
}
// savePeople writes the casts.
func (d *diskCache) savePeople(all map[string]savedPeople) error {
if d.dir == "" {
return nil
}
data, err := json.Marshal(all)
if err != nil {
return err
}
return writeAtomic(d.peoplePath(), data)
}
func (d *diskCache) lookupsPath() string { return filepath.Join(d.dir, "lookups.json") }
// loadLookups reads the stored Radarr/Sonarr lookups that are not too old.
func (d *diskCache) loadLookups() map[string]savedLookup {
out := map[string]savedLookup{}
if d.dir == "" {
return out
}
data, err := os.ReadFile(d.lookupsPath())
if err != nil {
return out
}
var all map[string]savedLookup
if json.Unmarshal(data, &all) != nil {
return out // unreadable: start afresh, it is only a cache
}
for k, l := range all {
if time.Since(l.Saved) <= lookupTTL {
out[k] = l
}
}
return out
}
// saveLookups writes the lookups.
func (d *diskCache) saveLookups(all map[string]savedLookup) error {
if d.dir == "" {
return nil
}
data, err := json.Marshal(all)
if err != nil {
return err
}
return writeAtomic(d.lookupsPath(), data)
}
// writeAtomic writes a file through a temporary file and a rename, creating
// the directory (private) if needed.
func writeAtomic(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // no-op after the rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+123
View File
@@ -0,0 +1,123 @@
package ui
import (
"os"
"path/filepath"
"testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func TestPostersOnDisk(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
if _, ok := d.readPoster("k", false); ok {
t.Fatal("empty cache returned a poster")
}
d.writePoster("k", false, []byte("SMALL"))
d.writePoster("k", true, []byte("LARGE"))
if data, ok := d.readPoster("k", false); !ok || string(data) != "SMALL" {
t.Errorf("small: %q %v", data, ok)
}
if data, ok := d.readPoster("k", true); !ok || string(data) != "LARGE" {
t.Errorf("large copies are kept apart: %q %v", data, ok)
}
if info, _ := os.Stat(filepath.Dir(d.posterPath("k", false))); info.Mode().Perm() != 0o700 {
t.Errorf("directory mode %v", info.Mode().Perm())
}
// Too old: not used, and pruned.
old := time.Now().Add(-posterTTL - time.Hour)
_ = os.Chtimes(d.posterPath("k", false), old, old)
if _, ok := d.readPoster("k", false); ok {
t.Error("an expired poster was used")
}
d.prunePosters()
if _, err := os.Stat(d.posterPath("k", false)); !os.IsNotExist(err) {
t.Error("an expired poster was not pruned")
}
if _, ok := d.readPoster("k", true); !ok {
t.Error("a fresh poster was pruned")
}
// Off: nothing is read or written.
off := &diskCache{}
off.writePoster("k", false, []byte("x"))
if _, ok := off.readPoster("k", false); ok {
t.Error("cache without a directory returned a poster")
}
}
func TestPrunePostersKeepsTheNewestWithinTheCap(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
big := make([]byte, posterCacheCap/2+1) // two of these do not fit
for i, key := range []string{"oldest", "middle", "newest"} {
d.writePoster(key, false, big)
at := time.Now().Add(time.Duration(i-3) * time.Hour)
_ = os.Chtimes(d.posterPath(key, false), at, at)
}
d.prunePosters()
for key, want := range map[string]bool{"oldest": false, "middle": false, "newest": true} {
if _, ok := d.readPoster(key, false); ok != want {
t.Errorf("%s kept: %v, want %v", key, ok, want)
}
}
}
func TestPeopleOnDisk(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
all := map[string]savedPeople{
"movie:tmdb:1": {Saved: time.Now(), Cast: []trakt.Person{{Name: "A", Role: "B"}}, Leaders: []trakt.Person{{Name: "C"}}},
"movie:tmdb:2": {Saved: time.Now().Add(-peopleTTL - time.Hour), Cast: []trakt.Person{{Name: "Old"}}},
"show:tvdb:3": {Saved: time.Now()}, // Trakt had no cast: remembered as such
}
if err := d.savePeople(all); err != nil {
t.Fatal(err)
}
got := d.loadPeople()
if len(got) != 2 || got["movie:tmdb:1"].Cast[0].Role != "B" || got["movie:tmdb:1"].Leaders[0].Name != "C" {
t.Errorf("loaded %+v", got)
}
if _, ok := got["show:tvdb:3"]; !ok {
t.Error("an empty cast was not kept")
}
// A damaged file is ignored.
_ = os.WriteFile(d.peoplePath(), []byte("{not json"), 0o600)
if len(d.loadPeople()) != 0 {
t.Error("damaged file")
}
}
func TestPeopleCacheKeepsTheNewest(t *testing.T) {
a := newTestApp(t)
for i := 0; i < maxPeopleSaved+5; i++ {
a.remember(trakt.Ref{Kind: trakt.Movies, TMDB: i + 1}.Key(), trakt.People{})
a.people.saved[trakt.Ref{Kind: trakt.Movies, TMDB: i + 1}.Key()] = time.Unix(int64(i), 0)
}
a.people.timer.Stop()
if len(a.people.done) != maxPeopleSaved {
t.Errorf("%d casts kept", len(a.people.done))
}
if _, ok := a.people.done["movie:tmdb:1"]; ok {
t.Error("the oldest cast was kept")
}
if !a.people.dirty || len(a.peopleSnapshot()) != maxPeopleSaved {
t.Error("new casts must be written")
}
}
func TestPostersOfOlderVersionsAreDropped(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
old := filepath.Join(d.dir, "posters", "abc.img")
if err := writeAtomic(old, []byte("v1")); err != nil {
t.Fatal(err)
}
d.writePoster("k", true, []byte("v2"))
d.prunePosters()
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Error("a poster of the old format was kept")
}
if data, ok := d.readPoster("k", true); !ok || string(data) != "v2" {
t.Error("the current poster was dropped")
}
}
+26 -3
View File
@@ -35,6 +35,7 @@ type downloadsView struct {
sab *sabnzbd.Client
sources []arrSource
interval time.Duration
sabUnset bool // SABnzbd is not set up: its lists say so instead of "empty"
root *tview.Flex
summary *tview.TextView
@@ -134,6 +135,14 @@ func (v *downloadsView) nextPanel(dir int) {
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.
func (v *downloadsView) activate() {
v.deactivate()
@@ -202,6 +211,12 @@ func (v *downloadsView) refresh(ctx context.Context) {
v.app.post(func() {
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 {
v.sabQueue, v.slots, v.paused = queue, queue.Slots, queue.Paused
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() {
v.arrRows = v.arrRows[:0]
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.
func header(t *tview.Table, expand int, cols ...string) {
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 {
cell.SetExpansion(1)
}
@@ -323,7 +346,7 @@ func (v *downloadsView) renderQueue() {
}
}
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() {
@@ -406,7 +429,7 @@ func (v *downloadsView) renderHistory() {
}
}
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.
+157 -18
View File
@@ -37,28 +37,37 @@ type seriesView struct {
root *tview.Flex
header *tview.TextView
table *tview.Table
detail *tview.TextView
detail *detailPane // poster, series and the selected season or episode
cover coverRef
episodes []sonarr.Episode
expanded map[int]bool
rows []epRow
loaded bool
// Episodes in Sonarr's download queue, by episode id; watched while the
// view is open.
downloading map[int]dlInfo
queueStop func()
}
// openSeries opens the season/episode drill-down above the current view.
func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
v := &seriesView{
app: a, c: c, series: s, onChange: onChange,
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
expanded: map[int]bool{},
header: tview.NewTextView().SetDynamicColors(true),
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
detail: newDetailPane(a),
expanded: map[int]bool{},
downloading: map[int]dlInfo{},
}
v.table.SetBorder(true)
v.cover = coverFor(c.Client, s.Images, true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(v.table, 0, 1, true).
AddItem(v.detail, 2, 0, false)
AddItem(tview.NewFlex().
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.SetSelectedFunc(func(int, int) { v.enter() })
@@ -86,8 +95,55 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
})
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.queueStop()
})
v.reload()
v.loadQueue()
v.queueStop = a.every(libQueueInterval, v.loadQueue)
}
// loadQueue marks the episodes in Sonarr's download queue. When one has left
// it (imported, or removed) the episodes are loaded again, so its status
// follows. Failures leave the marks as they were.
func (v *seriesView) loadQueue() {
id := v.series.ID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
items, err := v.c.Queue(ctx)
v.app.post(func() {
if err != nil {
return
}
now := queueByEpisode(items, id)
left := false
for ep := range v.downloading {
if _, still := now[ep]; !still {
left = true
}
}
v.downloading = now
if left {
v.reload()
} else {
v.render()
}
})
}()
}
// seasonDownloads counts the episodes of a season in the download queue.
func (v *seriesView) seasonDownloads(season int) int {
n := 0
for _, e := range v.episodes {
if _, ok := v.downloading[e.ID]; ok && e.SeasonNumber == season {
n++
}
}
return n
}
// reload fetches the series and its episodes.
@@ -188,18 +244,23 @@ func (v *seriesView) render() {
} else {
status = "[gray]" + status
}
v.table.SetCell(row, 0, tview.NewTableCell(monMark(v.seasonMonitored(r.season))))
v.table.SetCell(row, 0, tview.NewTableCell(libMark(v.seasonMonitored(r.season), v.seasonDownloads(r.season) > 0)))
v.table.SetCell(row, 1, tview.NewTableCell(arrow))
v.table.SetCell(row, 2, tview.NewTableCell("[::b]"+seasonLabel(r.season)).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(""))
v.table.SetCell(row, 4, tview.NewTableCell(status))
} else {
e := r.ep
v.table.SetCell(row, 0, tview.NewTableCell(monMark(e.Monitored)))
dl, downloading := v.downloading[e.ID]
status := episodeStatus(*e, today)
if downloading && !e.HasFile {
status = fmt.Sprintf("[blue]downloading %.0f%%", dl.progress)
}
v.table.SetCell(row, 0, tview.NewTableCell(libMark(e.Monitored, downloading)))
v.table.SetCell(row, 1, tview.NewTableCell(fmt.Sprintf("E%02d", e.EpisodeNumber)))
v.table.SetCell(row, 2, tview.NewTableCell(tview.Escape(e.Title)).SetExpansion(1))
v.table.SetCell(row, 3, tview.NewTableCell(e.AirDate))
v.table.SetCell(row, 4, tview.NewTableCell(episodeStatus(*e, today)))
v.table.SetCell(row, 4, tview.NewTableCell(status))
}
if hasSel && r.key() == selKey {
selRow = row
@@ -243,14 +304,92 @@ func episodeStatus(e sonarr.Episode, today string) string {
func (v *seriesView) renderDetail() {
r, ok := v.selected()
switch {
case !ok:
v.detail.SetText("")
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)))
if !ok {
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, "", dlInfo{}), v.cover)
return
}
var sum seasonSummary
for _, se := range summarizeSeasons(v.episodes) {
if se.number == r.season {
sum = se
}
}
dl := dlInfo{count: v.seasonDownloads(r.season)}
if r.ep != nil {
dl = v.downloading[r.ep.ID]
}
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02"), dl), v.seasonCover(r.season))
}
// seasonCover is the poster of a season, for its row and its episodes: the
// season's own poster where Sonarr has one (a TheTVDB link, fetched without
// credentials), else, or when it cannot be loaded, the series poster.
func (v *seriesView) seasonCover(season int) coverRef {
for _, se := range v.series.Seasons {
if se.SeasonNumber != season {
continue
}
for _, img := range se.Images {
if img.CoverType != "poster" || img.RemoteURL == "" {
continue
}
remote, series := img.RemoteURL, v.cover
return coverRef{key: "season|" + remote, fetch: func(ctx context.Context, large bool) ([]byte, error) {
data, err := arr.FetchURL(ctx, remote)
if err != nil && series.fetch != nil {
return series.fetch(ctx, large)
}
return data, err
}}
}
}
return 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. dl is the episode's place in the
// download queue, or for a season the number of its episodes there.
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string, dl dlInfo) 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)
if dl.count == 1 {
head += "[blue]Downloading[-] 1 episode\n"
} else if dl.count > 1 {
head += dl.dlLine()
}
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"
if dl.count > 0 {
head += dl.dlLine()
}
head += monitoredLine(ep.Monitored)
if ep.Overview != "" {
plot = tview.Escape(ep.Overview)
}
}
return detailText{head: head, plot: plot, people: seriesRef(s)}
}
// 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
// (tview would otherwise treat that as cancelling the whole form).
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 closingList bool
f.SetCancelFunc(func() {
@@ -44,16 +56,37 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
return ev
}
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()
pos := item
if button >= 0 {
pos = f.GetFormItemCount() + button
}
switch ev.Key() {
case tcell.KeyDown:
f.SetFocus(min(pos+1, total-1))
case tcell.KeyUp:
f.SetFocus(max(pos-1, 0))
case tcell.KeyDown, tcell.KeyUp:
dir := 1
if ev.Key() == tcell.KeyUp {
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:
step := 1
if ev.Key() == tcell.KeyLeft {
@@ -65,7 +98,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
cur, _ := dd.GetCurrentOption()
dd.SetCurrentOption((cur + step + n) % n)
case button >= 0:
f.SetFocus(min(max(pos+step, f.GetFormItemCount()), total-1))
move(min(max(pos+step, f.GetFormItemCount()), total-1))
default:
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) {
if item < 0 {
return nil, false
}
dd, ok := f.GetFormItem(item).(*tview.DropDown)
return dd, ok
switch it := f.GetFormItem(item).(type) {
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
@@ -109,7 +150,7 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
box.SetChecked(want)
return
}
box.SetLabelColor(tcell.ColorGray)
box.SetLabelColor(named("gray"))
box.SetChecked(false)
}
}
@@ -118,6 +159,11 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
type browseHooks struct {
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
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
@@ -160,3 +206,135 @@ func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks)
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 {
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) {
+49 -2
View File
@@ -11,6 +11,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
// movieLabel is the one-line list entry for a movie.
@@ -29,6 +30,9 @@ func movieLabel(m radarr.Movie) string {
// state) and the plot below it.
type detailText struct {
head, plot string
// people names the title for its cast, loaded from Trakt when it is shown
// (zero: none).
people trakt.Ref
}
// String joins both parts, for places that show them as one text.
@@ -39,6 +43,49 @@ func (d detailText) String() string {
return d.head + "\n" + d.plot
}
func movieRef(m radarr.Movie) trakt.Ref {
return trakt.Ref{Kind: trakt.Movies, TMDB: m.TMDBID, IMDB: m.IMDBID}
}
func seriesRef(s sonarr.Series) trakt.Ref {
return trakt.Ref{Kind: trakt.Shows, TVDB: s.TVDBID, TMDB: s.TMDBID, IMDB: s.IMDBID}
}
// peopleText renders the cast and directors (creators) of a title for the
// details pane, at most maxCast actors.
func peopleText(p trakt.People, kind trakt.Kind) string {
const maxCast = 8
var b strings.Builder
if len(p.Leaders) > 0 {
label := "Director"
if kind == trakt.Shows {
label = "Created by"
} else if len(p.Leaders) > 1 {
label = "Directors"
}
names := make([]string, len(p.Leaders))
for i, l := range p.Leaders {
names[i] = tview.Escape(l.Name)
}
fmt.Fprintf(&b, "[::b]%s[::-] %s\n", label, strings.Join(names, ", "))
}
if len(p.Cast) > 0 {
b.WriteString("[::b]Cast[::-]\n")
for i, c := range p.Cast {
if i == maxCast {
fmt.Fprintf(&b, "[gray]and %d more[-]\n", len(p.Cast)-maxCast)
break
}
b.WriteString(tview.Escape(c.Name))
if c.Role != "" {
b.WriteString(" [gray]as " + tview.Escape(c.Role) + "[-]")
}
b.WriteByte('\n')
}
}
return strings.TrimRight(b.String(), "\n")
}
// movieDetails renders the detail pane text for a movie.
func movieDetails(m radarr.Movie) detailText {
var b strings.Builder
@@ -73,7 +120,7 @@ func movieDetails(m radarr.Movie) detailText {
case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n")
}
return detailText{head: b.String(), plot: tview.Escape(m.Overview)}
return detailText{head: b.String(), plot: tview.Escape(m.Overview), people: movieRef(m)}
}
// profileIndex returns the index of the profile named name, or 0.
@@ -149,7 +196,7 @@ func seriesDetails(s sonarr.Series) detailText {
fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
}
}
return detailText{head: b.String(), plot: tview.Escape(s.Overview)}
return detailText{head: b.String(), plot: tview.Escape(s.Overview), people: seriesRef(s)}
}
func plural(n int) string {
+129 -55
View File
@@ -1,77 +1,151 @@
package ui
import (
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const helpText = `[yellow]Global[-]
F1-F5 switch view (also from the release and episode lists)
Tab, Shift+Tab switch panel within a view
?, F9 this help (F9 works while typing)
q, Ctrl+C quit
Esc close dialog / go back
// helpItem is one line of the key reference: the keys and what they do.
type helpItem struct{ keys, desc string }
[yellow]Movies and Series[-]
/ focus the search box
Enter, a add the selected title (dialog: ↑↓ move, ←→ change a
dropdown, Space toggles, Enter/Space opens a list)
i interactive release search (library titles)
// helpSection is a titled group of items, with an optional note above them.
type helpSection struct {
title string
note string
items []helpItem
}
[yellow]Library[-]
Tab switch between the movies and series lists
f cycle 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 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
s automatic search (series: all missing, asks first)
i, Enter movie: release search; series: seasons and episodes
r reload
var helpSections = []helpSection{
{title: "Global", items: []helpItem{
{"F1-F7", "switch view (also from the release and episode lists)"},
{"Tab, Shift+Tab", "switch panel within a view"},
{"F9", "this help (works everywhere, also while typing)"},
{"Ctrl+C", "quit"},
{"Esc", "close a dialog / go back"},
}},
{title: "Movies and Series", items: []helpItem{
{"/", "focus the search box"},
{"Enter", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"},
{"i", "interactive release search (library titles)"},
{"[ ]", "with an empty search box: previous / next Trakt list (trending now, most watched this week, anticipated, popular; movies also the US box office); needs a Trakt client ID (Config view)"},
{"r", "reload the Trakt list"},
}},
{title: "Library", note: "The dot before a title: green when monitored, grey hollow when not, blue while it is in the Radarr/Sonarr download queue (the details say how far).", items: []helpItem{
{"Tab", "switch between the movies and series lists"},
{"f", "cycle the status filter (all, missing, downloaded, monitored, unmonitored); every list has its own filters"},
{"F", "filter by file properties: quality, language, codec (x265, x264, AV1), HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...). 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, language, codec, HDR / DV, audio (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, Trakt 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 service settings) checks the services and Trakt 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[-]
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)
const helpKeyWidth = 16 // columns for the keys, before the descriptions
[yellow]Release list[-]
Enter, g grab the selected release
s cycle 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)
[yellow]Downloads[-]
p pause / resume the SABnzbd queue
d delete the selected job or entry
b remove from arr queue + blocklist
t retry a failed download (history)
r refresh now
[yellow]Log (F5)[-]
Popups fade away by themselves; everything they said is kept here.
↑↓, PgUp/PgDn scroll G follow new entries c clear
[yellow]Connections[-]
`
// renderHelp lays the key reference out for a text area of the given width:
// descriptions that do not fit wrap under themselves, not under the keys.
func renderHelp(width int) string {
var b strings.Builder
descWidth := max(width-2-helpKeyWidth, 20)
for i, sec := range helpSections {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString("[yellow]" + sec.title + "[-]\n")
if sec.note != "" {
for _, l := range flowWrap(tview.Escape(sec.note), width-2, width-2, 0) {
b.WriteString(" " + l + "\n")
}
}
for _, it := range sec.items {
keys := tview.Escape(it.keys)
pad := strings.Repeat(" ", max(helpKeyWidth-tview.TaggedStringWidth(keys), 1))
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.
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.SetText(helpText + a.serviceSummary())
tv.SetBorderPadding(0, 0, 1, 0)
tv.SetText(text)
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()
return nil
}
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)
}
}
+262
View File
@@ -0,0 +1,262 @@
package ui
import (
"bytes"
"encoding/base64"
"fmt"
xdraw "golang.org/x/image/draw"
"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
// cellPixels returns the size of a cell in pixels (0 if unknown): images are
// scaled to exactly fill their cells, because terminals do not all enlarge
// an image that is smaller than its placement (Ghostty shows it at its own
// size, so a small poster came out smaller than the others).
cellPixels func() (w, h int)
// background, if set, runs prepare off the UI goroutine and then ready on
// it. Scaling and encoding a poster takes about 200 ms; done in Draw, every
// step to a title with a poster not uploaded yet waited that long. Without
// it (tests) posters are prepared at once.
background func(prepare func() prepared, ready func(prepared))
preparing map[footprint]bool
done map[footprint]prepared // prepared, not uploaded yet
}
// prepared is a poster encoded for upload.
type prepared struct {
encoded string // base64 PNG
w, h int // its size in pixels
err error
}
// maxPrepared bounds the prepared posters that wait for a draw (the
// selection moved on before they were ready).
const maxPrepared = 8
// 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{},
preparing: map[footprint]bool{}, done: map[footprint]prepared{}}
}
// 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, or 0
// while the poster is still being prepared in the background (show is called
// again on the redraw when it is ready). 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
}
cw, ch := 0, 0
if k.cellPixels != nil {
cw, ch = k.cellPixels()
}
prepare := func() prepared { return encodePoster(img, rect, cols*cw, rows*ch) }
p, ok := k.done[key]
switch {
case ok:
delete(k.done, key)
case k.background == nil:
p = prepare()
default:
if !k.preparing[key] {
k.preparing[key] = true
k.background(prepare, func(p prepared) {
delete(k.preparing, key)
if len(k.done) >= maxPrepared {
clear(k.done)
}
k.done[key] = p
})
}
return 0, nil
}
if p.err != nil {
return 0, p.err
}
return k.upload(key, p)
}
// encodePoster scales the part rect of img to w×h pixels (if both are known)
// and encodes it. Safe for use from any goroutine.
func encodePoster(img *image.RGBA, rect image.Rectangle, w, h int) prepared {
var src image.Image = img.SubImage(rect)
if w > 0 && h > 0 {
src = scaleImage(src, w, h)
}
var data bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestSpeed}
if err := enc.Encode(&data, src); err != nil {
return prepared{err: err}
}
return prepared{encoded: base64.StdEncoding.EncodeToString(data.Bytes()), w: src.Bounds().Dx(), h: src.Bounds().Dy()}
}
// upload sends a prepared poster and places it.
func (k *kittyGraphics) upload(key footprint, p prepared) (uint32, error) {
img, cols, rows := key.img, key.cols, key.rows
encoded := p.encoded
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, sent as %dx%d px, placed over %dx%d cells",
id, img.Bounds().Dx(), img.Bounds().Dy(), key.rect, p.w, p.h, 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{}
clear(k.done) // posters in preparation still arrive, but are not used without a draw asking for them
}
// 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
}
// scaleImage resizes src to w×h pixels, smoothly (bilinear) both up and down.
// Sizes beyond 4000 pixels are not made (a misreported cell size).
func scaleImage(src image.Image, w, h int) image.Image {
b := src.Bounds()
if w <= 0 || h <= 0 || w > 4000 || h > 4000 || (w == b.Dx() && h == b.Dy()) {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, b, xdraw.Src, nil)
return dst
}
+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,
}
+445
View File
@@ -0,0 +1,445 @@
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")
}
}
func TestPostersAreSentAtTheBoxSize(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 333, 500)) // Radarr's "500" copy, smaller than the box
var msgs []string
k := newKittyGraphics(func([]byte) error { return nil })
k.debug = func(m string) { msgs = append(msgs, m) }
k.cellPixels = func() (int, int) { return 22, 48 } // a high-resolution screen
if _, err := k.show(img, img.Bounds(), 31, 21); err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 682x1008 px") {
t.Errorf("upload: %q", msgs)
}
// Without the cell size the image goes as it is.
k.cellPixels = func() (int, int) { return 0, 0 }
img2 := image.NewRGBA(image.Rect(0, 0, 426, 640))
msgs = nil
if _, err := k.show(img2, img2.Bounds(), 31, 21); err != nil {
t.Fatal(err)
}
if len(msgs) != 1 || !strings.Contains(msgs[0], "sent as 426x640 px") {
t.Errorf("upload without cell size: %q", msgs)
}
}
func TestPostersArePreparedInTheBackground(t *testing.T) {
var rec recorder
k := newKittyGraphics(rec.write)
var queued []func()
k.background = func(prepare func() prepared, ready func(prepared)) {
p := prepare()
queued = append(queued, func() { ready(p) })
}
img := image.NewRGBA(image.Rect(0, 0, 40, 60))
if id, err := k.show(img, img.Bounds(), 4, 3); id != 0 || err != nil || len(rec.cmds) != 0 {
t.Fatalf("first draw: id %d, err %v, %d commands", id, err, len(rec.cmds))
}
k.show(img, img.Bounds(), 4, 3) // a redraw meanwhile does not prepare it twice
if len(queued) != 1 {
t.Fatalf("%d preparations", len(queued))
}
queued[0]()
id, err := k.show(img, img.Bounds(), 4, 3)
if id == 0 || err != nil || len(rec.cmds) == 0 {
t.Fatalf("once ready: id %d, err %v, %d commands", id, err, len(rec.cmds))
}
if again, _ := k.show(img, img.Bounds(), 4, 3); again != id || len(queued) != 1 {
t.Errorf("uploaded posters are reused: id %d, %d preparations", again, len(queued))
}
}
+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)
}
}
+3 -3
View File
@@ -129,10 +129,10 @@ func staticOptions(o []string) func(filterPool) []string {
var facetDefs = []facet{
{"Quality", staticOptions(resOptions), func(f attrFilter) string { return f.res }, func(f attrFilter, v string) attrFilter { f.res = v; return f }, nil, nil},
{"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = v; return f }, nil, nil},
{"Codec", staticOptions(codecOptions), func(f attrFilter) string { return f.codec }, func(f attrFilter, v string) attrFilter { f.codec = v; return f }, nil, nil},
{"HDR", staticOptions(hdrOptions), func(f attrFilter) string { return f.hdr }, func(f attrFilter, v string) attrFilter { f.hdr = v; return f }, nil, nil},
{"Audio", staticOptions(audioOptions), func(f attrFilter) string { return f.audio }, func(f attrFilter, v string) attrFilter { f.audio = v; return f }, nil, nil},
{"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = v; return f }, nil, nil},
}
// listedValues returns the options a facet offers: those that, together with
@@ -153,8 +153,8 @@ func listedValues(f facet, state attrFilter, pool filterPool) []string {
return vals
}
// openAttrFilter shows the dialog to pick quality, codec, HDR, audio and
// language. Every change is passed to set immediately so the list behind the
// openAttrFilter shows the dialog to pick quality, language, codec, HDR and
// audio (the most used first). Every change is passed to set immediately so the list behind the
// dialog follows along, also while moving through an open list; Cancel and Esc
// restore cur. Entries that would leave no results are not offered. The
// returned function re-evaluates them when the pool has changed; onClose runs
+123
View File
@@ -0,0 +1,123 @@
package ui
import (
"context"
"fmt"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// libQueueInterval is how often the Library (and a series' seasons and
// episodes) ask for the download queues while they are visible.
const libQueueInterval = 10 * time.Second
// dlInfo says that a library title is in the Radarr/Sonarr download queue.
type dlInfo struct {
count int // queue entries (episodes of a series)
progress float64 // percent, of the first entry
timeLeft string // of the first entry
}
// dlLine is the details pane line for a title in the queue.
func (d dlInfo) dlLine() string {
if d.count > 1 {
return fmt.Sprintf("[blue]Downloading[-] %d episodes\n", d.count)
}
line := fmt.Sprintf("[blue]Downloading[-] %.0f%%", d.progress)
if d.timeLeft != "" {
line += " · " + d.timeLeft + " left"
}
return line + "\n"
}
// queueByTitle groups queue entries by the library title they belong to.
func queueByTitle(items []arr.QueueItem, series bool) map[int]dlInfo {
out := map[int]dlInfo{}
for _, q := range items {
id := q.MovieID
if series {
id = q.SeriesID
}
if id == 0 {
continue
}
d, seen := out[id]
d.count++
if !seen {
d.progress, d.timeLeft = q.Progress(), q.TimeLeft
}
out[id] = d
}
return out
}
// queueByEpisode picks the queue entries of one series, by episode.
func queueByEpisode(items []arr.QueueItem, seriesID int) map[int]dlInfo {
out := map[int]dlInfo{}
for _, q := range items {
if q.SeriesID != seriesID || q.EpisodeID == 0 {
continue
}
if _, seen := out[q.EpisodeID]; !seen {
out[q.EpisodeID] = dlInfo{count: 1, progress: q.Progress(), timeLeft: q.TimeLeft}
}
}
return out
}
// every runs f on the UI goroutine every d until stop is called.
func (a *App) every(d time.Duration, f func()) (stop func()) {
done := make(chan struct{})
go func() {
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
a.post(f)
}
}
}()
return func() { close(done) }
}
// loadQueue fetches both download queues and marks the titles in them.
// Failures (a service down or not configured) leave the marks as they were.
func (v *libraryView) loadQueue() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
movies, merr := v.radarr.Queue(ctx)
series, serr := v.sonarr.Queue(ctx)
v.app.post(func() {
if merr == nil {
v.downloading[kindMovies] = queueByTitle(movies, false)
}
if serr == nil {
v.downloading[kindSeries] = queueByTitle(series, true)
}
if merr == nil || serr == nil {
v.render()
}
})
}()
}
// startQueueWatch refreshes the queue marks while the Library is visible.
func (v *libraryView) startQueueWatch() {
v.loadQueue()
if v.queueStop == nil {
v.queueStop = v.app.every(libQueueInterval, v.loadQueue)
}
}
// deactivate stops the queue watch when the Library is left.
func (v *libraryView) deactivate() {
if v.queueStop != nil {
v.queueStop()
v.queueStop = nil
}
}
+134 -8
View File
@@ -67,6 +67,19 @@ var libFilters = []struct {
{"unmonitored", func(i libItem) bool { return !i.monitored }},
}
// libMark is the mark before a library title: blue while it is in the
// download queue, else green when monitored; filled when monitored, hollow
// when not.
func libMark(monitored, downloading bool) string {
if !downloading {
return monMark(monitored)
}
if monitored {
return "[blue]●[-]"
}
return "[blue]○[-]"
}
func monMark(monitored bool) string {
if monitored {
return "[green]●[-]"
@@ -215,6 +228,10 @@ type libraryView struct {
filePending int
refreshDialog func() // re-evaluates the options of the open filter dialog
// Titles in the Radarr/Sonarr download queue, by kind and id (libqueue.go).
downloading [2]map[int]dlInfo
queueStop func()
}
type seriesFiles struct {
@@ -244,12 +261,12 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
v.cur = kind
v.input.SetLabel("Filter " + kind.String() + ": ")
v.input.SetText(p.text)
p.table.SetBorderColor(tcell.ColorYellow)
p.table.SetBorderColor(named("yellow"))
v.renderDetails()
})
p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) })
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).
AddItem(v.header, 1, 0, false).
@@ -263,6 +280,14 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
v.render()
})
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()
return v
}
@@ -293,6 +318,8 @@ func (v *libraryView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
v.search()
case 't':
v.toggleMonitored()
case 'd':
v.remove()
default:
return ev
}
@@ -307,6 +334,7 @@ func (v *libraryView) activate() {
v.app.app.SetFocus(v.pane().table)
v.render()
v.reload()
v.startQueueWatch()
}
// reload fetches both lists in parallel.
@@ -353,8 +381,11 @@ func (v *libraryView) reloadPane(p *libPane) {
defer v.loadSeriesFiles(false)
}
if err != nil {
// Keep showing the last good list; say why it is stale.
v.app.SetMessage("[red]" + kind.service() + ": " + tview.Escape(err.Error()))
// 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()))
}
} else {
p.all, p.loaded = items, true
}
@@ -381,9 +412,9 @@ func (v *libraryView) render() {
}
func (v *libraryView) renderPane(p *libPane) {
selID := -1
selID, prevRow := -1, 0
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)
@@ -391,15 +422,26 @@ func (v *libraryView) renderPane(p *libPane) {
header(p.table, 0, "Title", "Status", "Size")
selRow := 0
for i, it := range p.shown {
p.table.SetCell(i+1, 0, tview.NewTableCell(monMark(it.monitored)+" "+it.label).SetExpansion(1))
_, dl := v.downloading[p.kind][it.id]
title := titleCell{prefix: libMark(it.monitored, dl) + " ", 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, 2, tview.NewTableCell(it.size).SetAlign(tview.AlignRight))
if it.id == selID {
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"
switch {
case notConfigured(p.err):
placeholder = p.kind.service() + " is not configured (Config view, F6)"
case p.err != nil && !p.loaded:
placeholder = "Could not load, press r to retry"
case !p.loaded:
@@ -427,9 +469,93 @@ func (v *libraryView) renderPane(p *libPane) {
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() {
if it, ok := v.selected(); ok {
v.details.Show(it.details, it.cover)
d := it.details
if dl, ok := v.downloading[v.cur][it.id]; ok {
d.head += dl.dlLine()
}
v.details.Show(d, it.cover)
return
}
v.details.Clear()
+200
View File
@@ -1,10 +1,16 @@
package ui
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"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/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
@@ -209,3 +215,197 @@ func TestListedValues(t *testing.T) {
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)
}
}
}
func TestSeasonPoster(t *testing.T) {
seasonSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/broken.jpg" {
http.NotFound(w, r)
return
}
w.Write([]byte("SEASON"))
}))
defer seasonSrv.Close()
series := coverRef{key: "series", fetch: func(context.Context, bool) ([]byte, error) { return []byte("SERIES"), nil }}
v := &seriesView{cover: series, series: sonarr.Series{Seasons: []sonarr.Season{
{SeasonNumber: 1, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/s1.jpg"}}},
{SeasonNumber: 2},
{SeasonNumber: 3, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/broken.jpg"}}},
}}}
fetch := func(season int) string {
data, err := v.seasonCover(season).fetch(t.Context(), false)
if err != nil {
t.Fatal(err)
}
return string(data)
}
if got := fetch(1); got != "SEASON" || v.seasonCover(1).key == series.key {
t.Errorf("season 1: %q", got)
}
if v.seasonCover(2).key != series.key {
t.Error("a season without a poster shows the series poster")
}
if got := fetch(3); got != "SERIES" {
t.Errorf("a season poster that cannot be loaded falls back to the series poster: %q", got)
}
}
func TestDownloadingTitlesAreMarked(t *testing.T) {
movies := queueByTitle([]arr.QueueItem{
{MovieID: 3, Size: 100, SizeLeft: 55, TimeLeft: "00:06:12"},
{MovieID: 0}, // not matched to a movie
}, false)
series := queueByTitle([]arr.QueueItem{{SeriesID: 7, Size: 10, SizeLeft: 10}, {SeriesID: 7}, {MovieID: 3}}, true)
if len(movies) != 1 || movies[3].count != 1 || movies[3].progress != 45 || movies[3].dlLine() != "[blue]Downloading[-] 45% · 00:06:12 left\n" {
t.Errorf("movies %+v", movies)
}
if len(series) != 1 || series[7].count != 2 || series[7].dlLine() != "[blue]Downloading[-] 2 episodes\n" {
t.Errorf("series %+v", series)
}
a := newTestApp(t)
v := newLibraryView(a, a.radarr, a.sonarr)
p := v.panes[kindMovies]
p.all, p.loaded = []libItem{
{id: 3, title: "Dune", label: "Dune (2021)", monitored: true, details: detailText{head: "Dune\n"}},
{id: 4, title: "Heat", label: "Heat (1995)", monitored: true},
}, true
v.downloading[kindMovies] = movies
v.render()
cell := func(row int) string { return p.table.GetCell(row, 0).Text }
if !strings.HasPrefix(cell(1), "[blue]●[-]") || !strings.HasPrefix(cell(2), "[green]●[-]") {
t.Errorf("marks: %q / %q", cell(1), cell(2))
}
p.table.Select(1, 0)
v.renderDetails()
if !strings.Contains(v.details.detail.head, "Downloading[-] 45%") {
t.Errorf("details %q", v.details.detail.head)
}
if libMark(false, true) != "[blue]○[-]" {
t.Error("an unmonitored title in the queue keeps the hollow mark")
}
}
func TestEpisodesInTheQueue(t *testing.T) {
got := queueByEpisode([]arr.QueueItem{
{SeriesID: 7, EpisodeID: 70, Size: 10, SizeLeft: 5, TimeLeft: "00:01:00"},
{SeriesID: 7, EpisodeID: 71},
{SeriesID: 8, EpisodeID: 80}, // another series
{SeriesID: 7}, // no episode
}, 7)
if len(got) != 2 || got[70].progress != 50 || got[70].timeLeft != "00:01:00" {
t.Fatalf("queueByEpisode: %+v", got)
}
s := sonarr.Series{Title: "Signal Fire"}
ep := &sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, Title: "Ember", Monitored: true}
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", got[70]); !strings.Contains(d.head, "Downloading[-] 50% · 00:01:00 left") {
t.Errorf("episode: %q", d.head)
}
sum := seasonSummary{number: 3, total: 10, have: 4}
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 1}); !strings.Contains(d.head, "Downloading[-] 1 episode\n") {
t.Errorf("season with one: %q", d.head)
}
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 2}); !strings.Contains(d.head, "Downloading[-] 2 episodes") {
t.Errorf("season with two: %q", d.head)
}
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", dlInfo{}); strings.Contains(d.head, "Downloading") {
t.Errorf("not downloading: %q", d.head)
}
}
+95
View File
@@ -0,0 +1,95 @@
package ui
import (
"encoding/json"
"maps"
"slices"
"sync"
"time"
)
const (
lookupTTL = 3 * 24 * time.Hour // a stored lookup is used this long
maxLookupsSaved = 2000 // lookups kept (in memory and on disk)
lookupSaveDelay = 5 * time.Second // new lookups are written to disk this long after the last one
)
// lookupStore keeps the Radarr/Sonarr lookups of Trakt list titles that are
// not in the library, so a list shows at once after a restart: a lookup asks
// the metadata provider and takes about a second, while whether a title is
// in the library is asked of the server every time (that is quick, and must
// not be stale). Safe for use from any goroutine; read from disk on first use.
type lookupStore struct {
disk *diskCache
mu sync.Mutex
loaded bool
entries map[string]savedLookup
dirty bool
timer *time.Timer
}
// savedLookup is one lookup result as Radarr/Sonarr sent it.
type savedLookup struct {
Saved time.Time `json:"saved"`
Data json.RawMessage `json:"data"`
}
func newLookupStore(disk *diskCache) *lookupStore { return &lookupStore{disk: disk} }
func (s *lookupStore) load() {
if !s.loaded {
s.loaded, s.entries = true, s.disk.loadLookups()
}
}
// get decodes the stored lookup of key into v, if there is a fresh one.
func (s *lookupStore) get(key string, v any) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.load()
e, ok := s.entries[key]
if !ok || time.Since(e.Saved) > lookupTTL {
return false
}
return json.Unmarshal(e.Data, v) == nil
}
// put stores a lookup, dropping the oldest ones beyond maxLookupsSaved, and
// has it written to disk soon.
func (s *lookupStore) put(key string, v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.load()
s.entries[key] = savedLookup{Saved: time.Now(), Data: data}
if len(s.entries) > maxLookupsSaved {
keys := slices.SortedFunc(maps.Keys(s.entries), func(x, y string) int {
return s.entries[x].Saved.Compare(s.entries[y].Saved)
})
for _, k := range keys[:len(keys)-maxLookupsSaved] {
delete(s.entries, k)
}
}
s.dirty = true
if s.timer != nil {
s.timer.Stop()
}
s.timer = time.AfterFunc(lookupSaveDelay, func() { _ = s.save() })
}
// save writes the lookups to disk if there are new ones.
func (s *lookupStore) save() error {
s.mu.Lock()
if !s.dirty {
s.mu.Unlock()
return nil
}
s.dirty = false
all := maps.Clone(s.entries)
s.mu.Unlock()
return s.disk.saveLookups(all)
}
+63
View File
@@ -0,0 +1,63 @@
package ui
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func TestListLookupsKeptOnDisk(t *testing.T) {
var lookups, libraryAsks atomic.Int32
inLibrary := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie":
libraryAsks.Add(1)
if inLibrary && r.URL.Query().Get("tmdbId") == "949" {
w.Write([]byte(`[{"id":7,"title":"Heat","year":1995,"tmdbId":949}]`))
return
}
w.Write([]byte(`[{"id":3,"title":"Other","tmdbId":1}]`)) // ignores the filter
case "/api/v3/movie/lookup":
lookups.Add(1)
w.Write([]byte(`[{"title":"Heat","year":1995,"tmdbId":949,"overview":"Cops and robbers."}]`))
}
}))
defer srv.Close()
disk := &diskCache{dir: t.TempDir()}
entry := trakt.Entry{Title: "Heat", IDs: trakt.IDs{TMDB: 949}}
resolve := func() result {
t.Helper()
store := newLookupStore(disk) // a new run
b := radarrBackend{radarr.NewClient(srv.URL, "k"), store}
r, err := b.resolve(context.Background(), entry)
if err != nil || r == nil {
t.Fatalf("resolve: %v %v", r, err)
}
if err := store.save(); err != nil {
t.Fatal(err)
}
return r
}
if r := resolve(); r.inLibrary() || lookups.Load() != 1 {
t.Fatalf("first run: in library %v, %d lookups", r.inLibrary(), lookups.Load())
}
r := resolve()
if lookups.Load() != 1 || r.details().plot != "Cops and robbers." {
t.Errorf("second run: %d lookups, plot %q", lookups.Load(), r.details().plot)
}
// Added meanwhile (elsewhere): the library answer wins over the stored lookup.
inLibrary = true
if r := resolve(); !r.inLibrary() || lookups.Load() != 1 {
t.Errorf("after adding: in library %v, %d lookups", r.inLibrary(), lookups.Load())
}
if libraryAsks.Load() != 3 {
t.Errorf("library asked %d times", libraryAsks.Load())
}
}
+14
View File
@@ -0,0 +1,14 @@
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{} }
cacheDirFunc = func() string { return "" } // never the user's cache
os.Exit(m.Run())
}
+9 -3
View File
@@ -23,7 +23,7 @@ func (l level) String() string {
}
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.
@@ -115,7 +115,13 @@ type toastRoot struct {
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) {
w, _ := screen.Size()
r.app.setNarrow(w < narrowWidth)
r.Flex.Draw(screen)
a := r.app
now := time.Now()
@@ -136,9 +142,9 @@ func (r *toastRoot) Draw(screen tcell.Screen) {
x := w - width - 1
fg := t.level.color()
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++ {
screen.SetContent(cx, y, ' ', nil, style)
}
+37
View File
@@ -1,6 +1,7 @@
package ui
import (
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
"testing"
"github.com/rivo/tview"
@@ -39,3 +40,39 @@ func TestOnlyFullOverlays(t *testing.T) {
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)
}
}
+166
View File
@@ -0,0 +1,166 @@
package ui
import (
"context"
"errors"
"slices"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
peopleRetryWait = 2 * time.Minute // a failed lookup is not repeated sooner
peopleSaveDelay = 5 * time.Second // new casts are written to disk this long after the last one
)
// peopleCache holds the casts loaded from Trakt (also those kept on disk from
// earlier runs, see diskcache.go) and the lookups in flight. UI goroutine only.
type peopleCache struct {
done map[string]trakt.People
saved map[string]time.Time // when each cast was fetched
failed map[string]time.Time
waiting map[string][]func(trakt.People, error)
dirty bool // casts not written to disk yet
timer *time.Timer // writes them
}
func newPeopleCache() *peopleCache {
return &peopleCache{done: map[string]trakt.People{}, saved: map[string]time.Time{}, failed: map[string]time.Time{},
waiting: map[string][]func(trakt.People, error){}}
}
// loadSavedPeople fills the cache with the casts stored on disk.
func (a *App) loadSavedPeople() {
for k, p := range a.disk.loadPeople() {
a.people.done[k] = trakt.People{Cast: p.Cast, Leaders: p.Leaders}
a.people.saved[k] = p.Saved
}
}
// remember adds a fetched cast, dropping the oldest ones beyond
// maxPeopleSaved, and has it written to disk soon.
func (a *App) remember(key string, p trakt.People) {
c := a.people
c.done[key], c.saved[key] = p, time.Now()
if len(c.done) > maxPeopleSaved {
keys := make([]string, 0, len(c.saved))
for k := range c.saved {
keys = append(keys, k)
}
slices.SortFunc(keys, func(x, y string) int { return c.saved[x].Compare(c.saved[y]) })
for _, k := range keys[:len(keys)-maxPeopleSaved] {
delete(c.done, k)
delete(c.saved, k)
}
}
c.dirty = true
if c.timer != nil {
c.timer.Stop()
}
c.timer = time.AfterFunc(peopleSaveDelay, func() { a.post(a.savePeople) })
}
// savePeople writes the casts to disk if there are new ones (UI goroutine;
// the writing itself happens in the background).
func (a *App) savePeople() {
c := a.people
if !c.dirty {
return
}
c.dirty = false
all := a.peopleSnapshot()
go func() { _ = a.disk.savePeople(all) }()
}
func (a *App) peopleSnapshot() map[string]savedPeople {
c := a.people
all := make(map[string]savedPeople, len(c.done))
for k, p := range c.done {
all[k] = savedPeople{Saved: c.saved[k], Cast: p.Cast, Leaders: p.Leaders}
}
return all
}
// loadPeople delivers the cast of a title to done on the UI goroutine,
// asking Trakt in the background when it is not cached. A title Trakt does
// not know is remembered as having no cast.
func (a *App) loadPeople(r trakt.Ref, done func(trakt.People, error)) {
c, key := a.people, r.Key()
if p, ok := c.done[key]; ok {
done(p, nil)
return
}
if t, ok := c.failed[key]; ok && time.Since(t) < peopleRetryWait {
done(trakt.People{}, errors.New("cast not available"))
return
}
if _, inflight := c.waiting[key]; inflight {
c.waiting[key] = append(c.waiting[key], done)
return
}
c.waiting[key] = []func(trakt.People, error){done}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
p, err := a.trakt.People(ctx, r)
if errors.Is(err, trakt.ErrNotFound) {
p, err = trakt.People{}, nil
}
a.post(func() {
if a.people != c {
return // dropped meanwhile (the client ID changed)
}
waiters := c.waiting[key]
delete(c.waiting, key)
if err != nil {
c.failed[key] = time.Now()
} else {
a.remember(key, p)
}
for _, w := range waiters {
w(p, err)
}
})
}()
}
// showPeople loads the cast of the title the pane shows, once the selection
// rests (like the poster: holding an arrow key must not send a request per
// step, Trakt allows 500 per five minutes).
func (d *detailPane) showPeople(r trakt.Ref) {
key := r.Key()
if key == d.castKey {
return // the same title again (a refresh): keep what is shown
}
if d.castSettle != nil {
d.castSettle.Stop()
}
d.castKey, d.cast = key, ""
if key == "" || !d.app.trakt.Configured() {
return
}
now := time.Now()
moving := now.Sub(d.castShown) < posterSettle
d.castShown = now
load := func() {
d.app.loadPeople(r, func(p trakt.People, err error) {
if d.castKey != key || err != nil {
return
}
d.cast = peopleText(p, r.Kind)
d.laidOut.valid = false
})
}
if moving {
d.castSettle = time.AfterFunc(posterSettle, func() {
d.app.post(func() {
if d.castKey == key {
load()
}
})
})
return
}
load()
}
+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)
}
+5 -1
View File
@@ -135,7 +135,7 @@ func (v *releasesView) render() {
v.table.Clear()
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" {
cell.SetExpansion(1)
}
@@ -269,6 +269,10 @@ func (v *releasesView) doGrab(r arr.Release) {
return
}
v.app.SetMessage("[green]Sent to the download client: " + tview.Escape(truncate(r.Title, 60)))
v.app.closeIfTop(v.root) // done here: back to where the search started
for _, f := range v.app.afterGrab {
time.AfterFunc(3*time.Second, func() { v.app.post(f) })
}
})
}()
}
+111 -20
View File
@@ -14,6 +14,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
@@ -25,7 +26,6 @@ const (
type searchView struct {
app *App
be backend
cfg config.Arr
root *tview.Flex
input *tview.InputField
@@ -33,10 +33,20 @@ type searchView struct {
details *detailPane
// UI-goroutine state.
results []result
timer *time.Timer
cancel context.CancelFunc
gen int
results []result
notes []string // per result, a Trakt list's number ("1.2k watching"); nil for searches
placeholder string // shown when there are no results
notesWidth int // list width the rows were laid out for
timer *time.Timer
cancel context.CancelFunc
gen int
// Trakt lists, shown while the search box is empty (see discover.go).
lists []trakt.List
listIdx int
browsing bool // the results are a Trakt list
discover map[int]*discovered
loadingList map[int]bool
// Add-form options, loaded in the background.
mu sync.Mutex
@@ -46,20 +56,23 @@ type searchView struct {
loaded bool
}
func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
func newSearchView(app *App, be backend) *searchView {
v := &searchView{
app: app,
be: be,
cfg: cfg,
input: tview.NewInputField().SetLabel("Search: "),
list: tview.NewList().ShowSecondaryText(false),
details: newDetailPane(app),
lists: be.lists(),
discover: map[int]*discovered{},
loadingList: map[int]bool{},
}
v.list.SetBorder(true).SetTitle(" " + be.noun() + " ")
left := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.input, 1, 0, true).
AddItem(v.list, 0, 1, false)
AddItem(notesList{v.list, v}, 0, 1, false)
v.root = tview.NewFlex().
AddItem(left, 0, 1, true).
AddItem(v.details, 0, 1, false)
@@ -79,6 +92,12 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
v.app.app.SetFocus(v.list)
return nil
}
// While the box is empty a Trakt list is shown, and [ ] switch it (no
// search starts with a bracket).
if ev.Key() == tcell.KeyRune && (ev.Rune() == '[' || ev.Rune() == ']') && v.browsing && v.input.GetText() == "" {
v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()])
return nil
}
return ev
})
@@ -96,9 +115,16 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
case '/':
v.app.app.SetFocus(v.input)
return nil
case 'a':
v.add(v.list.GetCurrentItem())
return nil
case '[', ']':
if v.browsing {
v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()])
return nil
}
case 'r':
if v.browsing {
v.reloadList()
return nil
}
case 'i':
v.interactive(v.list.GetCurrentItem())
return nil
@@ -106,10 +132,27 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView {
return ev
})
app.reconnect = append(app.reconnect, v.reloadOptions)
go v.loadOptions()
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.
func (v *searchView) loadOptions() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
@@ -158,7 +201,7 @@ func (v *searchView) onSearchChanged(text string) {
gen := v.invalidate()
if len([]rune(text)) < minAutoSearch {
if text == "" {
v.setResults(nil)
v.showList()
}
return
}
@@ -184,7 +227,7 @@ func (v *searchView) search(text string) {
}
gen := v.invalidate()
if text == "" {
v.setResults(nil)
v.showList()
return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -217,19 +260,32 @@ func (v *searchView) run(ctx context.Context, gen int, term string) {
})
}
// setResults replaces the list contents (UI goroutine).
// setResults replaces the list contents with search results (UI goroutine).
func (v *searchView) setResults(results []result) {
v.results = results
v.results, v.notes, v.placeholder, v.browsing = results, nil, "", false
v.list.SetTitle(" " + v.be.noun() + " ")
v.render(0)
}
// activate shows a Trakt list when the view opens with an empty search.
func (v *searchView) activate() {
if !v.browsing && len(v.results) == 0 && strings.TrimSpace(v.input.GetText()) == "" {
v.showList()
}
}
// render rebuilds the list, selecting index sel.
func (v *searchView) render(sel int) {
v.list.Clear()
for _, r := range v.results {
v.list.AddItem(r.label(), "", 0, nil)
_, _, width, _ := v.list.GetInnerRect()
v.notesWidth = width
for i := range v.results {
v.list.AddItem(v.itemText(i, width), "", 0, nil)
}
if len(v.results) == 0 {
if v.placeholder != "" {
v.list.AddItem("[gray]"+v.placeholder+"[-]", "", 0, nil)
}
v.details.Clear()
return
}
@@ -238,6 +294,41 @@ func (v *searchView) render(sel int) {
v.showDetails(sel)
}
// itemText is the row of result i in a list width columns wide: the label,
// and a Trakt list's number (if any) in grey against the right edge, so the
// numbers of all rows line up. A label too long for that gets the number
// right after it.
func (v *searchView) itemText(i, width int) string {
label := v.results[i].label()
if i >= len(v.notes) || v.notes[i] == "" {
return label
}
note := tview.Escape(v.notes[i])
gap := width - tview.TaggedStringWidth(label) - tview.TaggedStringWidth(note)
if gap < 2 {
gap = 2
}
return label + strings.Repeat(" ", gap) + "[gray]" + note + "[-]"
}
// notesList draws the results list, first laying the rows out again when the
// width changed (the numbers of a Trakt list sit against the right edge).
type notesList struct {
*tview.List
v *searchView
}
func (n notesList) Draw(screen tcell.Screen) {
v := n.v
if _, _, width, _ := v.list.GetInnerRect(); width != v.notesWidth && len(v.notes) > 0 {
v.notesWidth = width
for i := range v.results {
v.list.SetItemText(i, v.itemText(i, width), "")
}
}
n.List.Draw(screen)
}
func (v *searchView) showDetails(i int) {
if i < 0 || i >= len(v.results) {
v.details.Clear()
@@ -253,7 +344,7 @@ func (v *searchView) interactive(i int) {
}
r := v.results[i]
if !r.inLibrary() {
v.app.SetMessage(fmt.Sprintf("[yellow]Add %s to %s first (a), then search for releases", tview.Escape(r.title()), v.be.name()))
v.app.SetMessage(fmt.Sprintf("[yellow]Add %s to %s first (Enter), then search for releases", tview.Escape(r.title()), v.be.name()))
return
}
v.be.interactive(v, r)
@@ -301,8 +392,8 @@ func (v *searchView) add(i int) {
lSearch = "Search now"
)
form := tview.NewForm().
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.cfg.DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.cfg.DefaultRootFolder), nil)
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
height := 13
if len(modes) > 0 {
form.AddDropDown(lEpisode, modeNames, 0, nil)
+507
View File
@@ -0,0 +1,507 @@
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"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
// 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
traktID 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,
traktID: c.Trakt.ClientID,
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)
set("trakt.client_id", &raw.Trakt.ClientID, v.traktID)
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 secret(key) {
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)
f.AddFormItem(newFormHeading("Trakt (optional: trending, most watched, anticipated)"))
input("Client ID", "trakt.client_id")
// 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
}
}
// secret reports whether a setting is masked like a password.
func secret(key string) bool { return strings.HasSuffix(key, ".apikey") || key == "trakt.client_id" }
// maskKeys hides or shows the API keys.
func (v *settingsView) maskKeys(hide bool) {
for key, in := range v.inputs {
if secret(key) {
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,
"trakt.client_id": s.traktID,
}
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"),
traktID: text("trakt.client_id"),
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)
}},
}
if strings.TrimSpace(s.traktID) != "" {
tests = append(tests, struct {
name string
run probeFn
}{"Trakt", func(ctx context.Context) (string, error) {
_, err := trakt.NewClient(s.traktID, "arr-tui").Entries(ctx, trakt.MovieLists[0], 1)
return "", err
}})
}
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]✓ " + strings.TrimSpace(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)
}
+451
View File
@@ -0,0 +1,451 @@
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 last connection setting (the Trakt client ID)
// and before the Interface section.
v.focusGroup(0, false)
for i := 0; i < 11; i++ { // Radarr 4, Sonarr 4, SABnzbd 2, Trakt 1 fields
send(v.form, tcell.KeyDown)
}
if focusedItem() != tview.FormItem(v.testItem) {
t.Fatalf("↓ after the Trakt client ID 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"
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
out/
+92
View File
@@ -0,0 +1,92 @@
# arr-tui for LG webOS TVs
The same views as the terminal app, as a TV app driven by the remote:
- **Movies** and **Series**: search Radarr / Sonarr. With an empty search box you get the Trakt lists
(trending, most watched this week, anticipated, popular, US box office). Add a title, or open
releases and other actions for library titles.
- **Library**: movies and series, each with its own status, title and file-property filters
(quality, language, codec, HDR, audio). A blue dot marks titles that are downloading. A series
opens its seasons and episodes.
- **Downloads**: the SABnzbd queue and history and the Radarr / Sonarr queues. Pause / resume,
delete, retry, remove and blocklist.
- **Calendar**: two weeks of movie releases and airing episodes.
- **Settings** and **Log**.
- **Release search** with approval and rejection reasons, sort, filters and grab.
- **Details pane** with poster, plot and cast from Trakt.
It is a plain web app (TypeScript, no framework) that talks to Radarr, Sonarr, SABnzbd and Trakt
directly: all four answer cross-origin requests, so the TV needs nothing else running.
## The remote
| Button | Does |
|---|---|
| ◀ ▶ ▲ ▼ | move; in a list ▲ ▼ move the selection, at its ends they leave it |
| OK | press; on a title, the menu of what can be done with it |
| Back | close a dialog or page; in a view, back to its tab; on the tabs, leave the app |
| 1–7 | open a view |
| CH ▲ / ▼ | page through a list |
| Red / Green | previous / next Trakt list or calendar week; Library: movies ⇄ series |
| Yellow | filter by file properties (Library); pause / resume SABnzbd (Downloads) |
| Blue | reload |
The footer shows what OK and the colour buttons do in the current view. With the Magic Remote's
pointer, hovering focuses and clicking presses. In a desktop browser, F1–F4 stand in for the
colour buttons and Escape for Back.
## Configuration
The build bakes your arr-tui config into the app: `$ARR_TUI_CONFIG`, else
`~/.config/arr-tui/config.json` (or `node build.mjs --config FILE`). On the TV, **Settings**
changes the addresses, keys, defaults, Trakt client ID and theme. Those changes are kept on the TV
until "Reset to built-in".
The package then holds your API keys. Keep it private and never publish it.
Addresses must not redirect. The terminal app follows a redirect from `http://` to `https://`; a
browser does not for cross-origin requests, so enter the final (usually `https://`) address.
## Install on the TV (Developer Mode)
1. On the TV, install **Developer Mode** from the LG Content Store and sign in with an LG
developer account (free, [webostv.developer.lge.com](https://webostv.developer.lge.com)). Turn
on **Dev Mode Status** and **Key Server**, and note the IP address and the passphrase.
2. Once, on the computer (Node.js 22 or newer):
```sh
cd webos
npm install
npx ares-setup-device -a tv -i "host=TV_IP" -i "port=9922" -i "username=prisoner"
npx ares-novacom --device tv --getkey # asks for the passphrase
```
3. Build, package, install and start:
```sh
npm run package # dist/ and out/com.stinnesbeck.arrtui_<version>_all.ipk
npm run install-tv
npm run launch
```
A Developer Mode session lasts 50 hours; the Developer Mode app extends it, otherwise the TV removes
developer apps. On a rooted TV with the Homebrew Channel the `.ipk` can be installed without that
limit.
The app targets webOS 5 (2020 TVs, Chromium 68) and newer.
## Development
`nix develop` in the repository has Node.js and Chromium (and sets `$CHROMIUM`).
```sh
npm run typecheck
npm test # unit tests (Node's test runner)
npm run demo # dist/ with the fake services of dev/screenshots
python3 ../dev/screenshots/server.py & # the fake Radarr, Sonarr, SABnzbd and Trakt
CHROMIUM=$(command -v chromium) node dev/drive.mjs SCENE OUTDIR
```
`dev/drive.mjs` opens `dist/index.html` in Chromium at 1920×1080, presses remote buttons from a
scene file (`key Down Enter Red`, `type text`, `sleep 1`, `shot name`, `log`) and takes screenshots.
The page can also be opened in any desktop browser (`dist/index.html`).
+150
View File
@@ -0,0 +1,150 @@
// Builds the webOS app into dist/: the bundled script, the page, the icons
// and config.js with the arr-tui config baked in.
//
// node build.mjs config from $ARR_TUI_CONFIG or ~/.config/arr-tui/config.json
// node build.mjs --config FILE another config file
// node build.mjs --demo the fake services of dev/screenshots (server.py)
// node build.mjs --dev not minified
//
// The baked-in config holds API keys: never publish a package built with it.
import * as esbuild from "esbuild";
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
const here = dirname(fileURLToPath(import.meta.url));
const dist = join(here, "dist");
const args = process.argv.slice(2);
const flag = (name) => args.includes(name);
const option = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
// The oldest browser engine targeted: webOS 5 (2020 TVs) has Chromium 68.
const target = "chrome68";
function configPath() {
if (option("--config")) return option("--config");
if (process.env.ARR_TUI_CONFIG) return process.env.ARR_TUI_CONFIG;
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(base, "arr-tui", "config.json");
}
function demoConfig() {
const key = "0123456789abcdef0123456789abcdef";
return {
radarr: { url: "http://127.0.0.1:19891", apikey: key, default_quality_profile: "Ultra-HD", default_root_folder: "/movies" },
sonarr: { url: "http://127.0.0.1:19892", apikey: key, default_quality_profile: "HD-1080p", default_root_folder: "/tv" },
sabnzbd: { url: "http://127.0.0.1:19893", apikey: key },
trakt: { client_id: key },
ui: { theme: "dark" },
};
}
// pick keeps the settings the TV app uses, nothing else from the file.
function pick(c) {
const svc = (s = {}) => ({ url: s.url ?? "", apikey: s.apikey ?? "" });
const arr = (s = {}) => ({ ...svc(s), default_quality_profile: s.default_quality_profile, default_root_folder: s.default_root_folder });
const ui = c.ui ?? {};
return {
radarr: arr(c.radarr),
sonarr: arr(c.sonarr),
sabnzbd: svc(c.sabnzbd),
trakt: { client_id: c.trakt?.client_id },
ui: { theme: ui.theme, refresh_seconds: ui.refresh_seconds, covers: ui.covers, notify_downloads: ui.notify_downloads },
};
}
function loadConfig() {
if (flag("--demo")) return { config: demoConfig(), from: "the demo services", extra: 'window.ARR_TUI_TRAKT_URL = "http://127.0.0.1:19894";\n' };
const path = configPath();
if (!existsSync(path)) {
console.warn(`no config at ${path}: the app starts with Settings`);
return { config: pick({}), from: "nothing", extra: "" };
}
return { config: pick(JSON.parse(readFileSync(path, "utf8"))), from: path, extra: "" };
}
// --- Icons: drawn here, so no image tools are needed ---
function png(size, draw) {
const px = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) px.set(draw(x + 0.5, y + 0.5, size), (y * size + x) * 4);
const raw = Buffer.alloc((size * 4 + 1) * size);
for (let y = 0; y < size; y++) px.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
const chunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td) >>> 0);
return Buffer.concat([len, td, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
}
function crc32(buf) {
let c = ~0;
for (const b of buf) {
c ^= b;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
}
return ~c;
}
// The icon: a dark rounded square with three yellow list bars of different
// lengths and a blue dot (a title downloading), as in the Library.
function icon(x, y, s) {
const u = (v) => v * s;
const r = u(0.18);
const inside = (() => {
const cx = Math.min(Math.max(x, r), s - r);
const cy = Math.min(Math.max(y, r), s - r);
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
})();
if (!inside) return [0, 0, 0, 0];
const bars = [[0.26, 0.42, 0.74], [0.45, 0.42, 0.62], [0.64, 0.42, 0.70]];
for (const [top, left, right] of bars) {
if (y >= u(top) && y <= u(top + 0.1) && x >= u(left) && x <= u(right)) return [0xf2, 0xb6, 0x32, 255];
}
const dots = [[0.31, [0x7f, 0xcc, 0x6e]], [0.50, [0x5f, 0xad, 0xf2]], [0.69, [0x7f, 0xcc, 0x6e]]];
for (const [cy, rgb] of dots) {
if ((x - u(0.3)) ** 2 + (y - u(cy)) ** 2 <= u(0.055) ** 2) return [...rgb, 255];
}
return [0x1d, 0x20, 0x27, 255];
}
// --- Build ---
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
for (const f of readdirSync(join(here, "static"))) copyFileSync(join(here, "static", f), join(dist, f));
writeFileSync(join(dist, "icon.png"), png(80, icon));
writeFileSync(join(dist, "largeIcon.png"), png(130, icon));
const { config, from, extra } = loadConfig();
writeFileSync(join(dist, "config.js"), `// Built from ${from === "nothing" || from === "the demo services" ? from : "the arr-tui config"}. Holds API keys: keep this package private.\nwindow.ARR_TUI_CONFIG = ${JSON.stringify(config, null, 2)};\n${extra}`, { mode: 0o600 });
await esbuild.build({
entryPoints: [join(here, "src", "main.ts")],
bundle: true,
outfile: join(dist, "app.js"),
format: "iife",
target,
minify: !flag("--dev"),
sourcemap: flag("--dev") ? "inline" : false,
legalComments: "none",
logLevel: "warning",
});
const services = ["radarr", "sonarr", "sabnzbd"].filter((s) => config[s].url).join(", ") || "none";
console.log(`dist/ built (config from ${from}; services: ${services}${config.trakt.client_id ? ", Trakt" : ""})`);
+101
View File
@@ -0,0 +1,101 @@
// Runs the built app (dist/) in Chromium at 1920×1080 from file://, as the
// TV loads it, presses remote buttons from a scene file and takes
// screenshots. For development and the README pictures.
//
// node dev/drive.mjs SCENE OUTDIR
//
// CHROMIUM (default: chromium on PATH). Scene lines:
// key Down [Down ...] remote buttons: Left Right Up Down Enter Back Red
// Green Yellow Blue PageUp PageDown 1-7
// type TEXT type into the focused field
// sleep SECONDS
// point X Y / click X Y the Magic Remote's pointer: move there / click there
// shot NAME OUTDIR/NAME.png
// log print the app's console and the focused element
import puppeteer from "puppeteer-core";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const [scenePath, outDir] = process.argv.slice(2);
if (!scenePath || !outDir) {
console.error("usage: node dev/drive.mjs SCENE OUTDIR");
process.exit(2);
}
mkdirSync(outDir, { recursive: true });
const codes = {
Left: 37, Up: 38, Right: 39, Down: 40, Enter: 13, Back: 461,
Red: 403, Green: 404, Yellow: 405, Blue: 406, PageUp: 33, PageDown: 34,
};
for (let i = 1; i <= 9; i++) codes[String(i)] = 48 + i;
const browser = await puppeteer.launch({
executablePath: process.env.CHROMIUM || "chromium",
headless: true,
// file:// pages may call the local fake services.
args: ["--window-size=1920,1080", "--allow-file-access-from-files", "--no-sandbox"],
defaultViewport: { width: 1920, height: 1080 },
});
const page = await browser.newPage();
const logs = [];
// Request URLs in browser messages can carry an API key (SABnzbd, posters).
const redact = (t) => t.replace(/(apikey=)[^&\s'"]+/gi, "$1…");
page.on("console", (m) => logs.push(redact(`${m.type()}: ${m.text()}`)));
page.on("pageerror", (e) => logs.push(redact(`pageerror: ${e.message}`)));
await page.goto("file://" + resolve(here, "..", "dist", "index.html"));
// press sends a remote button as the TV does: a keydown with its keyCode.
async function press(name) {
const code = codes[name];
if (!code) throw new Error("unknown key " + name);
await page.evaluate((code) => {
const target = document.activeElement || document.body;
const ev = new KeyboardEvent("keydown", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "keyCode", { get: () => code });
Object.defineProperty(ev, "which", { get: () => code });
target.dispatchEvent(ev);
}, code);
await new Promise((r) => setTimeout(r, 120));
}
for (const raw of readFileSync(scenePath, "utf8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const [cmd, ...rest] = line.split(/\s+/);
switch (cmd) {
case "key":
for (const k of rest) await press(k);
break;
case "type":
await page.keyboard.type(line.slice(5));
break;
case "point":
await page.mouse.move(Number(rest[0]), Number(rest[1]));
break;
case "click":
await page.mouse.click(Number(rest[0]), Number(rest[1]));
break;
case "sleep":
await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000));
break;
case "shot":
await page.screenshot({ path: join(outDir, rest[0] + ".png") });
break;
case "log": {
const focused = await page.evaluate(() => {
const a = document.activeElement;
return a ? `${a.tagName}.${a.className} "${(a.textContent || "").slice(0, 40)}"` : "none";
});
console.log(logs.splice(0).join("\n"));
console.log("focused:", focused);
break;
}
default:
throw new Error("unknown scene command: " + line);
}
}
if (logs.length) console.log(logs.join("\n"));
await browser.close();
+5
View File
@@ -0,0 +1,5 @@
// Not Go: this file keeps webos/ (and the Go files some npm packages ship
// in node_modules) out of the arr-tui module, so `go test ./...` skips it.
module git.stinnesbeck.com/nils/arr-tui/webos
go 1.26

Some files were not shown because too many files have changed in this diff Show More