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:
2026-03-19 13:53:36 -04:00
parent 3face33a57
commit 5e98c03634
4 changed files with 840 additions and 0 deletions
+575
View File
@@ -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()
}
+259
View File
@@ -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)
}
+2
View File
@@ -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)
}
+4
View File
@@ -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)
}