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 package library
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"image"
"image/jpeg"
_ "image/png" // Register PNG decoder.
"os" "os"
"path/filepath" "path/filepath"
"strings"
"golang.org/x/image/draw"
"yellowjacket/backend/metadata" "yellowjacket/backend/metadata"
"yellowjacket/backend/system" "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. // 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. // 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) { 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) filename := fmt.Sprintf("%s.%s", hashStr, ext)
filePath := filepath.Join(coverDir, filename) 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 { if _, err := os.Stat(filePath); err == nil {
l.logger.Debug("cover art already exists", "path", filePath) 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)) 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 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. // extensionFromMIME returns a file extension for common image MIME types.
func extensionFromMIME(mimeType string) string { func extensionFromMIME(mimeType string) string {
switch mimeType { switch mimeType {
+4
View File
@@ -37,6 +37,10 @@ func (h *CoverArtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return 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) filePath := filepath.Join(h.coversDir, filename)
http.ServeFile(w, r, filePath) http.ServeFile(w, r, filePath)
} }
+5
View File
@@ -321,6 +321,11 @@ func (l *Library) Scan() error {
return true 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( l.logger.Info(
"library scan complete", "library scan complete",
"added", added.Load(), "added", added.Load(),
+9 -6
View File
@@ -23,11 +23,12 @@ type Track struct {
// Album represents an album for the cover grid display. // Album represents an album for the cover grid display.
type Album struct { type Album struct {
ID int64 ID int64
Name string Name string
ArtistName string ArtistName string
CoverArtPath string CoverArtPath string
Year int64 CoverArtThumbnailPath string
Year int64
} }
// GetAllTracks returns an array of track structs of every file in the library. // 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 // Convert filesystem path to URL path for the asset handler
if row.CoverArtPath != "" { 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) albums = append(albums, album)
+16 -8
View File
@@ -548,6 +548,7 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
artist := "" artist := ""
album := "" album := ""
coverArt := "" coverArt := ""
coverArtThumbnail := ""
// Try to get metadata from database // Try to get metadata from database
if p.db != nil { if p.db != nil {
@@ -561,7 +562,13 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
album = meta.Album album = meta.Album
if meta.CoverArtPath != "" { 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 { } else {
p.logger.Debug("Could not get track metadata from database", "path", filePath, "err", err) 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{}{ return map[string]interface{}{
"fileName": fileName, "fileName": fileName,
"filePath": filePath, "filePath": filePath,
"state": string(p.state), "state": string(p.state),
"title": title, "title": title,
"artist": artist, "artist": artist,
"album": album, "album": album,
"coverArt": coverArt, "coverArt": coverArt,
"coverArtThumbnail": coverArtThumbnail,
}, nil }, nil
} }
@@ -278,9 +278,15 @@ export class CoverGrid extends LitElement {
${album.CoverArtPath ${album.CoverArtPath
? html`<img ? html`<img
class="cover-image" class="cover-image"
src="${album.CoverArtPath}" src="${album.CoverArtThumbnailPath || album.CoverArtPath}"
alt="${album.Name} cover" alt="${album.Name} cover"
loading="lazy" loading="lazy"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
if (img.src !== album.CoverArtPath) {
img.src = album.CoverArtPath;
}
}}
/>` />`
: html`<div class="placeholder-cover"> : html`<div class="placeholder-cover">
${this.getAlbumInitial(album.Name)} ${this.getAlbumInitial(album.Name)}
@@ -85,7 +85,16 @@ export class NowPlaying extends LitElement {
<div class="now-playing"> <div class="now-playing">
<div class="cover-art"> <div class="cover-art">
${track.coverArt ${track.coverArt
? html`<img src="${track.coverArt}" alt="Album cover" />` ? html`<img
src="${track.coverArtThumbnail || track.coverArt}"
alt="Album cover"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
if (track.coverArt && img.src !== track.coverArt) {
img.src = track.coverArt;
}
}}
/>`
: html`<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>`} : html`<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>`}
</div> </div>
<div class="track-info"> <div class="track-info">
+1
View File
@@ -12,6 +12,7 @@ export interface TrackInfo {
artist: string; // artist name artist: string; // artist name
album: string; // album name album: string; // album name
coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string
coverArtThumbnail: string; // URL path to thumbnail (e.g., "/covers/abc_thumb.jpg") or empty string
} }
export interface PlayerState { export interface PlayerState {
+2
View File
@@ -5,6 +5,7 @@ export namespace library {
Name: string; Name: string;
ArtistName: string; ArtistName: string;
CoverArtPath: string; CoverArtPath: string;
CoverArtThumbnailPath: string;
Year: number; Year: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -17,6 +18,7 @@ export namespace library {
this.Name = source["Name"]; this.Name = source["Name"];
this.ArtistName = source["ArtistName"]; this.ArtistName = source["ArtistName"];
this.CoverArtPath = source["CoverArtPath"]; this.CoverArtPath = source["CoverArtPath"];
this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"];
this.Year = source["Year"]; this.Year = source["Year"];
} }
} }