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:
2026-10-23 23:05:35 +02:00
co-authored by Claude Opus 5.5
parent b71659b357
commit 4dba794b26
18 changed files with 764 additions and 40 deletions
+23 -11
View File
@@ -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 {
+31
View File
@@ -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,
+8
View File
@@ -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)
}
+3 -2
View File
@@ -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.
+148
View File
@@ -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:]
}
+88
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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[-]")
})
}()
}
+4 -3
View File
@@ -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)" {