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()