Cover art thumbnails

library scan creates thumbnails, cover grid component defaults to
thumbnail if available.
This commit is contained in:
2026-02-14 15:25:12 -05:00
parent 7579a768be
commit 38f3abbd3e
9 changed files with 213 additions and 17 deletions
+159 -1
View File
@@ -1,16 +1,32 @@
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 = 256
// 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) {
@@ -44,7 +60,8 @@ func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
filename := fmt.Sprintf("%s.%s", hashStr, ext)
filePath := filepath.Join(coverDir, filename)
// Skip if already exists (same content hash)
// 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)
@@ -58,9 +75,150 @@ func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
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 {
+4
View File
@@ -37,6 +37,10 @@ func (h *CoverArtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Filenames are content-hashed (SHA-256), so they are immutable.
// Set aggressive cache headers to avoid redundant re-fetches.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
filePath := filepath.Join(h.coversDir, filename)
http.ServeFile(w, r, filePath)
}
+5
View File
@@ -321,6 +321,11 @@ func (l *Library) Scan() error {
return true
})
// Generate thumbnails for any cover art that doesn't have one yet.
if err := l.generateMissingThumbnails(); err != nil {
l.logger.Warn("could not generate missing thumbnails", "err", err)
}
l.logger.Info(
"library scan complete",
"added", added.Load(),
+9 -6
View File
@@ -23,11 +23,12 @@ type Track struct {
// Album represents an album for the cover grid display.
type Album struct {
ID int64
Name string
ArtistName string
CoverArtPath string
Year int64
ID int64
Name string
ArtistName string
CoverArtPath string
CoverArtThumbnailPath string
Year int64
}
// GetAllTracks returns an array of track structs of every file in the library.
@@ -117,7 +118,9 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
// Convert filesystem path to URL path for the asset handler
if row.CoverArtPath != "" {
album.CoverArtPath = "/covers/" + filepath.Base(row.CoverArtPath)
base := filepath.Base(row.CoverArtPath)
album.CoverArtPath = "/covers/" + base
album.CoverArtThumbnailPath = "/covers/" + ThumbnailFilename(base)
}
albums = append(albums, album)