Add the calendar view (M10)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:39:39 +02:00
co-authored by Claude Sonnet 5.5
parent 7582504268
commit cd5786ba78
13 changed files with 756 additions and 30 deletions
+11 -1
View File
@@ -1,5 +1,15 @@
# Changelog # 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 ## v0.1.0
First release. First release.
@@ -17,7 +27,7 @@ First release.
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete, - **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
retry, remove and blocklist. 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`). - **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. - Connection indicators for all three services, context-sensitive key hints, `?` help.
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds - JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
(`make dist`). (`make dist`).
+6 -2
View File
@@ -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 - **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**: 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, - **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 (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, - **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 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, 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 | | 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 | | `Tab`, `Shift+Tab` | switch panel within a view |
| `?` / `F9` | help | | `?` / `F9` | help |
| `/` | focus search box | | `/` | focus search box |
| `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) | 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 |
+7 -8
View File
@@ -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) - **TUI library:** [tview](https://github.com/rivo/tview) (on top of tcell)
- **License:** MIT - **License:** MIT
- **Module:** `git.stinnesbeck.com/nils/arr-tui` - **Module:** `git.stinnesbeck.com/nils/arr-tui`
- **Status:** M9 done - **Status:** M10 done
## 1. Goals ## 1. Goals
@@ -64,6 +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):** 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.
@@ -74,9 +75,6 @@ Notes:
### 4.3 Later / ideas ### 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. - Poster preview via sixel / kitty graphics where supported.
## 5. UI Design (tview) ## 5. UI Design (tview)
@@ -107,7 +105,7 @@ Keybindings (the status bar shows the ones that apply to the current view):
| Key | Action | | 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 | | `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) | | `?` / `F9` | Help (`F9` also works while typing) |
| `q` / `Ctrl+C` | Quit | | `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) | | `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: 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) |
@@ -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] **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] **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. - [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. - [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. Multiple instances per service are explicitly out of scope. - [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title.
## 10. Open Questions ## 10. Open Questions
@@ -214,7 +213,7 @@ Open: none.
Decided: Decided:
- Go module path: `git.stinnesbeck.com/nils/arr-tui`. - 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. - 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. - 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. - Config format: JSON.
+18
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/arr"
) )
@@ -56,6 +57,9 @@ type Movie struct {
Monitored bool `json:"monitored"` Monitored bool `json:"monitored"`
HasFile bool `json:"hasFile"` HasFile bool `json:"hasFile"`
QualityProfileID int `json:"qualityProfileId"` 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"` SizeOnDisk int64 `json:"sizeOnDisk"`
IsAvailable bool `json:"isAvailable"` IsAvailable bool `json:"isAvailable"`
Path string `json:"path,omitempty"` 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) 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
}
+19
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/arr"
) )
@@ -176,3 +177,21 @@ func TestDeleteMovie(t *testing.T) {
t.Fatal(err) 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)
}
}
+18
View File
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/arr"
) )
@@ -216,8 +217,11 @@ type Episode struct {
EpisodeNumber int `json:"episodeNumber"` EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"` Title string `json:"title"`
AirDate string `json:"airDate"` AirDate string `json:"airDate"`
AirDateUTC string `json:"airDateUtc"`
Overview string `json:"overview,omitempty"`
HasFile bool `json:"hasFile"` HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"` Monitored bool `json:"monitored"`
Series *Series `json:"series,omitempty"` // only with includeSeries
} }
// Episodes lists all episodes of a library series. // 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) 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
}
+19
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
) )
const lookupJSON = `[ const lookupJSON = `[
@@ -179,3 +180,21 @@ func TestDeleteSeries(t *testing.T) {
t.Fatal(err) 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)
}
}
+41 -7
View File
@@ -41,6 +41,8 @@ type App struct {
overlays []overlay // dialogs and full-screen views stacked above the pages overlays []overlay // dialogs and full-screen views stacked above the pages
services *tview.TextView 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 state map[string]serviceState // keyed by service name
probes []probe probes []probe
@@ -79,7 +81,7 @@ type overlay struct {
page string page string
focus tview.Primitive focus tview.Primitive
hints string 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 close func() // optional cleanup when the overlay is removed
} }
@@ -117,6 +119,7 @@ func New(cfg *config.Config) *App {
}}, }},
{"SABnzbd", sabClient.Version}, {"SABnzbd", sabClient.Version},
} }
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient) library := newLibraryView(a, radarrClient, sonarrClient)
downloads := newDownloadsView(a, sabClient, downloads := newDownloadsView(a, sabClient,
[]arrSource{ []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: '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: '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: '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 { for i, v := range a.views {
a.pages.AddPage(v.name, v.body, true, i == 0) a.pages.AddPage(v.name, v.body, true, i == 0)
} }
layout := tview.NewFlex().SetDirection(tview.FlexRow). a.topBar = tview.NewFlex().
AddItem(tview.NewFlex().
AddItem(a.tabs, 0, 1, false). AddItem(a.tabs, 0, 1, false).
AddItem(a.services, 25, 0, false), 1, 0, false). AddItem(a.services, servicesWidth(false), 0, false)
layout := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(a.topBar, 1, 0, false).
AddItem(a.pages, 0, 1, true). AddItem(a.pages, 0, 1, true).
AddItem(a.status, 1, 0, false) AddItem(a.status, 1, 0, false)
@@ -171,10 +176,35 @@ func (a *App) SetService(name, version string, err error) {
a.renderServices() 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() { func (a *App) renderServices() {
var s string var s string
for _, n := range serviceNames { for _, n := range serviceNames {
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow 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] st := a.state[n]
switch { switch {
case !st.known: case !st.known:
@@ -254,7 +284,7 @@ func (a *App) renderStatus() {
if h := a.views[a.active].hints; h != nil { if h := a.views[a.active].hints; h != nil {
hints = h() + " · " hints = h() + " · "
} }
hints += "? help · F1-F5 · q quit" hints += "? help · F1-F6 · q quit"
} }
a.status.SetText("[gray]" + hints) a.status.SetText("[gray]" + hints)
} }
@@ -381,7 +411,7 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
return ev 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 { func (a *App) viewShortcut(ev *tcell.EventKey) int {
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 { if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
return int(ev.Key() - tcell.KeyF1) 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" 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" } func logHints() string { return "↑↓ scroll · G follow · c clear" }
+2 -2
View File
@@ -22,7 +22,7 @@ func newTestApp(t *testing.T) *App {
func TestOnlyFunctionKeysSwitchViews(t *testing.T) { func TestOnlyFunctionKeysSwitchViews(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
for _, r := range "12345\t" { for _, r := range "123456\t" {
key := tcell.NewEventKey(tcell.KeyRune, r, 0) key := tcell.NewEventKey(tcell.KeyRune, r, 0)
if r == '\t' { if r == '\t' {
key = tcell.NewEventKey(tcell.KeyTab, 0, 0) 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) 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 { 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) t.Errorf("F%d: active view %d, want %d", i+1, a.active, i)
} }
+466
View File
@@ -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)
})
}()
}
+126
View File
@@ -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)
}
}
}
+9 -2
View File
@@ -6,7 +6,7 @@ import (
) )
const helpText = `[yellow]Global[-] 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 Tab, Shift+Tab switch panel within a view
?, F9 this help (F9 works while typing) ?, F9 this help (F9 works while typing)
q, Ctrl+C quit q, Ctrl+C quit
@@ -37,6 +37,13 @@ const helpText = `[yellow]Global[-]
i, Enter movie: release search; series: seasons and episodes i, Enter movie: release search; series: seasons and episodes
r reload 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[-] [yellow]Seasons and episodes[-]
Enter season: expand/collapse; episode: release search Enter season: expand/collapse; episode: release search
i, s, t release search, automatic search, toggle monitored i, s, t release search, automatic search, toggle monitored
@@ -56,7 +63,7 @@ const helpText = `[yellow]Global[-]
t retry a failed download (history) t retry a failed download (history)
r refresh now r refresh now
[yellow]Log (F5)[-] [yellow]Log (F6)[-]
Popups fade away by themselves; everything they said is kept here. Popups fade away by themselves; everything they said is kept here.
↑↓, PgUp/PgDn scroll G follow new entries c clear ↑↓, PgUp/PgDn scroll G follow new entries c clear
+6
View File
@@ -115,7 +115,13 @@ type toastRoot struct {
app *App 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) { func (r *toastRoot) Draw(screen tcell.Screen) {
w, _ := screen.Size()
r.app.setNarrow(w < narrowWidth)
r.Flex.Draw(screen) r.Flex.Draw(screen)
a := r.app a := r.app
now := time.Now() now := time.Now()