From 4da5114a70ac3e53303e3dd84043c4a20513fdb2 Mon Sep 17 00:00:00 2001 From: Joren Date: Sun, 19 Apr 2026 22:54:07 +0200 Subject: [PATCH] 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. --- internal/app/app.go | 61 +++++++++++++++++++++++++++---- internal/app/app_test.go | 27 ++++++++++++++ internal/audio/tag/tagger.go | 45 ++++++++++++++++++++--- internal/audio/tag/tagger_test.go | 49 +++++++++++++++++++++---- 4 files changed, 160 insertions(+), 22 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index eaf55e0..4109623 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -680,10 +680,8 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall coverPath = tag.CoverPathForTrack(outPath, opts.albumFolder) } } - if strings.EqualFold(filepath.Ext(outPath), ".flac") { - if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil { - m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err) - } + if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil { + m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err) } if m.Config.Session.Conversion.Enabled { @@ -847,15 +845,19 @@ func titleFromMetadata(meta map[string]any, fallback string) string { } func nestedString(v map[string]any, keys ...string) string { + return stringFromAny(nestedAny(v, keys...)) +} + +func nestedAny(v map[string]any, keys ...string) any { cur := any(v) for _, key := range keys { m, ok := cur.(map[string]any) if !ok { - return "" + return nil } cur = m[key] } - return stringFromAny(cur) + return cur } func stringFromAny(v any) string { @@ -904,6 +906,31 @@ func boolFromAny(v any) bool { return b } +func replaygainGainFromAny(v any) string { + s := strings.TrimSpace(stringFromAny(v)) + if s == "" { + return "" + } + + lower := strings.ToLower(s) + if strings.HasSuffix(lower, "db") { + n := strings.TrimSpace(s[:len(s)-2]) + if n == "" { + return "" + } + return n + " dB" + } + + if _, err := strconv.ParseFloat(s, 64); err == nil { + return s + " dB" + } + return s +} + +func replaygainPeakFromAny(v any) string { + return strings.TrimSpace(stringFromAny(v)) +} + func trackMetaAlbum(trackMeta map[string]any) map[string]any { album, ok := trackMeta["album"].(map[string]any) if !ok { @@ -967,6 +994,22 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o comment := stringFromAny(trackMeta["comment"]) description := stringFromAny(trackMeta["description"]) lyrics := stringFromAny(trackMeta["lyrics"]) + trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"]) + if trackGain == "" { + trackGain = replaygainGainFromAny(trackMeta["replayGain"]) + } + albumGain := replaygainGainFromAny(trackMeta["replaygain_album_gain"]) + if albumGain == "" { + albumGain = replaygainGainFromAny(nestedAny(trackMeta, "album", "replaygain_album_gain")) + } + trackPeak := replaygainPeakFromAny(trackMeta["replaygain_track_peak"]) + if trackPeak == "" { + trackPeak = replaygainPeakFromAny(trackMeta["peak"]) + } + albumPeak := replaygainPeakFromAny(trackMeta["replaygain_album_peak"]) + if albumPeak == "" { + albumPeak = replaygainPeakFromAny(nestedAny(trackMeta, "album", "replaygain_album_peak")) + } sourceAlbumID := nestedString(trackMeta, "album", "id") sourceArtistID := nestedString(trackMeta, "artist", "id") @@ -990,8 +1033,10 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o Lyrics: lyrics, Copyright: stringFromAny(trackMeta["copyright"]), ISRC: stringFromAny(trackMeta["isrc"]), - ReplaygainTrackGain: stringFromAny(trackMeta["replaygain_track_gain"]), - ReplaygainAlbumGain: stringFromAny(trackMeta["replaygain_album_gain"]), + ReplaygainTrackGain: trackGain, + ReplaygainAlbumGain: albumGain, + ReplaygainTrackPeak: trackPeak, + ReplaygainAlbumPeak: albumPeak, SourcePlatform: source, SourceTrackID: trackID, SourceAlbumID: sourceAlbumID, diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 02c47e9..1168a76 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -357,3 +357,30 @@ func TestTrackOutputPathSinglesUsesAlbumID(t *testing.T) { t.Fatalf("trackOutputPath() dir=%q want %q", got, want) } } + +func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) { + meta := map[string]any{ + "replayGain": float64(-7.25), + "peak": float64(0.989), + "album": map[string]any{ + "title": "Album", + "replaygain_album_gain": float64(-8.1), + "replaygain_album_peak": float64(1.001), + }, + "performer": map[string]any{"name": "Artist"}, + } + + tags := buildTagMetadata(meta, "Song", "tidal", "t1", ripTrackOptions{}) + if tags.ReplaygainTrackGain != "-7.25 dB" { + t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain) + } + if tags.ReplaygainAlbumGain != "-8.1 dB" { + t.Fatalf("album replaygain gain=%q", tags.ReplaygainAlbumGain) + } + if tags.ReplaygainTrackPeak != "0.989" { + t.Fatalf("track replaygain peak=%q", tags.ReplaygainTrackPeak) + } + if tags.ReplaygainAlbumPeak != "1.001" { + t.Fatalf("album replaygain peak=%q", tags.ReplaygainAlbumPeak) + } +} diff --git a/internal/audio/tag/tagger.go b/internal/audio/tag/tagger.go index 33f0000..7c231fb 100644 --- a/internal/audio/tag/tagger.go +++ b/internal/audio/tag/tagger.go @@ -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, diff --git a/internal/audio/tag/tagger_test.go b/internal/audio/tag/tagger_test.go index f4f080b..9c3d0d8 100644 --- a/internal/audio/tag/tagger_test.go +++ b/internal/audio/tag/tagger_test.go @@ -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) + } +}