From f9284279939887c98315587248aa155fdf56ad51 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 20 Oct 2026 18:09:19 +0200 Subject: [PATCH] Add interactive release search (M6) Release listing and grab in the shared arr client, a full-screen release list (sort, hide rejected, rejection reasons, confirmation before grabbing rejected releases; Usenet only), and a season/episode picker for Sonarr. Overlays are now a stack so dialogs can open above the release list. Add a fake Newznab indexer to the dev setup. Co-Authored-By: Claude Sonnet 5.5 --- SPECS.md | 10 +- dev/README.md | 15 ++ dev/add-indexer.sh | 18 ++ dev/fake-indexer.py | 91 +++++++++ internal/arr/client.go | 2 +- internal/arr/release.go | 66 +++++++ internal/arr/release_test.go | 66 +++++++ internal/radarr/radarr.go | 5 + internal/radarr/radarr_test.go | 13 ++ internal/sonarr/sonarr.go | 33 ++++ internal/sonarr/sonarr_test.go | 33 ++++ internal/ui/app.go | 60 ++++-- internal/ui/backend.go | 30 +++ internal/ui/format.go | 8 + internal/ui/picker.go | 114 +++++++++++ internal/ui/releases.go | 335 +++++++++++++++++++++++++++++++++ internal/ui/releases_test.go | 110 +++++++++++ internal/ui/search.go | 16 ++ 18 files changed, 1002 insertions(+), 23 deletions(-) create mode 100755 dev/add-indexer.sh create mode 100755 dev/fake-indexer.py create mode 100644 internal/arr/release.go create mode 100644 internal/arr/release_test.go create mode 100644 internal/ui/picker.go create mode 100644 internal/ui/releases.go create mode 100644 internal/ui/releases_test.go diff --git a/SPECS.md b/SPECS.md index ad7fbac..5cde07b 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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) - **License:** MIT - **Module:** `git.stinnesbeck.com/nils/arr-tui` -- **Status:** M5 done +- **Status:** M6 done ## 1. Goals @@ -111,8 +111,10 @@ Keybindings (initial proposal): | `t` | Retry the selected failed download (history) | | `h` | Downloads view: cycle focus between SABnzbd queue, Radarr/Sonarr queue and history | | `b` | Downloads view (Radarr/Sonarr queue): blocklist the release and search for another | -| `i` | Interactive release search for the selected item | -| `g` | Grab the selected release | +| `i` | Interactive release search for the selected library item (movies; series ask for a season, then an episode or the whole season) | +| `g` / `Enter` | Release list: grab the selected release (rejected ones ask for confirmation) | +| `s` / `f` | Release list: cycle sort (best / size / age) / hide rejected | +| `Esc` | Close a dialog or the release list | | `r` | Refresh | | `?` | Help | | `q` / `Ctrl+C` | Quit | @@ -190,7 +192,7 @@ The file should be created with mode `0600`; warn if it is more permissive. API - [x] **M3** — Sonarr client and series search + add flow. - [x] **M4** — SABnzbd client and Downloads view (queue + history) with live updates and pause/resume/delete. - [x] **M5** — Radarr/Sonarr queues merged into the Downloads view. -- [ ] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. +- [x] **M6** — Interactive release search (movies, then series seasons/episodes) and grab. - [ ] **M7** — Polish: help screen, error handling, README, release builds. - [ ] **M8+** — v0.2 features (library view, series drill-down). diff --git a/dev/README.md b/dev/README.md index 5623957..58640c8 100644 --- a/dev/README.md +++ b/dev/README.md @@ -35,3 +35,18 @@ curl -H "X-Api-Key: devradarrapikey00000000000000000" -H 'Content-Type: applicat -d '{"title":"The.Matrix.1999.1080p.BluRay.x264-DEV","downloadUrl":"http://host.containers.internal:19191/some.nzb","protocol":"usenet","publishDate":"2026-01-01T00:00:00Z"}' \ http://127.0.0.1:17878/api/v3/release/push ``` + +## Interactive search without a real indexer + +`dev/fake-indexer.py` is a tiny Newznab indexer that returns a fixed set of releases for "The Matrix (1999)" +and "Breaking Bad"; Radarr/Sonarr still do their own parsing and approve/reject decisions. Its NZBs are +dummies, so grabbed releases just sit in the SABnzbd queue. + +```sh +python3 dev/fake-indexer.py & # listens on :19292 +dev/add-indexer.sh # registers it in the dev Radarr and Sonarr +``` + +Add the movie / series to the dev instances first (e.g. from the TUI), then press `i` on it. +To test with a real indexer, add it in the dev Radarr/Sonarr web UI (Settings, Indexers) instead of pasting +credentials anywhere else. diff --git a/dev/add-indexer.sh b/dev/add-indexer.sh new file mode 100755 index 0000000..d9ab5b9 --- /dev/null +++ b/dev/add-indexer.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Register dev/fake-indexer.py as an indexer in the dev Radarr and Sonarr (idempotent). +# Start the fake indexer first: python3 dev/fake-indexer.py +set -euo pipefail + +add() { # base key categories + local base=$1 key=$2 cats=$3 + if curl -sf -H "X-Api-Key: $key" "$base/api/v3/indexer" | grep -q '"name": "Fake indexer"'; then return 0; fi + curl -sf -H "X-Api-Key: $key" -H 'Content-Type: application/json' "$base/api/v3/indexer" -d "{ + \"name\":\"Fake indexer\",\"implementation\":\"Newznab\",\"configContract\":\"NewznabSettings\", + \"enableRss\":true,\"enableAutomaticSearch\":true,\"enableInteractiveSearch\":true,\"protocol\":\"usenet\",\"priority\":25,\"tags\":[], + \"fields\":[{\"name\":\"baseUrl\",\"value\":\"http://host.containers.internal:19292\"},{\"name\":\"apiPath\",\"value\":\"/api\"}, + {\"name\":\"apiKey\",\"value\":\"dev\"},{\"name\":\"categories\",\"value\":$cats}]}" >/dev/null + echo "added indexer to $base" +} + +add http://127.0.0.1:17878 devradarrapikey00000000000000000 '[2000,2040,2045]' +add http://127.0.0.1:18989 devsonarrapikey00000000000000000 '[5000,5040]' diff --git a/dev/fake-indexer.py b/dev/fake-indexer.py new file mode 100755 index 0000000..7bef5b6 --- /dev/null +++ b/dev/fake-indexer.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""A tiny fake Newznab indexer for the dev instances (no real indexer needed). + +Answers every movie / TV search with a fixed set of releases for "The Matrix (1999)" and +"Breaking Bad", and serves a dummy NZB for all of them. Radarr/Sonarr then do their real +release parsing and decisions (approved / rejected, with reasons) on top. + + python3 dev/fake-indexer.py # listens on 0.0.0.0:19292 + dev/add-indexer.sh # registers it in the dev Radarr and Sonarr +""" +import time +from email.utils import formatdate +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, parse_qs +from xml.sax.saxutils import escape + +PORT = 19292 +HOST = "host.containers.internal" # how the dev containers reach this machine + +GB = 1024 ** 3 +MOVIES = [ # title, size, age in days + ("The.Matrix.1999.1080p.BluRay.x264-DEV", 8 * GB, 1), + ("The.Matrix.1999.2160p.UHD.BluRay.x265-DEV", 40 * GB, 3), + ("The.Matrix.1999.720p.BluRay.x264-OTHER", 4 * GB, 400), + ("The.Matrix.1999.480p.DVDRip.XviD-OLD", 700 * 1024 ** 2, 2000), + ("The.Matrix.Reloaded.2003.1080p.BluRay.x264-DEV", 9 * GB, 5), +] +TV = [ + ("Breaking.Bad.S01E01.1080p.BluRay.x264-DEV", 2 * GB, 30), + ("Breaking.Bad.S01E01.720p.HDTV.x264-OTHER", 900 * 1024 ** 2, 60), + ("Breaking.Bad.S01.1080p.BluRay.x264-DEV", 14 * GB, 20), + ("Breaking.Bad.S02E01.1080p.BluRay.x264-DEV", 2 * GB, 30), +] + +NZB = b""" + + + + alt.binaries.test + abc123@example.com + + +""" + +CAPS = """ + + + + + +""" + + +def item(n, title, size, age_days, cat): + link = f"http://{HOST}:{PORT}/nzb/{n}.nzb" + date = formatdate(time.time() - age_days * 86400) + return f"""{escape(title)}http://{HOST}:{PORT}/details/{n} +{link}{date} +""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def reply(self, body, ctype="application/xml"): + data = body if isinstance(body, bytes) else body.encode() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + url = urlparse(self.path) + if url.path.startswith("/nzb/"): + return self.reply(NZB, "application/x-nzb") + q = {k: v[0] for k, v in parse_qs(url.query).items()} + t = q.get("t", "") + if t == "caps": + return self.reply(CAPS) + releases = TV if t == "tvsearch" else MOVIES + cat = 5040 if t == "tvsearch" else 2040 + items = "".join(item(i, *r, cat) for i, r in enumerate(releases)) + self.reply(f""" +fake{items}""") + + +if __name__ == "__main__": + print(f"fake indexer on :{PORT}") + HTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/internal/arr/client.go b/internal/arr/client.go index 63d93a4..d36708e 100644 --- a/internal/arr/client.go +++ b/internal/arr/client.go @@ -26,7 +26,7 @@ func NewClient(baseURL, apiKey string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, - http: &http.Client{Timeout: 30 * time.Second}, + http: &http.Client{Timeout: 2 * time.Minute}, // interactive release searches query every indexer } } diff --git a/internal/arr/release.go b/internal/arr/release.go new file mode 100644 index 0000000..c047d24 --- /dev/null +++ b/internal/arr/release.go @@ -0,0 +1,66 @@ +package arr + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" +) + +// Release is an indexer search result from an interactive search. +type Release struct { + GUID string `json:"guid"` + Title string `json:"title"` + Indexer string `json:"indexer"` + IndexerID int `json:"indexerId"` + Protocol string `json:"protocol"` // usenet or torrent + Size int64 `json:"size"` + AgeMinutes float64 `json:"ageMinutes"` + QualityWeight int `json:"qualityWeight"` + CustomFormatScore int `json:"customFormatScore"` + ReleaseGroup string `json:"releaseGroup"` + Approved bool `json:"approved"` + Rejected bool `json:"rejected"` + TemporarilyRejected bool `json:"temporarilyRejected"` + Rejections []string `json:"rejections"` + FullSeason bool `json:"fullSeason"` + Quality struct { + Quality struct { + Name string `json:"name"` + } `json:"quality"` + } `json:"quality"` + Languages []struct { + Name string `json:"name"` + } `json:"languages"` +} + +// QualityName returns the release's quality, e.g. "Bluray-1080p". +func (r Release) QualityName() string { return r.Quality.Quality.Name } + +// LanguageNames returns the release's languages joined by commas. +func (r Release) LanguageNames() string { + names := make([]string, len(r.Languages)) + for i, l := range r.Languages { + names[i] = l.Name + } + return strings.Join(names, ", ") +} + +// Releases runs an interactive search with the given query, e.g. movieId=12. +// Searching queries every indexer and can take a while; use a generous timeout. +func (c *Client) Releases(ctx context.Context, query url.Values) ([]Release, error) { + var releases []Release + err := c.Do(ctx, http.MethodGet, "release", query, nil, &releases) + return releases, err +} + +// Grab sends a release found by a previous search to the download client. +// Releases that were rejected can be grabbed as well. +func (c *Client) Grab(ctx context.Context, r Release) error { + if r.GUID == "" { + return fmt.Errorf("release has no guid") + } + body := map[string]any{"guid": r.GUID, "indexerId": r.IndexerID} + return c.Do(ctx, http.MethodPost, "release", nil, body, nil) +} diff --git a/internal/arr/release_test.go b/internal/arr/release_test.go new file mode 100644 index 0000000..389d2ec --- /dev/null +++ b/internal/arr/release_test.go @@ -0,0 +1,66 @@ +package arr + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +const releasesJSON = `[ + {"guid":"g1","title":"Movie.2020.1080p.BluRay-GRP","indexer":"NZBgeek","indexerId":3,"protocol":"usenet","size":8589934592, + "ageMinutes":4320,"qualityWeight":1801,"customFormatScore":10,"releaseGroup":"GRP","approved":true,"rejected":false, + "rejections":[],"quality":{"quality":{"name":"Bluray-1080p"}},"languages":[{"name":"English"},{"name":"German"}]}, + {"guid":"g2","title":"Movie.2020.720p-X","indexer":"Other","indexerId":4,"protocol":"torrent","approved":false,"rejected":true, + "rejections":["Quality Bluray-720p is not wanted in profile","Release is older than 30 days"],"quality":{"quality":{"name":"Bluray-720p"}}} +]` + +func TestReleases(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/release" || r.URL.Query().Get("movieId") != "12" { + t.Errorf("unexpected request %s", r.URL) + } + w.Write([]byte(releasesJSON)) + })) + defer srv.Close() + + rels, err := NewClient(srv.URL, "k").Releases(context.Background(), url.Values{"movieId": {"12"}}) + if err != nil { + t.Fatal(err) + } + if len(rels) != 2 { + t.Fatalf("got %d releases", len(rels)) + } + a, b := rels[0], rels[1] + if a.QualityName() != "Bluray-1080p" || a.Size != 8<<30 || !a.Approved || a.IndexerID != 3 || a.LanguageNames() != "English, German" { + t.Errorf("release a = %+v", a) + } + if !b.Rejected || len(b.Rejections) != 2 || b.Protocol != "torrent" { + t.Errorf("release b = %+v", b) + } +} + +func TestGrab(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v3/release" { + t.Errorf("unexpected request %s %s", r.Method, r.URL) + } + json.NewDecoder(r.Body).Decode(&got) + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, "k") + if err := c.Grab(context.Background(), Release{GUID: "g1", IndexerID: 3}); err != nil { + t.Fatal(err) + } + if got["guid"] != "g1" || got["indexerId"] != float64(3) { + t.Errorf("body = %v", got) + } + if err := c.Grab(context.Background(), Release{}); err == nil { + t.Error("expected error for release without guid") + } +} diff --git a/internal/radarr/radarr.go b/internal/radarr/radarr.go index 14de08c..0c5ecff 100644 --- a/internal/radarr/radarr.go +++ b/internal/radarr/radarr.go @@ -145,3 +145,8 @@ func TMDBTerm(tmdbID int) string { return "tmdb:" + strconv.Itoa(tmdbID) } func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) { return c.QueueWith(ctx, url.Values{"includeMovie": {"true"}}) } + +// Releases runs an interactive search for a library movie. +func (c *Client) Releases(ctx context.Context, movieID int) ([]arr.Release, error) { + return c.Client.Releases(ctx, url.Values{"movieId": {strconv.Itoa(movieID)}}) +} diff --git a/internal/radarr/radarr_test.go b/internal/radarr/radarr_test.go index 512ae5d..3876b01 100644 --- a/internal/radarr/radarr_test.go +++ b/internal/radarr/radarr_test.go @@ -148,3 +148,16 @@ func TestSearchMovie(t *testing.T) { t.Errorf("body = %v", got) } } + +func TestReleases(t *testing.T) { + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/release" || r.URL.Query().Get("movieId") != "7" { + t.Errorf("unexpected request %s", r.URL) + } + w.Write([]byte(`[{"guid":"g","title":"x","approved":true}]`)) + }) + rels, err := c.Releases(context.Background(), 7) + if err != nil || len(rels) != 1 || rels[0].GUID != "g" { + t.Fatalf("releases = %+v, %v", rels, err) + } +} diff --git a/internal/sonarr/sonarr.go b/internal/sonarr/sonarr.go index e0d84de..513e141 100644 --- a/internal/sonarr/sonarr.go +++ b/internal/sonarr/sonarr.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "net/url" + "strconv" "git.stinnesbeck.com/nils/arr-tui/internal/arr" ) @@ -166,3 +167,35 @@ func (c *Client) SearchSeries(ctx context.Context, seriesID int) error { func (c *Client) Queue(ctx context.Context) ([]arr.QueueItem, error) { return c.QueueWith(ctx, url.Values{"includeSeries": {"true"}, "includeEpisode": {"true"}}) } + +// Episode is an episode of a library series. +type Episode struct { + ID int `json:"id"` + SeriesID int `json:"seriesId"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + Title string `json:"title"` + AirDate string `json:"airDate"` + HasFile bool `json:"hasFile"` + Monitored bool `json:"monitored"` +} + +// Episodes lists all episodes of a library series. +func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) { + var episodes []Episode + err := c.Do(ctx, http.MethodGet, "episode", url.Values{"seriesId": {strconv.Itoa(seriesID)}}, nil, &episodes) + return episodes, err +} + +// EpisodeReleases runs an interactive search for one episode. +func (c *Client) EpisodeReleases(ctx context.Context, episodeID int) ([]arr.Release, error) { + return c.Client.Releases(ctx, url.Values{"episodeId": {strconv.Itoa(episodeID)}}) +} + +// SeasonReleases runs an interactive search for a whole season (season packs and episodes). +func (c *Client) SeasonReleases(ctx context.Context, seriesID, season int) ([]arr.Release, error) { + return c.Client.Releases(ctx, url.Values{ + "seriesId": {strconv.Itoa(seriesID)}, + "seasonNumber": {strconv.Itoa(season)}, + }) +} diff --git a/internal/sonarr/sonarr_test.go b/internal/sonarr/sonarr_test.go index b72fa80..50c4a08 100644 --- a/internal/sonarr/sonarr_test.go +++ b/internal/sonarr/sonarr_test.go @@ -115,3 +115,36 @@ func TestSearchSeries(t *testing.T) { t.Errorf("body = %v", got) } } + +func TestEpisodesAndReleaseQueries(t *testing.T) { + var queries []string + c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.Path+"?"+r.URL.RawQuery) + if r.URL.Path == "/api/v3/episode" { + w.Write([]byte(`[{"id":10,"seriesId":3,"seasonNumber":1,"episodeNumber":2,"title":"Two","hasFile":true}]`)) + return + } + w.Write([]byte(`[]`)) + }) + ctx := context.Background() + eps, err := c.Episodes(ctx, 3) + if err != nil || len(eps) != 1 || eps[0].ID != 10 || !eps[0].HasFile || eps[0].EpisodeNumber != 2 { + t.Fatalf("episodes = %+v, %v", eps, err) + } + if _, err := c.EpisodeReleases(ctx, 10); err != nil { + t.Fatal(err) + } + if _, err := c.SeasonReleases(ctx, 3, 1); err != nil { + t.Fatal(err) + } + want := []string{ + "/api/v3/episode?seriesId=3", + "/api/v3/release?episodeId=10", + "/api/v3/release?seasonNumber=1&seriesId=3", + } + for i := range want { + if queries[i] != want[i] { + t.Errorf("query %d = %q, want %q", i, queries[i], want[i]) + } + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 06b3de9..bc30a52 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -3,6 +3,7 @@ package ui import ( "fmt" + "strings" "time" "github.com/gdamore/tcell/v2" @@ -14,8 +15,6 @@ import ( "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" ) -const modalPage = "modal" - type view struct { key rune name string @@ -33,9 +32,14 @@ type App struct { views []view active int - message string - modalOpen bool - modalFocus tview.Primitive + message string + overlays []overlay // dialogs and full-screen views stacked above the pages +} + +// overlay is a page stacked above the main views; focus is restored on close. +type overlay struct { + page string + focus tview.Primitive } // New builds the application shell: a tab bar, one page per view and a status bar. @@ -96,6 +100,9 @@ func (a *App) Run() error { func (a *App) renderStatus() { hints := "[gray]q quit · Tab/1-4/F1-F4 views · / search" + if len(a.overlays) > 0 { + hints = "[gray]Esc back · Ctrl+C quit" + } if a.message == "" { a.status.SetText(hints) return @@ -103,10 +110,17 @@ func (a *App) renderStatus() { a.status.SetText(a.message + " [gray]│ " + hints) } -// openModal shows p centered over the current view and remembers the focus. +// pushOverlay stacks p above everything and focuses focus. +func (a *App) pushOverlay(p, focus tview.Primitive) { + name := fmt.Sprintf("overlay-%d", len(a.overlays)) + a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus()}) + a.pages.AddPage(name, p, true, true) + a.app.SetFocus(focus) + a.renderStatus() +} + +// openModal shows p centered above the current view. func (a *App) openModal(p tview.Primitive, width, height int) { - a.modalFocus = a.app.GetFocus() - a.modalOpen = true box := tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). @@ -114,21 +128,31 @@ func (a *App) openModal(p tview.Primitive, width, height int) { AddItem(p, height, 0, true). AddItem(nil, 0, 1, false), width, 0, true). AddItem(nil, 0, 1, false) - a.pages.AddPage(modalPage, box, true, true) - a.app.SetFocus(p) + a.pushOverlay(box, p) } +// openFull shows p above the whole view area (the tab bar and status bar stay visible). +func (a *App) openFull(p tview.Primitive) { + a.pushOverlay(p, p) +} + +// closeModal removes the topmost overlay and restores the previous focus. func (a *App) closeModal() { - a.pages.RemovePage(modalPage) - a.modalOpen = false - if a.modalFocus != nil { - a.app.SetFocus(a.modalFocus) - a.modalFocus = nil + if len(a.overlays) == 0 { + return } + top := a.overlays[len(a.overlays)-1] + a.overlays = a.overlays[:len(a.overlays)-1] + a.pages.RemovePage(top.page) + if top.focus != nil { + a.app.SetFocus(top.focus) + } + a.renderStatus() } // confirm asks a yes/no question; yes runs only if the user picks Delete/Yes. func (a *App) confirm(text string, yes func()) { + lines := strings.Count(text, "\n") + 1 + len([]rune(text))/44 m := tview.NewModal(). SetText(text). AddButtons([]string{"Cancel", "Confirm"}). // Cancel first so a stray Enter is safe @@ -138,13 +162,13 @@ func (a *App) confirm(text string, yes func()) { yes() } }) - a.openModal(m, 60, 9) + a.openModal(m, 60, 7+min(lines, 10)) } // typing reports whether keystrokes belong to a text field or dialog and must // not trigger global shortcuts. func (a *App) typing() bool { - if a.modalOpen { + if len(a.overlays) > 0 { return true } _, isInput := a.app.GetFocus().(*tview.InputField) @@ -157,7 +181,7 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { return nil } // View switching that also works while typing in a search box. Not while a dialog is open. - if !a.modalOpen { + if len(a.overlays) == 0 { if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) { a.switchTo(i) return nil diff --git a/internal/ui/backend.go b/internal/ui/backend.go index 5551a73..773f44d 100644 --- a/internal/ui/backend.go +++ b/internal/ui/backend.go @@ -2,6 +2,9 @@ package ui import ( "context" + "time" + + "github.com/rivo/tview" "git.stinnesbeck.com/nils/arr-tui/internal/arr" "git.stinnesbeck.com/nils/arr-tui/internal/radarr" @@ -41,6 +44,8 @@ type backend interface { search(ctx context.Context, term string) ([]result, error) monitorModes() []monitorMode // nil if not applicable add(ctx context.Context, r result, c addChoice) (id int, err error) + // interactive starts an interactive release search for a library item. + interactive(v *searchView, r result) } // --- Radarr --- @@ -83,6 +88,13 @@ func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, err return created.ID, err } +func (b radarrBackend) interactive(v *searchView, r result) { + m := r.(movieResult) + v.app.openReleases("Releases for "+tview.Escape(movieTitleYear(m.Movie)), + func(ctx context.Context) ([]arr.Release, error) { return b.c.Releases(ctx, m.ID) }, + b.c.Grab) +} + // --- Sonarr --- type seriesResult struct{ sonarr.Series } @@ -133,3 +145,21 @@ func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, err }) return created.ID, err } + +func (b sonarrBackend) interactive(v *searchView, r result) { + s := r.(seriesResult) + v.app.SetMessage("Loading episodes…") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + episodes, err := b.c.Episodes(ctx, s.ID) + v.app.post(func() { + if err != nil { + v.app.SetMessage("[red]Loading episodes failed: " + tview.Escape(err.Error())) + return + } + v.app.SetMessage("") + v.app.pickSeason(b.c, s.Series, episodes) + }) + }() +} diff --git a/internal/ui/format.go b/internal/ui/format.go index 32896eb..caf2f44 100644 --- a/internal/ui/format.go +++ b/internal/ui/format.go @@ -223,3 +223,11 @@ func arrStatusText(it arr.QueueItem) string { } return label } + +// movieTitleYear renders "Title (Year)". +func movieTitleYear(m radarr.Movie) string { + if m.Year > 0 { + return fmt.Sprintf("%s (%d)", m.Title, m.Year) + } + return m.Title +} diff --git a/internal/ui/picker.go b/internal/ui/picker.go new file mode 100644 index 0000000..3f6a6e1 --- /dev/null +++ b/internal/ui/picker.go @@ -0,0 +1,114 @@ +package ui + +import ( + "cmp" + "context" + "fmt" + "slices" + + "github.com/rivo/tview" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" + "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" +) + +// seasonSummary counts the episodes of one season. +type seasonSummary struct { + number, total, have int +} + +// summarizeSeasons groups episodes by season, newest season first and specials last. +func summarizeSeasons(episodes []sonarr.Episode) []seasonSummary { + byNumber := map[int]*seasonSummary{} + for _, e := range episodes { + s := byNumber[e.SeasonNumber] + if s == nil { + s = &seasonSummary{number: e.SeasonNumber} + byNumber[e.SeasonNumber] = s + } + s.total++ + if e.HasFile { + s.have++ + } + } + out := make([]seasonSummary, 0, len(byNumber)) + for _, s := range byNumber { + out = append(out, *s) + } + slices.SortFunc(out, func(a, b seasonSummary) int { + // specials (season 0) last, otherwise descending + return cmp.Or(cmp.Compare(boolInt(a.number == 0), boolInt(b.number == 0)), cmp.Compare(b.number, a.number)) + }) + return out +} + +func seasonLabel(n int) string { + if n == 0 { + return "Specials" + } + return fmt.Sprintf("Season %d", n) +} + +// pickList shows a selectable list in a dialog; Esc closes it. +func (a *App) pickList(title string, labels []string, onPick func(i int)) { + list := tview.NewList().ShowSecondaryText(false) + for _, l := range labels { + list.AddItem(l, "", 0, nil) + } + list.SetSelectedFunc(func(i int, _, _ string, _ rune) { + a.closeModal() + onPick(i) + }) + list.SetDoneFunc(a.closeModal) + list.SetBorder(true).SetTitle(" " + title + " ") + a.openModal(list, 56, min(len(labels)+2, 22)) +} + +// pickSeason lets the user choose a season, then an episode or the whole season, +// and opens the interactive search for it. +func (a *App) pickSeason(c *sonarr.Client, s sonarr.Series, episodes []sonarr.Episode) { + seasons := summarizeSeasons(episodes) + if len(seasons) == 0 { + a.SetMessage("[yellow]Sonarr has no episodes for " + tview.Escape(s.Title)) + return + } + labels := make([]string, len(seasons)) + for i, se := range seasons { + labels[i] = fmt.Sprintf("%s (%d/%d downloaded)", seasonLabel(se.number), se.have, se.total) + } + a.pickList("Search releases for "+tview.Escape(s.Title), labels, func(i int) { + a.pickEpisode(c, s, seasons[i].number, episodes) + }) +} + +func (a *App) pickEpisode(c *sonarr.Client, s sonarr.Series, season int, all []sonarr.Episode) { + var episodes []sonarr.Episode + for _, e := range all { + if e.SeasonNumber == season { + episodes = append(episodes, e) + } + } + slices.SortFunc(episodes, func(x, y sonarr.Episode) int { return cmp.Compare(x.EpisodeNumber, y.EpisodeNumber) }) + + labels := []string{"Whole season"} + for _, e := range episodes { + mark := "" + if e.HasFile { + mark = " ✓" + } + labels = append(labels, fmt.Sprintf("E%02d %s%s", e.EpisodeNumber, tview.Escape(e.Title), mark)) + } + title := tview.Escape(s.Title) + ", " + seasonLabel(season) + a.pickList(title, labels, func(i int) { + if i == 0 { + a.openReleases("Releases for "+title, + func(ctx context.Context) ([]arr.Release, error) { return c.SeasonReleases(ctx, s.ID, season) }, + c.Grab) + return + } + e := episodes[i-1] + a.openReleases(fmt.Sprintf("Releases for %s S%02dE%02d", tview.Escape(s.Title), season, e.EpisodeNumber), + func(ctx context.Context) ([]arr.Release, error) { return c.EpisodeReleases(ctx, e.ID) }, + c.Grab) + }) +} diff --git a/internal/ui/releases.go b/internal/ui/releases.go new file mode 100644 index 0000000..555b0fa --- /dev/null +++ b/internal/ui/releases.go @@ -0,0 +1,335 @@ +package ui + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" +) + +// Sort modes of the release list; sortLabels[i] names mode i. +const ( + sortBest = iota + sortSize + sortAge + numSortModes +) + +var sortLabels = [numSortModes]string{"best", "size", "age"} + +// releasesView is the full-screen interactive search result list. +type releasesView struct { + app *App + title string + grab func(ctx context.Context, r arr.Release) error + + root *tview.Flex + header *tview.TextView + table *tview.Table + detail *tview.TextView + cancel context.CancelFunc // aborts the running indexer search + + all []arr.Release // usenet releases only + shown []arr.Release + torrents int // torrent releases left out + searched bool // the indexer search has finished + sortMode int + hideRejected bool +} + +// openReleases starts an interactive search and shows the results as they arrive. +func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Release, error), grab func(context.Context, arr.Release) error) { + v := &releasesView{ + app: a, + title: title, + grab: grab, + header: tview.NewTextView().SetDynamicColors(true), + table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0), + detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true), + } + foot := tview.NewTextView().SetDynamicColors(true). + SetText("[gray]Enter/g grab · s sort · f hide rejected · Esc back") + v.table.SetBorder(true) + v.root = tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(v.header, 1, 0, false). + AddItem(v.table, 0, 1, true). + AddItem(v.detail, 3, 0, false). + AddItem(foot, 1, 0, false) + + v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() }) + v.table.SetSelectedFunc(func(int, int) { v.grabSelected() }) + v.table.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey { + switch { + case ev.Key() == tcell.KeyEscape: + v.close() + case ev.Key() == tcell.KeyRune && ev.Rune() == 'g': + v.grabSelected() + case ev.Key() == tcell.KeyRune && ev.Rune() == 's': + v.sortMode = (v.sortMode + 1) % numSortModes + v.render() + case ev.Key() == tcell.KeyRune && ev.Rune() == 'f': + v.hideRejected = !v.hideRejected + v.render() + default: + return ev + } + return nil + }) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + v.cancel = cancel + v.render() // shows the "searching" placeholder + a.openFull(v.root) + + go func() { + releases, err := fetch(ctx) + if ctx.Err() == context.Canceled { + return + } + a.post(func() { + if err != nil { + v.header.SetText("[red]Search failed: " + tview.Escape(err.Error())) + v.table.Clear() + return + } + v.setReleases(releases) + }) + }() +} + +func (v *releasesView) close() { + v.cancel() + v.app.closeModal() +} + +func (v *releasesView) setReleases(releases []arr.Release) { + v.all, v.torrents = nil, 0 + for _, r := range releases { + // Downloads go to SABnzbd, which can only handle Usenet. + if r.Protocol == "torrent" { + v.torrents++ + continue + } + v.all = append(v.all, r) + } + v.searched = true + v.render() +} + +// render rebuilds the table, keeping the selected release selected. +func (v *releasesView) render() { + selGUID := "" + if r, ok := v.selected(); ok { + selGUID = r.GUID + } + v.shown = visibleReleases(v.all, v.hideRejected, v.sortMode) + + v.table.Clear() + for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} { + cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false) + if c == "Title" { + cell.SetExpansion(1) + } + v.table.SetCell(0, i, cell) + } + selRow := 0 + for i, r := range v.shown { + cells := []string{ + releaseMark(r), tview.Escape(r.Title), tview.Escape(r.QualityName()), formatSize(r.Size), + formatReleaseAge(r.AgeMinutes), tview.Escape(r.Indexer), fmt.Sprint(r.CustomFormatScore), + } + for c, text := range cells { + cell := tview.NewTableCell(text) + if c == 1 { + cell.SetExpansion(1) + } + v.table.SetCell(i+1, c, cell) + } + if r.GUID == selGUID { + selRow = i + 1 + } + } + switch { + case !v.searched: + v.table.SetCell(1, 1, tview.NewTableCell("[gray]Searching indexers… (Esc to cancel)").SetSelectable(false)) + case len(v.shown) == 0: + msg := "No releases found. Are indexers configured?" + if len(v.all) > 0 { + msg = "All releases are hidden by the filter (f)" + } + v.table.SetCell(1, 1, tview.NewTableCell("[gray]"+msg).SetSelectable(false)) + } + if len(v.shown) > 0 { + v.table.Select(max(selRow, 1), 0) + } else { + v.table.Select(0, 0) + } + v.renderHeader() + v.renderDetail() +} + +func (v *releasesView) renderHeader() { + s := "[::b]" + v.title + "[::-]" // callers escape the title + if v.searched { + s += fmt.Sprintf(" · %d of %d releases", len(v.shown), len(v.all)) + } + s += " · sort: " + sortLabels[v.sortMode] + if v.hideRejected { + s += " · [yellow]rejected hidden[-]" + } + if v.torrents > 0 { + s += fmt.Sprintf(" · [gray]%d torrent release%s hidden[-]", v.torrents, plural(v.torrents)) + } + v.header.SetText(s) +} + +func (v *releasesView) renderDetail() { + r, ok := v.selected() + if !ok { + v.detail.SetText("") + return + } + var lines []string + switch { + case len(r.Rejections) > 0: + lines = append(lines, "[red]Rejected:[-] "+tview.Escape(strings.Join(r.Rejections, "; "))) + case r.Approved: + lines = append(lines, "[green]Approved[-]") + } + var facts []string + if r.ReleaseGroup != "" { + facts = append(facts, "group "+tview.Escape(r.ReleaseGroup)) + } + if l := r.LanguageNames(); l != "" { + facts = append(facts, tview.Escape(l)) + } + if len(facts) > 0 { + lines = append(lines, "[gray]"+strings.Join(facts, " · ")+"[-]") + } + v.detail.SetText(strings.Join(lines, "\n")) +} + +func (v *releasesView) selected() (arr.Release, bool) { + row, _ := v.table.GetSelection() + if row < 1 || row > len(v.shown) { + return arr.Release{}, false + } + return v.shown[row-1], true +} + +// grabSelected sends the selected release to the download client; rejected +// releases need an explicit confirmation first. +func (v *releasesView) grabSelected() { + r, ok := v.selected() + if !ok { + return + } + if len(r.Rejections) == 0 && !r.Rejected { + v.doGrab(r) + return + } + reasons := r.Rejections + extra := "" + if len(reasons) > 3 { + extra = fmt.Sprintf("\n+%d more", len(reasons)-3) + reasons = reasons[:3] + } + v.app.confirm("This release was rejected:\n"+tview.Escape(strings.Join(reasons, "\n"))+extra+"\n\nGrab it anyway?", + func() { v.doGrab(r) }) +} + +func (v *releasesView) doGrab(r arr.Release) { + v.app.SetMessage("Grabbing " + tview.Escape(truncate(r.Title, 60)) + "…") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + err := v.grab(ctx, r) + v.app.post(func() { + if err != nil { + v.app.SetMessage("[red]Grab failed: " + tview.Escape(err.Error())) + return + } + v.app.SetMessage("[green]Sent to the download client: " + tview.Escape(truncate(r.Title, 60))) + }) + }() +} + +// visibleReleases applies the filter and sort order, returning a new slice. +func visibleReleases(all []arr.Release, hideRejected bool, mode int) []arr.Release { + out := make([]arr.Release, 0, len(all)) + for _, r := range all { + if hideRejected && isRejected(r) { + continue + } + out = append(out, r) + } + slices.SortStableFunc(out, func(a, b arr.Release) int { + switch mode { + case sortSize: + return cmp.Compare(b.Size, a.Size) + case sortAge: + return cmp.Compare(a.AgeMinutes, b.AgeMinutes) + } + // best: approved first, then format score, quality, and newest + return cmp.Or( + cmp.Compare(boolInt(isRejected(a)), boolInt(isRejected(b))), + cmp.Compare(b.CustomFormatScore, a.CustomFormatScore), + cmp.Compare(b.QualityWeight, a.QualityWeight), + cmp.Compare(a.AgeMinutes, b.AgeMinutes), + ) + }) + return out +} + +func isRejected(r arr.Release) bool { return r.Rejected || len(r.Rejections) > 0 } + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// releaseMark is the status glyph in the first column. +func releaseMark(r arr.Release) string { + switch { + case !isRejected(r): + return "[green]✓[-]" + case r.TemporarilyRejected: + return "[yellow]~[-]" + } + return "[red]✗[-]" +} + +// formatSize renders a byte count as "1.4 GB". +func formatSize(b int64) string { + const k = 1024.0 + f := float64(b) + switch { + case f >= k*k*k: + return fmt.Sprintf("%.1f GB", f/(k*k*k)) + case f >= k*k: + return fmt.Sprintf("%.0f MB", f/(k*k)) + case f > 0: + return fmt.Sprintf("%.0f KB", f/k) + } + return "-" +} + +// formatReleaseAge renders an age given in minutes as "45m", "5h" or "3d". +func formatReleaseAge(minutes float64) string { + switch { + case minutes < 60: + return fmt.Sprintf("%dm", int(minutes)) + case minutes < 48*60: + return fmt.Sprintf("%dh", int(minutes/60)) + } + return fmt.Sprintf("%dd", int(minutes/(24*60))) +} diff --git a/internal/ui/releases_test.go b/internal/ui/releases_test.go new file mode 100644 index 0000000..e95b278 --- /dev/null +++ b/internal/ui/releases_test.go @@ -0,0 +1,110 @@ +package ui + +import ( + "testing" + + "git.stinnesbeck.com/nils/arr-tui/internal/arr" + "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" +) + +func sampleReleases() []arr.Release { + return []arr.Release{ + {GUID: "rej-big", Size: 40 << 30, AgeMinutes: 60, QualityWeight: 2000, Rejections: []string{"no space"}}, + {GUID: "ok-720", Size: 4 << 30, AgeMinutes: 600, QualityWeight: 1000, Approved: true}, + {GUID: "ok-1080", Size: 8 << 30, AgeMinutes: 100, QualityWeight: 1800, Approved: true}, + {GUID: "ok-1080-score", Size: 6 << 30, AgeMinutes: 900, QualityWeight: 1800, CustomFormatScore: 50, Approved: true}, + } +} + +func guids(rs []arr.Release) []string { + out := make([]string, len(rs)) + for i, r := range rs { + out[i] = r.GUID + } + return out +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestVisibleReleasesSort(t *testing.T) { + all := sampleReleases() + tests := []struct { + name string + hide bool + mode int + want []string + }{ + {"best: approved first, score, quality", false, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720", "rej-big"}}, + {"size desc", false, sortSize, []string{"rej-big", "ok-1080", "ok-1080-score", "ok-720"}}, + {"newest first", false, sortAge, []string{"rej-big", "ok-1080", "ok-720", "ok-1080-score"}}, + {"hide rejected", true, sortBest, []string{"ok-1080-score", "ok-1080", "ok-720"}}, + } + for _, tc := range tests { + if got := guids(visibleReleases(all, tc.hide, tc.mode)); !equal(got, tc.want) { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + } + if all[0].GUID != "rej-big" { + t.Error("visibleReleases must not reorder its input") + } +} + +func TestReleaseMark(t *testing.T) { + tests := map[string]arr.Release{ + "[green]✓[-]": {Approved: true}, + "[red]✗[-]": {Rejected: true, Rejections: []string{"x"}}, + "[yellow]~[-]": {Rejected: true, TemporarilyRejected: true}, + } + for want, r := range tests { + if got := releaseMark(r); got != want { + t.Errorf("releaseMark(%+v) = %q, want %q", r, got, want) + } + } +} + +func TestFormatSizeAndAge(t *testing.T) { + sizes := map[int64]string{0: "-", 2048: "2 KB", 5 << 20: "5 MB", 3<<30 + 1<<29: "3.5 GB"} + for in, want := range sizes { + if got := formatSize(in); got != want { + t.Errorf("formatSize(%d) = %q, want %q", in, got, want) + } + } + ages := map[float64]string{5: "5m", 120: "2h", 47 * 60: "47h", 3 * 24 * 60: "3d"} + for in, want := range ages { + if got := formatReleaseAge(in); got != want { + t.Errorf("formatReleaseAge(%v) = %q, want %q", in, got, want) + } + } +} + +func TestSummarizeSeasons(t *testing.T) { + eps := []sonarr.Episode{ + {SeasonNumber: 1, EpisodeNumber: 1, HasFile: true}, + {SeasonNumber: 1, EpisodeNumber: 2}, + {SeasonNumber: 0, EpisodeNumber: 1}, + {SeasonNumber: 2, EpisodeNumber: 1}, + } + got := summarizeSeasons(eps) + want := []seasonSummary{{2, 1, 0}, {1, 2, 1}, {0, 1, 0}} + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("season %d: got %+v, want %+v", i, got[i], want[i]) + } + } + if seasonLabel(0) != "Specials" || seasonLabel(3) != "Season 3" { + t.Error("seasonLabel") + } +} diff --git a/internal/ui/search.go b/internal/ui/search.go index 920775e..94806eb 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -95,6 +95,9 @@ func newSearchView(app *App, be backend, cfg config.Arr) *searchView { case 'a': v.add(v.list.GetCurrentItem()) return nil + case 'i': + v.interactive(v.list.GetCurrentItem()) + return nil } return ev }) @@ -239,6 +242,19 @@ func (v *searchView) showDetails(i int) { v.details.SetText(v.results[i].details()).ScrollToBeginning() } +// interactive opens the release list for the result at index i. +func (v *searchView) interactive(i int) { + if i < 0 || i >= len(v.results) { + return + } + r := v.results[i] + if !r.inLibrary() { + v.app.SetMessage(fmt.Sprintf("[yellow]Add %s to %s first (a), then search for releases", tview.Escape(r.title()), v.be.name())) + return + } + v.be.interactive(v, r) +} + // add opens the add dialog for the result at index i. func (v *searchView) add(i int) { if i < 0 || i >= len(v.results) {