Series drill-down: mark downloading episodes and seasons

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:30:14 +02:00
co-authored by Claude Opus 5.5
parent 45e8daaa71
commit 0fcc5fb7fa
10 changed files with 161 additions and 40 deletions
+3 -2
View File
@@ -31,8 +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
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"`
+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)
}
}
+82 -12
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,7 +314,11 @@ 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.seasonCover(r.season))
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
@@ -287,8 +348,9 @@ func (v *seriesView) seasonCover(season int) coverRef {
// 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)
@@ -306,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:
@@ -314,6 +381,9 @@ 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)
+37 -19
View File
@@ -8,8 +8,8 @@ import (
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
// libQueueInterval is how often the Library asks for the download queues
// while it is visible.
// 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.
@@ -52,6 +52,38 @@ func queueByTitle(items []arr.QueueItem, series bool) map[int]dlInfo {
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() {
@@ -77,29 +109,15 @@ func (v *libraryView) loadQueue() {
// startQueueWatch refreshes the queue marks while the Library is visible.
func (v *libraryView) startQueueWatch() {
v.loadQueue()
if v.queueStop != nil {
return
if v.queueStop == nil {
v.queueStop = v.app.every(libQueueInterval, v.loadQueue)
}
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()
v.queueStop = nil
}
}
+1 -1
View File
@@ -231,7 +231,7 @@ type libraryView struct {
// Titles in the Radarr/Sonarr download queue, by kind and id (libqueue.go).
downloading [2]map[int]dlInfo
queueStop chan struct{}
queueStop func()
}
type seriesFiles struct {
+28
View File
@@ -381,3 +381,31 @@ func TestDownloadingTitlesAreMarked(t *testing.T) {
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)
}
}