// Package coverart discovers cover art (embedded artwork or folder // images) and caches resized copies on disk, so the same image is never // re-resized for every request. package coverart import ( "context" "crypto/sha256" "encoding/hex" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "io" "os" "os/exec" "path/filepath" "golang.org/x/image/draw" ) // maxDecodePixels bounds the decoded pixel count of a source image so a // small, highly-compressed file (a "decompression bomb") can't force an // enormous in-memory pixel buffer allocation. 40 megapixels comfortably // covers any legitimate cover art. const maxDecodePixels = 40_000_000 // commonFolderImageNames are checked, in priority order, for a // folder-level cover image when a file has no embedded artwork. var commonFolderImageNames = []string{ "cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.jpeg", "folder.png", "front.jpg", "front.jpeg", "front.png", "albumart.jpg", "albumart.png", } // FindFolderImage returns the first common cover image filename found // directly inside dir, or "" if none exists. func FindFolderImage(dir string) string { for _, name := range commonFolderImageNames { path := filepath.Join(dir, name) if info, err := os.Stat(path); err == nil && !info.IsDir() { return path } } return "" } // Store manages the on-disk resized-image cache under cacheDir. type Store struct { CacheDir string FFmpegPath string MaxSourceBytes int64 // guard against absurd source images before decoding } func New(cacheDir, ffmpegPath string) *Store { if ffmpegPath == "" { ffmpegPath = "ffmpeg" } return &Store{CacheDir: cacheDir, FFmpegPath: ffmpegPath, MaxSourceBytes: 32 * 1024 * 1024} } // ExtractEmbedded pulls the attached-picture stream out of a media file // into a temporary file using ffmpeg, returning its path. The caller owns // cleanup. func (s *Store) ExtractEmbedded(ctx context.Context, mediaPath string, streamIndex int) (string, error) { tmp, err := os.CreateTemp("", "vibesonic-art-*.img") if err != nil { return "", err } tmp.Close() cmd := exec.CommandContext(ctx, s.FFmpegPath, "-y", "-v", "error", "-i", mediaPath, "-map", fmt.Sprintf("0:%d", streamIndex), "-frames:v", "1", "-c:v", "copy", // Temporary files deliberately have a neutral .img suffix. Tell // ffmpeg which muxer to use instead of asking it to infer one from // that suffix. ffmpeg does not accept `--` as an end-of-options // marker, so including it here made every embedded-art request fail. "-f", "image2", tmp.Name(), ) if out, err := cmd.CombinedOutput(); err != nil { os.Remove(tmp.Name()) return "", fmt.Errorf("ffmpeg extract art: %w: %s", err, string(out)) } return tmp.Name(), nil } // CachedPath returns the on-disk path for a resized variant of the image // identified by contentHash at the given target size, without generating // it. func (s *Store) CachedPath(contentHash string, size int) string { return filepath.Join(s.CacheDir, fmt.Sprintf("%s_%d.jpg", contentHash, size)) } // GetOrCreate returns a reader-ready path to a JPEG resized so its // longest side is at most `size` pixels, generating and caching it from // sourcePath on first request. size <= 0 returns the original image // unresized (still copied through the cache so repeat requests are // cheap). func (s *Store) GetOrCreate(sourcePath, contentHash string, size int) (string, error) { if err := os.MkdirAll(s.CacheDir, 0o755); err != nil { return "", err } cached := s.CachedPath(contentHash, size) if _, err := os.Stat(cached); err == nil { return cached, nil } if info, err := os.Stat(sourcePath); err != nil { return "", err } else if s.MaxSourceBytes > 0 && info.Size() > s.MaxSourceBytes { return "", fmt.Errorf("source image %s exceeds %d byte limit", sourcePath, s.MaxSourceBytes) } src, err := os.Open(sourcePath) if err != nil { return "", err } defer src.Close() if cfg, _, err := image.DecodeConfig(src); err == nil { if cfg.Width*cfg.Height > maxDecodePixels { return "", fmt.Errorf("source image %s exceeds %d pixel limit (%dx%d)", sourcePath, maxDecodePixels, cfg.Width, cfg.Height) } } if _, err := src.Seek(0, io.SeekStart); err != nil { return "", err } img, _, err := image.Decode(src) if err != nil { return "", fmt.Errorf("decoding image: %w", err) } resized := img if size > 0 { resized = resize(img, size) } tmpPath := cached + ".tmp" out, err := os.Create(tmpPath) if err != nil { return "", err } if err := jpeg.Encode(out, resized, &jpeg.Options{Quality: 85}); err != nil { out.Close() os.Remove(tmpPath) return "", err } if err := out.Close(); err != nil { return "", err } if err := os.Rename(tmpPath, cached); err != nil { return "", err } return cached, nil } // resize scales img so its longest side is at most maxSize pixels, // preserving aspect ratio. Images already smaller are left unscaled. func resize(img image.Image, maxSize int) image.Image { b := img.Bounds() w, h := b.Dx(), b.Dy() if w <= maxSize && h <= maxSize { return img } var newW, newH int if w >= h { newW = maxSize newH = h * maxSize / w } else { newH = maxSize newW = w * maxSize / h } if newW < 1 { newW = 1 } if newH < 1 { newH = 1 } dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil) return dst } // ContentHashOf returns a stable cache key for a file's current content, // cheap enough to compute per-request by hashing size+mtime rather than // the full file body. func ContentHashOf(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() info, err := f.Stat() if err != nil { return "", err } h := sha256.New() fmt.Fprintf(h, "%s-%d-%d", path, info.Size(), info.ModTime().Unix()) return hex.EncodeToString(h.Sum(nil))[:16], nil }