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:
@@ -36,6 +36,7 @@ type App struct {
|
||||
theme theme // the colors in use
|
||||
trakt *trakt.Client // trending, most watched and anticipated lists, casts
|
||||
people *peopleCache // casts loaded from Trakt
|
||||
disk *diskCache // posters and casts kept between runs
|
||||
// Called (on the UI goroutine) a moment after a release was grabbed.
|
||||
afterGrab []func()
|
||||
// The Movies and Series views, to refresh their Trakt lists.
|
||||
@@ -180,7 +181,9 @@ func New(cfg *config.Config) *App {
|
||||
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
||||
a.downloads = downloads
|
||||
a.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui")
|
||||
a.disk = newDiskCache()
|
||||
a.people = newPeopleCache()
|
||||
a.loadSavedPeople()
|
||||
if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" {
|
||||
a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots)
|
||||
}
|
||||
@@ -443,7 +446,11 @@ func (a *App) Run() error {
|
||||
if a.gfx != nil {
|
||||
a.gfx.deleteAll()
|
||||
}
|
||||
if a.people.dirty { // casts fetched in the last seconds
|
||||
_ = a.disk.savePeople(a.peopleSnapshot())
|
||||
}
|
||||
}()
|
||||
go a.disk.prunePosters()
|
||||
// The screen draws through the theme (see themedScreen).
|
||||
s, err := tcell.NewScreen()
|
||||
if err != nil {
|
||||
|
||||
@@ -33,7 +33,7 @@ func (a *App) applyConfig(c *config.Config) {
|
||||
}
|
||||
if old.Trakt.ClientID != c.Trakt.ClientID {
|
||||
a.trakt.Reconfigure(c.Trakt.ClientID)
|
||||
a.people = newPeopleCache()
|
||||
a.people.failed = map[string]time.Time{} // casts do not depend on the client ID; failures may
|
||||
for _, p := range a.panes {
|
||||
p.castKey = "" // ask again the next time a title is shown
|
||||
}
|
||||
|
||||
+17
-3
@@ -525,9 +525,23 @@ func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
var img *image.RGBA
|
||||
data, err := ref.fetch(ctx, large)
|
||||
if err == nil {
|
||||
img, err = decodeCover(data, maxHeight)
|
||||
var err error
|
||||
// A copy from an earlier run is used while it is fresh enough.
|
||||
if data, ok := a.disk.readPoster(ref.key, large); ok {
|
||||
if img, err = decodeCover(data, maxHeight); err != nil {
|
||||
a.disk.dropPoster(ref.key, large)
|
||||
img = nil
|
||||
}
|
||||
}
|
||||
if img == nil {
|
||||
var data []byte
|
||||
data, err = ref.fetch(ctx, large)
|
||||
if err == nil {
|
||||
img, err = decodeCover(data, maxHeight)
|
||||
}
|
||||
if err == nil {
|
||||
a.disk.writePoster(ref.key, large, data)
|
||||
}
|
||||
}
|
||||
a.post(func() {
|
||||
waiters := c.waiting[ref.key]
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
const (
|
||||
posterTTL = 30 * 24 * time.Hour // a poster on disk is used this long
|
||||
posterCacheCap = 200 << 20 // bytes of posters kept on disk
|
||||
peopleTTL = 7 * 24 * time.Hour // a cast on disk is used this long
|
||||
maxPeopleSaved = 2000 // casts kept (in memory and on disk)
|
||||
)
|
||||
|
||||
// cacheDirFunc returns the directory for the disk cache, "" for none. It is a
|
||||
// variable so tests can turn the cache off.
|
||||
var cacheDirFunc = func() string {
|
||||
dir, err := os.UserCacheDir() // $XDG_CACHE_HOME or ~/.cache, ~/Library/Caches on macOS
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, "arr-tui")
|
||||
}
|
||||
|
||||
// diskCache keeps posters and casts between runs. With an empty dir it does
|
||||
// nothing. Safe for use from any goroutine (each file is written atomically).
|
||||
type diskCache struct{ dir string }
|
||||
|
||||
func newDiskCache() *diskCache { return &diskCache{dir: cacheDirFunc()} }
|
||||
|
||||
func (d *diskCache) posterPath(key string, large bool) string {
|
||||
size := "small"
|
||||
if large {
|
||||
size = "large"
|
||||
}
|
||||
sum := sha256.Sum256([]byte(size + "|" + key))
|
||||
return filepath.Join(d.dir, "posters", hex.EncodeToString(sum[:16])+".img")
|
||||
}
|
||||
|
||||
// readPoster returns the stored bytes of a poster, if they are not too old.
|
||||
func (d *diskCache) readPoster(key string, large bool) ([]byte, bool) {
|
||||
if d.dir == "" {
|
||||
return nil, false
|
||||
}
|
||||
p := d.posterPath(key, large)
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || time.Since(info.ModTime()) > posterTTL {
|
||||
return nil, false
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
return data, err == nil
|
||||
}
|
||||
|
||||
// writePoster stores the bytes of a poster as downloaded.
|
||||
func (d *diskCache) writePoster(key string, large bool, data []byte) {
|
||||
if d.dir == "" {
|
||||
return
|
||||
}
|
||||
_ = writeAtomic(d.posterPath(key, large), data)
|
||||
}
|
||||
|
||||
// dropPoster removes a stored poster (it could not be read).
|
||||
func (d *diskCache) dropPoster(key string, large bool) {
|
||||
if d.dir != "" {
|
||||
_ = os.Remove(d.posterPath(key, large))
|
||||
}
|
||||
}
|
||||
|
||||
// prunePosters deletes posters older than posterTTL, then the oldest ones
|
||||
// until the rest fit posterCacheCap.
|
||||
func (d *diskCache) prunePosters() {
|
||||
if d.dir == "" {
|
||||
return
|
||||
}
|
||||
type file struct {
|
||||
path string
|
||||
size int64
|
||||
mod time.Time
|
||||
}
|
||||
var files []file
|
||||
var total int64
|
||||
_ = filepath.WalkDir(filepath.Join(d.dir, "posters"), func(p string, e fs.DirEntry, err error) error {
|
||||
if err != nil || e.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if time.Since(info.ModTime()) > posterTTL {
|
||||
_ = os.Remove(p)
|
||||
return nil
|
||||
}
|
||||
files = append(files, file{p, info.Size(), info.ModTime()})
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
slices.SortFunc(files, func(a, b file) int { return a.mod.Compare(b.mod) })
|
||||
for _, f := range files {
|
||||
if total <= posterCacheCap {
|
||||
break
|
||||
}
|
||||
if os.Remove(f.path) == nil {
|
||||
total -= f.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// savedPeople is a cast as stored on disk.
|
||||
type savedPeople struct {
|
||||
Saved time.Time `json:"saved"`
|
||||
Cast []trakt.Person `json:"cast,omitempty"`
|
||||
Leaders []trakt.Person `json:"leaders,omitempty"`
|
||||
}
|
||||
|
||||
func (d *diskCache) peoplePath() string { return filepath.Join(d.dir, "people.json") }
|
||||
|
||||
// loadPeople reads the stored casts that are not too old.
|
||||
func (d *diskCache) loadPeople() map[string]savedPeople {
|
||||
out := map[string]savedPeople{}
|
||||
if d.dir == "" {
|
||||
return out
|
||||
}
|
||||
data, err := os.ReadFile(d.peoplePath())
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var all map[string]savedPeople
|
||||
if json.Unmarshal(data, &all) != nil {
|
||||
return out // unreadable: start afresh, it is only a cache
|
||||
}
|
||||
for k, p := range all {
|
||||
if time.Since(p.Saved) <= peopleTTL {
|
||||
out[k] = p
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// savePeople writes the casts.
|
||||
func (d *diskCache) savePeople(all map[string]savedPeople) error {
|
||||
if d.dir == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(all)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(d.peoplePath(), data)
|
||||
}
|
||||
|
||||
// writeAtomic writes a file through a temporary file and a rename, creating
|
||||
// the directory (private) if needed.
|
||||
func writeAtomic(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // no-op after the rename
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
|
||||
)
|
||||
|
||||
func TestPostersOnDisk(t *testing.T) {
|
||||
d := &diskCache{dir: t.TempDir()}
|
||||
if _, ok := d.readPoster("k", false); ok {
|
||||
t.Fatal("empty cache returned a poster")
|
||||
}
|
||||
d.writePoster("k", false, []byte("SMALL"))
|
||||
d.writePoster("k", true, []byte("LARGE"))
|
||||
if data, ok := d.readPoster("k", false); !ok || string(data) != "SMALL" {
|
||||
t.Errorf("small: %q %v", data, ok)
|
||||
}
|
||||
if data, ok := d.readPoster("k", true); !ok || string(data) != "LARGE" {
|
||||
t.Errorf("large copies are kept apart: %q %v", data, ok)
|
||||
}
|
||||
if info, _ := os.Stat(filepath.Dir(d.posterPath("k", false))); info.Mode().Perm() != 0o700 {
|
||||
t.Errorf("directory mode %v", info.Mode().Perm())
|
||||
}
|
||||
|
||||
// Too old: not used, and pruned.
|
||||
old := time.Now().Add(-posterTTL - time.Hour)
|
||||
_ = os.Chtimes(d.posterPath("k", false), old, old)
|
||||
if _, ok := d.readPoster("k", false); ok {
|
||||
t.Error("an expired poster was used")
|
||||
}
|
||||
d.prunePosters()
|
||||
if _, err := os.Stat(d.posterPath("k", false)); !os.IsNotExist(err) {
|
||||
t.Error("an expired poster was not pruned")
|
||||
}
|
||||
if _, ok := d.readPoster("k", true); !ok {
|
||||
t.Error("a fresh poster was pruned")
|
||||
}
|
||||
|
||||
// Off: nothing is read or written.
|
||||
off := &diskCache{}
|
||||
off.writePoster("k", false, []byte("x"))
|
||||
if _, ok := off.readPoster("k", false); ok {
|
||||
t.Error("cache without a directory returned a poster")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrunePostersKeepsTheNewestWithinTheCap(t *testing.T) {
|
||||
d := &diskCache{dir: t.TempDir()}
|
||||
big := make([]byte, posterCacheCap/2+1) // two of these do not fit
|
||||
for i, key := range []string{"oldest", "middle", "newest"} {
|
||||
d.writePoster(key, false, big)
|
||||
at := time.Now().Add(time.Duration(i-3) * time.Hour)
|
||||
_ = os.Chtimes(d.posterPath(key, false), at, at)
|
||||
}
|
||||
d.prunePosters()
|
||||
for key, want := range map[string]bool{"oldest": false, "middle": false, "newest": true} {
|
||||
if _, ok := d.readPoster(key, false); ok != want {
|
||||
t.Errorf("%s kept: %v, want %v", key, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeopleOnDisk(t *testing.T) {
|
||||
d := &diskCache{dir: t.TempDir()}
|
||||
all := map[string]savedPeople{
|
||||
"movie:tmdb:1": {Saved: time.Now(), Cast: []trakt.Person{{Name: "A", Role: "B"}}, Leaders: []trakt.Person{{Name: "C"}}},
|
||||
"movie:tmdb:2": {Saved: time.Now().Add(-peopleTTL - time.Hour), Cast: []trakt.Person{{Name: "Old"}}},
|
||||
"show:tvdb:3": {Saved: time.Now()}, // Trakt had no cast: remembered as such
|
||||
}
|
||||
if err := d.savePeople(all); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := d.loadPeople()
|
||||
if len(got) != 2 || got["movie:tmdb:1"].Cast[0].Role != "B" || got["movie:tmdb:1"].Leaders[0].Name != "C" {
|
||||
t.Errorf("loaded %+v", got)
|
||||
}
|
||||
if _, ok := got["show:tvdb:3"]; !ok {
|
||||
t.Error("an empty cast was not kept")
|
||||
}
|
||||
// A damaged file is ignored.
|
||||
_ = os.WriteFile(d.peoplePath(), []byte("{not json"), 0o600)
|
||||
if len(d.loadPeople()) != 0 {
|
||||
t.Error("damaged file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeopleCacheKeepsTheNewest(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
for i := 0; i < maxPeopleSaved+5; i++ {
|
||||
a.remember(trakt.Ref{Kind: trakt.Movies, TMDB: i + 1}.Key(), trakt.People{})
|
||||
a.people.saved[trakt.Ref{Kind: trakt.Movies, TMDB: i + 1}.Key()] = time.Unix(int64(i), 0)
|
||||
}
|
||||
a.people.timer.Stop()
|
||||
if len(a.people.done) != maxPeopleSaved {
|
||||
t.Errorf("%d casts kept", len(a.people.done))
|
||||
}
|
||||
if _, ok := a.people.done["movie:tmdb:1"]; ok {
|
||||
t.Error("the oldest cast was kept")
|
||||
}
|
||||
if !a.people.dirty || len(a.peopleSnapshot()) != maxPeopleSaved {
|
||||
t.Error("new casts must be written")
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ import (
|
||||
// support and its colors (the probe writes to and reads from /dev/tty).
|
||||
func TestMain(m *testing.M) {
|
||||
terminalAnswers = func() terminalInfo { return terminalInfo{} }
|
||||
cacheDirFunc = func() string { return "" } // never the user's cache
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
+62
-8
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user