Files
arr-tui/internal/ui/episodes.go
T
2026-10-20 19:31:12 +02:00

356 lines
9.1 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 *tview.TextView
episodes []sonarr.Episode
expanded map[int]bool
rows []epRow
loaded bool
}
// 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: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
expanded: map[int]bool{},
}
foot := tview.NewTextView().SetDynamicColors(true).
SetText("[gray]Enter expand / releases · i releases · s search · t monitor · r reload · Esc back")
v.table.SetBorder(true)
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false).
AddItem(v.table, 0, 1, true).
AddItem(v.detail, 2, 0, false).
AddItem(foot, 1, 0, false)
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()
a.openFull(v.root)
v.reload()
}
// 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(monMark(v.seasonMonitored(r.season))))
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)))
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)))
}
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()
switch {
case !ok:
v.detail.SetText("")
case r.ep == nil:
v.detail.SetText("[gray]Enter expands the season · t toggles monitoring for the whole season · i searches for releases")
default:
v.detail.SetText(fmt.Sprintf("[gray]S%02dE%02d[-] %s", r.season, r.ep.EpisodeNumber, tview.Escape(r.ep.Title)))
}
}
// 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()
}
}
})
}()
}