Files
arr-tui/internal/ui/calendar.go
T

595 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
}()
}