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
+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)