Series drill-down: mark downloading episodes and seasons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user