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:
@@ -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