353 lines
9.0 KiB
Go
353 lines
9.0 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{},
|
|
}
|
|
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)
|
|
|
|
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, "Enter expand/releases · i releases · s search · t monitor · r reload · Esc back")
|
|
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()
|
|
}
|
|
}
|
|
})
|
|
}()
|
|
}
|