Add the calendar view (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
-1
@@ -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`).
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user