Announce finished and failed downloads in the background (M10)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 22:34:55 +02:00
co-authored by Claude Sonnet 5.5
parent a7765c5079
commit ebbb3cd178
7 changed files with 157 additions and 2 deletions
+8
View File
@@ -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 }
+16
View File
@@ -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()
}
+37
View File
@@ -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)
}
}
+86
View File
@@ -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()
}
}
}