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) {