Announce finished and failed downloads in the background (M10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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/<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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
"ui": {
|
||||
"refresh_seconds": 3,
|
||||
"covers": true,
|
||||
"notify_downloads": true,
|
||||
"bell": false,
|
||||
"cover_style": "auto"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user