mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
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:
@@ -680,10 +680,8 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
|
|||||||
coverPath = tag.CoverPathForTrack(outPath, opts.albumFolder)
|
coverPath = tag.CoverPathForTrack(outPath, opts.albumFolder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.EqualFold(filepath.Ext(outPath), ".flac") {
|
if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil {
|
||||||
if err = m.Tagger.TagFLAC(outPath, tagMeta, coverPath); err != nil {
|
m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err)
|
||||||
m.logf("warning: tag failed for %s: %v\n", filepath.Base(outPath), err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.Config.Session.Conversion.Enabled {
|
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 {
|
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)
|
cur := any(v)
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
m, ok := cur.(map[string]any)
|
m, ok := cur.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
cur = m[key]
|
cur = m[key]
|
||||||
}
|
}
|
||||||
return stringFromAny(cur)
|
return cur
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringFromAny(v any) string {
|
func stringFromAny(v any) string {
|
||||||
@@ -904,6 +906,31 @@ func boolFromAny(v any) bool {
|
|||||||
return b
|
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 {
|
func trackMetaAlbum(trackMeta map[string]any) map[string]any {
|
||||||
album, ok := trackMeta["album"].(map[string]any)
|
album, ok := trackMeta["album"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -967,6 +994,22 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
comment := stringFromAny(trackMeta["comment"])
|
comment := stringFromAny(trackMeta["comment"])
|
||||||
description := stringFromAny(trackMeta["description"])
|
description := stringFromAny(trackMeta["description"])
|
||||||
lyrics := stringFromAny(trackMeta["lyrics"])
|
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")
|
sourceAlbumID := nestedString(trackMeta, "album", "id")
|
||||||
sourceArtistID := nestedString(trackMeta, "artist", "id")
|
sourceArtistID := nestedString(trackMeta, "artist", "id")
|
||||||
@@ -990,8 +1033,10 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
Lyrics: lyrics,
|
Lyrics: lyrics,
|
||||||
Copyright: stringFromAny(trackMeta["copyright"]),
|
Copyright: stringFromAny(trackMeta["copyright"]),
|
||||||
ISRC: stringFromAny(trackMeta["isrc"]),
|
ISRC: stringFromAny(trackMeta["isrc"]),
|
||||||
ReplaygainTrackGain: stringFromAny(trackMeta["replaygain_track_gain"]),
|
ReplaygainTrackGain: trackGain,
|
||||||
ReplaygainAlbumGain: stringFromAny(trackMeta["replaygain_album_gain"]),
|
ReplaygainAlbumGain: albumGain,
|
||||||
|
ReplaygainTrackPeak: trackPeak,
|
||||||
|
ReplaygainAlbumPeak: albumPeak,
|
||||||
SourcePlatform: source,
|
SourcePlatform: source,
|
||||||
SourceTrackID: trackID,
|
SourceTrackID: trackID,
|
||||||
SourceAlbumID: sourceAlbumID,
|
SourceAlbumID: sourceAlbumID,
|
||||||
|
|||||||
@@ -357,3 +357,30 @@ func TestTrackOutputPathSinglesUsesAlbumID(t *testing.T) {
|
|||||||
t.Fatalf("trackOutputPath() dir=%q want %q", got, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type Metadata struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
ReplaygainTrackGain string
|
ReplaygainTrackGain string
|
||||||
ReplaygainAlbumGain string
|
ReplaygainAlbumGain string
|
||||||
|
ReplaygainTrackPeak string
|
||||||
|
ReplaygainAlbumPeak string
|
||||||
SourcePlatform string
|
SourcePlatform string
|
||||||
SourceTrackID string
|
SourceTrackID string
|
||||||
SourceAlbumID 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)
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpPath := path + ".tmp.flac"
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
|
||||||
args := buildFFmpegArgs(path, tmpPath, meta, coverPath)
|
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 := runTag(coverPath)
|
||||||
output, err := cmd.CombinedOutput()
|
if err != nil && strings.TrimSpace(coverPath) != "" {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
output, err = runTag("")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
return fmt.Errorf("ffmpeg tag failed: %w: %s", err, string(output))
|
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
|
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}
|
args := []string{"-y", "-i", inputPath}
|
||||||
withCover := coverPath != "" && fileExists(coverPath)
|
withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext)
|
||||||
if withCover {
|
if withCover {
|
||||||
args = append(args, "-i", coverPath)
|
args = append(args, "-i", coverPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"-map", "0:a",
|
"-map", "0:a",
|
||||||
|
"-map_metadata", "0",
|
||||||
"-c:a", "copy",
|
"-c:a", "copy",
|
||||||
)
|
)
|
||||||
if withCover {
|
if withCover {
|
||||||
@@ -79,6 +90,9 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath stri
|
|||||||
"-c:v", "mjpeg",
|
"-c:v", "mjpeg",
|
||||||
"-disposition:v:0", "attached_pic",
|
"-disposition:v:0", "attached_pic",
|
||||||
)
|
)
|
||||||
|
if ext == "mp3" {
|
||||||
|
args = append(args, "-id3v2_version", "3")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range toTags(meta) {
|
for k, v := range toTags(meta) {
|
||||||
@@ -92,6 +106,23 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath stri
|
|||||||
return args
|
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 {
|
func toTags(meta Metadata) map[string]string {
|
||||||
tags := map[string]string{
|
tags := map[string]string{
|
||||||
"title": meta.Title,
|
"title": meta.Title,
|
||||||
@@ -107,6 +138,8 @@ func toTags(meta Metadata) map[string]string {
|
|||||||
"isrc": meta.ISRC,
|
"isrc": meta.ISRC,
|
||||||
"replaygain_track_gain": meta.ReplaygainTrackGain,
|
"replaygain_track_gain": meta.ReplaygainTrackGain,
|
||||||
"replaygain_album_gain": meta.ReplaygainAlbumGain,
|
"replaygain_album_gain": meta.ReplaygainAlbumGain,
|
||||||
|
"replaygain_track_peak": meta.ReplaygainTrackPeak,
|
||||||
|
"replaygain_album_peak": meta.ReplaygainAlbumPeak,
|
||||||
"source_platform": strings.ToUpper(strings.TrimSpace(meta.SourcePlatform)),
|
"source_platform": strings.ToUpper(strings.TrimSpace(meta.SourcePlatform)),
|
||||||
"source_track_id": meta.SourceTrackID,
|
"source_track_id": meta.SourceTrackID,
|
||||||
"source_album_id": meta.SourceAlbumID,
|
"source_album_id": meta.SourceAlbumID,
|
||||||
|
|||||||
@@ -25,13 +25,17 @@ func TestToTagsTrackDiscFormatting(t *testing.T) {
|
|||||||
|
|
||||||
func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||||
tags := toTags(Metadata{
|
tags := toTags(Metadata{
|
||||||
TrackNumber: 3,
|
TrackNumber: 3,
|
||||||
TrackTotal: 12,
|
TrackTotal: 12,
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
DiscTotal: 2,
|
DiscTotal: 2,
|
||||||
ISRC: "USABC1234567",
|
ISRC: "USABC1234567",
|
||||||
SourcePlatform: "qobuz",
|
ReplaygainTrackGain: "-7.25 dB",
|
||||||
SourceTrackID: "t1",
|
ReplaygainAlbumGain: "-8.1 dB",
|
||||||
|
ReplaygainTrackPeak: "0.989",
|
||||||
|
ReplaygainAlbumPeak: "1.001",
|
||||||
|
SourcePlatform: "qobuz",
|
||||||
|
SourceTrackID: "t1",
|
||||||
})
|
})
|
||||||
if tags["track"] != "03/12" {
|
if tags["track"] != "03/12" {
|
||||||
t.Fatalf("track tag = %q", tags["track"])
|
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" {
|
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
|
||||||
t.Fatalf("source tags missing: %+v", tags)
|
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) {
|
func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
||||||
@@ -56,7 +66,7 @@ func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
|||||||
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
|
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
|
||||||
t.Fatalf("write cover: %v", err)
|
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
|
foundInput2 := false
|
||||||
foundAttach := false
|
foundAttach := false
|
||||||
for i := 0; i < len(args)-1; i++ {
|
for i := 0; i < len(args)-1; i++ {
|
||||||
@@ -71,3 +81,26 @@ func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
|||||||
t.Fatalf("missing cover args: %v", args)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user