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:
@@ -77,11 +77,18 @@ func (u UI) ShouldNotifyDownloads() bool { return u.NotifyDownloads == nil || *u
|
||||
// ShowCovers reports whether poster thumbnails are enabled.
|
||||
func (u UI) ShowCovers() bool { return u.Covers == nil || *u.Covers }
|
||||
|
||||
// Trakt holds the client ID of a Trakt API app, used for the trending,
|
||||
// most watched and anticipated lists. Optional.
|
||||
type Trakt struct {
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
}
|
||||
|
||||
// Config is the full application configuration.
|
||||
type Config struct {
|
||||
Radarr Arr `json:"radarr"`
|
||||
Sonarr Arr `json:"sonarr"`
|
||||
SABnzbd Service `json:"sabnzbd"`
|
||||
Trakt Trakt `json:"trakt"`
|
||||
UI UI `json:"ui"`
|
||||
}
|
||||
|
||||
@@ -184,12 +191,13 @@ func (c *Config) Effective(env func(string) string) (*Config, error) {
|
||||
// envVars maps the settings that can be overridden by the environment to the
|
||||
// variable that does it.
|
||||
var envVars = map[string]string{
|
||||
"radarr.url": "ARR_TUI_RADARR_URL",
|
||||
"radarr.apikey": "ARR_TUI_RADARR_APIKEY",
|
||||
"sonarr.url": "ARR_TUI_SONARR_URL",
|
||||
"sonarr.apikey": "ARR_TUI_SONARR_APIKEY",
|
||||
"sabnzbd.url": "ARR_TUI_SABNZBD_URL",
|
||||
"sabnzbd.apikey": "ARR_TUI_SABNZBD_APIKEY",
|
||||
"radarr.url": "ARR_TUI_RADARR_URL",
|
||||
"radarr.apikey": "ARR_TUI_RADARR_APIKEY",
|
||||
"sonarr.url": "ARR_TUI_SONARR_URL",
|
||||
"sonarr.apikey": "ARR_TUI_SONARR_APIKEY",
|
||||
"sabnzbd.url": "ARR_TUI_SABNZBD_URL",
|
||||
"sabnzbd.apikey": "ARR_TUI_SABNZBD_APIKEY",
|
||||
"trakt.client_id": "ARR_TUI_TRAKT_CLIENT_ID",
|
||||
}
|
||||
|
||||
// Overrides returns, for every setting that the environment currently
|
||||
@@ -253,6 +261,7 @@ func (c *Config) applyEnv(get func(string) string) {
|
||||
override(&c.Sonarr.APIKey, "ARR_TUI_SONARR_APIKEY")
|
||||
override(&c.SABnzbd.URL, "ARR_TUI_SABNZBD_URL")
|
||||
override(&c.SABnzbd.APIKey, "ARR_TUI_SABNZBD_APIKEY")
|
||||
override(&c.Trakt.ClientID, "ARR_TUI_TRAKT_CLIENT_ID")
|
||||
}
|
||||
|
||||
func (c *Config) applyDefaults() {
|
||||
@@ -262,6 +271,7 @@ func (c *Config) applyDefaults() {
|
||||
c.Radarr.URL = strings.TrimRight(c.Radarr.URL, "/")
|
||||
c.Sonarr.URL = strings.TrimRight(c.Sonarr.URL, "/")
|
||||
c.SABnzbd.URL = strings.TrimRight(c.SABnzbd.URL, "/")
|
||||
c.Trakt.ClientID = strings.TrimSpace(c.Trakt.ClientID)
|
||||
}
|
||||
|
||||
// Validate checks that every service that is set up has a usable URL and API
|
||||
|
||||
@@ -233,3 +233,15 @@ func TestCreate(t *testing.T) {
|
||||
t.Errorf("the new file must load cleanly: %v %v", warnings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraktClientID(t *testing.T) {
|
||||
cfg, err := Parse([]byte(`{"trakt":{"client_id":" abc "}}`))
|
||||
if err != nil || cfg.Trakt.ClientID != "abc" {
|
||||
t.Fatalf("client id %q, %v", cfg.Trakt.ClientID, err)
|
||||
}
|
||||
t.Setenv("ARR_TUI_TRAKT_CLIENT_ID", "from-env")
|
||||
cfg, _ = Parse([]byte(`{}`))
|
||||
if cfg.Trakt.ClientID != "from-env" || Overrides(os.Getenv)["trakt.client_id"] != "ARR_TUI_TRAKT_CLIENT_ID" {
|
||||
t.Errorf("env override: %q", cfg.Trakt.ClientID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Package trakt reads the public lists of Trakt (trakt.tv): trending, most
|
||||
// watched, anticipated, popular and box office movies and shows. Only a client
|
||||
// ID is needed; nothing is written and no user account is involved.
|
||||
package trakt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the Trakt API.
|
||||
const DefaultBaseURL = "https://api.trakt.tv"
|
||||
|
||||
// ErrNotConfigured is returned without a request while no client ID is set.
|
||||
var ErrNotConfigured = errors.New("no Trakt client ID: create an API app on trakt.tv and enter its client ID in the Config view (F6)")
|
||||
|
||||
// Kind says whether a list holds movies or shows.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
Movies Kind = iota
|
||||
Shows
|
||||
)
|
||||
|
||||
// List is one of Trakt's public lists.
|
||||
type List struct {
|
||||
Name string // shown to the user, e.g. "Trending now"
|
||||
path string // e.g. "/movies/trending"
|
||||
count string // the field holding the list's number, "" for none
|
||||
// Note formats the number for display, e.g. "1.2k watching".
|
||||
note func(n int64) string
|
||||
kind Kind
|
||||
}
|
||||
|
||||
// Kind returns whether the list holds movies or shows.
|
||||
func (l List) Kind() Kind { return l.kind }
|
||||
|
||||
// Note describes an entry's number in this list, or "" when it has none.
|
||||
func (l List) Note(n int64) string {
|
||||
if l.note == nil || n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return l.note(n)
|
||||
}
|
||||
|
||||
func watching(n int64) string { return Compact(n) + " watching" }
|
||||
func watchers(n int64) string { return Compact(n) + " watched it" }
|
||||
func lists(n int64) string { return "on " + Compact(n) + " lists" }
|
||||
func revenue(n int64) string { return "$" + Compact(n) + " last weekend" }
|
||||
func movieList(name, path, count string, note func(int64) string) List {
|
||||
return List{Name: name, path: path, count: count, note: note, kind: Movies}
|
||||
}
|
||||
func showList(name, path, count string, note func(int64) string) List {
|
||||
return List{Name: name, path: path, count: count, note: note, kind: Shows}
|
||||
}
|
||||
|
||||
// MovieLists and ShowLists are the lists arr-tui offers, in order.
|
||||
var (
|
||||
MovieLists = []List{
|
||||
movieList("Trending now", "/movies/trending", "watchers", watching),
|
||||
movieList("Most watched this week", "/movies/watched/weekly", "watcher_count", watchers),
|
||||
movieList("Anticipated", "/movies/anticipated", "list_count", lists),
|
||||
movieList("Popular", "/movies/popular", "", nil),
|
||||
movieList("Box office (US, last weekend)", "/movies/boxoffice", "revenue", revenue),
|
||||
}
|
||||
ShowLists = []List{
|
||||
showList("Trending now", "/shows/trending", "watchers", watching),
|
||||
showList("Most watched this week", "/shows/watched/weekly", "watcher_count", watchers),
|
||||
showList("Anticipated", "/shows/anticipated", "list_count", lists),
|
||||
showList("Popular", "/shows/popular", "", nil),
|
||||
}
|
||||
)
|
||||
|
||||
// IDs are the identifiers Trakt knows for a title. TVDB is set for shows only.
|
||||
type IDs struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
IMDB string `json:"imdb"`
|
||||
TMDB int `json:"tmdb"`
|
||||
TVDB int `json:"tvdb"`
|
||||
}
|
||||
|
||||
// Entry is one title on a list, with the list's number for it (watchers,
|
||||
// lists it is on, revenue; 0 for lists without one).
|
||||
type Entry struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
IDs IDs `json:"ids"`
|
||||
Count int64 `json:"-"`
|
||||
}
|
||||
|
||||
// Client talks to the Trakt API.
|
||||
type Client struct {
|
||||
mu sync.RWMutex
|
||||
baseURL string
|
||||
clientID string
|
||||
userAgent string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient returns a client with the given client ID (may be empty: then
|
||||
// every call returns ErrNotConfigured).
|
||||
func NewClient(clientID, userAgent string) *Client {
|
||||
return &Client{baseURL: DefaultBaseURL, clientID: strings.TrimSpace(clientID), userAgent: userAgent,
|
||||
http: &http.Client{Timeout: 20 * time.Second}}
|
||||
}
|
||||
|
||||
// Reconfigure sets another client ID.
|
||||
func (c *Client) Reconfigure(clientID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.clientID = strings.TrimSpace(clientID)
|
||||
}
|
||||
|
||||
// SetBaseURL points the client at another server (tests).
|
||||
func (c *Client) SetBaseURL(u string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.baseURL = strings.TrimRight(u, "/")
|
||||
}
|
||||
|
||||
// Configured reports whether a client ID is set.
|
||||
func (c *Client) Configured() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.clientID != ""
|
||||
}
|
||||
|
||||
// Entries fetches up to limit entries of a list.
|
||||
func (c *Client) Entries(ctx context.Context, l List, limit int) ([]Entry, error) {
|
||||
c.mu.RLock()
|
||||
base, id := c.baseURL, c.clientID
|
||||
c.mu.RUnlock()
|
||||
if id == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
q := url.Values{"page": {"1"}, "limit": {strconv.Itoa(limit)}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+l.path+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", c.userAgent)
|
||||
req.Header.Set("trakt-api-key", id)
|
||||
req.Header.Set("trakt-api-version", "2")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return nil, fmt.Errorf("trakt: %w", ue.Err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
return nil, fmt.Errorf("trakt: the client ID was not accepted (HTTP %d)", resp.StatusCode)
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
return nil, errors.New("trakt: too many requests, try again in a few minutes")
|
||||
case resp.StatusCode < 200 || resp.StatusCode > 299:
|
||||
return nil, fmt.Errorf("trakt: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := decode(data, l)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trakt: decoding %s: %w", l.path, err)
|
||||
}
|
||||
if len(entries) > limit {
|
||||
entries = entries[:limit]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// decode reads a list response: either bare titles, or items with the title
|
||||
// under "movie"/"show" next to the list's number.
|
||||
func decode(data []byte, l List) ([]Entry, error) {
|
||||
var items []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := "movie"
|
||||
if l.kind == Shows {
|
||||
key = "show"
|
||||
}
|
||||
out := make([]Entry, 0, len(items))
|
||||
for _, item := range items {
|
||||
var e Entry
|
||||
raw, wrapped := item[key]
|
||||
if !wrapped {
|
||||
// A bare title: the item is the title itself.
|
||||
b, _ := json.Marshal(item)
|
||||
raw = b
|
||||
}
|
||||
if err := json.Unmarshal(raw, &e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l.count != "" {
|
||||
if n, ok := item[l.count]; ok {
|
||||
_ = json.Unmarshal(n, &e.Count)
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Compact writes a number briefly: 950, 1.2k, 34k, 1.5M.
|
||||
func Compact(n int64) string {
|
||||
switch {
|
||||
case n < 1000:
|
||||
return strconv.FormatInt(n, 10)
|
||||
case n < 10_000:
|
||||
return trimZero(fmt.Sprintf("%.1fk", float64(n)/1000))
|
||||
case n < 1_000_000:
|
||||
return fmt.Sprintf("%dk", n/1000)
|
||||
case n < 10_000_000:
|
||||
return trimZero(fmt.Sprintf("%.1fM", float64(n)/1_000_000))
|
||||
default:
|
||||
return fmt.Sprintf("%dM", n/1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
func trimZero(s string) string { return strings.Replace(s, ".0", "", 1) }
|
||||
@@ -0,0 +1,87 @@
|
||||
package trakt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEntries(t *testing.T) {
|
||||
var got http.Header
|
||||
var gotQuery string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got, gotQuery = r.Header, r.URL.RawQuery
|
||||
switch r.URL.Path {
|
||||
case "/movies/trending":
|
||||
w.Write([]byte(`[{"watchers":1234,"movie":{"title":"Dune","year":2021,"ids":{"trakt":1,"slug":"dune-2021","imdb":"tt1160419","tmdb":438631}}},
|
||||
{"watchers":7,"movie":{"title":"No Year","year":null,"ids":{"trakt":2,"slug":"x","imdb":null,"tmdb":null}}}]`))
|
||||
case "/movies/popular":
|
||||
w.Write([]byte(`[{"title":"Heat","year":1995,"ids":{"trakt":3,"slug":"heat-1995","tmdb":949}}]`))
|
||||
case "/shows/watched/weekly":
|
||||
w.Write([]byte(`[{"watcher_count":52000,"play_count":1,"collected_count":2,"collector_count":3,"show":{"title":"Andor","year":2022,"ids":{"trakt":4,"slug":"andor","tvdb":393189,"tmdb":83867}}}]`))
|
||||
case "/movies/boxoffice":
|
||||
w.Write([]byte(`[{"revenue":48464322,"movie":{"title":"Big","year":2026,"ids":{"trakt":5,"tmdb":5}}}]`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(" my-id ", "arr-tui/test")
|
||||
c.SetBaseURL(srv.URL)
|
||||
movies, err := c.Entries(context.Background(), MovieLists[0], 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Get("trakt-api-key") != "my-id" || got.Get("trakt-api-version") != "2" || got.Get("User-Agent") != "arr-tui/test" ||
|
||||
got.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("headers: %v", got)
|
||||
}
|
||||
if gotQuery != "limit=20&page=1" {
|
||||
t.Errorf("query %q", gotQuery)
|
||||
}
|
||||
if len(movies) != 2 || movies[0].Title != "Dune" || movies[0].IDs.TMDB != 438631 || movies[0].Count != 1234 || movies[1].IDs.TMDB != 0 {
|
||||
t.Errorf("trending: %+v", movies)
|
||||
}
|
||||
if note := MovieLists[0].Note(movies[0].Count); note != "1.2k watching" {
|
||||
t.Errorf("note %q", note)
|
||||
}
|
||||
|
||||
popular, err := c.Entries(context.Background(), MovieLists[3], 20)
|
||||
if err != nil || len(popular) != 1 || popular[0].Title != "Heat" || popular[0].IDs.TMDB != 949 || MovieLists[3].Note(popular[0].Count) != "" {
|
||||
t.Errorf("popular (bare items): %+v %v", popular, err)
|
||||
}
|
||||
shows, err := c.Entries(context.Background(), ShowLists[1], 20)
|
||||
if err != nil || len(shows) != 1 || shows[0].IDs.TVDB != 393189 || shows[0].Count != 52000 || ShowLists[1].Note(52000) != "52k watched it" {
|
||||
t.Errorf("most watched shows: %+v %v", shows, err)
|
||||
}
|
||||
box, err := c.Entries(context.Background(), MovieLists[4], 20)
|
||||
if err != nil || box[0].Count != 48464322 || MovieLists[4].Note(box[0].Count) != "$48M last weekend" {
|
||||
t.Errorf("box office: %+v %v", box, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesErrors(t *testing.T) {
|
||||
if _, err := NewClient("", "ua").Entries(context.Background(), MovieLists[0], 5); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("no client id: %v", err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient("bad", "ua")
|
||||
c.SetBaseURL(srv.URL)
|
||||
if _, err := c.Entries(context.Background(), MovieLists[0], 5); err == nil || err.Error() != "trakt: the client ID was not accepted (HTTP 403)" {
|
||||
t.Errorf("rejected id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompact(t *testing.T) {
|
||||
for n, want := range map[int64]string{0: "0", 950: "950", 1000: "1k", 1234: "1.2k", 34567: "34k", 1500000: "1.5M", 48464322: "48M"} {
|
||||
if got := Compact(n); got != want {
|
||||
t.Errorf("Compact(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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