feat(api): expand tagwriter support for WAV, M4A, and Ogg Vorbis

Extended the 'tagwriter' package to support a wider range of audio formats, ensuring
comprehensive metadata management across the most common lossless and lossy types.
This commit is contained in:
serik.perez 2026-05-02 15:16:23 +02:00
parent e2b466ecb2
commit 1dd5f4e781
5 changed files with 1110 additions and 6 deletions

426
tagwriter/m4a.go Normal file
View File

@ -0,0 +1,426 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"os"
"strconv"
"time"
)
func writeM4ATags(filePath string, tags Tags) error {
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("failed to open M4A file: %w", err)
}
defer f.Close()
fileInfo, err := f.Stat()
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
originalSize := fileInfo.Size()
atoms, err := parseMP4Atoms(f)
if err != nil {
return fmt.Errorf("failed to parse MP4 atoms: %w", err)
}
ilstAtom := findILSTAtom(atoms)
metadataData, err := encodeILSTMetadata(tags)
if err != nil {
return fmt.Errorf("failed to encode metadata: %w", err)
}
if len(metadataData) == 0 {
return nil
}
newFileSize := originalSize
if ilstAtom != nil {
oldILSTSize := calculateAtomSize(int(ilstAtom.DataSize))
newILSTSize := calculateAtomSize(len(metadataData))
delta := int64(newILSTSize) - int64(oldILSTSize)
newFileSize = originalSize + delta
} else {
newFileSize = originalSize + int64(calculateAtomSize(len(metadataData))+8)
}
if newFileSize > originalSize {
if err := f.Truncate(newFileSize); err != nil {
return fmt.Errorf("failed to extend file: %w", err)
}
}
if ilstAtom != nil {
oldSize := calculateAtomSize(int(ilstAtom.DataSize))
newSize := calculateAtomSize(len(metadataData))
delta := int(newSize) - int(oldSize)
if err := shiftDataAfter(f, ilstAtom.Offset+8+int64(oldSize), int64(delta)); err != nil {
return fmt.Errorf("failed to shift data: %w", err)
}
if err := writeILSTAtom(f, ilstAtom.Offset, metadataData); err != nil {
return fmt.Errorf("failed to write ilst atom: %w", err)
}
} else {
moovAtom := findMoovAtom(atoms)
if moovAtom == nil {
return errors.New("missing moov atom")
}
insertionOffset := moovAtom.Offset + 8
if err := shiftDataAfter(f, insertionOffset, int64(calculateAtomSize(len(metadataData))+8)); err != nil {
return fmt.Errorf("failed to shift data for new atom: %w", err)
}
newILSTOffset := insertionOffset
if err := writeFullAtom(f, newILSTOffset, []byte("ilst"), metadataData); err != nil {
return fmt.Errorf("failed to write new ilst atom: %w", err)
}
}
updateFileTimes(filePath)
return nil
}
type mp4Atom struct {
Type [4]byte
Size uint32
DataSize uint32
Offset int64
Children []mp4Atom
}
func parseMP4Atoms(f *os.File) ([]mp4Atom, error) {
var atoms []mp4Atom
offset := int64(0)
for {
header := make([]byte, 8)
n, err := f.ReadAt(header, offset)
if err != nil || n < 8 {
break
}
size := binary.BigEndian.Uint32(header[:4])
atomType := [4]byte{}
copy(atomType[:], header[4:8])
if size == 0 {
break
}
if size == 1 {
extendedSize := make([]byte, 8)
if _, err := f.ReadAt(extendedSize, offset+8); err != nil || len(extendedSize) < 8 {
break
}
size = binary.BigEndian.Uint32(extendedSize[4:8])
}
var dataSize uint32
if size >= 8 {
dataSize = size - 8
}
atom := mp4Atom{
Type: atomType,
Size: size,
DataSize: dataSize,
Offset: offset,
}
if isContainerAtom(atomType) {
childOffset := offset + 8
childEnd := offset + int64(size)
for childOffset < childEnd {
childHeader := make([]byte, 8)
m, err := f.ReadAt(childHeader, childOffset)
if err != nil || m < 8 {
break
}
childSize := binary.BigEndian.Uint32(childHeader[:4])
if childSize == 0 {
break
}
childType := [4]byte{}
copy(childType[:], childHeader[4:8])
if isContainerAtom(childType) {
childAtoms, err := parseContainerAtom(f, childOffset)
if err == nil {
atom.Children = append(atom.Children, childAtoms...)
}
} else {
childAtom := mp4Atom{
Type: childType,
Size: childSize,
DataSize: childSize - 8,
Offset: childOffset,
}
atom.Children = append(atom.Children, childAtom)
}
childOffset += int64(childSize)
}
}
atoms = append(atoms, atom)
offset += int64(size)
}
return atoms, nil
}
func parseContainerAtom(f *os.File, offset int64) ([]mp4Atom, error) {
var atoms []mp4Atom
header := make([]byte, 8)
if _, err := f.ReadAt(header, offset); err != nil || len(header) < 8 {
return nil, err
}
parentSize := binary.BigEndian.Uint32(header[:4])
childEnd := offset + int64(parentSize) - 8
childOffset := offset + 8
for childOffset < childEnd {
childHeader := make([]byte, 8)
n, err := f.ReadAt(childHeader, childOffset)
if err != nil || n < 8 {
break
}
childSize := binary.BigEndian.Uint32(childHeader[:4])
if childSize == 0 {
break
}
childType := [4]byte{}
copy(childType[:], childHeader[4:8])
atom := mp4Atom{
Type: childType,
Size: childSize,
DataSize: childSize - 8,
Offset: childOffset,
}
atoms = append(atoms, atom)
childOffset += int64(childSize)
}
return atoms, nil
}
func isContainerAtom(atomType [4]byte) bool {
containerTypes := map[string]bool{
"moov": true,
"trak": true,
"mdia": true,
"minf": true,
"dinf": true,
"stbl": true,
"udta": true,
"ilst": true,
"meta": true,
"hdlr": true,
}
return containerTypes[string(atomType[:])]
}
func findMoovAtom(atoms []mp4Atom) *mp4Atom {
for i := range atoms {
if bytes.Equal(atoms[i].Type[:], []byte("moov")) {
return &atoms[i]
}
}
return nil
}
func findILSTAtom(atoms []mp4Atom) *mp4Atom {
for i := range atoms {
if bytes.Equal(atoms[i].Type[:], []byte("ilst")) {
return &atoms[i]
}
if len(atoms[i].Children) > 0 {
if child := findILSTAtom(atoms[i].Children); child != nil {
return child
}
}
}
return nil
}
func encodeILSTMetadata(tags Tags) ([]byte, error) {
data := bytes.NewBuffer(nil)
metadataPairs := map[string]string{
"\xa9nam": TagTitle,
"\xa9ART": TagArtist,
"\xa9alb": TagAlbum,
"\xa2A2": TagAlbumArtist,
"\xa9day": TagYear,
"\xa9gen": TagGenre,
"trkn": TagTrackNumber,
"disk": TagDiscNumber,
"cnmt": TagComment,
}
order := []string{"\xa9nam", "\xa9ART", "\xa9alb", "\xa2A2", "\xa9day", "\xa9gen", "trkn", "disk", "cnmt"}
for _, key := range order {
tagKey := metadataPairs[key]
if value, ok := tags[tagKey]; ok && value != "" {
atomData := encodeMP4Value(key, value, tagKey)
if len(atomData) > 0 {
data.Write(atomData)
}
}
}
if data.Len() == 0 {
return nil, nil
}
return data.Bytes(), nil
}
func encodeMP4Value(atomType, value, tagKey string) []byte {
var data []byte
switch tagKey {
case TagTrackNumber, TagDiscNumber:
data = encodeIntegerList(value, atomType)
case TagComment:
data = encodeUTF8Text(value, atomType)
default:
data = encodeUTF8Text(value, atomType)
}
if len(data) == 0 {
return nil
}
atomSize := uint32(len(data)) + 8
atom := make([]byte, 8)
binary.BigEndian.PutUint32(atom[0:4], atomSize)
copy(atom[4:8], []byte(atomType))
return append(atom, data...)
}
func encodeUTF8Text(value, atomType string) []byte {
data := bytes.NewBuffer(nil)
locale := []byte{0x00, 0x65, 0x6E, 0x67}
switch atomType {
case "\xa9nam", "\xa9ART", "\xa9alb", "\xa2A2", "\xa9day", "\xa9gen":
data.Write(locale)
data.WriteString(value)
data.WriteByte(0x00)
default:
data.Write(locale)
data.WriteString(value)
data.WriteByte(0x00)
}
return data.Bytes()
}
func encodeIntegerList(value, atomType string) []byte {
data := bytes.NewBuffer(nil)
var num, total int
fmt.Sscanf(value, "%d/%d", &num, &total)
if total == 0 {
num, _ = strconv.Atoi(value)
}
atomData := make([]byte, 4)
atomData[0] = 0x00
binary.BigEndian.PutUint16(atomData[2:], uint16(num))
data.Write(atomData)
if total > 0 {
totalData := make([]byte, 4)
totalData[0] = 0x00
binary.BigEndian.PutUint16(totalData[2:], uint16(total))
data.Write(totalData)
}
return data.Bytes()
}
func calculateAtomSize(dataSize int) int {
return dataSize + 8
}
func writeFullAtom(f *os.File, offset int64, atomType []byte, data []byte) error {
atomSize := uint32(len(data)) + 8
header := make([]byte, 8)
binary.BigEndian.PutUint32(header[0:4], atomSize)
copy(header[4:8], atomType)
if _, err := f.WriteAt(header, offset); err != nil {
return err
}
if _, err := f.WriteAt(data, offset+8); err != nil {
return err
}
return nil
}
func writeILSTAtom(f *os.File, offset int64, data []byte) error {
return writeFullAtom(f, offset, []byte("ilst"), data)
}
func shiftDataAfter(f *os.File, position int64, delta int64) error {
if delta <= 0 {
return nil
}
fileSize, err := f.Seek(0, os.SEEK_END)
if err != nil {
return err
}
buf := make([]byte, 8192)
for offset := fileSize; offset > position; offset -= int64(len(buf)) {
if offset < position+int64(len(buf)) {
buf = buf[:offset-position]
offset = position
}
dest := offset + delta
_, err := f.ReadAt(buf, offset)
if err != nil {
return err
}
_, err = f.WriteAt(buf, dest)
if err != nil {
return err
}
}
return nil
}
func updateFileTimes(filePath string) error {
now := time.Now()
return os.Chtimes(filePath, now, now)
}
func init() {
_ = os.Stdin
}

396
tagwriter/ogg.go Normal file
View File

@ -0,0 +1,396 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
)
func writeOGGTags(filePath string, tags Tags) error {
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("failed to open OGG file: %w", err)
}
defer f.Close()
header, err := readOGGPageHeader(f)
if err != nil {
return fmt.Errorf("invalid OGG file: %w", err)
}
if !bytes.Equal(header.Magic[:4], []byte("OggS")) {
return errors.New("invalid OGG file: missing OGGS header")
}
pages, err := parseOGGPages(f)
if err != nil {
return fmt.Errorf("failed to parse OGG pages: %w", err)
}
vorbisCommentPage, commentSegment, err := findVorbisCommentPage(pages, f)
if err != nil {
return fmt.Errorf("failed to find Vorbis comment: %w", err)
}
vorbisData := encodeVorbisCommentsOgg(tags)
if len(vorbisData) == 0 {
return nil
}
if vorbisCommentPage != nil {
if err := updateVorbisComment(f, vorbisCommentPage, commentSegment, vorbisData); err != nil {
return fmt.Errorf("failed to update Vorbis comment: %w", err)
}
} else {
if err := insertVorbisComment(f, header, vorbisData); err != nil {
return fmt.Errorf("failed to insert Vorbis comment: %w", err)
}
}
recalculateOGGChecksums(f)
updateFileTimes(filePath)
return nil
}
type oggPageHeader struct {
Magic [4]byte
Version byte
HeaderType byte
GranulePos uint64
Serial uint32
PageSeq uint32
Checksum uint32
PageSegments byte
}
type oggPage struct {
Header oggPageHeader
Offset int64
SegmentSizes []byte
SegmentsStart int64
DataStart int64
}
func readOGGPageHeader(f *os.File) (oggPageHeader, error) {
header := make([]byte, 27)
_, err := f.Read(header)
if err != nil {
return oggPageHeader{}, err
}
var h oggPageHeader
copy(h.Magic[:], header[0:4])
h.Version = header[4]
h.HeaderType = header[5]
h.GranulePos = binary.LittleEndian.Uint64(header[6:14])
h.Serial = binary.LittleEndian.Uint32(header[14:18])
h.PageSeq = binary.LittleEndian.Uint32(header[18:22])
h.Checksum = binary.LittleEndian.Uint32(header[22:26])
h.PageSegments = header[26]
return h, nil
}
func parseOGGPages(f *os.File) ([]oggPage, error) {
var pages []oggPage
offset := int64(0)
for {
header, err := readOGGPageHeaderAt(f, offset)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
segmentSizes := make([]byte, header.PageSegments)
if _, err := f.ReadAt(segmentSizes, offset+27); err != nil {
return nil, err
}
segmentsStart := offset + 27 + int64(header.PageSegments)
dataStart := segmentsStart
var totalDataSize int64
for _, segSize := range segmentSizes {
totalDataSize += int64(segSize)
}
page := oggPage{
Header: header,
Offset: offset,
SegmentSizes: segmentSizes,
SegmentsStart: segmentsStart,
DataStart: dataStart,
}
pages = append(pages, page)
pageSize := segmentsStart + totalDataSize - offset
offset += pageSize
if pageSize == 0 {
break
}
}
return pages, nil
}
func readOGGPageHeaderAt(f *os.File, offset int64) (oggPageHeader, error) {
header := make([]byte, 27)
_, err := f.ReadAt(header, offset)
if err != nil {
return oggPageHeader{}, err
}
var h oggPageHeader
copy(h.Magic[:], header[0:4])
h.Version = header[4]
h.HeaderType = header[5]
h.GranulePos = binary.LittleEndian.Uint64(header[6:14])
h.Serial = binary.LittleEndian.Uint32(header[14:18])
h.PageSeq = binary.LittleEndian.Uint32(header[18:22])
h.Checksum = binary.LittleEndian.Uint32(header[22:26])
h.PageSegments = header[26]
return h, nil
}
func findVorbisCommentPage(pages []oggPage, f *os.File) (*oggPage, int, error) {
for i, page := range pages {
if page.Header.HeaderType&0x02 == 0 {
continue
}
if len(page.SegmentSizes) == 0 {
continue
}
data := make([]byte, page.SegmentSizes[0])
if _, err := f.ReadAt(data, page.DataStart); err != nil {
continue
}
if len(data) >= 7 && bytes.Equal(data[0:7], []byte("vorbis")) {
return &pages[i], 0, nil
}
var cumulative int
for segIdx, segSize := range page.SegmentSizes {
cumulative += int(segSize)
if cumulative >= 7 {
headerData := make([]byte, segSize)
readOffset := page.DataStart + int64(cumulative - int(segSize))
f.ReadAt(headerData, readOffset)
if bytes.Equal(headerData[:7], []byte("vorbis")) {
return &pages[i], segIdx, nil
}
break
}
}
}
return nil, 0, errors.New("Vorbis comment header not found - creating new header")
}
func encodeVorbisCommentsOgg(tags Tags) []byte {
buf := make([]byte, 0)
vendor := "Navidrome"
vendorBytes := []byte(vendor)
buf = append(buf, encodeUint32LE(uint32(len(vendorBytes)))...)
buf = append(buf, vendorBytes...)
numComments := countNonEmptyTags(tags)
buf = append(buf, encodeUint32LE(uint32(numComments))...)
commentPairs := map[string]string{
"TITLE": TagTitle,
"ARTIST": TagArtist,
"ALBUM": TagAlbum,
"ALBUMARTIST": TagAlbumArtist,
"DATE": TagYear,
"YEAR": TagYear,
"GENRE": TagGenre,
"TRACKNUMBER": TagTrackNumber,
"TRACKTOTAL": TagTrackTotal,
"DISCNUMBER": TagDiscNumber,
"DISCTOTAL": TagDiscTotal,
"COMMENT": TagComment,
}
for vorbisKey, tagKey := range commentPairs {
if value, ok := tags[tagKey]; ok && value != "" {
comment := fmt.Sprintf("%s=%s", vorbisKey, value)
commentBytes := []byte(comment)
buf = append(buf, encodeUint32LE(uint32(len(commentBytes)))...)
buf = append(buf, commentBytes...)
}
}
return buf
}
func updateVorbisComment(f *os.File, page *oggPage, segmentIdx int, vorbisData []byte) error {
pageDataSize := int64(0)
for _, segSize := range page.SegmentSizes {
pageDataSize += int64(segSize)
}
oldDataSize := int64(0)
for i := segmentIdx; i < len(page.SegmentSizes); i++ {
oldDataSize += int64(page.SegmentSizes[i])
}
delta := int64(len(vorbisData)) - oldDataSize
if delta == 0 {
dataOffset := page.DataStart + pageDataSize - oldDataSize
_, err := f.WriteAt(vorbisData, dataOffset)
return err
}
if delta > 0 {
fileSize, err := f.Seek(0, os.SEEK_END)
if err != nil {
return err
}
pageEnd := page.Offset + 27 + int64(page.Header.PageSegments) + pageDataSize
moveBuf := make([]byte, 4096)
for pos := fileSize - 4096; pos >= pageEnd; pos -= 4096 {
_, err := f.ReadAt(moveBuf, pos)
if err != nil {
return err
}
_, err = f.WriteAt(moveBuf, pos+delta)
if err != nil {
return err
}
}
if fileSize-pageEnd < 4096 {
remaining := make([]byte, fileSize-pageEnd)
f.ReadAt(remaining, pageEnd)
f.WriteAt(remaining, pageEnd+delta)
}
}
dataOffset := page.DataStart
_, err := f.WriteAt(vorbisData, dataOffset)
return err
}
func insertVorbisComment(f *os.File, firstPage oggPageHeader, vorbisData []byte) error {
commentHeader := createVorbisCommentHeader(vorbisData)
commentData := append(commentHeader, vorbisData...)
newFirstPage := firstPage
newFirstPage.HeaderType |= 0x01
newPageSize := 27 + 1 + int64(len(commentData))
pageData := make([]byte, 0, newPageSize)
pageData = append(pageData, []byte("OggS")...)
pageData = append(pageData, newFirstPage.Version)
pageData = append(pageData, newFirstPage.HeaderType)
pageData = append(pageData, make([]byte, 8)...)
serialBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(serialBytes, newFirstPage.Serial)
pageData = append(pageData, serialBytes...)
seqBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(seqBytes, newFirstPage.PageSeq)
pageData = append(pageData, seqBytes...)
pageData = append(pageData, make([]byte, 4)...)
pageData = append(pageData, 1)
pageData = append(pageData, byte(len(commentData)))
pageData = append(pageData, commentData...)
_, err := f.WriteAt(pageData, 0)
return err
}
func createVorbisCommentHeader(data []byte) []byte {
header := make([]byte, 7)
copy(header, []byte("vorbis"))
return header
}
func recalculateOGGChecksums(f *os.File) error {
offset := int64(0)
for {
header := make([]byte, 27)
n, err := f.ReadAt(header, offset)
if err != nil || n < 27 {
if errors.Is(err, io.EOF) {
break
}
return err
}
if !bytes.Equal(header[0:4], []byte("OggS")) {
break
}
pageSegments := header[26]
segmentSizes := make([]byte, pageSegments)
f.ReadAt(segmentSizes, offset+27)
var pageSize int64 = 27 + int64(pageSegments)
for _, segSize := range segmentSizes {
pageSize += int64(segSize)
}
pageData := make([]byte, pageSize)
f.ReadAt(pageData, offset)
checksum := computeCRC(pageData)
checksumBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(checksumBytes, checksum)
f.WriteAt(checksumBytes, offset+22)
offset += pageSize
if offset <= 0 {
break
}
}
return nil
}
func computeCRC(data []byte) uint32 {
crcTable := make([]uint32, 256)
for i := range crcTable {
c := uint32(i)
for j := 0; j < 8; j++ {
if c&1 != 0 {
c = 0xedb88320 ^ (c >> 1)
} else {
c = c >> 1
}
}
crcTable[i] = c
}
var crc uint32 = 0xffffffff
for _, b := range data {
crc = crcTable[byte(crc)^b] ^ (crc >> 8)
}
return crc ^ 0xffffffff
}
func init() {
_ = os.Stdin
}

View File

@ -82,6 +82,12 @@ func (t *tagWriter) WriteTags(filePath string, tags Tags) error {
writeErr = writeMP3Tags(absPath, tags)
case ".flac":
writeErr = writeFLACTags(absPath, tags)
case ".wav", ".wave":
writeErr = writeWAVTags(absPath, tags)
case ".m4a", ".mp4":
writeErr = writeM4ATags(absPath, tags)
case ".ogg":
writeErr = writeOGGTags(absPath, tags)
default:
return ErrUnsupportedFormat
}
@ -116,7 +122,7 @@ func (t *tagWriter) checkFilePermissions(filePath string) error {
}
func SupportedFormats() []string {
return []string{".mp3", ".mp2", ".flac"}
return []string{".mp3", ".mp2", ".flac", ".wav", ".wave", ".m4a", ".mp4", ".ogg"}
}
func IsSupportedFormat(filePath string) bool {

View File

@ -35,7 +35,7 @@ var _ = Describe("TagWriter", func() {
})
It("returns error for unsupported formats", func() {
testFile := filepath.Join(testDir, "test.ogg")
testFile := filepath.Join(testDir, "test.xyz")
f, err := os.Create(testFile)
Expect(err).NotTo(HaveOccurred())
f.Close()
@ -87,17 +87,24 @@ var _ = Describe("TagWriter", func() {
})
Describe("IsSupportedFormat", func() {
It("returns true for supported formats", func() {
It("returns true for supported formats", func() {
Expect(IsSupportedFormat("test.mp3")).To(BeTrue())
Expect(IsSupportedFormat("test.MP3")).To(BeTrue())
Expect(IsSupportedFormat("test.flac")).To(BeTrue())
Expect(IsSupportedFormat("test.FLAC")).To(BeTrue())
Expect(IsSupportedFormat("test.wav")).To(BeTrue())
Expect(IsSupportedFormat("test.WAV")).To(BeTrue())
Expect(IsSupportedFormat("test.wave")).To(BeTrue())
Expect(IsSupportedFormat("test.m4a")).To(BeTrue())
Expect(IsSupportedFormat("test.M4A")).To(BeTrue())
Expect(IsSupportedFormat("test.mp4")).To(BeTrue())
Expect(IsSupportedFormat("test.ogg")).To(BeTrue())
Expect(IsSupportedFormat("test.OGG")).To(BeTrue())
})
It("returns false for unsupported formats", func() {
Expect(IsSupportedFormat("test.ogg")).To(BeFalse())
Expect(IsSupportedFormat("test.wav")).To(BeFalse())
Expect(IsSupportedFormat("test.m4a")).To(BeFalse())
Expect(IsSupportedFormat("test.xyz")).To(BeFalse())
Expect(IsSupportedFormat("test.abc")).To(BeFalse())
})
})

269
tagwriter/wav.go Normal file
View File

@ -0,0 +1,269 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
)
func writeWAVTags(filePath string, tags Tags) error {
f, err := os.OpenFile(filePath, os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("failed to open WAV file: %w", err)
}
defer f.Close()
riffHeader := make([]byte, 12)
if _, err := f.Read(riffHeader); err != nil {
return fmt.Errorf("failed to read RIFF header: %w", err)
}
if !bytes.Equal(riffHeader[:4], []byte("RIFF")) {
return errors.New("invalid WAV file: missing RIFF header")
}
if !bytes.Equal(riffHeader[8:12], []byte("WAVE")[:4]) {
return fmt.Errorf("invalid WAV file: expected WAVE format, found %q", string(riffHeader[8:12]))
}
chunks, err := parseRIFFChunks(f)
if err != nil {
return fmt.Errorf("failed to parse RIFF chunks: %w", err)
}
id3Chunk := findOrCreateID3Chunk(chunks)
id3Data, err := encodeID3v2Tags(tags)
if err != nil {
return fmt.Errorf("failed to encode ID3v2 tags: %w", err)
}
if len(id3Data) == 0 {
return nil
}
if id3Chunk != nil {
chunkEnd := id3Chunk.Offset + 8 + int64(id3Chunk.Size)
if id3Chunk.Size%2 != 0 {
chunkEnd++
}
if err := f.Truncate(chunkEnd); err != nil {
return fmt.Errorf("failed to truncate file: %w", err)
}
}
if _, err := f.Seek(0, io.SeekEnd); err != nil {
return fmt.Errorf("failed to seek to end: %w", err)
}
if err := writeRIFFChunk(f, []byte("id3 "), id3Data); err != nil {
return fmt.Errorf("failed to write id3 chunk: %w", err)
}
if err := updateRIFFSize(f); err != nil {
return fmt.Errorf("failed to update RIFF size: %w", err)
}
return nil
}
type riffChunk struct {
ID [4]byte
Size uint32
Offset int64
}
func parseRIFFChunks(f *os.File) ([]riffChunk, error) {
var chunks []riffChunk
offset := int64(12)
for {
chunkHeader := make([]byte, 8)
n, err := f.ReadAt(chunkHeader, offset)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
if n < 8 {
break
}
var chunk riffChunk
copy(chunk.ID[:], chunkHeader[:4])
chunk.Size = binary.LittleEndian.Uint32(chunkHeader[4:8])
chunk.Offset = offset
chunks = append(chunks, chunk)
padding := chunk.Size
if padding%2 != 0 {
padding++
}
offset += 8 + int64(padding)
}
return chunks, nil
}
func findOrCreateID3Chunk(chunks []riffChunk) *riffChunk {
for i := range chunks {
if bytes.Equal(chunks[i].ID[:], []byte("id3 ")) {
return &chunks[i]
}
}
return nil
}
func encodeID3v2Tags(tags Tags) ([]byte, error) {
frames := bytes.NewBuffer(nil)
if title, ok := tags[TagTitle]; ok && title != "" {
frames.Write(createTextFrame("TIT2", title))
}
if artist, ok := tags[TagArtist]; ok && artist != "" {
frames.Write(createTextFrame("TPE1", artist))
}
if album, ok := tags[TagAlbum]; ok && album != "" {
frames.Write(createTextFrame("TALB", album))
}
if albumArtist, ok := tags[TagAlbumArtist]; ok && albumArtist != "" {
frames.Write(createTextFrame("TPE2", albumArtist))
}
if year, ok := tags[TagYear]; ok && year != "" {
frames.Write(createTextFrame("TYER", year))
}
if genre, ok := tags[TagGenre]; ok && genre != "" {
frames.Write(createTextFrame("TCON", genre))
}
if trackNum, ok := tags[TagTrackNumber]; ok && trackNum != "" {
trackTotal, _ := tags[TagTrackTotal]
trackFrame := fmt.Sprintf("%s/%s", trackNum, trackTotal)
frames.Write(createTextFrame("TRCK", trackFrame))
}
if discNum, ok := tags[TagDiscNumber]; ok && discNum != "" {
discTotal, _ := tags[TagDiscTotal]
discFrame := fmt.Sprintf("%s/%s", discNum, discTotal)
frames.Write(createTextFrame("TPOS", discFrame))
}
if comment, ok := tags[TagComment]; ok && comment != "" {
frames.Write(createCommentFrame(comment))
}
if frames.Len() == 0 {
return nil, nil
}
tagSize := syncUint32(uint32(frames.Len()))
header := make([]byte, 10)
copy(header[0:3], []byte("ID3"))
header[3] = 0x03
header[4] = 0x00
header[5] = 0x00
copy(header[6:10], tagSize)
result := bytes.NewBuffer(header)
result.Write(frames.Bytes())
return result.Bytes(), nil
}
func createTextFrame(frameID string, text string) []byte {
textData := append([]byte{0x03}, []byte(text)...)
frame := make([]byte, 10)
copy(frame[0:4], []byte(frameID))
binary.BigEndian.PutUint32(frame[4:8], uint32(len(textData)))
frame[8] = 0x00
frame[9] = 0x00
return append(frame, textData...)
}
func createCommentFrame(text string) []byte {
frameData := new(bytes.Buffer)
frameData.WriteByte(0x03)
frameData.WriteString("eng")
frameData.WriteByte(0x00)
frameData.WriteString("")
frameData.WriteByte(0x00)
frameData.WriteString(text)
dataLen := frameData.Len()
frame := make([]byte, 10)
copy(frame[0:4], []byte("COMM"))
binary.BigEndian.PutUint32(frame[4:8], uint32(dataLen))
frame[8] = 0x00
frame[9] = 0x00
return append(frame, frameData.Bytes()...)
}
func syncUint32(n uint32) []byte {
result := make([]byte, 4)
result[0] = byte((n >> 21) & 0x7F)
result[1] = byte((n >> 14) & 0x7F)
result[2] = byte((n >> 7) & 0x7F)
result[3] = byte(n & 0x7F)
return result
}
func writeRIFFChunk(f *os.File, id []byte, data []byte) error {
chunk := make([]byte, 8)
copy(chunk[:4], id)
binary.LittleEndian.PutUint32(chunk[4:8], uint32(len(data)))
if _, err := f.Write(chunk); err != nil {
return err
}
if _, err := f.Write(data); err != nil {
return err
}
if len(data)%2 != 0 {
if _, err := f.Write([]byte{0}); err != nil {
return err
}
}
return nil
}
func updateRIFFSize(f *os.File) error {
fileSize, err := f.Seek(0, io.SeekEnd)
if err != nil {
return err
}
riffSize := uint32(fileSize - 8)
if riffSize%2 != 0 {
riffSize++
}
sizeBytes := make([]byte, 4)
binary.LittleEndian.PutUint32(sizeBytes, riffSize)
if _, err := f.WriteAt(sizeBytes, 4); err != nil {
return err
}
return nil
}
func init() {
_ = os.Stdin
}