feat: tag wav files with id3 and info chunks
This commit is contained in:
+34
-30
@@ -14,68 +14,72 @@ type id3v2Tagger struct {
|
|||||||
func (t *id3v2Tagger) write(m Metadata) error {
|
func (t *id3v2Tagger) write(m Metadata) error {
|
||||||
defer t.tag.Close()
|
defer t.tag.Close()
|
||||||
|
|
||||||
|
applyID3Frames(t.tag, m)
|
||||||
|
|
||||||
|
return t.tag.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyID3Frames(tag *id3v2.Tag, m Metadata) {
|
||||||
if m.Album != nil {
|
if m.Album != nil {
|
||||||
year := m.Album.ReleaseDate
|
year := m.Album.ReleaseDate
|
||||||
if parts := strings.Split(year, "-"); len(parts) == 3 {
|
if parts := strings.Split(year, "-"); len(parts) == 3 {
|
||||||
year = parts[0]
|
year = parts[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
t.addTag("TRCK", m.TrackNumber)
|
addID3Text(tag, "TRCK", m.TrackNumber)
|
||||||
t.addTag("TPE2", m.Album.Artist)
|
addID3Text(tag, "TPE2", m.Album.Artist)
|
||||||
t.addTag("TALB", m.Album.Title)
|
addID3Text(tag, "TALB", m.Album.Title)
|
||||||
t.addTag("TPUB", m.Album.Label)
|
addID3Text(tag, "TPUB", m.Album.Label)
|
||||||
t.addTag("TDOR", m.Album.OriginalReleaseDate)
|
addID3Text(tag, "TDOR", m.Album.OriginalReleaseDate)
|
||||||
t.addTag("TYER", year)
|
addID3Text(tag, "TYER", year)
|
||||||
t.addComment(m.Album.ProducerLine)
|
addID3Comment(tag, m.Album.ProducerLine)
|
||||||
t.addTag("TCOP", m.Album.Copyright)
|
addID3Text(tag, "TCOP", m.Album.Copyright)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.addTag("TPE1", m.Artists)
|
addID3Text(tag, "TPE1", m.Artists)
|
||||||
t.addTag("TIT2", m.Title)
|
addID3Text(tag, "TIT2", m.Title)
|
||||||
t.addTag("TCOM", m.Composers)
|
addID3Text(tag, "TCOM", m.Composers)
|
||||||
t.addTag("TEXT", m.Lyricists)
|
addID3Text(tag, "TEXT", m.Lyricists)
|
||||||
t.addTag("TCON", m.Genre)
|
addID3Text(tag, "TCON", m.Genre)
|
||||||
if duration, err := strconv.Atoi(m.Duration); err == nil {
|
if duration, err := strconv.Atoi(m.Duration); err == nil {
|
||||||
t.addTag("TLEN", strconv.Itoa(duration*1000))
|
addID3Text(tag, "TLEN", strconv.Itoa(duration*1000))
|
||||||
}
|
}
|
||||||
t.addTag("TBPM", m.BPM)
|
addID3Text(tag, "TBPM", m.BPM)
|
||||||
t.addTag("TKEY", m.Key)
|
addID3Text(tag, "TKEY", m.Key)
|
||||||
t.addTXXX("GAIN", m.Gain)
|
addID3TXXX(tag, "GAIN", m.Gain)
|
||||||
t.addTXXX("ISRC", m.ISRC)
|
addID3TXXX(tag, "ISRC", m.ISRC)
|
||||||
|
|
||||||
if len(m.Cover) > 0 {
|
if len(m.Cover) > 0 {
|
||||||
t.tag.AddAttachedPicture(id3v2.PictureFrame{
|
tag.AddAttachedPicture(id3v2.PictureFrame{
|
||||||
Encoding: t.tag.DefaultEncoding(),
|
Encoding: tag.DefaultEncoding(),
|
||||||
MimeType: "image/jpeg",
|
MimeType: "image/jpeg",
|
||||||
PictureType: id3v2.PTFrontCover,
|
PictureType: id3v2.PTFrontCover,
|
||||||
Description: "Cover",
|
Description: "Cover",
|
||||||
Picture: m.Cover,
|
Picture: m.Cover,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.tag.Save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *id3v2Tagger) addTag(name, value string) {
|
func addID3Text(tag *id3v2.Tag, name, value string) {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
t.tag.AddTextFrame(name, t.tag.DefaultEncoding(), value)
|
tag.AddTextFrame(name, tag.DefaultEncoding(), value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *id3v2Tagger) addComment(value string) {
|
func addID3Comment(tag *id3v2.Tag, value string) {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
t.tag.AddCommentFrame(id3v2.CommentFrame{
|
tag.AddCommentFrame(id3v2.CommentFrame{
|
||||||
Encoding: t.tag.DefaultEncoding(),
|
Encoding: tag.DefaultEncoding(),
|
||||||
Language: "eng",
|
Language: "eng",
|
||||||
Text: value,
|
Text: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *id3v2Tagger) addTXXX(description, value string) {
|
func addID3TXXX(tag *id3v2.Tag, description, value string) {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
|
tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{
|
||||||
Encoding: t.tag.DefaultEncoding(),
|
Encoding: tag.DefaultEncoding(),
|
||||||
Description: description,
|
Description: description,
|
||||||
Value: value,
|
Value: value,
|
||||||
})
|
})
|
||||||
|
|||||||
+4
-1
@@ -39,12 +39,15 @@ type tagger interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTagger(filePath string) (tagger, error) {
|
func newTagger(filePath string) (tagger, error) {
|
||||||
if filepath.Ext(filePath) == ".mp3" {
|
switch filepath.Ext(filePath) {
|
||||||
|
case ".mp3":
|
||||||
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &id3v2Tagger{tag: tag}, nil
|
return &id3v2Tagger{tag: tag}, nil
|
||||||
|
case ".wav":
|
||||||
|
return &wavTagger{path: filePath}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := flac.ParseFile(filePath)
|
file, err := flac.ParseFile(filePath)
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bogem/id3v2/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wavTagger struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
type wavChunk struct {
|
||||||
|
id string
|
||||||
|
payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type infoField struct {
|
||||||
|
id string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *wavTagger) write(m Metadata) error {
|
||||||
|
id3Chunk, err := buildID3Chunk(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunks []wavChunk
|
||||||
|
if info := buildInfoChunk(m); info != nil {
|
||||||
|
chunks = append(chunks, wavChunk{id: "LIST", payload: info})
|
||||||
|
}
|
||||||
|
if id3Chunk != nil {
|
||||||
|
chunks = append(chunks, wavChunk{id: "id3 ", payload: id3Chunk})
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewriteWAV(t.path, chunks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildID3Chunk(m Metadata) ([]byte, error) {
|
||||||
|
tag := id3v2.NewEmptyTag()
|
||||||
|
applyID3Frames(tag, m)
|
||||||
|
if !tag.HasFrames() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if _, err := tag.WriteTo(&buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInfoChunk(m Metadata) []byte {
|
||||||
|
fields := []infoField{
|
||||||
|
{"INAM", m.Title},
|
||||||
|
{"IART", m.Artists},
|
||||||
|
{"IGNR", m.Genre},
|
||||||
|
{"ITRK", m.TrackNumber},
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Album != nil {
|
||||||
|
date := m.Album.ReleaseDate
|
||||||
|
if parts := strings.Split(date, "-"); len(parts) == 3 {
|
||||||
|
date = parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
fields = append(fields,
|
||||||
|
infoField{"IPRD", m.Album.Title},
|
||||||
|
infoField{"ICRD", date},
|
||||||
|
infoField{"ICMT", m.Album.ProducerLine},
|
||||||
|
infoField{"ICOP", m.Album.Copyright},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteString("INFO")
|
||||||
|
|
||||||
|
for _, field := range fields {
|
||||||
|
if field.value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeChunk(&buf, field.id, append([]byte(field.value), 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.Len() == 4 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeChunk(w io.Writer, id string, payload []byte) {
|
||||||
|
header := make([]byte, 0, 8)
|
||||||
|
header = append(header, id...)
|
||||||
|
header = binary.LittleEndian.AppendUint32(header, uint32(len(payload)))
|
||||||
|
|
||||||
|
w.Write(header)
|
||||||
|
w.Write(payload)
|
||||||
|
if len(payload)%2 != 0 {
|
||||||
|
w.Write([]byte{0})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteWAV(path string, chunks []wavChunk) error {
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
header := make([]byte, 12)
|
||||||
|
if _, err := io.ReadFull(src, header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
|
||||||
|
return errors.New("not a wav file")
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := path + ".tmp"
|
||||||
|
dst, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done := false
|
||||||
|
defer func() {
|
||||||
|
if !done {
|
||||||
|
dst.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, err := dst.Write(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := copyChunks(dst, src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeChunk(&buf, chunk.id, chunk.payload)
|
||||||
|
if _, err := dst.Write(buf.Bytes()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
size += int64(buf.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
riffSize := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(riffSize, uint32(size))
|
||||||
|
if _, err := dst.WriteAt(riffSize, 4); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dst.Sync(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dst.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
done = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyChunks(dst io.Writer, src io.Reader) (int64, error) {
|
||||||
|
size := int64(4)
|
||||||
|
head := make([]byte, 8)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(src, head); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id := string(head[0:4])
|
||||||
|
payloadSize := int64(binary.LittleEndian.Uint32(head[4:8]))
|
||||||
|
|
||||||
|
if id == "id3 " || id == "ID3 " {
|
||||||
|
if err := skipPayload(src, payloadSize); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == "LIST" {
|
||||||
|
payload := make([]byte, payloadSize)
|
||||||
|
if _, err := io.ReadFull(src, payload); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
if err := skipPad(src, payloadSize); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
if bytes.HasPrefix(payload, []byte("INFO")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writeChunk(&buf, id, payload)
|
||||||
|
if _, err := dst.Write(buf.Bytes()); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
size += int64(buf.Len())
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := dst.Write(head); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
if _, err := io.CopyN(dst, src, payloadSize); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
size += 8 + payloadSize
|
||||||
|
|
||||||
|
if payloadSize%2 != 0 {
|
||||||
|
if _, err := dst.Write([]byte{0}); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
size++
|
||||||
|
if err := skipPad(src, payloadSize); err != nil {
|
||||||
|
return size, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipPayload(src io.Reader, payloadSize int64) error {
|
||||||
|
if _, err := io.CopyN(io.Discard, src, payloadSize); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return skipPad(src, payloadSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func skipPad(src io.Reader, payloadSize int64) error {
|
||||||
|
if payloadSize%2 == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := io.CopyN(io.Discard, src, 1); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package tag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func minimalWAV(audio []byte) []byte {
|
||||||
|
var body bytes.Buffer
|
||||||
|
body.WriteString("WAVE")
|
||||||
|
|
||||||
|
fmtPayload := make([]byte, 0, 16)
|
||||||
|
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 1)
|
||||||
|
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 2)
|
||||||
|
fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 44100)
|
||||||
|
fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 176400)
|
||||||
|
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 4)
|
||||||
|
fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 16)
|
||||||
|
writeChunk(&body, "fmt ", fmtPayload)
|
||||||
|
writeChunk(&body, "data", audio)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("RIFF")
|
||||||
|
binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
|
||||||
|
out.Write(body.Bytes())
|
||||||
|
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChunks(t *testing.T, data []byte) map[string][]byte {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
||||||
|
t.Fatalf("bad riff header: %q %q", data[0:4], data[8:12])
|
||||||
|
}
|
||||||
|
if size := binary.LittleEndian.Uint32(data[4:8]); int(size) != len(data)-8 {
|
||||||
|
t.Errorf("riff size = %d, want %d", size, len(data)-8)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := make(map[string][]byte)
|
||||||
|
for offset := 12; offset < len(data); {
|
||||||
|
if offset%2 != 0 {
|
||||||
|
t.Errorf("chunk at offset %d is not word aligned", offset)
|
||||||
|
}
|
||||||
|
if offset+8 > len(data) {
|
||||||
|
t.Fatalf("truncated chunk header at offset %d", offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := string(data[offset : offset+4])
|
||||||
|
size := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
|
||||||
|
if offset+8+size > len(data) {
|
||||||
|
t.Fatalf("chunk %q at offset %d overruns the file", id, offset)
|
||||||
|
}
|
||||||
|
if _, ok := chunks[id]; ok {
|
||||||
|
t.Errorf("duplicate %q chunk", id)
|
||||||
|
}
|
||||||
|
chunks[id] = data[offset+8 : offset+8+size]
|
||||||
|
|
||||||
|
offset += 8 + size + size%2
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMetadata() Metadata {
|
||||||
|
return Metadata{
|
||||||
|
Title: "Song",
|
||||||
|
Artists: "Artist",
|
||||||
|
Genre: "Rock",
|
||||||
|
BPM: "120",
|
||||||
|
Key: "Am",
|
||||||
|
TrackNumber: "3",
|
||||||
|
Duration: "215",
|
||||||
|
Gain: "-7.5",
|
||||||
|
ISRC: "FR1234567890",
|
||||||
|
Cover: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00},
|
||||||
|
Album: &AlbumMetadata{
|
||||||
|
Artist: "Album Artist",
|
||||||
|
Title: "Album",
|
||||||
|
Label: "Label",
|
||||||
|
ReleaseDate: "2024-05-01",
|
||||||
|
ProducerLine: "Producer line",
|
||||||
|
Copyright: "Copyright",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestWAV(t *testing.T, audio []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "track.wav")
|
||||||
|
if err := os.WriteFile(path, minimalWAV(audio), 0644); err != nil {
|
||||||
|
t.Fatalf("write wav: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteWAV(t *testing.T) {
|
||||||
|
audio := bytes.Repeat([]byte{0x11, 0x22, 0x33, 0x44}, 16)
|
||||||
|
path := writeTestWAV(t, audio)
|
||||||
|
original := parseChunks(t, minimalWAV(audio))
|
||||||
|
|
||||||
|
if err := Write(path, testMetadata()); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wav: %v", err)
|
||||||
|
}
|
||||||
|
chunks := parseChunks(t, data)
|
||||||
|
|
||||||
|
if !bytes.Equal(chunks["data"], original["data"]) {
|
||||||
|
t.Error("data chunk was modified")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(chunks["fmt "], original["fmt "]) {
|
||||||
|
t.Error("fmt chunk was modified")
|
||||||
|
}
|
||||||
|
|
||||||
|
id3, ok := chunks["id3 "]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing id3 chunk")
|
||||||
|
}
|
||||||
|
if string(id3[0:3]) != "ID3" {
|
||||||
|
t.Errorf("id3 chunk does not start with an ID3 header: %q", id3[0:3])
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Song", "Artist", "Album", "120", "Am", "FR1234567890", "Rock"} {
|
||||||
|
if !bytes.Contains(id3, []byte(want)) {
|
||||||
|
t.Errorf("id3 chunk is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bytes.Contains(id3, []byte{0xff, 0xd8, 0xff, 0xe0}) {
|
||||||
|
t.Error("id3 chunk is missing the cover art")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, ok := chunks["LIST"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing LIST chunk")
|
||||||
|
}
|
||||||
|
if string(list[0:4]) != "INFO" {
|
||||||
|
t.Errorf("LIST form = %q, want \"INFO\"", list[0:4])
|
||||||
|
}
|
||||||
|
for _, want := range []struct{ id, value string }{
|
||||||
|
{"INAM", "Song"},
|
||||||
|
{"IART", "Artist"},
|
||||||
|
{"IGNR", "Rock"},
|
||||||
|
{"ITRK", "3"},
|
||||||
|
{"IPRD", "Album"},
|
||||||
|
{"ICRD", "2024"},
|
||||||
|
{"ICMT", "Producer line"},
|
||||||
|
{"ICOP", "Copyright"},
|
||||||
|
} {
|
||||||
|
if !bytes.Contains(list, append([]byte(want.id), append([]byte{byte(len(want.value) + 1), 0, 0, 0}, want.value...)...)) {
|
||||||
|
t.Errorf("LIST chunk is missing %s = %q", want.id, want.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteWAVIsIdempotent(t *testing.T) {
|
||||||
|
audio := bytes.Repeat([]byte{0x01, 0x02}, 32)
|
||||||
|
path := writeTestWAV(t, audio)
|
||||||
|
|
||||||
|
if err := Write(path, testMetadata()); err != nil {
|
||||||
|
t.Fatalf("first Write() error = %v", err)
|
||||||
|
}
|
||||||
|
first, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wav: %v", err)
|
||||||
|
}
|
||||||
|
firstChunks := parseChunks(t, first)
|
||||||
|
|
||||||
|
if err := Write(path, testMetadata()); err != nil {
|
||||||
|
t.Fatalf("second Write() error = %v", err)
|
||||||
|
}
|
||||||
|
second, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wav: %v", err)
|
||||||
|
}
|
||||||
|
secondChunks := parseChunks(t, second)
|
||||||
|
|
||||||
|
if len(first) != len(second) {
|
||||||
|
t.Errorf("re-tagging changed the file size: %d bytes then %d bytes", len(first), len(second))
|
||||||
|
}
|
||||||
|
if len(firstChunks) != len(secondChunks) {
|
||||||
|
t.Errorf("chunk count = %d, want %d", len(secondChunks), len(firstChunks))
|
||||||
|
}
|
||||||
|
for id, payload := range firstChunks {
|
||||||
|
got, ok := secondChunks[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("re-tagging dropped the %q chunk", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(got) != len(payload) {
|
||||||
|
t.Errorf("%q chunk size = %d, want %d", id, len(got), len(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bytes.Equal(secondChunks["data"], audio) {
|
||||||
|
t.Error("data chunk was modified")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(secondChunks["fmt "], firstChunks["fmt "]) {
|
||||||
|
t.Error("fmt chunk was modified")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(secondChunks["LIST"], firstChunks["LIST"]) {
|
||||||
|
t.Error("LIST chunk was modified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteWAVOddSizedChunks(t *testing.T) {
|
||||||
|
audio := bytes.Repeat([]byte{0x07}, 33)
|
||||||
|
path := writeTestWAV(t, audio)
|
||||||
|
|
||||||
|
if err := Write(path, testMetadata()); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read wav: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := parseChunks(t, data)
|
||||||
|
if !bytes.Equal(chunks["data"], audio) {
|
||||||
|
t.Error("data chunk was modified")
|
||||||
|
}
|
||||||
|
if _, ok := chunks["id3 "]; !ok {
|
||||||
|
t.Error("missing id3 chunk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteWAVRejectsNonWAV(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "track.wav")
|
||||||
|
if err := os.WriteFile(path, []byte("this is not a wav file at all"), 0644); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Write(path, testMetadata()); err == nil {
|
||||||
|
t.Error("Write() error = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path + ".tmp"); err == nil {
|
||||||
|
t.Error("Write() left a temp file behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user