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