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
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## Unreleased
- **Trending, most watched and anticipated** movies and series from Trakt: with an empty search
box, Movies and Series show trending now, most watched this week, anticipated, popular and (movies)
the US box office; `[` `]` switch lists, `r` reloads. Titles in the library are ticked, the others
can be added and searched like search results. Needs a free Trakt client ID, set in the new Trakt
section of the Config view (`trakt.client_id`, `ARR_TUI_TRAKT_CLIENT_ID`).
## v0.4.1
- The README shows screenshots of every view (a made-up library). No changes to the program.
+13 -2
View File
@@ -8,6 +8,10 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
- **Movies / Series**: search as you type, add titles with a quality profile and root folder,
optionally start the automatic search.
- **Trending, most watched, anticipated**: while the search box is empty, Movies and Series show
lists from [Trakt](https://trakt.tv): trending now, most watched this week, anticipated, popular,
and the US box office for movies. `[` and `]` switch lists; titles already in your library are
ticked, the others can be added right there. Needs a free Trakt client ID (see Configure).
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
- **Posters**: the detail panes show the poster at the top right; title, facts, path and plot
@@ -61,8 +65,8 @@ where posters are real images.
| **Seasons and episodes** of a series | **Downloads** from SABnzbd, Radarr and Sonarr |
| ![Calendar: the next two weeks of releases and episodes, grouped by day](docs/screenshots/calendar.png) | ![Config view: every setting in one list, with defaults loaded from the servers](docs/screenshots/config.png) |
| **Calendar** of the next two weeks | **Config** inside the program |
| ![The library in the light theme](docs/screenshots/library-light.png) | |
| **Light theme** | |
| ![Movies view with an empty search: the Trakt list of anticipated movies, with how many lists each is on](docs/screenshots/discover.png) | ![The library in the light theme](docs/screenshots/library-light.png) |
| **Trending, most watched, anticipated** from Trakt | **Light theme** |
## Install
@@ -91,6 +95,12 @@ stays grey. You can also write the file yourself, starting from `config.example.
`default_*` are optional preselections in the add dialog. URL and key can also come from the
environment: `ARR_TUI_{RADARR,SONARR,SABNZBD}_{URL,APIKEY}`, which takes precedence over the file.
The trending, most watched and anticipated lists need a Trakt client ID (free, no Trakt account
of yours is touched): sign in at [trakt.tv](https://trakt.tv), create an app under
[Settings → Your API Apps → New Application](https://trakt.tv/oauth/applications/new) (any name;
redirect URI `urn:ietf:wg:oauth:2.0:oob`), and enter its **Client ID** in the Config view under
Trakt, or as `"trakt": {"client_id": "..."}` in the file, or in `ARR_TUI_TRAKT_CLIENT_ID`.
Keep the file private (`chmod 600`); it holds your API keys.
## Keys
@@ -101,6 +111,7 @@ Keep the file private (`chmod 600`); it holds your API keys.
| `Tab`, `Shift+Tab` | switch panel within a view |
| `F9` | help |
| `/` | focus search box |
| `[`, `]`, `r` (Movies / Series, empty search) | previous / next Trakt list, reload it |
| `Enter`, `a` | add selected title |
| `i` | interactive release search |
| `Tab`, `f`, `F`, `/`, `t`, `s`, `d`, `i`/`Enter`, `r` (library) | movies/series list, status filter, file-property filter, text filter, monitor, search, remove, releases/episodes, reload |
+2 -1
View File
@@ -69,7 +69,8 @@ Notes:
Posters are JPEG, PNG, GIF or WebP (Radarr and Sonarr keep whatever bytes the provider sent under a `.jpg` name, which can be WebP), decoded and downscaled in the process; for any other format the Log says what the data looked like (e.g. AVIF, an HTML error page, or its first bytes). Library items fetch `/MediaCover/<id>/poster-250.jpg` (250×375; `poster-500.jpg` when posters are real images, which otherwise enlarge the small copy) from their Radarr/Sonarr instance with the API key (then the original `poster.jpg`; an instance that answers 200 with an HTML page, i.e. wants a login for `/MediaCover`, is also tried with the key as the `apikey` query parameter; and if the instance has no usable copy, the provider's `remoteUrl`, fetched without credentials); search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes and are written to the Log (F7) with the reason, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd, Trakt and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
### 4.2 v0.2 (done, M8)
+3
View File
@@ -15,6 +15,9 @@
"url": "http://localhost:8080",
"apikey": "YOUR_SABNZBD_API_KEY"
},
"trakt": {
"client_id": "YOUR_TRAKT_CLIENT_ID"
},
"ui": {
"refresh_seconds": 3,
"covers": true,
+16 -6
View File
@@ -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
+12
View File
@@ -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)
}
}
+236
View File
@@ -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) }
+87
View File
@@ -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
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)" {