Trending, most watched and anticipated movies and series from Trakt
With an empty search box, Movies and Series show one of Trakt's public lists: trending now, most watched this week, anticipated, popular, and for movies the US box office. [ ] switch lists, r reloads. Each entry is looked up in Radarr (tmdb:) or Sonarr (tvdb:), so it behaves like a search result: details, poster, tick when in the library, add, releases. Needs a Trakt client ID: new optional trakt.client_id setting (Config view section "Trakt", ARR_TUI_TRAKT_CLIENT_ID), masked like the API keys and checked by Test. ARR_TUI_TRAKT_URL points at another server for development. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+23
-11
@@ -17,6 +17,7 @@ import (
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
type view struct {
|
||||
@@ -31,14 +32,17 @@ type view struct {
|
||||
|
||||
// App is the top-level arr-tui application.
|
||||
type App struct {
|
||||
app *tview.Application
|
||||
theme theme // the colors in use
|
||||
themed *themedScreen // draws with them; nil until Run
|
||||
tabs *tview.TextView
|
||||
pages *tview.Pages
|
||||
status *tview.TextView
|
||||
views []view
|
||||
active int
|
||||
app *tview.Application
|
||||
theme theme // the colors in use
|
||||
trakt *trakt.Client // trending, most watched and anticipated lists
|
||||
// The Movies and Series views, to refresh their Trakt lists.
|
||||
searches []*searchView
|
||||
themed *themedScreen // draws with them; nil until Run
|
||||
tabs *tview.TextView
|
||||
pages *tview.Pages
|
||||
status *tview.TextView
|
||||
views []view
|
||||
active int
|
||||
|
||||
toasts []toast
|
||||
logs *logView
|
||||
@@ -170,9 +174,16 @@ func New(cfg *config.Config) *App {
|
||||
},
|
||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||
a.downloads = downloads
|
||||
a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui")
|
||||
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
|
||||
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots)
|
||||
}
|
||||
movies := newSearchView(a, radarrBackend{radarrClient})
|
||||
series := newSearchView(a, sonarrBackend{sonarrClient})
|
||||
a.searches = []*searchView{movies, series}
|
||||
a.views = []view{
|
||||
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
|
||||
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
|
||||
{key: '1', name: "Movies", body: movies.root, onShow: movies.activate, hints: searchHints},
|
||||
{key: '2', name: "Series", body: series.root, onShow: series.activate, hints: searchHints},
|
||||
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
||||
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
||||
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
|
||||
@@ -205,6 +216,7 @@ func New(cfg *config.Config) *App {
|
||||
syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) }
|
||||
a.app.SetInputCapture(a.handleKey)
|
||||
a.switchTo(0)
|
||||
movies.activate() // switchTo does not call onShow for the view already active
|
||||
if !cfg.AnyConfigured() {
|
||||
// First start (or nothing set up): go where that is done.
|
||||
a.switchTo(a.viewIndex("Config"))
|
||||
@@ -656,7 +668,7 @@ func (a *App) renderTabs() {
|
||||
}
|
||||
|
||||
func searchHints() string {
|
||||
return "/ search · Tab list/search · Enter add · i releases"
|
||||
return "/ search · [ ] lists · Tab list/search · Enter add · i releases"
|
||||
}
|
||||
|
||||
func libraryHints() string {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
// result is one search hit, independent of which *arr produced it.
|
||||
@@ -47,6 +48,10 @@ type backend interface {
|
||||
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)
|
||||
// lists are the Trakt lists offered while the search box is empty, and
|
||||
// resolve looks a list entry up (nil without error if it is not found).
|
||||
lists() []trakt.List
|
||||
resolve(ctx context.Context, e trakt.Entry) (result, error)
|
||||
}
|
||||
|
||||
// --- Radarr ---
|
||||
@@ -83,6 +88,19 @@ func (b radarrBackend) search(ctx context.Context, term string) ([]result, error
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (radarrBackend) lists() []trakt.List { return trakt.MovieLists }
|
||||
|
||||
func (b radarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
|
||||
if e.IDs.TMDB == 0 {
|
||||
return nil, errNotListed
|
||||
}
|
||||
found, err := b.search(ctx, radarr.TMDBTerm(e.IDs.TMDB))
|
||||
if err != nil || len(found) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func (b radarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
|
||||
created, err := b.c.AddMovie(ctx, r.(movieResult).Movie, radarr.AddOptions{
|
||||
QualityProfileID: c.profileID,
|
||||
@@ -144,6 +162,19 @@ func (b sonarrBackend) search(ctx context.Context, term string) ([]result, error
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (sonarrBackend) lists() []trakt.List { return trakt.ShowLists }
|
||||
|
||||
func (b sonarrBackend) resolve(ctx context.Context, e trakt.Entry) (result, error) {
|
||||
if e.IDs.TVDB == 0 {
|
||||
return nil, errNotListed
|
||||
}
|
||||
found, err := b.search(ctx, "tvdb:"+itoa(e.IDs.TVDB))
|
||||
if err != nil || len(found) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return found[0], nil
|
||||
}
|
||||
|
||||
func (b sonarrBackend) add(ctx context.Context, r result, c addChoice) (int, error) {
|
||||
created, err := b.c.AddSeries(ctx, r.(seriesResult).Series, sonarr.AddOptions{
|
||||
QualityProfileID: c.profileID,
|
||||
|
||||
@@ -31,6 +31,14 @@ func (a *App) applyConfig(c *config.Config) {
|
||||
f()
|
||||
}
|
||||
}
|
||||
if old.Trakt.ClientID != c.Trakt.ClientID {
|
||||
a.trakt.Reconfigure(c.Trakt.ClientID)
|
||||
}
|
||||
if old.Trakt.ClientID != c.Trakt.ClientID || old.Radarr.Service != c.Radarr.Service || old.Sonarr.Service != c.Sonarr.Service {
|
||||
for _, v := range a.searches {
|
||||
v.forgetLists()
|
||||
}
|
||||
}
|
||||
if old.UI.RefreshSeconds != c.UI.RefreshSeconds {
|
||||
a.downloads.setInterval(time.Duration(c.UI.RefreshSeconds) * time.Second)
|
||||
}
|
||||
|
||||
@@ -467,8 +467,9 @@ func (c *coverView) build(cols, rows int) []styledCell {
|
||||
|
||||
// coverRef says where a poster comes from. The zero value means "no poster".
|
||||
type coverRef struct {
|
||||
key string // identifies the poster in the cache
|
||||
fetch func(ctx context.Context, large bool) ([]byte, error) // large: for real images
|
||||
key string // identifies the poster in the cache
|
||||
// fetch downloads the poster; large asks for a bigger copy (for real images).
|
||||
fetch func(ctx context.Context, large bool) ([]byte, error)
|
||||
}
|
||||
|
||||
// coverFor builds the poster reference of a title from its image list.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
const (
|
||||
discoverLimit = 20 // titles per Trakt list
|
||||
discoverTTL = 15 * time.Minute // how long a fetched list is shown again without asking
|
||||
resolveLimit = 4 // Radarr/Sonarr lookups at the same time
|
||||
)
|
||||
|
||||
// discovered is a Trakt list, resolved into Radarr/Sonarr results.
|
||||
type discovered struct {
|
||||
results []result
|
||||
notes []string // the list's number per result ("1.2k watching"), may be ""
|
||||
err error
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// showList shows the current Trakt list (UI goroutine): what the Movies and
|
||||
// Series views show while the search box is empty.
|
||||
func (v *searchView) showList() {
|
||||
v.browsing = true
|
||||
v.results, v.notes = nil, nil
|
||||
l := v.lists[v.listIdx]
|
||||
v.list.SetTitle(fmt.Sprintf(" %s · %s · [yellow][ ][-] other lists ", v.be.noun(), l.Name))
|
||||
if !v.app.trakt.Configured() {
|
||||
v.placeholder = "Trending, most watched and anticipated " + lower(v.be.noun()) +
|
||||
" appear here once a Trakt client ID is set in the Config view (F6)."
|
||||
v.render(0)
|
||||
return
|
||||
}
|
||||
if d := v.discover[v.listIdx]; d != nil && time.Since(d.at) < discoverTTL {
|
||||
v.applyList(d)
|
||||
return
|
||||
}
|
||||
v.placeholder = "Loading " + l.Name + "…"
|
||||
v.render(0)
|
||||
idx := v.listIdx
|
||||
if v.loadingList[idx] {
|
||||
return
|
||||
}
|
||||
v.loadingList[idx] = true
|
||||
go func() {
|
||||
d := v.fetchList(l)
|
||||
v.app.post(func() {
|
||||
v.loadingList[idx] = false
|
||||
v.discover[idx] = d
|
||||
if v.browsing && v.listIdx == idx {
|
||||
v.applyList(d)
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// applyList puts a fetched list on screen (UI goroutine).
|
||||
func (v *searchView) applyList(d *discovered) {
|
||||
v.results, v.notes, v.placeholder = d.results, d.notes, ""
|
||||
if d.err != nil {
|
||||
v.placeholder = "[red]" + tview.Escape(d.err.Error()) + "[-]"
|
||||
} else if len(d.results) == 0 {
|
||||
v.placeholder = "Nothing on this list right now."
|
||||
}
|
||||
v.render(0)
|
||||
}
|
||||
|
||||
// fetchList reads a Trakt list and looks every title up in Radarr/Sonarr, in
|
||||
// the list's order (off the UI goroutine). Titles that cannot be found are
|
||||
// left out; if none can, the reason is kept.
|
||||
func (v *searchView) fetchList(l trakt.List) *discovered {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
d := &discovered{at: time.Now()}
|
||||
entries, err := v.app.trakt.Entries(ctx, l, discoverLimit)
|
||||
if err != nil {
|
||||
d.err = err
|
||||
return d
|
||||
}
|
||||
found := make([]result, len(entries))
|
||||
errs := make([]error, len(entries))
|
||||
var wg sync.WaitGroup
|
||||
slots := make(chan struct{}, resolveLimit)
|
||||
for i, e := range entries {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
slots <- struct{}{}
|
||||
defer func() { <-slots }()
|
||||
found[i], errs[i] = v.be.resolve(ctx, e)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
var lastErr error
|
||||
for i, r := range found {
|
||||
if r != nil {
|
||||
d.results = append(d.results, r)
|
||||
d.notes = append(d.notes, l.Note(entries[i].Count))
|
||||
} else if errs[i] != nil {
|
||||
lastErr = errs[i]
|
||||
}
|
||||
}
|
||||
if len(d.results) == 0 && lastErr != nil {
|
||||
d.err = fmt.Errorf("%s: %w", v.be.name(), lastErr)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// switchList moves to the next or previous Trakt list.
|
||||
func (v *searchView) switchList(step int) {
|
||||
n := len(v.lists)
|
||||
v.listIdx = (v.listIdx + step + n) % n
|
||||
v.showList()
|
||||
}
|
||||
|
||||
// reloadList fetches the current list again.
|
||||
func (v *searchView) reloadList() {
|
||||
delete(v.discover, v.listIdx)
|
||||
v.showList()
|
||||
}
|
||||
|
||||
// forgetLists drops the fetched lists (the Trakt client ID or a service
|
||||
// changed) and shows the current one afresh if it is on screen.
|
||||
func (v *searchView) forgetLists() {
|
||||
v.discover = map[int]*discovered{}
|
||||
if v.browsing {
|
||||
v.showList()
|
||||
}
|
||||
}
|
||||
|
||||
// errNotListed is returned for list titles Radarr/Sonarr cannot look up (no
|
||||
// TMDb or TVDB id).
|
||||
var errNotListed = errors.New("no id to look the title up with")
|
||||
|
||||
func lower(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return string(s[0]|0x20) + s[1:]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestTraktListsInTheSearchView(t *testing.T) {
|
||||
traktSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`[
|
||||
{"watchers":1234,"movie":{"title":"Dune","year":2021,"ids":{"trakt":1,"tmdb":438631}}},
|
||||
{"watchers":900,"movie":{"title":"Unknown To Radarr","year":2020,"ids":{"trakt":2,"tmdb":1}}},
|
||||
{"watchers":7,"movie":{"title":"No Ids","year":2019,"ids":{"trakt":3}}},
|
||||
{"watchers":5,"movie":{"title":"Heat","year":1995,"ids":{"trakt":4,"tmdb":949}}}]`))
|
||||
}))
|
||||
defer traktSrv.Close()
|
||||
radarrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("term") {
|
||||
case "tmdb:438631":
|
||||
w.Write([]byte(`[{"id":5,"title":"Dune","year":2021,"tmdbId":438631}]`))
|
||||
case "tmdb:949":
|
||||
w.Write([]byte(`[{"title":"Heat","year":1995,"tmdbId":949}]`))
|
||||
default:
|
||||
w.Write([]byte(`[]`))
|
||||
}
|
||||
}))
|
||||
defer radarrSrv.Close()
|
||||
|
||||
a := newTestApp(t)
|
||||
v := a.searches[0]
|
||||
// Without a client ID the list says how to get one.
|
||||
v.activate()
|
||||
if !v.browsing || !strings.Contains(v.placeholder, "Trakt client ID") {
|
||||
t.Fatalf("unconfigured: browsing %v, placeholder %q", v.browsing, v.placeholder)
|
||||
}
|
||||
|
||||
a.radarr.Reconfigure(radarrSrv.URL, "k")
|
||||
a.trakt.Reconfigure("id")
|
||||
a.trakt.SetBaseURL(traktSrv.URL)
|
||||
d := v.fetchList(v.lists[0])
|
||||
if d.err != nil || len(d.results) != 2 {
|
||||
t.Fatalf("fetched %d results, err %v", len(d.results), d.err)
|
||||
}
|
||||
// In the list's order; titles Radarr does not know, or without ids, are left out.
|
||||
if d.results[0].title() != "Dune" || d.results[1].title() != "Heat" || !d.results[0].inLibrary() || d.results[1].inLibrary() {
|
||||
t.Errorf("results %v %v", d.results[0].title(), d.results[1].title())
|
||||
}
|
||||
if strings.Join(d.notes, "|") != "1.2k watching|5 watching" {
|
||||
t.Errorf("notes %q", d.notes)
|
||||
}
|
||||
|
||||
// A fresh list is shown from memory, with its numbers and its name in the title.
|
||||
v.discover[0] = d
|
||||
v.showList()
|
||||
if main, _ := v.list.GetItemText(0); !strings.Contains(main, "Dune") || !strings.Contains(main, "1.2k watching") {
|
||||
t.Errorf("first row %q", main)
|
||||
}
|
||||
if title := v.list.GetTitle(); !strings.Contains(title, "Trending now") {
|
||||
t.Errorf("title %q", title)
|
||||
}
|
||||
|
||||
// ] and [ switch lists while browsing.
|
||||
v.discover[1] = &discovered{at: time.Now()}
|
||||
v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, ']', 0))
|
||||
if v.listIdx != 1 || !strings.Contains(v.list.GetTitle(), "Most watched") || v.placeholder != "Nothing on this list right now." {
|
||||
t.Errorf("after ]: list %d, title %q, placeholder %q", v.listIdx, v.list.GetTitle(), v.placeholder)
|
||||
}
|
||||
v.list.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, '[', 0))
|
||||
// From the empty search box too (where the cursor is when the view opens).
|
||||
v.input.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, '[', 0))
|
||||
if v.listIdx != len(v.lists)-1 || v.input.GetText() != "" {
|
||||
t.Errorf("[ wraps around: list %d, search box %q", v.listIdx, v.input.GetText())
|
||||
}
|
||||
|
||||
// Search results replace the list; their rows have no numbers.
|
||||
v.setResults(d.results)
|
||||
if v.browsing || v.notes != nil || strings.Contains(v.list.GetTitle(), "lists") {
|
||||
t.Errorf("search results: browsing %v, title %q", v.browsing, v.list.GetTitle())
|
||||
}
|
||||
if main, _ := v.list.GetItemText(0); strings.Contains(main, "watching") {
|
||||
t.Errorf("search row %q", main)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -29,6 +29,8 @@ var helpSections = []helpSection{
|
||||
{"/", "focus the search box"},
|
||||
{"Enter, a", "add the selected title (dialog: ↑↓ move, ←→ change a dropdown, Space toggles, Enter/Space opens a list)"},
|
||||
{"i", "interactive release search (library titles)"},
|
||||
{"[ ]", "with an empty search box: previous / next Trakt list (trending now, most watched this week, anticipated, popular; movies also the US box office); needs a Trakt client ID (Config view)"},
|
||||
{"r", "reload the Trakt list"},
|
||||
}},
|
||||
{title: "Library", items: []helpItem{
|
||||
{"Tab", "switch between the movies and series lists"},
|
||||
@@ -67,11 +69,11 @@ var helpSections = []helpSection{
|
||||
{"r", "refresh now"},
|
||||
}},
|
||||
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, theme, notifications.", items: []helpItem{
|
||||
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
|
||||
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd, Trakt and Interface; ↓ after the last field reaches Save and Revert"},
|
||||
{"Tab", "jump between the fields and Save / Revert"},
|
||||
{"Space", "toggle a checkbox; press a button"},
|
||||
{"←→, Enter", "the default quality profile and root folder: pick from the lists the server has (loaded with the address and key in the fields)"},
|
||||
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
|
||||
{"Test", "(button below the service settings) checks the services and Trakt with the values typed in"},
|
||||
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
|
||||
}},
|
||||
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
|
||||
|
||||
+55
-10
@@ -14,6 +14,7 @@ import (
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,10 +33,19 @@ type searchView struct {
|
||||
details *detailPane
|
||||
|
||||
// UI-goroutine state.
|
||||
results []result
|
||||
timer *time.Timer
|
||||
cancel context.CancelFunc
|
||||
gen int
|
||||
results []result
|
||||
notes []string // per result, a Trakt list's number ("1.2k watching"); nil for searches
|
||||
placeholder string // shown when there are no results
|
||||
timer *time.Timer
|
||||
cancel context.CancelFunc
|
||||
gen int
|
||||
|
||||
// Trakt lists, shown while the search box is empty (see discover.go).
|
||||
lists []trakt.List
|
||||
listIdx int
|
||||
browsing bool // the results are a Trakt list
|
||||
discover map[int]*discovered
|
||||
loadingList map[int]bool
|
||||
|
||||
// Add-form options, loaded in the background.
|
||||
mu sync.Mutex
|
||||
@@ -52,6 +62,10 @@ func newSearchView(app *App, be backend) *searchView {
|
||||
input: tview.NewInputField().SetLabel("Search: "),
|
||||
list: tview.NewList().ShowSecondaryText(false),
|
||||
details: newDetailPane(app),
|
||||
|
||||
lists: be.lists(),
|
||||
discover: map[int]*discovered{},
|
||||
loadingList: map[int]bool{},
|
||||
}
|
||||
v.list.SetBorder(true).SetTitle(" " + be.noun() + " ")
|
||||
|
||||
@@ -77,6 +91,12 @@ func newSearchView(app *App, be backend) *searchView {
|
||||
v.app.app.SetFocus(v.list)
|
||||
return nil
|
||||
}
|
||||
// While the box is empty a Trakt list is shown, and [ ] switch it (no
|
||||
// search starts with a bracket).
|
||||
if ev.Key() == tcell.KeyRune && (ev.Rune() == '[' || ev.Rune() == ']') && v.browsing && v.input.GetText() == "" {
|
||||
v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()])
|
||||
return nil
|
||||
}
|
||||
return ev
|
||||
})
|
||||
|
||||
@@ -94,6 +114,16 @@ func newSearchView(app *App, be backend) *searchView {
|
||||
case '/':
|
||||
v.app.app.SetFocus(v.input)
|
||||
return nil
|
||||
case '[', ']':
|
||||
if v.browsing {
|
||||
v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()])
|
||||
return nil
|
||||
}
|
||||
case 'r':
|
||||
if v.browsing {
|
||||
v.reloadList()
|
||||
return nil
|
||||
}
|
||||
case 'a':
|
||||
v.add(v.list.GetCurrentItem())
|
||||
return nil
|
||||
@@ -173,7 +203,7 @@ func (v *searchView) onSearchChanged(text string) {
|
||||
gen := v.invalidate()
|
||||
if len([]rune(text)) < minAutoSearch {
|
||||
if text == "" {
|
||||
v.setResults(nil)
|
||||
v.showList()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -199,7 +229,7 @@ func (v *searchView) search(text string) {
|
||||
}
|
||||
gen := v.invalidate()
|
||||
if text == "" {
|
||||
v.setResults(nil)
|
||||
v.showList()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -232,19 +262,34 @@ func (v *searchView) run(ctx context.Context, gen int, term string) {
|
||||
})
|
||||
}
|
||||
|
||||
// setResults replaces the list contents (UI goroutine).
|
||||
// setResults replaces the list contents with search results (UI goroutine).
|
||||
func (v *searchView) setResults(results []result) {
|
||||
v.results = results
|
||||
v.results, v.notes, v.placeholder, v.browsing = results, nil, "", false
|
||||
v.list.SetTitle(" " + v.be.noun() + " ")
|
||||
v.render(0)
|
||||
}
|
||||
|
||||
// activate shows a Trakt list when the view opens with an empty search.
|
||||
func (v *searchView) activate() {
|
||||
if !v.browsing && len(v.results) == 0 && strings.TrimSpace(v.input.GetText()) == "" {
|
||||
v.showList()
|
||||
}
|
||||
}
|
||||
|
||||
// render rebuilds the list, selecting index sel.
|
||||
func (v *searchView) render(sel int) {
|
||||
v.list.Clear()
|
||||
for _, r := range v.results {
|
||||
v.list.AddItem(r.label(), "", 0, nil)
|
||||
for i, r := range v.results {
|
||||
label := r.label()
|
||||
if i < len(v.notes) && v.notes[i] != "" {
|
||||
label += " [gray]" + tview.Escape(v.notes[i]) + "[-]"
|
||||
}
|
||||
v.list.AddItem(label, "", 0, nil)
|
||||
}
|
||||
if len(v.results) == 0 {
|
||||
if v.placeholder != "" {
|
||||
v.list.AddItem("[gray]"+v.placeholder+"[-]", "", 0, nil)
|
||||
}
|
||||
v.details.Clear()
|
||||
return
|
||||
}
|
||||
|
||||
+23
-3
@@ -13,6 +13,7 @@ import (
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
// settingsValues are the settings as shown in the Config view.
|
||||
@@ -20,6 +21,7 @@ type settingsValues struct {
|
||||
radarrURL, radarrKey, radarrProfile, radarrFolder string
|
||||
sonarrURL, sonarrKey, sonarrProfile, sonarrFolder string
|
||||
sabURL, sabKey string
|
||||
traktID string
|
||||
refresh string // seconds, as typed
|
||||
covers bool
|
||||
coverStyle string
|
||||
@@ -47,6 +49,7 @@ func settingsFrom(c *config.Config) settingsValues {
|
||||
sonarrURL: c.Sonarr.URL, sonarrKey: c.Sonarr.APIKey,
|
||||
sonarrProfile: c.Sonarr.DefaultQualityProfile, sonarrFolder: c.Sonarr.DefaultRootFolder,
|
||||
sabURL: c.SABnzbd.URL, sabKey: c.SABnzbd.APIKey,
|
||||
traktID: c.Trakt.ClientID,
|
||||
refresh: strconv.Itoa(c.UI.RefreshSeconds),
|
||||
covers: c.UI.ShowCovers(), coverStyle: style,
|
||||
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
|
||||
@@ -70,6 +73,7 @@ func applySettings(raw config.Config, v settingsValues, overridden map[string]st
|
||||
set("sonarr.apikey", &raw.Sonarr.APIKey, v.sonarrKey)
|
||||
set("sabnzbd.url", &raw.SABnzbd.URL, v.sabURL)
|
||||
set("sabnzbd.apikey", &raw.SABnzbd.APIKey, v.sabKey)
|
||||
set("trakt.client_id", &raw.Trakt.ClientID, v.traktID)
|
||||
raw.Radarr.DefaultQualityProfile = strings.TrimSpace(v.radarrProfile)
|
||||
raw.Radarr.DefaultRootFolder = strings.TrimSpace(v.radarrFolder)
|
||||
raw.Sonarr.DefaultQualityProfile = strings.TrimSpace(v.sonarrProfile)
|
||||
@@ -161,7 +165,7 @@ func newSettingsView(app *App) *settingsView {
|
||||
|
||||
input := func(label, key string) {
|
||||
in := tview.NewInputField().SetLabel(label)
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
if secret(key) {
|
||||
in.SetMaskCharacter('*')
|
||||
}
|
||||
if _, ok := v.overridden[key]; ok {
|
||||
@@ -195,6 +199,8 @@ func newSettingsView(app *App) *settingsView {
|
||||
service("Radarr", "radarr", true)
|
||||
service("Sonarr", "sonarr", true)
|
||||
service("SABnzbd", "sabnzbd", false)
|
||||
f.AddFormItem(newFormHeading("Trakt (optional: trending, most watched, anticipated)"))
|
||||
input("Client ID", "trakt.client_id")
|
||||
// Right below the connection settings it checks.
|
||||
v.testItem = newFormAction("Test", v.test)
|
||||
f.AddFormItem(v.testItem)
|
||||
@@ -295,10 +301,13 @@ func (v *settingsView) edited() {
|
||||
}
|
||||
}
|
||||
|
||||
// secret reports whether a setting is masked like a password.
|
||||
func secret(key string) bool { return strings.HasSuffix(key, ".apikey") || key == "trakt.client_id" }
|
||||
|
||||
// maskKeys hides or shows the API keys.
|
||||
func (v *settingsView) maskKeys(hide bool) {
|
||||
for key, in := range v.inputs {
|
||||
if strings.HasSuffix(key, ".apikey") {
|
||||
if secret(key) {
|
||||
if hide {
|
||||
in.SetMaskCharacter('*')
|
||||
} else {
|
||||
@@ -316,6 +325,7 @@ func (v *settingsView) fill(s settingsValues) {
|
||||
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey,
|
||||
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey,
|
||||
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
||||
"trakt.client_id": s.traktID,
|
||||
}
|
||||
for key, value := range set {
|
||||
v.inputs[key].SetText(value)
|
||||
@@ -366,6 +376,7 @@ func (v *settingsView) read() settingsValues {
|
||||
sonarrURL: text("sonarr.url"), sonarrKey: text("sonarr.apikey"),
|
||||
sonarrProfile: text("sonarr.profile"), sonarrFolder: text("sonarr.folder"),
|
||||
sabURL: text("sabnzbd.url"), sabKey: text("sabnzbd.apikey"),
|
||||
traktID: text("trakt.client_id"),
|
||||
refresh: v.refresh.GetText(),
|
||||
covers: v.covers.IsChecked(), coverStyle: style,
|
||||
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
|
||||
@@ -441,6 +452,15 @@ func (v *settingsView) test() {
|
||||
return sabnzbd.NewClient(strings.TrimSpace(s.sabURL), strings.TrimSpace(s.sabKey)).Version(ctx)
|
||||
}},
|
||||
}
|
||||
if strings.TrimSpace(s.traktID) != "" {
|
||||
tests = append(tests, struct {
|
||||
name string
|
||||
run probeFn
|
||||
}{"Trakt", func(ctx context.Context) (string, error) {
|
||||
_, err := trakt.NewClient(s.traktID, "arr-tui").Entries(ctx, trakt.MovieLists[0], 1)
|
||||
return "", err
|
||||
}})
|
||||
}
|
||||
for _, t := range tests {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -451,7 +471,7 @@ func (v *settingsView) test() {
|
||||
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
|
||||
return
|
||||
}
|
||||
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
|
||||
v.report("[green]✓ " + strings.TrimSpace(t.name+" "+tview.Escape(version)) + " answers[-]")
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -282,13 +282,14 @@ func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
|
||||
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||
focusedItem := func() tview.FormItem { i, _ := v.form.GetFocusedItemIndex(); return v.form.GetFormItem(i) }
|
||||
|
||||
// It comes right after the SABnzbd API key and before the Interface section.
|
||||
// It comes right after the last connection setting (the Trakt client ID)
|
||||
// and before the Interface section.
|
||||
v.focusGroup(0, false)
|
||||
for i := 0; i < 10; i++ { // Radarr 4, Sonarr 4, SABnzbd 2 fields
|
||||
for i := 0; i < 11; i++ { // Radarr 4, Sonarr 4, SABnzbd 2, Trakt 1 fields
|
||||
send(v.form, tcell.KeyDown)
|
||||
}
|
||||
if focusedItem() != tview.FormItem(v.testItem) {
|
||||
t.Fatalf("↓ after the SABnzbd API key reached %T %q, want the Test button", focusedItem(), focusedItem().GetLabel())
|
||||
t.Fatalf("↓ after the Trakt client ID reached %T %q, want the Test button", focusedItem(), focusedItem().GetLabel())
|
||||
}
|
||||
send(v.form, tcell.KeyDown)
|
||||
if got := focusedItem().GetLabel(); got != "Downloads refresh (seconds)" {
|
||||
|
||||
Reference in New Issue
Block a user