diff --git a/backend/library/coverart.go b/backend/library/coverart.go
index c2b62dd..86bfed6 100644
--- a/backend/library/coverart.go
+++ b/backend/library/coverart.go
@@ -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 {
diff --git a/backend/library/coverart_handler.go b/backend/library/coverart_handler.go
index 2ab3e89..0e1ffc3 100644
--- a/backend/library/coverart_handler.go
+++ b/backend/library/coverart_handler.go
@@ -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)
}
diff --git a/backend/library/library.go b/backend/library/library.go
index 0d739bd..a6bf945 100644
--- a/backend/library/library.go
+++ b/backend/library/library.go
@@ -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(),
diff --git a/backend/library/query.go b/backend/library/query.go
index ca33335..9d7c569 100644
--- a/backend/library/query.go
+++ b/backend/library/query.go
@@ -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)
diff --git a/backend/player/player.go b/backend/player/player.go
index d491d11..4e2682d 100644
--- a/backend/player/player.go
+++ b/backend/player/player.go
@@ -548,6 +548,7 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
artist := ""
album := ""
coverArt := ""
+ coverArtThumbnail := ""
// Try to get metadata from database
if p.db != nil {
@@ -561,7 +562,13 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
album = meta.Album
if meta.CoverArtPath != "" {
- coverArt = "/covers/" + filepath.Base(meta.CoverArtPath)
+ base := filepath.Base(meta.CoverArtPath)
+ coverArt = "/covers/" + base
+
+ // Derive thumbnail filename from original: hash.ext -> hash_thumb.jpg
+ ext := filepath.Ext(base)
+ name := base[:len(base)-len(ext)]
+ coverArtThumbnail = "/covers/" + name + "_thumb.jpg"
}
} else {
p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err)
@@ -569,13 +576,14 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
}
return map[string]interface{}{
- "fileName": fileName,
- "filePath": filePath,
- "state": string(p.state),
- "title": title,
- "artist": artist,
- "album": album,
- "coverArt": coverArt,
+ "fileName": fileName,
+ "filePath": filePath,
+ "state": string(p.state),
+ "title": title,
+ "artist": artist,
+ "album": album,
+ "coverArt": coverArt,
+ "coverArtThumbnail": coverArtThumbnail,
}, nil
}
diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts
index 304511c..0283f4a 100644
--- a/frontend/src/components/cover-grid/cover-grid.ts
+++ b/frontend/src/components/cover-grid/cover-grid.ts
@@ -278,9 +278,15 @@ export class CoverGrid extends LitElement {
${album.CoverArtPath
? html` {
+ const img = e.target as HTMLImageElement;
+ if (img.src !== album.CoverArtPath) {
+ img.src = album.CoverArtPath;
+ }
+ }}
/>`
: html`