From 938980f1e1650bc5bcb33ac95eb04278e035f954 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 22:47:51 +0200 Subject: [PATCH] Calendar: one scrolling list that loads more days at either end Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +- README.md | 8 +- SPECS.md | 4 +- internal/ui/app.go | 2 +- internal/ui/calendar.go | 244 ++++++++++++++++++++++++++--------- internal/ui/calendar_test.go | 18 +++ internal/ui/help.go | 3 +- 7 files changed, 216 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c28e2..3b85258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ## Unreleased -- **Calendar (F5):** two weeks of movie releases and airing episodes grouped by day, with poster, - plot, release search and automatic search. The Log moves to F6. +- **Calendar (F5):** a scrolling list of movie releases and airing episodes grouped by day, starting + at today and loading more days as you scroll, with poster, plot, release search and automatic + search. The Log moves to F6. - **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the background (`ui.notify_downloads`, optional `ui.bell`). - **Remove from the library** (`d`), with separate checkboxes for deleting the files and for the diff --git a/README.md b/README.md index da554fe..dee70cd 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video - **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle monitoring, trigger searches, remove titles (the files stay on disk unless you tick "Delete files"). Series open into seasons and episodes with per-season and per-episode monitoring, automatic search and release search. -- **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes - in one list grouped by day, with poster and plot. Move by weeks, include unmonitored items, search - for releases or start an automatic search from there. +- **Calendar**: movie releases (cinemas, digital, physical) and airing episodes in one scrolling list + grouped by day, starting at today, with poster and plot. More days load as you scroll; include + unmonitored items, search for releases or start an automatic search from there. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, retry failed downloads, remove from the arr queue and blocklist. - **Log (F6)**: actions report through popups that fade away; the log keeps the history. @@ -73,7 +73,7 @@ Keep the file private (`chmod 600`); it holds your API keys. | `Enter`, `a` | add selected title | | `i` | interactive release search | | `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload | -| `[`, `]`, `t`, `u`, `Enter`, `s` (calendar) | week back / forward, today, unmonitored, releases, search | +| `t`, `u`, `Enter`, `s` (calendar) | today, unmonitored, releases, search; scroll for more days | | `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor | | `Enter`/`g`, `s`, `f`, `F` (release list) | grab, cycle sort, hide rejected, filter by properties | | `p`, `d`, `t`, `b`, `r` (downloads) | pause, delete, retry, blocklist, refresh | diff --git a/SPECS.md b/SPECS.md index 48b760e..ee17d50 100644 --- a/SPECS.md +++ b/SPECS.md @@ -64,7 +64,7 @@ Notes: - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. - **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference. - **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. -- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search. +- **Calendar view (F5):** one scrolling list of the library's movie releases and airing episodes, grouped by day. It starts with 7 days back and 28 ahead, today's header at the top, and loads another 28 days whenever the selection comes within 6 rows of either end (or ↓/↑ is pressed at an end), up to about 180 days back and 400 ahead; the selection and scroll position stay put when days are added above. `t` returns to today (resetting the list if it was left), `u` includes unmonitored items, `r` reloads. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). A movie appears once per release date. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search. - **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that. - **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll. @@ -115,7 +115,7 @@ Keybindings (the status bar shows the ones that apply to the current view): | `i` | Movies, Series, Library: interactive release search (series: seasons and episodes) | | `F` | Library and release list: filter by properties (quality, codec, HDR/DV, audio, language) | | `f` | Library: cycle the status filter; release list: hide rejected releases | -| `[` `]` `t` `u` | Calendar: week back / forward, today, include unmonitored | +| `t` `u` | Calendar: back to today, include unmonitored (scroll for more days) | | `d` | Library: remove the item from the library (asks first) | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | diff --git a/internal/ui/app.go b/internal/ui/app.go index 5959ce9..d471801 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -453,7 +453,7 @@ func libraryHints() string { } func calendarHints() string { - return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload" + return "scroll for more · t today · u unmonitored · Enter releases · s search · r reload" } func logHints() string { return "↑↓ scroll · G follow · c clear" } diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index 0ec6d82..f67a3ef 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -18,8 +18,12 @@ import ( ) const ( - calendarDays = 14 // length of the window - calendarStep = 7 // `[` and `]` move by this many days + 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. @@ -166,7 +170,9 @@ func dayLabel(day, today time.Time) (date, relative string) { return date, "" } -// calendarView lists the movies and episodes coming up in a two-week window. +// 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 @@ -178,12 +184,14 @@ type calendarView struct { details *detailPane // UI-goroutine state. - offset int // days from today to the first day shown; moved with [ and ] unmonitored bool + from, to time.Time // the days loaded so far entries []calEntry rows []calRow loaded bool - gen int + 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. @@ -199,14 +207,17 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0), details: newDetailPane(app), } - v.table.SetBorder(true).SetTitle(" Upcoming ") + 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.table.SetSelectionChangedFunc(func(int, int) { + v.renderDetails() + v.loadNearEnd() + }) v.table.SetSelectedFunc(func(int, int) { v.open() }) v.table.SetInputCapture(v.handleKey) v.render() @@ -214,50 +225,130 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView } func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey { - if ev.Key() != tcell.KeyRune { - return ev + 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 } - switch ev.Rune() { - case '[': - v.offset -= calendarStep - v.reload() - case ']': - v.offset += calendarStep - v.reload() - case 't': - v.offset = 0 - v.reload() - 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 } -// window returns the first and last day shown. -func (v *calendarView) window(now time.Time) (from, to time.Time) { - from = localDay(now).AddDate(0, 0, v.offset) - return from, from.AddDate(0, 0, calendarDays-1) +func (v *calendarView) activate() { + if !v.loaded { + v.reload() + } else { + v.refresh() + } } -func (v *calendarView) activate() { v.reload() } - -// reload fetches both calendars in parallel. +// reload forgets everything and loads the initial range, with today at the top. func (v *calendarView) reload() { v.gen++ - gen, unmonitored := v.gen, v.unmonitored - now := time.Now() - from, to := v.window(now) + 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() @@ -293,19 +384,35 @@ func (v *calendarView) reload() { 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 - v.entries, v.loaded = entries, true + 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 { @@ -316,12 +423,13 @@ func (v *calendarView) selected() (*calEntry, bool) { 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) - from, to := v.window(now) v.rows = v.rows[:0] var lastDay time.Time @@ -335,8 +443,11 @@ func (v *calendarView) render() { } v.table.Clear() - selRow, firstEntry := 0, -1 + 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)) @@ -366,13 +477,16 @@ func (v *calendarView) render() { } } - title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " " + title := " Upcoming · " + v.from.Format("2 Jan") + " – " + v.to.Format("2 Jan") if v.unmonitored { - title += "· incl. unmonitored " + title += " · incl. unmonitored" } - v.table.SetTitle(title) - switch { - case len(v.entries) == 0: + 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" @@ -380,17 +494,31 @@ func (v *calendarView) render() { v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false)) v.table.SetSelectable(false, false) v.table.Select(0, 0) - default: + } else { v.table.SetSelectable(true, false) - if selRow == 0 { - selRow = firstEntry - } - v.table.Select(selRow, 0) - if selRow == firstEntry { - v.table.ScrollToBeginning() + 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[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search") + v.header.SetText("[::b]Calendar[::-] · scroll for more · t today · u unmonitored · Enter releases · s search") v.renderDetails() } diff --git a/internal/ui/calendar_test.go b/internal/ui/calendar_test.go index ff5493d..1408bf2 100644 --- a/internal/ui/calendar_test.go +++ b/internal/ui/calendar_test.go @@ -124,3 +124,21 @@ func TestSortEntriesAndDayLabel(t *testing.T) { } } } + +func TestMergeEntries(t *testing.T) { + d1, d2, d3 := day(2026, 10, 21), day(2026, 10, 22), day(2026, 10, 23) + have := []calEntry{{day: d2, title: "b", episodeID: 2}, {day: d3, title: "c", episodeID: 3}} + add := []calEntry{ + {day: d1, title: "a", episodeID: 1}, + {day: d2, title: "b", episodeID: 2}, // already there: not duplicated + {day: d3, title: "film", movie: true, movieID: 7, sub: "In cinemas"}, + } + got := mergeEntries(have, add) + var titles []string + for _, e := range got { + titles = append(titles, e.title) + } + if strings.Join(titles, ",") != "a,b,c,film" { + t.Errorf("merged = %v", titles) + } +} diff --git a/internal/ui/help.go b/internal/ui/help.go index 119b3a7..c3e2249 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -38,7 +38,8 @@ const helpText = `[yellow]Global[-] r reload [yellow]Calendar (F5)[-] - [ ] move the two-week window back / forward by a week + ↑↓, PgUp/PgDn scroll; earlier and later days are loaded as you get near + either end (up to about 6 months back and 13 months ahead) t back to today u include unmonitored items Enter, i release search for the selected movie / episode