Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89541d46f9 | ||
|
|
de1491617e | ||
|
|
0dedcb3893 | ||
|
|
7f6d4d07b9 | ||
|
|
1015135a4b | ||
|
|
0fcc5fb7fa | ||
|
|
45e8daaa71 | ||
|
|
f0bc284e82 | ||
|
|
a70daaa0a3 | ||
|
|
ca8c71fa52 | ||
|
|
15e97f4582 | ||
|
|
786e783951 | ||
|
|
1056846a48 | ||
|
|
4eace6849e | ||
|
|
2b6cf71967 | ||
|
|
7261667d72 | ||
|
|
5b8100eb54 | ||
|
|
ca28a3c9bb | ||
|
|
73efe279d4 | ||
|
|
12e825cff0 | ||
|
|
8775c27aab | ||
|
|
9b8d59bd48 | ||
|
|
68c87bc3cd | ||
|
|
4dba794b26 | ||
|
|
b71659b357 | ||
|
|
d84c566412 | ||
|
|
35278bf2f5 | ||
|
|
b5c1f60464 | ||
|
|
2798977867 | ||
|
|
b259e22458 | ||
|
|
12d572757e | ||
|
|
15f7fc7ee8 | ||
|
|
12e5d5aa33 | ||
|
|
b49bb1ef31 | ||
|
|
191cfdbabb | ||
|
|
c3eca0b68c |
@@ -7,3 +7,5 @@ config.json
|
||||
/dev/data/
|
||||
*.swp
|
||||
*.swo
|
||||
__pycache__/
|
||||
/dev/screenshots/posters/
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
# 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:
|
||||
|
||||
@@ -10,7 +10,7 @@ build:
|
||||
test:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
@if command -v shellcheck >/dev/null; then shellcheck scripts/*.sh dev/*.sh; else echo "shellcheck not installed, skipping shell script checks"; fi
|
||||
@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:
|
||||
|
||||
@@ -4,8 +4,23 @@ 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).
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
@@ -28,7 +43,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||
retry failed downloads, remove from the arr queue and blocklist.
|
||||
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move
|
||||
through with the arrow keys: service addresses and API keys, the add-form defaults, refresh interval, posters, theme, notifications. *Test*
|
||||
through with the arrow keys: service addresses and API keys, the add-form defaults (quality profile and root folder, picked from the lists the server has), refresh interval, posters, theme, notifications. *Test*
|
||||
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a
|
||||
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.
|
||||
@@ -46,6 +61,22 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
||||
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.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Movies / Series**: search as you type | **Releases**: pick one by hand |
|
||||
|  |  |
|
||||
| **Seasons and episodes** of a series | **Downloads** from SABnzbd, Radarr and Sonarr |
|
||||
|  |  |
|
||||
| **Calendar** of the next two weeks | **Config** inside the program |
|
||||
|  |  |
|
||||
| **Trending, most watched, anticipated** from Trakt | **Light theme** |
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
@@ -56,9 +87,11 @@ 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
|
||||
{
|
||||
@@ -71,6 +104,12 @@ Settings → General.
|
||||
|
||||
`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
|
||||
@@ -81,7 +120,8 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
||||
| `Tab`, `Shift+Tab` | switch panel within a view |
|
||||
| `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`, `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 |
|
||||
@@ -91,11 +131,21 @@ Keep the file private (`chmod 600`); it holds your API keys.
|
||||
| `Esc` | back / close dialog |
|
||||
| `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
|
||||
`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
|
||||
|
||||
@@ -57,19 +57,24 @@ 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; `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 uploaded as PNG (`a=t,f=100,q=2`, 4096-byte base64 chunks) and placed virtually with the cell footprint (`a=p,U=1,c=,r=`) right away. Every footprint (crop and cell size) is its own image id, placed once and never changed: Ghostty kept showing the old, small placement when the size of an existing one was changed, so a new size uploads under a new id and deletes the old one. The poster is shown by writing U+10EEEE placeholder characters whose combining marks give row and column (the 297-entry table from the protocol) and whose foreground color is the image id. Since it is ordinary text, popups, dialogs and view changes cover it correctly and tcell's diffing keeps it in place. The footprint uses the real cell size in pixels (the window's pixel size divided by its cell count, rounded down to whole pixels so padding around the grid does not skew it; all images are deleted whenever the window or cell size changes, block characters stand in while the size keeps changing, and real images are uploaded afresh once it has been stable for 300 ms, because uploading and placing for every intermediate size of a window drag raced with the terminal's own resizing) and a box whose shape is within 3% of the poster's; the remaining sliver is cropped from the poster's edges before upload (one upload per size, the previous one is deleted), because the terminal letterboxes an image that does not fit its box exactly. As a second safeguard the placeholder cells use the panes' background color, so anything the picture does not cover blends in (without it the terminal's own, slightly different, default background showed as grey bars beside the poster). Writes to the terminal go through the screen's tty on the UI goroutine, so they cannot interleave with drawing. Requires 24-bit color; otherwise, and inside tmux/screen, the block fallback is used. Terminals are recognised from `TERM`/`TERM_PROGRAM`/`KITTY_WINDOW_ID`/`GHOSTTY_RESOURCES_DIR`, and then asked before the screen starts (a Kitty graphics query and a background-color query followed by a device-attributes request, 300 ms timeout, waiting with select(2) because a read deadline does not work on a terminal device on macOS): only a terminal that answers the query gets real images, also with `cover_style: kitty`, because a terminal without the protocol prints the commands as text. The Log says which way posters are drawn. With `ARR_TUI_DEBUG_GFX=1` the Log (F7) lists every window size, the cell shape, every upload (poster size, crop, cells) and every change of what is drawn (image id, position, cells); with a path (`ARR_TUI_DEBUG_GFX=/tmp/arr-gfx.log`) the same lines also go to that file with millisecond timestamps, so they can be read without leaving the view that shows the problem. The go-termimg library was looked at and not used: its placeholder support is marked experimental, its diacritic table does not match the protocol's, and it writes images straight to the terminal instead of through the screen.
|
||||
- **Real images** (`kitty`, and `auto` in kitty/Ghostty): the Kitty graphics protocol with Unicode placeholders. The poster (kept up to 640 px tall) is 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` from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
||||
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` (250 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.
|
||||
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
|
||||
- **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)
|
||||
@@ -117,9 +122,9 @@ Keybindings (the status bar shows the ones that apply to the current view):
|
||||
| `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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"url": "http://localhost:8080",
|
||||
"apikey": "YOUR_SABNZBD_API_KEY"
|
||||
},
|
||||
"trakt": {
|
||||
"client_id": "YOUR_TRAKT_CLIENT_ID"
|
||||
},
|
||||
"ui": {
|
||||
"refresh_seconds": 3,
|
||||
"covers": true,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# The Config view; the API keys are masked.
|
||||
sleep 2
|
||||
key F6
|
||||
sleep 3
|
||||
shot config
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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" "$@"
|
||||
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,10 @@ func NewClient(baseURL, apiKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNotConfigured is returned without a request while the client has no
|
||||
// address: the service is not set up.
|
||||
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
||||
|
||||
// APIError is returned for non-2xx responses.
|
||||
type APIError struct {
|
||||
Status int
|
||||
@@ -65,6 +69,9 @@ func (e *APIError) Error() string {
|
||||
// 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 {
|
||||
base, apiKey := c.settings()
|
||||
if base == "" {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
u := base + "/api/v3/" + strings.TrimLeft(path, "/")
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -17,10 +18,11 @@ 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
|
||||
@@ -28,12 +30,12 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
||||
if inLibrary && img.URL != "" {
|
||||
local, remote := img.URL, img.RemoteURL
|
||||
base, _ := c.settings()
|
||||
return base + "|" + local, func(ctx context.Context) ([]byte, error) {
|
||||
data, err := c.localPoster(ctx, local)
|
||||
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, remote); remoteErr == nil {
|
||||
if remoteData, remoteErr := FetchURL(ctx, tmdbSized(remote, large)); remoteErr == nil {
|
||||
return remoteData, nil
|
||||
}
|
||||
}
|
||||
@@ -42,17 +44,20 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// localPoster fetches a poster from the instance: the small variant, then the
|
||||
// original. When the instance answers with a page instead of an image (it
|
||||
// wants a login for /MediaCover) the API key is also tried as a query
|
||||
// parameter, which is the other way Radarr and Sonarr accept it.
|
||||
func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error) {
|
||||
// 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
|
||||
@@ -63,8 +68,13 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
if small := smallVariant(local); small != local {
|
||||
data, err := try(small)
|
||||
// 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
|
||||
}
|
||||
@@ -76,13 +86,33 @@ func (c *Client) localPoster(ctx context.Context, local string) ([]byte, error)
|
||||
return try(local)
|
||||
}
|
||||
|
||||
// smallVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-250.jpg?x.
|
||||
func smallVariant(p string) string {
|
||||
// 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
|
||||
}
|
||||
@@ -135,6 +165,9 @@ func (e *notImageError) Error() string {
|
||||
// their URL base in image paths, so it must not be added twice.
|
||||
func (c *Client) resolve(ref string) (string, error) {
|
||||
baseURL, _ := c.settings()
|
||||
if baseURL == "" {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
base, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -8,18 +8,59 @@ import (
|
||||
"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
|
||||
@@ -40,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)
|
||||
}
|
||||
@@ -67,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 != "" {
|
||||
@@ -111,7 +152,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("no poster")
|
||||
}
|
||||
data, err := fetch(context.Background())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "REMOTE" {
|
||||
t.Fatalf("data=%q err=%v", data, err)
|
||||
}
|
||||
@@ -121,7 +162,7 @@ func TestPosterFallsBackToTheProviderWhenTheInstanceHasNoCopy(t *testing.T) {
|
||||
|
||||
// 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()); err == nil {
|
||||
if _, err := fetch(context.Background(), false); err == nil {
|
||||
t.Error("expected the local error")
|
||||
}
|
||||
}
|
||||
@@ -148,7 +189,7 @@ func TestPosterTriesTheKeyInTheQueryWhenGivenALoginPage(t *testing.T) {
|
||||
|
||||
c := NewClient(srv.URL, "secret")
|
||||
_, fetch, _ := c.Poster([]Image{{CoverType: "poster", URL: "/MediaCover/89/poster.jpg"}}, true)
|
||||
data, err := fetch(context.Background())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "IMAGE" {
|
||||
t.Fatalf("data=%q err=%v, hits=%v", data, err, hits)
|
||||
}
|
||||
@@ -173,7 +214,7 @@ func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
||||
|
||||
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())
|
||||
data, err := fetch(context.Background(), false)
|
||||
if err != nil || string(data) != "WEBP" {
|
||||
t.Fatalf("data=%q err=%v", data, err)
|
||||
}
|
||||
@@ -186,7 +227,7 @@ func TestPosterUsesTheProviderWhenTheInstanceOnlyShowsALogin(t *testing.T) {
|
||||
|
||||
// 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())
|
||||
_, err = fetch(context.Background(), false)
|
||||
if err == nil || !strings.Contains(err.Error(), "text/html") || !strings.Contains(err.Error(), "login") {
|
||||
t.Errorf("error = %v", err)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
@@ -59,11 +77,18 @@ func (u UI) ShouldNotifyDownloads() bool { return u.NotifyDownloads == nil || *u
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -166,12 +191,13 @@ func (c *Config) Effective(env func(string) string) (*Config, error) {
|
||||
// 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",
|
||||
"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
|
||||
@@ -235,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() {
|
||||
@@ -244,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 {
|
||||
@@ -257,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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -200,3 +201,47 @@ func TestEffectiveValidates(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,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
|
||||
|
||||
@@ -40,6 +40,10 @@ func NewClient(baseURL, apiKey string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNotConfigured is returned without a request while the client has no
|
||||
// address: the service is not set up.
|
||||
var ErrNotConfigured = errors.New("not configured: enter its address and API key in the Config view (F6)")
|
||||
|
||||
// Number is a float that SABnzbd sends either as a JSON number or as a string ("0.48").
|
||||
type Number float64
|
||||
|
||||
@@ -172,6 +176,9 @@ func (c *Client) call(ctx context.Context, params url.Values, out any) error {
|
||||
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, baseURL+"/api?"+params.Encode(), nil)
|
||||
|
||||
@@ -46,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.
|
||||
@@ -108,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
|
||||
@@ -179,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
|
||||
}
|
||||
|
||||
|
||||
@@ -198,3 +198,23 @@ func TestCalendar(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,14 +32,23 @@ type view struct {
|
||||
|
||||
// App is the top-level arr-tui application.
|
||||
type App struct {
|
||||
app *tview.Application
|
||||
theme theme // the colors in use
|
||||
themed *themedScreen // draws with them; nil until Run
|
||||
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
|
||||
@@ -94,6 +106,7 @@ 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-F7), as opposed to a dialog
|
||||
@@ -161,6 +174,7 @@ func New(cfg *config.Config) *App {
|
||||
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},
|
||||
@@ -168,10 +182,21 @@ func New(cfg *config.Config) *App {
|
||||
},
|
||||
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}).root, hints: searchHints},
|
||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
|
||||
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
||||
{key: '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: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
|
||||
{key: '6', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints},
|
||||
@@ -196,12 +221,20 @@ func New(cfg *config.Config) *App {
|
||||
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
|
||||
}
|
||||
@@ -211,6 +244,13 @@ func New(cfg *config.Config) *App {
|
||||
// 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, "/"):
|
||||
@@ -269,6 +309,17 @@ func (a *App) writeTerminal(b []byte) error {
|
||||
|
||||
// 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 {
|
||||
@@ -313,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")
|
||||
@@ -353,7 +408,7 @@ func (a *App) renderServices() {
|
||||
}
|
||||
st := a.state[n]
|
||||
switch {
|
||||
case !st.known:
|
||||
case !st.known, notConfigured(st.err):
|
||||
s += "[gray]○ " + short + "[-] "
|
||||
case st.err != nil:
|
||||
s += "[red]● " + short + "[-] "
|
||||
@@ -372,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 != "":
|
||||
@@ -383,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)
|
||||
@@ -396,7 +468,12 @@ func (a *App) Run() error {
|
||||
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 {
|
||||
@@ -484,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()
|
||||
@@ -523,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 {
|
||||
@@ -627,7 +712,7 @@ 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 {
|
||||
|
||||
@@ -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 {
|
||||
@@ -46,3 +50,34 @@ func TestQDoesNotQuit(t *testing.T) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,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"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -134,7 +135,7 @@ func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time
|
||||
return calEntry{
|
||||
day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
|
||||
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired,
|
||||
details: detailText{head: head, plot: tview.Escape(overview)}, cover: cover,
|
||||
details: detailText{head: head, plot: tview.Escape(overview), people: episodeSeriesRef(e)}, cover: cover,
|
||||
episodeID: e.ID,
|
||||
}, true
|
||||
}
|
||||
@@ -294,12 +295,14 @@ func (v *calendarView) reload() {
|
||||
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 err != nil {
|
||||
if !usable(err) {
|
||||
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
||||
}
|
||||
}
|
||||
if movieErr == nil || tvErr == nil { // show what we got
|
||||
if usable(movieErr) || usable(tvErr) { // show what we got
|
||||
v.entries, v.loaded = entries, true
|
||||
}
|
||||
v.render()
|
||||
@@ -465,3 +468,11 @@ func (v *calendarView) search() {
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ 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")
|
||||
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)
|
||||
@@ -141,7 +141,7 @@ func TestSeriesRowDetail(t *testing.T) {
|
||||
|
||||
// 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")
|
||||
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)
|
||||
@@ -152,7 +152,7 @@ func TestSeriesRowDetail(t *testing.T) {
|
||||
}
|
||||
// Without an episode plot the series plot stays.
|
||||
ep.Overview = ""
|
||||
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01"); d.plot != "Series plot" {
|
||||
if d := seriesRowDetail(s, ep, 2, seasonSummary{}, true, "2026-01-01", dlInfo{}); d.plot != "Series plot" {
|
||||
t.Errorf("fallback plot = %q", d.plot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,18 @@ func (a *App) applyConfig(c *config.Config) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/gif" // poster formats
|
||||
_ "image/jpeg" // (Radarr and Sonarr store the provider's bytes under a .jpg name,
|
||||
_ "image/png" // whatever format they are in)
|
||||
_ "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"
|
||||
@@ -311,13 +311,26 @@ func (c *coverView) aspectOfCells() float64 {
|
||||
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
|
||||
}
|
||||
|
||||
// coverFitTolerance is how far the shape of the cell box may differ from the
|
||||
@@ -376,19 +389,26 @@ func (c *coverView) Draw(screen tcell.Screen) {
|
||||
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, c.aspectOfCells())
|
||||
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
|
||||
}
|
||||
if c.drawGraphics(screen, x+w-cols, y, cols, rows) {
|
||||
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]
|
||||
@@ -427,6 +447,9 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool
|
||||
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
|
||||
@@ -445,7 +468,10 @@ func (c *coverView) drawGraphics(screen tcell.Screen, x, y, cols, rows int) bool
|
||||
// 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++ {
|
||||
@@ -467,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.
|
||||
@@ -516,6 +543,7 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
||||
}
|
||||
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{}{}
|
||||
@@ -523,9 +551,28 @@ 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, maxHeight)
|
||||
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]
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestLoadCoverCachesAndDedupes(t *testing.T) {
|
||||
var fetches atomic.Int32
|
||||
var buf bytes.Buffer
|
||||
png.Encode(&buf, stripes(10, 20))
|
||||
ref := coverRef{key: "k", fetch: func(context.Context) ([]byte, error) {
|
||||
ref := coverRef{key: "k", fetch: func(context.Context, bool) ([]byte, error) {
|
||||
fetches.Add(1)
|
||||
return buf.Bytes(), nil
|
||||
}}
|
||||
@@ -186,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 })
|
||||
@@ -358,3 +358,48 @@ func TestCellsStyleIsFlat(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ type detailPane struct {
|
||||
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 {
|
||||
width, narrow, rows int
|
||||
@@ -65,6 +71,7 @@ func newDetailPane(app *App) *detailPane {
|
||||
func (d *detailPane) Show(text detailText, ref coverRef) {
|
||||
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)
|
||||
@@ -137,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)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"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 {
|
||||
@@ -116,7 +118,7 @@ func TestPosterWaitsWhileTheSelectionMoves(t *testing.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) ([]byte, error) { return nil, nil }}
|
||||
return coverRef{key: key, fetch: func(context.Context, bool) ([]byte, error) { return nil, nil }}
|
||||
}
|
||||
d := newDetailPane(a)
|
||||
|
||||
@@ -137,3 +139,60 @@ func TestPosterWaitsWhileTheSelectionMoves(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -210,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()
|
||||
@@ -230,6 +237,14 @@ func (v *downloadsView) refresh(ctx context.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// sabPlaceholder is what an empty SABnzbd list says.
|
||||
func (v *downloadsView) sabPlaceholder(empty string) string {
|
||||
if v.sabUnset {
|
||||
return "SABnzbd is not configured (Config view, F6)"
|
||||
}
|
||||
return empty
|
||||
}
|
||||
|
||||
func (v *downloadsView) rebuildArrRows() {
|
||||
v.arrRows = v.arrRows[:0]
|
||||
for _, s := range v.sources {
|
||||
@@ -331,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() {
|
||||
@@ -414,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.
|
||||
|
||||
@@ -44,16 +44,22 @@ type seriesView struct {
|
||||
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: newDetailPane(a),
|
||||
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)
|
||||
@@ -90,8 +96,54 @@ func (a *App) openSeries(c *sonarr.Client, s sonarr.Series, onChange func()) {
|
||||
|
||||
v.render()
|
||||
// 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) })
|
||||
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.
|
||||
@@ -192,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
|
||||
@@ -248,7 +305,7 @@ func episodeStatus(e sonarr.Episode, today string) string {
|
||||
func (v *seriesView) renderDetail() {
|
||||
r, ok := v.selected()
|
||||
if !ok {
|
||||
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, ""), v.cover)
|
||||
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, "", dlInfo{}), v.cover)
|
||||
return
|
||||
}
|
||||
var sum seasonSummary
|
||||
@@ -257,13 +314,43 @@ func (v *seriesView) renderDetail() {
|
||||
sum = se
|
||||
}
|
||||
}
|
||||
v.detail.Show(seriesRowDetail(v.series, r.ep, r.season, sum, v.seasonMonitored(r.season), time.Now().Format("2006-01-02")), v.cover)
|
||||
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.
|
||||
func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum seasonSummary, seasonMonitored bool, today string) detailText {
|
||||
// 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)
|
||||
@@ -281,6 +368,11 @@ func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum season
|
||||
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:
|
||||
@@ -289,12 +381,15 @@ func seriesRowDetail(s sonarr.Series, ep *sonarr.Episode, season int, sum season
|
||||
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}
|
||||
return detailText{head: head, plot: plot, people: seriesRef(s)}
|
||||
}
|
||||
|
||||
// enter expands or collapses a season, or opens the release search for an episode.
|
||||
|
||||
@@ -18,6 +18,18 @@ import (
|
||||
// cancel is called on Esc, except when Esc only closes an open dropdown list
|
||||
// (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() {
|
||||
@@ -65,7 +77,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.SetFocus(next)
|
||||
move(next)
|
||||
case tcell.KeyTab, tcell.KeyBacktab:
|
||||
dir := 1
|
||||
if ev.Key() == tcell.KeyBacktab {
|
||||
@@ -86,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
|
||||
}
|
||||
@@ -97,12 +109,20 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
||||
})
|
||||
}
|
||||
|
||||
// syncFocus gives a primitive the application's focus; New sets it.
|
||||
var syncFocus func(p tview.Primitive)
|
||||
|
||||
func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) {
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27,13 +27,15 @@ var helpSections = []helpSection{
|
||||
}},
|
||||
{title: "Movies and Series", items: []helpItem{
|
||||
{"/", "focus the search box"},
|
||||
{"Enter, a", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"},
|
||||
{"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", items: []helpItem{
|
||||
{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, codec (x265, x264, AV1), HDR / DV / SDR, audio (Atmos, TrueHD, DTS, DD+ ...), language. Changes apply live behind the dialog, also while you move through an open list; Esc undoes them. Options that would leave no results are not offered. Series files are read on demand."},
|
||||
{"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\")"},
|
||||
@@ -57,7 +59,7 @@ var helpSections = []helpSection{
|
||||
{"Enter, g", "grab the selected release"},
|
||||
{"s", "cycle the sort (best, size, age)"},
|
||||
{"f", "hide rejected releases"},
|
||||
{"F", "filter by quality, codec, HDR / DV, audio, language (read from the release title, quality and custom formats)"},
|
||||
{"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"},
|
||||
@@ -67,10 +69,11 @@ var helpSections = []helpSection{
|
||||
{"r", "refresh now"},
|
||||
}},
|
||||
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, theme, notifications.", items: []helpItem{
|
||||
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
|
||||
{"↑↓", "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"},
|
||||
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
|
||||
{"←→, 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{
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
xdraw "golang.org/x/image/draw"
|
||||
"image"
|
||||
"image/png"
|
||||
"strings"
|
||||
@@ -43,8 +44,32 @@ type kittyGraphics struct {
|
||||
|
||||
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
|
||||
@@ -67,7 +92,8 @@ type footprint struct {
|
||||
}
|
||||
|
||||
func newKittyGraphics(write func([]byte) error) *kittyGraphics {
|
||||
return &kittyGraphics{write: write, nextID: 1, ids: map[footprint]uint32{}, latest: map[*image.RGBA]footprint{}}
|
||||
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 \.
|
||||
@@ -84,20 +110,65 @@ func kittyAPC(keys string, payload []byte) []byte {
|
||||
}
|
||||
|
||||
// show makes sure the part rect of the image is uploaded and placed over
|
||||
// cols×rows cells, and returns the id the placeholder cells must carry. The same
|
||||
// footprint is reused; a different one for the same poster replaces the old
|
||||
// upload (which is removed from the terminal).
|
||||
// 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, img.SubImage(rect)); err != nil {
|
||||
return 0, err
|
||||
if err := enc.Encode(&data, src); err != nil {
|
||||
return prepared{err: err}
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(data.Bytes())
|
||||
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)
|
||||
@@ -130,7 +201,8 @@ func (k *kittyGraphics) show(img *image.RGBA, rect image.Rectangle, cols, rows i
|
||||
k.ids[key] = id
|
||||
k.latest[img] = key
|
||||
if k.debug != nil {
|
||||
k.debug(fmt.Sprintf("graphics: id %d, poster %dx%d px cropped to %v, placed over %dx%d cells", id, img.Bounds().Dx(), img.Bounds().Dy(), rect, cols, rows))
|
||||
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
|
||||
}
|
||||
@@ -143,6 +215,7 @@ func (k *kittyGraphics) deleteAll() {
|
||||
}
|
||||
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
|
||||
@@ -175,3 +248,15 @@ func kittyTerminal(env func(string) string) bool {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -393,3 +393,53 @@ func TestGraphicsNeedTheTerminalsConfirmation(t *testing.T) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -249,7 +266,7 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
|
||||
})
|
||||
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).
|
||||
@@ -317,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.
|
||||
@@ -363,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
|
||||
}
|
||||
@@ -391,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)
|
||||
|
||||
@@ -401,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:
|
||||
@@ -437,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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ import (
|
||||
// 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())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
@@ -32,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
|
||||
@@ -52,12 +63,16 @@ func newSearchView(app *App, be backend) *searchView {
|
||||
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)
|
||||
@@ -77,6 +92,12 @@ func newSearchView(app *App, be backend) *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
|
||||
})
|
||||
|
||||
@@ -94,9 +115,16 @@ func newSearchView(app *App, be backend) *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
|
||||
@@ -173,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
|
||||
}
|
||||
@@ -199,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())
|
||||
@@ -232,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
|
||||
}
|
||||
@@ -253,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()
|
||||
@@ -268,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)
|
||||
|
||||
@@ -13,6 +13,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/sabnzbd"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
// settingsValues are the settings as shown in the Config view.
|
||||
@@ -20,6 +21,7 @@ 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
|
||||
@@ -47,6 +49,7 @@ func settingsFrom(c *config.Config) settingsValues {
|
||||
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,
|
||||
@@ -70,6 +73,7 @@ func applySettings(raw config.Config, v settingsValues, overridden map[string]st
|
||||
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)
|
||||
@@ -120,17 +124,23 @@ type settingsView struct {
|
||||
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"
|
||||
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
|
||||
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 {
|
||||
@@ -138,7 +148,13 @@ func settingsHints() string {
|
||||
}
|
||||
|
||||
func newSettingsView(app *App) *settingsView {
|
||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv),
|
||||
choices: map[string]*serverChoice{}, choiceTimers: map[string]*time.Timer{}, choiceGen: map[string]int{}, choiceErr: map[string]string{}}
|
||||
quiet := func() func() {
|
||||
was := v.filling
|
||||
v.filling = true
|
||||
return func() { v.filling = was }
|
||||
}
|
||||
|
||||
// One form with every field below the other: ↑↓ moves through all of them
|
||||
// and the form scrolls to keep the focused field in view.
|
||||
@@ -149,29 +165,42 @@ func newSettingsView(app *App) *settingsView {
|
||||
|
||||
input := func(label, key string) {
|
||||
in := tview.NewInputField().SetLabel(label)
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
if secret(key) {
|
||||
in.SetMaskCharacter('*')
|
||||
}
|
||||
if _, ok := v.overridden[key]; ok {
|
||||
in.SetLabel(label + " (env)")
|
||||
in.SetDisabled(true)
|
||||
}
|
||||
in.SetChangedFunc(func(string) { v.edited() })
|
||||
in.SetChangedFunc(func(string) {
|
||||
v.edited()
|
||||
// New address or key: ask that server for its profiles and folders.
|
||||
if service, field, _ := strings.Cut(key, "."); service != "sabnzbd" && !v.filling && (field == "url" || field == "apikey") {
|
||||
v.scheduleChoices(service)
|
||||
}
|
||||
})
|
||||
f.AddFormItem(in)
|
||||
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 {
|
||||
input("Default quality profile", name+".profile")
|
||||
input("Default root folder", name+".folder")
|
||||
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)
|
||||
@@ -272,10 +301,13 @@ func (v *settingsView) edited() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 strings.HasSuffix(key, ".apikey") {
|
||||
if secret(key) {
|
||||
if hide {
|
||||
in.SetMaskCharacter('*')
|
||||
} else {
|
||||
@@ -290,13 +322,20 @@ 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, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
|
||||
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
|
||||
"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 {
|
||||
@@ -316,7 +355,12 @@ func (v *settingsView) fill(s settingsValues) {
|
||||
|
||||
// read collects the values from the fields.
|
||||
func (v *settingsView) read() settingsValues {
|
||||
text := func(key string) string { return v.inputs[key].GetText() }
|
||||
text := func(key string) string {
|
||||
if c, ok := v.choices[key]; ok {
|
||||
return c.value
|
||||
}
|
||||
return v.inputs[key].GetText()
|
||||
}
|
||||
i, _ := v.style.GetCurrentOption()
|
||||
style := "auto"
|
||||
if i >= 0 && i < len(coverStyles) {
|
||||
@@ -332,6 +376,7 @@ func (v *settingsView) read() settingsValues {
|
||||
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(),
|
||||
@@ -348,6 +393,9 @@ func (v *settingsView) describe() {
|
||||
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 {
|
||||
@@ -363,6 +411,8 @@ func (v *settingsView) activate() {
|
||||
if !v.dirty {
|
||||
v.fill(settingsFrom(v.app.cfg))
|
||||
}
|
||||
v.loadChoices("radarr")
|
||||
v.loadChoices("sonarr")
|
||||
v.focusGroup(0, false)
|
||||
}
|
||||
|
||||
@@ -374,6 +424,8 @@ func (v *settingsView) report(line string) {
|
||||
|
||||
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.[-]")
|
||||
}
|
||||
@@ -400,6 +452,15 @@ func (v *settingsView) test() {
|
||||
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)
|
||||
@@ -410,7 +471,7 @@ func (v *settingsView) test() {
|
||||
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
|
||||
v.report("[green]✓ " + strings.TrimSpace(t.name+" "+tview.Escape(version)) + " answers[-]")
|
||||
})
|
||||
}()
|
||||
}
|
||||
@@ -440,6 +501,7 @@ func (v *settingsView) save() {
|
||||
v.app.cfgFile = &raw
|
||||
v.app.applyConfig(effective)
|
||||
v.dirty = false
|
||||
v.describe() // what it says may depend on the settings (e.g. nothing set up yet)
|
||||
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
|
||||
v.app.Notify(levelSuccess, "Settings saved")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
)
|
||||
|
||||
// serverChoice is a setting whose values come from the server: a dropdown of
|
||||
// the names Radarr/Sonarr know (quality profiles, root folders), with "(none)"
|
||||
// first. The value in use is kept as an option even when the server does not
|
||||
// list it (or cannot be asked), so nothing is lost by opening the view.
|
||||
type serverChoice struct {
|
||||
*tview.DropDown
|
||||
value string // the selected value; "" = none
|
||||
names []string // from the server, in its order
|
||||
loaded bool // names came from the server
|
||||
quiet func() func()
|
||||
picked func(text string, index int) // SetOptions replaces it, so it is kept here
|
||||
}
|
||||
|
||||
const noChoice = "(none)"
|
||||
|
||||
// newServerChoice makes the dropdown. quiet marks changes as not made by the
|
||||
// user while the options are rebuilt; it returns the function that ends that.
|
||||
func newServerChoice(label string, quiet func() func(), changed func()) *serverChoice {
|
||||
c := &serverChoice{DropDown: tview.NewDropDown().SetLabel(label), quiet: quiet}
|
||||
c.picked = func(_ string, i int) {
|
||||
c.value = c.valueAt(i)
|
||||
changed()
|
||||
}
|
||||
c.rebuild()
|
||||
return c
|
||||
}
|
||||
|
||||
// options lists the values in the order shown: none, the server's names, and
|
||||
// the value in use when the server does not have it.
|
||||
func (c *serverChoice) options() []string {
|
||||
opts := append([]string{""}, c.names...)
|
||||
if c.value != "" && !contains(c.names, c.value) {
|
||||
opts = append(opts, c.value)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (c *serverChoice) valueAt(i int) string {
|
||||
opts := c.options()
|
||||
if i < 0 || i >= len(opts) {
|
||||
return ""
|
||||
}
|
||||
return opts[i]
|
||||
}
|
||||
|
||||
// rebuild puts the options into the dropdown and selects the value.
|
||||
func (c *serverChoice) rebuild() {
|
||||
done := c.quiet()
|
||||
defer done()
|
||||
value := c.value
|
||||
var labels []string
|
||||
sel := 0
|
||||
for i, o := range c.options() {
|
||||
label := o
|
||||
switch {
|
||||
case o == "":
|
||||
label = noChoice
|
||||
case c.loaded && !contains(c.names, o):
|
||||
label = o + " (not on the server)"
|
||||
}
|
||||
labels = append(labels, label)
|
||||
if o == value {
|
||||
sel = i
|
||||
}
|
||||
}
|
||||
c.value = value
|
||||
c.SetOptions(labels, c.picked)
|
||||
c.SetCurrentOption(sel) // reports sel, which is value
|
||||
}
|
||||
|
||||
// set selects a value.
|
||||
func (c *serverChoice) set(value string) {
|
||||
c.value = strings.TrimSpace(value)
|
||||
c.rebuild()
|
||||
}
|
||||
|
||||
// setNames replaces the server's names; nil with loaded false means they are
|
||||
// not known.
|
||||
func (c *serverChoice) setNames(names []string, loaded bool) {
|
||||
c.names, c.loaded = names, loaded
|
||||
c.rebuild()
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// choicesDelay is how long typing in an address or API key pauses before the
|
||||
// server is asked for its profiles and folders.
|
||||
const choicesDelay = 700 * time.Millisecond
|
||||
|
||||
// scheduleChoices asks the service again once its address and key have not
|
||||
// changed for choicesDelay.
|
||||
func (v *settingsView) scheduleChoices(service string) {
|
||||
if t := v.choiceTimers[service]; t != nil {
|
||||
t.Stop()
|
||||
}
|
||||
v.choiceTimers[service] = time.AfterFunc(choicesDelay, func() {
|
||||
v.app.post(func() { v.loadChoices(service) })
|
||||
})
|
||||
}
|
||||
|
||||
// loadChoices fills the profile and folder dropdowns of a service ("radarr" or
|
||||
// "sonarr") from the server, using the address and key typed in the fields.
|
||||
func (v *settingsView) loadChoices(service string) {
|
||||
profile, folder := v.choices[service+".profile"], v.choices[service+".folder"]
|
||||
url := strings.TrimSpace(v.inputs[service+".url"].GetText())
|
||||
key := strings.TrimSpace(v.inputs[service+".apikey"].GetText())
|
||||
v.choiceGen[service]++
|
||||
gen := v.choiceGen[service]
|
||||
if url == "" || key == "" {
|
||||
profile.setNames(nil, false)
|
||||
folder.setNames(nil, false)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
profiles, folders, err := fetchChoices(ctx, url, key)
|
||||
v.app.post(func() { v.applyChoices(service, gen, profiles, folders, err) })
|
||||
}()
|
||||
}
|
||||
|
||||
// fetchChoices asks a Radarr/Sonarr for its quality profile names and root
|
||||
// folder paths.
|
||||
func fetchChoices(ctx context.Context, url, key string) (profiles, folders []string, err error) {
|
||||
client := arr.NewClient(url, key)
|
||||
qp, err := client.QualityProfiles(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rf, err := client.RootFolders(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, p := range qp {
|
||||
profiles = append(profiles, p.Name)
|
||||
}
|
||||
for _, f := range rf {
|
||||
folders = append(folders, f.Path)
|
||||
}
|
||||
return profiles, folders, nil
|
||||
}
|
||||
|
||||
// applyChoices puts what fetchChoices found into the dropdowns, unless the
|
||||
// address or key changed since it was asked (gen).
|
||||
func (v *settingsView) applyChoices(service string, gen int, profiles, folders []string, err error) {
|
||||
if v.choiceGen[service] != gen {
|
||||
return
|
||||
}
|
||||
profile, folder := v.choices[service+".profile"], v.choices[service+".folder"]
|
||||
if err != nil {
|
||||
profile.setNames(nil, false)
|
||||
folder.setNames(nil, false)
|
||||
name := map[string]string{"radarr": "Radarr", "sonarr": "Sonarr"}[service]
|
||||
if msg := err.Error(); v.choiceErr[service] != msg {
|
||||
v.choiceErr[service] = msg
|
||||
v.report("[yellow]" + name + ": quality profiles and root folders not loaded: " + tview.Escape(msg) + "[-]")
|
||||
}
|
||||
return
|
||||
}
|
||||
delete(v.choiceErr, service)
|
||||
profile.setNames(profiles, true)
|
||||
folder.setNames(folders, true)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
"net/http"
|
||||
@@ -133,7 +134,8 @@ func TestSettingsViewSavesAndAppliesLive(t *testing.T) {
|
||||
// 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.inputs["sonarr.profile"].SetText("Any")
|
||||
v.choices["sonarr.profile"].set("Any")
|
||||
v.edited()
|
||||
v.refresh.SetText("7")
|
||||
v.bell.SetChecked(true)
|
||||
if !v.dirty {
|
||||
@@ -280,13 +282,14 @@ func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
|
||||
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||
focusedItem := func() tview.FormItem { i, _ := v.form.GetFocusedItemIndex(); return v.form.GetFormItem(i) }
|
||||
|
||||
// It comes right after the SABnzbd API key and before the Interface section.
|
||||
// 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 < 10; i++ { // Radarr 4, Sonarr 4, SABnzbd 2 fields
|
||||
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 SABnzbd API key reached %T %q, want the Test button", focusedItem(), focusedItem().GetLabel())
|
||||
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)" {
|
||||
@@ -313,3 +316,136 @@ func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpOverConfigTakesTheFocus(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0))
|
||||
moveDown := a.settings.form.GetInputCapture()
|
||||
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
|
||||
field := a.settings.inputs["radarr.apikey"]
|
||||
if a.app.GetFocus() != field {
|
||||
t.Fatalf("after ↓ the application's focus is %T, not the second field", a.app.GetFocus())
|
||||
}
|
||||
|
||||
a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0))
|
||||
// The field must not keep the focus behind the help: it would draw its
|
||||
// cursor through the window, and the form would take the help's keys.
|
||||
if field.HasFocus() || a.settings.form.HasFocus() {
|
||||
t.Errorf("behind the help: field focused %v, form focused %v", field.HasFocus(), a.settings.form.HasFocus())
|
||||
}
|
||||
if _, ok := a.app.GetFocus().(*tview.TextView); !ok {
|
||||
t.Errorf("help has no focus: %T", a.app.GetFocus())
|
||||
}
|
||||
|
||||
a.closeModal()
|
||||
if a.app.GetFocus() != field || !field.HasFocus() {
|
||||
t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultsAreChosenFromTheServer(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Header.Get("X-Api-Key") != "key":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
case strings.HasSuffix(r.URL.Path, "/qualityprofile"):
|
||||
w.Write([]byte(`[{"id":1,"name":"Any"},{"id":4,"name":"HD-1080p"}]`))
|
||||
case strings.HasSuffix(r.URL.Path, "/rootfolder"):
|
||||
w.Write([]byte(`[{"id":1,"path":"/movies"}]`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
profiles, folders, err := fetchChoices(t.Context(), srv.URL, "key")
|
||||
if err != nil || strings.Join(profiles, ",") != "Any,HD-1080p" || strings.Join(folders, ",") != "/movies" {
|
||||
t.Fatalf("fetched %v %v %v", profiles, folders, err)
|
||||
}
|
||||
if _, _, err := fetchChoices(t.Context(), srv.URL, "wrong"); err == nil {
|
||||
t.Error("a wrong key must fail")
|
||||
}
|
||||
|
||||
a := newTestApp(t)
|
||||
v := a.settings
|
||||
p := v.choices["radarr.profile"]
|
||||
p.set("Old profile") // configured, but not on the server
|
||||
v.dirty = false
|
||||
v.choiceGen["radarr"] = 1
|
||||
v.applyChoices("radarr", 1, profiles, folders, nil)
|
||||
|
||||
n := p.GetOptionCount()
|
||||
var labels []string
|
||||
for i := 0; i < n; i++ {
|
||||
labels = append(labels, p.valueAt(i))
|
||||
}
|
||||
if strings.Join(labels, "|") != "|Any|HD-1080p|Old profile" {
|
||||
t.Errorf("options %q", labels)
|
||||
}
|
||||
if _, text := p.GetCurrentOption(); text != "Old profile (not on the server)" || p.value != "Old profile" {
|
||||
t.Errorf("selected %q, value %q", text, p.value)
|
||||
}
|
||||
if v.dirty {
|
||||
t.Error("loading the options is not an edit")
|
||||
}
|
||||
|
||||
// Choosing one is an edit and is what gets saved.
|
||||
p.SetCurrentOption(2)
|
||||
if p.value != "HD-1080p" || !v.dirty || v.read().radarrProfile != "HD-1080p" {
|
||||
t.Errorf("chosen %q dirty %v", p.value, v.dirty)
|
||||
}
|
||||
// An answer for an address that has changed since is dropped.
|
||||
v.choiceGen["radarr"] = 2
|
||||
v.applyChoices("radarr", 1, nil, nil, nil)
|
||||
if p.GetOptionCount() != n {
|
||||
t.Error("stale answer applied")
|
||||
}
|
||||
// Typing a new address asks that server again (after a pause); SABnzbd has no such lists.
|
||||
v.inputs["sonarr.url"].SetText("http://elsewhere:8989")
|
||||
v.inputs["sabnzbd.url"].SetText("http://elsewhere:8080")
|
||||
if v.choiceTimers["sonarr"] == nil || v.choiceTimers["sabnzbd"] != nil {
|
||||
t.Errorf("reload scheduled: %v", v.choiceTimers)
|
||||
}
|
||||
v.choiceTimers["sonarr"].Stop() // the test has no event loop to deliver it
|
||||
|
||||
// Without the server only none and the value in use are offered.
|
||||
v.applyChoices("radarr", 2, nil, nil, errors.New("connection refused"))
|
||||
if p.GetOptionCount() != 2 || !strings.Contains(v.status.GetText(false), "connection refused") {
|
||||
t.Errorf("offline: %d options, result %q", p.GetOptionCount(), v.status.GetText(false))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerChoiceWorksWithTheArrowKeys(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
v := a.settings
|
||||
p := v.choices["radarr.profile"]
|
||||
v.choiceGen["radarr"] = 1
|
||||
v.applyChoices("radarr", 1, []string{"Any", "HD-1080p"}, nil, nil)
|
||||
v.focusGroup(0, false)
|
||||
keys := v.form.GetInputCapture()
|
||||
send := func(k tcell.Key) { keys(tcell.NewEventKey(k, 0, 0)) }
|
||||
send(tcell.KeyDown) // API key
|
||||
send(tcell.KeyDown) // Default quality profile
|
||||
if item, _ := v.form.GetFocusedItemIndex(); v.form.GetFormItem(item) != p {
|
||||
t.Fatalf("on %q", v.form.GetFormItem(item).GetLabel())
|
||||
}
|
||||
|
||||
// → and ← change the value, like on every other dropdown.
|
||||
send(tcell.KeyRight)
|
||||
send(tcell.KeyRight)
|
||||
if p.value != "HD-1080p" || !v.dirty {
|
||||
t.Errorf("→→: value %q, dirty %v", p.value, v.dirty)
|
||||
}
|
||||
send(tcell.KeyLeft)
|
||||
if p.value != "Any" {
|
||||
t.Errorf("←: value %q", p.value)
|
||||
}
|
||||
// While its list is open, ↑↓ belong to the list, not to the form.
|
||||
var focus func(tview.Primitive)
|
||||
focus = func(q tview.Primitive) { q.Focus(focus) }
|
||||
p.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), focus)
|
||||
if !p.IsOpen() {
|
||||
t.Fatal("Enter does not open the list")
|
||||
}
|
||||
if got := keys(tcell.NewEventKey(tcell.KeyDown, 0, 0)); got == nil {
|
||||
t.Error("↓ in the open list was taken by the form")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
@@ -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`).
|
||||
@@ -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" : ""})`);
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "arr-tui-webos",
|
||||
"private": true,
|
||||
"description": "arr-tui for LG webOS TVs: Radarr, Sonarr and SABnzbd on the big screen",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"demo": "node build.mjs --demo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test test/*.test.ts",
|
||||
"package": "node build.mjs && ares-package dist -o out",
|
||||
"install-tv": "ares-install -d tv out/com.stinnesbeck.arrtui_*.ipk",
|
||||
"launch": "ares-launch -d tv com.stinnesbeck.arrtui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.19.2",
|
||||
"@webos-tools/cli": "3.2.6",
|
||||
"esbuild": "0.28.2",
|
||||
"puppeteer-core": "25.13.0",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Client and types shared by Radarr and Sonarr (their v3 API), ported from
|
||||
// internal/arr.
|
||||
|
||||
import { HTTPError, NotConfiguredError, query, request, trimSlash } from "./http.ts";
|
||||
|
||||
export interface Image {
|
||||
coverType: string;
|
||||
url?: string;
|
||||
remoteUrl?: string;
|
||||
}
|
||||
|
||||
export interface QualityProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RootFolder {
|
||||
id: number;
|
||||
path: string;
|
||||
freeSpace: number;
|
||||
accessible: boolean;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
appName: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface MediaFile {
|
||||
size: number;
|
||||
sceneName?: string;
|
||||
relativePath?: string;
|
||||
quality?: { quality?: { name?: string; resolution?: number } };
|
||||
mediaInfo?: {
|
||||
videoCodec?: string;
|
||||
videoDynamicRange?: string;
|
||||
videoDynamicRangeType?: string;
|
||||
audioCodec?: string;
|
||||
audioAdditionalFeatures?: string;
|
||||
};
|
||||
languages?: { name: string }[];
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
trackedDownloadStatus?: string;
|
||||
trackedDownloadState?: string;
|
||||
statusMessages?: { title: string; messages: string[] }[];
|
||||
errorMessage?: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
timeleft?: string;
|
||||
downloadClient?: string;
|
||||
downloadId?: string;
|
||||
indexer?: string;
|
||||
protocol?: string;
|
||||
movieId?: number;
|
||||
seriesId?: number;
|
||||
episodeId?: number;
|
||||
movie?: { title: string; year: number };
|
||||
series?: { title: string };
|
||||
episode?: { seasonNumber: number; episodeNumber: number; title: string };
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
guid: string;
|
||||
title: string;
|
||||
indexer: string;
|
||||
indexerId: number;
|
||||
protocol: string;
|
||||
size: number;
|
||||
ageMinutes: number;
|
||||
qualityWeight: number;
|
||||
customFormatScore: number;
|
||||
releaseGroup?: string;
|
||||
approved: boolean;
|
||||
rejected: boolean;
|
||||
temporarilyRejected: boolean;
|
||||
rejections?: string[];
|
||||
fullSeason?: boolean;
|
||||
quality?: { quality?: { name?: string; resolution?: number } };
|
||||
languages?: { name: string }[];
|
||||
customFormats?: { name: string }[];
|
||||
}
|
||||
|
||||
export function queueName(q: QueueItem): string {
|
||||
if (q.movie?.title) return q.movie.year > 0 ? `${q.movie.title} (${q.movie.year})` : q.movie.title;
|
||||
if (q.series?.title) {
|
||||
if (q.episode) return `${q.series.title} S${pad2(q.episode.seasonNumber)}E${pad2(q.episode.episodeNumber)}`;
|
||||
return q.series.title;
|
||||
}
|
||||
return q.title;
|
||||
}
|
||||
|
||||
export function queueProgress(q: QueueItem): number {
|
||||
if (q.size <= 0) return 0;
|
||||
return Math.min(Math.max(((q.size - q.sizeleft) / q.size) * 100, 0), 100);
|
||||
}
|
||||
|
||||
export function queueProblem(q: QueueItem): string {
|
||||
const parts: string[] = [];
|
||||
if (q.errorMessage) parts.push(q.errorMessage);
|
||||
for (const m of q.statusMessages ?? []) {
|
||||
parts.push(...m.messages);
|
||||
if (m.messages.length === 0 && m.title) parts.push(m.title);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export function pad2(n: number): string {
|
||||
return n < 10 ? "0" + n : String(n);
|
||||
}
|
||||
|
||||
type Body = Record<string, unknown> | unknown[];
|
||||
|
||||
export class ArrClient {
|
||||
name: string; // "Radarr" or "Sonarr", for messages
|
||||
baseURL = "";
|
||||
apiKey = "";
|
||||
|
||||
constructor(name: string, baseURL: string, apiKey: string) {
|
||||
this.name = name;
|
||||
this.configure(baseURL, apiKey);
|
||||
}
|
||||
|
||||
configure(baseURL: string, apiKey: string): void {
|
||||
this.baseURL = trimSlash(baseURL);
|
||||
this.apiKey = apiKey.trim();
|
||||
}
|
||||
|
||||
configured(): boolean {
|
||||
return this.baseURL !== "";
|
||||
}
|
||||
|
||||
// do sends a request to /api/v3/<path> and decodes the JSON answer.
|
||||
async do<T>(method: string, path: string, params: Record<string, string | number | boolean | undefined> = {}, body?: Body, opts: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<T> {
|
||||
if (!this.configured()) throw new NotConfiguredError(this.name);
|
||||
const headers: Record<string, string> = { "X-Api-Key": this.apiKey, Accept: "application/json" };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const resp = await request(
|
||||
this.baseURL + "/api/v3/" + path.replace(/^\/+/, "") + query(params),
|
||||
{ method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: opts.signal },
|
||||
opts.timeoutMs ?? 30_000,
|
||||
);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) throw new HTTPError(resp.status, errorMessage(text));
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new Error(`${this.name}: the answer is not JSON (${text.slice(0, 80)})`);
|
||||
}
|
||||
}
|
||||
|
||||
// update reads <path>/<id>, lets mutate change the object and PUTs it back
|
||||
// whole (Radarr and Sonarr replace the resource on PUT).
|
||||
async update(path: string, id: number, mutate: (obj: Record<string, unknown>) => void): Promise<void> {
|
||||
const obj = await this.do<Record<string, unknown>>("GET", `${path}/${id}`);
|
||||
mutate(obj);
|
||||
await this.do("PUT", `${path}/${id}`, {}, obj);
|
||||
}
|
||||
|
||||
command(body: Record<string, unknown>): Promise<void> {
|
||||
return this.do("POST", "command", {}, body);
|
||||
}
|
||||
|
||||
systemStatus(): Promise<SystemStatus> {
|
||||
return this.do("GET", "system/status", {}, undefined, { timeoutMs: 10_000 });
|
||||
}
|
||||
|
||||
qualityProfiles(): Promise<QualityProfile[]> {
|
||||
return this.do("GET", "qualityprofile");
|
||||
}
|
||||
|
||||
rootFolders(): Promise<RootFolder[]> {
|
||||
return this.do("GET", "rootfolder");
|
||||
}
|
||||
|
||||
async queue(include: Record<string, string>): Promise<QueueItem[]> {
|
||||
const page = await this.do<{ records: QueueItem[] }>("GET", "queue", { page: 1, pageSize: 200, ...include });
|
||||
return page.records ?? [];
|
||||
}
|
||||
|
||||
removeQueueItem(id: number, blocklist: boolean): Promise<void> {
|
||||
return this.do("DELETE", `queue/${id}`, { removeFromClient: true, blocklist });
|
||||
}
|
||||
|
||||
// releases runs an interactive search; it queries every indexer and can
|
||||
// take minutes.
|
||||
releases(params: Record<string, number>, signal?: AbortSignal): Promise<Release[]> {
|
||||
return this.do("GET", "release", params, undefined, { signal, timeoutMs: 180_000 });
|
||||
}
|
||||
|
||||
grab(r: Release): Promise<void> {
|
||||
return this.do("POST", "release", {}, { guid: r.guid, indexerId: r.indexerId });
|
||||
}
|
||||
|
||||
// posterURL returns an image URL for an <img>: the instance's own copy for
|
||||
// library titles (with the API key as a query parameter, as an <img> cannot
|
||||
// send headers), else the metadata provider's, at a size fitting the use.
|
||||
posterURL(images: Image[] | undefined, inLibrary: boolean, large: boolean): string {
|
||||
const img = images?.find((i) => i.coverType === "poster");
|
||||
if (!img) return "";
|
||||
if (inLibrary && img.url) {
|
||||
let path = img.url;
|
||||
if (!large) path = scaledVariant(path, 500);
|
||||
return this.resolve(path) + (path.includes("?") ? "&" : "?") + "apikey=" + encodeURIComponent(this.apiKey);
|
||||
}
|
||||
return img.remoteUrl ? tmdbSized(img.remoteUrl, large) : "";
|
||||
}
|
||||
|
||||
// resolve joins a server-relative path to the base URL. Radarr/Sonarr put
|
||||
// their URL base into image paths, so it must not be added twice.
|
||||
resolve(ref: string): string {
|
||||
try {
|
||||
const base = new URL(this.baseURL);
|
||||
const prefix = base.pathname.replace(/\/+$/, "");
|
||||
if (prefix && ref.startsWith(prefix + "/")) return base.origin + ref;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return this.baseURL + ref;
|
||||
}
|
||||
}
|
||||
|
||||
// errorMessage reads Radarr/Sonarr error bodies: {"message": "..."} or a list
|
||||
// of validation failures.
|
||||
export function errorMessage(text: string): string {
|
||||
try {
|
||||
const v = JSON.parse(text);
|
||||
if (v && typeof v.message === "string" && v.message) return v.message;
|
||||
if (Array.isArray(v) && v.length) return v.map((e: { errorMessage?: string }) => e.errorMessage ?? "").join("; ");
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
return text.trim().slice(0, 200);
|
||||
}
|
||||
|
||||
// scaledVariant turns /MediaCover/1/poster.jpg?x into /MediaCover/1/poster-500.jpg?x.
|
||||
export function scaledVariant(p: string, height: number): string {
|
||||
const [path, q] = p.split("?", 2);
|
||||
if (!path.endsWith("/poster.jpg")) return p;
|
||||
const scaled = path.slice(0, -4) + "-" + height + ".jpg";
|
||||
return q === undefined ? scaled : scaled + "?" + q;
|
||||
}
|
||||
|
||||
// tmdbSized asks TheMovieDB for the size shown instead of the original
|
||||
// (often 2000×3000 and over 2 MB).
|
||||
export function tmdbSized(raw: string, large: boolean): string {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.host !== "image.tmdb.org" || !u.pathname.startsWith("/t/p/original/")) return raw;
|
||||
u.pathname = "/t/p/" + (large ? "w780" : "w342") + u.pathname.slice("/t/p/original".length);
|
||||
return u.toString();
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Shared HTTP helpers. The TV app talks to Radarr, Sonarr, SABnzbd and Trakt
|
||||
// directly from the browser: all four answer cross-origin requests (CORS).
|
||||
|
||||
export class HTTPError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message ? `HTTP ${status}: ${message}` : `unexpected response: HTTP ${status}`);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotConfiguredError extends Error {
|
||||
constructor(what: string) {
|
||||
super(`${what} is not configured: set its address and API key in Settings`);
|
||||
}
|
||||
}
|
||||
|
||||
// request sends a request that gives up after timeoutMs. Network failures
|
||||
// become an Error naming the host only (the URL may carry an API key).
|
||||
export async function request(url: string, init: RequestInit = {}, timeoutMs = 30_000): Promise<Response> {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
const outer = init.signal;
|
||||
if (outer) {
|
||||
if (outer.aborted) ctl.abort();
|
||||
else outer.addEventListener("abort", () => ctl.abort());
|
||||
}
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: ctl.signal });
|
||||
} catch (e) {
|
||||
if (outer?.aborted) throw new DOMException("cancelled", "AbortError");
|
||||
if (ctl.signal.aborted) throw new Error(`${hostOf(url)}: no answer within ${Math.round(timeoutMs / 1000)} s`);
|
||||
// A browser cannot follow a redirect (e.g. http to https) of a cross-origin
|
||||
// request unless the redirect itself allows it; the TUI never notices.
|
||||
const hint = url.startsWith("http://") ? "; if the address redirects to https, enter the https address" : "";
|
||||
throw new Error(`${hostOf(url)}: cannot connect (${(e as Error).message})${hint}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return "server";
|
||||
}
|
||||
}
|
||||
|
||||
export function isAbort(e: unknown): boolean {
|
||||
return e instanceof DOMException && e.name === "AbortError";
|
||||
}
|
||||
|
||||
export function query(params: Record<string, string | number | boolean | undefined>): string {
|
||||
const parts: string[] = [];
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v)));
|
||||
}
|
||||
return parts.length ? "?" + parts.join("&") : "";
|
||||
}
|
||||
|
||||
export function trimSlash(u: string): string {
|
||||
return u.trim().replace(/\/+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Radarr v3 client, ported from internal/radarr.
|
||||
|
||||
import { ArrClient, type Image, type MediaFile, type QueueItem, type Release } from "./arr.ts";
|
||||
|
||||
export interface Rating {
|
||||
votes: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Movie {
|
||||
id: number; // 0 when not in the library
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
year: number;
|
||||
overview?: string;
|
||||
runtime: number;
|
||||
genres?: string[];
|
||||
studio?: string;
|
||||
status?: string;
|
||||
tmdbId: number;
|
||||
imdbId?: string;
|
||||
titleSlug?: string;
|
||||
images?: Image[];
|
||||
ratings?: { imdb?: Rating; tmdb?: Rating };
|
||||
monitored: boolean;
|
||||
hasFile: boolean;
|
||||
qualityProfileId?: number;
|
||||
inCinemas?: string;
|
||||
physicalRelease?: string;
|
||||
digitalRelease?: string;
|
||||
sizeOnDisk: number;
|
||||
isAvailable: boolean;
|
||||
path?: string;
|
||||
movieFile?: MediaFile;
|
||||
}
|
||||
|
||||
export function movieRating(m: Movie): number {
|
||||
if (m.ratings?.imdb && m.ratings.imdb.value > 0) return m.ratings.imdb.value;
|
||||
return m.ratings?.tmdb?.value ?? 0;
|
||||
}
|
||||
|
||||
export function inLibrary(m: { id: number }): boolean {
|
||||
return !!m.id;
|
||||
}
|
||||
|
||||
export interface AddMovieOptions {
|
||||
qualityProfileId: number;
|
||||
rootFolderPath: string;
|
||||
monitored: boolean;
|
||||
searchNow: boolean;
|
||||
}
|
||||
|
||||
export class RadarrClient extends ArrClient {
|
||||
constructor(baseURL: string, apiKey: string) {
|
||||
super("Radarr", baseURL, apiKey);
|
||||
}
|
||||
|
||||
lookup(term: string, signal?: AbortSignal): Promise<Movie[]> {
|
||||
return this.do("GET", "movie/lookup", { term }, undefined, { signal });
|
||||
}
|
||||
|
||||
movies(): Promise<Movie[]> {
|
||||
return this.do("GET", "movie", {}, undefined, { timeoutMs: 60_000 });
|
||||
}
|
||||
|
||||
// movieByTMDB returns the library movie with the TMDb id, or null. The
|
||||
// answer is checked: a version that ignores the filter sends the library.
|
||||
async movieByTMDB(tmdbId: number): Promise<Movie | null> {
|
||||
const movies = await this.do<Movie[]>("GET", "movie", { tmdbId });
|
||||
return movies.find((m) => m.tmdbId === tmdbId && m.id) ?? null;
|
||||
}
|
||||
|
||||
addMovie(m: Movie, o: AddMovieOptions): Promise<Movie> {
|
||||
return this.do("POST", "movie", {}, {
|
||||
title: m.title,
|
||||
year: m.year,
|
||||
tmdbId: m.tmdbId,
|
||||
titleSlug: m.titleSlug,
|
||||
images: m.images,
|
||||
qualityProfileId: o.qualityProfileId,
|
||||
rootFolderPath: o.rootFolderPath,
|
||||
monitored: o.monitored,
|
||||
minimumAvailability: "released",
|
||||
addOptions: { searchForMovie: o.searchNow },
|
||||
});
|
||||
}
|
||||
|
||||
searchMovie(id: number): Promise<void> {
|
||||
return this.command({ name: "MoviesSearch", movieIds: [id] });
|
||||
}
|
||||
|
||||
movieQueue(): Promise<QueueItem[]> {
|
||||
return this.queue({ includeMovie: "true" });
|
||||
}
|
||||
|
||||
movieReleases(id: number, signal?: AbortSignal): Promise<Release[]> {
|
||||
return this.releases({ movieId: id }, signal);
|
||||
}
|
||||
|
||||
setMonitored(id: number, monitored: boolean): Promise<void> {
|
||||
return this.update("movie", id, (o) => {
|
||||
o.monitored = monitored;
|
||||
});
|
||||
}
|
||||
|
||||
deleteMovie(id: number, deleteFiles: boolean, addExclusion: boolean): Promise<void> {
|
||||
return this.do("DELETE", `movie/${id}`, { deleteFiles, addImportExclusion: addExclusion });
|
||||
}
|
||||
|
||||
calendar(start: string, end: string, unmonitored: boolean): Promise<Movie[]> {
|
||||
return this.do("GET", "calendar", { start, end, unmonitored });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SABnzbd client, ported from internal/sabnzbd.
|
||||
|
||||
import { NotConfiguredError, query, request, trimSlash } from "./http.ts";
|
||||
|
||||
export interface Slot {
|
||||
nzo_id: string;
|
||||
filename: string;
|
||||
cat: string;
|
||||
status: string; // Downloading, Queued, Paused, Fetching, ...
|
||||
priority: string;
|
||||
size: string;
|
||||
sizeleft: string;
|
||||
mb: number;
|
||||
mbleft: number;
|
||||
percentage: number;
|
||||
timeleft: string;
|
||||
}
|
||||
|
||||
export interface Queue {
|
||||
status: string;
|
||||
paused: boolean;
|
||||
kbpersec: number;
|
||||
speedlimit_abs: number;
|
||||
size: string;
|
||||
sizeleft: string;
|
||||
timeleft: string;
|
||||
noofslots_total: number;
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
export interface HistorySlot {
|
||||
nzo_id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string; // Completed, Failed, Extracting, ...
|
||||
size: string;
|
||||
fail_message: string;
|
||||
completed: number; // unix time
|
||||
}
|
||||
|
||||
// num reads SABnzbd's numbers, which come as numbers or numeric strings.
|
||||
export function num(v: unknown): number {
|
||||
if (typeof v === "number") return v;
|
||||
const f = parseFloat(String(v ?? ""));
|
||||
return Number.isFinite(f) ? f : 0;
|
||||
}
|
||||
|
||||
export class SABnzbdClient {
|
||||
baseURL = "";
|
||||
apiKey = "";
|
||||
|
||||
constructor(baseURL: string, apiKey: string) {
|
||||
this.configure(baseURL, apiKey);
|
||||
}
|
||||
|
||||
configure(baseURL: string, apiKey: string): void {
|
||||
this.baseURL = trimSlash(baseURL);
|
||||
this.apiKey = apiKey.trim();
|
||||
}
|
||||
|
||||
configured(): boolean {
|
||||
return this.baseURL !== "";
|
||||
}
|
||||
|
||||
// call performs an API request. SABnzbd reports failures with HTTP 200 and
|
||||
// {"status": false, "error": "..."}.
|
||||
async call<T>(params: Record<string, string | number>, timeoutMs = 30_000): Promise<T> {
|
||||
if (!this.configured()) throw new NotConfiguredError("SABnzbd");
|
||||
const resp = await request(this.baseURL + "/api" + query({ ...params, output: "json", apikey: this.apiKey }), {}, timeoutMs);
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.trim().slice(0, 200)}`);
|
||||
let v: { status?: unknown; error?: string };
|
||||
try {
|
||||
v = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`unexpected response: ${text.trim().slice(0, 200)}`); // e.g. "Access denied"
|
||||
}
|
||||
if (v.error) throw new Error(v.error);
|
||||
if (v.status === false) throw new Error("request failed");
|
||||
return v as T;
|
||||
}
|
||||
|
||||
async version(): Promise<string> {
|
||||
return (await this.call<{ version: string }>({ mode: "version" }, 10_000)).version;
|
||||
}
|
||||
|
||||
async queue(): Promise<Queue> {
|
||||
const q = (await this.call<{ queue: Queue }>({ mode: "queue" })).queue;
|
||||
q.kbpersec = num(q.kbpersec);
|
||||
q.speedlimit_abs = num(q.speedlimit_abs);
|
||||
for (const s of q.slots ?? []) {
|
||||
s.mb = num(s.mb);
|
||||
s.mbleft = num(s.mbleft);
|
||||
s.percentage = num(s.percentage);
|
||||
}
|
||||
q.slots ??= [];
|
||||
return q;
|
||||
}
|
||||
|
||||
async history(limit: number): Promise<HistorySlot[]> {
|
||||
const h = await this.call<{ history: { slots: HistorySlot[] } }>({ mode: "history", limit });
|
||||
return h.history?.slots ?? [];
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await this.call({ mode: "pause" });
|
||||
}
|
||||
|
||||
async resume(): Promise<void> {
|
||||
await this.call({ mode: "resume" });
|
||||
}
|
||||
|
||||
async deleteJob(id: string, deleteFiles: boolean): Promise<void> {
|
||||
await this.call({ mode: "queue", name: "delete", value: id, del_files: deleteFiles ? 1 : 0 });
|
||||
}
|
||||
|
||||
async deleteHistory(id: string, deleteFiles: boolean): Promise<void> {
|
||||
await this.call({ mode: "history", name: "delete", value: id, del_files: deleteFiles ? 1 : 0 });
|
||||
}
|
||||
|
||||
async retry(id: string): Promise<void> {
|
||||
await this.call({ mode: "retry", value: id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Sonarr v3/v4 client, ported from internal/sonarr.
|
||||
|
||||
import { ArrClient, type Image, type MediaFile, type QueueItem, type Release } from "./arr.ts";
|
||||
|
||||
export interface Statistics {
|
||||
seasonCount?: number;
|
||||
episodeFileCount: number;
|
||||
episodeCount: number;
|
||||
totalEpisodeCount?: number;
|
||||
sizeOnDisk: number;
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
seasonNumber: number;
|
||||
monitored: boolean;
|
||||
statistics?: Statistics;
|
||||
images?: Image[]; // only from getSeries
|
||||
}
|
||||
|
||||
export interface Series {
|
||||
id: number; // 0 when not in the library
|
||||
title: string;
|
||||
year: number;
|
||||
overview?: string;
|
||||
network?: string;
|
||||
status?: string;
|
||||
runtime: number;
|
||||
genres?: string[];
|
||||
tvdbId: number;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
titleSlug?: string;
|
||||
seriesType?: string;
|
||||
images?: Image[];
|
||||
ratings?: { votes: number; value: number };
|
||||
seasons?: Season[];
|
||||
monitored: boolean;
|
||||
statistics?: Statistics;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
title: string;
|
||||
airDate?: string;
|
||||
airDateUtc?: string;
|
||||
overview?: string;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export function seasonCount(s: Series): number {
|
||||
return (s.seasons ?? []).filter((x) => x.seasonNumber > 0).length;
|
||||
}
|
||||
|
||||
export const monitorModes: [label: string, value: string][] = [
|
||||
["All episodes", "all"],
|
||||
["Future episodes", "future"],
|
||||
["Missing episodes", "missing"],
|
||||
["First season", "firstSeason"],
|
||||
["Latest season", "latestSeason"],
|
||||
["None", "none"],
|
||||
];
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
qualityProfileId: number;
|
||||
rootFolderPath: string;
|
||||
monitored: boolean;
|
||||
monitor: string;
|
||||
searchNow: boolean;
|
||||
}
|
||||
|
||||
export class SonarrClient extends ArrClient {
|
||||
constructor(baseURL: string, apiKey: string) {
|
||||
super("Sonarr", baseURL, apiKey);
|
||||
}
|
||||
|
||||
lookup(term: string, signal?: AbortSignal): Promise<Series[]> {
|
||||
return this.do("GET", "series/lookup", { term }, undefined, { signal });
|
||||
}
|
||||
|
||||
allSeries(): Promise<Series[]> {
|
||||
return this.do("GET", "series", {}, undefined, { timeoutMs: 60_000 });
|
||||
}
|
||||
|
||||
async seriesByTVDB(tvdbId: number): Promise<Series | null> {
|
||||
const series = await this.do<Series[]>("GET", "series", { tvdbId });
|
||||
return series.find((s) => s.tvdbId === tvdbId && s.id) ?? null;
|
||||
}
|
||||
|
||||
// getSeries fetches a library series with its season posters.
|
||||
getSeries(id: number): Promise<Series> {
|
||||
return this.do("GET", `series/${id}`, { includeSeasonImages: true });
|
||||
}
|
||||
|
||||
addSeries(s: Series, o: AddSeriesOptions): Promise<Series> {
|
||||
return this.do("POST", "series", {}, {
|
||||
title: s.title,
|
||||
year: s.year,
|
||||
tvdbId: s.tvdbId,
|
||||
titleSlug: s.titleSlug,
|
||||
seriesType: s.seriesType,
|
||||
images: s.images,
|
||||
seasons: s.seasons,
|
||||
qualityProfileId: o.qualityProfileId,
|
||||
rootFolderPath: o.rootFolderPath,
|
||||
monitored: o.monitored,
|
||||
seasonFolder: true,
|
||||
addOptions: { monitor: o.monitor || "all", searchForMissingEpisodes: o.searchNow },
|
||||
});
|
||||
}
|
||||
|
||||
searchSeries(id: number): Promise<void> {
|
||||
return this.command({ name: "SeriesSearch", seriesId: id });
|
||||
}
|
||||
|
||||
searchSeason(id: number, season: number): Promise<void> {
|
||||
return this.command({ name: "SeasonSearch", seriesId: id, seasonNumber: season });
|
||||
}
|
||||
|
||||
searchEpisode(episodeId: number): Promise<void> {
|
||||
return this.command({ name: "EpisodeSearch", episodeIds: [episodeId] });
|
||||
}
|
||||
|
||||
setMonitored(id: number, monitored: boolean): Promise<void> {
|
||||
return this.update("series", id, (o) => {
|
||||
o.monitored = monitored;
|
||||
});
|
||||
}
|
||||
|
||||
setSeasonMonitored(id: number, season: number, monitored: boolean): Promise<void> {
|
||||
return this.update("series", id, (o) => {
|
||||
const seasons = o.seasons as { seasonNumber: number; monitored: boolean }[] | undefined;
|
||||
const s = seasons?.find((x) => x.seasonNumber === season);
|
||||
if (!s) throw new Error(`season ${season} not found`);
|
||||
s.monitored = monitored;
|
||||
});
|
||||
}
|
||||
|
||||
setEpisodesMonitored(ids: number[], monitored: boolean): Promise<void> {
|
||||
return this.do("PUT", "episode/monitor", {}, { episodeIds: ids, monitored });
|
||||
}
|
||||
|
||||
seriesQueue(): Promise<QueueItem[]> {
|
||||
return this.queue({ includeSeries: "true", includeEpisode: "true" });
|
||||
}
|
||||
|
||||
episodes(seriesId: number): Promise<Episode[]> {
|
||||
return this.do("GET", "episode", { seriesId });
|
||||
}
|
||||
|
||||
episodeReleases(episodeId: number, signal?: AbortSignal): Promise<Release[]> {
|
||||
return this.releases({ episodeId }, signal);
|
||||
}
|
||||
|
||||
seasonReleases(seriesId: number, season: number, signal?: AbortSignal): Promise<Release[]> {
|
||||
return this.releases({ seriesId, seasonNumber: season }, signal);
|
||||
}
|
||||
|
||||
episodeFiles(seriesId: number): Promise<MediaFile[]> {
|
||||
return this.do("GET", "episodefile", { seriesId });
|
||||
}
|
||||
|
||||
deleteSeries(id: number, deleteFiles: boolean, addExclusion: boolean): Promise<void> {
|
||||
return this.do("DELETE", `series/${id}`, { deleteFiles, addImportListExclusion: addExclusion });
|
||||
}
|
||||
|
||||
calendar(start: string, end: string, unmonitored: boolean): Promise<Episode[]> {
|
||||
return this.do("GET", "calendar", { start, end, unmonitored, includeSeries: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Trakt's public lists and casts (client ID only), ported from internal/trakt.
|
||||
|
||||
import { query, request, trimSlash } from "./http.ts";
|
||||
|
||||
export type Kind = "movie" | "show";
|
||||
|
||||
export interface List {
|
||||
name: string;
|
||||
path: string;
|
||||
count: string; // field holding the list's number, "" for none
|
||||
note?: (n: number) => string;
|
||||
kind: Kind;
|
||||
}
|
||||
|
||||
export interface IDs {
|
||||
trakt: number;
|
||||
slug?: string;
|
||||
imdb?: string;
|
||||
tmdb?: number;
|
||||
tvdb?: number;
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
title: string;
|
||||
year: number;
|
||||
ids: IDs;
|
||||
count: number; // the list's number for it, 0 if none
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface People {
|
||||
cast: Person[];
|
||||
leaders: Person[]; // directors of a movie, creators of a show
|
||||
}
|
||||
|
||||
export interface Ref {
|
||||
kind: Kind;
|
||||
tmdb?: number;
|
||||
tvdb?: number;
|
||||
imdb?: string;
|
||||
}
|
||||
|
||||
export class TraktNotFound extends Error {
|
||||
constructor() {
|
||||
super("trakt: not found");
|
||||
}
|
||||
}
|
||||
|
||||
const watching = (n: number) => compact(n) + " watching";
|
||||
const watchers = (n: number) => compact(n) + " watched it";
|
||||
const onLists = (n: number) => "on " + compact(n) + " lists";
|
||||
const revenue = (n: number) => "$" + compact(n) + " last weekend";
|
||||
|
||||
export const movieLists: List[] = [
|
||||
{ name: "Trending now", path: "/movies/trending", count: "watchers", note: watching, kind: "movie" },
|
||||
{ name: "Most watched this week", path: "/movies/watched/weekly", count: "watcher_count", note: watchers, kind: "movie" },
|
||||
{ name: "Anticipated", path: "/movies/anticipated", count: "list_count", note: onLists, kind: "movie" },
|
||||
{ name: "Popular", path: "/movies/popular", count: "", kind: "movie" },
|
||||
{ name: "Box office (US)", path: "/movies/boxoffice", count: "revenue", note: revenue, kind: "movie" },
|
||||
];
|
||||
|
||||
export const showLists: List[] = [
|
||||
{ name: "Trending now", path: "/shows/trending", count: "watchers", note: watching, kind: "show" },
|
||||
{ name: "Most watched this week", path: "/shows/watched/weekly", count: "watcher_count", note: watchers, kind: "show" },
|
||||
{ name: "Anticipated", path: "/shows/anticipated", count: "list_count", note: onLists, kind: "show" },
|
||||
{ name: "Popular", path: "/shows/popular", count: "", kind: "show" },
|
||||
];
|
||||
|
||||
export function listNote(l: List, n: number): string {
|
||||
return l.note && n > 0 ? l.note(n) : "";
|
||||
}
|
||||
|
||||
export function refKey(r: Ref): string {
|
||||
const k = r.kind + ":";
|
||||
if (r.kind === "movie" && r.tmdb) return k + "tmdb:" + r.tmdb;
|
||||
if (r.kind === "show" && r.tvdb) return k + "tvdb:" + r.tvdb;
|
||||
if (r.imdb) return k + "imdb:" + r.imdb;
|
||||
return "";
|
||||
}
|
||||
|
||||
export class TraktClient {
|
||||
baseURL: string;
|
||||
clientID: string;
|
||||
|
||||
constructor(clientID: string, baseURL = "https://api.trakt.tv") {
|
||||
this.clientID = clientID.trim();
|
||||
this.baseURL = trimSlash(baseURL);
|
||||
}
|
||||
|
||||
configured(): boolean {
|
||||
return this.clientID !== "";
|
||||
}
|
||||
|
||||
async get(path: string, params: Record<string, string | number> = {}): Promise<unknown> {
|
||||
if (!this.configured()) throw new Error("no Trakt client ID: create an API app on trakt.tv and enter its client ID in Settings");
|
||||
// No User-Agent: browsers do not let pages set it.
|
||||
const resp = await request(this.baseURL + path + query(params), {
|
||||
headers: { "Content-Type": "application/json", "trakt-api-key": this.clientID, "trakt-api-version": "2" },
|
||||
}, 20_000);
|
||||
if (resp.status === 401 || resp.status === 403) throw new Error(`trakt: the client ID was not accepted (HTTP ${resp.status})`);
|
||||
if (resp.status === 429) throw new Error("trakt: too many requests, try again in a few minutes");
|
||||
if (resp.status === 404) throw new TraktNotFound();
|
||||
if (!resp.ok) throw new Error(`trakt: HTTP ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async entries(l: List, limit: number): Promise<Entry[]> {
|
||||
return decodeList(await this.get(l.path, { page: 1, limit }), l).slice(0, limit);
|
||||
}
|
||||
|
||||
async people(r: Ref): Promise<People> {
|
||||
const id = await this.traktID(r);
|
||||
const raw = (await this.get(`/${r.kind}s/${id}/people`)) as {
|
||||
cast?: { character?: string; characters?: string[]; person: { name: string } }[];
|
||||
crew?: Record<string, { job?: string; jobs?: string[]; person: { name: string } }[]>;
|
||||
};
|
||||
const p: People = { cast: [], leaders: [] };
|
||||
for (const m of raw.cast ?? []) {
|
||||
const role = m.characters?.length ? m.characters.join(" / ") : (m.character ?? "");
|
||||
p.cast.push({ name: m.person.name, role });
|
||||
}
|
||||
const [key, job] = r.kind === "show" ? ["created by", ""] : ["directing", "Director"];
|
||||
for (const m of raw.crew?.[key] ?? []) {
|
||||
const jobs = [m.job ?? "", ...(m.jobs ?? [])];
|
||||
if (!job || jobs.includes(job)) p.leaders.push({ name: m.person.name, role: m.job ?? "" });
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
async traktID(r: Ref): Promise<number> {
|
||||
let type: string, id: string;
|
||||
if (r.kind === "movie" && r.tmdb) [type, id] = ["tmdb", String(r.tmdb)];
|
||||
else if (r.kind === "show" && r.tvdb) [type, id] = ["tvdb", String(r.tvdb)];
|
||||
else if (r.imdb) [type, id] = ["imdb", r.imdb];
|
||||
else throw new TraktNotFound();
|
||||
const results = (await this.get(`/search/${type}/${encodeURIComponent(id)}`, { type: r.kind })) as Record<string, { ids?: IDs }>[];
|
||||
for (const res of results) {
|
||||
const t = res[r.kind]?.ids?.trakt;
|
||||
if (t) return t;
|
||||
}
|
||||
throw new TraktNotFound();
|
||||
}
|
||||
}
|
||||
|
||||
// decodeList reads a list answer: bare titles, or items with the title under
|
||||
// "movie"/"show" next to the list's number.
|
||||
export function decodeList(data: unknown, l: List): Entry[] {
|
||||
if (!Array.isArray(data)) throw new Error(`trakt: unexpected answer for ${l.path}`);
|
||||
return data.map((item: Record<string, unknown>) => {
|
||||
const t = (item[l.kind] ?? item) as { title: string; year: number; ids: IDs };
|
||||
const n = l.count ? Number(item[l.count] ?? 0) : 0;
|
||||
return { title: t.title, year: t.year, ids: t.ids ?? { trakt: 0 }, count: Number.isFinite(n) ? n : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
// compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
|
||||
export function compact(n: number): string {
|
||||
const trim = (s: string) => s.replace(".0", "");
|
||||
if (n < 1000) return String(Math.trunc(n));
|
||||
if (n < 10_000) return trim((n / 1000).toFixed(1) + "k");
|
||||
if (n < 1_000_000) return Math.trunc(n / 1000) + "k";
|
||||
if (n < 10_000_000) return trim((n / 1_000_000).toFixed(1) + "M");
|
||||
return Math.trunc(n / 1_000_000) + "M";
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// The services, shared caches and the page stack every view uses.
|
||||
|
||||
import { RadarrClient } from "./api/radarr.ts";
|
||||
import { SonarrClient } from "./api/sonarr.ts";
|
||||
import { SABnzbdClient } from "./api/sabnzbd.ts";
|
||||
import { TraktClient, refKey, TraktNotFound, type People, type Ref } from "./api/trakt.ts";
|
||||
import { loadConfig, type Config } from "./config.ts";
|
||||
import { h } from "./ui/dom.ts";
|
||||
import { focusFirst, popLayer, pushLayer } from "./ui/focus.ts";
|
||||
|
||||
export type ServiceName = "Radarr" | "Sonarr" | "SABnzbd" | "Trakt";
|
||||
|
||||
class Services {
|
||||
cfg: Config;
|
||||
radarr: RadarrClient;
|
||||
sonarr: SonarrClient;
|
||||
sab: SABnzbdClient;
|
||||
trakt: TraktClient;
|
||||
// Called a few seconds after a grab (the Library's download marks).
|
||||
afterGrab: (() => void)[] = [];
|
||||
// Called when the config changes (Settings saved).
|
||||
onConfig: (() => void)[] = [];
|
||||
status: Record<ServiceName, string> = { Radarr: "", Sonarr: "", SABnzbd: "", Trakt: "" }; // "" ok, else the error
|
||||
onStatus: (() => void)[] = [];
|
||||
|
||||
constructor() {
|
||||
this.cfg = loadConfig();
|
||||
const c = this.cfg;
|
||||
this.radarr = new RadarrClient(c.radarr.url, c.radarr.apikey);
|
||||
this.sonarr = new SonarrClient(c.sonarr.url, c.sonarr.apikey);
|
||||
this.sab = new SABnzbdClient(c.sabnzbd.url, c.sabnzbd.apikey);
|
||||
this.trakt = new TraktClient(c.trakt.client_id ?? "", traktURL());
|
||||
}
|
||||
|
||||
apply(c: Config): void {
|
||||
this.cfg = c;
|
||||
this.radarr.configure(c.radarr.url, c.radarr.apikey);
|
||||
this.sonarr.configure(c.sonarr.url, c.sonarr.apikey);
|
||||
this.sab.configure(c.sabnzbd.url, c.sabnzbd.apikey);
|
||||
this.trakt = new TraktClient(c.trakt.client_id ?? "", traktURL());
|
||||
people.failed.clear();
|
||||
for (const f of this.onConfig) f();
|
||||
}
|
||||
|
||||
setStatus(name: ServiceName, err: unknown): void {
|
||||
const text = err ? (err instanceof Error ? err.message : String(err)) : "";
|
||||
if (this.status[name] === text) return;
|
||||
this.status[name] = text;
|
||||
for (const f of this.onStatus) f();
|
||||
}
|
||||
|
||||
covers(): boolean {
|
||||
return this.cfg.ui.covers !== false;
|
||||
}
|
||||
}
|
||||
|
||||
// traktURL: another Trakt API for development (?trakt=http://127.0.0.1:19894).
|
||||
function traktURL(): string | undefined {
|
||||
return new URLSearchParams(location.search).get("trakt") ?? (window as unknown as { ARR_TUI_TRAKT_URL?: string }).ARR_TUI_TRAKT_URL;
|
||||
}
|
||||
|
||||
export const app = new Services();
|
||||
|
||||
// --- Pages: full-screen views opened on top (releases, episodes) ---
|
||||
|
||||
export interface Page {
|
||||
el: HTMLElement;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function openPage(el: HTMLElement, onClose?: () => void): Page {
|
||||
el.classList.add("page");
|
||||
document.getElementById("overlays")!.appendChild(el);
|
||||
let closed = false;
|
||||
const page: Page = {
|
||||
el,
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
popLayer(el);
|
||||
el.remove();
|
||||
onClose?.();
|
||||
},
|
||||
};
|
||||
pushLayer(el, page.close);
|
||||
focusFirst(el);
|
||||
return page;
|
||||
}
|
||||
|
||||
export function pageHeader(title: string, ...extra: (Node | string)[]): HTMLElement {
|
||||
return h("div.page-header", h("h1", title), ...extra);
|
||||
}
|
||||
|
||||
// --- Stores kept on the TV (local storage), like arr-tui's disk cache ---
|
||||
|
||||
interface Saved<T> {
|
||||
saved: number; // ms
|
||||
data: T;
|
||||
}
|
||||
|
||||
class Store<T> {
|
||||
private key: string;
|
||||
private ttl: number;
|
||||
private cap: number;
|
||||
private entries: Record<string, Saved<T>> | null = null;
|
||||
private timer = 0;
|
||||
|
||||
constructor(key: string, ttlDays: number, cap: number) {
|
||||
this.key = key;
|
||||
this.ttl = ttlDays * 86400_000;
|
||||
this.cap = cap;
|
||||
}
|
||||
|
||||
private load(): Record<string, Saved<T>> {
|
||||
if (!this.entries) {
|
||||
try {
|
||||
this.entries = JSON.parse(localStorage.getItem(this.key) ?? "{}");
|
||||
} catch {
|
||||
this.entries = {};
|
||||
}
|
||||
}
|
||||
return this.entries!;
|
||||
}
|
||||
|
||||
get(k: string): T | undefined {
|
||||
const e = this.load()[k];
|
||||
return e && Date.now() - e.saved < this.ttl ? e.data : undefined;
|
||||
}
|
||||
|
||||
put(k: string, data: T): void {
|
||||
const all = this.load();
|
||||
all[k] = { saved: Date.now(), data };
|
||||
const keys = Object.keys(all);
|
||||
if (keys.length > this.cap) {
|
||||
keys.sort((a, b) => all[a].saved - all[b].saved);
|
||||
for (const old of keys.slice(0, keys.length - this.cap)) delete all[old];
|
||||
}
|
||||
clearTimeout(this.timer);
|
||||
this.timer = window.setTimeout(() => this.save(), 3000);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.entries) return;
|
||||
const now = Date.now();
|
||||
for (const [k, e] of Object.entries(this.entries)) if (now - e.saved >= this.ttl) delete this.entries[k];
|
||||
try {
|
||||
localStorage.setItem(this.key, JSON.stringify(this.entries));
|
||||
} catch {
|
||||
/* full: kept for this run only */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookups of Trakt list titles that are not in the library (3 days).
|
||||
export const lookups = new Store<unknown>("arr-tui-lookups", 3, 300);
|
||||
|
||||
// Casts from Trakt (7 days).
|
||||
const peopleStore = new Store<People>("arr-tui-people", 7, 1000);
|
||||
|
||||
export const people = {
|
||||
failed: new Map<string, number>(),
|
||||
waiting: new Map<string, Promise<People>>(),
|
||||
|
||||
// load returns the cast of a title, from the store or Trakt. A title Trakt
|
||||
// does not know has no cast.
|
||||
load(r: Ref): Promise<People> {
|
||||
const key = refKey(r);
|
||||
const known = peopleStore.get(key);
|
||||
if (known) return Promise.resolve(known);
|
||||
const failedAt = this.failed.get(key);
|
||||
if (failedAt && Date.now() - failedAt < 120_000) return Promise.reject(new Error("cast not available"));
|
||||
let p = this.waiting.get(key);
|
||||
if (!p) {
|
||||
p = app.trakt.people(r).catch((e) => {
|
||||
if (e instanceof TraktNotFound) return { cast: [], leaders: [] };
|
||||
this.failed.set(key, Date.now());
|
||||
throw e;
|
||||
}).then((ppl) => {
|
||||
peopleStore.put(key, ppl);
|
||||
return ppl;
|
||||
}).finally(() => this.waiting.delete(key));
|
||||
this.waiting.set(key, p);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
lookups.save();
|
||||
peopleStore.save();
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// The configuration: the arr-tui config file baked in at build time
|
||||
// (config.js sets window.ARR_TUI_CONFIG), with what was changed in Settings
|
||||
// on the TV kept in local storage on top.
|
||||
|
||||
export interface Service {
|
||||
url: string;
|
||||
apikey: string;
|
||||
}
|
||||
|
||||
export interface Arr extends Service {
|
||||
default_quality_profile?: string;
|
||||
default_root_folder?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
radarr: Arr;
|
||||
sonarr: Arr;
|
||||
sabnzbd: Service;
|
||||
trakt: { client_id?: string };
|
||||
ui: {
|
||||
refresh_seconds?: number;
|
||||
covers?: boolean;
|
||||
notify_downloads?: boolean;
|
||||
theme?: string; // auto, dark, light
|
||||
};
|
||||
}
|
||||
|
||||
const storageKey = "arr-tui-settings";
|
||||
|
||||
export function emptyConfig(): Config {
|
||||
return { radarr: { url: "", apikey: "" }, sonarr: { url: "", apikey: "" }, sabnzbd: { url: "", apikey: "" }, trakt: {}, ui: {} };
|
||||
}
|
||||
|
||||
// builtIn is the config the app was built with.
|
||||
export function builtIn(): Config {
|
||||
const raw = (window as unknown as { ARR_TUI_CONFIG?: Partial<Config> }).ARR_TUI_CONFIG;
|
||||
return merge(emptyConfig(), raw ?? {});
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
let saved: Partial<Config> = {};
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem(storageKey) ?? "{}");
|
||||
} catch {
|
||||
/* unreadable: the built-in config */
|
||||
}
|
||||
return merge(builtIn(), saved);
|
||||
}
|
||||
|
||||
export function saveConfig(c: Config): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(c));
|
||||
} catch {
|
||||
/* storage full or blocked: the change lasts until the app closes */
|
||||
}
|
||||
}
|
||||
|
||||
export function resetConfig(): void {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
/* nothing stored */
|
||||
}
|
||||
}
|
||||
|
||||
export function hasLocalChanges(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(storageKey) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function merge(base: Config, over: Partial<Config>): Config {
|
||||
return {
|
||||
radarr: { ...base.radarr, ...over.radarr },
|
||||
sonarr: { ...base.sonarr, ...over.sonarr },
|
||||
sabnzbd: { ...base.sabnzbd, ...over.sabnzbd },
|
||||
trakt: { ...base.trakt, ...over.trakt },
|
||||
ui: { ...base.ui, ...over.ui },
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshSeconds(c: Config): number {
|
||||
return c.ui.refresh_seconds && c.ui.refresh_seconds > 0 ? c.ui.refresh_seconds : 3;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// How movies and series are described in lists and the details pane.
|
||||
|
||||
import { app } from "./app.ts";
|
||||
import { pad2 } from "./api/arr.ts";
|
||||
import { movieRating, type Movie } from "./api/radarr.ts";
|
||||
import { seasonCount, type Series } from "./api/sonarr.ts";
|
||||
import { formatSize, plural, runtime } from "./format.ts";
|
||||
import type { Detail, Line } from "./ui/detail.ts";
|
||||
|
||||
export function titleYear(t: { title: string; year: number }): string {
|
||||
return t.year > 0 ? `${t.title} (${t.year})` : t.title;
|
||||
}
|
||||
|
||||
export function movieDetail(m: Movie, large = true): Detail {
|
||||
const facts: string[] = [];
|
||||
const r = movieRating(m);
|
||||
if (r > 0) facts.push("★ " + r.toFixed(1));
|
||||
if (m.runtime > 0) facts.push(runtime(m.runtime));
|
||||
if (m.genres?.length) facts.push(m.genres.join(", "));
|
||||
const lines: Line[] = [];
|
||||
if (m.studio) lines.push({ text: "Studio: " + m.studio });
|
||||
if (m.status) lines.push({ text: "Status: " + m.status });
|
||||
if (m.hasFile) lines.push({ text: "In library, downloaded", cls: "ok" });
|
||||
else if (m.id) lines.push({ text: "In library, not downloaded", cls: "warn" });
|
||||
return {
|
||||
title: titleYear(m),
|
||||
facts,
|
||||
lines,
|
||||
plot: m.overview ?? "",
|
||||
poster: app.radarr.posterURL(m.images, !!m.id, large),
|
||||
ref: { kind: "movie", tmdb: m.tmdbId, imdb: m.imdbId },
|
||||
};
|
||||
}
|
||||
|
||||
export function seriesDetail(s: Series, large = true): Detail {
|
||||
const facts: string[] = [];
|
||||
if (s.ratings && s.ratings.value > 0) facts.push("★ " + s.ratings.value.toFixed(1));
|
||||
const n = seasonCount(s);
|
||||
if (n > 0) facts.push(plural(n, "season"));
|
||||
if (s.runtime > 0) facts.push(s.runtime + " min");
|
||||
if (s.genres?.length) facts.push(s.genres.join(", "));
|
||||
const lines: Line[] = [];
|
||||
if (s.network) lines.push({ text: "Network: " + s.network });
|
||||
if (s.status) lines.push({ text: "Status: " + s.status });
|
||||
if (s.id) {
|
||||
const st = s.statistics;
|
||||
if (st && st.episodeCount > 0 && st.episodeFileCount >= st.episodeCount) lines.push({ text: "In library, all episodes downloaded", cls: "ok" });
|
||||
else lines.push({ text: `In library, ${st?.episodeFileCount ?? 0}/${st?.episodeCount ?? 0} episodes downloaded`, cls: "warn" });
|
||||
}
|
||||
return {
|
||||
title: titleYear(s),
|
||||
facts,
|
||||
lines,
|
||||
plot: s.overview ?? "",
|
||||
poster: app.sonarr.posterURL(s.images, !!s.id, large),
|
||||
ref: { kind: "show", tvdb: s.tvdbId, imdb: s.imdbId },
|
||||
};
|
||||
}
|
||||
|
||||
export function monitoredLine(m: boolean): Line {
|
||||
return m ? { text: "Monitored: yes" } : { text: "Monitored: no", cls: "muted" };
|
||||
}
|
||||
|
||||
export function sizeLine(b: number): Line | null {
|
||||
return b > 0 ? { text: "Size on disk: " + formatSize(b) } : null;
|
||||
}
|
||||
|
||||
// Download state of a title in the Radarr/Sonarr queue.
|
||||
export interface DlInfo {
|
||||
count: number;
|
||||
progress: number;
|
||||
timeLeft: string;
|
||||
}
|
||||
|
||||
export function dlLine(d: DlInfo): Line {
|
||||
if (d.count > 1) return { text: `Downloading ${d.count} episodes`, cls: "dl" };
|
||||
return { text: `Downloading ${Math.round(d.progress)}%` + (d.timeLeft ? ` · ${d.timeLeft} left` : ""), cls: "dl" };
|
||||
}
|
||||
|
||||
export function episodeCode(season: number, episode: number): string {
|
||||
return `S${pad2(season)}E${pad2(episode)}`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Display helpers, ported from internal/ui/format.go and releases.go.
|
||||
|
||||
export function formatSize(b: number): string {
|
||||
const k = 1024;
|
||||
if (b >= k * k * k) return (b / (k * k * k)).toFixed(1) + " GB";
|
||||
if (b >= k * k) return Math.round(b / (k * k)) + " MB";
|
||||
if (b > 0) return Math.round(b / k) + " KB";
|
||||
return "–";
|
||||
}
|
||||
|
||||
export function formatReleaseAge(minutes: number): string {
|
||||
if (minutes < 60) return Math.trunc(minutes) + "m";
|
||||
if (minutes < 48 * 60) return Math.trunc(minutes / 60) + "h";
|
||||
return Math.trunc(minutes / 1440) + "d";
|
||||
}
|
||||
|
||||
export function formatSpeed(kbPerSec: number): string {
|
||||
if (kbPerSec >= 1024) return (kbPerSec / 1024).toFixed(1) + " MB/s";
|
||||
if (kbPerSec >= 1) return Math.round(kbPerSec) + " KB/s";
|
||||
return "0 KB/s";
|
||||
}
|
||||
|
||||
// formatAge renders a unix time relative to now ("5m ago", "3d ago").
|
||||
export function formatAge(unix: number, now = Date.now()): string {
|
||||
if (unix <= 0) return "";
|
||||
const s = now / 1000 - unix;
|
||||
if (s < 60) return "just now";
|
||||
if (s < 3600) return Math.trunc(s / 60) + "m ago";
|
||||
if (s < 48 * 3600) return Math.trunc(s / 3600) + "h ago";
|
||||
return Math.trunc(s / 86400) + "d ago";
|
||||
}
|
||||
|
||||
export function runtime(min: number): string {
|
||||
return `${Math.trunc(min / 60)}h${String(min % 60).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
export function plural(n: number, word: string): string {
|
||||
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
// isoDay is the local date as YYYY-MM-DD.
|
||||
export function isoDay(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setDate(out.getDate() + n);
|
||||
return out;
|
||||
}
|
||||
|
||||
// dayLabel: "Today", "Tomorrow", "Yesterday" or "Mon 26 Oct".
|
||||
export function dayLabel(day: string, today: string): string {
|
||||
const d = new Date(day + "T12:00:00");
|
||||
const t = new Date(today + "T12:00:00");
|
||||
const diff = Math.round((d.getTime() - t.getTime()) / 86400000);
|
||||
if (diff === 0) return "Today";
|
||||
if (diff === 1) return "Tomorrow";
|
||||
if (diff === -1) return "Yesterday";
|
||||
return d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
export function capitalize(s: string): string {
|
||||
return s ? s[0].toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// arr-tui for webOS: the shell with the views, the status of the services,
|
||||
// the remote's hints and its buttons.
|
||||
|
||||
import { app } from "./app.ts";
|
||||
import { applyTheme } from "./theme.ts";
|
||||
import { h, replace } from "./ui/dom.ts";
|
||||
import { current, focus, focusFirst, initFocus, Keys, pointerFocus } from "./ui/focus.ts";
|
||||
import { button, confirmDialog, toast } from "./ui/widgets.ts";
|
||||
import { CalendarView } from "./views/calendar.ts";
|
||||
import { DownloadsView, watchDownloads } from "./views/downloads.ts";
|
||||
import { LibraryView } from "./views/library.ts";
|
||||
import { LogView } from "./views/log.ts";
|
||||
import { SearchView } from "./views/search.ts";
|
||||
import { SettingsView } from "./views/settings.ts";
|
||||
import type { Hint, View } from "./views/view.ts";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
view: View;
|
||||
tab: HTMLButtonElement;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
applyTheme(app.cfg.ui.theme);
|
||||
const content = h("main.content");
|
||||
const hints = h("div.footer-hints");
|
||||
const dots = h("div.status-dots");
|
||||
const tabs = h("nav.tabs.fgroup");
|
||||
const entries: Entry[] = [];
|
||||
let cur: Entry | null = null;
|
||||
let switchTimer = 0;
|
||||
|
||||
const show = (e: Entry, focusView: boolean) => {
|
||||
if (cur === e) {
|
||||
if (focusView) focusFirst(e.view.el);
|
||||
return;
|
||||
}
|
||||
cur?.view.hide();
|
||||
cur?.tab.classList.remove("on");
|
||||
cur = e;
|
||||
e.tab.classList.add("on");
|
||||
replace(content, e.view.el);
|
||||
renderHints(e.view.hints);
|
||||
e.view.show();
|
||||
if (!focusView && current() !== e.tab) focus(e.tab);
|
||||
};
|
||||
|
||||
const views: [string, View][] = [
|
||||
["Movies", new SearchView("movie")],
|
||||
["Series", new SearchView("series")],
|
||||
["Library", new LibraryView()],
|
||||
["Downloads", new DownloadsView()],
|
||||
["Calendar", new CalendarView()],
|
||||
["Settings", new SettingsView()],
|
||||
["Log", new LogView()],
|
||||
];
|
||||
views.forEach(([name, view], i) => {
|
||||
const tab = button(`${i + 1} ${name}`, () => show(entry, true), "tab");
|
||||
const entry: Entry = { name, view, tab };
|
||||
// Moving along the tabs with the arrows switches views once the focus
|
||||
// rests there; the pointer only switches on a click.
|
||||
tab.addEventListener("focus", () => {
|
||||
if (pointerFocus) return;
|
||||
clearTimeout(switchTimer);
|
||||
switchTimer = window.setTimeout(() => {
|
||||
if (document.activeElement === tab) show(entry, false);
|
||||
}, 250);
|
||||
});
|
||||
entries.push(entry);
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
|
||||
function renderHints(list: Hint[]): void {
|
||||
replace(hints, ...list.map(([b, text]) => h("span.hint", h(`span.key.${b}` as "span", b === "ok" ? "OK" : b === "back" ? "Back" : ""), text)),
|
||||
h("span.hint", h("span.key.back", "Back"), "back"));
|
||||
}
|
||||
|
||||
function renderDots(): void {
|
||||
replace(dots, ...(["Radarr", "Sonarr", "SABnzbd", "Trakt"] as const).map((name) => {
|
||||
const configured = name === "Radarr" ? app.radarr.configured() : name === "Sonarr" ? app.sonarr.configured()
|
||||
: name === "SABnzbd" ? app.sab.configured() : app.trakt.configured();
|
||||
const err = app.status[name];
|
||||
return h(`span.status-dot.${!configured ? "off" : err ? "bad" : "ok"}` as "span", { title: err || name }, name);
|
||||
}));
|
||||
}
|
||||
app.onStatus.push(renderDots);
|
||||
app.onConfig.push(renderDots);
|
||||
renderDots();
|
||||
|
||||
const root = h("div.app",
|
||||
h("header.topbar", h("div.brand", "arr-tui"), tabs, dots),
|
||||
content,
|
||||
h("footer.footer", hints),
|
||||
);
|
||||
document.getElementById("root")!.appendChild(root);
|
||||
|
||||
// Back: from a view to its tab; on the tabs, ask before leaving the app.
|
||||
const back = () => {
|
||||
const el = current();
|
||||
if (cur && el && !tabs.contains(el)) {
|
||||
focus(cur.tab);
|
||||
return;
|
||||
}
|
||||
confirmDialog("Leave arr-tui?", "Leave", () => window.close());
|
||||
};
|
||||
|
||||
const keys = (ev: KeyboardEvent): boolean => {
|
||||
// 1-7 open a view.
|
||||
const n = ev.keyCode >= 49 && ev.keyCode <= 57 ? ev.keyCode - 49 : ev.keyCode >= 97 && ev.keyCode <= 105 ? ev.keyCode - 97 : -1;
|
||||
if (n >= 0 && n < entries.length) {
|
||||
show(entries[n], true);
|
||||
return true;
|
||||
}
|
||||
// Desktop browsers: F1-F4 stand in for the colour buttons.
|
||||
const code = ({ 112: Keys.red, 113: Keys.green, 114: Keys.yellow, 115: Keys.blue } as Record<number, number>)[ev.keyCode] ?? ev.keyCode;
|
||||
return !!cur?.view.keys?.(code);
|
||||
};
|
||||
initFocus(root, back, keys);
|
||||
|
||||
const first = app.radarr.configured() || app.sonarr.configured() || app.sab.configured() ? 0 : 5; // nothing set up: Settings
|
||||
show(entries[first], false);
|
||||
watchDownloads();
|
||||
if (first === 5) toast("Nothing is configured yet: enter the addresses and API keys of your services.", "warn");
|
||||
|
||||
// Check the services once at start, for the status dots.
|
||||
for (const [name, check] of [
|
||||
["Radarr", () => app.radarr.systemStatus()],
|
||||
["Sonarr", () => app.sonarr.systemStatus()],
|
||||
["SABnzbd", () => app.sab.version()],
|
||||
] as const) {
|
||||
const configured = name === "Radarr" ? app.radarr.configured() : name === "Sonarr" ? app.sonarr.configured() : app.sab.configured();
|
||||
if (configured) check().then(() => app.setStatus(name, null), (e) => app.setStatus(name, e));
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start);
|
||||
else start();
|
||||
@@ -0,0 +1,200 @@
|
||||
// File and release properties and the property filter, ported from
|
||||
// internal/arr/mediafile.go, internal/arr/release.go and internal/ui/libfilter.go.
|
||||
|
||||
import type { MediaFile, Release } from "./api/arr.ts";
|
||||
|
||||
export interface FileAttrs {
|
||||
resolution: number; // 2160, 1080, 720, 480 ... or 0
|
||||
codec: string; // x265, x264, AV1 or ""
|
||||
hdr: boolean;
|
||||
dv: boolean;
|
||||
audio: string[];
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
export const resOptions = ["2160p", "1080p", "720p", "SD"];
|
||||
export const codecOptions = ["x265", "x264", "AV1"];
|
||||
export const hdrOptions = ["HDR", "DV", "SDR"];
|
||||
export const audioOptions = ["Atmos", "TrueHD", "DTS:X", "DTS-HD", "DTS", "DD+", "DD", "AAC", "FLAC", "Opus"];
|
||||
|
||||
const reRes = /\b(2160|1080|720|576|480)p\b/i;
|
||||
const re265 = /(x\.?265|h\.?265|hevc)/i;
|
||||
const re264 = /(x\.?264|h\.?264|\bavc\b)/i;
|
||||
const reAV1 = /\bav1\b/i;
|
||||
const reDV = /(\bdv\b|dovi|dolby[ .]?vision)/i;
|
||||
const reAtmos = /(atmos|\bddpa\b)/i;
|
||||
const reTrueHD = /true[ .-]?hd/i;
|
||||
const reDTSX = /\bdts[ .:-]?x\b/i;
|
||||
const reDTSHD = /dts[ .-]?(hd|ma|hra|es)/i;
|
||||
const reDTS = /\bdts([^a-z]|$)/i;
|
||||
const reDDPlus = /(e[ .-]?ac[ .-]?3|\bddp|dd\+|dolby digital plus)/i;
|
||||
const reDD = /(\bac[ .-]?3\b|\bdd[ .]?[0-9]|dolby digital\b)/i;
|
||||
const reAAC = /\baac/i;
|
||||
const reFLAC = /\bflac\b/i;
|
||||
const reOpus = /\bopus\b/i;
|
||||
const reHDR = /\b(hdr(10(\+|plus)?)?|hlg|pq)\b/i;
|
||||
|
||||
export function codecOf(s: string): string {
|
||||
if (re265.test(s)) return "x265";
|
||||
if (re264.test(s)) return "x264";
|
||||
if (reAV1.test(s)) return "AV1";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function audioTags(s: string): string[] {
|
||||
const tags: string[] = [];
|
||||
if (reAtmos.test(s)) tags.push("Atmos");
|
||||
if (reTrueHD.test(s)) tags.push("TrueHD");
|
||||
const dtsx = reDTSX.test(s);
|
||||
const dtshd = reDTSHD.test(s);
|
||||
if (dtsx) tags.push("DTS:X");
|
||||
if (dtshd) tags.push("DTS-HD");
|
||||
if (dtsx || dtshd || reDTS.test(s)) tags.push("DTS");
|
||||
if (reDDPlus.test(s)) tags.push("DD+");
|
||||
else if (reDD.test(s)) tags.push("DD");
|
||||
if (reAAC.test(s)) tags.push("AAC");
|
||||
if (reFLAC.test(s)) tags.push("FLAC");
|
||||
if (reOpus.test(s)) tags.push("Opus");
|
||||
return tags;
|
||||
}
|
||||
|
||||
function resolutionIn(text: string): number {
|
||||
const m = reRes.exec(text);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
}
|
||||
|
||||
// fileAttrs uses Radarr/Sonarr's media info where present, else the names.
|
||||
export function fileAttrs(f: MediaFile): FileAttrs {
|
||||
const q = f.quality?.quality;
|
||||
const names = [f.sceneName ?? "", f.relativePath ?? "", q?.name ?? ""].join(" ");
|
||||
const mi = f.mediaInfo ?? {};
|
||||
let dr = `${mi.videoDynamicRangeType ?? ""} ${mi.videoDynamicRange ?? ""}`;
|
||||
if (!dr.trim()) dr = names;
|
||||
let audio = `${mi.audioCodec ?? ""} ${mi.audioAdditionalFeatures ?? ""}`;
|
||||
if (!audio.trim()) audio = names;
|
||||
return {
|
||||
resolution: q?.resolution || resolutionIn(names),
|
||||
codec: mi.videoCodec ? codecOf(mi.videoCodec) : codecOf(names),
|
||||
dv: reDV.test(dr),
|
||||
hdr: reHDR.test(dr),
|
||||
audio: audioTags(audio),
|
||||
languages: (f.languages ?? []).map((l) => l.name).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// releaseAttrs reads what a release announces: title, quality, custom formats.
|
||||
export function releaseAttrs(r: Release): FileAttrs {
|
||||
const q = r.quality?.quality;
|
||||
const text = [r.title, q?.name ?? "", ...(r.customFormats ?? []).map((c) => c.name)].join(" ");
|
||||
return {
|
||||
resolution: q?.resolution || resolutionIn(text),
|
||||
codec: codecOf(text),
|
||||
dv: reDV.test(text),
|
||||
hdr: reHDR.test(text),
|
||||
audio: audioTags(text),
|
||||
languages: (r.languages ?? []).map((l) => l.name).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
// AttrFilter: "" means any. All fields must match the same file.
|
||||
export interface AttrFilter {
|
||||
res: string;
|
||||
lang: string;
|
||||
codec: string;
|
||||
hdr: string;
|
||||
audio: string;
|
||||
}
|
||||
|
||||
export const noFilter = (): AttrFilter => ({ res: "", lang: "", codec: "", hdr: "", audio: "" });
|
||||
|
||||
export function filterActive(f: AttrFilter): boolean {
|
||||
return !!(f.res || f.lang || f.codec || f.hdr || f.audio);
|
||||
}
|
||||
|
||||
export function filterLabel(f: AttrFilter): string {
|
||||
return [f.res, f.lang, f.codec, f.hdr, f.audio].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function matchFile(f: AttrFilter, a: FileAttrs): boolean {
|
||||
if (f.res === "SD") {
|
||||
if (a.resolution <= 0 || a.resolution > 576) return false;
|
||||
} else if (f.res && f.res !== a.resolution + "p") return false;
|
||||
if (f.codec && f.codec !== a.codec) return false;
|
||||
if (f.hdr === "HDR" && !a.hdr) return false;
|
||||
if (f.hdr === "DV" && !a.dv) return false;
|
||||
if (f.hdr === "SDR" && (a.hdr || a.dv)) return false;
|
||||
if (f.audio && !a.audio.includes(f.audio)) return false;
|
||||
if (f.lang && !a.languages.some((l) => l.toLowerCase() === f.lang.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// matchAny: any of an item's files (a series has several). None: no match.
|
||||
export function matchAny(f: AttrFilter, files: FileAttrs[]): boolean {
|
||||
return files.some((a) => matchFile(f, a));
|
||||
}
|
||||
|
||||
// Pool is what the filter dialog works on: the files of every item that
|
||||
// passes the other filters. unknown: some are not read yet, nothing can be
|
||||
// ruled out.
|
||||
export interface Pool {
|
||||
files: FileAttrs[][];
|
||||
unknown: boolean;
|
||||
}
|
||||
|
||||
export function poolHas(p: Pool, f: AttrFilter): boolean {
|
||||
return p.unknown || p.files.some((files) => matchAny(f, files));
|
||||
}
|
||||
|
||||
export function poolLanguages(p: Pool): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const files of p.files) for (const a of files) for (const l of a.languages) seen.add(l);
|
||||
return [...seen].sort();
|
||||
}
|
||||
|
||||
export interface Facet {
|
||||
label: string;
|
||||
key: keyof AttrFilter;
|
||||
candidates: (p: Pool) => string[];
|
||||
}
|
||||
|
||||
// Most used first.
|
||||
export const facets: Facet[] = [
|
||||
{ label: "Quality", key: "res", candidates: () => resOptions },
|
||||
{ label: "Language", key: "lang", candidates: poolLanguages },
|
||||
{ label: "Codec", key: "codec", candidates: () => codecOptions },
|
||||
{ label: "HDR", key: "hdr", candidates: () => hdrOptions },
|
||||
{ label: "Audio", key: "audio", candidates: () => audioOptions },
|
||||
];
|
||||
|
||||
// listedValues: the options a facet offers, those that together with the
|
||||
// other choices still leave a result, plus the current choice; "" first (any).
|
||||
export function listedValues(fc: Facet, state: AttrFilter, pool: Pool): string[] {
|
||||
const cur = state[fc.key];
|
||||
let cands = fc.candidates(pool);
|
||||
if (cur && !cands.includes(cur)) cands = [cur, ...cands];
|
||||
const vals = [""];
|
||||
for (const o of cands) {
|
||||
if (o === cur || poolHas(pool, { ...state, [fc.key]: o })) vals.push(o);
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
|
||||
// --- Releases ---
|
||||
|
||||
export type SortMode = "best" | "size" | "age";
|
||||
|
||||
export function isRejected(r: Release): boolean {
|
||||
return r.rejected || (r.rejections?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function visibleReleases(all: Release[], hideRejected: boolean, mode: SortMode, af: AttrFilter): Release[] {
|
||||
const out = all.filter((r) => !(hideRejected && isRejected(r)) && (!filterActive(af) || matchFile(af, releaseAttrs(r))));
|
||||
const cmp = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
// Array.prototype.sort is stable in Chrome 70+; older TVs may reorder ties.
|
||||
return out.sort((a, b) => {
|
||||
if (mode === "size") return cmp(b.size, a.size);
|
||||
if (mode === "age") return cmp(a.ageMinutes, b.ageMinutes);
|
||||
return cmp(+isRejected(a), +isRejected(b)) || cmp(b.customFormatScore, a.customFormatScore) ||
|
||||
cmp(b.qualityWeight, a.qualityWeight) || cmp(a.ageMinutes, b.ageMinutes);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Seasons of a series: counts, labels and episode states.
|
||||
|
||||
import type { Episode } from "./api/sonarr.ts";
|
||||
|
||||
export interface SeasonSummary {
|
||||
number: number;
|
||||
have: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// summarizeSeasons counts the episodes per season, newest season first,
|
||||
// specials last.
|
||||
export function summarizeSeasons(eps: Episode[]): SeasonSummary[] {
|
||||
const by = new Map<number, SeasonSummary>();
|
||||
for (const e of eps) {
|
||||
const s = by.get(e.seasonNumber) ?? { number: e.seasonNumber, have: 0, total: 0 };
|
||||
s.total++;
|
||||
if (e.hasFile) s.have++;
|
||||
by.set(e.seasonNumber, s);
|
||||
}
|
||||
return [...by.values()].sort((a, b) => (a.number === 0 ? 1 : b.number === 0 ? -1 : b.number - a.number));
|
||||
}
|
||||
|
||||
export function seasonLabel(n: number): string {
|
||||
return n === 0 ? "Specials" : "Season " + n;
|
||||
}
|
||||
|
||||
export function episodeStatus(e: Episode, today: string): [string, string] {
|
||||
if (e.hasFile) return ["✓ downloaded", "ok"];
|
||||
if (!e.airDate || e.airDate > today) return ["unaired", "muted"];
|
||||
if (!e.monitored) return ["unmonitored", "muted"];
|
||||
return ["missing", "warn"];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Dark and light themes (CSS variables in style.css). "auto" follows the
|
||||
// system's preference where the platform reports one, else dark.
|
||||
|
||||
export function applyTheme(setting: string | undefined): void {
|
||||
let theme = setting === "light" || setting === "dark" ? setting : "dark";
|
||||
if (!setting || setting === "auto") {
|
||||
theme = window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// The details pane: poster, title, facts, state, plot and the cast.
|
||||
|
||||
import { app, people } from "../app.ts";
|
||||
import { refKey, type People, type Ref } from "../api/trakt.ts";
|
||||
import { h, replace } from "./dom.ts";
|
||||
|
||||
export interface Line {
|
||||
text: string;
|
||||
cls?: string; // ok, warn, muted, dl (downloading), bold
|
||||
}
|
||||
|
||||
export interface Detail {
|
||||
title: string;
|
||||
sub?: string; // a second heading line (an episode)
|
||||
facts: string[];
|
||||
lines: Line[];
|
||||
plot: string;
|
||||
poster: string; // URL, "" for none
|
||||
ref?: Ref; // for the cast
|
||||
}
|
||||
|
||||
export class DetailPane {
|
||||
el: HTMLElement;
|
||||
private poster: HTMLImageElement;
|
||||
private text: HTMLElement;
|
||||
private cast: HTMLElement;
|
||||
private castKey = "";
|
||||
private settle = 0;
|
||||
private posterSettle = 0;
|
||||
|
||||
constructor() {
|
||||
this.poster = h("img.detail-poster", { alt: "" });
|
||||
this.poster.addEventListener("error", () => this.poster.classList.add("none"));
|
||||
this.poster.addEventListener("load", () => this.poster.classList.remove("loading"));
|
||||
this.text = h("div.detail-text");
|
||||
this.cast = h("div.detail-cast");
|
||||
this.el = h("div.detail", this.poster, this.text, this.cast);
|
||||
}
|
||||
|
||||
show(d: Detail | null): void {
|
||||
if (!d) {
|
||||
replace(this.text);
|
||||
replace(this.cast);
|
||||
this.poster.classList.add("none");
|
||||
this.castKey = "";
|
||||
return;
|
||||
}
|
||||
replace(this.text,
|
||||
h("h2.detail-title", d.title),
|
||||
d.sub ? h("div.detail-sub", d.sub) : null,
|
||||
d.facts.length ? h("div.detail-facts", d.facts.join(" · ")) : null,
|
||||
...d.lines.map((l) => h(`div.detail-line${l.cls ? "." + l.cls : ""}` as "div", l.text)),
|
||||
d.plot ? h("p.detail-plot", d.plot) : null,
|
||||
);
|
||||
this.showPoster(app.covers() ? d.poster : "");
|
||||
this.showCast(d.ref);
|
||||
}
|
||||
|
||||
// showPoster waits until the selection rests (a held arrow key must not
|
||||
// start a download per step).
|
||||
private showPoster(url: string): void {
|
||||
clearTimeout(this.posterSettle);
|
||||
if (!url) {
|
||||
this.poster.classList.add("none");
|
||||
this.poster.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
if (this.poster.getAttribute("src") === url) return;
|
||||
this.poster.classList.remove("none");
|
||||
this.poster.classList.add("loading");
|
||||
this.posterSettle = window.setTimeout(() => {
|
||||
this.poster.src = url;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
private showCast(ref: Ref | undefined): void {
|
||||
const key = ref ? refKey(ref) : "";
|
||||
if (key === this.castKey) return;
|
||||
this.castKey = key;
|
||||
replace(this.cast);
|
||||
clearTimeout(this.settle);
|
||||
if (!ref || !key || !app.trakt.configured()) return;
|
||||
this.settle = window.setTimeout(() => {
|
||||
people.load(ref).then((p) => {
|
||||
if (this.castKey === key) replace(this.cast, ...castLines(p, ref.kind));
|
||||
}, () => {
|
||||
/* no cast: nothing to show */
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function castLines(p: People, kind: "movie" | "show"): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
if (p.leaders.length) {
|
||||
out.push(h("div.cast-head", kind === "show" ? "Created by" : "Director"), h("div.cast-names", p.leaders.map((l) => l.name).join(", ")));
|
||||
}
|
||||
if (p.cast.length) {
|
||||
out.push(h("div.cast-head", "Cast"));
|
||||
for (const c of p.cast.slice(0, 8)) {
|
||||
out.push(h("div.cast-person", h("span", c.name), c.role ? h("span.muted", " as " + c.role) : null));
|
||||
}
|
||||
if (p.cast.length > 8) out.push(h("div.muted", `and ${p.cast.length - 8} more`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Tiny DOM helpers: h("div.card", {onclick}, child, "text").
|
||||
|
||||
type Child = Node | string | number | null | undefined | false;
|
||||
type Attrs = Record<string, unknown>;
|
||||
|
||||
export function h<K extends keyof HTMLElementTagNameMap>(spec: K | `${K}.${string}` | `${K}#${string}`, attrs?: Attrs | Child, ...children: Child[]): HTMLElementTagNameMap[K] {
|
||||
const [tagAndId, ...classes] = spec.split(".");
|
||||
const [tag, id] = tagAndId.split("#");
|
||||
const el = document.createElement(tag) as HTMLElementTagNameMap[K];
|
||||
if (id) el.id = id;
|
||||
if (classes.length) el.className = classes.join(" ");
|
||||
if (attrs && typeof attrs === "object" && !(attrs instanceof Node)) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v as EventListener);
|
||||
else if (k === "class") el.className += (el.className ? " " : "") + v;
|
||||
else if (k === "style") el.setAttribute("style", String(v));
|
||||
else if (k in el && typeof v !== "string") (el as unknown as Record<string, unknown>)[k] = v;
|
||||
else el.setAttribute(k, v === true ? "" : String(v));
|
||||
}
|
||||
} else if (attrs !== undefined) {
|
||||
children.unshift(attrs as Child);
|
||||
}
|
||||
append(el, ...children);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function append(el: Element, ...children: Child[]): void {
|
||||
for (const c of children) {
|
||||
if (c === null || c === undefined || c === false) continue;
|
||||
el.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(el: Element): void {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
export function replace(el: Element, ...children: Child[]): void {
|
||||
clear(el);
|
||||
append(el, ...children);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Remote-control focus: the arrow keys move between focusable elements by
|
||||
// their position on screen (spatial navigation), OK presses, Back goes back.
|
||||
//
|
||||
// Focusable elements carry the class "f". Dialogs open a layer: navigation
|
||||
// stays inside the topmost layer, and closing it returns the focus to where
|
||||
// it was. Elements can handle keys themselves (a list moves its selection
|
||||
// with up and down) by setting onKey; returning true stops the navigation.
|
||||
// A group (class "fgroup") remembers its last focused element, so moving back
|
||||
// into a group continues where one left it.
|
||||
//
|
||||
// Text fields are read-only while the focus merely passes over them: webOS
|
||||
// opens its on-screen keyboard for every editable field that gets focus. OK
|
||||
// (or a pointer click) starts editing and so opens the keyboard; Enter, Back
|
||||
// or moving up or down ends it.
|
||||
|
||||
export const Keys = {
|
||||
left: 37,
|
||||
up: 38,
|
||||
right: 39,
|
||||
down: 40,
|
||||
enter: 13,
|
||||
back: 461, // webOS remote
|
||||
escape: 27,
|
||||
backspace: 8,
|
||||
red: 403,
|
||||
green: 404,
|
||||
yellow: 405,
|
||||
blue: 406,
|
||||
pageUp: 33, // also channel up on the remote
|
||||
pageDown: 34,
|
||||
} as const;
|
||||
|
||||
export type Dir = "left" | "right" | "up" | "down";
|
||||
|
||||
export interface KeyHandling {
|
||||
onKey?: (ev: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
root: HTMLElement;
|
||||
back: () => void;
|
||||
restore: HTMLElement | null; // focused when the layer opened
|
||||
}
|
||||
|
||||
const layers: Layer[] = [];
|
||||
|
||||
// pointerFocus tells whether the last focus came from the Magic Remote's
|
||||
// pointer hovering (not from the arrow keys).
|
||||
export let pointerFocus = false;
|
||||
let globalKeys: (ev: KeyboardEvent) => boolean = () => false;
|
||||
|
||||
export function initFocus(root: HTMLElement, back: () => void, keys: (ev: KeyboardEvent) => boolean): void {
|
||||
layers.length = 0;
|
||||
layers.push({ root, back, restore: null });
|
||||
globalKeys = keys;
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
document.addEventListener("click", (ev) => {
|
||||
const el = ev.target as Element | null;
|
||||
if (isTextInput(el) && el.classList.contains("f") && el.readOnly) startEditing(el);
|
||||
});
|
||||
document.addEventListener("focusout", (ev) => {
|
||||
if (isTextInput(ev.target as Element)) (ev.target as HTMLInputElement).readOnly = true;
|
||||
});
|
||||
// The Magic Remote's pointer: hovering focuses, like moving there.
|
||||
document.addEventListener("mouseover", (ev) => {
|
||||
const el = (ev.target as Element | null)?.closest?.(".f") as HTMLElement | null;
|
||||
if (el && inLayer(el) && el !== document.activeElement && !editing(document.activeElement)) {
|
||||
pointerFocus = true;
|
||||
focus(el, false);
|
||||
pointerFocus = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function pushLayer(root: HTMLElement, back: () => void): void {
|
||||
layers.push({ root, back, restore: current() });
|
||||
}
|
||||
|
||||
// popLayer closes the topmost layer (root must be it) and refocuses.
|
||||
export function popLayer(root: HTMLElement): void {
|
||||
const i = layers.findIndex((l) => l.root === root);
|
||||
if (i <= 0) return;
|
||||
const [layer] = layers.splice(i, 1);
|
||||
if (i === layers.length) {
|
||||
const back = layer.restore;
|
||||
if (back && back.isConnected && visible(back)) focus(back);
|
||||
else focusFirst(top().root);
|
||||
}
|
||||
}
|
||||
|
||||
export function top(): Layer {
|
||||
return layers[layers.length - 1];
|
||||
}
|
||||
|
||||
export function current(): HTMLElement | null {
|
||||
const a = document.activeElement as HTMLElement | null;
|
||||
return a && a !== document.body && a.classList?.contains("f") ? a : null;
|
||||
}
|
||||
|
||||
export function focus(el: HTMLElement, scroll = true): void {
|
||||
if (isTextInput(el)) el.readOnly = true; // no keyboard until OK
|
||||
else if (!el.hasAttribute("tabindex")) el.tabIndex = -1;
|
||||
el.focus({ preventScroll: true });
|
||||
if (scroll) el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
const g = el.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
|
||||
if (g) g.lastFocus = el;
|
||||
}
|
||||
|
||||
// focusFirst focuses the first focusable element in root (or a remembered
|
||||
// one of its group).
|
||||
export function focusFirst(root: HTMLElement): boolean {
|
||||
const g = root as HTMLElement & { lastFocus?: HTMLElement };
|
||||
if (g.lastFocus && g.lastFocus.isConnected && visible(g.lastFocus) && root.contains(g.lastFocus)) {
|
||||
focus(g.lastFocus);
|
||||
return true;
|
||||
}
|
||||
const el = focusables(root)[0];
|
||||
if (el) focus(el);
|
||||
return !!el;
|
||||
}
|
||||
|
||||
export function focusables(root: Element): HTMLElement[] {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(".f")).filter(visible);
|
||||
}
|
||||
|
||||
function visible(el: HTMLElement): boolean {
|
||||
if (el.classList.contains("f-off") || (el as HTMLButtonElement).disabled) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
}
|
||||
|
||||
function inLayer(el: Element): boolean {
|
||||
return top().root.contains(el);
|
||||
}
|
||||
|
||||
export function isTextInput(el: Element | null): el is HTMLInputElement {
|
||||
return !!el && el.tagName === "INPUT" && ["text", "search", "password", "url", ""].includes((el as HTMLInputElement).type);
|
||||
}
|
||||
|
||||
// editing: a text field that takes typing (its keyboard is open).
|
||||
function editing(el: Element | null): el is HTMLInputElement {
|
||||
return isTextInput(el) && !el.readOnly;
|
||||
}
|
||||
|
||||
// startEditing makes the field editable and refocuses it, which opens the
|
||||
// on-screen keyboard, with the cursor at the end.
|
||||
function startEditing(input: HTMLInputElement): void {
|
||||
input.blur(); // (leaving makes it read-only: before it becomes editable)
|
||||
input.readOnly = false;
|
||||
input.focus({ preventScroll: true });
|
||||
const end = input.value.length;
|
||||
try {
|
||||
input.setSelectionRange(end, end);
|
||||
} catch {
|
||||
/* some input types have no selection */
|
||||
}
|
||||
}
|
||||
|
||||
function stopEditing(input: HTMLInputElement): void {
|
||||
input.readOnly = true;
|
||||
}
|
||||
|
||||
export function isBackKey(ev: KeyboardEvent): boolean {
|
||||
if (ev.keyCode === Keys.back || ev.keyCode === Keys.escape) return true;
|
||||
return ev.keyCode === Keys.backspace && !editing(document.activeElement);
|
||||
}
|
||||
|
||||
function onKeyDown(ev: KeyboardEvent): void {
|
||||
const el = current();
|
||||
if (el && inLayer(el) && (el as KeyHandling).onKey?.(ev)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
const typing = editing(document.activeElement) ? (document.activeElement as HTMLInputElement) : null;
|
||||
if (typing && (ev.keyCode === Keys.enter || isBackKey(ev))) {
|
||||
// Done typing: the field stays focused (Enter still reaches the view,
|
||||
// e.g. the search box moves on to the results).
|
||||
stopEditing(typing);
|
||||
if (ev.keyCode !== Keys.enter) ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!typing && ev.keyCode === Keys.enter && isTextInput(el)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
startEditing(el);
|
||||
return;
|
||||
}
|
||||
if (isBackKey(ev)) {
|
||||
ev.preventDefault();
|
||||
top().back();
|
||||
return;
|
||||
}
|
||||
const dir = ({ 37: "left", 38: "up", 39: "right", 40: "down" } as Record<number, Dir>)[ev.keyCode];
|
||||
if (dir) {
|
||||
if (typing && (dir === "left" || dir === "right") && caretInside(typing, dir)) return;
|
||||
if (typing) stopEditing(typing);
|
||||
ev.preventDefault();
|
||||
if (!el || !inLayer(el)) {
|
||||
focusFirst(top().root);
|
||||
return;
|
||||
}
|
||||
const next = neighbour(el, dir, focusables(top().root));
|
||||
if (next) focus(enterGroup(el, next));
|
||||
return;
|
||||
}
|
||||
if (ev.keyCode === Keys.enter && el && !isTextInput(el)) {
|
||||
ev.preventDefault();
|
||||
el.click();
|
||||
return;
|
||||
}
|
||||
// View keys (numbers, colour buttons) only while no dialog or page is open.
|
||||
if (layers.length === 1 && !typing && globalKeys(ev)) ev.preventDefault();
|
||||
}
|
||||
|
||||
function caretInside(input: HTMLInputElement, dir: Dir): boolean {
|
||||
const pos = input.selectionStart ?? 0;
|
||||
return dir === "left" ? pos > 0 : pos < input.value.length;
|
||||
}
|
||||
|
||||
// enterGroup: moving into another group goes to the element remembered there.
|
||||
function enterGroup(from: HTMLElement, to: HTMLElement): HTMLElement {
|
||||
const g = to.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
|
||||
if (!g || g.contains(from)) return to;
|
||||
const last = g.lastFocus;
|
||||
return last && last.isConnected && visible(last) && g.contains(last) ? last : to;
|
||||
}
|
||||
|
||||
// neighbour finds the element to move to: the nearest one in the direction,
|
||||
// preferring those that overlap the current one across the direction (the
|
||||
// card right below, not the one diagonally closer).
|
||||
export function neighbour(from: HTMLElement, dir: Dir, candidates: HTMLElement[]): HTMLElement | null {
|
||||
const c = from.getBoundingClientRect();
|
||||
let best: HTMLElement | null = null;
|
||||
let bestScore = Infinity;
|
||||
for (const el of candidates) {
|
||||
if (el === from || el.contains(from) || from.contains(el)) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
const slack = 4;
|
||||
let gap: number, cross: number;
|
||||
switch (dir) {
|
||||
case "right":
|
||||
if (r.left < c.right - slack) continue;
|
||||
gap = r.left - c.right;
|
||||
cross = intervalGap(c.top, c.bottom, r.top, r.bottom);
|
||||
break;
|
||||
case "left":
|
||||
if (r.right > c.left + slack) continue;
|
||||
gap = c.left - r.right;
|
||||
cross = intervalGap(c.top, c.bottom, r.top, r.bottom);
|
||||
break;
|
||||
case "down":
|
||||
if (r.top < c.bottom - slack) continue;
|
||||
gap = r.top - c.bottom;
|
||||
cross = intervalGap(c.left, c.right, r.left, r.right);
|
||||
break;
|
||||
case "up":
|
||||
if (r.bottom > c.top + slack) continue;
|
||||
gap = c.top - r.bottom;
|
||||
cross = intervalGap(c.left, c.right, r.left, r.right);
|
||||
break;
|
||||
}
|
||||
// Among overlapping ones, the one whose start is nearest wins (a list
|
||||
// of columns under a wide bar: the left-most below it, not a random one).
|
||||
const align = dir === "up" || dir === "down" ? Math.abs(r.left - c.left) : Math.abs(r.top - c.top);
|
||||
const score = Math.max(gap, 0) + (cross > 0 ? 10000 + cross * 3 : align * 0.01);
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = el;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function intervalGap(a1: number, a2: number, b1: number, b2: number): number {
|
||||
if (b2 <= a1) return a1 - b2;
|
||||
if (b1 >= a2) return b1 - a2;
|
||||
return 0;
|
||||
}
|
||||