mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-07-27 23:42:28 +02:00
feat: format multi-artist credits
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,6 +14,7 @@ type Metadata struct {
|
||||
Title string
|
||||
Album string
|
||||
Artist string
|
||||
Artists []string
|
||||
AlbumArtist string
|
||||
OmitDiscTags bool
|
||||
TrackNumber int
|
||||
@@ -71,6 +73,9 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err = applyMultiValueFLACTags(path, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -195,6 +200,150 @@ func toTags(meta Metadata) map[string]string {
|
||||
return tags
|
||||
}
|
||||
|
||||
func applyMultiValueFLACTags(path string, meta Metadata) error {
|
||||
if strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) != "flac" || len(meta.Artists) == 0 {
|
||||
return nil
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := replaceFLACVorbisComments(data, "ARTISTS", meta.Artists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated == nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, updated, st.Mode())
|
||||
}
|
||||
|
||||
type flacMetadataBlock struct {
|
||||
isLast bool
|
||||
blockType byte
|
||||
data []byte
|
||||
}
|
||||
|
||||
func replaceFLACVorbisComments(data []byte, key string, values []string) ([]byte, error) {
|
||||
if len(data) < 4 || string(data[:4]) != "fLaC" {
|
||||
return nil, fmt.Errorf("not a FLAC file")
|
||||
}
|
||||
|
||||
blocks := []flacMetadataBlock{}
|
||||
pos := 4
|
||||
vorbisIndex := -1
|
||||
for {
|
||||
if pos+4 > len(data) {
|
||||
return nil, fmt.Errorf("truncated FLAC metadata header")
|
||||
}
|
||||
header := data[pos]
|
||||
blockType := header & 0x7f
|
||||
length := int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3])
|
||||
pos += 4
|
||||
if pos+length > len(data) {
|
||||
return nil, fmt.Errorf("truncated FLAC metadata block")
|
||||
}
|
||||
if blockType == 4 {
|
||||
vorbisIndex = len(blocks)
|
||||
}
|
||||
blocks = append(blocks, flacMetadataBlock{isLast: header&0x80 != 0, blockType: blockType, data: data[pos : pos+length]})
|
||||
pos += length
|
||||
if header&0x80 != 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if vorbisIndex < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commentBlock, err := replaceVorbisCommentValues(blocks[vorbisIndex].data, key, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks[vorbisIndex].data = commentBlock
|
||||
|
||||
out := make([]byte, 0, len(data)+len(commentBlock)-len(blocks[vorbisIndex].data))
|
||||
out = append(out, data[:4]...)
|
||||
for _, block := range blocks {
|
||||
if len(block.data) > 0xffffff {
|
||||
return nil, fmt.Errorf("FLAC metadata block too large")
|
||||
}
|
||||
header := block.blockType
|
||||
if block.isLast {
|
||||
header |= 0x80
|
||||
}
|
||||
out = append(out, header, byte(len(block.data)>>16), byte(len(block.data)>>8), byte(len(block.data)))
|
||||
out = append(out, block.data...)
|
||||
}
|
||||
out = append(out, data[pos:]...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func replaceVorbisCommentValues(data []byte, key string, values []string) ([]byte, error) {
|
||||
if len(data) < 8 {
|
||||
return nil, fmt.Errorf("truncated Vorbis comment block")
|
||||
}
|
||||
pos := 0
|
||||
vendorLength := int(binary.LittleEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
if pos+vendorLength+4 > len(data) {
|
||||
return nil, fmt.Errorf("truncated Vorbis vendor string")
|
||||
}
|
||||
vendor := data[pos : pos+vendorLength]
|
||||
pos += vendorLength
|
||||
commentCount := int(binary.LittleEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
|
||||
comments := make([][]byte, 0, commentCount+len(values))
|
||||
for i := 0; i < commentCount; i++ {
|
||||
if pos+4 > len(data) {
|
||||
return nil, fmt.Errorf("truncated Vorbis comment length")
|
||||
}
|
||||
commentLength := int(binary.LittleEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
if pos+commentLength > len(data) {
|
||||
return nil, fmt.Errorf("truncated Vorbis comment")
|
||||
}
|
||||
comment := data[pos : pos+commentLength]
|
||||
pos += commentLength
|
||||
if !vorbisCommentKeyEqual(comment, key) {
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
comments = append(comments, []byte(key+"="+value))
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]byte, 0, len(data))
|
||||
out = appendUint32LE(out, uint32(len(vendor)))
|
||||
out = append(out, vendor...)
|
||||
out = appendUint32LE(out, uint32(len(comments)))
|
||||
for _, comment := range comments {
|
||||
out = appendUint32LE(out, uint32(len(comment)))
|
||||
out = append(out, comment...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func vorbisCommentKeyEqual(comment []byte, key string) bool {
|
||||
idx := strings.IndexByte(string(comment), '=')
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(string(comment[:idx]), key)
|
||||
}
|
||||
|
||||
func appendUint32LE(out []byte, v uint32) []byte {
|
||||
return append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
|
||||
}
|
||||
|
||||
func normalizeCopyright(in string) string {
|
||||
out := strings.ReplaceAll(in, "(c)", "©")
|
||||
out = strings.ReplaceAll(out, "(C)", "©")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -64,6 +65,102 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTagsArtistList(t *testing.T) {
|
||||
tags := toTags(Metadata{Artist: "A, B & C", Artists: []string{"A", "B", "C"}})
|
||||
if tags["artist"] != "A, B & C" {
|
||||
t.Fatalf("artist tags = %+v", tags)
|
||||
}
|
||||
if _, ok := tags["ARTISTS"]; ok {
|
||||
t.Fatalf("ARTISTS should be written as repeated FLAC comments, got %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceVorbisCommentValuesWritesRepeatedTags(t *testing.T) {
|
||||
block := vorbisCommentBlock("vendor", []string{
|
||||
"ARTIST=A, B & C",
|
||||
"ARTISTS=A;B;C",
|
||||
"TITLE=Song",
|
||||
})
|
||||
|
||||
updated, err := replaceVorbisCommentValues(block, "ARTISTS", []string{"A", "B", "C"})
|
||||
if err != nil {
|
||||
t.Fatalf("replaceVorbisCommentValues() error = %v", err)
|
||||
}
|
||||
comments := readVorbisComments(t, updated)
|
||||
want := []string{"ARTIST=A, B & C", "TITLE=Song", "ARTISTS=A", "ARTISTS=B", "ARTISTS=C"}
|
||||
if len(comments) != len(want) {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
for i := range want {
|
||||
if comments[i] != want[i] {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceFLACVorbisComments(t *testing.T) {
|
||||
vorbis := vorbisCommentBlock("vendor", []string{"ARTISTS=A;B", "TITLE=Song"})
|
||||
flac := append([]byte("fLaC"), flacBlockHeader(true, 4, len(vorbis))...)
|
||||
flac = append(flac, vorbis...)
|
||||
flac = append(flac, []byte("audio")...)
|
||||
|
||||
updated, err := replaceFLACVorbisComments(flac, "ARTISTS", []string{"A", "B"})
|
||||
if err != nil {
|
||||
t.Fatalf("replaceFLACVorbisComments() error = %v", err)
|
||||
}
|
||||
if string(updated[len(updated)-5:]) != "audio" {
|
||||
t.Fatalf("audio payload not preserved")
|
||||
}
|
||||
length := int(updated[5])<<16 | int(updated[6])<<8 | int(updated[7])
|
||||
comments := readVorbisComments(t, updated[8:8+length])
|
||||
want := []string{"TITLE=Song", "ARTISTS=A", "ARTISTS=B"}
|
||||
if len(comments) != len(want) {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
for i := range want {
|
||||
if comments[i] != want[i] {
|
||||
t.Fatalf("comments=%#v want %#v", comments, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vorbisCommentBlock(vendor string, comments []string) []byte {
|
||||
out := []byte{}
|
||||
out = appendUint32LE(out, uint32(len(vendor)))
|
||||
out = append(out, vendor...)
|
||||
out = appendUint32LE(out, uint32(len(comments)))
|
||||
for _, comment := range comments {
|
||||
out = appendUint32LE(out, uint32(len(comment)))
|
||||
out = append(out, comment...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flacBlockHeader(last bool, blockType byte, length int) []byte {
|
||||
header := blockType
|
||||
if last {
|
||||
header |= 0x80
|
||||
}
|
||||
return []byte{header, byte(length >> 16), byte(length >> 8), byte(length)}
|
||||
}
|
||||
|
||||
func readVorbisComments(t *testing.T, block []byte) []string {
|
||||
t.Helper()
|
||||
pos := 0
|
||||
vendorLength := int(binary.LittleEndian.Uint32(block[pos:]))
|
||||
pos += 4 + vendorLength
|
||||
count := int(binary.LittleEndian.Uint32(block[pos:]))
|
||||
pos += 4
|
||||
comments := make([]string, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
length := int(binary.LittleEndian.Uint32(block[pos:]))
|
||||
pos += 4
|
||||
comments = append(comments, string(block[pos:pos+length]))
|
||||
pos += length
|
||||
}
|
||||
return comments
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cover := filepath.Join(tmp, "cover.jpg")
|
||||
|
||||
Reference in New Issue
Block a user