Keep posters and casts between runs

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>
This commit is contained in:
2026-10-24 10:35:02 +02:00
co-authored by Claude Opus 5.5
parent 4eace6849e
commit 1056846a48
10 changed files with 381 additions and 13 deletions
+62 -8
View File
@@ -3,26 +3,83 @@ package ui
import (
"context"
"errors"
"slices"
"time"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
maxPeopleCache = 500
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 and the lookups in flight.
// UI goroutine only.
// 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{}, failed: map[string]time.Time{}, waiting: map[string][]func(trakt.People, error){}}
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,
@@ -59,10 +116,7 @@ func (a *App) loadPeople(r trakt.Ref, done func(trakt.People, error)) {
if err != nil {
c.failed[key] = time.Now()
} else {
if len(c.done) >= maxPeopleCache {
c.done = map[string]trakt.People{}
}
c.done[key] = p
a.remember(key, p)
}
for _, w := range waiters {
w(p, err)