feat(20-01): implement OGG Vorbis tag writer with custom page parser and CRC32
- Custom OGG page parser/writer with MSB-first CRC32 lookup table (ogg.go) - Vorbis Comment packet parse/serialize with raw byte preservation (ogg_vorbis.go) - METADATA_BLOCK_PICTURE base64 encoding for cover art with legacy field stripping - Multi-stream and non-Vorbis OGG rejection with clear error messages - Pipeline integration: FormatOGG constant, .ogg in DetectFormat, writeOggTags dispatch - Lenient-read/strict-write: warn on CRC mismatch, always write correct CRCs - Page sequence renumbering and crash-safe writes via AtomicWrite
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"yellowjacket/backend/fileutil"
|
||||
)
|
||||
|
||||
// Sentinel errors for OGG operations.
|
||||
var (
|
||||
errNotOgg = errors.New("not an OGG file")
|
||||
errNotVorbis = errors.New("not an OGG Vorbis file")
|
||||
errOggMultiStream = errors.New("this OGG file contains multiple streams and cannot be edited")
|
||||
errOggTruncated = errors.New("OGG file is truncated")
|
||||
errOggInvalidPage = errors.New("invalid OGG page")
|
||||
errOggBadVersion = errors.New("unsupported OGG version")
|
||||
errOggNoPages = errors.New("OGG file contains no pages")
|
||||
errOggMissingSetup = errors.New("OGG file is missing the setup header packet")
|
||||
)
|
||||
|
||||
// oggCRCTable is the pre-computed 256-entry CRC32 lookup table for OGG.
|
||||
// OGG uses the standard CRC32 polynomial 0x04c11db7 with MSB-first
|
||||
// (unreflected) bit ordering. Go's hash/crc32 package uses reflected
|
||||
// (LSB-first) ordering and CANNOT be used here.
|
||||
var oggCRCTable [256]uint32 //nolint:gochecknoglobals // spec-mandated lookup table
|
||||
|
||||
func init() {
|
||||
const poly = 0x04c11db7 //nolint:mnd // OGG CRC32 polynomial
|
||||
|
||||
for i := range 256 {
|
||||
crc := uint32(i) << 24 //nolint:mnd // MSB-first table generation
|
||||
|
||||
for range 8 {
|
||||
if crc&(1<<31) != 0 { //nolint:mnd // check MSB
|
||||
crc = (crc << 1) ^ poly
|
||||
} else {
|
||||
crc <<= 1
|
||||
}
|
||||
}
|
||||
|
||||
oggCRCTable[i] = crc
|
||||
}
|
||||
}
|
||||
|
||||
// oggCRC computes the OGG CRC32 checksum over data using the MSB-first
|
||||
// (unreflected) algorithm. Initial value is 0; no final XOR.
|
||||
func oggCRC(data []byte) uint32 {
|
||||
var crc uint32
|
||||
|
||||
for _, b := range data {
|
||||
crc = (crc << 8) ^ oggCRCTable[(crc>>24)^uint32(b)] //nolint:mnd // MSB-first CRC update
|
||||
}
|
||||
|
||||
return crc
|
||||
}
|
||||
|
||||
// oggPage represents a single OGG page parsed from a file.
|
||||
type oggPage struct {
|
||||
headerType byte // 0x01=continued, 0x02=bos, 0x04=eos
|
||||
granulePos int64 // granule position (LE)
|
||||
serialNo uint32 // stream serial number (LE)
|
||||
seqNo uint32 // page sequence number (LE)
|
||||
segmentTable []byte // lacing values (each 0-255)
|
||||
data []byte // page body (sum of lacing values bytes)
|
||||
}
|
||||
|
||||
// parseOggPages reads the entire file and parses all OGG pages.
|
||||
// CRC mismatches produce a warning but do not reject the file
|
||||
// (lenient-read per project convention). Truncated files are rejected.
|
||||
// After parsing, the function validates that the stream is a single-stream
|
||||
// OGG Vorbis file (no multi-stream, no chained streams, no non-Vorbis).
|
||||
func parseOggPages(logger *slog.Logger, filePath string) ([]oggPage, error) {
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ogg file: %w", err)
|
||||
}
|
||||
|
||||
if len(raw) < 27 { //nolint:mnd // minimum OGG page header size
|
||||
return nil, errOggTruncated
|
||||
}
|
||||
|
||||
var pages []oggPage
|
||||
|
||||
offset := 0
|
||||
|
||||
for offset < len(raw) {
|
||||
// Verify capture pattern "OggS".
|
||||
if offset+4 > len(raw) || string(raw[offset:offset+4]) != "OggS" {
|
||||
return nil, fmt.Errorf("%w: bad capture pattern at offset %d", errNotOgg, offset)
|
||||
}
|
||||
|
||||
// Need at least 27 bytes for the fixed header.
|
||||
if offset+27 > len(raw) { //nolint:mnd // OGG page header size
|
||||
return nil, fmt.Errorf("%w: header truncated at offset %d", errOggTruncated, offset)
|
||||
}
|
||||
|
||||
version := raw[offset+4]
|
||||
if version != 0 {
|
||||
return nil, fmt.Errorf("%w: version %d at offset %d", errOggBadVersion, version, offset)
|
||||
}
|
||||
|
||||
headerType := raw[offset+5]
|
||||
granulePos := int64(binary.LittleEndian.Uint64(raw[offset+6 : offset+14]))
|
||||
serialNo := binary.LittleEndian.Uint32(raw[offset+14 : offset+18])
|
||||
seqNo := binary.LittleEndian.Uint32(raw[offset+18 : offset+22])
|
||||
storedCRC := binary.LittleEndian.Uint32(raw[offset+22 : offset+26])
|
||||
numSegments := int(raw[offset+26])
|
||||
|
||||
// Read segment table.
|
||||
segStart := offset + 27 //nolint:mnd // fixed header size
|
||||
segEnd := segStart + numSegments
|
||||
|
||||
if segEnd > len(raw) {
|
||||
return nil, fmt.Errorf("%w: segment table truncated at offset %d", errOggTruncated, offset)
|
||||
}
|
||||
|
||||
segmentTable := make([]byte, numSegments)
|
||||
copy(segmentTable, raw[segStart:segEnd])
|
||||
|
||||
// Compute total page body size from lacing values.
|
||||
bodySize := 0
|
||||
for _, s := range segmentTable {
|
||||
bodySize += int(s)
|
||||
}
|
||||
|
||||
dataStart := segEnd
|
||||
dataEnd := dataStart + bodySize
|
||||
|
||||
if dataEnd > len(raw) {
|
||||
return nil, fmt.Errorf("%w: page data truncated at offset %d", errOggTruncated, offset)
|
||||
}
|
||||
|
||||
pageData := make([]byte, bodySize)
|
||||
copy(pageData, raw[dataStart:dataEnd])
|
||||
|
||||
// CRC check: compute over entire page with CRC field zeroed.
|
||||
pageBytes := make([]byte, dataEnd-offset)
|
||||
copy(pageBytes, raw[offset:dataEnd])
|
||||
// Zero CRC field at offset 22-25 relative to page start.
|
||||
pageBytes[22] = 0
|
||||
pageBytes[23] = 0
|
||||
pageBytes[24] = 0
|
||||
pageBytes[25] = 0
|
||||
|
||||
computedCRC := oggCRC(pageBytes)
|
||||
if computedCRC != storedCRC {
|
||||
logger.Warn("OGG page CRC mismatch (lenient read, continuing)",
|
||||
slog.Int("page", len(pages)),
|
||||
slog.Int("offset", offset),
|
||||
slog.String("stored", fmt.Sprintf("0x%08x", storedCRC)),
|
||||
slog.String("computed", fmt.Sprintf("0x%08x", computedCRC)),
|
||||
)
|
||||
}
|
||||
|
||||
pages = append(pages, oggPage{
|
||||
headerType: headerType,
|
||||
granulePos: granulePos,
|
||||
serialNo: serialNo,
|
||||
seqNo: seqNo,
|
||||
segmentTable: segmentTable,
|
||||
data: pageData,
|
||||
})
|
||||
|
||||
offset = dataEnd
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
return nil, errOggNoPages
|
||||
}
|
||||
|
||||
// Validate: single stream (no multi-stream, no chained).
|
||||
serialNumbers := make(map[uint32]struct{})
|
||||
bosCount := 0
|
||||
|
||||
for _, p := range pages {
|
||||
serialNumbers[p.serialNo] = struct{}{}
|
||||
|
||||
if p.headerType&0x02 != 0 {
|
||||
bosCount++
|
||||
}
|
||||
}
|
||||
|
||||
if len(serialNumbers) > 1 || bosCount > 1 {
|
||||
return nil, errOggMultiStream
|
||||
}
|
||||
|
||||
// Validate: first page is Vorbis identification header.
|
||||
firstPacketData := pages[0].data
|
||||
if len(firstPacketData) < 7 || firstPacketData[0] != 0x01 || //nolint:mnd // Vorbis ID header magic
|
||||
string(firstPacketData[1:7]) != "vorbis" {
|
||||
return nil, errNotVorbis
|
||||
}
|
||||
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// writeOggPage serializes a single OGG page to w with a correctly
|
||||
// computed CRC32 checksum.
|
||||
func writeOggPage(w io.Writer, page oggPage) error {
|
||||
numSegments := len(page.segmentTable)
|
||||
headerSize := 27 + numSegments //nolint:mnd // fixed OGG header + segment table
|
||||
|
||||
buf := make([]byte, headerSize+len(page.data))
|
||||
|
||||
// Write capture pattern.
|
||||
copy(buf[0:4], "OggS")
|
||||
// Version = 0.
|
||||
buf[4] = 0
|
||||
// Header type.
|
||||
buf[5] = page.headerType
|
||||
// Granule position (LE).
|
||||
binary.LittleEndian.PutUint64(buf[6:14], uint64(page.granulePos))
|
||||
// Serial number (LE).
|
||||
binary.LittleEndian.PutUint32(buf[14:18], page.serialNo)
|
||||
// Sequence number (LE).
|
||||
binary.LittleEndian.PutUint32(buf[18:22], page.seqNo)
|
||||
// CRC placeholder (zeroed for computation).
|
||||
buf[22] = 0
|
||||
buf[23] = 0
|
||||
buf[24] = 0
|
||||
buf[25] = 0
|
||||
// Number of segments.
|
||||
buf[26] = byte(numSegments)
|
||||
// Segment table.
|
||||
copy(buf[27:27+numSegments], page.segmentTable)
|
||||
// Page data.
|
||||
copy(buf[headerSize:], page.data)
|
||||
|
||||
// Compute CRC over entire page (with CRC field = 0) and patch.
|
||||
crc := oggCRC(buf)
|
||||
binary.LittleEndian.PutUint32(buf[22:26], crc)
|
||||
|
||||
_, err := w.Write(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write ogg page: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractPackets reassembles logical packets from OGG pages using
|
||||
// lacing values. A segment with value 255 continues the packet;
|
||||
// a value <255 terminates it.
|
||||
func extractPackets(pages []oggPage) [][]byte {
|
||||
var packets [][]byte
|
||||
var current []byte
|
||||
|
||||
for _, page := range pages {
|
||||
dataOffset := 0
|
||||
|
||||
for _, lacing := range page.segmentTable {
|
||||
segSize := int(lacing)
|
||||
current = append(current, page.data[dataOffset:dataOffset+segSize]...)
|
||||
dataOffset += segSize
|
||||
|
||||
// A lacing value <255 terminates the packet.
|
||||
if lacing < 255 { //nolint:mnd // OGG lacing value max
|
||||
packets = append(packets, current)
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have data, it's a continuation that didn't terminate
|
||||
// (shouldn't happen in a well-formed file, but handle gracefully).
|
||||
if len(current) > 0 {
|
||||
packets = append(packets, current)
|
||||
}
|
||||
|
||||
return packets
|
||||
}
|
||||
|
||||
// splitPacketIntoSegments splits a packet into 255-byte segments
|
||||
// plus a final shorter segment. If the packet length is an exact
|
||||
// multiple of 255, a 0-length terminating segment is appended.
|
||||
func splitPacketIntoSegments(packet []byte) []byte {
|
||||
const segSize = 255
|
||||
|
||||
var segments []byte
|
||||
|
||||
for len(packet) >= segSize {
|
||||
segments = append(segments, segSize)
|
||||
packet = packet[segSize:]
|
||||
}
|
||||
|
||||
// Final segment (0..254 bytes) — terminates the packet.
|
||||
segments = append(segments, byte(len(packet)))
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
// buildPagesFromSegments builds OGG pages from serialized segment data,
|
||||
// respecting the 255-segment-per-page limit. The first page gets the
|
||||
// headerType as-is; continuation pages get the continued flag (0x01)
|
||||
// ORed in. All pages share the same serialNo and granulePos.
|
||||
func buildPagesFromSegments(segments []byte, packetData []byte, serialNo uint32, granulePos int64) []oggPage {
|
||||
const maxSegmentsPerPage = 255
|
||||
|
||||
var pages []oggPage
|
||||
|
||||
segIdx := 0
|
||||
dataIdx := 0
|
||||
|
||||
for segIdx < len(segments) {
|
||||
// Determine how many segments fit on this page.
|
||||
end := segIdx + maxSegmentsPerPage
|
||||
if end > len(segments) {
|
||||
end = len(segments)
|
||||
}
|
||||
|
||||
pageSegs := segments[segIdx:end]
|
||||
|
||||
// Compute data for this page.
|
||||
pageDataSize := 0
|
||||
for _, s := range pageSegs {
|
||||
pageDataSize += int(s)
|
||||
}
|
||||
|
||||
pageData := make([]byte, pageDataSize)
|
||||
copy(pageData, packetData[dataIdx:dataIdx+pageDataSize])
|
||||
|
||||
headerType := byte(0x00)
|
||||
if segIdx > 0 {
|
||||
// Continuation page.
|
||||
headerType = 0x01
|
||||
}
|
||||
|
||||
segTable := make([]byte, len(pageSegs))
|
||||
copy(segTable, pageSegs)
|
||||
|
||||
pages = append(pages, oggPage{
|
||||
headerType: headerType,
|
||||
granulePos: granulePos,
|
||||
serialNo: serialNo,
|
||||
segmentTable: segTable,
|
||||
data: pageData,
|
||||
})
|
||||
|
||||
segIdx = end
|
||||
dataIdx += pageDataSize
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// buildHeaderPages builds OGG pages for the comment and setup packets
|
||||
// combined. Both packets go into shared pages (per Vorbis spec),
|
||||
// with the setup packet ending on a page boundary.
|
||||
func buildHeaderPages(commentPacket, setupPacket []byte, serialNo uint32) []oggPage {
|
||||
// Build segment lacing values for both packets.
|
||||
commentSegs := splitPacketIntoSegments(commentPacket)
|
||||
setupSegs := splitPacketIntoSegments(setupPacket)
|
||||
|
||||
// Concatenate all segments from both packets.
|
||||
allSegs := append(commentSegs, setupSegs...) //nolint:gocritic // intentional append
|
||||
|
||||
// Concatenate all packet data.
|
||||
allData := append(commentPacket, setupPacket...) //nolint:gocritic // intentional append
|
||||
|
||||
// Build pages from the combined segments, respecting 255-per-page limit.
|
||||
// Granule position for header pages is 0 per Vorbis spec.
|
||||
pages := buildPagesFromSegments(allSegs, allData, serialNo, 0)
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// writeOggTags is the entry point for writing metadata to an OGG Vorbis file.
|
||||
// It reads all pages, modifies the Vorbis Comment packet, and rewrites the
|
||||
// entire file atomically via fileutil.AtomicWrite.
|
||||
func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error {
|
||||
// Warn for very large files (same threshold as FLAC/WAV writers).
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
const largeSizeThreshold = 500 * 1024 * 1024 //nolint:mnd // 500 MB
|
||||
|
||||
if info.Size() > largeSizeThreshold {
|
||||
logger.Warn("large OGG file may use significant memory",
|
||||
slog.String("path", filePath),
|
||||
slog.Int64("size", info.Size()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Read and parse all OGG pages (lenient CRC).
|
||||
pages, err := parseOggPages(logger, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse ogg: %w", err)
|
||||
}
|
||||
|
||||
// 2. Extract the 3 header packets from pages.
|
||||
// The first page (bos) contains the identification packet.
|
||||
// Subsequent header pages contain the comment and setup packets.
|
||||
packets := extractPackets(pages)
|
||||
|
||||
const minHeaderPackets = 3
|
||||
|
||||
if len(packets) < minHeaderPackets {
|
||||
return fmt.Errorf("ogg vorbis: expected at least 3 header packets, got %d", len(packets))
|
||||
}
|
||||
|
||||
_ = packets[0] // identification packet — preserved as page 0 unchanged
|
||||
commentPacket := packets[1]
|
||||
setupPacket := packets[2]
|
||||
|
||||
// Verify setup header magic.
|
||||
if len(setupPacket) < 7 || setupPacket[0] != 0x05 || //nolint:mnd // Vorbis setup header type
|
||||
string(setupPacket[1:7]) != "vorbis" {
|
||||
return errOggMissingSetup
|
||||
}
|
||||
|
||||
// 3. Parse Vorbis Comment from the comment packet.
|
||||
vc, err := parseVorbisCommentPacket(commentPacket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse vorbis comment: %w", err)
|
||||
}
|
||||
|
||||
// 4. Apply text changes.
|
||||
applyOggTextChanges(vc, changes)
|
||||
|
||||
// 5. Apply cover art changes.
|
||||
applyOggCoverArt(vc, changes)
|
||||
|
||||
// 6. Serialize modified Vorbis Comment back to packet bytes.
|
||||
newCommentPacket := serializeVorbisCommentPacket(vc)
|
||||
|
||||
// 7. Get the serial number from the first page.
|
||||
serialNo := pages[0].serialNo
|
||||
|
||||
// 8. Rebuild the page list.
|
||||
var rebuilt []oggPage
|
||||
|
||||
// Page 0: identification header (copy original bos page unchanged).
|
||||
rebuilt = append(rebuilt, pages[0])
|
||||
|
||||
// New header pages: comment + setup.
|
||||
headerPages := buildHeaderPages(newCommentPacket, setupPacket, serialNo)
|
||||
rebuilt = append(rebuilt, headerPages...)
|
||||
|
||||
// Audio pages: find where audio starts in the original pages.
|
||||
// Audio pages are all pages after the header pages.
|
||||
// We need to find the first page that contains audio data.
|
||||
// The header packets (ident, comment, setup) span some number of pages.
|
||||
// We identify audio pages by skipping pages until we've consumed
|
||||
// all 3 header packets.
|
||||
audioStartIdx := findAudioPageStart(pages)
|
||||
rebuilt = append(rebuilt, pages[audioStartIdx:]...)
|
||||
|
||||
// 9. Renumber ALL page sequence numbers sequentially from 0.
|
||||
for i := range rebuilt {
|
||||
rebuilt[i].seqNo = uint32(i)
|
||||
}
|
||||
|
||||
// 10. Write via AtomicWrite.
|
||||
return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error {
|
||||
w := io.Writer(tmp)
|
||||
for _, page := range rebuilt {
|
||||
if err := writeOggPage(w, page); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// findAudioPageStart determines the index of the first audio page
|
||||
// in the parsed page list. It walks through pages consuming lacing
|
||||
// values until 3 header packets have been fully read.
|
||||
func findAudioPageStart(pages []oggPage) int {
|
||||
packetsCompleted := 0
|
||||
|
||||
const headerPacketCount = 3
|
||||
|
||||
for i, page := range pages {
|
||||
for _, lacing := range page.segmentTable {
|
||||
if lacing < 255 { //nolint:mnd // OGG lacing value terminates packet
|
||||
packetsCompleted++
|
||||
|
||||
if packetsCompleted >= headerPacketCount {
|
||||
// All header packets consumed. If this is the last
|
||||
// segment on this page, audio starts on the next page.
|
||||
// If there are more segments, they belong to the next
|
||||
// page's audio (but they're on this page, so audio
|
||||
// starts here too — but we've already included the
|
||||
// setup packet data, so the audio pages start at i+1).
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: shouldn't happen in a valid file. Return after the
|
||||
// first page (identification header).
|
||||
if len(pages) > 1 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return len(pages)
|
||||
}
|
||||
|
||||
// serializePacketsToPages is a helper that builds pages from multiple
|
||||
// packets concatenated into the same page stream. This is used for
|
||||
// the comment + setup header packets which share pages per Vorbis spec.
|
||||
func serializePacketsToPages(packets [][]byte, serialNo uint32, granulePos int64) []oggPage {
|
||||
var allSegs []byte
|
||||
var allData []byte
|
||||
|
||||
for _, pkt := range packets {
|
||||
segs := splitPacketIntoSegments(pkt)
|
||||
allSegs = append(allSegs, segs...)
|
||||
allData = append(allData, pkt...)
|
||||
}
|
||||
|
||||
return buildPagesFromSegments(allSegs, allData, serialNo, granulePos)
|
||||
}
|
||||
|
||||
// computePacketBoundaryInPages determines how many complete packets
|
||||
// exist starting from the beginning of a sequence of pages, using
|
||||
// lacing values. It returns the number of complete packets and
|
||||
// whether the reading ended mid-packet (a continued packet).
|
||||
func computePacketBoundaryInPages(pages []oggPage) (completedPackets int, midPacket bool) {
|
||||
inPacket := false
|
||||
|
||||
for _, page := range pages {
|
||||
for _, lacing := range page.segmentTable {
|
||||
if lacing > 0 {
|
||||
inPacket = true
|
||||
}
|
||||
|
||||
if lacing < 255 { //nolint:mnd // OGG lacing terminates packet
|
||||
if inPacket || lacing == 0 {
|
||||
completedPackets++
|
||||
inPacket = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completedPackets, inPacket
|
||||
}
|
||||
|
||||
// oggPagesCopy creates a deep copy of a slice of oggPage.
|
||||
func oggPagesCopy(pages []oggPage) []oggPage {
|
||||
cp := make([]oggPage, len(pages))
|
||||
|
||||
for i, p := range pages {
|
||||
cp[i] = oggPage{
|
||||
headerType: p.headerType,
|
||||
granulePos: p.granulePos,
|
||||
serialNo: p.serialNo,
|
||||
seqNo: p.seqNo,
|
||||
}
|
||||
|
||||
cp[i].segmentTable = make([]byte, len(p.segmentTable))
|
||||
copy(cp[i].segmentTable, p.segmentTable)
|
||||
|
||||
cp[i].data = make([]byte, len(p.data))
|
||||
copy(cp[i].data, p.data)
|
||||
}
|
||||
|
||||
return cp
|
||||
}
|
||||
|
||||
// pageBytes serializes an OGG page to raw bytes (for CRC verification etc.).
|
||||
func pageBytes(page oggPage) []byte {
|
||||
var buf bytes.Buffer
|
||||
_ = writeOggPage(&buf, page)
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package tagwriter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// oggVorbisComment holds a parsed Vorbis Comment structure with raw
|
||||
// byte preservation for non-edited fields (even if they contain
|
||||
// invalid UTF-8).
|
||||
type oggVorbisComment struct {
|
||||
vendor []byte // raw vendor string bytes (preserved)
|
||||
entries [][]byte // raw "FIELD=value" entries as byte slices
|
||||
}
|
||||
|
||||
// parseVorbisCommentPacket parses a Vorbis Comment header packet.
|
||||
// The packet must start with the 7-byte prefix "\x03vorbis".
|
||||
// The trailing framing bit is consumed but not validated.
|
||||
func parseVorbisCommentPacket(packet []byte) (*oggVorbisComment, error) {
|
||||
const prefixLen = 7 // \x03 + "vorbis"
|
||||
|
||||
if len(packet) < prefixLen {
|
||||
return nil, fmt.Errorf("vorbis comment packet too short: %d bytes", len(packet))
|
||||
}
|
||||
|
||||
if packet[0] != 0x03 || string(packet[1:7]) != "vorbis" { //nolint:mnd // Vorbis comment header magic
|
||||
return nil, fmt.Errorf("invalid vorbis comment header magic")
|
||||
}
|
||||
|
||||
r := bytes.NewReader(packet[prefixLen:])
|
||||
|
||||
// Read vendor string.
|
||||
var vendorLen uint32
|
||||
if err := binary.Read(r, binary.LittleEndian, &vendorLen); err != nil {
|
||||
return nil, fmt.Errorf("read vendor length: %w", err)
|
||||
}
|
||||
|
||||
vendor := make([]byte, vendorLen)
|
||||
if _, err := r.Read(vendor); err != nil {
|
||||
return nil, fmt.Errorf("read vendor string: %w", err)
|
||||
}
|
||||
|
||||
// Read comment count.
|
||||
var commentCount uint32
|
||||
if err := binary.Read(r, binary.LittleEndian, &commentCount); err != nil {
|
||||
return nil, fmt.Errorf("read comment count: %w", err)
|
||||
}
|
||||
|
||||
entries := make([][]byte, 0, commentCount)
|
||||
|
||||
for i := range commentCount {
|
||||
var entryLen uint32
|
||||
if err := binary.Read(r, binary.LittleEndian, &entryLen); err != nil {
|
||||
return nil, fmt.Errorf("read comment %d length: %w", i, err)
|
||||
}
|
||||
|
||||
entry := make([]byte, entryLen)
|
||||
if _, err := r.Read(entry); err != nil {
|
||||
return nil, fmt.Errorf("read comment %d data: %w", i, err)
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
// Ignore the trailing framing bit (if present).
|
||||
|
||||
return &oggVorbisComment{
|
||||
vendor: vendor,
|
||||
entries: entries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// serializeVorbisCommentPacket serializes a Vorbis Comment structure
|
||||
// back to a packet including the \x03vorbis prefix and trailing
|
||||
// framing bit 0x01.
|
||||
func serializeVorbisCommentPacket(vc *oggVorbisComment) []byte {
|
||||
// Calculate total size.
|
||||
size := 7 + 4 + len(vc.vendor) + 4 //nolint:mnd // prefix + vendor_len + vendor + count
|
||||
|
||||
for _, e := range vc.entries {
|
||||
size += 4 + len(e) //nolint:mnd // entry_len + entry
|
||||
}
|
||||
|
||||
size++ // framing bit
|
||||
|
||||
buf := make([]byte, 0, size)
|
||||
|
||||
// Prefix: \x03 + "vorbis".
|
||||
buf = append(buf, 0x03)
|
||||
buf = append(buf, "vorbis"...)
|
||||
|
||||
// Vendor length + vendor string.
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(vc.vendor)))
|
||||
buf = append(buf, vc.vendor...)
|
||||
|
||||
// Comment count.
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(vc.entries)))
|
||||
|
||||
// Each comment entry.
|
||||
for _, e := range vc.entries {
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(e)))
|
||||
buf = append(buf, e...)
|
||||
}
|
||||
|
||||
// Framing bit: single byte 0x01.
|
||||
buf = append(buf, 0x01)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// replaceField removes all existing entries for field (case-insensitive)
|
||||
// and adds a new entry with the uppercase field name.
|
||||
func (vc *oggVorbisComment) replaceField(field, value string) {
|
||||
prefix := []byte(strings.ToUpper(field) + "=")
|
||||
filtered := make([][]byte, 0, len(vc.entries))
|
||||
|
||||
for _, entry := range vc.entries {
|
||||
if !bytes.HasPrefix(bytes.ToUpper(entry), prefix) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, []byte(strings.ToUpper(field)+"="+value))
|
||||
vc.entries = filtered
|
||||
}
|
||||
|
||||
// removeField removes all entries for field (case-insensitive)
|
||||
// without adding a replacement. Used for stripping legacy cover art fields.
|
||||
func (vc *oggVorbisComment) removeField(field string) {
|
||||
prefix := []byte(strings.ToUpper(field) + "=")
|
||||
filtered := make([][]byte, 0, len(vc.entries))
|
||||
|
||||
for _, entry := range vc.entries {
|
||||
if !bytes.HasPrefix(bytes.ToUpper(entry), prefix) {
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
}
|
||||
|
||||
vc.entries = filtered
|
||||
}
|
||||
|
||||
// oggFieldMappings maps TagChanges field names to Vorbis Comment field names.
|
||||
// Same mappings as FLAC (Vorbis Comment field names are identical).
|
||||
var oggFieldMappings = []struct { //nolint:gochecknoglobals // field mapping table
|
||||
key string
|
||||
vorbisID string
|
||||
isInt bool
|
||||
}{
|
||||
{FieldTitle, "TITLE", false},
|
||||
{FieldArtist, "ARTIST", false},
|
||||
{FieldAlbum, "ALBUM", false},
|
||||
{FieldAlbumArtist, "ALBUMARTIST", false},
|
||||
{FieldGenre, "GENRE", false},
|
||||
{FieldYear, "DATE", true},
|
||||
{FieldTrackNumber, "TRACKNUMBER", true},
|
||||
{FieldDiscNumber, "DISCNUMBER", true},
|
||||
{FieldComposer, "COMPOSER", false},
|
||||
}
|
||||
|
||||
// applyOggTextChanges applies text field changes to a Vorbis Comment.
|
||||
func applyOggTextChanges(vc *oggVorbisComment, changes TagChanges) {
|
||||
for _, m := range oggFieldMappings {
|
||||
v, ok := changes[m.key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var val string
|
||||
|
||||
if m.isInt {
|
||||
n, ok := asInt(v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
val = strconv.Itoa(n)
|
||||
} else {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
val = s
|
||||
}
|
||||
|
||||
vc.replaceField(m.vorbisID, val)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMetadataBlockPicture builds the binary FLAC PICTURE block for
|
||||
// embedding in a Vorbis Comment METADATA_BLOCK_PICTURE field.
|
||||
// All lengths are big-endian uint32 per the FLAC picture block spec.
|
||||
func buildMetadataBlockPicture(imageData []byte) []byte {
|
||||
mime := detectMIME(imageData)
|
||||
desc := "Front cover"
|
||||
|
||||
// Calculate size: type(4) + mimeLen(4) + mime + descLen(4) + desc
|
||||
// + width(4) + height(4) + depth(4) + colors(4) + dataLen(4) + data
|
||||
size := 4 + 4 + len(mime) + 4 + len(desc) + 4*4 + 4 + len(imageData) //nolint:mnd // FLAC PICTURE block fields
|
||||
|
||||
buf := make([]byte, 0, size)
|
||||
|
||||
// Picture type: 3 = front cover (big-endian).
|
||||
buf = binary.BigEndian.AppendUint32(buf, 3) //nolint:mnd // PictureTypeFrontCover
|
||||
|
||||
// MIME type.
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(len(mime)))
|
||||
buf = append(buf, mime...)
|
||||
|
||||
// Description.
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(len(desc)))
|
||||
buf = append(buf, desc...)
|
||||
|
||||
// Width, height, color depth, indexed colors — all 0 (unknown).
|
||||
buf = binary.BigEndian.AppendUint32(buf, 0)
|
||||
buf = binary.BigEndian.AppendUint32(buf, 0)
|
||||
buf = binary.BigEndian.AppendUint32(buf, 0)
|
||||
buf = binary.BigEndian.AppendUint32(buf, 0)
|
||||
|
||||
// Picture data.
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(len(imageData)))
|
||||
buf = append(buf, imageData...)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// applyOggCoverArt handles cover art changes for OGG Vorbis files.
|
||||
// When cover art is present in changes:
|
||||
// - Always removes all METADATA_BLOCK_PICTURE, COVERART, and COVERARTMIME entries
|
||||
// - If value is non-nil []byte with len>0: builds FLAC PICTURE block,
|
||||
// base64-encodes it, adds as METADATA_BLOCK_PICTURE entry
|
||||
// - If value is nil or empty: just the removal (clear all art)
|
||||
func applyOggCoverArt(vc *oggVorbisComment, changes TagChanges) {
|
||||
v, ok := changes[FieldCoverArt]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Always strip all picture-related fields.
|
||||
vc.removeField("METADATA_BLOCK_PICTURE")
|
||||
vc.removeField("COVERART")
|
||||
vc.removeField("COVERARTMIME")
|
||||
|
||||
// If value is nil or empty, we've cleared the art — done.
|
||||
data, isBytes := asBytes(v)
|
||||
if !isBytes || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build METADATA_BLOCK_PICTURE and base64-encode.
|
||||
pictureBlock := buildMetadataBlockPicture(data)
|
||||
encoded := base64.StdEncoding.EncodeToString(pictureBlock)
|
||||
|
||||
vc.replaceField("METADATA_BLOCK_PICTURE", encoded)
|
||||
}
|
||||
@@ -146,6 +146,8 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error {
|
||||
err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
|
||||
case FormatWAV:
|
||||
err = writeWavTags(tw.logger, audioFile.FilePath, changes)
|
||||
case FormatOGG:
|
||||
err = writeOggTags(tw.logger, audioFile.FilePath, changes)
|
||||
default:
|
||||
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ const (
|
||||
FormatFLAC AudioFormat = "flac"
|
||||
// FormatWAV is the WAV audio format.
|
||||
FormatWAV AudioFormat = "wav"
|
||||
// FormatOGG is the OGG Vorbis audio format.
|
||||
FormatOGG AudioFormat = "ogg"
|
||||
)
|
||||
|
||||
// errUnsupportedFormat is returned when the audio format is not supported.
|
||||
@@ -54,6 +56,8 @@ func DetectFormat(filePath string) (AudioFormat, error) {
|
||||
return FormatFLAC, nil
|
||||
case ".wav":
|
||||
return FormatWAV, nil
|
||||
case ".ogg":
|
||||
return FormatOGG, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user