Files
nilsandClaude Opus 5.5 4dba794b26 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>
2026-10-23 23:05:35 +02:00

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:]
}