music-importer/metadata.go
2026-04-02 19:14:36 -04:00

131 lines
3.1 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
type MusicMetadata struct {
Artist string
Album string
Title string
Year string
}
// Read embedded tags from an audio file using ffprobe.
func readTags(path string) (*MusicMetadata, error) {
out, err := exec.Command(
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", path,
).Output()
if err != nil {
return nil, err
}
var data struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
json.Unmarshal(out, &data)
t := data.Format.Tags
if t == nil {
return &MusicMetadata{}, nil
}
return &MusicMetadata{
Artist: firstNonEmpty(t["artist"], t["ARTIST"]),
Album: firstNonEmpty(t["album"], t["ALBUM"]),
Title: firstNonEmpty(t["title"], t["TITLE"]),
Year: firstNonEmpty(t["year"], t["YEAR"], t["ORIGINALYEAR"]),
}, nil
}
// Use beets to fetch metadata and tag all files in a directory.
func tagWithBeets(path string) error {
fmt.Println("→ Tagging with beets:", path)
return runCmd("beet", "import", "-Cq", path)
}
// Fallback: query MusicBrainz API manually if beets fails.
func fetchMusicBrainzInfo(filename string) (*MusicMetadata, error) {
fmt.Println("→ Fallback: querying MusicBrainz:", filename)
query := fmt.Sprintf("recording:%q", strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)))
url := "https://musicbrainz.org/ws/2/recording/?query=" + query + "&fmt=json"
resp, err := exec.Command("curl", "-s", url).Output()
if err != nil {
return nil, err
}
var data struct {
Recordings []struct {
Title string `json:"title"`
Releases []struct {
Title string `json:"title"`
ArtistCredit []struct {
Name string `json:"name"`
} `json:"artist-credit"`
} `json:"releases"`
FirstReleaseDate string `json:"first-release-date"`
} `json:"recordings"`
}
if err := json.Unmarshal(resp, &data); err != nil {
return nil, err
}
if len(data.Recordings) == 0 || len(data.Recordings[0].Releases) == 0 {
return nil, errors.New("no MusicBrainz match")
}
r := data.Recordings[0]
rel := r.Releases[0]
return &MusicMetadata{
Artist: rel.ArtistCredit[0].Name,
Album: rel.Title,
Title: r.Title,
Year: strings.Split(r.FirstReleaseDate, "-")[0],
}, nil
}
// getAlbumMetadata attempts beets tagging on the album directory, reads tags
// back from the first track, and falls back to MusicBrainz if tags are missing.
func getAlbumMetadata(albumPath, trackPath string) (*MusicMetadata, error) {
fmt.Println("→ Tagging track with beets:", trackPath)
if err := tagWithBeets(albumPath); err != nil {
fmt.Println("Beets tagging failed; fallback to manual MusicBrainz lookup:", err)
}
md, err := readTags(trackPath)
if err == nil && md.Artist != "" && md.Album != "" {
return md, nil
}
fmt.Println("→ Missing tags, attempting MusicBrainz manual lookup...")
md, err = fetchMusicBrainzInfo(trackPath)
if err != nil {
return nil, fmt.Errorf("metadata lookup failed: %w", err)
}
return md, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}