Calendar: one scrolling list that loads more days at either end

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:47:51 +02:00
co-authored by Claude Sonnet 5.5
parent cd5786ba78
commit 938980f1e1
7 changed files with 216 additions and 68 deletions
+3 -2
View File
@@ -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
+4 -4
View File
@@ -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 |
+2 -2
View File
@@ -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/<id>/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) |
+1 -1
View File
@@ -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" }
+186 -58
View File
@@ -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()
}
+18
View File
@@ -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)
}
}
+2 -1
View File
@@ -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