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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 16:55:19 +02:00
113 changed files with 15738 additions and 123 deletions
+2
View File
@@ -7,3 +7,5 @@ config.json
/dev/data/
*.swp
*.swo
__pycache__/
/dev/screenshots/posters/
+58
View File
@@ -1,5 +1,63 @@
# 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:
+1 -1
View File
@@ -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:
+50 -2
View File
@@ -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).
![The library: movies and series with their status, and the details of the selected title with its poster](docs/screenshots/library.png)
- **Movies / Series**: search as you type, add titles with a quality profile and root folder,
optionally start the automatic search.
- **Trending, most watched, anticipated**: while the search box is empty, Movies and Series show
lists from [Trakt](https://trakt.tv): trending now, most watched this week, anticipated, popular,
and the US box office for movies. `[` and `]` switch lists; titles already in your library are
ticked, the others can be added right there (Enter). Needs a free Trakt client ID (see Configure).
- **Library**: movies and series with their status; the dot before a title turns blue while it
is downloading, and the details say how far. In a series' seasons and episodes too.
- **Cache**: posters (30 days, at most 200 MB), casts (7 days) and the Radarr/Sonarr lookups of
Trakt list titles (3 days) are kept between runs in
`~/.cache/arr-tui` (`$XDG_CACHE_HOME`; `~/Library/Caches/arr-tui` on macOS), so they show at
once after a restart. Deleting the folder is always safe.
- **Season posters**: in a series' seasons and episodes, the poster follows the selected season.
- **Cast**: with a Trakt client ID, the details pane also lists the director (or the creators of a
series) and the cast with their roles, below the plot, in every view.
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
- **Posters**: the detail panes show the poster at the top right; title, facts, path and plot
@@ -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.
| | |
|---|---|
| ![Search as you type, with the details and poster of the selected result](docs/screenshots/search.png) | ![Interactive search: the releases the indexers found, approved and rejected, sortable and filterable](docs/screenshots/releases.png) |
| **Movies / Series**: search as you type | **Releases**: pick one by hand |
| ![A series' seasons and episodes, with the poster and the selected season's details](docs/screenshots/episodes.png) | ![Downloads: the SABnzbd queue, the Radarr/Sonarr queues with problems, and the history](docs/screenshots/downloads.png) |
| **Seasons and episodes** of a series | **Downloads** from SABnzbd, Radarr and Sonarr |
| ![Calendar: the next two weeks of releases and episodes, grouped by day](docs/screenshots/calendar.png) | ![Config view: every setting in one list, with defaults loaded from the servers](docs/screenshots/config.png) |
| **Calendar** of the next two weeks | **Config** inside the program |
| ![Movies view with an empty search: the Trakt list of anticipated movies, with how many lists each is on](docs/screenshots/discover.png) | ![The library in the light theme](docs/screenshots/library-light.png) |
| **Trending, most watched, anticipated** from Trakt | **Light theme** |
## Install
```sh
@@ -73,6 +104,12 @@ stays grey. You can also write the file yourself, starting from `config.example.
`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
@@ -83,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 |
@@ -93,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
+11 -6
View File
@@ -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` (250×375; `poster-500.jpg` when posters are real images, which otherwise enlarge the small copy) from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
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. 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.
- **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) |
+3
View File
@@ -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,
+5
View File
@@ -50,3 +50,8 @@ dev/add-indexer.sh # registers it in the dev Radarr and Sonarr
Add the movie / series to the dev instances first (e.g. from the TUI), then press `i` on it.
To test with a real indexer, add it in the dev Radarr/Sonarr web UI (Settings, Indexers) instead of pasting
credentials anywhere else.
## Screenshots
`dev/screenshots/take.sh` makes the README screenshots from a fake, made-up library; see
[screenshots/README.md](screenshots/README.md).
+27
View File
@@ -0,0 +1,27 @@
# README screenshots
`take.sh` makes the pictures in `docs/screenshots/`: it builds arr-tui, starts a fake Radarr,
Sonarr and SABnzbd with a made-up library, and runs arr-tui in Ghostty on a virtual X display,
following the scene scripts. Nothing real is shown: invented titles, generated posters, dummy API
keys (masked in the Config view anyway).
```sh
dev/screenshots/take.sh
# with Nix, everything from the flake's dev shell:
nix develop --command dev/screenshots/take.sh
```
Needs Go, Python 3, Ghostty, `xvfb-run` (Xvfb), `xdotool`, ImageMagick 7 and fontconfig with the
DejaVu fonts; `oxipng` (optional) compresses the PNGs losslessly. It takes about a minute.
| File | What it is |
|---|---|
| `catalog.py` | The made-up library: movies (with file details) and series (with seasons). |
| `posters.sh` | Draws a poster per title into `posters/` (gradient, title, year); run by `take.sh` when `posters/` is missing. |
| `server.py` | The fake services on 127.0.0.1: Radarr 19891, Sonarr 19892, SABnzbd 19893, Trakt 19894 (`take.sh` points arr-tui there with `ARR_TUI_TRAKT_URL`). Library, search, lists, releases, queues, history, calendar, posters. Like the real services it answers cross-origin requests, and it accepts writes without changing anything, for the webOS app (`webos/`, `npm run demo`). |
| `xshot.sh` | Runs a command in Ghostty under Xvfb and follows a scene script. |
| `scenes/*.txt` | What to press and when to take a picture: `key`, `type`, `sleep`, `shot <name>`. |
To change a picture, edit its scene (the key presses depend on the order of `catalog.py`, e.g.
"The Long Orbit" is the 13th movie alphabetically) and run `take.sh` again. Some views show today's
date (Calendar, Downloads), so every run changes those pictures a little.
+48
View File
@@ -0,0 +1,48 @@
# Fictional library for the README screenshots: invented titles, generated posters.
MOVIES = [
# id, title, year, genres, studio, runtime, rating, file (res, codec, dr, audio, lang, size GB) or None, monitored, overview, colors
(1,"The Long Orbit",2023,["Science Fiction","Drama"],"Halcyon Pictures",142,8.1,(2160,"x265","DV HDR10","TrueHD","Atmos","English",58.2),True,
"When a deep-space freighter loses contact with Earth, its last remaining engineer must decide whether to keep the ship on course for a colony that may no longer exist, or turn back on a journey that will take the rest of her life.",("#0b132b","#5bc0be")),
(2,"Paper Lanterns",2019,["Drama","Romance"],"Northlight",118,7.4,(1080,"x264","","DTS","","English",12.4),True,
"Two strangers meet every year at the same river festival, and over a decade of single nights their lives drift together and apart.",("#3d0c02","#f4a259")),
(3,"Iron Meridian",2021,["Action","Thriller"],"Blackstone",131,6.9,(2160,"x265","HDR10","EAC3","Atmos","English",41.7),True,
"A former border guard is pulled back into service when a smuggling route through the mountains starts moving something far more dangerous than contraband.",("#1b1b1b","#c1121f")),
(4,"The Quiet Archive",2024,["Mystery"],"Fernwood",109,7.8,None,True,
"A librarian cataloguing a dead author's papers finds letters addressed to people who were never born.",("#2b2d42","#8d99ae")),
(5,"Saltwater Kings",2017,["Adventure","Comedy"],"Gull Island",101,6.6,(1080,"x265","","AAC","","English",4.9),True,
"Three washed-up fishermen bet their boats on one last race around the cape.",("#023e8a","#90e0ef")),
(6,"Glass Mountain",2022,["Science Fiction","Thriller"],"Halcyon Pictures",127,7.2,(2160,"x265","DV","TrueHD","Atmos","English",49.0),True,
"An avalanche researcher discovers that the glacier she studies is moving against the slope.",("#03045e","#caf0f8")),
(7,"Midnight at the Odeon",2015,["Comedy","Crime"],"Starling",96,7.0,(1080,"x264","","DTS","","English",9.1),True,
"A cinema projectionist and a pickpocket team up for one heist during the last screening before the theatre closes for good.",("#370617","#f48c06")),
(8,"Borrowed Summers",2020,["Drama"],"Northlight",112,7.6,(1080,"x265","HDR10","EAC3","","French",8.3),True,
"A grandmother teaches her grandson to sail on the lake where she once lost everything.",("#606c38","#fefae0")),
(9,"The Cartographer's Daughter",2025,["Fantasy","Adventure"],"Fernwood",138,0,None,True,
"The daughter of a map-maker sets out to chart the island that her father swore was real.",("#283618","#dda15e")),
(10,"Static",2018,["Horror"],"Blackstone",94,6.1,(1080,"x264","","AC3","","English",6.0),False,
"A late-night radio host starts receiving calls from listeners who say the broadcast has already ended.",("#000000","#6c757d")),
(11,"Neon Harbour",2023,["Crime","Thriller"],"Starling",121,7.3,(2160,"x265","HDR10+","DTS-HD MA","","English",44.5),True,
"A detective in a flooded port city follows a trail of stolen streetlights to a conspiracy at city hall.",("#10002b","#e0aaff")),
(12,"Wild Clover",2016,["Family","Animation"],"Gull Island",88,7.9,(1080,"x265","","AAC","","German",3.2),True,
"A rabbit who can't stop counting sets out to find the last four-leaf clover in the valley.",("#2d6a4f","#b7e4c7")),
(13,"Under the Same Sky",2024,["Drama","War"],"Northlight",146,8.3,(2160,"x265","DV HDR10","TrueHD","Atmos","English",61.8),True,
"Two soldiers on opposite sides of a frozen front share a single radio frequency through one long winter.",("#14213d","#e5e5e5")),
(14,"Hollow Point Road",2021,["Western"],"Blackstone",117,6.8,None,False,
"A retired sheriff escorts a stranger across a county that wants them both gone.",("#7f5539","#ede0d4")),
(15,"The Last Lighthouse",2026,["Drama","Mystery"],"Fernwood",0,0,None,True,
"The keeper of the coast's last manned lighthouse refuses to leave when the automation crew arrives.",("#1d3557","#f1faee")),
(16,"Copper & Sage",2019,["Romance","Comedy"],"Starling",104,6.4,(1080,"x264","","AC3","","English",7.7),True,
"Rival food-truck owners are forced to share a parking spot for a summer.",("#bc6c25","#fefae0")),
]
SERIES = [
# id, title, year, network, status, seasons [(episodes, files)], overview, colors
(1,"Signal Fire",2021,"Aurora","continuing",[(8,8),(8,8),(10,4)],"A volunteer fire crew in a remote mountain town keeps the region safe while hiding a secret about the fire that founded it.",("#6a040f","#ffba08")),
(2,"Low Tide",2019,"Coastline","ended",[(6,6),(6,6)],"A marine biologist returns to her hometown and reopens a case everyone wanted closed.",("#03045e","#48cae4")),
(3,"The Night Shift",2023,"Aurora","continuing",[(10,10),(10,3)],"Stories from the staff of a city hospital between midnight and dawn.",("#240046","#c77dff")),
(4,"Hearth & Home",2018,"Meadow","ended",[(12,12),(12,12),(12,12)],"A family restaurant, three generations, one kitchen.",("#9c6644","#ede0d4")),
(5,"Orbital",2025,"Vector","continuing",[(8,2)],"The crew of a commercial space station tries to stay profitable, and alive.",("#000814","#ffc300")),
(6,"Ferryman",2020,"Coastline","ended",[(6,0)],"A ferry captain becomes the only link between an island and the mainland during a long quarantine.",("#2f3e46","#cad2c5")),
]
# A sequel the Trakt "Anticipated" list has, not in the library: id, title, year, colors.
UPCOMING = (9001, "The Long Orbit: Return", 2027, ("#0b132b", "#e0aaff"))
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Generate a poster for every title in catalog.py: a gradient with the title
# and year, 500x750 JPEG in posters/. Needs ImageMagick 7 and fontconfig.
set -euo pipefail
cd "$(dirname "$0")"
font=$(fc-match -f '%{file}' 'DejaVu Serif:style=Bold')
small=$(fc-match -f '%{file}' 'DejaVu Sans')
mkdir -p posters
python3 -c '
import catalog
for m in catalog.MOVIES: print("m%d|%s|%d|%s|%s" % (m[0], m[1], m[2], m[10][0], m[10][1]))
for s in catalog.SERIES: print("s%d|%s|%d|%s|%s" % (s[0], s[1], s[2], s[7][0], s[7][1]))
for s in catalog.SERIES:
for n in range(1, len(s[5]) + 1): print("s%d-%d|%s|Season %d|%s|%s" % (s[0], n, s[1], n, s[7][1], s[7][0]))
u = catalog.UPCOMING; print("m%d|%s|%d|%s|%s" % (u[0], u[1], u[2], u[3][0], u[3][1]))
' | while IFS='|' read -r id title year c1 c2; do
magick -size 500x750 "gradient:$c1-$c2" \
\( -size 420x320 -background none -fill white -font "$font" -gravity center caption:"$title" \) \
-gravity north -geometry +0+120 -composite \
\( -size 420x60 -background none -fill '#ffffffcc' -font "$small" -pointsize 30 -gravity center caption:"$year" \) \
-gravity south -geometry +0+70 -composite \
-quality 88 "posters/$id.jpg"
done
+5
View File
@@ -0,0 +1,5 @@
# The Config view; the API keys are masked.
sleep 2
key F6
sleep 3
shot config
+6
View File
@@ -0,0 +1,6 @@
# The Trakt lists in the Movies view (empty search): Anticipated, with a title
# that is not in the library yet selected.
sleep 4
key bracketright bracketright
sleep 4
shot discover
+10
View File
@@ -0,0 +1,10 @@
# The seasons and episodes of Signal Fire (5th series).
sleep 2
key F3
sleep 3
key Tab
key Down Down Down Down
sleep 1
key Return
sleep 3
shot episodes
+7
View File
@@ -0,0 +1,7 @@
# The library in the light theme, Neon Harbour (7th movie) selected.
sleep 2
key F3
sleep 3
key Down Down Down Down Down Down
sleep 2
shot library-light
+24
View File
@@ -0,0 +1,24 @@
# Search, library, releases, downloads and calendar, in the dark theme.
sleep 2
key F1
type orbit
sleep 3
shot search
key F3
sleep 3
# The Long Orbit, 13th in the list
key Down Down Down Down Down Down Down Down Down Down Down Down
sleep 2
shot library
key i
sleep 3
shot releases
key Escape
key F4
sleep 4
shot downloads
key F5
sleep 3
key Down
sleep 2
shot calendar
+265
View File
@@ -0,0 +1,265 @@
"""Fake Radarr (19891), Sonarr (19892), SABnzbd (19893) and Trakt (19894) serving the made-up
library in catalog.py, for the README screenshots. Only the API calls arr-tui
makes are answered; posters come from posters/ (see posters.sh)."""
import datetime, json, os, sys, threading, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import catalog
HERE = os.path.dirname(os.path.abspath(__file__))
TODAY = datetime.date.today()
GB = 1 << 30
RADARR, SONARR, SAB, TRAKT = 19891, 19892, 19893, 19894
def day(n): return (TODAY + datetime.timedelta(days=n)).isoformat()
def mediafile(res, codec, dr, audio, extra, lang, gb, name):
return {"size": int(gb * GB), "sceneName": name, "relativePath": name + ".mkv",
"quality": {"quality": {"name": ("Bluray-%dp" % res) if res == 2160 else ("WEBDL-%dp" % res), "resolution": res}},
"mediaInfo": {"videoCodec": codec, "videoDynamicRange": "HDR" if dr else "", "videoDynamicRangeType": dr,
"audioCodec": audio, "audioAdditionalFeatures": extra},
"languages": [{"name": lang}]}
def poster(port, kind, id):
local = "/MediaCover/%d/poster.jpg" % id
return [{"coverType": "poster", "url": local, "remoteUrl": "http://127.0.0.1:%d/remote/%s%d.jpg" % (port, kind, id)}]
def movies():
out = []
for (id, title, year, genres, studio, runtime, rating, f, mon, overview, _c) in catalog.MOVIES:
m = {"id": id, "title": title, "year": year, "genres": genres, "studio": studio, "runtime": runtime,
"overview": overview, "tmdbId": 1000 + id, "titleSlug": title.lower().replace(" ", "-"),
"monitored": mon, "hasFile": f is not None, "isAvailable": year < 2026, "status": "released" if year < 2026 else "announced",
"path": "/movies/%s (%d)" % (title, year), "images": poster(RADARR, "m", id), "qualityProfileId": 4,
"ratings": {"imdb": {"value": rating, "votes": 12000}} if rating else {}}
if f:
res, codec, dr, audio, extra, lang, gb = f
name = "%s.%d.%dp.%s%s" % (title.replace(" ", "."), year, res, (dr.replace(" ", ".") + ".") if dr else "", codec)
m["movieFile"] = mediafile(res, codec, dr, audio, extra, lang, gb, name)
m["sizeOnDisk"] = int(gb * GB)
out.append(m)
return out
def series():
out = []
for (id, title, year, network, status, seasons, overview, _c) in catalog.SERIES:
eps = sum(s[0] for s in seasons); files = sum(s[1] for s in seasons)
out.append({"id": id, "title": title, "year": year, "network": network, "status": status, "overview": overview,
"runtime": 45, "genres": ["Drama"], "tvdbId": 2000 + id, "titleSlug": title.lower().replace(" ", "-"),
"images": poster(SONARR, "s", id), "monitored": True, "ratings": {"value": 8.0, "votes": 900},
"path": "/tv/" + title,
"seasons": [{"seasonNumber": i + 1, "monitored": True,
"statistics": {"episodeFileCount": f, "episodeCount": e, "totalEpisodeCount": e, "sizeOnDisk": f * 2 * GB}}
for i, (e, f) in enumerate(seasons)],
"statistics": {"seasonCount": len(seasons), "episodeFileCount": files, "episodeCount": eps,
"totalEpisodeCount": eps, "sizeOnDisk": files * 2 * GB}})
return out
EPISODE_TITLES = ["Kindling", "The Ridge", "Backdraft", "Smoke Line", "Ember", "Firebreak", "Updraft", "Ash",
"Tinder", "Afterglow", "Spark", "Wildland"]
def episodes(series_id):
s = next(x for x in catalog.SERIES if x[0] == series_id)
out, eid = [], series_id * 1000
for si, (count, files) in enumerate(s[5]):
for e in range(count):
eid += 1
aired = day(-(len(s[5]) - si) * 90 + e * 7)
out.append({"id": eid, "seriesId": series_id, "seasonNumber": si + 1, "episodeNumber": e + 1,
"title": EPISODE_TITLES[e % len(EPISODE_TITLES)], "airDate": aired, "airDateUtc": aired + "T20:00:00Z",
"overview": "The crew faces a long night on the ridge as the wind turns.", "hasFile": e < files, "monitored": True})
return out
def releases(title, year):
base = title.replace(" ", ".")
rows = [
("%s.%d.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos.7.1-FRAMES" % (base, year), "NZBgeek", 68.4, 2160, "Remux-2160p", True, [], 2, ["DV", "HDR10", "Atmos"]),
("%s.%d.2160p.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX" % (base, year), "DrunkenSlug", 21.9, 2160, "WEBDL-2160p", True, [], 1, ["DV", "Atmos"]),
("%s.%d.1080p.BluRay.DTS-HD.MA.5.1.x264-SPARKS" % (base, year), "NZBgeek", 14.2, 1080, "Bluray-1080p", True, [], 6, []),
("%s.%d.1080p.WEB-DL.DDP5.1.H.264-NTb" % (base, year), "NZBFinder", 6.8, 1080, "WEBDL-1080p", True, [], 3, []),
("%s.%d.2160p.WEB-DL.DDP5.1.HDR10.HEVC-GERMAN-iNTENSO" % (base, year), "DrunkenSlug", 18.1, 2160, "WEBDL-2160p", False, ["Language is not wanted"], 4, ["HDR10"]),
("%s.%d.720p.WEBRip.x264-ION10" % (base, year), "NZBFinder", 1.9, 720, "WEBRip-720p", False, ["Quality is not wanted in profile"], 9, []),
("%s.%d.1080p.HDTV.x264-CAM" % (base, year), "NZBgeek", 2.4, 1080, "HDTV-1080p", False, ["Release is a CAM", "Quality is not wanted in profile"], 1, []),
("%s.%d.2160p.BluRay.x265.10bit.HDR.DTS-HD.MA-TERMiNAL" % (base, year), "NZBFinder", 34.0, 2160, "Bluray-2160p", True, [], 12, ["HDR10"]),
]
out = []
for i, (name, idx, gb, res, q, ok, rej, days, cfs) in enumerate(rows):
out.append({"guid": "g%d" % i, "title": name, "indexer": idx, "indexerId": 1, "protocol": "usenet", "size": int(gb * GB),
"ageMinutes": days * 1440.0, "qualityWeight": res, "customFormatScore": 100 * len(cfs), "releaseGroup": name.rsplit("-", 1)[-1],
"approved": ok, "rejected": not ok, "rejections": rej,
"quality": {"quality": {"name": q, "resolution": res}},
"languages": [{"name": "German" if "GERMAN" in name else "English"}],
"customFormats": [{"name": c} for c in cfs]})
return out
def radarr(path, q):
if path.endswith("/system/status"): return {"appName": "Radarr", "version": "5.26.2"}
if path.endswith("/movie"): return movies()
if "/movie/" in path and path.rsplit("/", 1)[1].isdigit():
return next(x for x in movies() if x["id"] == int(path.rsplit("/", 1)[1]))
if path.endswith("/movie/lookup"):
term = q.get("term", [""])[0].lower()
if term.startswith("tmdb:"):
return [m for m in movies() + [UPCOMING] if m["tmdbId"] == int(term[5:])]
res = [m for m in movies() if term and term in m["title"].lower()]
return res + ([UPCOMING] if "orbit" in term else [])
if path.endswith("/qualityprofile"): return [{"id": 1, "name": "Any"}, {"id": 4, "name": "HD-1080p"}, {"id": 5, "name": "Ultra-HD"}]
if path.endswith("/rootfolder"): return [{"id": 1, "path": "/movies", "freeSpace": 3 << 40, "accessible": True}]
if path.endswith("/release"):
m = next(x for x in catalog.MOVIES if x[0] == int(q["movieId"][0]))
return releases(m[1], m[2])
if path.endswith("/queue"):
return {"records": [
{"id": 1, "title": "Iron.Meridian.2021.2160p.WEB-DL.DDP5.1.Atmos.HDR.H.265-FLUX", "status": "downloading", "trackedDownloadStatus": "ok",
"trackedDownloadState": "downloading", "size": 21.9 * GB, "sizeleft": 7.2 * GB, "timeleft": "00:06:12", "downloadClient": "SABnzbd",
"downloadId": "SABnzbd_nzo_a1", "protocol": "usenet", "movieId": 3, "movie": {"title": "Iron Meridian", "year": 2021}},
{"id": 2, "title": "Static.2018.1080p.BluRay.x264-AMIABLE", "status": "completed", "trackedDownloadStatus": "warning",
"trackedDownloadState": "importBlocked", "size": 6.0 * GB, "sizeleft": 0, "timeleft": "", "downloadClient": "SABnzbd",
"downloadId": "SABnzbd_nzo_h3", "protocol": "usenet", "statusMessages": [{"title": "Static.2018.1080p", "messages": ["Not an upgrade for existing movie file"]}],
"movieId": 10, "movie": {"title": "Static", "year": 2018}}]}
if path.endswith("/calendar"):
out = []
for off, mid, kind in [(1, 9, "digitalRelease"), (5, 15, "inCinemas"), (9, 4, "physicalRelease")]:
m = dict(next(x for x in movies() if x["id"] == mid)); m[kind] = day(off) + "T00:00:00Z"; out.append(m)
return out
return []
def sonarr(path, q):
if path.endswith("/system/status"): return {"appName": "Sonarr", "version": "4.0.15"}
if path.endswith("/series/lookup"):
term = q.get("term", [""])[0].lower()
if term.startswith("tvdb:"):
return [x for x in series() if x["tvdbId"] == int(term[5:])]
return [x for x in series() if term in x["title"].lower()]
if path.endswith("/series"): return series()
if "/series/" in path and path.rsplit("/", 1)[1].isdigit():
s = next(x for x in series() if x["id"] == int(path.rsplit("/", 1)[1]))
if q.get("includeSeasonImages") == ["true"]: # like Sonarr: season posters only on request, remote only
for se in s["seasons"]:
se["images"] = [{"coverType": "poster", "remoteUrl": "http://127.0.0.1:%d/remote/s%d-%d.jpg" % (SONARR, s["id"], se["seasonNumber"])}]
return s
if path.endswith("/episode"): return episodes(int(q["seriesId"][0]))
if path.endswith("/episodefile"): return []
if path.endswith("/qualityprofile"): return [{"id": 1, "name": "Any"}, {"id": 4, "name": "HD-1080p"}]
if path.endswith("/rootfolder"): return [{"id": 1, "path": "/tv", "freeSpace": 3 << 40, "accessible": True}]
if path.endswith("/queue"):
return {"records": [
{"id": 7, "title": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "status": "downloading", "trackedDownloadStatus": "ok",
"trackedDownloadState": "downloading", "size": 2.1 * GB, "sizeleft": 1.3 * GB, "timeleft": "00:01:40", "downloadClient": "SABnzbd",
"downloadId": "SABnzbd_nzo_a2", "protocol": "usenet", "seriesId": 1, "episodeId": 1021, "series": {"title": "Signal Fire"},
"episode": {"seasonNumber": 3, "episodeNumber": 5, "title": "Ember"}}]}
if path.endswith("/calendar"):
out = []
for off, sid, s, e, t in [(0, 1, 3, 7, "Updraft"), (2, 3, 2, 4, "Code Blue"), (4, 5, 1, 3, "Dock"), (7, 1, 3, 8, "Ash"), (11, 3, 2, 5, "Triage")]:
ser = next(x for x in series() if x["id"] == sid)
out.append({"id": 90000 + off, "seriesId": sid, "seasonNumber": s, "episodeNumber": e, "title": t,
"airDate": day(off), "airDateUtc": day(off) + "T20:00:00Z", "monitored": True, "hasFile": False,
"overview": "A new episode of " + ser["title"] + ".", "series": ser})
return out
return []
# A title Trakt lists that is not in the library yet.
UPCOMING = {"id": 0, "title": catalog.UPCOMING[1], "year": catalog.UPCOMING[2], "overview": "Years after the first voyage, a second crew follows the freighter's last signal.",
"tmdbId": 9001, "titleSlug": "the-long-orbit-return", "genres": ["Science Fiction"], "studio": "Halcyon Pictures", "runtime": 0,
"images": [{"coverType": "poster", "remoteUrl": "http://127.0.0.1:%d/remote/m9001.jpg" % RADARR}], "status": "announced", "ratings": {}}
FIRST = ["Maren", "Theo", "Ilse", "Jonah", "Priya", "Oskar", "Lena", "Rafael", "Wren", "Tobias", "Noor", "Elias", "Saoirse", "Kenji", "Ada", "Milo"]
LAST = ["Halvorsen", "Achebe", "Lindqvist", "Moreau", "Okafor", "Brandt", "Castellano", "Yamada", "Petrov", "Quinn", "Ferreira", "Novak"]
ROLES = ["Captain Ilsa Rhee", "Dr. Amos Kell", "Navigator Juno", "The Engineer", "Mara Voss", "Commander Teague", "Pilot Sev", "Young Ilsa", "Radio Operator", "Station Chief"]
def people(seed, show):
def name(i): return FIRST[(seed * 3 + i * 5) % len(FIRST)] + " " + LAST[(seed * 7 + i * 3) % len(LAST)]
cast = [{"character": ROLES[(seed + i) % len(ROLES)], "characters": [ROLES[(seed + i) % len(ROLES)]], "person": {"name": name(i), "ids": {"trakt": i}}} for i in range(9)]
lead = {"job": "Creator" if show else "Director", "jobs": ["Creator" if show else "Director"], "person": {"name": name(20), "ids": {"trakt": 99}}}
return {"cast": cast, "crew": {("created by" if show else "directing"): [lead]}}
def trakt(path):
parts = path.strip("/").split("/")
if parts[0] == "search": # /search/tmdb/1013?type=movie or /search/tvdb/2001?type=show
id = int(parts[2]); key = "movie" if parts[1] == "tmdb" else "show"
return [{"type": key, "score": 1000, key: {"title": "x", "year": 2020, "ids": {"trakt": id, "slug": "x"}}}]
if len(parts) == 3 and parts[2] == "people":
return people(int(parts[1]), parts[0] == "shows")
def movie(id, title, year): return {"title": title, "year": year, "ids": {"trakt": id, "slug": "x", "tmdb": 1000 + id if id < 9000 else id}}
def show(id): s = next(x for x in catalog.SERIES if x[0] == id); return {"title": s[1], "year": s[2], "ids": {"trakt": id, "slug": "x", "tvdb": 2000 + id}}
by = {m[0]: m for m in catalog.MOVIES}
if path == "/movies/trending":
return [{"watchers": n, "movie": movie(i, by[i][1], by[i][2])} for i, n in [(13, 2841), (1, 2217), (11, 1604), (6, 1186), (3, 902), (8, 655), (16, 412), (12, 371)]]
if path == "/movies/anticipated":
return [{"list_count": 18342, "movie": movie(9001, "The Long Orbit: Return", 2027)}] + \
[{"list_count": n, "movie": movie(i, by[i][1], by[i][2])} for i, n in [(15, 9120), (9, 6204), (4, 3311)]]
if path.startswith("/movies/"):
return [{"watcher_count": n * 10, "movie": movie(i, by[i][1], by[i][2])} for i, n in [(1, 5120), (13, 4870), (3, 3010), (2, 1200)]]
if path == "/shows/trending":
return [{"watchers": n, "show": show(i)} for i, n in [(1, 3022), (5, 2410), (3, 1170), (2, 640)]]
if path.startswith("/shows/"):
return [{"watcher_count": n, "show": show(i)} for i, n in [(3, 51200), (1, 40100), (4, 22000)]]
return []
def sab(q):
mode = q.get("mode", [""])[0]
if mode == "version": return {"version": "4.5.1"}
if mode == "queue":
return {"queue": {"status": "Downloading", "paused": False, "kbpersec": "48213", "speedlimit_abs": "0", "size": "26.4 GB", "sizeleft": "9.1 GB",
"timeleft": "0:08:05", "noofslots_total": 3, "slots": [
{"nzo_id": "nzo_a1", "filename": "Iron.Meridian.2021.2160p.WEB-DL.DDP5.1.Atmos.HDR.H.265-FLUX", "cat": "movies", "status": "Downloading",
"size": "21.9 GB", "sizeleft": "7.2 GB", "mb": "22425", "mbleft": "7372", "percentage": "67", "timeleft": "0:06:12"},
{"nzo_id": "nzo_a2", "filename": "Signal.Fire.S03E05.1080p.WEB-DL.DDP5.1.H.264-NTb", "cat": "tv", "status": "Downloading",
"size": "2.1 GB", "sizeleft": "1.3 GB", "mb": "2150", "mbleft": "1331", "percentage": "38", "timeleft": "0:01:40"},
{"nzo_id": "nzo_a3", "filename": "Hearth.and.Home.S03.1080p.BluRay.x264-ROVERS", "cat": "tv", "status": "Queued",
"size": "2.4 GB", "sizeleft": "2.4 GB", "mb": "2457", "mbleft": "2457", "percentage": "0", "timeleft": "0:00:00"}]}}
if mode == "history":
now = int(datetime.datetime.now().timestamp())
return {"history": {"slots": [
{"nzo_id": "nzo_h1", "name": "The.Long.Orbit.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.Atmos-FRAMES", "category": "movies", "status": "Completed", "size": "58.2 GB", "fail_message": "", "completed": now - 3600},
{"nzo_id": "nzo_h2", "name": "Signal.Fire.S03E04.1080p.WEB-DL.DDP5.1.H.264-NTb", "category": "tv", "status": "Completed", "size": "2.0 GB", "fail_message": "", "completed": now - 7200},
{"nzo_id": "nzo_h3", "name": "Static.2018.1080p.BluRay.x264-AMIABLE", "category": "movies", "status": "Completed", "size": "6.0 GB", "fail_message": "", "completed": now - 20000},
{"nzo_id": "nzo_h4", "name": "Low.Tide.S02E06.720p.HDTV.x264-KILLERS", "category": "tv", "status": "Failed", "size": "1.1 GB", "fail_message": "Aborted, cannot be completed - https://sabnzbd.org/not-complete", "completed": now - 90000},
{"nzo_id": "nzo_h5", "name": "Neon.Harbour.2023.2160p.WEB-DL.DTS-HD.MA.HDR10Plus.HEVC-SMURF", "category": "movies", "status": "Completed", "size": "44.5 GB", "fail_message": "", "completed": now - 180000}]}}
return {}
def handler(port):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
# Like the real services, answer cross-origin requests (the webOS app
# runs in a browser).
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
self.send_header("Access-Control-Allow-Headers", self.headers.get("Access-Control-Request-Headers", "*"))
self.end_headers()
# Writes (add, grab, commands, monitoring, removal) are accepted and
# change nothing; an added title comes back with an id.
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
out = dict(body, id=900) if isinstance(body, dict) and self.path.split("?")[0].endswith(("/movie", "/series")) else {}
b = json.dumps(out).encode()
self.send_response(201 if out else 200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
do_PUT = do_POST
def do_DELETE(self):
self.send_response(200); self.send_header("Content-Length", "0"); self.end_headers()
def do_GET(self):
u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query); path = u.path
if path.startswith("/MediaCover/") or path.startswith("/remote/"):
if path.startswith("/MediaCover/"):
id = path.split("/")[2]; f = ("m" if port == RADARR else "s") + id + ".jpg"
else:
f = path.rsplit("/", 1)[1]
p = os.path.join(HERE, "posters", f)
if not os.path.exists(p): self.send_response(404); self.end_headers(); return
data = open(p, "rb").read()
self.send_response(200); self.send_header("Content-Type", "image/jpeg"); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data); return
if port == TRAKT:
out = trakt(path)
else:
out = sab(q) if port == SAB else (radarr(path, q) if port == RADARR else sonarr(path, q))
b = json.dumps(out).encode()
self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
return H
for port in (RADARR, SONARR, TRAKT):
threading.Thread(target=ThreadingHTTPServer(("127.0.0.1", port), handler(port)).serve_forever, daemon=True).start()
ThreadingHTTPServer(("127.0.0.1", SAB), handler(SAB)).serve_forever()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Take the README screenshots: build arr-tui, start the fake services with the
# made-up library, and run every scene in Ghostty on a virtual X display. The
# pictures go to docs/screenshots/ (losslessly compressed if oxipng is there).
#
# Needs: go, python3, ghostty, xvfb-run (Xvfb), xdotool, ImageMagick 7,
# fontconfig with DejaVu fonts; oxipng optional. On NixOS:
# nix develop --command dev/screenshots/take.sh
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
out=$repo/docs/screenshots
# The Config view shows the config file's path; keep it neutral.
demo=/tmp/arr-tui-demo
for tool in go python3 ghostty xvfb-run xdotool magick fc-match; do
command -v "$tool" >/dev/null || { echo "take.sh: $tool is missing" >&2; exit 1; }
done
work=$(mktemp -d)
cleanup() {
[[ -n ${server:-} ]] && kill "$server" 2>/dev/null
rm -rf "$work" "$demo"
}
trap cleanup EXIT
echo "building arr-tui"
go build -C "$repo" -o "$work/arr-tui" ./cmd/arr-tui
[[ -d $here/posters ]] || { echo "generating posters"; "$here/posters.sh"; }
echo "starting the fake Radarr, Sonarr, SABnzbd and Trakt"
export ARR_TUI_TRAKT_URL=http://127.0.0.1:19894
python3 "$here/server.py" &
server=$!
sleep 1
# One config per theme, each at $demo/<theme>/.config/arr-tui/config.json.
for theme in dark light; do
mkdir -p "$demo/$theme/.config/arr-tui"
cat >"$demo/$theme/.config/arr-tui/config.json" <<JSON
{
"radarr": {"url": "http://127.0.0.1:19891", "apikey": "0123456789abcdef0123456789abcdef", "default_quality_profile": "Ultra-HD", "default_root_folder": "/movies"},
"sonarr": {"url": "http://127.0.0.1:19892", "apikey": "0123456789abcdef0123456789abcdef", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv"},
"sabnzbd": {"url": "http://127.0.0.1:19893", "apikey": "0123456789abcdef0123456789abcdef"},
"trakt": {"client_id": "0123456789abcdef0123456789abcdef"},
"ui": {"theme": "$theme"}
}
JSON
chmod 600 "$demo/$theme/.config/arr-tui/config.json"
done
mkdir -p "$out"
shoot() { # scene theme [ghostty args]
echo "scene $1"
XDG_CONFIG_HOME=$demo/$2/.config "$here/xshot.sh" "$here/scenes/$1.txt" "$out" "${3:-}" "$work/arr-tui"
}
shoot views dark
shoot discover dark
shoot episodes dark
shoot config dark
shoot light light "--background=eeeeee --foreground=1c1c1c"
if command -v oxipng >/dev/null; then
oxipng -o 4 --strip safe -q "$out"/*.png
fi
echo "screenshots in $out:"
ls "$out"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Run a command in Ghostty on a virtual X display and follow a scene script.
#
# xshot.sh <scene> <outdir> "<extra ghostty args>" <command...>
#
# Scene lines: key <keysyms...> | type <text> | sleep <seconds> | shot <name>
# (shot saves the Ghostty window as <outdir>/<name>.png).
set -euo pipefail
scene=$1 out=$2 extra=$3
shift 3
export GDK_BACKEND=x11 LIBGL_ALWAYS_SOFTWARE=1 GSK_RENDERER=gl
unset WAYLAND_DISPLAY DBUS_SESSION_BUS_ADDRESS
# shellcheck disable=SC2016 # expanded by the inner shell
xvfb-run -a -s "-screen 0 1800x1200x24" bash -c '
scene=$1 out=$2 extra=$3
shift 3
# shellcheck disable=SC2086 # extra holds several arguments
ghostty --gtk-single-instance=false --class=arrshot --window-width=132 --window-height=38 \
--window-decoration=false --confirm-close-surface=false --font-size=12 \
--window-padding-x=8 --window-padding-y=6 $extra -e "$@" >/dev/null 2>&1 &
ghostty=$!
sleep 7 # Ghostty with software rendering takes a while to show its window
wid=$(xdotool search --onlyvisible --pid "$ghostty" | tail -n1)
while read -r cmd args; do
case $cmd in
key) for k in $args; do xdotool key "$k"; sleep 0.25; done ;;
type) xdotool type --delay 80 "$args"; sleep 0.5 ;;
sleep) sleep "$args" ;;
shot) import -window "$wid" "$out/$args.png" ;;
""|"#"*) ;;
*) echo "unknown scene command: $cmd" >&2; exit 1 ;;
esac
done < "$scene"
kill "$ghostty" 2>/dev/null || true
' _ "$scene" "$out" "$extra" "$@"
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
description = "arr-tui: a terminal UI (and webOS TV app) for Radarr, Sonarr and SABnzbd";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAll = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
# nix develop: everything make test, the release script, the README
# screenshots (dev/screenshots) and the webOS app (webos/) need.
# Containers for dev/up.sh (docker or podman) come from the system.
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = with pkgs; [
# arr-tui
go
gopls
gnumake
shellcheck
git
# scripts/release.sh, dev/up.sh
curl
jq
# dev/screenshots (fake services), webos (app, tests, ares CLI)
python3
nodejs_24
# README pictures: oxipng compresses them
oxipng
imagemagick
] ++ lib.optionals stdenv.hostPlatform.isLinux [
# dev/screenshots/take.sh: Ghostty on a virtual X display
ghostty
xvfb-run
xorg-server
xdotool
fontconfig
dejavu_fonts
# webos/dev/drive.mjs
chromium
];
# posters.sh asks fontconfig for the DejaVu fonts: the system's
# fonts plus DejaVu.
FONTCONFIG_FILE = pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux (pkgs.writeText "fonts.conf" ''
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
<dir>${pkgs.dejavu_fonts}/share/fonts</dir>
</fontconfig>
'');
shellHook = pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux ''
export CHROMIUM=${pkgs.chromium}/bin/chromium
'';
};
});
};
}
+33 -11
View File
@@ -18,8 +18,8 @@ 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 a scaled variant: -250 (250×375), or -500
// (500×750) when fetch is asked for a large poster. Titles that are not in
// 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) {
@@ -35,7 +35,7 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
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
}
}
@@ -44,16 +44,19 @@ func (c *Client) Poster(images []Image, inLibrary bool) (key string, fetch func(
}
if img.RemoteURL != "" {
remote := img.RemoteURL
return remote, func(ctx context.Context, _ bool) ([]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 scaled variant (-500
// when large, else -250), then the original. When the instance answers with a page instead of an image (it
// wants a login for /MediaCover) the API key is also tried as a query
// parameter, which is the other way Radarr and Sonarr accept it.
// 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)
@@ -65,11 +68,12 @@ func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]b
}
return data, err
}
width := 250
// 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 {
width = 500
return try(local)
}
if scaled := scaledVariant(local, width); scaled != local {
if scaled := scaledVariant(local, 250); scaled != local {
data, err := try(scaled)
if err == nil {
return data, nil
@@ -82,6 +86,24 @@ func (c *Client) localPoster(ctx context.Context, local string, large bool) ([]b
return try(local)
}
// tmdbSized asks TheMovieDB for a poster of the size that is shown instead of
// the original Radarr hands out (often 2000×3000 and over 2 MB, which takes
// longer to download and to decode than everything else together): 780
// pixels wide for real images, 342 for block characters. Other URLs are
// returned as they are.
func tmdbSized(rawURL string, large bool) string {
u, err := url.Parse(rawURL)
if err != nil || u.Host != "image.tmdb.org" || !strings.HasPrefix(u.Path, "/t/p/original/") {
return rawURL
}
size := "w342"
if large {
size = "w780"
}
u.Path = "/t/p/" + size + strings.TrimPrefix(u.Path, "/t/p/original")
return u.String()
}
// scaledVariant turns /MediaCover/1/poster.jpg?x into
// /MediaCover/1/poster-250.jpg?x (for width 250; Radarr and Sonarr keep 250
// and 500).
+19 -2
View File
@@ -8,6 +8,23 @@ import (
"testing"
)
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 := 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
@@ -24,7 +41,7 @@ func TestScaledVariant(t *testing.T) {
}
}
func TestLargePosterUsesThe500Variant(t *testing.T) {
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)
@@ -39,7 +56,7 @@ func TestLargePosterUsesThe500Variant(t *testing.T) {
if _, err := fetch(context.Background(), false); err != nil {
t.Fatal(err)
}
if strings.Join(paths, " ") != "/MediaCover/1/poster-500.jpg /MediaCover/1/poster-250.jpg" {
if strings.Join(paths, " ") != "/MediaCover/1/poster.jpg /MediaCover/1/poster-250.jpg" {
t.Errorf("requests = %v", paths)
}
}
+3
View File
@@ -31,6 +31,9 @@ type QueueItem struct {
DownloadID string `json:"downloadId"` // the download client's id, e.g. SABnzbd's nzo_id
Indexer string `json:"indexer"`
Protocol string `json:"protocol"`
MovieID int `json:"movieId,omitempty"` // Radarr
SeriesID int `json:"seriesId,omitempty"` // Sonarr
EpisodeID int `json:"episodeId,omitempty"` // Sonarr; one item per episode
// Present depending on the include* parameters of the request.
Movie *QueueMovie `json:"movie,omitempty"`
+16 -6
View File
@@ -77,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"`
}
@@ -184,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
@@ -253,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() {
@@ -262,6 +271,7 @@ 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 that is set up has a usable URL and API
+12
View File
@@ -233,3 +233,15 @@ func TestCreate(t *testing.T) {
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)
}
}
+13
View File
@@ -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
+18 -1
View File
@@ -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
}
+20
View File
@@ -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)
}
}
+386
View File
@@ -0,0 +1,386 @@
// Package trakt reads the public lists of Trakt (trakt.tv): trending, most
// watched, anticipated, popular and box office movies and shows. Only a client
// ID is needed; nothing is written and no user account is involved.
package trakt
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// DefaultBaseURL is the Trakt API.
const DefaultBaseURL = "https://api.trakt.tv"
// ErrNotConfigured is returned without a request while no client ID is set.
var ErrNotConfigured = errors.New("no Trakt client ID: create an API app on trakt.tv and enter its client ID in the Config view (F6)")
// Kind says whether a list holds movies or shows.
type Kind int
const (
Movies Kind = iota
Shows
)
// List is one of Trakt's public lists.
type List struct {
Name string // shown to the user, e.g. "Trending now"
path string // e.g. "/movies/trending"
count string // the field holding the list's number, "" for none
// Note formats the number for display, e.g. "1.2k watching".
note func(n int64) string
kind Kind
}
// Kind returns whether the list holds movies or shows.
func (l List) Kind() Kind { return l.kind }
// Note describes an entry's number in this list, or "" when it has none.
func (l List) Note(n int64) string {
if l.note == nil || n <= 0 {
return ""
}
return l.note(n)
}
func watching(n int64) string { return Compact(n) + " watching" }
func watchers(n int64) string { return Compact(n) + " watched it" }
func lists(n int64) string { return "on " + Compact(n) + " lists" }
func revenue(n int64) string { return "$" + Compact(n) + " last weekend" }
func movieList(name, path, count string, note func(int64) string) List {
return List{Name: name, path: path, count: count, note: note, kind: Movies}
}
func showList(name, path, count string, note func(int64) string) List {
return List{Name: name, path: path, count: count, note: note, kind: Shows}
}
// MovieLists and ShowLists are the lists arr-tui offers, in order.
var (
MovieLists = []List{
movieList("Trending now", "/movies/trending", "watchers", watching),
movieList("Most watched this week", "/movies/watched/weekly", "watcher_count", watchers),
movieList("Anticipated", "/movies/anticipated", "list_count", lists),
movieList("Popular", "/movies/popular", "", nil),
movieList("Box office (US, last weekend)", "/movies/boxoffice", "revenue", revenue),
}
ShowLists = []List{
showList("Trending now", "/shows/trending", "watchers", watching),
showList("Most watched this week", "/shows/watched/weekly", "watcher_count", watchers),
showList("Anticipated", "/shows/anticipated", "list_count", lists),
showList("Popular", "/shows/popular", "", nil),
}
)
// IDs are the identifiers Trakt knows for a title. TVDB is set for shows only.
type IDs struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
IMDB string `json:"imdb"`
TMDB int `json:"tmdb"`
TVDB int `json:"tvdb"`
}
// Entry is one title on a list, with the list's number for it (watchers,
// lists it is on, revenue; 0 for lists without one).
type Entry struct {
Title string `json:"title"`
Year int `json:"year"`
IDs IDs `json:"ids"`
Count int64 `json:"-"`
}
// Client talks to the Trakt API.
type Client struct {
mu sync.RWMutex
baseURL string
clientID string
userAgent string
http *http.Client
}
// NewClient returns a client with the given client ID (may be empty: then
// every call returns ErrNotConfigured).
func NewClient(clientID, userAgent string) *Client {
return &Client{baseURL: DefaultBaseURL, clientID: strings.TrimSpace(clientID), userAgent: userAgent,
http: &http.Client{Timeout: 20 * time.Second}}
}
// Reconfigure sets another client ID.
func (c *Client) Reconfigure(clientID string) {
c.mu.Lock()
defer c.mu.Unlock()
c.clientID = strings.TrimSpace(clientID)
}
// SetBaseURL points the client at another server (tests).
func (c *Client) SetBaseURL(u string) {
c.mu.Lock()
defer c.mu.Unlock()
c.baseURL = strings.TrimRight(u, "/")
}
// Configured reports whether a client ID is set.
func (c *Client) Configured() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.clientID != ""
}
// Entries fetches up to limit entries of a list.
func (c *Client) Entries(ctx context.Context, l List, limit int) ([]Entry, error) {
data, err := c.get(ctx, l.path, url.Values{"page": {"1"}, "limit": {strconv.Itoa(limit)}})
if err != nil {
return nil, err
}
entries, err := decode(data, l)
if err != nil {
return nil, fmt.Errorf("trakt: decoding %s: %w", l.path, err)
}
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
// get sends a GET request to the API and returns the body of a successful answer.
func (c *Client) get(ctx context.Context, path string, query url.Values) ([]byte, error) {
c.mu.RLock()
base, id := c.baseURL, c.clientID
c.mu.RUnlock()
if id == "" {
return nil, ErrNotConfigured
}
u := base + path
if len(query) > 0 {
u += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("trakt-api-key", id)
req.Header.Set("trakt-api-version", "2")
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
return nil, fmt.Errorf("trakt: %w", ue.Err)
}
return nil, err
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
return nil, fmt.Errorf("trakt: the client ID was not accepted (HTTP %d)", resp.StatusCode)
case resp.StatusCode == http.StatusTooManyRequests:
return nil, errors.New("trakt: too many requests, try again in a few minutes")
case resp.StatusCode == http.StatusNotFound:
return nil, ErrNotFound
case resp.StatusCode < 200 || resp.StatusCode > 299:
return nil, fmt.Errorf("trakt: HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
}
// ErrNotFound is returned when Trakt does not know a title.
var ErrNotFound = errors.New("trakt: not found")
// Ref names a title by an id of another database, as Radarr and Sonarr have
// them: TMDb for movies, TVDB for shows, IMDb for both.
type Ref struct {
Kind Kind
TMDB int
TVDB int
IMDB string
}
// Key identifies the title, e.g. for caching ("" when it has no id).
func (r Ref) Key() string {
k := "movie:"
if r.Kind == Shows {
k = "show:"
}
switch {
case r.Kind == Movies && r.TMDB > 0:
return k + "tmdb:" + strconv.Itoa(r.TMDB)
case r.Kind == Shows && r.TVDB > 0:
return k + "tvdb:" + strconv.Itoa(r.TVDB)
case r.IMDB != "":
return k + "imdb:" + r.IMDB
}
return ""
}
// Person is a member of the cast or crew.
type Person struct {
Name string
Role string // the character played, or the job
}
// People are the cast (in Trakt's order) and the directors (movies) or
// creators (shows) of a title.
type People struct {
Cast []Person
Leaders []Person // directors of a movie, creators of a show
}
// People looks the title up on Trakt and fetches its cast and crew.
func (c *Client) People(ctx context.Context, r Ref) (People, error) {
id, err := c.traktID(ctx, r)
if err != nil {
return People{}, err
}
kind := "movies"
if r.Kind == Shows {
kind = "shows"
}
data, err := c.get(ctx, "/"+kind+"/"+strconv.Itoa(id)+"/people", nil)
if err != nil {
return People{}, err
}
var raw struct {
Cast []struct {
Character string `json:"character"`
Characters []string `json:"characters"`
Person struct {
Name string `json:"name"`
} `json:"person"`
} `json:"cast"`
Crew map[string][]struct {
Job string `json:"job"`
Jobs []string `json:"jobs"`
Person struct {
Name string `json:"name"`
} `json:"person"`
} `json:"crew"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return People{}, fmt.Errorf("trakt: decoding people: %w", err)
}
var p People
for _, m := range raw.Cast {
role := m.Character
if len(m.Characters) > 0 {
role = strings.Join(m.Characters, " / ")
}
p.Cast = append(p.Cast, Person{Name: m.Person.Name, Role: role})
}
// Directors of a movie; for a show the "created by" credits.
key, job := "directing", "Director"
if r.Kind == Shows {
key, job = "created by", ""
}
for _, m := range raw.Crew[key] {
jobs := append([]string{m.Job}, m.Jobs...)
if job == "" || contains(jobs, job) {
p.Leaders = append(p.Leaders, Person{Name: m.Person.Name, Role: m.Job})
}
}
return p, nil
}
// traktID finds Trakt's id of a title from its TMDb, TVDB or IMDb id.
func (c *Client) traktID(ctx context.Context, r Ref) (int, error) {
idType, id := "imdb", r.IMDB
switch {
case r.Kind == Movies && r.TMDB > 0:
idType, id = "tmdb", strconv.Itoa(r.TMDB)
case r.Kind == Shows && r.TVDB > 0:
idType, id = "tvdb", strconv.Itoa(r.TVDB)
case id == "":
return 0, ErrNotFound
}
kind := "movie"
if r.Kind == Shows {
kind = "show"
}
data, err := c.get(ctx, "/search/"+idType+"/"+url.PathEscape(id), url.Values{"type": {kind}})
if err != nil {
return 0, err
}
var results []map[string]json.RawMessage
if err := json.Unmarshal(data, &results); err != nil {
return 0, fmt.Errorf("trakt: decoding search: %w", err)
}
for _, res := range results {
var media struct {
IDs IDs `json:"ids"`
}
if raw, ok := res[kind]; ok && json.Unmarshal(raw, &media) == nil && media.IDs.Trakt > 0 {
return media.IDs.Trakt, nil
}
}
return 0, ErrNotFound
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// decode reads a list response: either bare titles, or items with the title
// under "movie"/"show" next to the list's number.
func decode(data []byte, l List) ([]Entry, error) {
var items []map[string]json.RawMessage
if err := json.Unmarshal(data, &items); err != nil {
return nil, err
}
key := "movie"
if l.kind == Shows {
key = "show"
}
out := make([]Entry, 0, len(items))
for _, item := range items {
var e Entry
raw, wrapped := item[key]
if !wrapped {
// A bare title: the item is the title itself.
b, _ := json.Marshal(item)
raw = b
}
if err := json.Unmarshal(raw, &e); err != nil {
return nil, err
}
if l.count != "" {
if n, ok := item[l.count]; ok {
_ = json.Unmarshal(n, &e.Count)
}
}
out = append(out, e)
}
return out, nil
}
// Compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
func Compact(n int64) string {
switch {
case n < 1000:
return strconv.FormatInt(n, 10)
case n < 10_000:
return trimZero(fmt.Sprintf("%.1fk", float64(n)/1000))
case n < 1_000_000:
return fmt.Sprintf("%dk", n/1000)
case n < 10_000_000:
return trimZero(fmt.Sprintf("%.1fM", float64(n)/1_000_000))
default:
return fmt.Sprintf("%dM", n/1_000_000)
}
}
func trimZero(s string) string { return strings.Replace(s, ".0", "", 1) }
+139
View File
@@ -0,0 +1,139 @@
package trakt
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestEntries(t *testing.T) {
var got http.Header
var gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got, gotQuery = r.Header, r.URL.RawQuery
switch r.URL.Path {
case "/movies/trending":
w.Write([]byte(`[{"watchers":1234,"movie":{"title":"Dune","year":2021,"ids":{"trakt":1,"slug":"dune-2021","imdb":"tt1160419","tmdb":438631}}},
{"watchers":7,"movie":{"title":"No Year","year":null,"ids":{"trakt":2,"slug":"x","imdb":null,"tmdb":null}}}]`))
case "/movies/popular":
w.Write([]byte(`[{"title":"Heat","year":1995,"ids":{"trakt":3,"slug":"heat-1995","tmdb":949}}]`))
case "/shows/watched/weekly":
w.Write([]byte(`[{"watcher_count":52000,"play_count":1,"collected_count":2,"collector_count":3,"show":{"title":"Andor","year":2022,"ids":{"trakt":4,"slug":"andor","tvdb":393189,"tmdb":83867}}}]`))
case "/movies/boxoffice":
w.Write([]byte(`[{"revenue":48464322,"movie":{"title":"Big","year":2026,"ids":{"trakt":5,"tmdb":5}}}]`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := NewClient(" my-id ", "arr-tui/test")
c.SetBaseURL(srv.URL)
movies, err := c.Entries(context.Background(), MovieLists[0], 20)
if err != nil {
t.Fatal(err)
}
if got.Get("trakt-api-key") != "my-id" || got.Get("trakt-api-version") != "2" || got.Get("User-Agent") != "arr-tui/test" ||
got.Get("Content-Type") != "application/json" {
t.Errorf("headers: %v", got)
}
if gotQuery != "limit=20&page=1" {
t.Errorf("query %q", gotQuery)
}
if len(movies) != 2 || movies[0].Title != "Dune" || movies[0].IDs.TMDB != 438631 || movies[0].Count != 1234 || movies[1].IDs.TMDB != 0 {
t.Errorf("trending: %+v", movies)
}
if note := MovieLists[0].Note(movies[0].Count); note != "1.2k watching" {
t.Errorf("note %q", note)
}
popular, err := c.Entries(context.Background(), MovieLists[3], 20)
if err != nil || len(popular) != 1 || popular[0].Title != "Heat" || popular[0].IDs.TMDB != 949 || MovieLists[3].Note(popular[0].Count) != "" {
t.Errorf("popular (bare items): %+v %v", popular, err)
}
shows, err := c.Entries(context.Background(), ShowLists[1], 20)
if err != nil || len(shows) != 1 || shows[0].IDs.TVDB != 393189 || shows[0].Count != 52000 || ShowLists[1].Note(52000) != "52k watched it" {
t.Errorf("most watched shows: %+v %v", shows, err)
}
box, err := c.Entries(context.Background(), MovieLists[4], 20)
if err != nil || box[0].Count != 48464322 || MovieLists[4].Note(box[0].Count) != "$48M last weekend" {
t.Errorf("box office: %+v %v", box, err)
}
}
func TestEntriesErrors(t *testing.T) {
if _, err := NewClient("", "ua").Entries(context.Background(), MovieLists[0], 5); !errors.Is(err, ErrNotConfigured) {
t.Errorf("no client id: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
c := NewClient("bad", "ua")
c.SetBaseURL(srv.URL)
if _, err := c.Entries(context.Background(), MovieLists[0], 5); err == nil || err.Error() != "trakt: the client ID was not accepted (HTTP 403)" {
t.Errorf("rejected id: %v", err)
}
}
func TestCompact(t *testing.T) {
for n, want := range map[int64]string{0: "0", 950: "950", 1000: "1k", 1234: "1.2k", 34567: "34k", 1500000: "1.5M", 48464322: "48M"} {
if got := Compact(n); got != want {
t.Errorf("Compact(%d) = %q, want %q", n, got, want)
}
}
}
func TestPeople(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.RequestURI())
switch r.URL.Path {
case "/search/tmdb/272":
w.Write([]byte(`[{"type":"movie","score":1000,"movie":{"title":"Batman Begins","year":2005,"ids":{"trakt":1,"slug":"batman-begins-2005","tmdb":272}}}]`))
case "/movies/1/people":
w.Write([]byte(`{"cast":[{"character":"Bruce Wayne","characters":["Bruce Wayne","Batman"],"person":{"name":"Christian Bale","ids":{"trakt":1}}},
{"character":"Alfred","characters":["Alfred"],"person":{"name":"Michael Caine","ids":{"trakt":2}}}],
"crew":{"directing":[{"job":"Director","jobs":["Director"],"person":{"name":"Christopher Nolan"}},{"job":"Assistant Director","jobs":["Assistant Director"],"person":{"name":"Someone"}}],
"writing":[{"job":"Screenplay","jobs":["Screenplay"],"person":{"name":"David S. Goyer"}}]}}`))
case "/search/tvdb/81189":
w.Write([]byte(`[{"type":"show","score":1000,"show":{"title":"Breaking Bad","year":2008,"ids":{"trakt":1388,"tvdb":81189}}}]`))
case "/shows/1388/people":
w.Write([]byte(`{"cast":[{"character":"Walter White","characters":["Walter White"],"episode_count":62,"person":{"name":"Bryan Cranston"}}],
"crew":{"created by":[{"job":"Creator","jobs":["Creator"],"person":{"name":"Vince Gilligan"}}]}}`))
case "/search/tmdb/5":
w.Write([]byte(`[]`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c := NewClient("id", "ua")
c.SetBaseURL(srv.URL)
movie, err := c.People(context.Background(), Ref{Kind: Movies, TMDB: 272, IMDB: "tt0372784"})
if err != nil {
t.Fatal(err)
}
if len(movie.Cast) != 2 || movie.Cast[0].Name != "Christian Bale" || movie.Cast[0].Role != "Bruce Wayne / Batman" {
t.Errorf("cast %+v", movie.Cast)
}
if len(movie.Leaders) != 1 || movie.Leaders[0].Name != "Christopher Nolan" {
t.Errorf("directors %+v", movie.Leaders)
}
if paths[0] != "/search/tmdb/272?type=movie" {
t.Errorf("lookup %q", paths[0])
}
show, err := c.People(context.Background(), Ref{Kind: Shows, TVDB: 81189})
if err != nil || show.Cast[0].Name != "Bryan Cranston" || len(show.Leaders) != 1 || show.Leaders[0].Name != "Vince Gilligan" {
t.Errorf("show %+v %v", show, err)
}
if _, err := c.People(context.Background(), Ref{Kind: Movies, TMDB: 5}); !errors.Is(err, ErrNotFound) {
t.Errorf("unknown title: %v", err)
}
if (Ref{Kind: Shows, TVDB: 81189}).Key() != "show:tvdb:81189" || (Ref{Kind: Movies}).Key() != "" {
t.Error("keys")
}
}
+69 -13
View File
@@ -17,6 +17,7 @@ import (
"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 {
@@ -31,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
@@ -96,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
@@ -163,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},
@@ -170,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},
@@ -198,6 +221,7 @@ 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
})
@@ -205,6 +229,7 @@ func New(cfg *config.Config) *App {
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"))
@@ -219,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, "/"):
@@ -277,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 {
@@ -425,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 {
@@ -513,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()
@@ -552,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 {
@@ -656,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 {
+71 -2
View File
@@ -9,6 +9,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
// result is one search hit, independent of which *arr produced it.
@@ -47,6 +48,10 @@ type backend interface {
add(ctx context.Context, r result, c addChoice) (id int, err error)
// interactive starts an interactive release search for a library item.
interactive(v *searchView, r result)
// lists are the Trakt lists offered while the search box is empty, and
// resolve looks a list entry up (nil without error if it is not found).
lists() []trakt.List
resolve(ctx context.Context, e trakt.Entry) (result, error)
}
// --- Radarr ---
@@ -67,7 +72,10 @@ func (m movieResult) withID(id int) result {
return m
}
type radarrBackend struct{ c *radarr.Client }
type radarrBackend struct {
c *radarr.Client
lookups *lookupStore
}
func (b radarrBackend) name() string { return "Radarr" }
func (b radarrBackend) noun() string { return "Movies" }
@@ -83,6 +91,35 @@ func (b radarrBackend) search(ctx context.Context, term string) ([]result, error
return out, err
}
func (radarrBackend) lists() []trakt.List { return trakt.MovieLists }
func (b radarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
id := e.IDs.TMDB
if id == 0 {
return nil, errNotListed
}
if m, ok, err := b.c.MovieByTMDB(ctx, id); err != nil {
return nil, err
} else if ok {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := radarr.TMDBTerm(id)
var m radarr.Movie
if b.lookups.get(key, &m) {
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, false)}, nil
}
movies, err := b.c.Lookup(ctx, key)
if err != nil || len(movies) == 0 {
return nil, err
}
m = movies[0]
if !m.InLibrary() {
b.lookups.put(key, m)
}
return movieResult{Movie: m, poster: coverFor(b.c.Client, m.Images, m.InLibrary())}, nil
}
func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
created, err := b.c.AddMovie(ctx, r.(movieResult).Movie, radarr.AddOptions{
QualityProfileID: c.profileID,
@@ -118,7 +155,10 @@ func (s seriesResult) withID(id int) result {
return s
}
type sonarrBackend struct{ c *sonarr.Client }
type sonarrBackend struct {
c *sonarr.Client
lookups *lookupStore
}
func (b sonarrBackend) name() string { return "Sonarr" }
func (b sonarrBackend) noun() string { return "Series" }
@@ -144,6 +184,35 @@ func (b sonarrBackend) search(ctx context.Context, term string) ([]result, error
return out, err
}
func (sonarrBackend) lists() []trakt.List { return trakt.ShowLists }
func (b sonarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
id := e.IDs.TVDB
if id == 0 {
return nil, errNotListed
}
if s, ok, err := b.c.SeriesByTVDB(ctx, id); err != nil {
return nil, err
} else if ok {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, true)}, nil
}
// Not in the library: the lookup from an earlier run, if fresh.
key := "tvdb:" + itoa(id)
var s sonarr.Series
if b.lookups.get(key, &s) {
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, false)}, nil
}
series, err := b.c.Lookup(ctx, key)
if err != nil || len(series) == 0 {
return nil, err
}
s = series[0]
if !s.InLibrary() {
b.lookups.put(key, s)
}
return seriesResult{Series: s, poster: coverFor(b.c.Client, s.Images, s.InLibrary())}, nil
}
func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
created, err := b.c.AddSeries(ctx, r.(seriesResult).Series, sonarr.AddOptions{
QualityProfileID: c.profileID,
+10 -1
View File
@@ -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
}
@@ -467,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)
}
+3 -3
View File
@@ -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)
}
}
+12
View File
@@ -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)
}
+65 -19
View File
@@ -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, large bool) ([]byte, error) // large: for real images
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.
@@ -524,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, large)
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]
+45
View File
@@ -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)
}
}
+12 -1
View File
@@ -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)
+59
View File
@@ -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 {
@@ -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")
}
}
+148
View File
@@ -0,0 +1,148 @@
package ui
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
discoverLimit = 20 // titles per Trakt list
discoverTTL = 15 * time.Minute // how long a fetched list is shown again without asking
resolveLimit = 4 // Radarr/Sonarr lookups at the same time
)
// discovered is a Trakt list, resolved into Radarr/Sonarr results.
type discovered struct {
results []result
notes []string // the list's number per result ("1.2k watching"), may be ""
err error
at time.Time
}
// showList shows the current Trakt list (UI goroutine): what the Movies and
// Series views show while the search box is empty.
func (v *searchView) showList() {
v.browsing = true
v.results, v.notes = nil, nil
l := v.lists[v.listIdx]
v.list.SetTitle(fmt.Sprintf(" %s · %s · [yellow][ ][-] other lists ", v.be.noun(), l.Name))
if !v.app.trakt.Configured() {
v.placeholder = "Trending, most watched and anticipated " + lower(v.be.noun()) +
" appear here once a Trakt client ID is set in the Config view (F6)."
v.render(0)
return
}
if d := v.discover[v.listIdx]; d != nil && time.Since(d.at) < discoverTTL {
v.applyList(d)
return
}
v.placeholder = "Loading " + l.Name + "…"
v.render(0)
idx := v.listIdx
if v.loadingList[idx] {
return
}
v.loadingList[idx] = true
go func() {
d := v.fetchList(l)
v.app.post(func() {
v.loadingList[idx] = false
v.discover[idx] = d
if v.browsing && v.listIdx == idx {
v.applyList(d)
}
})
}()
}
// applyList puts a fetched list on screen (UI goroutine).
func (v *searchView) applyList(d *discovered) {
v.results, v.notes, v.placeholder = d.results, d.notes, ""
if d.err != nil {
v.placeholder = "[red]" + tview.Escape(d.err.Error()) + "[-]"
} else if len(d.results) == 0 {
v.placeholder = "Nothing on this list right now."
}
v.render(0)
}
// fetchList reads a Trakt list and looks every title up in Radarr/Sonarr, in
// the list's order (off the UI goroutine). Titles that cannot be found are
// left out; if none can, the reason is kept.
func (v *searchView) fetchList(l trakt.List) *discovered {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
d := &discovered{at: time.Now()}
entries, err := v.app.trakt.Entries(ctx, l, discoverLimit)
if err != nil {
d.err = err
return d
}
found := make([]result, len(entries))
errs := make([]error, len(entries))
var wg sync.WaitGroup
slots := make(chan struct{}, resolveLimit)
for i, e := range entries {
wg.Add(1)
go func() {
defer wg.Done()
slots <- struct{}{}
defer func() { <-slots }()
found[i], errs[i] = v.be.resolve(ctx, e)
}()
}
wg.Wait()
var lastErr error
for i, r := range found {
if r != nil {
d.results = append(d.results, r)
d.notes = append(d.notes, l.Note(entries[i].Count))
} else if errs[i] != nil {
lastErr = errs[i]
}
}
if len(d.results) == 0 && lastErr != nil {
d.err = fmt.Errorf("%s: %w", v.be.name(), lastErr)
}
return d
}
// switchList moves to the next or previous Trakt list.
func (v *searchView) switchList(step int) {
n := len(v.lists)
v.listIdx = (v.listIdx + step + n) % n
v.showList()
}
// reloadList fetches the current list again.
func (v *searchView) reloadList() {
delete(v.discover, v.listIdx)
v.showList()
}
// forgetLists drops the fetched lists (the Trakt client ID or a service
// changed) and shows the current one afresh if it is on screen.
func (v *searchView) forgetLists() {
v.discover = map[int]*discovered{}
if v.browsing {
v.showList()
}
}
// errNotListed is returned for list titles Radarr/Sonarr cannot look up (no
// TMDb or TVDB id).
var errNotListed = errors.New("no id to look the title up with")
func lower(s string) string {
if s == "" {
return s
}
return string(s[0]|0x20) + s[1:]
}
+153
View File
@@ -0,0 +1,153 @@
package ui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gdamore/tcell/v2"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
)
func TestTraktListsInTheSearchView(t *testing.T) {
traktSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[
{"watchers":1234,"movie":{"title":"Dune","year":2021,"ids":{"trakt":1,"tmdb":438631}}},
{"watchers":900,"movie":{"title":"Unknown To Radarr","year":2020,"ids":{"trakt":2,"tmdb":1}}},
{"watchers":7,"movie":{"title":"No Ids","year":2019,"ids":{"trakt":3}}},
{"watchers":5,"movie":{"title":"Heat","year":1995,"ids":{"trakt":4,"tmdb":949}}}]`))
}))
defer traktSrv.Close()
radarrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("term") {
case "tmdb:438631":
w.Write([]byte(`[{"id":5,"title":"Dune","year":2021,"tmdbId":438631}]`))
case "tmdb:949":
w.Write([]byte(`[{"title":"Heat","year":1995,"tmdbId":949}]`))
default:
w.Write([]byte(`[]`))
}
}))
defer radarrSrv.Close()
a := newTestApp(t)
v := a.searches[0]
// Without a client ID the list says how to get one.
v.activate()
if !v.browsing || !strings.Contains(v.placeholder, "Trakt client ID") {
t.Fatalf("unconfigured: browsing %v, placeholder %q", v.browsing, v.placeholder)
}
a.radarr.Reconfigure(radarrSrv.URL, "k")
a.trakt.Reconfigure("id")
a.trakt.SetBaseURL(traktSrv.URL)
d := v.fetchList(v.lists[0])
if d.err != nil || len(d.results) != 2 {
t.Fatalf("fetched %d results, err %v", len(d.results), d.err)
}
// In the list's order; titles Radarr does not know, or without ids, are left out.
if d.results[0].title() != "Dune" || d.results[1].title() != "Heat" || !d.results[0].inLibrary() || d.results[1].inLibrary() {
t.Errorf("results %v %v", d.results[0].title(), d.results[1].title())
}
if strings.Join(d.notes, "|") != "1.2k watching|5 watching" {
t.Errorf("notes %q", d.notes)
}
// A fresh list is shown from memory, with its numbers and its name in the title.
v.discover[0] = d
v.showList()
if main, _ := v.list.GetItemText(0); !strings.Contains(main, "Dune") || !strings.Contains(main, "1.2k watching") {
t.Errorf("first row %q", main)
}
if title := v.list.GetTitle(); !strings.Contains(title, "Trending now") {
t.Errorf("title %q", title)
}
// ] and [ switch lists while browsing.
v.discover[1] = &discovered{at: time.Now()}
v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, ']', 0))
if v.listIdx != 1 || !strings.Contains(v.list.GetTitle(), "Most watched") || v.placeholder != "Nothing on this list right now." {
t.Errorf("after ]: list %d, title %q, placeholder %q", v.listIdx, v.list.GetTitle(), v.placeholder)
}
v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, '[', 0))
// From the empty search box too (where the cursor is when the view opens).
v.input.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, '[', 0))
if v.listIdx != len(v.lists)-1 || v.input.GetText() != "" {
t.Errorf("[ wraps around: list %d, search box %q", v.listIdx, v.input.GetText())
}
// Search results replace the list; their rows have no numbers.
v.setResults(d.results)
if v.browsing || v.notes != nil || strings.Contains(v.list.GetTitle(), "lists") {
t.Errorf("search results: browsing %v, title %q", v.browsing, v.list.GetTitle())
}
if main, _ := v.list.GetItemText(0); strings.Contains(main, "watching") {
t.Errorf("search row %q", main)
}
}
func TestListNumbersAreRightAligned(t *testing.T) {
sim := tcell.NewSimulationScreen("")
if err := sim.Init(); err != nil {
t.Fatal(err)
}
defer sim.Fini()
sim.SetSize(60, 6)
a := newTestApp(t)
v := a.searches[0]
a.trakt.Reconfigure("id")
v.discover[0] = &discovered{at: time.Now(),
results: []result{movieResult{Movie: radarr.Movie{Title: "Dune", Year: 2021}}, movieResult{Movie: radarr.Movie{Title: "A Much Longer Title", Year: 2024}}},
notes: []string{"1.2k watching", "5 watching"}}
v.showList()
ends := func(width int) []int {
sim.Clear()
l := notesList{v.list, v}
l.SetRect(0, 0, width, 6)
l.Draw(sim)
var out []int
for y := 1; y <= 2; y++ {
row := strings.TrimRight(rowText(sim, y, 0, width), " │")
out = append(out, len([]rune(row)))
if !strings.HasSuffix(row, "watching") {
t.Errorf("row %d does not end with its number: %q", y, row)
}
}
return out
}
if e := ends(60); e[0] != e[1] {
t.Errorf("numbers end in columns %v, want the same", e)
}
// After a resize they line up at the new edge.
if e := ends(44); e[0] != e[1] || e[0] > 44 {
t.Errorf("after resize: numbers end in columns %v", e)
}
}
func TestEnterAddsAndAIsAnOrdinaryKey(t *testing.T) {
a := newTestApp(t)
v := a.searches[0]
v.setResults([]result{movieResult{Movie: radarr.Movie{Title: "Dune", Year: 2021, TMDBID: 1}}})
// a is no longer a key of the list: it goes on to the list (which ignores it).
if got := v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'a', 0)); got == nil {
t.Error("a is still taken by the list")
}
// The hint for releases of a title not in the library names Enter.
v.interactive(0)
if msg := plainText(a.status.GetText(false)) + toastText(a); !strings.Contains(msg, "first (Enter)") {
t.Errorf("hint: %q", msg)
}
}
func toastText(a *App) string {
var b strings.Builder
for _, t := range a.toasts {
b.WriteString(t.text)
}
return b.String()
}
+227
View File
@@ -0,0 +1,227 @@
package ui
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io/fs"
"os"
"path/filepath"
"slices"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
posterTTL = 30 * 24 * time.Hour // a poster on disk is used this long
posterCacheCap = 200 << 20 // bytes of posters kept on disk
peopleTTL = 7 * 24 * time.Hour // a cast on disk is used this long
maxPeopleSaved = 2000 // casts kept (in memory and on disk)
// postersDir names the poster folder for the current way of storing them
// (v2: as decoded, at the size used). Folders of older ways are deleted by
// prunePosters: their files would otherwise be used as they are (v1 kept
// Radarr's 333×500 copies for real images).
postersDir = "posters-v2"
)
// oldPosterDirs are poster folders of earlier versions.
var oldPosterDirs = []string{"posters"}
// cacheDirFunc returns the directory for the disk cache, "" for none. It is a
// variable so tests can turn the cache off.
var cacheDirFunc = func() string {
dir, err := os.UserCacheDir() // $XDG_CACHE_HOME or ~/.cache, ~/Library/Caches on macOS
if err != nil {
return ""
}
return filepath.Join(dir, "arr-tui")
}
// diskCache keeps posters and casts between runs. With an empty dir it does
// nothing. Safe for use from any goroutine (each file is written atomically).
type diskCache struct{ dir string }
func newDiskCache() *diskCache { return &diskCache{dir: cacheDirFunc()} }
func (d *diskCache) posterPath(key string, large bool) string {
size := "small"
if large {
size = "large"
}
sum := sha256.Sum256([]byte(size + "|" + key))
return filepath.Join(d.dir, postersDir, hex.EncodeToString(sum[:16])+".img")
}
// readPoster returns the stored bytes of a poster, if they are not too old.
func (d *diskCache) readPoster(key string, large bool) ([]byte, bool) {
if d.dir == "" {
return nil, false
}
p := d.posterPath(key, large)
info, err := os.Stat(p)
if err != nil || time.Since(info.ModTime()) > posterTTL {
return nil, false
}
data, err := os.ReadFile(p)
return data, err == nil
}
// writePoster stores the bytes of a poster as downloaded.
func (d *diskCache) writePoster(key string, large bool, data []byte) {
if d.dir == "" {
return
}
_ = writeAtomic(d.posterPath(key, large), data)
}
// dropPoster removes a stored poster (it could not be read).
func (d *diskCache) dropPoster(key string, large bool) {
if d.dir != "" {
_ = os.Remove(d.posterPath(key, large))
}
}
// prunePosters deletes posters older than posterTTL, then the oldest ones
// until the rest fit posterCacheCap.
func (d *diskCache) prunePosters() {
if d.dir == "" {
return
}
type file struct {
path string
size int64
mod time.Time
}
for _, old := range oldPosterDirs {
_ = os.RemoveAll(filepath.Join(d.dir, old))
}
var files []file
var total int64
_ = filepath.WalkDir(filepath.Join(d.dir, postersDir), func(p string, e fs.DirEntry, err error) error {
if err != nil || e.IsDir() {
return nil
}
info, err := e.Info()
if err != nil {
return nil
}
if time.Since(info.ModTime()) > posterTTL {
_ = os.Remove(p)
return nil
}
files = append(files, file{p, info.Size(), info.ModTime()})
total += info.Size()
return nil
})
slices.SortFunc(files, func(a, b file) int { return a.mod.Compare(b.mod) })
for _, f := range files {
if total <= posterCacheCap {
break
}
if os.Remove(f.path) == nil {
total -= f.size
}
}
}
// savedPeople is a cast as stored on disk.
type savedPeople struct {
Saved time.Time `json:"saved"`
Cast []trakt.Person `json:"cast,omitempty"`
Leaders []trakt.Person `json:"leaders,omitempty"`
}
func (d *diskCache) peoplePath() string { return filepath.Join(d.dir, "people.json") }
// loadPeople reads the stored casts that are not too old.
func (d *diskCache) loadPeople() map[string]savedPeople {
out := map[string]savedPeople{}
if d.dir == "" {
return out
}
data, err := os.ReadFile(d.peoplePath())
if err != nil {
return out
}
var all map[string]savedPeople
if json.Unmarshal(data, &all) != nil {
return out // unreadable: start afresh, it is only a cache
}
for k, p := range all {
if time.Since(p.Saved) <= peopleTTL {
out[k] = p
}
}
return out
}
// savePeople writes the casts.
func (d *diskCache) savePeople(all map[string]savedPeople) error {
if d.dir == "" {
return nil
}
data, err := json.Marshal(all)
if err != nil {
return err
}
return writeAtomic(d.peoplePath(), data)
}
func (d *diskCache) lookupsPath() string { return filepath.Join(d.dir, "lookups.json") }
// loadLookups reads the stored Radarr/Sonarr lookups that are not too old.
func (d *diskCache) loadLookups() map[string]savedLookup {
out := map[string]savedLookup{}
if d.dir == "" {
return out
}
data, err := os.ReadFile(d.lookupsPath())
if err != nil {
return out
}
var all map[string]savedLookup
if json.Unmarshal(data, &all) != nil {
return out // unreadable: start afresh, it is only a cache
}
for k, l := range all {
if time.Since(l.Saved) <= lookupTTL {
out[k] = l
}
}
return out
}
// saveLookups writes the lookups.
func (d *diskCache) saveLookups(all map[string]savedLookup) error {
if d.dir == "" {
return nil
}
data, err := json.Marshal(all)
if err != nil {
return err
}
return writeAtomic(d.lookupsPath(), data)
}
// writeAtomic writes a file through a temporary file and a rename, creating
// the directory (private) if needed.
func writeAtomic(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // no-op after the rename
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+123
View File
@@ -0,0 +1,123 @@
package ui
import (
"os"
"path/filepath"
"testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func TestPostersOnDisk(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
if _, ok := d.readPoster("k", false); ok {
t.Fatal("empty cache returned a poster")
}
d.writePoster("k", false, []byte("SMALL"))
d.writePoster("k", true, []byte("LARGE"))
if data, ok := d.readPoster("k", false); !ok || string(data) != "SMALL" {
t.Errorf("small: %q %v", data, ok)
}
if data, ok := d.readPoster("k", true); !ok || string(data) != "LARGE" {
t.Errorf("large copies are kept apart: %q %v", data, ok)
}
if info, _ := os.Stat(filepath.Dir(d.posterPath("k", false))); info.Mode().Perm() != 0o700 {
t.Errorf("directory mode %v", info.Mode().Perm())
}
// Too old: not used, and pruned.
old := time.Now().Add(-posterTTL - time.Hour)
_ = os.Chtimes(d.posterPath("k", false), old, old)
if _, ok := d.readPoster("k", false); ok {
t.Error("an expired poster was used")
}
d.prunePosters()
if _, err := os.Stat(d.posterPath("k", false)); !os.IsNotExist(err) {
t.Error("an expired poster was not pruned")
}
if _, ok := d.readPoster("k", true); !ok {
t.Error("a fresh poster was pruned")
}
// Off: nothing is read or written.
off := &diskCache{}
off.writePoster("k", false, []byte("x"))
if _, ok := off.readPoster("k", false); ok {
t.Error("cache without a directory returned a poster")
}
}
func TestPrunePostersKeepsTheNewestWithinTheCap(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
big := make([]byte, posterCacheCap/2+1) // two of these do not fit
for i, key := range []string{"oldest", "middle", "newest"} {
d.writePoster(key, false, big)
at := time.Now().Add(time.Duration(i-3) * time.Hour)
_ = os.Chtimes(d.posterPath(key, false), at, at)
}
d.prunePosters()
for key, want := range map[string]bool{"oldest": false, "middle": false, "newest": true} {
if _, ok := d.readPoster(key, false); ok != want {
t.Errorf("%s kept: %v, want %v", key, ok, want)
}
}
}
func TestPeopleOnDisk(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
all := map[string]savedPeople{
"movie:tmdb:1": {Saved: time.Now(), Cast: []trakt.Person{{Name: "A", Role: "B"}}, Leaders: []trakt.Person{{Name: "C"}}},
"movie:tmdb:2": {Saved: time.Now().Add(-peopleTTL - time.Hour), Cast: []trakt.Person{{Name: "Old"}}},
"show:tvdb:3": {Saved: time.Now()}, // Trakt had no cast: remembered as such
}
if err := d.savePeople(all); err != nil {
t.Fatal(err)
}
got := d.loadPeople()
if len(got) != 2 || got["movie:tmdb:1"].Cast[0].Role != "B" || got["movie:tmdb:1"].Leaders[0].Name != "C" {
t.Errorf("loaded %+v", got)
}
if _, ok := got["show:tvdb:3"]; !ok {
t.Error("an empty cast was not kept")
}
// A damaged file is ignored.
_ = os.WriteFile(d.peoplePath(), []byte("{not json"), 0o600)
if len(d.loadPeople()) != 0 {
t.Error("damaged file")
}
}
func TestPeopleCacheKeepsTheNewest(t *testing.T) {
a := newTestApp(t)
for i := 0; i < maxPeopleSaved+5; i++ {
a.remember(trakt.Ref{Kind: trakt.Movies, TMDB: i + 1}.Key(), trakt.People{})
a.people.saved[trakt.Ref{Kind: trakt.Movies, TMDB: i + 1}.Key()] = time.Unix(int64(i), 0)
}
a.people.timer.Stop()
if len(a.people.done) != maxPeopleSaved {
t.Errorf("%d casts kept", len(a.people.done))
}
if _, ok := a.people.done["movie:tmdb:1"]; ok {
t.Error("the oldest cast was kept")
}
if !a.people.dirty || len(a.peopleSnapshot()) != maxPeopleSaved {
t.Error("new casts must be written")
}
}
func TestPostersOfOlderVersionsAreDropped(t *testing.T) {
d := &diskCache{dir: t.TempDir()}
old := filepath.Join(d.dir, "posters", "abc.img")
if err := writeAtomic(old, []byte("v1")); err != nil {
t.Fatal(err)
}
d.writePoster("k", true, []byte("v2"))
d.prunePosters()
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Error("a poster of the old format was kept")
}
if data, ok := d.readPoster("k", true); !ok || string(data) != "v2" {
t.Error("the current poster was dropped")
}
}
+108 -13
View File
@@ -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.
+49 -2
View File
@@ -11,6 +11,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
// movieLabel is the one-line list entry for a movie.
@@ -29,6 +30,9 @@ func movieLabel(m radarr.Movie) string {
// state) and the plot below it.
type detailText struct {
head, plot string
// people names the title for its cast, loaded from Trakt when it is shown
// (zero: none).
people trakt.Ref
}
// String joins both parts, for places that show them as one text.
@@ -39,6 +43,49 @@ func (d detailText) String() string {
return d.head + "\n" + d.plot
}
func movieRef(m radarr.Movie) trakt.Ref {
return trakt.Ref{Kind: trakt.Movies, TMDB: m.TMDBID, IMDB: m.IMDBID}
}
func seriesRef(s sonarr.Series) trakt.Ref {
return trakt.Ref{Kind: trakt.Shows, TVDB: s.TVDBID, TMDB: s.TMDBID, IMDB: s.IMDBID}
}
// peopleText renders the cast and directors (creators) of a title for the
// details pane, at most maxCast actors.
func peopleText(p trakt.People, kind trakt.Kind) string {
const maxCast = 8
var b strings.Builder
if len(p.Leaders) > 0 {
label := "Director"
if kind == trakt.Shows {
label = "Created by"
} else if len(p.Leaders) > 1 {
label = "Directors"
}
names := make([]string, len(p.Leaders))
for i, l := range p.Leaders {
names[i] = tview.Escape(l.Name)
}
fmt.Fprintf(&b, "[::b]%s[::-] %s\n", label, strings.Join(names, ", "))
}
if len(p.Cast) > 0 {
b.WriteString("[::b]Cast[::-]\n")
for i, c := range p.Cast {
if i == maxCast {
fmt.Fprintf(&b, "[gray]and %d more[-]\n", len(p.Cast)-maxCast)
break
}
b.WriteString(tview.Escape(c.Name))
if c.Role != "" {
b.WriteString(" [gray]as " + tview.Escape(c.Role) + "[-]")
}
b.WriteByte('\n')
}
}
return strings.TrimRight(b.String(), "\n")
}
// movieDetails renders the detail pane text for a movie.
func movieDetails(m radarr.Movie) detailText {
var b strings.Builder
@@ -73,7 +120,7 @@ func movieDetails(m radarr.Movie) detailText {
case m.InLibrary():
b.WriteString("[yellow]In library, not downloaded[-]\n")
}
return detailText{head: b.String(), plot: tview.Escape(m.Overview)}
return detailText{head: b.String(), plot: tview.Escape(m.Overview), people: movieRef(m)}
}
// profileIndex returns the index of the profile named name, or 0.
@@ -149,7 +196,7 @@ func seriesDetails(s sonarr.Series) detailText {
fmt.Fprintf(&b, "[yellow]In library, %d/%d episodes downloaded[-]\n", st.EpisodeFileCount, st.EpisodeCount)
}
}
return detailText{head: b.String(), plot: tview.Escape(s.Overview)}
return detailText{head: b.String(), plot: tview.Escape(s.Overview), people: seriesRef(s)}
}
func plural(n int) string {
+8 -6
View File
@@ -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,11 +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"},
{"←→, Enter", "the default quality profile and root folder: pick from the lists the server has (loaded with the address and key in the fields)"},
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
{"Test", "(button below the 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{
+93 -8
View File
@@ -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
}
+50
View File
@@ -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))
}
}
+3 -3
View File
@@ -129,10 +129,10 @@ func staticOptions(o []string) func(filterPool) []string {
var facetDefs = []facet{
{"Quality", staticOptions(resOptions), func(f attrFilter) string { return f.res }, func(f attrFilter, v string) attrFilter { f.res = v; return f }, nil, nil},
{"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = v; return f }, nil, nil},
{"Codec", staticOptions(codecOptions), func(f attrFilter) string { return f.codec }, func(f attrFilter, v string) attrFilter { f.codec = v; return f }, nil, nil},
{"HDR", staticOptions(hdrOptions), func(f attrFilter) string { return f.hdr }, func(f attrFilter, v string) attrFilter { f.hdr = v; return f }, nil, nil},
{"Audio", staticOptions(audioOptions), func(f attrFilter) string { return f.audio }, func(f attrFilter, v string) attrFilter { f.audio = v; return f }, nil, nil},
{"Language", filterPool.languages, func(f attrFilter) string { return f.lang }, func(f attrFilter, v string) attrFilter { f.lang = v; return f }, nil, nil},
}
// listedValues returns the options a facet offers: those that, together with
@@ -153,8 +153,8 @@ func listedValues(f facet, state attrFilter, pool filterPool) []string {
return vals
}
// openAttrFilter shows the dialog to pick quality, codec, HDR, audio and
// language. Every change is passed to set immediately so the list behind the
// openAttrFilter shows the dialog to pick quality, language, codec, HDR and
// audio (the most used first). Every change is passed to set immediately so the list behind the
// dialog follows along, also while moving through an open list; Cancel and Esc
// restore cur. Entries that would leave no results are not offered. The
// returned function re-evaluates them when the pool has changed; onClose runs
+123
View File
@@ -0,0 +1,123 @@
package ui
import (
"context"
"fmt"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// libQueueInterval is how often the Library (and a series' seasons and
// episodes) ask for the download queues while they are visible.
const libQueueInterval = 10 * time.Second
// dlInfo says that a library title is in the Radarr/Sonarr download queue.
type dlInfo struct {
count int // queue entries (episodes of a series)
progress float64 // percent, of the first entry
timeLeft string // of the first entry
}
// dlLine is the details pane line for a title in the queue.
func (d dlInfo) dlLine() string {
if d.count > 1 {
return fmt.Sprintf("[blue]Downloading[-] %d episodes\n", d.count)
}
line := fmt.Sprintf("[blue]Downloading[-] %.0f%%", d.progress)
if d.timeLeft != "" {
line += " · " + d.timeLeft + " left"
}
return line + "\n"
}
// queueByTitle groups queue entries by the library title they belong to.
func queueByTitle(items []arr.QueueItem, series bool) map[int]dlInfo {
out := map[int]dlInfo{}
for _, q := range items {
id := q.MovieID
if series {
id = q.SeriesID
}
if id == 0 {
continue
}
d, seen := out[id]
d.count++
if !seen {
d.progress, d.timeLeft = q.Progress(), q.TimeLeft
}
out[id] = d
}
return out
}
// queueByEpisode picks the queue entries of one series, by episode.
func queueByEpisode(items []arr.QueueItem, seriesID int) map[int]dlInfo {
out := map[int]dlInfo{}
for _, q := range items {
if q.SeriesID != seriesID || q.EpisodeID == 0 {
continue
}
if _, seen := out[q.EpisodeID]; !seen {
out[q.EpisodeID] = dlInfo{count: 1, progress: q.Progress(), timeLeft: q.TimeLeft}
}
}
return out
}
// every runs f on the UI goroutine every d until stop is called.
func (a *App) every(d time.Duration, f func()) (stop func()) {
done := make(chan struct{})
go func() {
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
a.post(f)
}
}
}()
return func() { close(done) }
}
// loadQueue fetches both download queues and marks the titles in them.
// Failures (a service down or not configured) leave the marks as they were.
func (v *libraryView) loadQueue() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
movies, merr := v.radarr.Queue(ctx)
series, serr := v.sonarr.Queue(ctx)
v.app.post(func() {
if merr == nil {
v.downloading[kindMovies] = queueByTitle(movies, false)
}
if serr == nil {
v.downloading[kindSeries] = queueByTitle(series, true)
}
if merr == nil || serr == nil {
v.render()
}
})
}()
}
// startQueueWatch refreshes the queue marks while the Library is visible.
func (v *libraryView) startQueueWatch() {
v.loadQueue()
if v.queueStop == nil {
v.queueStop = v.app.every(libQueueInterval, v.loadQueue)
}
}
// deactivate stops the queue watch when the Library is left.
func (v *libraryView) deactivate() {
if v.queueStop != nil {
v.queueStop()
v.queueStop = nil
}
}
+25 -2
View File
@@ -67,6 +67,19 @@ var libFilters = []struct {
{"unmonitored", func(i libItem) bool { return !i.monitored }},
}
// libMark is the mark before a library title: blue while it is in the
// download queue, else green when monitored; filled when monitored, hollow
// when not.
func libMark(monitored, downloading bool) string {
if !downloading {
return monMark(monitored)
}
if monitored {
return "[blue]●[-]"
}
return "[blue]○[-]"
}
func monMark(monitored bool) string {
if monitored {
return "[green]●[-]"
@@ -215,6 +228,10 @@ type libraryView struct {
filePending int
refreshDialog func() // re-evaluates the options of the open filter dialog
// Titles in the Radarr/Sonarr download queue, by kind and id (libqueue.go).
downloading [2]map[int]dlInfo
queueStop func()
}
type seriesFiles struct {
@@ -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.
@@ -404,7 +422,8 @@ func (v *libraryView) renderPane(p *libPane) {
header(p.table, 0, "Title", "Status", "Size")
selRow := 0
for i, it := range p.shown {
title := titleCell{prefix: monMark(it.monitored) + " ", title: it.label}
_, 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))
@@ -532,7 +551,11 @@ func containsItem(items []libItem, id int) bool {
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()
+101
View File
@@ -1,6 +1,9 @@
package ui
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
@@ -308,3 +311,101 @@ func TestCutToWidth(t *testing.T) {
}
}
}
func TestSeasonPoster(t *testing.T) {
seasonSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/broken.jpg" {
http.NotFound(w, r)
return
}
w.Write([]byte("SEASON"))
}))
defer seasonSrv.Close()
series := coverRef{key: "series", fetch: func(context.Context, bool) ([]byte, error) { return []byte("SERIES"), nil }}
v := &seriesView{cover: series, series: sonarr.Series{Seasons: []sonarr.Season{
{SeasonNumber: 1, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/s1.jpg"}}},
{SeasonNumber: 2},
{SeasonNumber: 3, Images: []arr.Image{{CoverType: "poster", RemoteURL: seasonSrv.URL + "/broken.jpg"}}},
}}}
fetch := func(season int) string {
data, err := v.seasonCover(season).fetch(t.Context(), false)
if err != nil {
t.Fatal(err)
}
return string(data)
}
if got := fetch(1); got != "SEASON" || v.seasonCover(1).key == series.key {
t.Errorf("season 1: %q", got)
}
if v.seasonCover(2).key != series.key {
t.Error("a season without a poster shows the series poster")
}
if got := fetch(3); got != "SERIES" {
t.Errorf("a season poster that cannot be loaded falls back to the series poster: %q", got)
}
}
func TestDownloadingTitlesAreMarked(t *testing.T) {
movies := queueByTitle([]arr.QueueItem{
{MovieID: 3, Size: 100, SizeLeft: 55, TimeLeft: "00:06:12"},
{MovieID: 0}, // not matched to a movie
}, false)
series := queueByTitle([]arr.QueueItem{{SeriesID: 7, Size: 10, SizeLeft: 10}, {SeriesID: 7}, {MovieID: 3}}, true)
if len(movies) != 1 || movies[3].count != 1 || movies[3].progress != 45 || movies[3].dlLine() != "[blue]Downloading[-] 45% · 00:06:12 left\n" {
t.Errorf("movies %+v", movies)
}
if len(series) != 1 || series[7].count != 2 || series[7].dlLine() != "[blue]Downloading[-] 2 episodes\n" {
t.Errorf("series %+v", series)
}
a := newTestApp(t)
v := newLibraryView(a, a.radarr, a.sonarr)
p := v.panes[kindMovies]
p.all, p.loaded = []libItem{
{id: 3, title: "Dune", label: "Dune (2021)", monitored: true, details: detailText{head: "Dune\n"}},
{id: 4, title: "Heat", label: "Heat (1995)", monitored: true},
}, true
v.downloading[kindMovies] = movies
v.render()
cell := func(row int) string { return p.table.GetCell(row, 0).Text }
if !strings.HasPrefix(cell(1), "[blue]●[-]") || !strings.HasPrefix(cell(2), "[green]●[-]") {
t.Errorf("marks: %q / %q", cell(1), cell(2))
}
p.table.Select(1, 0)
v.renderDetails()
if !strings.Contains(v.details.detail.head, "Downloading[-] 45%") {
t.Errorf("details %q", v.details.detail.head)
}
if libMark(false, true) != "[blue]○[-]" {
t.Error("an unmonitored title in the queue keeps the hollow mark")
}
}
func TestEpisodesInTheQueue(t *testing.T) {
got := queueByEpisode([]arr.QueueItem{
{SeriesID: 7, EpisodeID: 70, Size: 10, SizeLeft: 5, TimeLeft: "00:01:00"},
{SeriesID: 7, EpisodeID: 71},
{SeriesID: 8, EpisodeID: 80}, // another series
{SeriesID: 7}, // no episode
}, 7)
if len(got) != 2 || got[70].progress != 50 || got[70].timeLeft != "00:01:00" {
t.Fatalf("queueByEpisode: %+v", got)
}
s := sonarr.Series{Title: "Signal Fire"}
ep := &sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, Title: "Ember", Monitored: true}
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", got[70]); !strings.Contains(d.head, "Downloading[-] 50% · 00:01:00 left") {
t.Errorf("episode: %q", d.head)
}
sum := seasonSummary{number: 3, total: 10, have: 4}
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 1}); !strings.Contains(d.head, "Downloading[-] 1 episode\n") {
t.Errorf("season with one: %q", d.head)
}
if d := seriesRowDetail(s, nil, 3, sum, true, "2026-01-01", dlInfo{count: 2}); !strings.Contains(d.head, "Downloading[-] 2 episodes") {
t.Errorf("season with two: %q", d.head)
}
if d := seriesRowDetail(s, ep, 3, seasonSummary{}, true, "2026-01-01", dlInfo{}); strings.Contains(d.head, "Downloading") {
t.Errorf("not downloading: %q", d.head)
}
}
+95
View File
@@ -0,0 +1,95 @@
package ui
import (
"encoding/json"
"maps"
"slices"
"sync"
"time"
)
const (
lookupTTL = 3 * 24 * time.Hour // a stored lookup is used this long
maxLookupsSaved = 2000 // lookups kept (in memory and on disk)
lookupSaveDelay = 5 * time.Second // new lookups are written to disk this long after the last one
)
// lookupStore keeps the Radarr/Sonarr lookups of Trakt list titles that are
// not in the library, so a list shows at once after a restart: a lookup asks
// the metadata provider and takes about a second, while whether a title is
// in the library is asked of the server every time (that is quick, and must
// not be stale). Safe for use from any goroutine; read from disk on first use.
type lookupStore struct {
disk *diskCache
mu sync.Mutex
loaded bool
entries map[string]savedLookup
dirty bool
timer *time.Timer
}
// savedLookup is one lookup result as Radarr/Sonarr sent it.
type savedLookup struct {
Saved time.Time `json:"saved"`
Data json.RawMessage `json:"data"`
}
func newLookupStore(disk *diskCache) *lookupStore { return &lookupStore{disk: disk} }
func (s *lookupStore) load() {
if !s.loaded {
s.loaded, s.entries = true, s.disk.loadLookups()
}
}
// get decodes the stored lookup of key into v, if there is a fresh one.
func (s *lookupStore) get(key string, v any) bool {
s.mu.Lock()
defer s.mu.Unlock()
s.load()
e, ok := s.entries[key]
if !ok || time.Since(e.Saved) > lookupTTL {
return false
}
return json.Unmarshal(e.Data, v) == nil
}
// put stores a lookup, dropping the oldest ones beyond maxLookupsSaved, and
// has it written to disk soon.
func (s *lookupStore) put(key string, v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.load()
s.entries[key] = savedLookup{Saved: time.Now(), Data: data}
if len(s.entries) > maxLookupsSaved {
keys := slices.SortedFunc(maps.Keys(s.entries), func(x, y string) int {
return s.entries[x].Saved.Compare(s.entries[y].Saved)
})
for _, k := range keys[:len(keys)-maxLookupsSaved] {
delete(s.entries, k)
}
}
s.dirty = true
if s.timer != nil {
s.timer.Stop()
}
s.timer = time.AfterFunc(lookupSaveDelay, func() { _ = s.save() })
}
// save writes the lookups to disk if there are new ones.
func (s *lookupStore) save() error {
s.mu.Lock()
if !s.dirty {
s.mu.Unlock()
return nil
}
s.dirty = false
all := maps.Clone(s.entries)
s.mu.Unlock()
return s.disk.saveLookups(all)
}
+63
View File
@@ -0,0 +1,63 @@
package ui
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
func TestListLookupsKeptOnDisk(t *testing.T) {
var lookups, libraryAsks atomic.Int32
inLibrary := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/movie":
libraryAsks.Add(1)
if inLibrary && r.URL.Query().Get("tmdbId") == "949" {
w.Write([]byte(`[{"id":7,"title":"Heat","year":1995,"tmdbId":949}]`))
return
}
w.Write([]byte(`[{"id":3,"title":"Other","tmdbId":1}]`)) // ignores the filter
case "/api/v3/movie/lookup":
lookups.Add(1)
w.Write([]byte(`[{"title":"Heat","year":1995,"tmdbId":949,"overview":"Cops and robbers."}]`))
}
}))
defer srv.Close()
disk := &diskCache{dir: t.TempDir()}
entry := trakt.Entry{Title: "Heat", IDs: trakt.IDs{TMDB: 949}}
resolve := func() result {
t.Helper()
store := newLookupStore(disk) // a new run
b := radarrBackend{radarr.NewClient(srv.URL, "k"), store}
r, err := b.resolve(context.Background(), entry)
if err != nil || r == nil {
t.Fatalf("resolve: %v %v", r, err)
}
if err := store.save(); err != nil {
t.Fatal(err)
}
return r
}
if r := resolve(); r.inLibrary() || lookups.Load() != 1 {
t.Fatalf("first run: in library %v, %d lookups", r.inLibrary(), lookups.Load())
}
r := resolve()
if lookups.Load() != 1 || r.details().plot != "Cops and robbers." {
t.Errorf("second run: %d lookups, plot %q", lookups.Load(), r.details().plot)
}
// Added meanwhile (elsewhere): the library answer wins over the stored lookup.
inLibrary = true
if r := resolve(); !r.inLibrary() || lookups.Load() != 1 {
t.Errorf("after adding: in library %v, %d lookups", r.inLibrary(), lookups.Load())
}
if libraryAsks.Load() != 3 {
t.Errorf("library asked %d times", libraryAsks.Load())
}
}
+1
View File
@@ -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())
}
+166
View File
@@ -0,0 +1,166 @@
package ui
import (
"context"
"errors"
"slices"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
peopleRetryWait = 2 * time.Minute // a failed lookup is not repeated sooner
peopleSaveDelay = 5 * time.Second // new casts are written to disk this long after the last one
)
// peopleCache holds the casts loaded from Trakt (also those kept on disk from
// earlier runs, see diskcache.go) and the lookups in flight. UI goroutine only.
type peopleCache struct {
done map[string]trakt.People
saved map[string]time.Time // when each cast was fetched
failed map[string]time.Time
waiting map[string][]func(trakt.People, error)
dirty bool // casts not written to disk yet
timer *time.Timer // writes them
}
func newPeopleCache() *peopleCache {
return &peopleCache{done: map[string]trakt.People{}, saved: map[string]time.Time{}, failed: map[string]time.Time{},
waiting: map[string][]func(trakt.People, error){}}
}
// loadSavedPeople fills the cache with the casts stored on disk.
func (a *App) loadSavedPeople() {
for k, p := range a.disk.loadPeople() {
a.people.done[k] = trakt.People{Cast: p.Cast, Leaders: p.Leaders}
a.people.saved[k] = p.Saved
}
}
// remember adds a fetched cast, dropping the oldest ones beyond
// maxPeopleSaved, and has it written to disk soon.
func (a *App) remember(key string, p trakt.People) {
c := a.people
c.done[key], c.saved[key] = p, time.Now()
if len(c.done) > maxPeopleSaved {
keys := make([]string, 0, len(c.saved))
for k := range c.saved {
keys = append(keys, k)
}
slices.SortFunc(keys, func(x, y string) int { return c.saved[x].Compare(c.saved[y]) })
for _, k := range keys[:len(keys)-maxPeopleSaved] {
delete(c.done, k)
delete(c.saved, k)
}
}
c.dirty = true
if c.timer != nil {
c.timer.Stop()
}
c.timer = time.AfterFunc(peopleSaveDelay, func() { a.post(a.savePeople) })
}
// savePeople writes the casts to disk if there are new ones (UI goroutine;
// the writing itself happens in the background).
func (a *App) savePeople() {
c := a.people
if !c.dirty {
return
}
c.dirty = false
all := a.peopleSnapshot()
go func() { _ = a.disk.savePeople(all) }()
}
func (a *App) peopleSnapshot() map[string]savedPeople {
c := a.people
all := make(map[string]savedPeople, len(c.done))
for k, p := range c.done {
all[k] = savedPeople{Saved: c.saved[k], Cast: p.Cast, Leaders: p.Leaders}
}
return all
}
// loadPeople delivers the cast of a title to done on the UI goroutine,
// asking Trakt in the background when it is not cached. A title Trakt does
// not know is remembered as having no cast.
func (a *App) loadPeople(r trakt.Ref, done func(trakt.People, error)) {
c, key := a.people, r.Key()
if p, ok := c.done[key]; ok {
done(p, nil)
return
}
if t, ok := c.failed[key]; ok && time.Since(t) < peopleRetryWait {
done(trakt.People{}, errors.New("cast not available"))
return
}
if _, inflight := c.waiting[key]; inflight {
c.waiting[key] = append(c.waiting[key], done)
return
}
c.waiting[key] = []func(trakt.People, error){done}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
p, err := a.trakt.People(ctx, r)
if errors.Is(err, trakt.ErrNotFound) {
p, err = trakt.People{}, nil
}
a.post(func() {
if a.people != c {
return // dropped meanwhile (the client ID changed)
}
waiters := c.waiting[key]
delete(c.waiting, key)
if err != nil {
c.failed[key] = time.Now()
} else {
a.remember(key, p)
}
for _, w := range waiters {
w(p, err)
}
})
}()
}
// showPeople loads the cast of the title the pane shows, once the selection
// rests (like the poster: holding an arrow key must not send a request per
// step, Trakt allows 500 per five minutes).
func (d *detailPane) showPeople(r trakt.Ref) {
key := r.Key()
if key == d.castKey {
return // the same title again (a refresh): keep what is shown
}
if d.castSettle != nil {
d.castSettle.Stop()
}
d.castKey, d.cast = key, ""
if key == "" || !d.app.trakt.Configured() {
return
}
now := time.Now()
moving := now.Sub(d.castShown) < posterSettle
d.castShown = now
load := func() {
d.app.loadPeople(r, func(p trakt.People, err error) {
if d.castKey != key || err != nil {
return
}
d.cast = peopleText(p, r.Kind)
d.laidOut.valid = false
})
}
if moving {
d.castSettle = time.AfterFunc(posterSettle, func() {
d.app.post(func() {
if d.castKey == key {
load()
}
})
})
return
}
load()
}
+4
View File
@@ -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) })
}
})
}()
}
+91 -15
View File
@@ -14,6 +14,7 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
@@ -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)
+23 -3
View File
@@ -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)
@@ -161,7 +165,7 @@ 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 {
@@ -195,6 +199,8 @@ func newSettingsView(app *App) *settingsView {
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)
@@ -295,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 {
@@ -316,6 +325,7 @@ func (v *settingsView) fill(s settingsValues) {
"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)
@@ -366,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(),
@@ -441,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)
@@ -451,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[-]")
})
}()
}
+4 -3
View File
@@ -282,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)" {
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
out/
+92
View File
@@ -0,0 +1,92 @@
# arr-tui for LG webOS TVs
The same views as the terminal app, as a TV app driven by the remote:
- **Movies** and **Series**: search Radarr / Sonarr. With an empty search box you get the Trakt lists
(trending, most watched this week, anticipated, popular, US box office). Add a title, or open
releases and other actions for library titles.
- **Library**: movies and series, each with its own status, title and file-property filters
(quality, language, codec, HDR, audio). A blue dot marks titles that are downloading. A series
opens its seasons and episodes.
- **Downloads**: the SABnzbd queue and history and the Radarr / Sonarr queues. Pause / resume,
delete, retry, remove and blocklist.
- **Calendar**: two weeks of movie releases and airing episodes.
- **Settings** and **Log**.
- **Release search** with approval and rejection reasons, sort, filters and grab.
- **Details pane** with poster, plot and cast from Trakt.
It is a plain web app (TypeScript, no framework) that talks to Radarr, Sonarr, SABnzbd and Trakt
directly: all four answer cross-origin requests, so the TV needs nothing else running.
## The remote
| Button | Does |
|---|---|
| ◀ ▶ ▲ ▼ | move; in a list ▲ ▼ move the selection, at its ends they leave it |
| OK | press; on a title, the menu of what can be done with it |
| Back | close a dialog or page; in a view, back to its tab; on the tabs, leave the app |
| 1–7 | open a view |
| CH ▲ / ▼ | page through a list |
| Red / Green | previous / next Trakt list or calendar week; Library: movies ⇄ series |
| Yellow | filter by file properties (Library); pause / resume SABnzbd (Downloads) |
| Blue | reload |
The footer shows what OK and the colour buttons do in the current view. With the Magic Remote's
pointer, hovering focuses and clicking presses. In a desktop browser, F1–F4 stand in for the
colour buttons and Escape for Back.
## Configuration
The build bakes your arr-tui config into the app: `$ARR_TUI_CONFIG`, else
`~/.config/arr-tui/config.json` (or `node build.mjs --config FILE`). On the TV, **Settings**
changes the addresses, keys, defaults, Trakt client ID and theme. Those changes are kept on the TV
until "Reset to built-in".
The package then holds your API keys. Keep it private and never publish it.
Addresses must not redirect. The terminal app follows a redirect from `http://` to `https://`; a
browser does not for cross-origin requests, so enter the final (usually `https://`) address.
## Install on the TV (Developer Mode)
1. On the TV, install **Developer Mode** from the LG Content Store and sign in with an LG
developer account (free, [webostv.developer.lge.com](https://webostv.developer.lge.com)). Turn
on **Dev Mode Status** and **Key Server**, and note the IP address and the passphrase.
2. Once, on the computer (Node.js 22 or newer):
```sh
cd webos
npm install
npx ares-setup-device -a tv -i "host=TV_IP" -i "port=9922" -i "username=prisoner"
npx ares-novacom --device tv --getkey # asks for the passphrase
```
3. Build, package, install and start:
```sh
npm run package # dist/ and out/com.stinnesbeck.arrtui_<version>_all.ipk
npm run install-tv
npm run launch
```
A Developer Mode session lasts 50 hours; the Developer Mode app extends it, otherwise the TV removes
developer apps. On a rooted TV with the Homebrew Channel the `.ipk` can be installed without that
limit.
The app targets webOS 5 (2020 TVs, Chromium 68) and newer.
## Development
`nix develop` in the repository has Node.js and Chromium (and sets `$CHROMIUM`).
```sh
npm run typecheck
npm test # unit tests (Node's test runner)
npm run demo # dist/ with the fake services of dev/screenshots
python3 ../dev/screenshots/server.py & # the fake Radarr, Sonarr, SABnzbd and Trakt
CHROMIUM=$(command -v chromium) node dev/drive.mjs SCENE OUTDIR
```
`dev/drive.mjs` opens `dist/index.html` in Chromium at 1920×1080, presses remote buttons from a
scene file (`key Down Enter Red`, `type text`, `sleep 1`, `shot name`, `log`) and takes screenshots.
The page can also be opened in any desktop browser (`dist/index.html`).
+150
View File
@@ -0,0 +1,150 @@
// Builds the webOS app into dist/: the bundled script, the page, the icons
// and config.js with the arr-tui config baked in.
//
// node build.mjs config from $ARR_TUI_CONFIG or ~/.config/arr-tui/config.json
// node build.mjs --config FILE another config file
// node build.mjs --demo the fake services of dev/screenshots (server.py)
// node build.mjs --dev not minified
//
// The baked-in config holds API keys: never publish a package built with it.
import * as esbuild from "esbuild";
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
const here = dirname(fileURLToPath(import.meta.url));
const dist = join(here, "dist");
const args = process.argv.slice(2);
const flag = (name) => args.includes(name);
const option = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
// The oldest browser engine targeted: webOS 5 (2020 TVs) has Chromium 68.
const target = "chrome68";
function configPath() {
if (option("--config")) return option("--config");
if (process.env.ARR_TUI_CONFIG) return process.env.ARR_TUI_CONFIG;
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
return join(base, "arr-tui", "config.json");
}
function demoConfig() {
const key = "0123456789abcdef0123456789abcdef";
return {
radarr: { url: "http://127.0.0.1:19891", apikey: key, default_quality_profile: "Ultra-HD", default_root_folder: "/movies" },
sonarr: { url: "http://127.0.0.1:19892", apikey: key, default_quality_profile: "HD-1080p", default_root_folder: "/tv" },
sabnzbd: { url: "http://127.0.0.1:19893", apikey: key },
trakt: { client_id: key },
ui: { theme: "dark" },
};
}
// pick keeps the settings the TV app uses, nothing else from the file.
function pick(c) {
const svc = (s = {}) => ({ url: s.url ?? "", apikey: s.apikey ?? "" });
const arr = (s = {}) => ({ ...svc(s), default_quality_profile: s.default_quality_profile, default_root_folder: s.default_root_folder });
const ui = c.ui ?? {};
return {
radarr: arr(c.radarr),
sonarr: arr(c.sonarr),
sabnzbd: svc(c.sabnzbd),
trakt: { client_id: c.trakt?.client_id },
ui: { theme: ui.theme, refresh_seconds: ui.refresh_seconds, covers: ui.covers, notify_downloads: ui.notify_downloads },
};
}
function loadConfig() {
if (flag("--demo")) return { config: demoConfig(), from: "the demo services", extra: 'window.ARR_TUI_TRAKT_URL = "http://127.0.0.1:19894";\n' };
const path = configPath();
if (!existsSync(path)) {
console.warn(`no config at ${path}: the app starts with Settings`);
return { config: pick({}), from: "nothing", extra: "" };
}
return { config: pick(JSON.parse(readFileSync(path, "utf8"))), from: path, extra: "" };
}
// --- Icons: drawn here, so no image tools are needed ---
function png(size, draw) {
const px = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) px.set(draw(x + 0.5, y + 0.5, size), (y * size + x) * 4);
const raw = Buffer.alloc((size * 4 + 1) * size);
for (let y = 0; y < size; y++) px.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4);
const chunk = (type, data) => {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(td) >>> 0);
return Buffer.concat([len, td, crc]);
};
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
}
function crc32(buf) {
let c = ~0;
for (const b of buf) {
c ^= b;
for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
}
return ~c;
}
// The icon: a dark rounded square with three yellow list bars of different
// lengths and a blue dot (a title downloading), as in the Library.
function icon(x, y, s) {
const u = (v) => v * s;
const r = u(0.18);
const inside = (() => {
const cx = Math.min(Math.max(x, r), s - r);
const cy = Math.min(Math.max(y, r), s - r);
return (x - cx) ** 2 + (y - cy) ** 2 <= r * r;
})();
if (!inside) return [0, 0, 0, 0];
const bars = [[0.26, 0.42, 0.74], [0.45, 0.42, 0.62], [0.64, 0.42, 0.70]];
for (const [top, left, right] of bars) {
if (y >= u(top) && y <= u(top + 0.1) && x >= u(left) && x <= u(right)) return [0xf2, 0xb6, 0x32, 255];
}
const dots = [[0.31, [0x7f, 0xcc, 0x6e]], [0.50, [0x5f, 0xad, 0xf2]], [0.69, [0x7f, 0xcc, 0x6e]]];
for (const [cy, rgb] of dots) {
if ((x - u(0.3)) ** 2 + (y - u(cy)) ** 2 <= u(0.055) ** 2) return [...rgb, 255];
}
return [0x1d, 0x20, 0x27, 255];
}
// --- Build ---
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
for (const f of readdirSync(join(here, "static"))) copyFileSync(join(here, "static", f), join(dist, f));
writeFileSync(join(dist, "icon.png"), png(80, icon));
writeFileSync(join(dist, "largeIcon.png"), png(130, icon));
const { config, from, extra } = loadConfig();
writeFileSync(join(dist, "config.js"), `// Built from ${from === "nothing" || from === "the demo services" ? from : "the arr-tui config"}. Holds API keys: keep this package private.\nwindow.ARR_TUI_CONFIG = ${JSON.stringify(config, null, 2)};\n${extra}`, { mode: 0o600 });
await esbuild.build({
entryPoints: [join(here, "src", "main.ts")],
bundle: true,
outfile: join(dist, "app.js"),
format: "iife",
target,
minify: !flag("--dev"),
sourcemap: flag("--dev") ? "inline" : false,
legalComments: "none",
logLevel: "warning",
});
const services = ["radarr", "sonarr", "sabnzbd"].filter((s) => config[s].url).join(", ") || "none";
console.log(`dist/ built (config from ${from}; services: ${services}${config.trakt.client_id ? ", Trakt" : ""})`);
+101
View File
@@ -0,0 +1,101 @@
// Runs the built app (dist/) in Chromium at 1920×1080 from file://, as the
// TV loads it, presses remote buttons from a scene file and takes
// screenshots. For development and the README pictures.
//
// node dev/drive.mjs SCENE OUTDIR
//
// CHROMIUM (default: chromium on PATH). Scene lines:
// key Down [Down ...] remote buttons: Left Right Up Down Enter Back Red
// Green Yellow Blue PageUp PageDown 1-7
// type TEXT type into the focused field
// sleep SECONDS
// point X Y / click X Y the Magic Remote's pointer: move there / click there
// shot NAME OUTDIR/NAME.png
// log print the app's console and the focused element
import puppeteer from "puppeteer-core";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const [scenePath, outDir] = process.argv.slice(2);
if (!scenePath || !outDir) {
console.error("usage: node dev/drive.mjs SCENE OUTDIR");
process.exit(2);
}
mkdirSync(outDir, { recursive: true });
const codes = {
Left: 37, Up: 38, Right: 39, Down: 40, Enter: 13, Back: 461,
Red: 403, Green: 404, Yellow: 405, Blue: 406, PageUp: 33, PageDown: 34,
};
for (let i = 1; i <= 9; i++) codes[String(i)] = 48 + i;
const browser = await puppeteer.launch({
executablePath: process.env.CHROMIUM || "chromium",
headless: true,
// file:// pages may call the local fake services.
args: ["--window-size=1920,1080", "--allow-file-access-from-files", "--no-sandbox"],
defaultViewport: { width: 1920, height: 1080 },
});
const page = await browser.newPage();
const logs = [];
// Request URLs in browser messages can carry an API key (SABnzbd, posters).
const redact = (t) => t.replace(/(apikey=)[^&\s'"]+/gi, "$1…");
page.on("console", (m) => logs.push(redact(`${m.type()}: ${m.text()}`)));
page.on("pageerror", (e) => logs.push(redact(`pageerror: ${e.message}`)));
await page.goto("file://" + resolve(here, "..", "dist", "index.html"));
// press sends a remote button as the TV does: a keydown with its keyCode.
async function press(name) {
const code = codes[name];
if (!code) throw new Error("unknown key " + name);
await page.evaluate((code) => {
const target = document.activeElement || document.body;
const ev = new KeyboardEvent("keydown", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "keyCode", { get: () => code });
Object.defineProperty(ev, "which", { get: () => code });
target.dispatchEvent(ev);
}, code);
await new Promise((r) => setTimeout(r, 120));
}
for (const raw of readFileSync(scenePath, "utf8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const [cmd, ...rest] = line.split(/\s+/);
switch (cmd) {
case "key":
for (const k of rest) await press(k);
break;
case "type":
await page.keyboard.type(line.slice(5));
break;
case "point":
await page.mouse.move(Number(rest[0]), Number(rest[1]));
break;
case "click":
await page.mouse.click(Number(rest[0]), Number(rest[1]));
break;
case "sleep":
await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000));
break;
case "shot":
await page.screenshot({ path: join(outDir, rest[0] + ".png") });
break;
case "log": {
const focused = await page.evaluate(() => {
const a = document.activeElement;
return a ? `${a.tagName}.${a.className} "${(a.textContent || "").slice(0, 40)}"` : "none";
});
console.log(logs.splice(0).join("\n"));
console.log("focused:", focused);
break;
}
default:
throw new Error("unknown scene command: " + line);
}
}
if (logs.length) console.log(logs.join("\n"));
await browser.close();
+5
View File
@@ -0,0 +1,5 @@
// Not Go: this file keeps webos/ (and the Go files some npm packages ship
// in node_modules) out of the arr-tui module, so `go test ./...` skips it.
module git.stinnesbeck.com/nils/arr-tui/webos
go 1.26
+7099
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+260
View File
@@ -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;
}
}
+64
View File
@@ -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(/\/+$/, "");
}
+113
View File
@@ -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 });
}
}
+124
View File
@@ -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 });
}
}
+174
View File
@@ -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 });
}
}
+168
View File
@@ -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";
}
+191
View File
@@ -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();
});
+86
View File
@@ -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;
}
+82
View File
@@ -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)}`;
}
+65
View File
@@ -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;
}
+137
View File
@@ -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();
+200
View File
@@ -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);
});
}
+33
View File
@@ -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"];
}
+10
View File
@@ -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);
}
+106
View File
@@ -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;
}
+42
View File
@@ -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);
}
+278
View File
@@ -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;
}
+188
View File
@@ -0,0 +1,188 @@
// ListView: a focusable list with its own selection, like the TUI's tables.
// Up and down move the selection (and leave the list at its ends), channel
// up / down page, OK activates. Only the rows in view are in the DOM, so a
// library of thousands of titles stays quick on TV hardware.
import { h, replace } from "./dom.ts";
import { Keys, type KeyHandling } from "./focus.ts";
export interface Row<T> {
item?: T; // undefined: a heading (not selectable)
key: string;
}
export interface ListOptions<T> {
rowHeight: number; // pixels
render: (item: T, selected: boolean) => HTMLElement;
heading?: (key: string) => HTMLElement;
onSelect?: (item: T | undefined) => void;
onActivate?: (item: T) => void;
placeholder?: string;
header?: HTMLElement; // column titles, above the rows
}
export class ListView<T> {
el: HTMLElement & KeyHandling;
private body: HTMLElement;
private rows: Row<T>[] = [];
private sel = -1;
private top = 0;
private opts: ListOptions<T>;
private placeholder: string;
constructor(cls: string, opts: ListOptions<T>) {
this.opts = opts;
this.placeholder = opts.placeholder ?? "";
this.body = h("div.list-body");
opts.header?.classList.add("list-row");
this.el = h(`div.list.f.${cls}`, {}, opts.header ?? null, this.body) as HTMLElement & KeyHandling;
this.el.tabIndex = -1;
this.el.onKey = (ev) => this.key(ev);
this.el.addEventListener("focus", () => this.draw());
this.el.addEventListener("blur", () => this.draw());
this.el.addEventListener("wheel", (ev) => {
ev.preventDefault();
this.move(ev.deltaY > 0 ? 3 : -3);
}, { passive: false });
this.body.addEventListener("click", (ev) => {
const rowEl = (ev.target as Element).closest("[data-i]") as HTMLElement | null;
if (!rowEl) return;
const i = Number(rowEl.dataset.i);
if (i === this.sel) this.activate();
else this.select(i);
});
new ResizeObserverLike(this.body, () => this.draw());
}
get selected(): T | undefined {
return this.rows[this.sel]?.item;
}
get length(): number {
return this.rows.filter((r) => r.item !== undefined).length;
}
setPlaceholder(text: string): void {
this.placeholder = text;
if (!this.rows.length) this.draw();
}
// setRows replaces the rows, keeping the selection on the same key, or at
// the same place if that row is gone (removed: the next one moves up).
setRows(rows: Row<T>[], keepPlace = true): void {
const oldKey = this.rows[this.sel]?.key;
const oldIndex = this.sel;
this.rows = rows;
let sel = oldKey !== undefined ? rows.findIndex((r) => r.key === oldKey) : -1;
if (sel < 0 && keepPlace && oldIndex >= 0) sel = Math.min(oldIndex, rows.length - 1);
if (sel < 0) sel = 0;
this.sel = this.nearestItem(sel, 1);
if (this.sel < 0) this.sel = this.nearestItem(rows.length - 1, -1);
this.draw();
this.opts.onSelect?.(this.selected);
}
setItems(items: T[], key: (t: T) => string): void {
this.setRows(items.map((item) => ({ item, key: key(item) })));
}
// redraw re-renders the visible rows (an item changed in place).
redraw(): void {
this.draw();
}
select(i: number): void {
const n = this.nearestItem(i, i >= this.sel ? 1 : -1);
if (n < 0 || n === this.sel) return;
this.sel = n;
this.draw();
this.opts.onSelect?.(this.selected);
}
selectKey(key: string): void {
const i = this.rows.findIndex((r) => r.key === key);
if (i >= 0) this.select(i);
}
private nearestItem(i: number, step: number): number {
for (let j = i; j >= 0 && j < this.rows.length; j += step) if (this.rows[j].item !== undefined) return j;
return -1;
}
private activate(): void {
const it = this.selected;
if (it !== undefined) this.opts.onActivate?.(it);
}
private move(delta: number): boolean {
const target = this.nearestItem(Math.max(0, Math.min(this.rows.length - 1, this.sel + delta)), delta > 0 ? 1 : -1);
const fallback = this.nearestItem(Math.max(0, Math.min(this.rows.length - 1, this.sel + delta)), delta > 0 ? -1 : 1);
const n = target >= 0 ? target : fallback;
if (n < 0 || n === this.sel) return false;
this.select(n);
return true;
}
private key(ev: KeyboardEvent): boolean {
switch (ev.keyCode) {
case Keys.up:
return this.move(-1);
case Keys.down:
return this.move(1);
case Keys.pageUp:
this.move(-this.visibleRows());
return true;
case Keys.pageDown:
this.move(this.visibleRows());
return true;
case Keys.enter:
this.activate();
return true;
}
return false;
}
private visibleRows(): number {
return Math.max(1, Math.floor(this.body.clientHeight / this.opts.rowHeight));
}
private draw(): void {
const n = this.visibleRows();
if (this.sel < this.top) this.top = this.sel;
if (this.sel >= this.top + n) this.top = this.sel - n + 1;
this.top = Math.max(0, Math.min(this.top, Math.max(0, this.rows.length - n)));
const focused = document.activeElement === this.el;
const out: HTMLElement[] = [];
if (!this.rows.length && this.placeholder) out.push(h("div.list-placeholder", this.placeholder));
for (let i = this.top; i < Math.min(this.rows.length, this.top + n); i++) {
const row = this.rows[i];
let el: HTMLElement;
if (row.item === undefined) {
el = this.opts.heading ? this.opts.heading(row.key) : h("div.list-heading", row.key);
} else {
el = this.opts.render(row.item, i === this.sel);
el.dataset.i = String(i);
if (i === this.sel) el.classList.add(focused ? "sel" : "sel-idle");
}
el.classList.add("list-row");
el.style.height = this.opts.rowHeight + "px";
out.push(el);
}
if (this.rows.length > n) {
const pct = this.top / (this.rows.length - n);
const bar = h("div.list-scroll", h("div.list-thumb", { style: `height:${Math.max(8, (n / this.rows.length) * 100)}%;top:${pct * (100 - Math.max(8, (n / this.rows.length) * 100))}%` }));
out.push(bar);
}
replace(this.body, ...out);
}
}
// ResizeObserverLike redraws when the element changes size: ResizeObserver
// (Chrome 64+), else on window resizes.
class ResizeObserverLike {
constructor(el: HTMLElement, cb: () => void) {
const RO = (window as unknown as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
if (RO) new RO(() => cb()).observe(el);
else window.addEventListener("resize", cb);
}
}
+206
View File
@@ -0,0 +1,206 @@
// Dialogs, action menus, option pickers, buttons and toasts.
import { append, h, replace } from "./dom.ts";
import { focus, focusFirst, Keys, popLayer, pushLayer, type KeyHandling } from "./focus.ts";
export function button(label: string, onClick: () => void, cls = ""): HTMLButtonElement {
return h(`button.btn.f${cls ? "." + cls : ""}` as "button", { onclick: onClick, tabindex: -1 }, label);
}
// Dialog: a centred box over everything; Back closes it.
export class Dialog {
el: HTMLElement;
box: HTMLElement;
private onClose?: () => void;
private closed = false;
constructor(title: string, cls = "", onClose?: () => void) {
this.onClose = onClose;
this.box = h("div.dialog-box", h("h2", title));
if (cls) this.box.classList.add(cls);
this.el = h("div.dialog", this.box);
}
add(...children: (Node | string | null)[]): this {
append(this.box, ...children);
return this;
}
// open shows the dialog; called again (content changed), it only moves
// the focus.
open(focusEl?: HTMLElement): this {
if (this.closed) return this;
if (!this.el.isConnected) {
document.getElementById("overlays")!.appendChild(this.el);
pushLayer(this.el, () => this.close());
}
if (focusEl) focus(focusEl);
else focusFirst(this.el);
return this;
}
close(): void {
if (this.closed) return;
this.closed = true;
popLayer(this.el);
this.el.remove();
this.onClose?.();
}
}
export interface Action {
label: string;
run: () => void;
danger?: boolean;
hint?: string;
}
// actionSheet lists what can be done with an item; OK runs one.
export function actionSheet(title: string, actions: Action[]): void {
const d = new Dialog(title, "sheet");
for (const a of actions) {
const b = button(a.label, () => {
d.close();
a.run();
}, a.danger ? "danger" : "");
if (a.hint) b.appendChild(h("span.btn-hint", a.hint));
d.add(b);
}
d.add(button("Cancel", () => d.close(), "ghost"));
d.open();
}
export function confirmDialog(message: string, yes: string, run: () => void, danger = false): void {
const d = new Dialog("Are you sure?");
d.add(h("p.dialog-text", message));
const no = button("Cancel", () => d.close(), "ghost");
d.add(h("div.row-buttons", button(yes, () => {
d.close();
run();
}, danger ? "danger" : ""), no));
d.open(no);
}
// Choice: a value picked from options with left / right (or OK for the
// next one), the TV-friendly dropdown.
export class Choice<V> {
el: HTMLElement & KeyHandling;
private valueEl: HTMLElement;
options: { label: string; value: V }[] = [];
index = 0;
onChange?: (v: V) => void;
constructor(label: string, options: { label: string; value: V }[], value?: V, onChange?: (v: V) => void) {
this.valueEl = h("span.choice-value");
this.el = h("div.choice.f", { tabindex: -1 }, h("span.choice-label", label), h("span.choice-arrows", "‹", this.valueEl, "›")) as HTMLElement & KeyHandling;
this.onChange = onChange;
this.setOptions(options, value);
this.el.onKey = (ev) => {
if (ev.keyCode !== Keys.left && ev.keyCode !== Keys.right) return false;
this.step(ev.keyCode === Keys.left ? -1 : 1);
return true;
};
this.el.addEventListener("click", (ev) => {
const r = this.el.getBoundingClientRect();
const x = (ev as MouseEvent).clientX;
// A pointer click on the left third goes back; OK and other clicks forward.
this.step(x > 0 && x < r.left + r.width / 3 ? -1 : 1);
});
}
get value(): V {
return this.options[this.index]?.value;
}
setOptions(options: { label: string; value: V }[], value?: V): void {
this.options = options;
const i = value === undefined ? -1 : options.findIndex((o) => o.value === value);
this.index = i >= 0 ? i : Math.min(this.index, Math.max(0, options.length - 1));
this.show();
}
private step(d: number): void {
if (this.options.length < 2) return;
this.index = (this.index + d + this.options.length) % this.options.length;
this.show();
this.onChange?.(this.value);
}
private show(): void {
replace(this.valueEl, this.options[this.index]?.label ?? "–");
}
}
// Toggle: a checkbox; OK flips it.
export class Toggle {
el: HTMLElement;
value: boolean;
onChange?: (v: boolean) => void;
constructor(label: string, value: boolean, onChange?: (v: boolean) => void) {
this.value = value;
this.onChange = onChange;
this.el = h("div.toggle.f", { tabindex: -1, onclick: () => this.set(!this.value, true) }, h("span.toggle-box"), h("span", label));
this.set(value, false);
}
set(v: boolean, notify: boolean): void {
this.value = v;
this.el.classList.toggle("on", v);
if (notify) this.onChange?.(v);
}
}
export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } {
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off", readOnly: true });
if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value));
return { el: h("label.field", h("span.field-label", label), input), input };
}
// --- Toasts and the log ---
export type Level = "info" | "ok" | "warn" | "error";
export interface LogEntry {
at: Date;
level: Level;
text: string;
}
export const log: LogEntry[] = [];
const logListeners: (() => void)[] = [];
export function onLog(f: () => void): void {
logListeners.push(f);
}
// toast shows a message for a few seconds and keeps it in the log.
export function toast(text: string, level: Level = "info"): void {
log.push({ at: new Date(), level, text });
if (log.length > 500) log.splice(0, log.length - 500);
for (const f of logListeners) f();
const box = document.getElementById("toasts");
if (!box) return;
const el = h(`div.toast.toast-${level}` as "div", text);
box.appendChild(el);
while (box.children.length > 4) box.firstElementChild!.remove();
setTimeout(() => el.classList.add("gone"), level === "error" ? 7000 : 4000);
setTimeout(() => el.remove(), level === "error" ? 7600 : 4600);
}
export function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// run executes an action with a "…" toast and a result toast.
export async function run(doing: string, done: string, f: () => Promise<unknown>): Promise<boolean> {
toast(doing + "…");
try {
await f();
toast(done, "ok");
return true;
} catch (e) {
toast(`${doing} failed: ${errorText(e)}`, "error");
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
// What can be done with a library title, shared by Movies, Series, Library
// and Calendar.
import { app } from "../app.ts";
import type { Movie } from "../api/radarr.ts";
import type { Series } from "../api/sonarr.ts";
import { titleYear } from "../describe.ts";
import { h } from "../ui/dom.ts";
import { actionSheet, button, Dialog, run, Toggle, type Action } from "../ui/widgets.ts";
import { openEpisodes } from "./episodes.ts";
import { openReleases } from "./releases.ts";
export function movieReleases(m: Movie): void {
openReleases("Releases for " + titleYear(m), (signal) => app.radarr.movieReleases(m.id, signal), (r) => app.radarr.grab(r));
}
// movieActions: changed runs after something changed the movie (reload).
export function movieActions(m: Movie, changed: () => void, removed?: () => void): Action[] {
const name = titleYear(m);
return [
{ label: "Search releases", hint: "interactive", run: () => movieReleases(m) },
{ label: "Automatic search", run: () => void run("Searching " + name, "Search started: " + name, () => app.radarr.searchMovie(m.id)) },
{
label: m.monitored ? "Unmonitor" : "Monitor",
run: () => void run(name + ": " + (m.monitored ? "unmonitoring" : "monitoring"), "Done: " + name, () => app.radarr.setMonitored(m.id, !m.monitored)).then((ok) => ok && changed()),
},
{ label: "Remove from library…", danger: true, run: () => removeDialog(name, false, (files, excl) => app.radarr.deleteMovie(m.id, files, excl), removed ?? changed) },
];
}
export function seriesActions(s: Series, changed: () => void, removed?: () => void): Action[] {
const name = titleYear(s);
return [
{ label: "Seasons and episodes", run: () => openEpisodes(s, changed) },
{
label: "Search all missing episodes",
run: () => actionSheet(`Search all missing episodes of ${name}?`, [
{ label: "Search", run: () => void run("Searching " + name, "Search started: " + name, () => app.sonarr.searchSeries(s.id)) },
]),
},
{
label: s.monitored ? "Unmonitor" : "Monitor",
run: () => void run(name + ": " + (s.monitored ? "unmonitoring" : "monitoring"), "Done: " + name, () => app.sonarr.setMonitored(s.id, !s.monitored)).then((ok) => ok && changed()),
},
{ label: "Remove from library…", danger: true, run: () => removeDialog(name, true, (files, excl) => app.sonarr.deleteSeries(s.id, files, excl), removed ?? changed) },
];
}
export function showMovieActions(m: Movie, changed: () => void, removed?: () => void): void {
actionSheet(titleYear(m), movieActions(m, changed, removed));
}
export function showSeriesActions(s: Series, changed: () => void, removed?: () => void): void {
actionSheet(titleYear(s), seriesActions(s, changed, removed));
}
// removeDialog asks before removing; files and the import exclusion are
// separate choices, both off.
function removeDialog(name: string, series: boolean, remove: (files: boolean, exclusion: boolean) => Promise<void>, removed: () => void): void {
const d = new Dialog("Remove " + name);
const files = new Toggle(series ? "Delete all episode files" : "Delete the movie's files", false);
const excl = new Toggle("Do not add again from import lists", false);
const cancel = button("Cancel", () => d.close(), "ghost");
d.add(
h("p.dialog-text", "The title leaves the library. Files stay on disk unless you tick the box."),
files.el,
excl.el,
h("div.row-buttons", button("Remove", () => {
d.close();
void run("Removing " + name, "Removed " + name, () => remove(files.value, excl.value)).then((ok) => ok && removed());
}, "danger"), cancel),
);
d.open(cancel);
}
+70
View File
@@ -0,0 +1,70 @@
// The add dialog: quality profile, root folder, monitoring and an optional
// automatic search, with the defaults from the config.
import { app } from "../app.ts";
import type { QualityProfile, RootFolder } from "../api/arr.ts";
import type { Movie } from "../api/radarr.ts";
import { monitorModes, type Series } from "../api/sonarr.ts";
import { titleYear } from "../describe.ts";
import { formatSize } from "../format.ts";
import { h } from "../ui/dom.ts";
import { button, Choice, Dialog, errorText, toast, Toggle } from "../ui/widgets.ts";
export type AddTarget = { kind: "movie"; movie: Movie } | { kind: "series"; series: Series };
// openAdd asks for the options and adds the title; added gets the new id.
export function openAdd(t: AddTarget, added: (id: number) => void): void {
const client = t.kind === "movie" ? app.radarr : app.sonarr;
const defaults = t.kind === "movie" ? app.cfg.radarr : app.cfg.sonarr;
const name = t.kind === "movie" ? titleYear(t.movie) : titleYear(t.series);
const d = new Dialog("Add " + name);
const loading = h("p.dialog-text", "Loading quality profiles and root folders…");
d.add(loading).open();
Promise.all([client.qualityProfiles(), client.rootFolders()]).then(([profiles, folders]) => {
loading.remove();
build(profiles, folders);
}, (e) => {
loading.textContent = `${client.name}: ${errorText(e)}`;
d.add(button("Close", () => d.close()));
d.open();
});
function build(profiles: QualityProfile[], folders: RootFolder[]): void {
if (!profiles.length || !folders.length) {
d.add(h("p.dialog-text", `${client.name} has no ${profiles.length ? "root folders" : "quality profiles"} set up.`), button("Close", () => d.close()));
d.open();
return;
}
const profile = new Choice("Quality profile", profiles.map((p) => ({ label: p.name, value: p.id })),
profiles.find((p) => p.name === defaults.default_quality_profile)?.id);
const folder = new Choice("Root folder", folders.map((f) => ({ label: `${f.path} (${formatSize(f.freeSpace)} free)`, value: f.path })),
folders.find((f) => f.path === defaults.default_root_folder)?.path);
const monitored = new Toggle("Monitored", true);
const mode = t.kind === "series" ? new Choice("Monitor", monitorModes.map(([label, value]) => ({ label, value })), "all") : null;
const search = new Toggle("Search now", true);
// Radarr/Sonarr skip unmonitored items when searching.
const sync = () => {
const nothing = !monitored.value || mode?.value === "none";
search.el.classList.toggle("f-off", nothing);
if (nothing) search.set(false, false);
};
monitored.onChange = sync;
if (mode) mode.onChange = sync;
const addBtn = button("Add", () => {
addBtn.disabled = true;
const opts = { qualityProfileId: profile.value, rootFolderPath: folder.value, monitored: monitored.value, searchNow: search.value };
const req = t.kind === "movie" ? app.radarr.addMovie(t.movie, opts) : app.sonarr.addSeries(t.series, { ...opts, monitor: mode!.value });
req.then((created) => {
d.close();
toast(`Added ${name}` + (opts.searchNow ? ", searching" : ""), "ok");
added(created?.id ?? 0);
}, (e) => {
addBtn.disabled = false;
toast("Adding failed: " + errorText(e), "error");
});
});
d.add(profile.el, folder.el, monitored.el, mode?.el ?? null, search.el, h("div.row-buttons", addBtn, button("Cancel", () => d.close(), "ghost")));
d.open(addBtn);
}
}
+218
View File
@@ -0,0 +1,218 @@
// Calendar: two weeks of movie releases and airing episodes, by day.
// Ported from internal/ui/calendar.go.
import { app } from "../app.ts";
import type { Movie } from "../api/radarr.ts";
import type { Episode } from "../api/sonarr.ts";
import { episodeCode, movieDetail, titleYear } from "../describe.ts";
import { addDays, dayLabel, isoDay } from "../format.ts";
import { DetailPane, type Detail } from "../ui/detail.ts";
import { h, replace } from "../ui/dom.ts";
import { focus, Keys } from "../ui/focus.ts";
import { ListView, type Row } from "../ui/list.ts";
import { actionSheet, button, errorText, run, Toggle } from "../ui/widgets.ts";
import { movieReleases } from "./actions.ts";
import { openReleases } from "./releases.ts";
import type { Hint, View } from "./view.ts";
const days = 14;
const step = 7;
interface Entry {
key: string;
day: string; // YYYY-MM-DD, local
at: number; // ms, 0 for date-only entries (movie releases)
title: string;
sub: string;
have: boolean;
monitored: boolean;
missing: boolean;
detail: () => Detail;
movie?: Movie;
episode?: Episode;
}
// releaseDay reads a movie release date: midnight UTC meaning "this date",
// taken as written rather than shifted into the local zone.
export function releaseDay(s: string | undefined): string {
return s && /^\d{4}-\d{2}-\d{2}/.test(s) ? s.slice(0, 10) : "";
}
export function movieEntries(m: Movie, from: string, to: string, today: string): Entry[] {
const out: Entry[] = [];
for (const [name, date] of [["In cinemas", m.inCinemas], ["Digital release", m.digitalRelease], ["Physical release", m.physicalRelease]] as const) {
const day = releaseDay(date);
if (!day || day < from || day > to) continue;
out.push({
key: `m${m.id}/${name}/${day}`, day, at: 0, title: titleYear(m), sub: name,
have: m.hasFile, monitored: m.monitored, missing: m.monitored && !m.hasFile && day <= today,
detail: () => {
const d = movieDetail(m);
d.lines.push({ text: `Release: ${name}, ${new Date(day + "T12:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric" })}` });
return d;
},
movie: m,
});
}
return out;
}
export function episodeEntry(e: Episode, from: string, to: string, now: Date): Entry | null {
let day = "";
let at = 0;
const t = e.airDateUtc ? Date.parse(e.airDateUtc) : NaN;
if (!isNaN(t)) {
at = t;
day = isoDay(new Date(t));
} else if (e.airDate) day = e.airDate;
if (!day || day < from || day > to) return null;
const series = e.series?.title ?? "Unknown series";
const sub = `${episodeCode(e.seasonNumber, e.episodeNumber)} ${e.title}`;
const aired = at ? at < now.getTime() : day < isoDay(now);
return {
key: "e" + e.id, day, at, title: series, sub,
have: e.hasFile, monitored: e.monitored, missing: e.monitored && !e.hasFile && aired,
detail: () => {
const when = at ? new Date(at).toLocaleString("en-GB", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
: new Date(day + "T12:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
return {
title: series,
sub,
facts: [],
lines: [
{ text: "Airs " + when },
e.hasFile ? { text: "Downloaded", cls: "ok" } : !e.monitored ? { text: "Not monitored", cls: "muted" } : { text: "Not downloaded", cls: "warn" },
],
plot: e.overview || e.series?.overview || "",
poster: e.series ? app.sonarr.posterURL(e.series.images, true, true) : "",
ref: e.series ? { kind: "show", tvdb: e.series.tvdbId, imdb: e.series.imdbId } : undefined,
};
},
episode: e,
};
}
export class CalendarView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "releases and search"], ["red", "week back"], ["green", "week forward"], ["blue", "reload"]];
private offset = 0;
private unmonitored = false;
private range: HTMLElement;
private list: ListView<Entry>;
private detail = new DetailPane();
private errors: string[] = [];
constructor() {
this.range = h("div.cal-range");
const unmon = new Toggle("Unmonitored too", false, (v) => {
this.unmonitored = v;
this.reload();
});
this.list = new ListView<Entry>("calendar", {
rowHeight: 50,
heading: (key) => h("div.cal-day", key.slice(2)),
render: (e) => h("div.cal-row",
h("span.muted", e.at ? new Date(e.at).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : ""),
h(`span.dot${e.monitored ? ".on" : ""}` as "span", e.monitored ? "●" : "○"),
h("span.ellipsis.bold", e.title),
h("span.ellipsis", e.sub),
h(`span.${e.have ? "ok" : e.missing ? "warn" : "muted"}` as "span", e.have ? "✓ downloaded" : e.missing ? "missing" : e.monitored ? "" : "unmonitored"),
),
onSelect: (e) => this.detail.show(e ? e.detail() : null),
onActivate: (e) => this.activate(e),
placeholder: "Loading…",
});
this.el = h("div.view.calendar-view",
h("div.cal-main",
h("div.toolbar.fgroup",
button("◀ Week", () => this.move(-step)), button("Today", () => this.move(-this.offset)), button("Week ▶", () => this.move(step)),
unmon.el, this.range),
this.list.el),
this.detail.el,
);
}
show(): void {
this.reload();
if (!this.el.contains(document.activeElement)) focus(this.list.el);
}
hide(): void {}
keys(code: number): boolean {
switch (code) {
case Keys.red:
this.move(-step);
return true;
case Keys.green:
this.move(step);
return true;
case Keys.blue:
this.reload();
return true;
}
return false;
}
private move(d: number): void {
this.offset += d;
this.reload();
}
private reload(): void {
const now = new Date();
const today = isoDay(now);
const from = isoDay(addDays(now, this.offset));
const to = isoDay(addDays(now, this.offset + days - 1));
replace(this.range, `${new Date(from + "T12:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short" })} – ${new Date(to + "T12:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })}`);
// Radarr's window is by release date; a day on either side catches
// dates that the zone moves.
const qFrom = isoDay(addDays(now, this.offset - 1));
const qTo = isoDay(addDays(now, this.offset + days));
this.errors = [];
const movies = app.radarr.configured()
? app.radarr.calendar(qFrom, qTo, this.unmonitored).then((ms) => ([] as Entry[]).concat(...ms.map((m) => movieEntries(m, from, to, today))), (e) => {
this.errors.push("Radarr: " + errorText(e));
return [] as Entry[];
})
: Promise.resolve([] as Entry[]);
const episodes = app.sonarr.configured()
? app.sonarr.calendar(qFrom, qTo, this.unmonitored).then((es) => es.map((e) => episodeEntry(e, from, to, now)).filter((x): x is Entry => !!x), (e) => {
this.errors.push("Sonarr: " + errorText(e));
return [] as Entry[];
})
: Promise.resolve([] as Entry[]);
Promise.all([movies, episodes]).then(([ms, es]) => {
const all = ms.concat(es).sort((a, b) =>
a.day.localeCompare(b.day) || a.at - b.at || a.title.toLowerCase().localeCompare(b.title.toLowerCase()) || a.sub.localeCompare(b.sub));
const rows: Row<Entry>[] = [];
let lastDay = "";
for (const e of all) {
if (e.day !== lastDay) {
rows.push({ key: "d:" + dayLabel(e.day, today) + (["Today", "Tomorrow", "Yesterday"].includes(dayLabel(e.day, today)) ? " · " + new Date(e.day + "T12:00:00").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }) : "") });
lastDay = e.day;
}
rows.push({ item: e, key: e.key });
}
this.list.setPlaceholder(this.errors.join(" · ") || "Nothing in these two weeks.");
this.list.setRows(rows, false);
});
}
private activate(e: Entry): void {
if (e.movie) {
const m = e.movie;
actionSheet(titleYear(m), [
{ label: "Search releases", run: () => movieReleases(m) },
{ label: "Automatic search", run: () => void run("Searching " + m.title, "Search started: " + m.title, () => app.radarr.searchMovie(m.id)) },
]);
return;
}
const ep = e.episode!;
const title = `${e.title} ${episodeCode(ep.seasonNumber, ep.episodeNumber)}`;
actionSheet(title, [
{ label: "Search releases", run: () => openReleases("Releases for " + title, (sig) => app.sonarr.episodeReleases(ep.id, sig), (r) => app.sonarr.grab(r)) },
{ label: "Automatic search", run: () => void run("Searching " + title, "Search started: " + title, () => app.sonarr.searchEpisode(ep.id)) },
]);
}
}
+239
View File
@@ -0,0 +1,239 @@
// Downloads: the SABnzbd queue and history and the Radarr/Sonarr queues,
// refreshed every few seconds while visible; pause / resume, delete, retry,
// remove and blocklist. Also the background watch that announces finished
// downloads. Ported from internal/ui/downloads.go and watch.go.
import { app } from "../app.ts";
import { queueName, queueProblem, queueProgress, type ArrClient, type QueueItem } from "../api/arr.ts";
import type { HistorySlot, Queue, Slot } from "../api/sabnzbd.ts";
import { refreshSeconds } from "../config.ts";
import { capitalize, formatAge, formatSpeed } from "../format.ts";
import { h, replace } from "../ui/dom.ts";
import { focus, Keys } from "../ui/focus.ts";
import { ListView } from "../ui/list.ts";
import { actionSheet, button, confirmDialog, errorText, run, toast } from "../ui/widgets.ts";
import type { Hint, View } from "./view.ts";
interface ArrRow {
source: ArrClient;
item: QueueItem;
}
const stateLabels: Record<string, string> = {
downloading: "Downloading",
importPending: "Import pending",
importBlocked: "Import blocked",
importing: "Importing",
imported: "Imported",
failedPending: "Failed (pending)",
failed: "Failed",
ignored: "Ignored",
};
export function arrState(q: QueueItem): [string, string] {
const label = stateLabels[q.trackedDownloadState ?? ""] || capitalize(q.status ?? "");
const st = q.trackedDownloadState;
if (q.trackedDownloadStatus === "error" || st === "failed" || st === "failedPending" || st === "importBlocked") return [label, "bad"];
if (q.trackedDownloadStatus === "warning") return [label, "warn"];
return [label, ""];
}
function bar(pct: number): HTMLElement {
return h("span.bar", h("span.bar-fill", { style: `width:${Math.max(0, Math.min(100, pct))}%` }));
}
export class DownloadsView implements View {
el: HTMLElement;
hints: Hint[] = [["ok", "what to do"], ["yellow", "pause / resume SABnzbd"], ["blue", "refresh"]];
private sabHead: HTMLElement;
private pauseBtn: HTMLButtonElement;
private queue: ListView<Slot>;
private arr: ListView<ArrRow>;
private history: ListView<HistorySlot>;
private paused = false;
private timer = 0;
constructor() {
this.sabHead = h("div.sab-head");
this.pauseBtn = button("Pause", () => this.togglePause());
this.queue = new ListView<Slot>("dl-queue", {
rowHeight: 50,
header: h("div.dl-row.list-cols", h("span", "SABnzbd queue"), h("span", "Status"), h("span", "Progress"), h("span.num", "Left"), h("span.num", "Time")),
render: (s) => h("div.dl-row",
h("span.ellipsis", s.filename),
h(`span.${s.status === "Downloading" ? "dl" : "muted"}` as "span", s.status),
h("span.progress", bar(s.percentage), h("span.num", Math.round(s.percentage) + "%")),
h("span.num", s.sizeleft),
h("span.num", s.status === "Downloading" ? s.timeleft : ""),
),
onActivate: (s) => this.slotActions(s),
placeholder: "Loading…",
});
this.arr = new ListView<ArrRow>("dl-arr", {
rowHeight: 50,
header: h("div.dl-row.list-cols", h("span", "Radarr / Sonarr queue"), h("span", "State"), h("span", "Progress"), h("span", "Client"), h("span.num", "")),
render: (r) => {
const [label, cls] = arrState(r.item);
const problem = queueProblem(r.item);
return h("div.dl-row",
h("span.ellipsis", queueName(r.item), problem ? h("span.dl-problem", " — " + problem) : null),
h(`span.${cls || "plain"}` as "span", label),
h("span.progress", bar(queueProgress(r.item)), h("span.num", Math.round(queueProgress(r.item)) + "%")),
h("span.ellipsis.muted", r.item.downloadClient ?? ""),
h("span"),
);
},
onActivate: (r) => this.arrActions(r),
placeholder: "Loading…",
});
this.history = new ListView<HistorySlot>("dl-history", {
rowHeight: 50,
header: h("div.dl-row.list-cols", h("span", "SABnzbd history"), h("span", "Status"), h("span", ""), h("span.num", "Size"), h("span.num", "When")),
render: (s) => h("div.dl-row",
h("span.ellipsis", s.name, s.fail_message ? h("span.dl-problem", " — " + s.fail_message) : null),
h(`span.${s.status === "Failed" ? "bad" : s.status === "Completed" ? "ok" : "plain"}` as "span", s.status),
h("span"),
h("span.num", s.size),
h("span.num.muted", formatAge(s.completed)),
),
onActivate: (s) => this.historyActions(s),
placeholder: "Loading…",
});
this.el = h("div.view.downloads-view",
h("div.toolbar.fgroup", this.pauseBtn, this.sabHead),
this.queue.el, this.arr.el, this.history.el,
);
}
show(): void {
this.refresh();
this.timer = window.setInterval(() => this.refresh(), refreshSeconds(app.cfg) * 1000);
if (!this.el.contains(document.activeElement)) focus(this.queue.el);
}
hide(): void {
clearInterval(this.timer);
}
keys(code: number): boolean {
if (code === Keys.yellow) {
this.togglePause();
return true;
}
if (code === Keys.blue) {
this.refresh();
return true;
}
return false;
}
refresh(): void {
if (app.sab.configured()) {
app.sab.queue().then((q) => {
app.setStatus("SABnzbd", null);
this.showQueue(q);
}, (e) => {
app.setStatus("SABnzbd", e);
this.queue.setPlaceholder("SABnzbd: " + errorText(e));
this.queue.setItems([], (s) => s.nzo_id);
replace(this.sabHead);
});
app.sab.history(30).then((hs) => {
this.history.setPlaceholder("No finished downloads.");
this.history.setItems(hs, (s) => s.nzo_id);
}, (e) => {
this.history.setPlaceholder("SABnzbd: " + errorText(e));
this.history.setItems([], (s) => s.nzo_id);
});
} else {
this.queue.setPlaceholder("SABnzbd is not configured.");
this.history.setPlaceholder("");
this.queue.setItems([], (s) => s.nzo_id);
}
const sources = [app.radarr, app.sonarr].filter((c) => c.configured());
Promise.all(sources.map((c) => (c === app.radarr ? app.radarr.movieQueue() : app.sonarr.seriesQueue()).then(
(items) => items.map((item) => ({ source: c as ArrClient, item })),
(e) => {
this.arr.setPlaceholder(`${c.name}: ${errorText(e)}`);
return [] as ArrRow[];
},
))).then((parts) => {
const rows = ([] as ArrRow[]).concat(...parts);
if (rows.length) this.arr.setPlaceholder("");
else if (!this.arr.length) this.arr.setPlaceholder("Nothing in the Radarr or Sonarr queue.");
this.arr.setItems(rows, (r) => r.source.name + r.item.id);
});
}
private showQueue(q: Queue): void {
this.paused = q.paused;
this.pauseBtn.textContent = q.paused ? "Resume" : "Pause";
const parts = [q.paused ? "Paused" : q.status || "Idle"];
if (!q.paused && q.kbpersec > 0) parts.push(formatSpeed(q.kbpersec));
if (q.slots.length) parts.push(`${q.sizeleft} left`, q.timeleft);
replace(this.sabHead, h(`span.${q.paused ? "warn" : "plain"}` as "span", parts.join(" · ")));
this.queue.setPlaceholder("The SABnzbd queue is empty.");
this.queue.setItems(q.slots, (s) => s.nzo_id);
}
private togglePause(): void {
const resume = this.paused;
void run(resume ? "Resuming SABnzbd" : "Pausing SABnzbd", resume ? "SABnzbd resumed" : "SABnzbd paused",
() => (resume ? app.sab.resume() : app.sab.pause())).then(() => this.refresh());
}
private slotActions(s: Slot): void {
actionSheet(s.filename, [
{ label: "Delete from the queue", danger: true, run: () => confirmDialog(`Delete ${s.filename} from the SABnzbd queue?`, "Delete", () => {
void run("Deleting", "Deleted " + s.filename, () => app.sab.deleteJob(s.nzo_id, false)).then(() => this.refresh());
}, true) },
]);
}
private historyActions(s: HistorySlot): void {
const actions = [
{ label: "Remove from the history", danger: true, run: () => confirmDialog(`Remove ${s.name} from the history?`, "Remove", () => {
void run("Removing", "Removed " + s.name, () => app.sab.deleteHistory(s.nzo_id, false)).then(() => this.refresh());
}, true) },
];
if (s.status === "Failed") actions.unshift({ label: "Retry", danger: false, run: () => void run("Retrying " + s.name, "Queued again: " + s.name, () => app.sab.retry(s.nzo_id)).then(() => this.refresh()) });
actionSheet(s.name, actions);
}
private arrActions(r: ArrRow): void {
const name = queueName(r.item);
const remove = (blocklist: boolean) => confirmDialog(
blocklist ? `Remove ${name} from the ${r.source.name} queue and the download client, and blocklist the release? ${r.source.name} then searches for another one.` : `Remove ${name} from the ${r.source.name} queue and the download client?`,
blocklist ? "Remove and blocklist" : "Remove",
() => void run("Removing " + name, "Removed " + name, () => r.source.removeQueueItem(r.item.id, blocklist)).then(() => this.refresh()),
true,
);
actionSheet(name, [
{ label: "Remove", danger: true, run: () => remove(false) },
{ label: "Remove and blocklist", danger: true, run: () => remove(true) },
]);
}
}
// watchDownloads checks SABnzbd's history every 15 s and announces
// downloads that finished or failed since the app started.
export function watchDownloads(): void {
const seen = new Map<string, string>();
let baseline = true;
const poll = () => {
if (!app.sab.configured() || app.cfg.ui.notify_downloads === false) return;
app.sab.history(30).then((slots) => {
for (const s of slots) {
const prev = seen.get(s.nzo_id);
seen.set(s.nzo_id, s.status);
const final = s.status === "Completed" || s.status === "Failed";
if (baseline || !final || prev === s.status) continue;
if (s.status === "Failed") toast("Download failed: " + s.name + (s.fail_message ? ` (${s.fail_message.slice(0, 60)})` : ""), "error");
else toast("Download finished: " + s.name, "ok");
}
baseline = false;
}, () => undefined);
};
poll();
setInterval(poll, 15_000);
}
+197
View File
@@ -0,0 +1,197 @@
// A library series' seasons and episodes, with download marks, release and
// automatic searches and monitoring. Ported from internal/ui/episodes.go.
import { app, openPage, pageHeader } from "../app.ts";
import { queueProgress } from "../api/arr.ts";
import type { Episode, Series } from "../api/sonarr.ts";
import { dlLine, episodeCode, monitoredLine, seriesDetail, titleYear, type DlInfo } from "../describe.ts";
import { isoDay } from "../format.ts";
import { DetailPane, type Line } from "../ui/detail.ts";
import { h, replace } from "../ui/dom.ts";
import { focus } from "../ui/focus.ts";
import { ListView, type Row } from "../ui/list.ts";
import { actionSheet, errorText, run } from "../ui/widgets.ts";
import { openReleases } from "./releases.ts";
import { episodeStatus, seasonLabel, summarizeSeasons } from "../seasons.ts";
interface EpRow {
season: number;
ep?: Episode;
}
export function openEpisodes(start: Series, changed?: () => void): void {
let series = start;
let episodes: Episode[] = [];
let loaded = false;
const expanded = new Set<number>();
let downloading = new Map<number, DlInfo>();
const detail = new DetailPane();
const header = h("div.page-status");
const list = new ListView<EpRow>("episodes", {
rowHeight: 52,
render: (r) => (r.ep ? episodeRow(r.ep) : seasonRow(r.season)),
onSelect: (r) => showDetail(r),
onActivate: (r) => activate(r),
placeholder: "Loading…",
});
const el = h("div.episodes-page", pageHeader(titleYear(series), header), h("div.split", list.el, detail.el), h("div.hints", "OK: expand, releases and more · Back return"));
const page = openPage(el, () => clearInterval(watch));
focus(list.el);
const watch = window.setInterval(loadQueue, 10_000);
reload();
loadQueue();
function reload(): void {
Promise.all([app.sonarr.getSeries(series.id), app.sonarr.episodes(series.id)]).then(([s, eps]) => {
if (!loaded) {
const newest = summarizeSeasons(eps)[0];
if (newest) expanded.add(newest.number); // open the newest season first
}
series = s;
episodes = eps;
loaded = true;
app.setStatus("Sonarr", null);
render();
}, (e) => {
app.setStatus("Sonarr", e);
list.setPlaceholder("Sonarr: " + errorText(e));
});
}
// loadQueue marks the episodes in the download queue; when one has left
// it, the episodes are loaded again so its status follows.
function loadQueue(): void {
app.sonarr.seriesQueue().then((items) => {
const now = new Map<number, DlInfo>();
for (const q of items) {
if (q.seriesId === series.id && q.episodeId && !now.has(q.episodeId)) {
now.set(q.episodeId, { count: 1, progress: queueProgress(q), timeLeft: q.timeleft ?? "" });
}
}
const left = [...downloading.keys()].some((id) => !now.has(id));
downloading = now;
if (left) reload();
else render();
}, () => undefined);
}
function seasonMonitored(n: number): boolean {
return series.seasons?.find((s) => s.seasonNumber === n)?.monitored ?? false;
}
function seasonDownloads(n: number): number {
return episodes.filter((e) => e.seasonNumber === n && downloading.has(e.id)).length;
}
function mark(monitored: boolean, dl: boolean): HTMLElement {
return h(`span.dot${dl ? ".dl" : monitored ? ".on" : ""}` as "span", monitored ? "●" : "○");
}
function seasonRow(n: number): HTMLElement {
const s = summarizeSeasons(episodes).find((x) => x.number === n) ?? { number: n, have: 0, total: 0 };
const cls = s.have >= s.total ? "ok" : seasonMonitored(n) ? "warn" : "muted";
return h("div.ep-row.season",
mark(seasonMonitored(n), seasonDownloads(n) > 0),
h("span", expanded.has(n) ? "▼" : "▶"),
h("span.bold", seasonLabel(n)),
h("span"),
h(`span.${cls}` as "span", `${s.have}/${s.total}`),
);
}
function episodeRow(e: Episode): HTMLElement {
const dl = downloading.get(e.id);
let [status, cls] = episodeStatus(e, isoDay(new Date()));
if (dl && !e.hasFile) [status, cls] = [`downloading ${Math.round(dl.progress)}%`, "dl"];
return h("div.ep-row",
mark(e.monitored, !!dl),
h("span.muted", "E" + String(e.episodeNumber).padStart(2, "0")),
h("span.ellipsis", e.title),
h("span.muted", e.airDate ?? ""),
h(`span.${cls}` as "span", status),
);
}
function render(): void {
const rows: Row<EpRow>[] = [];
for (const s of summarizeSeasons(episodes)) {
rows.push({ item: { season: s.number }, key: "s" + s.number });
if (!expanded.has(s.number)) continue;
for (const e of episodes.filter((x) => x.seasonNumber === s.number).sort((a, b) => a.episodeNumber - b.episodeNumber)) {
rows.push({ item: { season: s.number, ep: e }, key: "e" + e.id });
}
}
if (loaded) list.setPlaceholder("Sonarr has no episodes for this series");
list.setRows(rows);
const st = series.statistics;
replace(header, `${series.monitored ? "monitored" : "unmonitored"} · ${st?.episodeFileCount ?? 0}/${st?.episodeCount ?? 0} episodes`);
}
function seasonPoster(n: number): string {
const img = series.seasons?.find((s) => s.seasonNumber === n)?.images?.find((i) => i.coverType === "poster" && i.remoteUrl);
return img?.remoteUrl ?? app.sonarr.posterURL(series.images, true, true);
}
function showDetail(r: EpRow | undefined): void {
const d = seriesDetail(series);
if (!r) return detail.show(d);
const lines: Line[] = [];
const s = summarizeSeasons(episodes).find((x) => x.number === r.season);
if (!r.ep) {
d.sub = seasonLabel(r.season);
if (s) lines.push({ text: `${s.have} of ${s.total} episodes downloaded` });
const n = seasonDownloads(r.season);
if (n === 1) lines.push({ text: "Downloading 1 episode", cls: "dl" });
else if (n > 1) lines.push({ text: `Downloading ${n} episodes`, cls: "dl" });
lines.push(monitoredLine(seasonMonitored(r.season)));
} else {
const e = r.ep;
d.sub = `${episodeCode(e.seasonNumber, e.episodeNumber)} ${e.title}`;
if (e.airDate) lines.push({ text: "Aired: " + e.airDate });
const [status, cls] = episodeStatus(e, isoDay(new Date()));
lines.push({ text: "Status: " + status, cls });
const dl = downloading.get(e.id);
if (dl) lines.push(dlLine(dl));
lines.push(monitoredLine(e.monitored));
if (e.overview) d.plot = e.overview;
}
d.lines = [...d.lines.filter((l) => l.text.startsWith("Network")), ...lines];
d.poster = seasonPoster(r.season);
detail.show(d);
}
function done(): void {
reload();
changed?.();
}
function activate(r: EpRow): void {
const id = series.id;
if (!r.ep) {
const n = r.season;
const want = !seasonMonitored(n);
actionSheet(seasonLabel(n), [
{ label: expanded.has(n) ? "Collapse" : "Expand", run: () => {
if (expanded.has(n)) expanded.delete(n);
else expanded.add(n);
render();
} },
{ label: "Search releases", hint: "season packs and episodes", run: () => openReleases(`Releases for ${series.title}, ${seasonLabel(n)}`, (sig) => app.sonarr.seasonReleases(id, n, sig), (rel) => app.sonarr.grab(rel)) },
{ label: "Automatic search", run: () => void run("Searching " + seasonLabel(n), "Search started: " + seasonLabel(n), () => app.sonarr.searchSeason(id, n)) },
{ label: want ? "Monitor the season" : "Unmonitor the season", run: () => void run(`${seasonLabel(n)}: ${want ? "monitoring" : "unmonitoring"}`, "Done", () => app.sonarr.setSeasonMonitored(id, n, want)).then((ok) => ok && done()) },
]);
return;
}
const e = r.ep;
const code = episodeCode(e.seasonNumber, e.episodeNumber);
actionSheet(`${code} ${e.title}`, [
{ label: "Search releases", run: () => openReleases(`Releases for ${series.title} ${code}`, (sig) => app.sonarr.episodeReleases(e.id, sig), (rel) => app.sonarr.grab(rel)) },
{ label: "Automatic search", run: () => void run("Searching " + code, "Search started: " + code, () => app.sonarr.searchEpisode(e.id)) },
{ label: e.monitored ? "Unmonitor" : "Monitor", run: () => void run(`${code}: ${e.monitored ? "unmonitoring" : "monitoring"}`, "Done", () => app.sonarr.setEpisodesMonitored([e.id], !e.monitored)).then((ok) => ok && done()) },
]);
}
void page;
}

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