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
+107
View File
@@ -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")
}
}