Files
arr-tui/internal/ui/diskcache.go
T
nilsandClaude Opus 5.5 f0bc284e82 Trakt lists: keep the Radarr/Sonarr lookups of their titles on disk
Library membership is asked every time via movie?tmdbId= / series?tvdbId=;
only the lookups of titles not in the library are stored (3 days).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:48:20 +02:00

228 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// postersDir names the poster folder for the current way of storing them
// (v2: as decoded, at the size used). Folders of older ways are deleted by
// prunePosters: their files would otherwise be used as they are (v1 kept
// Radarr's 333×500 copies for real images).
postersDir = "posters-v2"
)
// oldPosterDirs are poster folders of earlier versions.
var oldPosterDirs = []string{"posters"}
// 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, postersDir, 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
}
for _, old := range oldPosterDirs {
_ = os.RemoveAll(filepath.Join(d.dir, old))
}
var files []file
var total int64
_ = filepath.WalkDir(filepath.Join(d.dir, postersDir), 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)
}
func (d *diskCache) lookupsPath() string { return filepath.Join(d.dir, "lookups.json") }
// loadLookups reads the stored Radarr/Sonarr lookups that are not too old.
func (d *diskCache) loadLookups() map[string]savedLookup {
out := map[string]savedLookup{}
if d.dir == "" {
return out
}
data, err := os.ReadFile(d.lookupsPath())
if err != nil {
return out
}
var all map[string]savedLookup
if json.Unmarshal(data, &all) != nil {
return out // unreadable: start afresh, it is only a cache
}
for k, l := range all {
if time.Since(l.Saved) <= lookupTTL {
out[k] = l
}
}
return out
}
// saveLookups writes the lookups.
func (d *diskCache) saveLookups(all map[string]savedLookup) error {
if d.dir == "" {
return nil
}
data, err := json.Marshal(all)
if err != nil {
return err
}
return writeAtomic(d.lookupsPath(), 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)
}