595 lines
16 KiB
Go
595 lines
16 KiB
Go
package ui
|
||
|
||
import (
|
||
"cmp"
|
||
"context"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
"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/radarr"
|
||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||
)
|
||
|
||
const (
|
||
calendarPast = 7 // days before today loaded at the start
|
||
calendarAhead = 28 // days after today loaded at the start
|
||
calendarChunk = 28 // days added when scrolling reaches either end
|
||
calendarMaxBack = 180 // how far back, and
|
||
calendarMaxFwd = 400 // how far ahead the list can grow
|
||
calendarEdge = 6 // rows from an end at which more is loaded
|
||
)
|
||
|
||
// calEntry is one line of the calendar: a movie release or an episode airing.
|
||
type calEntry struct {
|
||
day time.Time // local midnight of the day
|
||
at time.Time // time of day, zero for date-only entries (movie releases)
|
||
movie bool
|
||
title string // plain title, for sorting
|
||
label string // escaped title for display
|
||
sub string // "S02E05 Welcome" or "Digital release"
|
||
have bool
|
||
monitored bool
|
||
missing bool // aired already and not downloaded
|
||
details detailText
|
||
cover coverRef
|
||
|
||
movieID, episodeID int
|
||
}
|
||
|
||
func (e calEntry) key() string {
|
||
if e.movie {
|
||
return fmt.Sprintf("m%d/%s/%s", e.movieID, e.sub, e.day.Format("2006-01-02"))
|
||
}
|
||
return fmt.Sprintf("e%d", e.episodeID)
|
||
}
|
||
|
||
// localDay returns midnight of the date of t in the local zone.
|
||
func localDay(t time.Time) time.Time {
|
||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||
}
|
||
|
||
// releaseDay reads a movie release date. Radarr sends these as midnight UTC
|
||
// timestamps meaning "this date", so the date is taken as written and not
|
||
// shifted into the local zone.
|
||
func releaseDay(s string) (time.Time, bool) {
|
||
if s == "" {
|
||
return time.Time{}, false
|
||
}
|
||
t, err := time.Parse(time.RFC3339, s)
|
||
if err != nil {
|
||
return time.Time{}, false
|
||
}
|
||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local), true
|
||
}
|
||
|
||
// movieEntries returns an entry for every release date of the movie that falls
|
||
// into [from, to] (local days).
|
||
func movieEntries(m radarr.Movie, from, to time.Time, cover coverRef, now time.Time) []calEntry {
|
||
var out []calEntry
|
||
for _, rel := range []struct{ name, date string }{
|
||
{"In cinemas", m.InCinemas}, {"Digital release", m.DigitalRelease}, {"Physical release", m.PhysicalRelease},
|
||
} {
|
||
day, ok := releaseDay(rel.date)
|
||
if !ok || day.Before(from) || day.After(to) {
|
||
continue
|
||
}
|
||
label := tview.Escape(m.Title)
|
||
if m.Year > 0 {
|
||
label += fmt.Sprintf(" (%d)", m.Year)
|
||
}
|
||
d := movieDetails(m)
|
||
d.head += "Release: " + rel.name + ", " + day.Format("Mon 2 Jan 2006") + "\n"
|
||
out = append(out, calEntry{
|
||
day: day, movie: true, title: m.Title, label: label, sub: rel.name,
|
||
have: m.HasFile, monitored: m.Monitored, missing: m.Monitored && !m.HasFile && !day.After(now),
|
||
details: d, cover: cover, movieID: m.ID,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// episodeEntry converts an airing episode, or reports false if it has no usable date.
|
||
func episodeEntry(e sonarr.Episode, from, to time.Time, cover coverRef, now time.Time) (calEntry, bool) {
|
||
var day, at time.Time
|
||
if t, err := time.Parse(time.RFC3339, e.AirDateUTC); err == nil {
|
||
at = t.Local()
|
||
day = localDay(at)
|
||
} else if t, err := time.Parse("2006-01-02", e.AirDate); err == nil {
|
||
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||
} else {
|
||
return calEntry{}, false
|
||
}
|
||
if day.Before(from) || day.After(to) {
|
||
return calEntry{}, false
|
||
}
|
||
series := "Unknown series"
|
||
var overview string
|
||
if e.Series != nil {
|
||
series = e.Series.Title
|
||
overview = e.Series.Overview
|
||
}
|
||
if e.Overview != "" {
|
||
overview = e.Overview
|
||
}
|
||
sub := fmt.Sprintf("S%02dE%02d %s", e.SeasonNumber, e.EpisodeNumber, e.Title)
|
||
|
||
head := "[::b]" + tview.Escape(series) + "[::-]\n" + tview.Escape(sub) + "\n"
|
||
if !at.IsZero() {
|
||
head += "Airs " + at.Format("Mon 2 Jan 15:04") + "\n"
|
||
} else {
|
||
head += "Airs " + day.Format("Mon 2 Jan") + "\n"
|
||
}
|
||
switch {
|
||
case e.HasFile:
|
||
head += "[green]Downloaded[-]\n"
|
||
case !e.Monitored:
|
||
head += "[gray]Not monitored[-]\n"
|
||
default:
|
||
head += "[yellow]Not downloaded[-]\n"
|
||
}
|
||
aired := !at.IsZero() && at.Before(now) || at.IsZero() && day.Before(localDay(now))
|
||
return calEntry{
|
||
day: day, at: at, title: series, label: tview.Escape(series), sub: sub,
|
||
have: e.HasFile, monitored: e.Monitored, missing: e.Monitored && !e.HasFile && aired,
|
||
details: detailText{head: head, plot: tview.Escape(overview)}, cover: cover,
|
||
episodeID: e.ID,
|
||
}, true
|
||
}
|
||
|
||
// sortEntries orders by day, then time of day (date-only first), then title.
|
||
func sortEntries(es []calEntry) {
|
||
slices.SortStableFunc(es, func(a, b calEntry) int {
|
||
return cmp.Or(
|
||
a.day.Compare(b.day),
|
||
a.at.Compare(b.at),
|
||
strings.Compare(strings.ToLower(a.title), strings.ToLower(b.title)),
|
||
strings.Compare(a.sub, b.sub),
|
||
)
|
||
})
|
||
}
|
||
|
||
// dayLabel returns the date of a day and, for days near today, its relative
|
||
// name: ("Wed 21 Oct", "Tomorrow").
|
||
func dayLabel(day, today time.Time) (date, relative string) {
|
||
date = day.Format("Mon 2 Jan")
|
||
switch int(day.Sub(today).Round(24*time.Hour).Hours() / 24) { // Round: days are 23 or 25 hours long when the clocks change
|
||
case 0:
|
||
return date, "Today"
|
||
case 1:
|
||
return date, "Tomorrow"
|
||
case -1:
|
||
return date, "Yesterday"
|
||
}
|
||
return date, ""
|
||
}
|
||
|
||
// calendarView lists the movies and episodes coming up as one scrolling list.
|
||
// It starts with a few days back and a month ahead and loads more days
|
||
// whenever the selection gets close to either end.
|
||
type calendarView struct {
|
||
app *App
|
||
radarr *radarr.Client
|
||
sonarr *sonarr.Client
|
||
|
||
root *tview.Flex
|
||
header *tview.TextView
|
||
table *tview.Table
|
||
details *detailPane
|
||
|
||
// UI-goroutine state.
|
||
unmonitored bool
|
||
from, to time.Time // the days loaded so far
|
||
entries []calEntry
|
||
rows []calRow
|
||
loaded bool
|
||
loading [2]bool // loading more at the [top, bottom]
|
||
gen int // invalidates loads from before a reset
|
||
reposition bool // put today at the top after the next render
|
||
}
|
||
|
||
// calRow is a table row: a day header (entry == nil) or an entry.
|
||
type calRow struct {
|
||
day time.Time
|
||
entry *calEntry
|
||
}
|
||
|
||
func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView {
|
||
v := &calendarView{
|
||
app: app, radarr: r, sonarr: s,
|
||
header: tview.NewTextView().SetDynamicColors(true),
|
||
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
|
||
details: newDetailPane(app),
|
||
}
|
||
v.table.SetBorder(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.details, 0, 2, false), 0, 1, true)
|
||
|
||
v.table.SetSelectionChangedFunc(func(int, int) {
|
||
v.renderDetails()
|
||
v.loadNearEnd()
|
||
})
|
||
v.table.SetSelectedFunc(func(int, int) { v.open() })
|
||
v.table.SetInputCapture(v.handleKey)
|
||
v.render()
|
||
return v
|
||
}
|
||
|
||
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
||
switch ev.Key() {
|
||
case tcell.KeyDown, tcell.KeyPgDn, tcell.KeyEnd:
|
||
v.loadMore(true) // pressing down at the last entry cannot change the selection, so ask here
|
||
case tcell.KeyUp, tcell.KeyPgUp, tcell.KeyHome:
|
||
v.loadMore(false)
|
||
case tcell.KeyRune:
|
||
switch ev.Rune() {
|
||
case 't':
|
||
v.today()
|
||
case 'u':
|
||
v.unmonitored = !v.unmonitored
|
||
v.reload()
|
||
case 'r':
|
||
v.reload()
|
||
case 'i':
|
||
v.open()
|
||
case 's':
|
||
v.search()
|
||
default:
|
||
return ev
|
||
}
|
||
return nil
|
||
}
|
||
return ev
|
||
}
|
||
|
||
func (v *calendarView) activate() {
|
||
if !v.loaded {
|
||
v.reload()
|
||
} else {
|
||
v.refresh()
|
||
}
|
||
}
|
||
|
||
// reload forgets everything and loads the initial range, with today at the top.
|
||
func (v *calendarView) reload() {
|
||
v.gen++
|
||
v.loading = [2]bool{}
|
||
today := localDay(time.Now())
|
||
v.from, v.to = today.AddDate(0, 0, -calendarPast), today.AddDate(0, 0, calendarAhead-1)
|
||
v.entries, v.loaded, v.reposition = nil, false, true
|
||
v.render()
|
||
v.fetch(v.from, v.to, v.gen, func(entries []calEntry) {
|
||
v.entries, v.loaded = entries, true
|
||
})
|
||
}
|
||
|
||
// refresh reloads the range loaded so far, keeping the position.
|
||
func (v *calendarView) refresh() {
|
||
gen := v.gen
|
||
v.fetch(v.from, v.to, gen, func(entries []calEntry) { v.entries = entries })
|
||
}
|
||
|
||
// loadNearEnd loads more days when the selection is close to either end.
|
||
func (v *calendarView) loadNearEnd() {
|
||
if !v.loaded {
|
||
return
|
||
}
|
||
r, _ := v.table.GetSelection()
|
||
if r >= len(v.rows)-calendarEdge {
|
||
v.loadMore(true)
|
||
}
|
||
if r <= calendarEdge {
|
||
v.loadMore(false)
|
||
}
|
||
}
|
||
|
||
// loadMore extends the loaded range by a chunk of days downwards (later) or
|
||
// upwards (earlier), up to the limits.
|
||
func (v *calendarView) loadMore(later bool) {
|
||
idx := 0
|
||
if later {
|
||
idx = 1
|
||
}
|
||
if !v.loaded || v.loading[idx] {
|
||
return
|
||
}
|
||
today := localDay(time.Now())
|
||
var from, to time.Time
|
||
if later {
|
||
if v.to.Sub(today) >= calendarMaxFwd*24*time.Hour {
|
||
return
|
||
}
|
||
from, to = v.to.AddDate(0, 0, 1), v.to.AddDate(0, 0, calendarChunk)
|
||
} else {
|
||
if today.Sub(v.from) >= calendarMaxBack*24*time.Hour {
|
||
return
|
||
}
|
||
from, to = v.from.AddDate(0, 0, -calendarChunk), v.from.AddDate(0, 0, -1)
|
||
}
|
||
v.loading[idx] = true
|
||
v.render() // shows "loading" in the title
|
||
gen := v.gen
|
||
v.fetch(from, to, gen, func(entries []calEntry) {
|
||
v.entries = mergeEntries(v.entries, entries)
|
||
if later {
|
||
v.to = to
|
||
} else {
|
||
v.from = from
|
||
}
|
||
}, func() { v.loading[idx] = false })
|
||
}
|
||
|
||
// mergeEntries adds new entries to the sorted list, skipping ones already there.
|
||
func mergeEntries(have, add []calEntry) []calEntry {
|
||
seen := make(map[string]bool, len(have))
|
||
for _, e := range have {
|
||
seen[e.key()] = true
|
||
}
|
||
for _, e := range add {
|
||
if !seen[e.key()] {
|
||
have = append(have, e)
|
||
}
|
||
}
|
||
sortEntries(have)
|
||
return have
|
||
}
|
||
|
||
// fetch loads the entries between from and to in the background. apply runs
|
||
// on the UI goroutine with the entries when at least one service answered;
|
||
// done (optional) always runs afterwards. Results from before a reset are dropped.
|
||
func (v *calendarView) fetch(from, to time.Time, gen int, apply func([]calEntry), done ...func()) {
|
||
unmonitored := v.unmonitored
|
||
now := time.Now()
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
var (
|
||
wg sync.WaitGroup
|
||
movies []radarr.Movie
|
||
episodes []sonarr.Episode
|
||
movieErr, tvErr error
|
||
)
|
||
wg.Add(2)
|
||
go func() { defer wg.Done(); movies, movieErr = v.radarr.Calendar(ctx, from, to, unmonitored) }()
|
||
go func() { defer wg.Done(); episodes, tvErr = v.sonarr.Calendar(ctx, from, to, unmonitored) }()
|
||
wg.Wait()
|
||
|
||
var entries []calEntry
|
||
for _, m := range movies {
|
||
entries = append(entries, movieEntries(m, from, to, coverFor(v.radarr.Client, m.Images, true), now)...)
|
||
}
|
||
for _, e := range episodes {
|
||
var cover coverRef
|
||
if e.Series != nil {
|
||
cover = coverFor(v.sonarr.Client, e.Series.Images, true)
|
||
}
|
||
if entry, ok := episodeEntry(e, from, to, cover, now); ok {
|
||
entries = append(entries, entry)
|
||
}
|
||
}
|
||
sortEntries(entries)
|
||
|
||
v.app.post(func() {
|
||
v.app.SetService("Radarr", "", movieErr)
|
||
v.app.SetService("Sonarr", "", tvErr)
|
||
if gen != v.gen {
|
||
return
|
||
}
|
||
for _, d := range done {
|
||
d() // before rendering, so the title no longer says "loading"
|
||
}
|
||
for _, err := range []error{movieErr, tvErr} {
|
||
if err != nil {
|
||
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
|
||
}
|
||
}
|
||
if movieErr == nil || tvErr == nil { // show what we got
|
||
apply(entries)
|
||
}
|
||
v.render()
|
||
})
|
||
}()
|
||
}
|
||
|
||
// today selects the first entry of today (or the next day with one), with
|
||
// today's header at the top. If today is out of the loaded range the list is
|
||
// reset around it.
|
||
func (v *calendarView) today() {
|
||
today := localDay(time.Now())
|
||
if today.Before(v.from) || today.After(v.to) {
|
||
v.reload()
|
||
return
|
||
}
|
||
v.reposition = true
|
||
v.render()
|
||
}
|
||
|
||
func (v *calendarView) selected() (*calEntry, bool) {
|
||
r, _ := v.table.GetSelection()
|
||
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
|
||
return nil, false
|
||
}
|
||
return v.rows[r].entry, true
|
||
}
|
||
|
||
func (v *calendarView) render() {
|
||
selKey := ""
|
||
oldSel, _ := v.table.GetSelection()
|
||
oldOffset, _ := v.table.GetOffset()
|
||
if e, ok := v.selected(); ok {
|
||
selKey = e.key()
|
||
}
|
||
now := time.Now()
|
||
today := localDay(now)
|
||
|
||
v.rows = v.rows[:0]
|
||
var lastDay time.Time
|
||
for i := range v.entries {
|
||
e := &v.entries[i]
|
||
if !e.day.Equal(lastDay) {
|
||
v.rows = append(v.rows, calRow{day: e.day})
|
||
lastDay = e.day
|
||
}
|
||
v.rows = append(v.rows, calRow{day: e.day, entry: e})
|
||
}
|
||
|
||
v.table.Clear()
|
||
selRow, firstEntry, todayRow := -1, -1, -1
|
||
for i, r := range v.rows {
|
||
if todayRow < 0 && !r.day.Before(today) {
|
||
todayRow = i // the header of today, or of the next day with entries
|
||
}
|
||
if r.entry == nil {
|
||
date, relative := dayLabel(r.day, today)
|
||
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
|
||
v.table.SetCell(i, 1, tview.NewTableCell("").SetSelectable(false))
|
||
v.table.SetCell(i, 2, tview.NewTableCell("[yellow]"+relative).SetSelectable(false))
|
||
v.table.SetCell(i, 3, tview.NewTableCell("").SetSelectable(false))
|
||
continue
|
||
}
|
||
e := r.entry
|
||
if firstEntry < 0 {
|
||
firstEntry = i
|
||
}
|
||
when := " "
|
||
if !e.at.IsZero() {
|
||
when = e.at.Format("15:04") + " "
|
||
}
|
||
kind := "[blue]TV "
|
||
if e.movie {
|
||
kind = "[purple]Movie"
|
||
}
|
||
v.table.SetCell(i, 0, tview.NewTableCell(" "+when).SetSelectable(true))
|
||
v.table.SetCell(i, 1, tview.NewTableCell(kind+"[-]"))
|
||
v.table.SetCell(i, 2, tview.NewTableCell(e.label+" [gray]"+tview.Escape(e.sub)).SetExpansion(1))
|
||
v.table.SetCell(i, 3, tview.NewTableCell(calStatus(*e)))
|
||
if selKey != "" && e.key() == selKey {
|
||
selRow = i
|
||
}
|
||
}
|
||
|
||
title := " Upcoming · " + v.from.Format("2 Jan") + " – " + v.to.Format("2 Jan")
|
||
if v.unmonitored {
|
||
title += " · incl. unmonitored"
|
||
}
|
||
if v.loading[0] || v.loading[1] || !v.loaded {
|
||
title += " · loading…"
|
||
}
|
||
v.table.SetTitle(title + " ")
|
||
|
||
if firstEntry < 0 {
|
||
msg := "Loading…"
|
||
if v.loaded {
|
||
msg = "Nothing in this period"
|
||
}
|
||
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
|
||
v.table.SetSelectable(false, false)
|
||
v.table.Select(0, 0)
|
||
} else {
|
||
v.table.SetSelectable(true, false)
|
||
switch {
|
||
case v.reposition && v.loaded:
|
||
// Today's header at the top, the first entry from today on selected.
|
||
v.reposition = false
|
||
target := firstEntry
|
||
for i := max(todayRow, 0); i < len(v.rows); i++ {
|
||
if v.rows[i].entry != nil {
|
||
target = i
|
||
break
|
||
}
|
||
}
|
||
v.table.Select(target, 0)
|
||
v.table.SetOffset(max(todayRow, 0), 0)
|
||
case selRow >= 0:
|
||
// Keep the selection where it was on screen, also when rows were
|
||
// added above it.
|
||
v.table.Select(selRow, 0)
|
||
v.table.SetOffset(max(oldOffset+selRow-oldSel, 0), 0)
|
||
default:
|
||
v.table.Select(firstEntry, 0)
|
||
}
|
||
}
|
||
v.header.SetText("[::b]Calendar[::-] · scroll for more · t today · u unmonitored · Enter releases · s search")
|
||
v.renderDetails()
|
||
}
|
||
|
||
// calStatus is the coloured status text of an entry.
|
||
func calStatus(e calEntry) string {
|
||
switch {
|
||
case e.have:
|
||
return "[green]✓ downloaded"
|
||
case !e.monitored:
|
||
return "[gray]unmonitored"
|
||
case e.missing:
|
||
return "[yellow]missing"
|
||
}
|
||
return "[gray]upcoming"
|
||
}
|
||
|
||
func (v *calendarView) renderDetails() {
|
||
if e, ok := v.selected(); ok {
|
||
v.details.Show(e.details, e.cover)
|
||
return
|
||
}
|
||
v.details.Clear()
|
||
}
|
||
|
||
// open starts the interactive release search for the selected entry.
|
||
func (v *calendarView) open() {
|
||
e, ok := v.selected()
|
||
if !ok {
|
||
return
|
||
}
|
||
if e.movie {
|
||
id, title := e.movieID, tview.Escape(e.title)
|
||
v.app.openReleases("Releases for "+title,
|
||
func(ctx context.Context) ([]arr.Release, error) { return v.radarr.Releases(ctx, id) },
|
||
v.radarr.Grab)
|
||
return
|
||
}
|
||
id := e.episodeID
|
||
v.app.openReleases("Releases for "+e.label+" "+tview.Escape(e.sub),
|
||
func(ctx context.Context) ([]arr.Release, error) { return v.sonarr.EpisodeReleases(ctx, id) },
|
||
v.sonarr.Grab)
|
||
}
|
||
|
||
// search starts an automatic search for the selected entry.
|
||
func (v *calendarView) search() {
|
||
e, ok := v.selected()
|
||
if !ok {
|
||
return
|
||
}
|
||
what := e.label
|
||
if !e.movie {
|
||
what += " " + tview.Escape(e.sub)
|
||
}
|
||
v.app.SetMessage("Searching for " + what + "…")
|
||
movie, movieID, episodeID := e.movie, e.movieID, e.episodeID
|
||
go func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
var err error
|
||
if movie {
|
||
err = v.radarr.SearchMovie(ctx, movieID)
|
||
} else {
|
||
err = v.sonarr.SearchEpisode(ctx, episodeID)
|
||
}
|
||
v.app.post(func() {
|
||
if err != nil {
|
||
v.app.SetMessage("[red]" + tview.Escape(err.Error()))
|
||
return
|
||
}
|
||
v.app.SetMessage("[green]Search started for " + what)
|
||
})
|
||
}()
|
||
}
|