diff --git a/Dockerfile b/Dockerfile index 3cd13d8..863cc2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN apk add --no-cache git WORKDIR /app # Copy go.mod/go.sum and download dependencies -COPY go.mod ./ +COPY go.mod go.sum ./ RUN go mod download # Copy source code @@ -31,6 +31,7 @@ RUN apt-get update && \ git \ curl \ rsgain \ + flac \ && rm -rf /var/lib/apt/lists/* # Install beets via pip diff --git a/README.md b/README.md index 7bee9bb..c203fb1 100644 --- a/README.md +++ b/README.md @@ -26,5 +26,5 @@ services: ## Quirks -- only works for .flac, .mp3, and .m4a +- only works for .flac and .mp3 - not configurable at all, other than dirs diff --git a/go.mod b/go.mod index 841b967..5470d28 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module github.com/gabehf/music-import go 1.24.2 + +require github.com/bogem/id3v2 v1.2.0 + +require golang.org/x/text v0.3.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dac20e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,5 @@ +github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI= +github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go index 6d4b5a0..dff2e4a 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,8 @@ import ( "strings" "sync" "text/template" + + "github.com/gabehf/music-import/media" ) type MusicMetadata struct { @@ -191,6 +193,13 @@ func RunImporter() { continue } + // embed cover img if available + fmt.Println("→ Applying ReplayGain to album:", albumPath) + if err := media.EmbedAlbumArtIntoFolder(albumPath); err != nil { + fmt.Println("Cover embed failed, skipping album:", err) + continue + } + // Move files to library for _, track := range tracks { if err := moveToLibrary(libraryDir, md, track); err != nil { @@ -198,6 +207,13 @@ func RunImporter() { } } + // Move album cover image + if coverImg, err := media.FindCoverImage(albumPath); err == nil { + if err := moveToLibrary(libraryDir, md, coverImg); err != nil { + fmt.Println("Failed to cover image:", coverImg, err) + } + } + // Remove empty album directory after moving files os.Remove(albumPath) } @@ -217,7 +233,7 @@ func getAudioFiles(dir string) ([]string, error) { continue } ext := strings.ToLower(filepath.Ext(e.Name())) - if ext == ".flac" || ext == ".mp3" || ext == ".m4a" { + if ext == ".flac" || ext == ".mp3" { tracks = append(tracks, filepath.Join(dir, e.Name())) } } diff --git a/media/media.go b/media/media.go new file mode 100644 index 0000000..f429a6f --- /dev/null +++ b/media/media.go @@ -0,0 +1,164 @@ +package media + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + id3v2 "github.com/bogem/id3v2" // optional alternative +) + +var coverNames = []string{ + "cover.jpg", "cover.jpeg", "cover.png", + "folder.jpg", "folder.jpeg", "folder.png", +} + +// EmbedAlbumArtIntoFolder scans one album folder and embeds cover art. +func EmbedAlbumArtIntoFolder(albumDir string) error { + coverFile, err := FindCoverImage(albumDir) + if err != nil { + fmt.Println("Could not find cover image. Skipping embed...") + return nil + } + + coverData, err := os.ReadFile(coverFile) + if err != nil { + return fmt.Errorf("failed to read cover image: %w", err) + } + + err = filepath.Walk(albumDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + lower := strings.ToLower(info.Name()) + switch { + case strings.HasSuffix(lower, ".mp3"): + return embedCoverMP3(path, coverData) + case strings.HasSuffix(lower, ".flac"): + return embedCoverFLAC(path, coverData) + default: + return nil + } + }) + + return err +} + +// ------------------------- +// Find cover image +// ------------------------- +func FindCoverImage(dir string) (string, error) { + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.IsDir() { + continue + } + l := strings.ToLower(e.Name()) + for _, name := range coverNames { + if l == name { + return filepath.Join(dir, e.Name()), nil + } + } + } + return "", fmt.Errorf("no cover image found in %s", dir) +} + +// ------------------------- +// Embed into MP3 +// ------------------------- +func embedCoverMP3(path string, cover []byte) error { + tag, err := id3v2.Open(path, id3v2.Options{Parse: true}) + if err != nil { + return fmt.Errorf("mp3 open: %w", err) + } + defer tag.Close() + + mime := guessMimeType(cover) + + pic := id3v2.PictureFrame{ + Encoding: id3v2.EncodingUTF8, + MimeType: mime, + PictureType: id3v2.PTFrontCover, + Description: "Cover", + Picture: cover, + } + + tag.AddAttachedPicture(pic) + + if err := tag.Save(); err != nil { + return fmt.Errorf("mp3 save: %w", err) + } + + fmt.Println("→ Embedded art into MP3:", filepath.Base(path)) + return nil +} + +// embedCoverFLAC writes cover bytes to a tempfile and uses metaflac to import it. +// Requires `metaflac` (from the flac package) to be installed and in PATH. +func embedCoverFLAC(path string, cover []byte) error { + // Ensure metaflac exists + if _, err := exec.LookPath("metaflac"); err != nil { + return fmt.Errorf("metaflac not found in PATH; please install package 'flac' (provides metaflac): %w", err) + } + + // Create a temp file for the cover image + tmp, err := os.CreateTemp("", "cover-*.img") + if err != nil { + return fmt.Errorf("creating temp file for cover: %w", err) + } + tmpPath := tmp.Name() + // Ensure we remove the temp file later + defer func() { + tmp.Close() + os.Remove(tmpPath) + }() + + // Write cover bytes + if _, err := tmp.Write(cover); err != nil { + return fmt.Errorf("writing cover to temp file: %w", err) + } + if err := tmp.Sync(); err != nil { + // non-fatal, but report if it happens + return fmt.Errorf("sync temp cover file: %w", err) + } + + // Remove existing PICTURE blocks (ignore non-zero exit -> continue, but report) + removeCmd := exec.Command("metaflac", "--remove", "--block-type=PICTURE", path) + removeOut, removeErr := removeCmd.CombinedOutput() + if removeErr != nil { + // metaflac returns non-zero if there were no picture blocks — that's OK. + // Only fail if it's some unexpected error. + // We'll print the output for debugging and continue. + fmt.Printf("metaflac --remove output (may be fine): %s\n", string(removeOut)) + } + + // Import the new picture. metaflac will auto-detect mime type from the file. + importCmd := exec.Command("metaflac", "--import-picture-from="+tmpPath, path) + importOut, importErr := importCmd.CombinedOutput() + if importErr != nil { + return fmt.Errorf("metaflac --import-picture-from failed: %v; output: %s", importErr, string(importOut)) + } + + fmt.Println("→ Embedded art into FLAC:", filepath.Base(path)) + return nil +} + +// ------------------------- +// Helpers +// ------------------------- +func guessMimeType(data []byte) string { + if bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}) { + return "image/jpeg" + } + if bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}) { + return "image/png" + } + return "image/jpeg" // fallback +}