add format-aware tagging and replaygain fallback mapping

Write replaygain gain and peak tags from provider-specific metadata fields, then apply tagging across supported output containers with graceful cover-art fallback so non-FLAC downloads retain metadata.
This commit is contained in:
2026-04-19 22:54:07 +02:00
parent d4643d877e
commit 4da5114a70
4 changed files with 160 additions and 22 deletions

View File

@@ -27,6 +27,8 @@ type Metadata struct {
ISRC string
ReplaygainTrackGain string
ReplaygainAlbumGain string
ReplaygainTrackPeak string
ReplaygainAlbumPeak string
SourcePlatform string
SourceTrackID string
SourceAlbumID string
@@ -44,11 +46,19 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
return fmt.Errorf("ffmpeg not found: %w", err)
}
tmpPath := path + ".tmp.flac"
args := buildFFmpegArgs(path, tmpPath, meta, coverPath)
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
tmpPath := taggedTempPath(path)
runTag := func(cover string) ([]byte, error) {
args := buildFFmpegArgs(path, tmpPath, meta, cover, ext)
cmd := exec.Command("ffmpeg", args...)
return cmd.CombinedOutput()
}
cmd := exec.Command("ffmpeg", args...)
output, err := cmd.CombinedOutput()
output, err := runTag(coverPath)
if err != nil && strings.TrimSpace(coverPath) != "" {
_ = os.Remove(tmpPath)
output, err = runTag("")
}
if err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("ffmpeg tag failed: %w: %s", err, string(output))
@@ -62,15 +72,16 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
return nil
}
func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath string) []string {
func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string) []string {
args := []string{"-y", "-i", inputPath}
withCover := coverPath != "" && fileExists(coverPath)
withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext)
if withCover {
args = append(args, "-i", coverPath)
}
args = append(args,
"-map", "0:a",
"-map_metadata", "0",
"-c:a", "copy",
)
if withCover {
@@ -79,6 +90,9 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath stri
"-c:v", "mjpeg",
"-disposition:v:0", "attached_pic",
)
if ext == "mp3" {
args = append(args, "-id3v2_version", "3")
}
}
for k, v := range toTags(meta) {
@@ -92,6 +106,23 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath stri
return args
}
func taggedTempPath(path string) string {
ext := filepath.Ext(path)
if ext == "" {
return path + ".tmp"
}
return strings.TrimSuffix(path, ext) + ".tmp" + ext
}
func supportsAttachedPicture(ext string) bool {
switch strings.TrimPrefix(strings.ToLower(ext), ".") {
case "flac", "mp3", "m4a", "mp4":
return true
default:
return false
}
}
func toTags(meta Metadata) map[string]string {
tags := map[string]string{
"title": meta.Title,
@@ -107,6 +138,8 @@ func toTags(meta Metadata) map[string]string {
"isrc": meta.ISRC,
"replaygain_track_gain": meta.ReplaygainTrackGain,
"replaygain_album_gain": meta.ReplaygainAlbumGain,
"replaygain_track_peak": meta.ReplaygainTrackPeak,
"replaygain_album_peak": meta.ReplaygainAlbumPeak,
"source_platform": strings.ToUpper(strings.TrimSpace(meta.SourcePlatform)),
"source_track_id": meta.SourceTrackID,
"source_album_id": meta.SourceAlbumID,

View File

@@ -25,13 +25,17 @@ func TestToTagsTrackDiscFormatting(t *testing.T) {
func TestToTagsTotalsAndSourceFields(t *testing.T) {
tags := toTags(Metadata{
TrackNumber: 3,
TrackTotal: 12,
DiscNumber: 1,
DiscTotal: 2,
ISRC: "USABC1234567",
SourcePlatform: "qobuz",
SourceTrackID: "t1",
TrackNumber: 3,
TrackTotal: 12,
DiscNumber: 1,
DiscTotal: 2,
ISRC: "USABC1234567",
ReplaygainTrackGain: "-7.25 dB",
ReplaygainAlbumGain: "-8.1 dB",
ReplaygainTrackPeak: "0.989",
ReplaygainAlbumPeak: "1.001",
SourcePlatform: "qobuz",
SourceTrackID: "t1",
})
if tags["track"] != "03/12" {
t.Fatalf("track tag = %q", tags["track"])
@@ -48,6 +52,12 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
t.Fatalf("source tags missing: %+v", tags)
}
if tags["replaygain_track_gain"] != "-7.25 dB" || tags["replaygain_album_gain"] != "-8.1 dB" {
t.Fatalf("replaygain gain tags missing: %+v", tags)
}
if tags["replaygain_track_peak"] != "0.989" || tags["replaygain_album_peak"] != "1.001" {
t.Fatalf("replaygain peak tags missing: %+v", tags)
}
}
func TestBuildFFmpegArgsWithCover(t *testing.T) {
@@ -56,7 +66,7 @@ func TestBuildFFmpegArgsWithCover(t *testing.T) {
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover)
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac")
foundInput2 := false
foundAttach := false
for i := 0; i < len(args)-1; i++ {
@@ -71,3 +81,26 @@ func TestBuildFFmpegArgsWithCover(t *testing.T) {
t.Fatalf("missing cover args: %v", args)
}
}
func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) {
tmp := t.TempDir()
cover := filepath.Join(tmp, "cover.jpg")
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
t.Fatalf("write cover: %v", err)
}
args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus")
for i := 0; i < len(args)-1; i++ {
if args[i] == "-i" && args[i+1] == cover {
t.Fatalf("unexpected cover input for opus: %v", args)
}
}
}
func TestTaggedTempPathPreservesExtension(t *testing.T) {
if got := taggedTempPath("/tmp/song.flac"); got != "/tmp/song.tmp.flac" {
t.Fatalf("taggedTempPath(flac)=%q", got)
}
if got := taggedTempPath("/tmp/song"); got != "/tmp/song.tmp" {
t.Fatalf("taggedTempPath(noext)=%q", got)
}
}