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