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) } // 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) }