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>
149 lines
3.8 KiB
Go
149 lines
3.8 KiB
Go
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:]
|
|
}
|