From 2f7034fae238b1ccda0c99e5c1a0651379a498b9 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 2 Apr 2026 22:43:11 +0300 Subject: [PATCH] feat: extract embedded album artwork during scans Add a dedicated artwork cache directory and save embedded album art from audio tags during library scans. Prefer embedded artwork for album cover resolution while keeping sidecar image files as the fallback path. --- .env.example | 1 + cmd/server/main.go | 2 +- internal/config/config.go | 2 ++ internal/scanner/service.go | 51 ++++++++++++++++++++++++++++++++++--- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index c2c9fc2..edbcad8 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ APP_ENV=development SERVER_HOST=0.0.0.0 SERVER_PORT=4040 DATABASE_PATH=./data/app.db +ARTWORK_CACHE_DIR=./data/artwork MEDIA_ROOT=./media CORS_ORIGINS=http://localhost:5173 DEFAULT_ADMIN_USERNAME=demo diff --git a/cmd/server/main.go b/cmd/server/main.go index 88ceca4..5cde936 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -33,7 +33,7 @@ func main() { log.Fatalf("database seed failed: %v", err) } - scanService := scanner.NewService(database, cfg.MediaRoot) + scanService := scanner.NewService(database, cfg.MediaRoot, cfg.ArtworkCacheDir) if scanService.HasMediaFiles() { if result, err := scanService.Scan(ctx); err != nil { log.Printf("startup scan failed: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index 8593312..39b55e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ type Config struct { ServerHost string ServerPort string DatabasePath string + ArtworkCacheDir string MediaRoot string CORSOrigins string DefaultAdminUsername string @@ -19,6 +20,7 @@ func Load() Config { ServerHost: getenv("SERVER_HOST", "0.0.0.0"), ServerPort: getenv("SERVER_PORT", "4040"), DatabasePath: getenv("DATABASE_PATH", "./data/app.db"), + ArtworkCacheDir: getenv("ARTWORK_CACHE_DIR", "./data/artwork"), MediaRoot: getenv("MEDIA_ROOT", "./media"), CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"), DefaultAdminUsername: getenv("DEFAULT_ADMIN_USERNAME", "demo"), diff --git a/internal/scanner/service.go b/internal/scanner/service.go index 1f4edbb..3f6342c 100644 --- a/internal/scanner/service.go +++ b/internal/scanner/service.go @@ -38,6 +38,7 @@ type Result struct { type Service struct { db *sql.DB mediaRoot string + artworkRoot string mu sync.RWMutex status Status } @@ -78,8 +79,8 @@ type scannedTrack struct { ContentType string } -func NewService(db *sql.DB, mediaRoot string) *Service { - return &Service{db: db, mediaRoot: mediaRoot} +func NewService(db *sql.DB, mediaRoot, artworkRoot string) *Service { + return &Service{db: db, mediaRoot: mediaRoot, artworkRoot: artworkRoot} } func (s *Service) HasMediaFiles() bool { @@ -327,7 +328,13 @@ func (s *Service) scanFile(path string) (scannedItem, error) { artistID := hashID("artist", artistName) albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year)) trackID := hashID("track", path) - coverArt := findCoverArt(filepath.Dir(path)) + coverArt := "" + if err == nil { + coverArt = s.extractEmbeddedCoverArt(metadata, albumID) + } + if coverArt == "" { + coverArt = findCoverArt(filepath.Dir(path)) + } return scannedItem{ artist: scannedArtist{ @@ -417,6 +424,44 @@ func findCoverArt(dir string) string { return "" } +func (s *Service) extractEmbeddedCoverArt(metadata tag.Metadata, albumID string) string { + if metadata == nil { + return "" + } + + picture := metadata.Picture() + if picture == nil || len(picture.Data) == 0 { + return "" + } + + if s.artworkRoot == "" { + return "" + } + + if err := os.MkdirAll(s.artworkRoot, 0o755); err != nil { + return "" + } + + filename := albumID + coverExtension(picture.MIMEType) + path := filepath.Join(s.artworkRoot, filename) + if err := os.WriteFile(path, picture.Data, 0o644); err != nil { + return "" + } + + return path +} + +func coverExtension(mimeType string) string { + switch strings.ToLower(mimeType) { + case "image/png": + return ".png" + case "image/webp": + return ".webp" + default: + return ".jpg" + } +} + func (s *Service) tryMarkStarted() bool { s.mu.Lock() defer s.mu.Unlock()