Files
arr-tui/internal/ui/episodes.go
T
2026-10-24 22:30:14 +02:00

492 lines
13 KiB
Go

package ui
import (
"cmp"
"context"
"fmt"
"slices"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
)
// epRow is one table row of the drill-down: a season (ep == nil) or an episode.
type epRow struct {
season int
ep *sonarr.Episode
}
func (r epRow) key() [2]int {
if r.ep == nil {
return [2]int{r.season, -1}
}
return [2]int{r.season, r.ep.ID}
}
// seriesView shows the seasons and episodes of one library series.
type seriesView struct {
app *App
c *sonarr.Client
series sonarr.Series
onChange func() // called after changes so the library list can refresh
root *tview.Flex
header *tview.TextView
table *tview.Table
detail *detailPane // poster, series and the selected season or episode
cover coverRef
episodes []sonarr.Episode
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{},
downloading: map[int]dlInfo{},
}
v.table.SetBorder(true)
v.cover = coverFor(c.Client, s.Images, true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(tview.NewFlex().
AddItem(v.table, 0, 3, true).
AddItem(v.detail, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
v.table.SetSelectedFunc(func(int, int) { v.enter() })
v.table.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyEscape {
a.closeModal()
return nil
}
if ev.Key() != tcell.KeyRune {
return ev
}
switch ev.Rune() {
case 'i':
v.releases()
case 's':
v.search()
case 't':
v.toggleMonitored()
case 'r':
v.reload()
default:
return ev
}
return nil
})
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)
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.
func (v *seriesView) reload() {
id := v.series.ID
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s, err := v.c.GetSeries(ctx, id)
var eps []sonarr.Episode
if err == nil {
eps, err = v.c.Episodes(ctx, id)
}
v.app.post(func() {
v.app.SetService("Sonarr", "", err)
if err != nil {
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
return
}
if !v.loaded {
// Open the newest regular season first.
if seasons := summarizeSeasons(eps); len(seasons) > 0 {
v.expanded[seasons[0].number] = true
}
}
v.series, v.episodes, v.loaded = s, eps, true
v.render()
})
}()
}
func (v *seriesView) seasonMonitored(n int) bool {
for _, s := range v.series.Seasons {
if s.SeasonNumber == n {
return s.Monitored
}
}
return false
}
func (v *seriesView) buildRows() {
v.rows = v.rows[:0]
for _, se := range summarizeSeasons(v.episodes) {
v.rows = append(v.rows, epRow{season: se.number})
if !v.expanded[se.number] {
continue
}
var eps []*sonarr.Episode
for i := range v.episodes {
if v.episodes[i].SeasonNumber == se.number {
eps = append(eps, &v.episodes[i])
}
}
slices.SortFunc(eps, func(a, b *sonarr.Episode) int { return cmp.Compare(a.EpisodeNumber, b.EpisodeNumber) })
for _, e := range eps {
v.rows = append(v.rows, epRow{season: se.number, ep: e})
}
}
}
func (v *seriesView) selected() (epRow, bool) {
r, _ := v.table.GetSelection()
if r < 1 || r > len(v.rows) {
return epRow{}, false
}
return v.rows[r-1], true
}
func (v *seriesView) render() {
var selKey [2]int
hasSel := false
if r, ok := v.selected(); ok {
selKey, hasSel = r.key(), true
}
v.buildRows()
v.table.Clear()
header(v.table, 2, "", "", "Title", "Aired", "Status")
today := time.Now().Format("2006-01-02")
selRow := 0
summary := map[int]seasonSummary{}
for _, se := range summarizeSeasons(v.episodes) {
summary[se.number] = se
}
for i, r := range v.rows {
row := i + 1
if r.ep == nil {
se := summary[r.season]
arrow := "▶"
if v.expanded[r.season] {
arrow = "▼"
}
status := fmt.Sprintf("%d/%d", se.have, se.total)
if se.have >= se.total {
status = "[green]" + status
} else if v.seasonMonitored(r.season) {
status = "[yellow]" + status
} else {
status = "[gray]" + status
}
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
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(status))
}
if hasSel && r.key() == selKey {
selRow = row
}
}
placeholder := "Loading…"
if v.loaded {
placeholder = "Sonarr has no episodes for this series"
}
restoreSelection(v.table, len(v.rows), selRow, placeholder)
if selRow == 0 {
v.table.ScrollToBeginning()
}
st := v.series.Statistics
v.header.SetText(fmt.Sprintf("[::b]%s[::-] · %s · %d/%d episodes",
tview.Escape(v.series.Title), monitoredWord(v.series.Monitored), st.EpisodeFileCount, st.EpisodeCount))
v.renderDetail()
}
func monitoredWord(m bool) string {
if m {
return "monitored"
}
return "[gray]unmonitored[-]"
}
// episodeStatus describes an episode's download state for the table.
func episodeStatus(e sonarr.Episode, today string) string {
switch {
case e.HasFile:
return "[green]✓ downloaded"
case e.AirDate == "" || e.AirDate > today:
return "[gray]unaired"
case !e.Monitored:
return "[gray]unmonitored"
default:
return "[yellow]missing"
}
}
func (v *seriesView) renderDetail() {
r, ok := v.selected()
if !ok {
v.detail.Show(seriesRowDetail(v.series, nil, 0, seasonSummary{}, false, "", dlInfo{}), v.cover)
return
}
var sum seasonSummary
for _, se := range summarizeSeasons(v.episodes) {
if se.number == r.season {
sum = se
}
}
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
// season's own poster where Sonarr has one (a TheTVDB link, fetched without
// credentials), else, or when it cannot be loaded, the series poster.
func (v *seriesView) seasonCover(season int) coverRef {
for _, se := range v.series.Seasons {
if se.SeasonNumber != season {
continue
}
for _, img := range se.Images {
if img.CoverType != "poster" || img.RemoteURL == "" {
continue
}
remote, series := img.RemoteURL, v.cover
return coverRef{key: "season|" + remote, fetch: func(ctx context.Context, large bool) ([]byte, error) {
data, err := arr.FetchURL(ctx, remote)
if err != nil && series.fetch != nil {
return series.fetch(ctx, large)
}
return data, err
}}
}
}
return v.cover
}
// 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. 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)
}
head += "\n"
if s.Network != "" {
head += tview.Escape(s.Network) + " · "
}
head += tview.Escape(s.Status) + "\n\n"
plot := tview.Escape(s.Overview)
switch {
case ep == nil && sum.total == 0:
// Nothing selected yet (still loading).
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:
head += fmt.Sprintf("[::b]S%02dE%02d %s[::-]\n", ep.SeasonNumber, ep.EpisodeNumber, tview.Escape(ep.Title))
if ep.AirDate != "" {
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)
}
}
return detailText{head: head, plot: plot, people: seriesRef(s)}
}
// enter expands or collapses a season, or opens the release search for an episode.
func (v *seriesView) enter() {
r, ok := v.selected()
if !ok {
return
}
if r.ep == nil {
v.expanded[r.season] = !v.expanded[r.season]
v.render()
return
}
v.releases()
}
func (v *seriesView) releases() {
r, ok := v.selected()
if !ok {
return
}
s := v.series
if r.ep == nil {
title := tview.Escape(s.Title) + ", " + seasonLabel(r.season)
v.app.openReleases("Releases for "+title,
func(ctx context.Context) ([]arr.Release, error) { return v.c.SeasonReleases(ctx, s.ID, r.season) },
v.c.Grab)
return
}
e := *r.ep
v.app.openReleases(fmt.Sprintf("Releases for %s S%02dE%02d", tview.Escape(s.Title), r.season, e.EpisodeNumber),
func(ctx context.Context) ([]arr.Release, error) { return v.c.EpisodeReleases(ctx, e.ID) },
v.c.Grab)
}
func (v *seriesView) search() {
r, ok := v.selected()
if !ok {
return
}
id := v.series.ID
if r.ep == nil {
season := r.season
v.run("Searching "+seasonLabel(season), func(ctx context.Context) error { return v.c.SearchSeason(ctx, id, season) }, false)
return
}
epID := r.ep.ID
v.run(fmt.Sprintf("Searching S%02dE%02d", r.season, r.ep.EpisodeNumber),
func(ctx context.Context) error { return v.c.SearchEpisode(ctx, epID) }, false)
}
func (v *seriesView) toggleMonitored() {
r, ok := v.selected()
if !ok {
return
}
id := v.series.ID
if r.ep == nil {
season, want := r.season, !v.seasonMonitored(r.season)
v.run(seasonLabel(season)+": "+monitorVerb(want), func(ctx context.Context) error {
return v.c.SetSeasonMonitored(ctx, id, season, want)
}, true)
return
}
epID, want := r.ep.ID, !r.ep.Monitored
v.run(fmt.Sprintf("S%02dE%02d: %s", r.season, r.ep.EpisodeNumber, monitorVerb(want)), func(ctx context.Context) error {
return v.c.SetEpisodesMonitored(ctx, []int{epID}, want)
}, true)
}
func monitorVerb(want bool) string {
if want {
return "monitored"
}
return "unmonitored"
}
// run executes fn off the UI goroutine; with refresh it reloads the data afterwards.
func (v *seriesView) run(what string, fn func(ctx context.Context) error, refresh bool) {
v.app.SetMessage(what + "…")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := fn(ctx)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
return
}
v.app.SetMessage("[green]Done: " + what)
if refresh {
v.reload()
if v.onChange != nil {
v.onChange()
}
}
})
}()
}