Cover art thumbnails
library scan creates thumbnails, cover grid component defaults to thumbnail if available.
This commit is contained in:
+159
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user