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 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 18:09:19 +02:00
co-authored by Claude Sonnet 5.5
parent 446cbdd6c5
commit f928427993
18 changed files with 1002 additions and 23 deletions
+6 -4
View File
@@ -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).
+15
View File
@@ -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.
+18
View File
@@ -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]'
+91
View File
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE nzb PUBLIC "-//newzBin//DTD NZB 1.1//EN" "http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd">
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="a@b.c" date="1700000000" subject="dummy [1/1] - &quot;dummy.mkv&quot; yEnc (1/10)">
<groups><group>alt.binaries.test</group></groups>
<segments><segment bytes="500000" number="1">abc123@example.com</segment></segments>
</file>
</nzb>
"""
CAPS = """<?xml version="1.0" encoding="UTF-8"?>
<caps><server version="1.0" title="arr-tui fake indexer"/><limits max="100" default="100"/>
<searching><search available="yes" supportedParams="q"/>
<tv-search available="yes" supportedParams="q,season,ep,tvdbid"/>
<movie-search available="yes" supportedParams="q,imdbid,tmdbid"/></searching>
<categories><category id="2000" name="Movies"><subcat id="2040" name="Movies/HD"/><subcat id="2045" name="Movies/UHD"/></category>
<category id="5000" name="TV"><subcat id="5040" name="TV/HD"/></category></categories></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"""<item><title>{escape(title)}</title><guid isPermaLink="true">http://{HOST}:{PORT}/details/{n}</guid>
<link>{link}</link><pubDate>{date}</pubDate><enclosure url="{link}" length="{size}" type="application/x-nzb"/>
<newznab:attr name="category" value="{cat}"/><newznab:attr name="size" value="{size}"/></item>"""
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"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"><channel><title>fake</title>{items}</channel></rss>""")
if __name__ == "__main__":
print(f"fake indexer on :{PORT}")
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+1 -1
View File
@@ -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
}
}
+66
View File
@@ -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)
}
+66
View File
@@ -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")
}
}
+5
View File
@@ -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)}})
}
+13
View File
@@ -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)
}
}
+33
View File
@@ -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)},
})
}
+33
View File
@@ -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])
}
}
}
+42 -18
View File
@@ -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
+30
View File
@@ -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)
})
}()
}
+8
View File
@@ -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
}
+114
View File
@@ -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)
})
}
+335
View File
@@ -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)))
}
+110
View File
@@ -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")
}
}
+16
View File
@@ -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) {