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
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
- **Posters and casts are kept between runs** (in `~/.cache/arr-tui`; posters 30 days and at
most 200 MB, casts 7 days), so they show at once after a restart and are not downloaded again.
- **Downloading titles in the Library:** the dot before a movie or series turns blue while it is in
the Radarr/Sonarr download queue, and its details say how far ("Downloading 45% · 0:06:12 left").
- **After a grab** the release list closes and you are back where the search started.
+3
View File
@@ -14,6 +14,9 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
ticked, the others can be added right there (Enter). Needs a free Trakt client ID (see Configure).
- **Library**: movies and series with their status; the dot before a title turns blue while it
is downloading, and the details say how far.
- **Cache**: posters (30 days, at most 200 MB) and casts (7 days) are kept between runs in
`~/.cache/arr-tui` (`$XDG_CACHE_HOME`; `~/Library/Caches/arr-tui` on macOS), so they show at
once after a restart. Deleting the folder is always safe.
- **Season posters**: in a series' seasons and episodes, the poster follows the selected season.
- **Cast**: with a Trakt client ID, the details pane also lists the director (or the creators of a
series) and the cast with their roles, below the plot, in every view.
+2 -1
View File
@@ -71,8 +71,9 @@ Notes:
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
- **Trakt lists:** while the search box of Movies or Series is empty, the list shows one of Trakt's public lists instead of search results: trending now (`/movies|shows/trending`, "N watching"), most watched this week (`/…/watched/weekly`, "N watched it"), anticipated (`/…/anticipated`, "on N lists"), popular (`/…/popular`), and for movies the US box office of last weekend (`/movies/boxoffice`, "$N last weekend"); 20 titles each (`page=1&limit=20`), with the headers `trakt-api-key` (the client ID, `trakt.client_id` or `ARR_TUI_TRAKT_CLIENT_ID`), `trakt-api-version: 2`, `Content-Type` and `User-Agent`. Every entry is looked up in Radarr (`tmdb:<id>`) or Sonarr (`tvdb:<id>`), at most four at a time, so it behaves like a search result (details, poster, tick when in the library, add, releases); entries without that id or unknown to the service are left out, in list order otherwise. `[` `]` switch lists (also from the empty search box), `r` reloads; a fetched list is shown again for 15 minutes without asking. Without a client ID the list says how to get one. Changing the client ID or a service in the Config view drops the fetched lists. `ARR_TUI_TRAKT_URL` points the client at another server (used by `dev/screenshots`).
- **Downloading in the Library:** while the Library is visible it reads the Radarr and Sonarr queues (`/queue`, matched to titles by `movieId` / `seriesId`) when it opens, every 10 s, and 3 s after a grab. A title with queue entries gets a blue mark (filled when monitored, hollow when not) instead of the green or grey one, and its details a line "Downloading 45% · 0:06:12 left" (series: "Downloading N episodes"). A queue that cannot be read leaves the marks as they were. After a successful grab the release list closes (if nothing was opened on top of it).
- **Disk cache:** posters and casts are kept between runs in `os.UserCacheDir()/arr-tui` (`$XDG_CACHE_HOME` or `~/.cache` on Linux, `~/Library/Caches` on macOS; directories `0700`, files written atomically). Posters are the downloaded bytes, one file per poster and size (small copy for block characters, large for real images) under `posters/<hash>.img`; a file is used for 30 days (by its modification time) and dropped when it cannot be decoded; at startup files older than that are deleted, then the oldest until the rest fit 200 MB. Casts are one `people.json` (key = `movie:tmdb:<id>` / `show:tvdb:<id>`, with the time it was fetched); entries are used for 7 days, at most 2000 are kept (the oldest go first), titles Trakt does not know are kept as having no cast; new casts are written 5 s after the last one and when the program quits. Changing the Trakt client ID keeps the casts and only forgets failed lookups.
- **Season posters:** the series drill-down reads the series with `includeSeasonImages=true` (Sonarr only sends season posters when asked, and keeps no copy of them: they are TheTVDB links, fetched without credentials). A selected season or episode shows its season's poster; seasons without one, or whose poster cannot be loaded, show the series poster.
- **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search/<type>/<id>?type=movie|show`), then `/movies|shows/<trakt id>/people` is read. Casts are cached for the session (up to 500 titles; a title Trakt does not know counts as having none; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes).
- **Cast:** with a Trakt client ID, every details pane (search results, Trakt lists, Library, Calendar, the series drill-down) shows the director(s) of a movie or the "created by" credits of a series and up to 8 cast members with their characters below the plot ("and N more" after that). The title is found on Trakt by its TMDb id (movies) or TVDB id (series), else IMDb id (`/search/<type>/<id>?type=movie|show`), then `/movies|shows/<trakt id>/people` is read. Casts are cached, also on disk (see Disk cache; failures are retried after two minutes) and, like posters, only loaded once the selection has rested for 120 ms, so a held arrow key does not spend Trakt's allowance (500 requests per five minutes).
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd, Trakt and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are centered in a bar below the list. When the config file does not exist, it is created empty (`config.Create`: directory `0700`, file `0600`) and the program opens on this view. A service with neither address nor key is "not configured": allowed by validation (half-filled ones are not), its client answers every call with `ErrNotConfigured` without a request, its indicator stays grey (logged once as information, not as unreachable) and its lists say "not configured (Config view, F6)" instead of an error. The fields start from the settings in use; API keys are masked (a checkbox shows them). The default quality profile and root folder of Radarr and Sonarr are dropdowns filled from the server (`/qualityprofile`, `/rootfolder`) with the address and key currently in the fields: when the view opens, on Revert, and 700 ms after the address or key was last changed; answers for values that have changed since are dropped, a failure is reported once in the result box. "(none)" comes first; a configured value the server does not list stays as an option, marked "(not on the server)", so opening the view never changes a setting. `Test` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live.
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
+7
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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]
+179
View File
@@ -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)
}
+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")
}
}
+1
View File
@@ -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
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)