package artwork
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/zeebo/xxh3"
)
type ImageStore struct {
root string
}
func NewImageStore(rootDir string) *ImageStore {
return &ImageStore{root: rootDir}
}
func GetImageStore() *ImageStore {
return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder))
}
func extForMime(m string) string {
switch m {
case "image/jpeg":
return ".jpg"
case "image/png":
return ".png"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
}
return ".img"
}
func hashImage(r io.Reader) (string, error) {
d := xxh3.New()
if _, err := io.Copy(d, r); err != nil {
return "", err
}
return fmt.Sprintf("%016x", d.Sum64()), nil
}
func validHash(hash string) bool {
if len(hash) != 16 {
return false
}
for _, c := range []byte(hash) {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
func (s *ImageStore) path(hash, mimeType string) string {
return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType))
}
func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error {
if !validHash(hash) {
return fmt.Errorf("imagestore: invalid hash %q", hash)
}
dst := s.path(hash, mimeType)
if _, err := os.Stat(dst); err == nil {
now := time.Now()
if err := os.Chtimes(dst, now, now); err == nil {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0640); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) {
if !validHash(hash) {
return nil, fmt.Errorf("imagestore: invalid hash %q", hash)
}
return os.Open(s.path(hash, mimeType))
}
func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) {
removed, failed := 0, 0
var lastErr error
err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
if err := ctx.Err(); err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
if info.ModTime().After(cutoff) {
return nil
}
name := d.Name()
remove := strings.HasPrefix(name, ".")
if !remove {
ext := filepath.Ext(name)
remove = !keep(strings.TrimSuffix(name, ext), ext)
}
if remove {
if err := os.Remove(path); err != nil {
failed, lastErr = failed+1, err
return nil
}
removed++
}
return nil
})
if failed > 0 {
log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr)
}
if errors.Is(err, fs.ErrNotExist) {
return removed, nil
}
return removed, err
}