package ui import ( "context" "errors" "slices" "time" "git.stinnesbeck.com/nils/arr-tui/internal/trakt" ) const ( peopleRetryWait = 2 * time.Minute // a failed lookup is not repeated sooner peopleSaveDelay = 5 * time.Second // new casts are written to disk this long after the last one ) // peopleCache holds the casts loaded from Trakt (also those kept on disk from // earlier runs, see diskcache.go) and the lookups in flight. UI goroutine only. type peopleCache struct { done map[string]trakt.People saved map[string]time.Time // when each cast was fetched failed map[string]time.Time waiting map[string][]func(trakt.People, error) dirty bool // casts not written to disk yet timer *time.Timer // writes them } func newPeopleCache() *peopleCache { return &peopleCache{done: map[string]trakt.People{}, saved: map[string]time.Time{}, failed: map[string]time.Time{}, waiting: map[string][]func(trakt.People, error){}} } // loadSavedPeople fills the cache with the casts stored on disk. func (a *App) loadSavedPeople() { for k, p := range a.disk.loadPeople() { a.people.done[k] = trakt.People{Cast: p.Cast, Leaders: p.Leaders} a.people.saved[k] = p.Saved } } // remember adds a fetched cast, dropping the oldest ones beyond // maxPeopleSaved, and has it written to disk soon. func (a *App) remember(key string, p trakt.People) { c := a.people c.done[key], c.saved[key] = p, time.Now() if len(c.done) > maxPeopleSaved { keys := make([]string, 0, len(c.saved)) for k := range c.saved { keys = append(keys, k) } slices.SortFunc(keys, func(x, y string) int { return c.saved[x].Compare(c.saved[y]) }) for _, k := range keys[:len(keys)-maxPeopleSaved] { delete(c.done, k) delete(c.saved, k) } } c.dirty = true if c.timer != nil { c.timer.Stop() } c.timer = time.AfterFunc(peopleSaveDelay, func() { a.post(a.savePeople) }) } // savePeople writes the casts to disk if there are new ones (UI goroutine; // the writing itself happens in the background). func (a *App) savePeople() { c := a.people if !c.dirty { return } c.dirty = false all := a.peopleSnapshot() go func() { _ = a.disk.savePeople(all) }() } func (a *App) peopleSnapshot() map[string]savedPeople { c := a.people all := make(map[string]savedPeople, len(c.done)) for k, p := range c.done { all[k] = savedPeople{Saved: c.saved[k], Cast: p.Cast, Leaders: p.Leaders} } return all } // loadPeople delivers the cast of a title to done on the UI goroutine, // asking Trakt in the background when it is not cached. A title Trakt does // not know is remembered as having no cast. func (a *App) loadPeople(r trakt.Ref, done func(trakt.People, error)) { c, key := a.people, r.Key() if p, ok := c.done[key]; ok { done(p, nil) return } if t, ok := c.failed[key]; ok && time.Since(t) < peopleRetryWait { done(trakt.People{}, errors.New("cast not available")) return } if _, inflight := c.waiting[key]; inflight { c.waiting[key] = append(c.waiting[key], done) return } c.waiting[key] = []func(trakt.People, error){done} go func() { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() p, err := a.trakt.People(ctx, r) if errors.Is(err, trakt.ErrNotFound) { p, err = trakt.People{}, nil } a.post(func() { if a.people != c { return // dropped meanwhile (the client ID changed) } waiters := c.waiting[key] delete(c.waiting, key) if err != nil { c.failed[key] = time.Now() } else { a.remember(key, p) } for _, w := range waiters { w(p, err) } }) }() } // showPeople loads the cast of the title the pane shows, once the selection // rests (like the poster: holding an arrow key must not send a request per // step, Trakt allows 500 per five minutes). func (d *detailPane) showPeople(r trakt.Ref) { key := r.Key() if key == d.castKey { return // the same title again (a refresh): keep what is shown } if d.castSettle != nil { d.castSettle.Stop() } d.castKey, d.cast = key, "" if key == "" || !d.app.trakt.Configured() { return } now := time.Now() moving := now.Sub(d.castShown) < posterSettle d.castShown = now load := func() { d.app.loadPeople(r, func(p trakt.People, err error) { if d.castKey != key || err != nil { return } d.cast = peopleText(p, r.Kind) d.laidOut.valid = false }) } if moving { d.castSettle = time.AfterFunc(posterSettle, func() { d.app.post(func() { if d.castKey == key { load() } }) }) return } load() }