mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
e2b466ecb2
commit
1dd5f4e781
426
tagwriter/m4a.go
Normal file
426
tagwriter/m4a.go
Normal 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
396
tagwriter/ogg.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
269
tagwriter/wav.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user