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:
2026-10-24 10:04:53 +02:00
co-authored by Claude Opus 5.5
parent 7261667d72
commit 2b6cf71967
10 changed files with 181 additions and 4 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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."},
+105
View File
@@ -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
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 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()
+36
View File
@@ -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")
}
}
+3
View File
@@ -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) })
}
})
}()
}