208 lines
5.8 KiB
Go
208 lines
5.8 KiB
Go
// 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
|
|
}
|