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:
@@ -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:]
|
||||
}
|
||||
Reference in New Issue
Block a user