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() } } }) }() }