embed & move covers, remove m4a support

This commit is contained in:
Gabe Farrell 2025-11-15 13:05:24 -05:00
parent 9de7dc83d8
commit 6480f93bb5
6 changed files with 193 additions and 3 deletions

View file

@ -8,7 +8,7 @@ RUN apk add --no-cache git
WORKDIR /app WORKDIR /app
# Copy go.mod/go.sum and download dependencies # Copy go.mod/go.sum and download dependencies
COPY go.mod ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code # Copy source code
@ -31,6 +31,7 @@ RUN apt-get update && \
git \ git \
curl \ curl \
rsgain \ rsgain \
flac \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install beets via pip # Install beets via pip

View file

@ -26,5 +26,5 @@ services:
## Quirks ## Quirks
- only works for .flac, .mp3, and .m4a - only works for .flac and .mp3
- not configurable at all, other than dirs - not configurable at all, other than dirs

4
go.mod
View file

@ -1,3 +1,7 @@
module github.com/gabehf/music-import module github.com/gabehf/music-import
go 1.24.2 go 1.24.2
require github.com/bogem/id3v2 v1.2.0
require golang.org/x/text v0.3.2 // indirect

5
go.sum Normal file
View file

@ -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=

18
main.go
View file

@ -12,6 +12,8 @@ import (
"strings" "strings"
"sync" "sync"
"text/template" "text/template"
"github.com/gabehf/music-import/media"
) )
type MusicMetadata struct { type MusicMetadata struct {
@ -191,6 +193,13 @@ func RunImporter() {
continue 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 // Move files to library
for _, track := range tracks { for _, track := range tracks {
if err := moveToLibrary(libraryDir, md, track); err != nil { 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 // Remove empty album directory after moving files
os.Remove(albumPath) os.Remove(albumPath)
} }
@ -217,7 +233,7 @@ func getAudioFiles(dir string) ([]string, error) {
continue continue
} }
ext := strings.ToLower(filepath.Ext(e.Name())) 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())) tracks = append(tracks, filepath.Join(dir, e.Name()))
} }
} }

164
media/media.go Normal file
View file

@ -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
}