diff --git a/README.md b/README.md index 6c98fe7..2a9318c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, retry failed downloads, remove from the arr queue and blocklist. - **Log (F5)**: actions report through popups that fade away; the log keeps the history. +- **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails, + whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished + before arr-tui started are not announced. `"ui": {"notify_downloads": false}` turns it off, + `"bell": true` also rings the terminal bell. - Connection indicators for all three services in the top right; `?` shows every key. Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are @@ -47,7 +51,7 @@ Settings → General. "radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" }, "sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" }, "sabnzbd": { "url": "http://localhost:8080", "apikey": "..." }, - "ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto" } + "ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto", "notify_downloads": true, "bell": false } } ``` diff --git a/SPECS.md b/SPECS.md index fe250e8..7e9898b 100644 --- a/SPECS.md +++ b/SPECS.md @@ -64,6 +64,7 @@ Notes: - Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job. - **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference. - **Posters:** the detail panes show the poster at the top right; the head (title, facts, path) and the plot are one text that flows along the poster's left side and continues at full width below it (text is wrapped by arr-tui itself, with a narrower width for the first lines); on panes too small for the poster, the text simply uses the full width. The poster is drawn with block characters in 24-bit colors: every cell shows several pixels in the best two-color split (`quadrant`: 2×2 pixels, glyphs from the Block Elements range every font has; `sextant`: 2×3 pixels, sharper, glyphs from Symbols for Legacy Computing). `ui.cover_style` is `auto` (default: sextant on kitty, WezTerm, foot, Ghostty, quadrant elsewhere), `quadrant` or `sextant`. JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed. Library items fetch `/MediaCover//poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it. +- **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. ### 4.2 v0.2 (done, M8) @@ -201,7 +202,8 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M7** — Polish: help screen (`?`/F9), per-service connection indicators, README, Makefile release builds, `--version`. - [x] **M8** — Library view (movies/series, filters, text filter, monitor toggle, automatic search) and series drill-down (seasons/episodes, monitoring, search, release search). - [x] **M9** — Popup notifications, Log view (F5), context-sensitive key hints in the status bar. -- [ ] **M10+** — ideas: calendar, completion notifications, delete from library, multiple instances. +- [ ] **M10** — Download completion notifications (done), delete from library, calendar view. +- [ ] **M11+** — ideas: more library actions (edit quality profile, root folder), history of grabs per title. Multiple instances per service are explicitly out of scope. ## 10. Open Questions diff --git a/config.example.json b/config.example.json index 773d370..bcab08a 100644 --- a/config.example.json +++ b/config.example.json @@ -18,6 +18,8 @@ "ui": { "refresh_seconds": 3, "covers": true, + "notify_downloads": true, + "bell": false, "cover_style": "auto" } } diff --git a/internal/config/config.go b/internal/config/config.go index 241adbe..7a1518d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,6 +32,11 @@ type UI struct { // Posters of titles that are not in the library yet are downloaded from // the public image hosts Radarr/Sonarr point to (TMDb, TheTVDB). Covers *bool `json:"covers,omitempty"` + // NotifyDownloads shows a popup (and logs it) when a SABnzbd download + // finishes or fails, whichever view is open (default true). + NotifyDownloads *bool `json:"notify_downloads,omitempty"` + // Bell also rings the terminal bell for those notifications (default false). + Bell bool `json:"bell,omitempty"` // CoverStyle selects the block characters posters are drawn with: // "quadrant" (2x2 pixels per cell, every font has the glyphs), "sextant" // (2x3, sharper, needs a font with the Symbols for Legacy Computing @@ -40,6 +45,9 @@ type UI struct { CoverStyle string `json:"cover_style,omitempty"` } +// ShouldNotifyDownloads reports whether finished downloads are announced. +func (u UI) ShouldNotifyDownloads() bool { return u.NotifyDownloads == nil || *u.NotifyDownloads } + // ShowCovers reports whether poster thumbnails are enabled. func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers } diff --git a/internal/ui/app.go b/internal/ui/app.go index 0f83c7c..ec19ff9 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -44,6 +44,10 @@ type App struct { state map[string]serviceState // keyed by service name probes []probe + screen tcell.Screen // remembered at the first draw, for the bell + sab *sabnzbd.Client + notifyDone bool // announce finished downloads + bell bool covers bool // poster thumbnails enabled coverStyle coverStyle coverCache *coverCache @@ -90,6 +94,8 @@ func New(cfg *config.Config) *App { services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight), state: map[string]serviceState{}, + notifyDone: cfg.UI.ShouldNotifyDownloads(), + bell: cfg.UI.Bell, covers: cfg.UI.ShowCovers(), coverStyle: coverStyleFor(cfg.UI.CoverStyle, os.Getenv), coverCache: newCoverCache(), @@ -99,6 +105,7 @@ func New(cfg *config.Config) *App { sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) a.logs = newLogView(a) sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey) + a.sab = sabClient a.probes = []probe{ {"Radarr", func(ctx context.Context) (string, error) { st, err := radarrClient.SystemStatus(ctx) @@ -135,6 +142,10 @@ func New(cfg *config.Config) *App { AddItem(a.pages, 0, 1, true). AddItem(a.status, 1, 0, false) + a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool { + a.screen = s + return false + }) a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) a.app.SetInputCapture(a.handleKey) a.switchTo(0) @@ -206,6 +217,11 @@ func (a *App) Run() error { stop := make(chan struct{}) defer close(stop) go a.watchServices(stop) + if a.notifyDone { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go a.watchDownloads(ctx, a.sab, a.bell) + } return a.app.Run() } diff --git a/internal/ui/notify_test.go b/internal/ui/notify_test.go index 87a27a0..ce1a267 100644 --- a/internal/ui/notify_test.go +++ b/internal/ui/notify_test.go @@ -1,6 +1,7 @@ package ui import ( + "git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd" "testing" "github.com/rivo/tview" @@ -39,3 +40,39 @@ func TestOnlyFullOverlays(t *testing.T) { t.Error("a dialog must block view shortcuts") } } + +func TestDiffHistory(t *testing.T) { + slot := func(id, name, status string) sabnzbd.HistorySlot { + return sabnzbd.HistorySlot{ID: id, Name: name, Status: status, FailMessage: map[bool]string{true: "disk full"}[status == "Failed"]} + } + seen := map[string]string{} + + // The first poll only records what is already there. + if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("1", "old", "Completed"), slot("2", "old fail", "Failed")}, true); len(ev) != 0 { + t.Fatalf("baseline produced %v", ev) + } + // Nothing new. + if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("1", "old", "Completed")}, false); len(ev) != 0 { + t.Fatalf("unchanged history produced %v", ev) + } + // A new job that is still being processed is not final yet. + if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("3", "movie", "Extracting")}, false); len(ev) != 0 { + t.Fatalf("extracting produced %v", ev) + } + // It completes, a second one fails, a third appears already completed. + ev := diffHistory(seen, []sabnzbd.HistorySlot{ + slot("3", "movie", "Completed"), slot("4", "show", "Failed"), slot("5", "quick", "Completed"), slot("1", "old", "Completed"), + }, false) + if len(ev) != 3 || ev[0].name != "movie" || ev[0].failed || ev[1].name != "show" || !ev[1].failed || ev[1].message != "disk full" || ev[2].name != "quick" { + t.Fatalf("events = %+v", ev) + } + // Reported once only. + if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("3", "movie", "Completed"), slot("4", "show", "Failed")}, false); len(ev) != 0 { + t.Errorf("repeated: %+v", ev) + } + // A retried job that completes later is reported again. + diffHistory(seen, []sabnzbd.HistorySlot{slot("4", "show", "Queued")}, false) + if ev := diffHistory(seen, []sabnzbd.HistorySlot{slot("4", "show", "Completed")}, false); len(ev) != 1 { + t.Errorf("retry: %+v", ev) + } +} diff --git a/internal/ui/watch.go b/internal/ui/watch.go new file mode 100644 index 0000000..43e079c --- /dev/null +++ b/internal/ui/watch.go @@ -0,0 +1,86 @@ +package ui + +import ( + "context" + "time" + + "git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd" + "github.com/rivo/tview" +) + +// watchInterval is how often SABnzbd's history is checked for finished +// downloads in the background, whatever view is showing. +const watchInterval = 15 * time.Second + +// downloadEvent is a download that just finished or failed. +type downloadEvent struct { + name string + failed bool + message string // failure reason, if any +} + +// diffHistory compares SABnzbd's history with what was seen before and +// returns the downloads that reached a final state since. seen maps job id to +// the last known status and is updated. With baseline set (the first poll) the +// current state is only recorded, so downloads that finished before arr-tui +// started do not produce notifications. +func diffHistory(seen map[string]string, slots []sabnzbd.HistorySlot, baseline bool) []downloadEvent { + var events []downloadEvent + for _, s := range slots { + prev, known := seen[s.ID] + seen[s.ID] = s.Status + final := s.Status == "Completed" || s.Failed() + if baseline || !final || (known && prev == s.Status) { + continue + } + events = append(events, downloadEvent{name: s.Name, failed: s.Failed(), message: s.FailMessage}) + } + return events +} + +// watchDownloads polls SABnzbd's history until ctx ends and reports finished +// and failed downloads as popups (and a bell, if enabled). Errors are not +// reported here: the connection indicator and the log already do that. +func (a *App) watchDownloads(ctx context.Context, sab *sabnzbd.Client, bell bool) { + seen := map[string]string{} + baseline := true + t := time.NewTicker(watchInterval) + defer t.Stop() + for { + hctx, cancel := context.WithTimeout(ctx, 10*time.Second) + slots, err := sab.History(hctx, 30) + cancel() + if err == nil { + events := diffHistory(seen, slots, baseline) + baseline = false + if len(events) > 0 { + a.post(func() { a.announce(events, bell) }) + } + } + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// announce shows a popup for each event; UI goroutine only. +func (a *App) announce(events []downloadEvent, bell bool) { + for _, e := range events { + if e.failed { + text := "Download failed: " + tview.Escape(e.name) + if e.message != "" { + text += " (" + tview.Escape(truncate(e.message, 60)) + ")" + } + a.SetMessage("[red]" + text) + } else { + a.SetMessage("[green]Download finished: " + tview.Escape(e.name)) + } + } + if bell { + if a.screen != nil { + a.screen.Beep() + } + } +}