diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b85258..15c28e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,8 @@ ## Unreleased -- **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. +- **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. - **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 dee70cd..da554fe 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**: 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. +- **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. - **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) | 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`/`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 ee17d50..48b760e 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):** 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. - **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: 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) | | `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 d471801..5959ce9 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -453,7 +453,7 @@ func libraryHints() 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" } diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index f67a3ef..0ec6d82 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -18,12 +18,8 @@ import ( ) 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 + calendarDays = 14 // length of the window + calendarStep = 7 // `[` and `]` move by this many days ) // 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, "" } -// 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. +// calendarView lists the movies and episodes coming up in a two-week window. type calendarView struct { app *App radarr *radarr.Client @@ -184,14 +178,12 @@ 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 - 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 + gen int } // 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), details: newDetailPane(app), } - v.table.SetBorder(true) + v.table.SetBorder(true).SetTitle(" Upcoming ") 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.SetSelectionChangedFunc(func(int, int) { v.renderDetails() }) v.table.SetSelectedFunc(func(int, int) { v.open() }) v.table.SetInputCapture(v.handleKey) v.render() @@ -225,130 +214,50 @@ func newCalendarView(app *App, r *radarr.Client, s *sonarr.Client) *calendarView } 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 + if ev.Key() != tcell.KeyRune { + return ev } - return ev -} - -func (v *calendarView) activate() { - if !v.loaded { + switch ev.Rune() { + case '[': + v.offset -= calendarStep v.reload() - } else { - v.refresh() + 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 } -// reload forgets everything and loads the initial range, with today at the top. +// 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() { v.reload() } + +// reload fetches both calendars in parallel. 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 + gen, unmonitored := v.gen, v.unmonitored now := time.Now() + from, to := v.window(now) + v.render() + go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -384,35 +293,19 @@ func (v *calendarView) fetch(from, to time.Time, gen int, apply func([]calEntry) 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.entries, v.loaded = entries, true } 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 { @@ -423,13 +316,12 @@ 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 @@ -443,11 +335,8 @@ func (v *calendarView) render() { } v.table.Clear() - selRow, firstEntry, todayRow := -1, -1, -1 + selRow, firstEntry := 0, -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)) @@ -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 { - title += " · incl. unmonitored" + title += "· incl. unmonitored " } - if v.loading[0] || v.loading[1] || !v.loaded { - title += " · loading…" - } - v.table.SetTitle(title + " ") - - if firstEntry < 0 { + v.table.SetTitle(title) + switch { + case len(v.entries) == 0: msg := "Loading…" if v.loaded { 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.SetSelectable(false, false) v.table.Select(0, 0) - } else { + default: 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) + 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() } diff --git a/internal/ui/calendar_test.go b/internal/ui/calendar_test.go index 1408bf2..ff5493d 100644 --- a/internal/ui/calendar_test.go +++ b/internal/ui/calendar_test.go @@ -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) - } -} diff --git a/internal/ui/help.go b/internal/ui/help.go index c3e2249..119b3a7 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -38,8 +38,7 @@ const helpText = `[yellow]Global[-] r reload [yellow]Calendar (F5)[-] - ↑↓, 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) + [ ] move the two-week window back / forward by a week t back to today u include unmonitored items Enter, i release search for the selected movie / episode