From 4dba794b26ae6d983d78849e5f2a77d9c3fbcb29 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Fri, 23 Oct 2026 23:05:35 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 8 ++ README.md | 15 ++- SPECS.md | 3 +- config.example.json | 3 + internal/config/config.go | 22 ++- internal/config/config_test.go | 12 ++ internal/trakt/trakt.go | 236 +++++++++++++++++++++++++++++++++ internal/trakt/trakt_test.go | 87 ++++++++++++ internal/ui/app.go | 34 +++-- internal/ui/backend.go | 31 +++++ internal/ui/configapply.go | 8 ++ internal/ui/cover.go | 5 +- internal/ui/discover.go | 148 +++++++++++++++++++++ internal/ui/discover_test.go | 88 ++++++++++++ internal/ui/help.go | 6 +- internal/ui/search.go | 65 +++++++-- internal/ui/settings.go | 26 +++- internal/ui/settings_test.go | 7 +- 18 files changed, 764 insertions(+), 40 deletions(-) create mode 100644 internal/trakt/trakt.go create mode 100644 internal/trakt/trakt_test.go create mode 100644 internal/ui/discover.go create mode 100644 internal/ui/discover_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 387b974..43ed30f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index b60bdf1..23a081f 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/SPECS.md b/SPECS.md index 6b17dd5..bdf983d 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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//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:`) or Sonarr (`tvdb:`), 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) diff --git a/config.example.json b/config.example.json index 5e0c7f2..df0e316 100644 --- a/config.example.json +++ b/config.example.json @@ -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, diff --git a/internal/config/config.go b/internal/config/config.go index 050ecf2..e4b4086 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cf12f47..c4d59f3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/trakt/trakt.go b/internal/trakt/trakt.go new file mode 100644 index 0000000..d5127d7 --- /dev/null +++ b/internal/trakt/trakt.go @@ -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) } diff --git a/internal/trakt/trakt_test.go b/internal/trakt/trakt_test.go new file mode 100644 index 0000000..5f34191 --- /dev/null +++ b/internal/trakt/trakt_test.go @@ -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) + } + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 1c97129..20fd449 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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 { diff --git a/internal/ui/backend.go b/internal/ui/backend.go index 81457fa..80b4d4e 100644 --- a/internal/ui/backend.go +++ b/internal/ui/backend.go @@ -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, diff --git a/internal/ui/configapply.go b/internal/ui/configapply.go index e99ebf3..6d0baec 100644 --- a/internal/ui/configapply.go +++ b/internal/ui/configapply.go @@ -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) } diff --git a/internal/ui/cover.go b/internal/ui/cover.go index a8a05be..9191af9 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -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. diff --git a/internal/ui/discover.go b/internal/ui/discover.go new file mode 100644 index 0000000..13c1f14 --- /dev/null +++ b/internal/ui/discover.go @@ -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:] +} diff --git a/internal/ui/discover_test.go b/internal/ui/discover_test.go new file mode 100644 index 0000000..18bd9b3 --- /dev/null +++ b/internal/ui/discover_test.go @@ -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) + } +} diff --git a/internal/ui/help.go b/internal/ui/help.go index b62dd4b..4da165e 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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{ diff --git a/internal/ui/search.go b/internal/ui/search.go index 5169203..203562e 100644 --- a/internal/ui/search.go +++ b/internal/ui/search.go @@ -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 } diff --git a/internal/ui/settings.go b/internal/ui/settings.go index ddee2c4..930a223 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -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[-]") }) }() } diff --git a/internal/ui/settings_test.go b/internal/ui/settings_test.go index b51189e..0ea29cb 100644 --- a/internal/ui/settings_test.go +++ b/internal/ui/settings_test.go @@ -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)" {