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>
228 lines
5.8 KiB
Go
228 lines
5.8 KiB
Go
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)
|
||
}
|