music-importer/media.go

268 lines
7.4 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
id3v2 "github.com/bogem/id3v2" // optional alternative
)
var coverNames = []string{
"cover.jpg", "cover.jpeg", "cover.png",
"folder.jpg", "folder.jpeg", "folder.png",
"album.jpg", "album.jpeg", "album.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
}
// DownloadCoverArt downloads the front cover from the Cover Art Archive and
// saves it as cover.jpg/cover.png inside albumDir.
// If mbid is non-empty it is used directly, bypassing the MusicBrainz search.
// Otherwise, a search is performed using md's artist and album.
func DownloadCoverArt(albumDir string, md *MusicMetadata, mbid string) error {
if mbid == "" {
var err error
mbid, err = searchMusicBrainzRelease(md.Artist, md.Album)
if err != nil {
return fmt.Errorf("MusicBrainz release search failed: %w", err)
}
}
data, ext, err := fetchCoverArtArchiveFront(mbid)
if err != nil {
return fmt.Errorf("Cover Art Archive fetch failed: %w", err)
}
dest := filepath.Join(albumDir, "cover."+ext)
if err := os.WriteFile(dest, data, 0644); err != nil {
return fmt.Errorf("writing cover image: %w", err)
}
fmt.Println("→ Downloaded cover art:", filepath.Base(dest))
return nil
}
// searchMusicBrainzRelease queries the MusicBrainz API for a release matching
// the given artist and album and returns its MBID.
func searchMusicBrainzRelease(artist, album string) (string, error) {
q := fmt.Sprintf(`release:"%s" AND artist:"%s"`,
strings.ReplaceAll(album, `"`, `\"`),
strings.ReplaceAll(artist, `"`, `\"`),
)
apiURL := "https://musicbrainz.org/ws/2/release/?query=" + url.QueryEscape(q) + "&fmt=json&limit=1"
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "music-importer/1.0 (https://github.com/example/music-importer)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MusicBrainz returned status %d", resp.StatusCode)
}
var result struct {
Releases []struct {
ID string `json:"id"`
} `json:"releases"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if len(result.Releases) == 0 {
return "", fmt.Errorf("no MusicBrainz release found for %q by %q", album, artist)
}
return result.Releases[0].ID, nil
}
// fetchCoverArtArchiveFront fetches the front cover image for the given
// MusicBrainz release MBID from coverartarchive.org. It follows the 307
// redirect to the actual image and returns the raw bytes plus the file
// extension (e.g. "jpg" or "png").
func fetchCoverArtArchiveFront(mbid string) ([]byte, string, error) {
apiURL := "https://coverartarchive.org/release/" + mbid + "/front"
resp, err := http.Get(apiURL)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("Cover Art Archive returned status %d for MBID %s", resp.StatusCode, mbid)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", err
}
// Derive the extension from the final URL after redirect, falling back to
// sniffing the magic bytes.
ext := "jpg"
if finalURL := resp.Request.URL.String(); strings.HasSuffix(strings.ToLower(finalURL), ".png") {
ext = "png"
} else if bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4E, 0x47}) {
ext = "png"
}
return data, ext, nil
}
// -------------------------
// 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())
if slices.Contains(coverNames, l) {
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
}