Posters (the downloaded bytes, per size, 30 days, at most 200 MB) and casts (people.json, 7 days, at most 2000) are stored in the user's cache directory (os.UserCacheDir()/arr-tui) and used on the next start before asking the servers. Expired posters are pruned at startup, casts are written shortly after new ones arrive and on exit. Tests turn the cache off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
167 lines
4.4 KiB
Go
167 lines
4.4 KiB
Go
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()
|
|
}
|