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>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -12,6 +12,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
||||
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.
|
||||
- **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.
|
||||
|
||||
@@ -70,6 +70,7 @@ Notes:
|
||||
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
|
||||
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
||||
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
|
||||
- **Downloading in the Library:** while the Library is visible it reads the Radarr and Sonarr queues (`/queue`, matched to titles by `movieId` / `seriesId`) when it opens, every 10 s, and 3 s after a grab. A title with queue entries gets a blue mark (filled when monitored, hollow when not) instead of the green or grey one, and its details a line "Downloading 45% · 0:06:12 left" (series: "Downloading N episodes"). A queue that cannot be read leaves the marks as they were. After a successful grab the release list closes (if nothing was opened on top of it).
|
||||
- **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 for the session (up to 500 titles; a title Trakt does not know counts as having none; 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.
|
||||
|
||||
@@ -31,6 +31,8 @@ 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
|
||||
|
||||
// Present depending on the include* parameters of the request.
|
||||
Movie *QueueMovie `json:"movie,omitempty"`
|
||||
|
||||
+4
-1
@@ -36,6 +36,8 @@ type App struct {
|
||||
theme theme // the colors in use
|
||||
trakt *trakt.Client // trending, most watched and anticipated lists, casts
|
||||
people *peopleCache // casts loaded from Trakt
|
||||
// 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
|
||||
@@ -169,6 +171,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},
|
||||
@@ -187,7 +190,7 @@ func New(cfg *config.Config) *App {
|
||||
a.views = []view{
|
||||
{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, hints: libraryHints},
|
||||
{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},
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ var helpSections = []helpSection{
|
||||
{"[ ]", "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."},
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
)
|
||||
|
||||
// libQueueInterval is how often the Library asks for the download queues
|
||||
// while it is 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
v.queueStop = stop
|
||||
go func() {
|
||||
t := time.NewTicker(libQueueInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
v.app.post(v.loadQueue)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// deactivate stops the queue watch when the Library is left.
|
||||
func (v *libraryView) deactivate() {
|
||||
if v.queueStop != nil {
|
||||
close(v.queueStop)
|
||||
v.queueStop = nil
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -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 chan struct{}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -345,3 +345,39 @@ func TestSeasonPoster(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,9 @@ func (v *releasesView) doGrab(r arr.Release) {
|
||||
}
|
||||
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) })
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user