From cd5786ba78b6e598125444802403f3fc9fcf5be7 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 22:39:39 +0200 Subject: [PATCH] Add the calendar view (M10) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 +- README.md | 8 +- SPECS.md | 15 +- internal/radarr/radarr.go | 18 ++ internal/radarr/radarr_test.go | 19 ++ internal/sonarr/sonarr.go | 34 ++- internal/sonarr/sonarr_test.go | 19 ++ internal/ui/app.go | 48 +++- internal/ui/app_test.go | 4 +- internal/ui/calendar.go | 466 +++++++++++++++++++++++++++++++++ internal/ui/calendar_test.go | 126 +++++++++ internal/ui/help.go | 11 +- internal/ui/notify.go | 6 + 13 files changed, 756 insertions(+), 30 deletions(-) create mode 100644 internal/ui/calendar.go create mode 100644 internal/ui/calendar_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5adf4c5..15c28e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 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. +- **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 + import exclusion; both off by default. +- On narrow terminals the connection indicators shrink to initials. + ## v0.1.0 First release. @@ -17,7 +27,7 @@ First release. - **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete, retry, remove and blocklist. - **Posters:** a thumbnail at the top right of the detail panes with the text flowing around it (quadrant or, on capable terminals, sextant block rendering; `ui.cover_style`). -- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history. +- **Log (F6) and popups:** feedback appears as fading popups; the log keeps the history. - Connection indicators for all three services, context-sensitive key hints, `?` help. - JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds (`make dist`). diff --git a/README.md b/README.md index 3ec5b01..da554fe 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ 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. - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, retry failed downloads, remove from the arr queue and blocklist. -- **Log (F5)**: 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. - **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails, whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished before arr-tui started are not announced. `"ui": {"notify_downloads": false}` turns it off, @@ -63,13 +66,14 @@ Keep the file private (`chmod 600`); it holds your API keys. | Key | Action | |---|---| -| `F1`–`F5` | switch view (Movies, Series, Library, Downloads, Log) | +| `F1`–`F6` | switch view (Movies, Series, Library, Downloads, Calendar, Log) | | `Tab`, `Shift+Tab` | switch panel within a view | | `?` / `F9` | help | | `/` | focus search box | | `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 | | `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 0554b9c..48b760e 100644 --- a/SPECS.md +++ b/SPECS.md @@ -6,7 +6,7 @@ A terminal UI to search for movies and series, add them to Radarr / Sonarr, and - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell) - **License:** MIT - **Module:** `git.stinnesbeck.com/nils/arr-tui` -- **Status:** M9 done +- **Status:** M10 done ## 1. Goals @@ -64,6 +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. - **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. @@ -74,9 +75,6 @@ Notes: ### 4.3 Later / ideas -- Calendar / upcoming view. -- Desktop/terminal notification when a download completes. -- Multiple instances per service (e.g. 4K Radarr). - Poster preview via sixel / kitty graphics where supported. ## 5. UI Design (tview) @@ -107,7 +105,7 @@ Keybindings (the status bar shows the ones that apply to the current view): | Key | Action | |---|---| -| `F1-F5` | Switch view (Movies, Series, Library, Downloads, Log); also while typing, and from full-screen views. Dialogs block them. | +| `F1-F6` | Switch view (Movies, Series, Library, Downloads, Calendar, Log); also while typing, and from full-screen views. Dialogs block them. | | `Tab` / `Shift+Tab` | Switch panel within the current view (never the view): search box / results, Downloads panels, Library lists | | `?` / `F9` | Help (`F9` also works while typing) | | `q` / `Ctrl+C` | Quit | @@ -117,6 +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 | | `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) | @@ -203,8 +202,8 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). - [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar. -- [ ] **M10** — Download completion notifications (done), delete from library (done), calendar view. -- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title. Multiple instances per service are explicitly out of scope. +- [x] **M10** — Download completion notifications, remove from library, calendar view (F5; the Log moves to F6). Multiple instances per service are out of scope. +- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title. ## 10. Open Questions @@ -214,7 +213,7 @@ Open: none. Decided: - Go module path: `git.stinnesbeck.com/nils/arr-tui`. -- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is a later idea). +- One instance each of Radarr, Sonarr and SABnzbd (multi-instance is out of scope). - Minimum Go version: latest stable. - License: MIT (Nils Stinnesbeck). The tool will be published, so keep secrets out of the repo, include a `config.example.json`, and write a proper README. - Config format: JSON. diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go index ad1e6ff..6fd3d64 100644 --- a/internal/radarr/radarr.go +++ b/internal/radarr/radarr.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "time" "git.stinnesbeck.com/nils/arr-tui/internal/arr" ) @@ -56,6 +57,9 @@ type Movie struct { Monitored bool `json:"monitored"` HasFile bool `json:"hasFile"` QualityProfileID int `json:"qualityProfileId"` + InCinemas string `json:"inCinemas,omitempty"` // ISO timestamps, empty if unknown + PhysicalRelease string `json:"physicalRelease,omitempty"` + DigitalRelease string `json:"digitalRelease,omitempty"` SizeOnDisk int64 `json:"sizeOnDisk"` IsAvailable bool `json:"isAvailable"` Path string `json:"path,omitempty"` @@ -181,3 +185,17 @@ func (c *Client) DeleteMovie(ctx context.Context, id int, deleteFiles, addExclus } return c.Do(ctx, http.MethodDelete, "movie/"+strconv.Itoa(id), q, nil, nil) } + +// Calendar lists the library movies with a release date (cinemas, digital or +// physical) between start and end, both inclusive. Unmonitored movies are +// only included when asked for. +func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Movie, error) { + q := url.Values{ + "start": {start.Format("2006-01-02")}, + "end": {end.Format("2006-01-02")}, + "unmonitored": {strconv.FormatBool(unmonitored)}, + } + var movies []Movie + err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &movies) + return movies, err +} diff --git a/internal/radarr/radarr_test.go b/internal/radarr/radarr_test.go index 3517f16..c069372 100644 --- a/internal/radarr/radarr_test.go +++ b/internal/radarr/radarr_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "git.stinnesbeck.com/nils/arr-tui/internal/arr" ) @@ -176,3 +177,21 @@ func TestDeleteMovie(t *testing.T) { t.Fatal(err) } } + +func TestCalendar(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if r.URL.Path != "/api/v3/calendar" || q.Get("start") != "2026-10-20" || q.Get("end") != "2026-11-02" || q.Get("unmonitored") != "true" { + t.Errorf("unexpected request %s", r.URL) + } + w.Write([]byte(`[{"id":4,"title":"Dune","year":2021,"monitored":true,"hasFile":false,"inCinemas":"2026-10-22T00:00:00Z","digitalRelease":"2026-11-01T00:00:00Z"}]`)) + }) + start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + movies, err := c.Calendar(context.Background(), start, start.AddDate(0, 0, 13), true) + if err != nil || len(movies) != 1 { + t.Fatalf("movies=%v err=%v", movies, err) + } + if m := movies[0]; m.InCinemas != "2026-10-22T00:00:00Z" || m.DigitalRelease == "" || m.PhysicalRelease != "" { + t.Errorf("dates not decoded: %+v", m) + } +} diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go index 2f4bf24..b944d62 100644 --- a/internal/sonarr/sonarr.go +++ b/internal/sonarr/sonarr.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strconv" + "time" "git.stinnesbeck.com/nils/arr-tui/internal/arr" ) @@ -210,14 +211,17 @@ func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) { // Episode is an episode of a library series. type Episode struct { - ID int `json:"id"` - SeriesID int `json:"seriesId"` - SeasonNumber int `json:"seasonNumber"` - EpisodeNumber int `json:"episodeNumber"` - Title string `json:"title"` - AirDate string `json:"airDate"` - HasFile bool `json:"hasFile"` - Monitored bool `json:"monitored"` + ID int `json:"id"` + SeriesID int `json:"seriesId"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + Title string `json:"title"` + AirDate string `json:"airDate"` + AirDateUTC string `json:"airDateUtc"` + Overview string `json:"overview,omitempty"` + HasFile bool `json:"hasFile"` + Monitored bool `json:"monitored"` + Series *Series `json:"series,omitempty"` // only with includeSeries } // Episodes lists all episodes of a library series. @@ -257,3 +261,17 @@ func (c *Client) DeleteSeries(ctx context.Context, id int, deleteFiles, addExclu } return c.Do(ctx, http.MethodDelete, "series/"+strconv.Itoa(id), q, nil, nil) } + +// Calendar lists the episodes airing between start and end, both inclusive, +// with their series. Unmonitored episodes are only included when asked for. +func (c *Client) Calendar(ctx context.Context, start, end time.Time, unmonitored bool) ([]Episode, error) { + q := url.Values{ + "start": {start.Format("2006-01-02")}, + "end": {end.Format("2006-01-02")}, + "unmonitored": {strconv.FormatBool(unmonitored)}, + "includeSeries": {"true"}, + } + var episodes []Episode + err := c.Do(ctx, http.MethodGet, "calendar", q, nil, &episodes) + return episodes, err +} diff --git a/internal/sonarr/sonarr_test.go b/internal/sonarr/sonarr_test.go index 339f29a..20e22c0 100644 --- a/internal/sonarr/sonarr_test.go +++ b/internal/sonarr/sonarr_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) const lookupJSON = `[ @@ -179,3 +180,21 @@ func TestDeleteSeries(t *testing.T) { t.Fatal(err) } } + +func TestCalendar(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if r.URL.Path != "/api/v3/calendar" || q.Get("includeSeries") != "true" || q.Get("unmonitored") != "false" || q.Get("start") != "2026-10-20" { + t.Errorf("unexpected request %s", r.URL) + } + w.Write([]byte(`[{"id":9,"seriesId":2,"seasonNumber":2,"episodeNumber":5,"title":"Welcome","airDate":"2026-10-21","airDateUtc":"2026-10-22T01:00:00Z","overview":"Pilot.","monitored":true,"series":{"id":2,"title":"Andor","images":[{"coverType":"poster","url":"/p.jpg"}]}}]`)) + }) + start := time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + eps, err := c.Calendar(context.Background(), start, start.AddDate(0, 0, 13), false) + if err != nil || len(eps) != 1 { + t.Fatalf("eps=%v err=%v", eps, err) + } + if e := eps[0]; e.Series == nil || e.Series.Title != "Andor" || e.AirDateUTC != "2026-10-22T01:00:00Z" || e.Overview != "Pilot." || len(e.Series.Images) != 1 { + t.Errorf("episode not decoded: %+v", e) + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index ba48729..5959ce9 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -41,6 +41,8 @@ type App struct { overlays []overlay // dialogs and full-screen views stacked above the pages services *tview.TextView + topBar *tview.Flex // tab bar and connection indicators + narrow bool // terminal too narrow for full service names state map[string]serviceState // keyed by service name probes []probe @@ -79,7 +81,7 @@ type overlay struct { page string focus tview.Primitive hints string - full bool // full-screen view (can be left with F1-F5), as opposed to a dialog + full bool // full-screen view (can be left with F1-F6), as opposed to a dialog close func() // optional cleanup when the overlay is removed } @@ -117,6 +119,7 @@ func New(cfg *config.Config) *App { }}, {"SABnzbd", sabClient.Version}, } + calendar := newCalendarView(a, radarrClient, sonarrClient) library := newLibraryView(a, radarrClient, sonarrClient) downloads := newDownloadsView(a, sabClient, []arrSource{ @@ -129,16 +132,18 @@ func New(cfg *config.Config) *App { {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints}, {key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints}, {key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints}, - {key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints}, + {key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints}, + {key: '6', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints}, } for i, v := range a.views { a.pages.AddPage(v.name, v.body, true, i == 0) } + a.topBar = tview.NewFlex(). + AddItem(a.tabs, 0, 1, false). + AddItem(a.services, servicesWidth(false), 0, false) layout := tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(tview.NewFlex(). - AddItem(a.tabs, 0, 1, false). - AddItem(a.services, 25, 0, false), 1, 0, false). + AddItem(a.topBar, 1, 0, false). AddItem(a.pages, 0, 1, true). AddItem(a.status, 1, 0, false) @@ -171,10 +176,35 @@ func (a *App) SetService(name, version string, err error) { a.renderServices() } +// servicesWidth is the width of the connection indicators. +func servicesWidth(narrow bool) int { + if narrow { + return 14 + } + return 25 +} + +// setNarrow switches the indicators between full names and initials; called +// from the draw handler when the terminal width crosses the threshold. +func (a *App) setNarrow(narrow bool) { + if narrow == a.narrow { + return + } + a.narrow = narrow + a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0) + a.renderServices() +} + func (a *App) renderServices() { var s string for _, n := range serviceNames { short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow + if a.narrow { + short = n[:1] + if n == "SABnzbd" { + short = "SAB" + } + } st := a.state[n] switch { case !st.known: @@ -254,7 +284,7 @@ func (a *App) renderStatus() { if h := a.views[a.active].hints; h != nil { hints = h() + " · " } - hints += "? help · F1-F5 · q quit" + hints += "? help · F1-F6 · q quit" } a.status.SetText("[gray]" + hints) } @@ -381,7 +411,7 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { return ev } -// viewShortcut returns the view index for F1-F5, or -1. +// viewShortcut returns the view index for F1-F6, or -1. func (a *App) viewShortcut(ev *tcell.EventKey) int { if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 { return int(ev.Key() - tcell.KeyF1) @@ -422,4 +452,8 @@ func libraryHints() string { return "Tab list · f status · F props · / text · t monitor · s search · d remove" } +func calendarHints() string { + 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/app_test.go b/internal/ui/app_test.go index 612df07..f051e18 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -22,7 +22,7 @@ func newTestApp(t *testing.T) *App { func TestOnlyFunctionKeysSwitchViews(t *testing.T) { a := newTestApp(t) - for _, r := range "12345\t" { + for _, r := range "123456\t" { key := tcell.NewEventKey(tcell.KeyRune, r, 0) if r == '\t' { key = tcell.NewEventKey(tcell.KeyTab, 0, 0) @@ -32,7 +32,7 @@ func TestOnlyFunctionKeysSwitchViews(t *testing.T) { t.Fatalf("key %q switched to view %d", r, a.active) } } - for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5} { + for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5, tcell.KeyF6} { if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i { t.Errorf("F%d: active view %d, want %d", i+1, a.active, i) } diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go new file mode 100644 index 0000000..0ec6d82 --- /dev/null +++ b/internal/ui/calendar.go @@ -0,0 +1,466 @@ +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 ( + 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. +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 in a two-week window. +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. + offset int // days from today to the first day shown; moved with [ and ] + unmonitored bool + entries []calEntry + rows []calRow + loaded bool + gen int +} + +// 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).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.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 { + if ev.Key() != tcell.KeyRune { + return ev + } + 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 +} + +// 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++ + 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() + 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 _, 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 + } + 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 := "" + 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 + 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 := 0, -1 + for i, r := range v.rows { + 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 · " + from.Format("2 Jan") + " – " + to.Format("2 Jan") + " " + if v.unmonitored { + title += "· incl. unmonitored " + } + v.table.SetTitle(title) + switch { + case len(v.entries) == 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) + default: + 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[::-] · [ ] move by a week · 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) + }) + }() +} diff --git a/internal/ui/calendar_test.go b/internal/ui/calendar_test.go new file mode 100644 index 0000000..ff5493d --- /dev/null +++ b/internal/ui/calendar_test.go @@ -0,0 +1,126 @@ +package ui + +import ( + "strings" + "testing" + "time" + + "git.stinnesbeck.com/nils/arr-tui/internal/radarr" + "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" +) + +func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.Local) } + +func TestMovieEntriesPerReleaseDate(t *testing.T) { + from, to := day(2026, 10, 20), day(2026, 11, 2) + now := day(2026, 10, 21).Add(10 * time.Hour) + m := radarr.Movie{ + ID: 4, Title: "Dune [Part 2]", Year: 2024, Monitored: true, + InCinemas: "2026-10-22T00:00:00Z", // inside + DigitalRelease: "2026-11-01T00:00:00Z", // inside + // physical release is after the window + PhysicalRelease: "2026-12-01T00:00:00Z", + } + es := movieEntries(m, from, to, coverRef{}, now) + if len(es) != 2 { + t.Fatalf("%d entries, want 2: %+v", len(es), es) + } + if es[0].sub != "In cinemas" || !es[0].day.Equal(day(2026, 10, 22)) || es[1].sub != "Digital release" || !es[1].day.Equal(day(2026, 11, 1)) { + t.Errorf("entries = %+v", es) + } + if !es[0].movie || es[0].missing || !strings.Contains(es[0].label, "Dune [Part 2[]") { + t.Errorf("flags/label wrong: %+v", es[0]) + } + if es[0].key() == es[1].key() { + t.Error("the same movie on two dates must have distinct keys") + } + if !strings.Contains(es[0].details.head, "Release: In cinemas, Thu 22 Oct 2026") { + t.Errorf("details: %q", es[0].details.head) + } + + // A release that already happened and is not downloaded counts as missing. + past := movieEntries(radarr.Movie{ID: 5, Title: "Old", Monitored: true, DigitalRelease: "2026-10-20T00:00:00Z"}, from, to, coverRef{}, now) + if len(past) != 1 || !past[0].missing { + t.Errorf("past release: %+v", past) + } + // Dates are taken as written: midnight UTC stays on that calendar day in every zone. + if es := movieEntries(radarr.Movie{ID: 6, Title: "Z", InCinemas: "2026-10-25T00:00:00Z"}, from, to, coverRef{}, now); len(es) != 1 || es[0].day.Day() != 25 { + t.Errorf("timezone shifted the release date: %+v", es) + } + if es := movieEntries(radarr.Movie{ID: 7, Title: "NoDates"}, from, to, coverRef{}, now); len(es) != 0 { + t.Errorf("movie without dates: %+v", es) + } +} + +func TestEpisodeEntry(t *testing.T) { + from, to := day(2026, 10, 20), day(2026, 11, 2) + now := day(2026, 10, 21).Add(12 * time.Hour) + at := time.Date(2026, 10, 22, 19, 30, 0, 0, time.UTC) + e := sonarr.Episode{ + ID: 9, SeasonNumber: 2, EpisodeNumber: 5, Title: "Welcome", Monitored: true, + AirDateUTC: at.Format(time.RFC3339), Overview: "Episode plot", + Series: &sonarr.Series{Title: "Andor", Overview: "Series plot"}, + } + got, ok := episodeEntry(e, from, to, coverRef{}, now) + if !ok { + t.Fatal("episode in the window rejected") + } + if got.sub != "S02E05 Welcome" || got.title != "Andor" || got.movie || got.episodeID != 9 || got.at.IsZero() { + t.Errorf("entry = %+v", got) + } + if !got.day.Equal(localDay(at.Local())) { + t.Errorf("day = %v, want the local date of the airing", got.day) + } + if got.details.plot != "Episode plot" || !strings.Contains(got.details.head, "Andor") || !strings.Contains(got.details.head, "S02E05") { + t.Errorf("details = %+v", got.details) + } + if got.missing { + t.Error("an episode that has not aired yet is not missing") + } + + // Falls back to the series plot, and to the date-only air date. + e2 := sonarr.Episode{ID: 10, Title: "X", AirDate: "2026-10-19", Monitored: true, Series: &sonarr.Series{Title: "S", Overview: "Series plot"}} + if _, ok := episodeEntry(e2, from, to, coverRef{}, now); ok { + t.Error("an episode before the window must be left out") + } + e2.AirDate = "2026-10-20" + got, ok = episodeEntry(e2, from, to, coverRef{}, now) + if !ok || got.details.plot != "Series plot" || !got.at.IsZero() || !got.missing { + t.Errorf("date-only episode: ok=%v %+v", ok, got) + } + if _, ok := episodeEntry(sonarr.Episode{ID: 11}, from, to, coverRef{}, now); ok { + t.Error("an episode without any date has no place in the calendar") + } +} + +func TestSortEntriesAndDayLabel(t *testing.T) { + d1, d2 := day(2026, 10, 21), day(2026, 10, 22) + es := []calEntry{ + {day: d2, title: "b"}, + {day: d1, at: d1.Add(20 * time.Hour), title: "late"}, + {day: d1, title: "dateonly"}, + {day: d1, at: d1.Add(9 * time.Hour), title: "early"}, + {day: d1, at: d1.Add(9 * time.Hour), title: "Alpha"}, + } + sortEntries(es) + var got []string + for _, e := range es { + got = append(got, e.title) + } + if strings.Join(got, ",") != "dateonly,Alpha,early,late,b" { + t.Errorf("order = %v", got) + } + + today := day(2026, 10, 21) + type label struct{ date, rel string } + for d, want := range map[time.Time]label{ + today: {"Wed 21 Oct", "Today"}, + today.AddDate(0, 0, 1): {"Thu 22 Oct", "Tomorrow"}, + today.AddDate(0, 0, -1): {"Tue 20 Oct", "Yesterday"}, + today.AddDate(0, 0, 5): {"Mon 26 Oct", ""}, + } { + if date, rel := dayLabel(d, today); (label{date, rel}) != want { + t.Errorf("dayLabel(%v) = %q, %q, want %v", d, date, rel, want) + } + } +} diff --git a/internal/ui/help.go b/internal/ui/help.go index db3f469..119b3a7 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -6,7 +6,7 @@ import ( ) const helpText = `[yellow]Global[-] - F1-F5 switch view (also from the release and episode lists) + F1-F6 switch view (also from the release and episode lists) Tab, Shift+Tab switch panel within a view ?, F9 this help (F9 works while typing) q, Ctrl+C quit @@ -37,6 +37,13 @@ const helpText = `[yellow]Global[-] i, Enter movie: release search; series: seasons and episodes r reload +[yellow]Calendar (F5)[-] + [ ] 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 + s automatic search r reload + [yellow]Seasons and episodes[-] Enter season: expand/collapse; episode: release search i, s, t release search, automatic search, toggle monitored @@ -56,7 +63,7 @@ const helpText = `[yellow]Global[-] t retry a failed download (history) r refresh now -[yellow]Log (F5)[-] +[yellow]Log (F6)[-] Popups fade away by themselves; everything they said is kept here. ↑↓, PgUp/PgDn scroll G follow new entries c clear diff --git a/internal/ui/notify.go b/internal/ui/notify.go index 48a42ad..e2c4eee 100644 --- a/internal/ui/notify.go +++ b/internal/ui/notify.go @@ -115,7 +115,13 @@ type toastRoot struct { app *App } +// narrowWidth is the terminal width below which the six tabs and the full +// service names no longer fit on one line. +const narrowWidth = 92 + func (r *toastRoot) Draw(screen tcell.Screen) { + w, _ := screen.Size() + r.app.setNarrow(w < narrowWidth) r.Flex.Draw(screen) a := r.app now := time.Now()