mirror of
https://github.com/gabehf/music-importer.git
synced 2026-03-07 13:38:14 -08:00
embed & move covers, remove m4a support
This commit is contained in:
parent
9de7dc83d8
commit
6480f93bb5
6 changed files with 193 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
|
|
|
|||
5
go.sum
Normal file
5
go.sum
Normal 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
18
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()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
164
media/media.go
Normal file
164
media/media.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue