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`${album.Name} cover { + const img = e.target as HTMLImageElement; + if (img.src !== album.CoverArtPath) { + img.src = album.CoverArtPath; + } + }} />` : html`
${this.getAlbumInitial(album.Name)} diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 6c60806..4ea0ca9 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -85,7 +85,16 @@ export class NowPlaying extends LitElement {
${track.coverArt - ? html`Album cover` + ? html`Album cover { + const img = e.target as HTMLImageElement; + if (track.coverArt && img.src !== track.coverArt) { + img.src = track.coverArt; + } + }} + />` : html`
`}
diff --git a/frontend/src/store/player-store.ts b/frontend/src/store/player-store.ts index fa38e29..a87e4c0 100644 --- a/frontend/src/store/player-store.ts +++ b/frontend/src/store/player-store.ts @@ -12,6 +12,7 @@ export interface TrackInfo { artist: string; // artist name album: string; // album name 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 { diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 9739106..267b08b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -5,6 +5,7 @@ export namespace library { Name: string; ArtistName: string; CoverArtPath: string; + CoverArtThumbnailPath: string; Year: number; static createFrom(source: any = {}) { @@ -17,6 +18,7 @@ export namespace library { this.Name = source["Name"]; this.ArtistName = source["ArtistName"]; this.CoverArtPath = source["CoverArtPath"]; + this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"]; this.Year = source["Year"]; } }