Streaming with range requests and path safety, cover art discovery/resize cache

This commit is contained in:
2026-08-22 23:33:00 -05:00
parent 49dbee617a
commit aa29f13846
6 changed files with 556 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
// 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"
"os"
"os/exec"
"path/filepath"
"golang.org/x/image/draw"
)
// 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",
"--", 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()
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
}
+149
View File
@@ -0,0 +1,149 @@
package coverart
import (
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"testing"
)
func writeTestJPEG(t *testing.T, path string, w, h int) {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 100, A: 255})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := jpeg.Encode(f, img, nil); err != nil {
t.Fatal(err)
}
}
func TestFindFolderImagePriority(t *testing.T) {
dir := t.TempDir()
writeTestJPEG(t, filepath.Join(dir, "folder.jpg"), 4, 4)
writeTestJPEG(t, filepath.Join(dir, "cover.jpg"), 4, 4)
got := FindFolderImage(dir)
if filepath.Base(got) != "cover.jpg" {
t.Errorf("FindFolderImage = %q, want cover.jpg to take priority over folder.jpg", got)
}
}
func TestFindFolderImageNone(t *testing.T) {
dir := t.TempDir()
if got := FindFolderImage(dir); got != "" {
t.Errorf("FindFolderImage = %q, want empty when no cover exists", got)
}
}
func TestGetOrCreateResizesAndCaches(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "source.jpg")
writeTestJPEG(t, srcPath, 800, 600)
cacheDir := filepath.Join(dir, "cache")
store := New(cacheDir, "")
path1, err := store.GetOrCreate(srcPath, "hash1", 200)
if err != nil {
t.Fatalf("GetOrCreate: %v", err)
}
info1, err := os.Stat(path1)
if err != nil {
t.Fatalf("stat cached file: %v", err)
}
f, err := os.Open(path1)
if err != nil {
t.Fatal(err)
}
cfg, _, err := image.DecodeConfig(f)
f.Close()
if err != nil {
t.Fatalf("decoding resized image: %v", err)
}
if cfg.Width != 200 {
t.Errorf("resized width = %d, want 200", cfg.Width)
}
if cfg.Height >= 600 {
t.Errorf("resized height = %d, should be scaled down proportionally", cfg.Height)
}
// Second call should hit the cache: same path, same mtime (not regenerated).
path2, err := store.GetOrCreate(srcPath, "hash1", 200)
if err != nil {
t.Fatalf("GetOrCreate (2nd): %v", err)
}
if path1 != path2 {
t.Errorf("cached path changed: %q -> %q", path1, path2)
}
info2, err := os.Stat(path2)
if err != nil {
t.Fatal(err)
}
if !info1.ModTime().Equal(info2.ModTime()) {
t.Error("second GetOrCreate call regenerated the cached file instead of reusing it")
}
}
func TestGetOrCreateSmallImageNotUpscaled(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "small.jpg")
writeTestJPEG(t, srcPath, 50, 50)
store := New(filepath.Join(dir, "cache"), "")
path, err := store.GetOrCreate(srcPath, "hash-small", 500)
if err != nil {
t.Fatalf("GetOrCreate: %v", err)
}
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
t.Fatal(err)
}
if cfg.Width != 50 || cfg.Height != 50 {
t.Errorf("size = %dx%d, want unscaled 50x50", cfg.Width, cfg.Height)
}
}
func TestGetOrCreateRejectsOversizedSource(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "big.jpg")
writeTestJPEG(t, srcPath, 200, 200)
info, err := os.Stat(srcPath)
if err != nil {
t.Fatal(err)
}
store := New(filepath.Join(dir, "cache"), "")
store.MaxSourceBytes = info.Size() - 1 // just under the real file size
if _, err := store.GetOrCreate(srcPath, "hash-big", 50); err == nil {
t.Fatal("expected GetOrCreate to reject a source image over MaxSourceBytes")
}
}
func TestGetOrCreateAllowsUnderLimit(t *testing.T) {
dir := t.TempDir()
srcPath := filepath.Join(dir, "ok.jpg")
writeTestJPEG(t, srcPath, 10, 10)
store := New(filepath.Join(dir, "cache"), "")
if _, err := store.GetOrCreate(srcPath, "hash-ok", 5); err != nil {
t.Fatalf("GetOrCreate should succeed under the default limit: %v", err)
}
}