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)
|
err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
|
||||||
case FormatWAV:
|
case FormatWAV:
|
||||||
err = writeWavTags(tw.logger, audioFile.FilePath, changes)
|
err = writeWavTags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
case FormatOGG:
|
||||||
|
err = writeOggTags(tw.logger, audioFile.FilePath, changes)
|
||||||
default:
|
default:
|
||||||
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
|
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const (
|
|||||||
FormatFLAC AudioFormat = "flac"
|
FormatFLAC AudioFormat = "flac"
|
||||||
// FormatWAV is the WAV audio format.
|
// FormatWAV is the WAV audio format.
|
||||||
FormatWAV AudioFormat = "wav"
|
FormatWAV AudioFormat = "wav"
|
||||||
|
// FormatOGG is the OGG Vorbis audio format.
|
||||||
|
FormatOGG AudioFormat = "ogg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnsupportedFormat is returned when the audio format is not supported.
|
// errUnsupportedFormat is returned when the audio format is not supported.
|
||||||
@@ -54,6 +56,8 @@ func DetectFormat(filePath string) (AudioFormat, error) {
|
|||||||
return FormatFLAC, nil
|
return FormatFLAC, nil
|
||||||
case ".wav":
|
case ".wav":
|
||||||
return FormatWAV, nil
|
return FormatWAV, nil
|
||||||
|
case ".ogg":
|
||||||
|
return FormatOGG, nil
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
|
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user