Files
yellowjacket/backend/library/coverart.go
T
yonlu 481beca00a frontend changes
-smaller thumbnails
-GPU acceleration
-lazy loading/ rendering only visible elements
-cover grid shows album tracks on click
2026-02-16 22:42:13 -05:00

242 lines
6.5 KiB
Go

package library
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/jpeg"
_ "image/png" // Register PNG decoder.
"os"
"path/filepath"
"strings"
"golang.org/x/image/draw"
"yellowjacket/backend/metadata"
"yellowjacket/backend/system"
)
const (
// thumbnailMaxSize is the maximum width/height for generated thumbnails.
thumbnailMaxSize = 200
// thumbnailQuality is the JPEG encoding quality for thumbnails.
thumbnailQuality = 80
// thumbnailSuffix is appended to the content hash for thumbnail filenames.
thumbnailSuffix = "_thumb"
)
// saveCoverArt saves embedded cover art to the cache directory.
// Returns the file path where the art was saved, or empty string if no picture data.
func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
if pic == nil || len(pic.Data) == 0 {
return "", nil
}
// Get the data directory for storing cover art
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return "", fmt.Errorf("could not get user data directory: %w", err)
}
coverDir := filepath.Join(dataDir, "covers")
// Ensure directory exists
if err := os.MkdirAll(coverDir, 0o755); err != nil {
return "", fmt.Errorf("could not create covers directory: %w", err)
}
// Generate filename from content hash (deduplication)
hash := sha256.Sum256(pic.Data)
hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars
ext := pic.Ext
if ext == "" {
// Determine extension from MIME type
ext = extensionFromMIME(pic.MIMEType)
}
filename := fmt.Sprintf("%s.%s", hashStr, ext)
filePath := filepath.Join(coverDir, filename)
// Skip if already exists (same content hash).
// Missing thumbnails are handled by generateMissingThumbnails() at the end of a scan.
if _, err := os.Stat(filePath); err == nil {
l.logger.Debug("cover art already exists", "path", filePath)
return filePath, nil
}
// Write file
if err := os.WriteFile(filePath, pic.Data, 0o644); err != nil {
return "", fmt.Errorf("could not write cover art: %w", err)
}
l.logger.Debug("saved cover art", "path", filePath, "size", len(pic.Data))
// Generate thumbnail alongside the original
if err := l.generateThumbnail(pic.Data, coverDir, hashStr); err != nil {
l.logger.Warn("could not generate thumbnail", "path", filePath, "err", err)
}
return filePath, nil
}
// generateThumbnail creates a downscaled JPEG thumbnail from cover art image data.
// The thumbnail is saved as {hashStr}_thumb.jpg in the given directory.
func (l *Library) generateThumbnail(imgData []byte, dir, hashStr string) error {
thumbFilename := fmt.Sprintf("%s%s.jpg", hashStr, thumbnailSuffix)
thumbPath := filepath.Join(dir, thumbFilename)
// Decode the source image
src, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return fmt.Errorf("could not decode image for thumbnail: %w", err)
}
// Calculate thumbnail dimensions preserving aspect ratio
bounds := src.Bounds()
srcW := bounds.Dx()
srcH := bounds.Dy()
// Skip if image is already smaller than the thumbnail size
if srcW <= thumbnailMaxSize && srcH <= thumbnailMaxSize {
// Still save a JPEG copy for consistent serving
return l.encodeAndSaveThumbnail(src, thumbPath, srcW, srcH)
}
// Scale down preserving aspect ratio
thumbW, thumbH := thumbnailMaxSize, thumbnailMaxSize
if srcW > srcH {
thumbH = srcH * thumbnailMaxSize / srcW
} else {
thumbW = srcW * thumbnailMaxSize / srcH
}
return l.encodeAndSaveThumbnail(src, thumbPath, thumbW, thumbH)
}
// encodeAndSaveThumbnail scales the source image to the given dimensions and saves as JPEG.
func (l *Library) encodeAndSaveThumbnail(src image.Image, path string, w, h int) error {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: thumbnailQuality}); err != nil {
return fmt.Errorf("could not encode thumbnail: %w", err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return fmt.Errorf("could not write thumbnail: %w", err)
}
l.logger.Debug(
"saved thumbnail",
"path",
path,
"size",
buf.Len(),
"dimensions",
fmt.Sprintf("%dx%d", w, h),
)
return nil
}
// generateMissingThumbnails scans the covers directory and generates thumbnails
// for any original cover art files that do not yet have a corresponding _thumb.jpg.
func (l *Library) generateMissingThumbnails() error {
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return fmt.Errorf("could not get user data directory: %w", err)
}
coverDir := filepath.Join(dataDir, "covers")
entries, err := os.ReadDir(coverDir)
if err != nil {
return fmt.Errorf("could not read covers directory: %w", err)
}
// Build a set of existing filenames for quick lookup.
existing := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
existing[entry.Name()] = struct{}{}
}
}
var generated, skipped int
for _, entry := range entries {
name := entry.Name()
// Skip directories and thumbnails themselves.
if entry.IsDir() || strings.Contains(name, thumbnailSuffix) {
continue
}
thumbName := ThumbnailFilename(name)
if _, exists := existing[thumbName]; exists {
skipped++
continue
}
// Extract hash from filename (everything before the first dot).
hashStr := strings.SplitN(name, ".", 2)[0]
imgData, err := os.ReadFile(filepath.Join(coverDir, name))
if err != nil {
l.logger.Warn(
"could not read cover art for thumbnail generation",
"file", name, "err", err,
)
continue
}
if err := l.generateThumbnail(imgData, coverDir, hashStr); err != nil {
l.logger.Warn("could not generate thumbnail", "file", name, "err", err)
continue
}
generated++
}
l.logger.Info("thumbnail generation complete", "generated", generated, "skipped", skipped)
return nil
}
// ThumbnailFilename derives the thumbnail filename from an original cover art filename.
// For example, "a1b2c3d4.jpg" becomes "a1b2c3d4_thumb.jpg".
func ThumbnailFilename(originalFilename string) string {
ext := filepath.Ext(originalFilename)
name := strings.TrimSuffix(originalFilename, ext)
return name + thumbnailSuffix + ".jpg"
}
// extensionFromMIME returns a file extension for common image MIME types.
func extensionFromMIME(mimeType string) string {
switch mimeType {
case "image/jpeg":
return "jpg"
case "image/png":
return "png"
case "image/gif":
return "gif"
case "image/webp":
return "webp"
case "image/bmp":
return "bmp"
default:
return "jpg" // Default to jpg
}
}