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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)}})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user