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
|
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user