diff --git a/backend/tagwriter/ogg.go b/backend/tagwriter/ogg.go new file mode 100644 index 0000000..2d57571 --- /dev/null +++ b/backend/tagwriter/ogg.go @@ -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() +} diff --git a/backend/tagwriter/ogg_vorbis.go b/backend/tagwriter/ogg_vorbis.go new file mode 100644 index 0000000..eb27211 --- /dev/null +++ b/backend/tagwriter/ogg_vorbis.go @@ -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) +} diff --git a/backend/tagwriter/pipeline.go b/backend/tagwriter/pipeline.go index f40fe6b..b5a7185 100644 --- a/backend/tagwriter/pipeline.go +++ b/backend/tagwriter/pipeline.go @@ -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) } diff --git a/backend/tagwriter/tagwriter.go b/backend/tagwriter/tagwriter.go index be9c43e..2e08ce1 100644 --- a/backend/tagwriter/tagwriter.go +++ b/backend/tagwriter/tagwriter.go @@ -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) }