Add library view and series drill-down (M8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user