cover-grid album dropdown behavior fixes

This commit is contained in:
2026-02-18 11:34:10 -05:00
parent 026ab1c333
commit c9e78c491c
17 changed files with 934 additions and 424 deletions
+274 -96
View File
@@ -18,42 +18,75 @@ import (
"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"
)
// thumbnailTier defines a single size tier for generated cover art thumbnails.
type thumbnailTier struct {
// Suffix appended to the content hash (e.g. "_sm", "_md", "_lg").
Suffix string
// MaxSize is the maximum width or height in pixels.
MaxSize int
// Quality is the JPEG encoding quality (1-100).
Quality int
}
// thumbnailTiers lists all generated size variants, ordered smallest to largest.
var thumbnailTiers = []thumbnailTier{
{Suffix: "_sm", MaxSize: 100, Quality: 75},
{Suffix: "_md", MaxSize: 200, Quality: 80},
{Suffix: "_lg", MaxSize: 400, Quality: 85},
}
// legacyThumbSuffix is the old single-thumbnail suffix used before the
// multi-tier system. Kept for migration purposes only.
const legacyThumbSuffix = "_thumb"
// isSizedVariant reports whether a filename contains any known size suffix
// (current tiers or legacy).
func isSizedVariant(name string) bool {
if strings.Contains(name, legacyThumbSuffix) {
return true
}
for _, tier := range thumbnailTiers {
if strings.Contains(name, tier.Suffix) {
return true
}
}
return false
}
// 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) {
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
// 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)
return "", fmt.Errorf(
"could not get user data directory: %w", err,
)
}
coverDir := filepath.Join(dataDir, "covers")
// Ensure directory exists
// Ensure directory exists.
if err := os.MkdirAll(coverDir, 0o755); err != nil {
return "", fmt.Errorf("could not create covers directory: %w", err)
return "", fmt.Errorf(
"could not create covers directory: %w", err,
)
}
// Generate filename from content hash (deduplication)
// Generate filename from content hash (deduplication).
hash := sha256.Sum256(pic.Data)
hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars
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)
}
@@ -61,103 +94,157 @@ func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
filePath := filepath.Join(coverDir, filename)
// Skip if already exists (same content hash).
// Missing thumbnails are handled by generateMissingThumbnails() at the end of a scan.
// Missing sized variants are handled by
// generateMissingSizedVariants() at the end of a scan.
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,
)
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)
// 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))
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)
// Generate all sized variants alongside the original.
if err := l.generateSizedVariants(
pic.Data, coverDir, hashStr,
); err != nil {
l.logger.Warn(
"could not generate sized variants",
"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
// generateSizedVariants creates all thumbnail tiers for the given image data.
// Each tier is saved as {hashStr}{suffix}.jpg in the given directory.
func (l *Library) generateSizedVariants(
imgData []byte,
dir, hashStr string,
) error {
src, _, err := image.Decode(bytes.NewReader(imgData))
if err != nil {
return fmt.Errorf("could not decode image for thumbnail: %w", err)
return fmt.Errorf(
"could not decode image for thumbnails: %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)
for _, tier := range thumbnailTiers {
tierPath := filepath.Join(
dir,
fmt.Sprintf("%s%s.jpg", hashStr, tier.Suffix),
)
w, h := fitDimensions(srcW, srcH, tier.MaxSize)
if err := encodeAndSaveImage(
src, tierPath, w, h, tier.Quality,
); err != nil {
l.logger.Warn(
"could not generate sized variant",
"tier", tier.Suffix,
"path", tierPath,
"err", err,
)
continue
}
l.logger.Debug(
"saved sized variant",
"tier", tier.Suffix,
"path", tierPath,
"dimensions", fmt.Sprintf("%dx%d", w, h),
)
}
// 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 {
// fitDimensions calculates the output dimensions that fit within maxSize
// while preserving the aspect ratio. If the source is already smaller
// than maxSize, the original dimensions are returned unchanged.
func fitDimensions(srcW, srcH, maxSize int) (int, int) {
if srcW <= maxSize && srcH <= maxSize {
return srcW, srcH
}
w, h := maxSize, maxSize
if srcW > srcH {
h = srcH * maxSize / srcW
} else {
w = srcW * maxSize / srcH
}
return w, h
}
// encodeAndSaveImage scales the source image to the given dimensions
// and saves it as a JPEG with the specified quality.
func encodeAndSaveImage(
src image.Image,
path string,
w, h, quality 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: quality},
); err != nil {
return fmt.Errorf("could not encode image: %w", err)
}
if err := os.WriteFile(
path, buf.Bytes(), 0o644,
); err != nil {
return fmt.Errorf("could not write image: %w", err)
}
return nil
}
// generateMissingSizedVariants scans the covers directory, migrates legacy
// _thumb files to _md, and generates any missing sized variants for each
// original cover art file.
func (l *Library) generateMissingSizedVariants() error {
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return fmt.Errorf("could not get user data directory: %w", err)
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)
return fmt.Errorf(
"could not read covers directory: %w", err,
)
}
// Build a set of existing filenames for quick lookup.
@@ -169,38 +256,64 @@ func (l *Library) generateMissingThumbnails() error {
}
}
// First pass: migrate legacy _thumb files to _md.
migrated := l.migrateLegacyThumbs(
coverDir, existing,
)
// Second pass: generate missing sized variants.
var generated, skipped int
for _, entry := range entries {
name := entry.Name()
// Skip directories and thumbnails themselves.
if entry.IsDir() || strings.Contains(name, thumbnailSuffix) {
// Skip directories and any sized variants.
if entry.IsDir() || isSizedVariant(name) {
continue
}
thumbName := ThumbnailFilename(name)
if _, exists := existing[thumbName]; exists {
hashStr := strings.SplitN(name, ".", 2)[0]
// Check which tiers are missing.
allPresent := true
for _, tier := range thumbnailTiers {
tierName := fmt.Sprintf(
"%s%s.jpg", hashStr, tier.Suffix,
)
if _, exists := existing[tierName]; !exists {
allPresent = false
break
}
}
if allPresent {
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))
// Read the original and generate missing tiers.
imgData, err := os.ReadFile(
filepath.Join(coverDir, name),
)
if err != nil {
l.logger.Warn(
"could not read cover art for thumbnail generation",
"could not read cover art for variant 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)
if err := l.generateSizedVariants(
imgData, coverDir, hashStr,
); err != nil {
l.logger.Warn(
"could not generate sized variants",
"file", name, "err", err,
)
continue
}
@@ -208,18 +321,83 @@ func (l *Library) generateMissingThumbnails() error {
generated++
}
l.logger.Info("thumbnail generation complete", "generated", generated, "skipped", skipped)
l.logger.Info(
"sized variant generation complete",
"generated", generated,
"skipped", skipped,
"migrated", migrated,
)
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 {
// migrateLegacyThumbs renames _thumb.jpg files to _md.jpg.
// Returns the number of files migrated.
func (l *Library) migrateLegacyThumbs(
coverDir string,
existing map[string]struct{},
) int {
var migrated int
for name := range existing {
if !strings.Contains(name, legacyThumbSuffix) {
continue
}
// Derive the _md name from the legacy name.
mdName := strings.Replace(
name, legacyThumbSuffix, "_md", 1,
)
oldPath := filepath.Join(coverDir, name)
newPath := filepath.Join(coverDir, mdName)
// Only rename if _md doesn't already exist.
if _, exists := existing[mdName]; exists {
// Both exist; remove the legacy file.
if err := os.Remove(oldPath); err != nil {
l.logger.Warn(
"could not remove legacy thumbnail",
"file", name, "err", err,
)
}
continue
}
if err := os.Rename(oldPath, newPath); err != nil {
l.logger.Warn(
"could not migrate legacy thumbnail",
"from", name, "to", mdName, "err", err,
)
continue
}
// Update the existing set so subsequent lookups
// see the new name.
delete(existing, name)
existing[mdName] = struct{}{}
migrated++
l.logger.Debug(
"migrated legacy thumbnail",
"from", name, "to", mdName,
)
}
return migrated
}
// SizedFilename derives a sized-variant filename from an original cover art
// filename and a size suffix.
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
func SizedFilename(originalFilename, suffix string) string {
ext := filepath.Ext(originalFilename)
name := strings.TrimSuffix(originalFilename, ext)
return name + thumbnailSuffix + ".jpg"
return name + suffix + ".jpg"
}
// extensionFromMIME returns a file extension for common image MIME types.
@@ -236,6 +414,6 @@ func extensionFromMIME(mimeType string) string {
case "image/bmp":
return "bmp"
default:
return "jpg" // Default to jpg
return "jpg" // Default to jpg.
}
}
+7 -3
View File
@@ -321,9 +321,13 @@ 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)
// Generate sized variants for any cover art missing them,
// and migrate legacy _thumb files.
if err := l.generateMissingSizedVariants(); err != nil {
l.logger.Warn(
"could not generate missing sized variants",
"err", err,
)
}
l.logger.Info(
+15 -8
View File
@@ -25,12 +25,14 @@ type Track struct {
// Album represents an album for the cover grid display.
type Album struct {
ID int64
Name string
ArtistName string
CoverArtPath string
CoverArtThumbnailPath string
Year int64
ID int64
Name string
ArtistName string
CoverArtPath string
CoverArtSmall string
CoverArtMedium string
CoverArtLarge string
Year int64
}
// GetAllTracks returns an array of track structs of every file in the library.
@@ -123,11 +125,16 @@ func (l *Library) GetAllAlbums() ([]Album, error) {
album.Year = row.Year.Int64
}
// Convert filesystem path to URL path for the asset handler
// Convert filesystem path to URL path for the asset handler.
if row.CoverArtPath != "" {
base := filepath.Base(row.CoverArtPath)
album.CoverArtPath = "/covers/" + base
album.CoverArtThumbnailPath = "/covers/" + ThumbnailFilename(base)
album.CoverArtSmall = "/covers/" +
SizedFilename(base, "_sm")
album.CoverArtMedium = "/covers/" +
SizedFilename(base, "_md")
album.CoverArtLarge = "/covers/" +
SizedFilename(base, "_lg")
}
albums = append(albums, album)
+21 -15
View File
@@ -20,6 +20,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/library"
"yellowjacket/backend/metadata"
)
@@ -598,12 +599,14 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
fileName := filepath.Base(p.currentFile.Name())
filePath := p.currentFile.Name()
// Default values
// Default values.
title := fileName
artist := ""
album := ""
coverArt := ""
coverArtThumbnail := ""
coverArtSmall := ""
coverArtMedium := ""
coverArtLarge := ""
// Try to get metadata from database
if p.db != nil {
@@ -619,11 +622,12 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
if 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"
coverArtSmall = "/covers/" +
library.SizedFilename(base, "_sm")
coverArtMedium = "/covers/" +
library.SizedFilename(base, "_md")
coverArtLarge = "/covers/" +
library.SizedFilename(base, "_lg")
}
} else {
p.logger.Debug(
@@ -634,14 +638,16 @@ 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,
"coverArtThumbnail": coverArtThumbnail,
"fileName": fileName,
"filePath": filePath,
"state": string(p.state),
"title": title,
"artist": artist,
"album": album,
"coverArt": coverArt,
"coverArtSmall": coverArtSmall,
"coverArtMedium": coverArtMedium,
"coverArtLarge": coverArtLarge,
}, nil
}
+17 -11
View File
@@ -29,15 +29,17 @@ type Summary struct {
// Track represents a track within a playlist, including its metadata.
type Track struct {
ID int64 `json:"ID"`
Position int64 `json:"Position"`
FilePath string `json:"FilePath"`
Title string `json:"Title"`
Artist string `json:"Artist"`
Album string `json:"Album"`
CoverArtPath string `json:"CoverArtPath"`
CoverArtThumbnailPath string `json:"CoverArtThumbnailPath"`
Duration string `json:"Duration"`
ID int64 `json:"ID"`
Position int64 `json:"Position"`
FilePath string `json:"FilePath"`
Title string `json:"Title"`
Artist string `json:"Artist"`
Album string `json:"Album"`
CoverArtPath string `json:"CoverArtPath"`
CoverArtSmall string `json:"CoverArtSmall"`
CoverArtMedium string `json:"CoverArtMedium"`
CoverArtLarge string `json:"CoverArtLarge"`
Duration string `json:"Duration"`
}
// WithTracks contains a playlist summary and all its tracks.
@@ -213,8 +215,12 @@ func trackFromRow(
if coverArtPath != "" {
base := filepath.Base(coverArtPath)
track.CoverArtPath = "/covers/" + base
track.CoverArtThumbnailPath = "/covers/" +
library.ThumbnailFilename(base)
track.CoverArtSmall = "/covers/" +
library.SizedFilename(base, "_sm")
track.CoverArtMedium = "/covers/" +
library.SizedFilename(base, "_md")
track.CoverArtLarge = "/covers/" +
library.SizedFilename(base, "_lg")
}
return track