Revert the scrolling calendar: back to moving the two-week window with [ and ]

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:49:12 +02:00
co-authored by Claude Sonnet 5.5
parent 938980f1e1
commit 37f86aaf75
7 changed files with 69 additions and 217 deletions
+2 -3
View File
@@ -2,9 +2,8 @@
## Unreleased ## Unreleased
- **Calendar (F5):** a scrolling list of movie releases and airing episodes grouped by day, starting - **Calendar (F5):** two weeks of movie releases and airing episodes grouped by day, with poster,
at today and loading more days as you scroll, with poster, plot, release search and automatic plot, release search and automatic search. The Log moves to F6.
search. The Log moves to F6.
- **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the - **Download notifications:** popup and log entry when a SABnzbd download finishes or fails, in the
background (`ui.notify_downloads`, optional `ui.bell`). background (`ui.notify_downloads`, optional `ui.bell`).
- **Remove from the library** (`d`), with separate checkboxes for deleting the files and for the - **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 - **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 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. per-episode monitoring, automatic search and release search.
- **Calendar**: movie releases (cinemas, digital, physical) and airing episodes in one scrolling list - **Calendar**: the next two weeks of movie releases (cinemas, digital, physical) and airing episodes
grouped by day, starting at today, with poster and plot. More days load as you scroll; include in one list grouped by day, with poster and plot. Move by weeks, include unmonitored items, search
unmonitored items, search for releases or start an automatic search from there. for releases or start an automatic search from there.
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
retry failed downloads, remove from the arr queue and blocklist. retry failed downloads, remove from the arr queue and blocklist.
- **Log (F6)**: actions report through popups that fade away; the log keeps the history. - **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 | | `Enter`, `a` | add selected title |
| `i` | interactive release search | | `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 | | `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) | today, unmonitored, releases, search; scroll for more days | | `[`, `]`, `t`, `u`, `Enter`, `s` (calendar) | week back / forward, today, unmonitored, releases, search |
| `Enter`, `i`, `s`, `t` (episodes) | expand season / releases, release search, search, monitor | | `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 | | `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 | | `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. - 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. - **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. - **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):** 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. - **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.
- **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. - **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. - **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) | | `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 and release list: filter by properties (quality, codec, HDR/DV, audio, language) |
| `f` | Library: cycle the status filter; release list: hide rejected releases | | `f` | Library: cycle the status filter; release list: hide rejected releases |
| `t` `u` | Calendar: back to today, include unmonitored (scroll for more days) | | `[` `]` `t` `u` | Calendar: week back / forward, today, include unmonitored |
| `d` | Library: remove the item from the library (asks first) | | `d` | Library: remove the item from the library (asks first) |
| `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download | | `t` | Library, episodes: toggle monitored; Downloads history: retry a failed download |
| `s` | Library, episodes: automatic search; release list: cycle sort (best / size / age) | | `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 { func calendarHints() string {
return "scroll for more · t today · u unmonitored · Enter releases · s search · r reload" return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
} }
func logHints() string { return "↑↓ scroll · G follow · c clear" } func logHints() string { return "↑↓ scroll · G follow · c clear" }
+44 -172
View File
@@ -18,12 +18,8 @@ import (
) )
const ( const (
calendarPast = 7 // days before today loaded at the start calendarDays = 14 // length of the window
calendarAhead = 28 // days after today loaded at the start calendarStep = 7 // `[` and `]` move by this many days
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. // calEntry is one line of the calendar: a movie release or an episode airing.
@@ -170,9 +166,7 @@ func dayLabel(day, today time.Time) (date, relative string) {
return date, "" return date, ""
} }
// calendarView lists the movies and episodes coming up as one scrolling list. // calendarView lists the movies and episodes coming up in a two-week window.
// 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 { type calendarView struct {
app *App app *App
radarr *radarr.Client radarr *radarr.Client
@@ -184,14 +178,12 @@ type calendarView struct {
details *detailPane details *detailPane
// UI-goroutine state. // UI-goroutine state.
offset int // days from today to the first day shown; moved with [ and ]
unmonitored bool unmonitored bool
from, to time.Time // the days loaded so far
entries []calEntry entries []calEntry
rows []calRow rows []calRow
loaded bool loaded bool
loading [2]bool // loading more at the [top, bottom] gen int
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. // calRow is a table row: a day header (entry == nil) or an entry.
@@ -207,17 +199,14 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0), table: tview.NewTable().SetSelectable(true, false).SetFixed(0, 0),
details: newDetailPane(app), details: newDetailPane(app),
} }
v.table.SetBorder(true) v.table.SetBorder(true).SetTitle(" Upcoming ")
v.root = tview.NewFlex().SetDirection(tview.FlexRow). v.root = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.header, 1, 0, false). AddItem(v.header, 1, 0, false).
AddItem(tview.NewFlex(). AddItem(tview.NewFlex().
AddItem(v.table, 0, 3, true). AddItem(v.table, 0, 3, true).
AddItem(v.details, 0, 2, false), 0, 1, true) AddItem(v.details, 0, 2, false), 0, 1, true)
v.table.SetSelectionChangedFunc(func(int, int) { v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetails() })
v.renderDetails()
v.loadNearEnd()
})
v.table.SetSelectedFunc(func(int, int) { v.open() }) v.table.SetSelectedFunc(func(int, int) { v.open() })
v.table.SetInputCapture(v.handleKey) v.table.SetInputCapture(v.handleKey)
v.render() v.render()
@@ -225,15 +214,19 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView
} }
func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey { func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
switch ev.Key() { if ev.Key() != tcell.KeyRune {
case tcell.KeyDown, tcell.KeyPgDn, tcell.KeyEnd: return ev
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() { switch ev.Rune() {
case '[':
v.offset -= calendarStep
v.reload()
case ']':
v.offset += calendarStep
v.reload()
case 't': case 't':
v.today() v.offset = 0
v.reload()
case 'u': case 'u':
v.unmonitored = !v.unmonitored v.unmonitored = !v.unmonitored
v.reload() v.reload()
@@ -248,107 +241,23 @@ func (v *calendarView) handleKey(ev *tcell.EventKey) *tcell.EventKey {
} }
return nil 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() { func (v *calendarView) activate() { v.reload() }
if !v.loaded {
v.reload()
} else {
v.refresh()
}
}
// reload forgets everything and loads the initial range, with today at the top. // reload fetches both calendars in parallel.
func (v *calendarView) reload() { func (v *calendarView) reload() {
v.gen++ v.gen++
v.loading = [2]bool{} gen, unmonitored := v.gen, v.unmonitored
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() now := time.Now()
from, to := v.window(now)
v.render()
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -384,35 +293,19 @@ func (v *calendarView) fetch(from, to time.Time, gen int, apply func([]calEntry)
if gen != v.gen { if gen != v.gen {
return return
} }
for _, d := range done {
d() // before rendering, so the title no longer says "loading"
}
for _, err := range []error{movieErr, tvErr} { for _, err := range []error{movieErr, tvErr} {
if err != nil { if err != nil {
v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error())) v.app.SetMessage("[red]Calendar: " + tview.Escape(err.Error()))
} }
} }
if movieErr == nil || tvErr == nil { // show what we got if movieErr == nil || tvErr == nil { // show what we got
apply(entries) v.entries, v.loaded = entries, true
} }
v.render() 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) { func (v *calendarView) selected() (*calEntry, bool) {
r, _ := v.table.GetSelection() r, _ := v.table.GetSelection()
if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil { if r < 0 || r >= len(v.rows) || v.rows[r].entry == nil {
@@ -423,13 +316,12 @@ func (v *calendarView) selected() (*calEntry, bool) {
func (v *calendarView) render() { func (v *calendarView) render() {
selKey := "" selKey := ""
oldSel, _ := v.table.GetSelection()
oldOffset, _ := v.table.GetOffset()
if e, ok := v.selected(); ok { if e, ok := v.selected(); ok {
selKey = e.key() selKey = e.key()
} }
now := time.Now() now := time.Now()
today := localDay(now) today := localDay(now)
from, to := v.window(now)
v.rows = v.rows[:0] v.rows = v.rows[:0]
var lastDay time.Time var lastDay time.Time
@@ -443,11 +335,8 @@ func (v *calendarView) render() {
} }
v.table.Clear() v.table.Clear()
selRow, firstEntry, todayRow := -1, -1, -1 selRow, firstEntry := 0, -1
for i, r := range v.rows { 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 { if r.entry == nil {
date, relative := dayLabel(r.day, today) date, relative := dayLabel(r.day, today)
v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false)) v.table.SetCell(i, 0, tview.NewTableCell("[::b][yellow]"+date).SetSelectable(false))
@@ -477,16 +366,13 @@ func (v *calendarView) render() {
} }
} }
title := " Upcoming · " + v.from.Format("2 Jan") + " – " + v.to.Format("2 Jan") title := " Upcoming · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " "
if v.unmonitored { if v.unmonitored {
title += "· incl. unmonitored " title += "· incl. unmonitored "
} }
if v.loading[0] || v.loading[1] || !v.loaded { v.table.SetTitle(title)
title += " · loading…" switch {
} case len(v.entries) == 0:
v.table.SetTitle(title + " ")
if firstEntry < 0 {
msg := "Loading…" msg := "Loading…"
if v.loaded { if v.loaded {
msg = "Nothing in this period" msg = "Nothing in this period"
@@ -494,31 +380,17 @@ func (v *calendarView) render() {
v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false)) v.table.SetCell(0, 0, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
v.table.SetSelectable(false, false) v.table.SetSelectable(false, false)
v.table.Select(0, 0) 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: default:
v.table.Select(firstEntry, 0) v.table.SetSelectable(true, false)
if selRow == 0 {
selRow = firstEntry
}
v.table.Select(selRow, 0)
if selRow == firstEntry {
v.table.ScrollToBeginning()
} }
} }
v.header.SetText("[::b]Calendar[::-] · scroll for more · t today · u unmonitored · Enter releases · s search") v.header.SetText("[::b]Calendar[::-] · [ ] move by a week · t today · u unmonitored · Enter releases · s search")
v.renderDetails() v.renderDetails()
} }
-18
View File
@@ -124,21 +124,3 @@ 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)
}
}
+1 -2
View File
@@ -38,8 +38,7 @@ const helpText = `[yellow]Global[-]
r reload r reload
[yellow]Calendar (F5)[-] [yellow]Calendar (F5)[-]
↑↓, PgUp/PgDn scroll; earlier and later days are loaded as you get near [ ] move the two-week window back / forward by a week
either end (up to about 6 months back and 13 months ahead)
t back to today t back to today
u include unmonitored items u include unmonitored items
Enter, i release search for the selected movie / episode Enter, i release search for the selected movie / episode