From 1dd5f4e781415759231b7067778df5f9f74ca0e9 Mon Sep 17 00:00:00 2001 From: "serik.perez" Date: Sat, 2 May 2026 15:16:23 +0200 Subject: [PATCH] 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. --- tagwriter/m4a.go | 426 ++++++++++++++++++++++++++++++++++++ tagwriter/ogg.go | 396 +++++++++++++++++++++++++++++++++ tagwriter/tagwriter.go | 8 +- tagwriter/tagwriter_test.go | 17 +- tagwriter/wav.go | 269 +++++++++++++++++++++++ 5 files changed, 1110 insertions(+), 6 deletions(-) create mode 100644 tagwriter/m4a.go create mode 100644 tagwriter/ogg.go create mode 100644 tagwriter/wav.go diff --git a/tagwriter/m4a.go b/tagwriter/m4a.go new file mode 100644 index 000000000..4c8764b73 --- /dev/null +++ b/tagwriter/m4a.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/ogg.go b/tagwriter/ogg.go new file mode 100644 index 000000000..38c59937f --- /dev/null +++ b/tagwriter/ogg.go @@ -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 +} \ No newline at end of file diff --git a/tagwriter/tagwriter.go b/tagwriter/tagwriter.go index ec944f7ad..bd852b5cf 100644 --- a/tagwriter/tagwriter.go +++ b/tagwriter/tagwriter.go @@ -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 { diff --git a/tagwriter/tagwriter_test.go b/tagwriter/tagwriter_test.go index 1faea5c10..81cde7b4d 100644 --- a/tagwriter/tagwriter_test.go +++ b/tagwriter/tagwriter_test.go @@ -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()) }) }) diff --git a/tagwriter/wav.go b/tagwriter/wav.go new file mode 100644 index 000000000..3f5b2d475 --- /dev/null +++ b/tagwriter/wav.go @@ -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 +} \ No newline at end of file