feat(metadata): add native Ogg/Opus vorbis comment editor

EditOggFields rebuilds only the OpusTags/vorbis comment packet and
repaginates the header pages; audio pages are copied verbatim and only
renumbered (with fresh CRCs) when the header page count changes. Cover art
is written as METADATA_BLOCK_PICTURE from the shared FLAC picture builder.
The field semantics are extracted from EditFlacFields into a shared
applyVorbisFieldEdits so FLAC and Ogg interpret edits identically.
Unsupported layouts (multiplexed streams, unknown codecs) return an error
so callers can fall back.
This commit is contained in:
zarzet
2026-07-11 12:46:51 +07:00
parent 2eab955f5f
commit 89e5f4bde9
2 changed files with 404 additions and 25 deletions
+32 -25
View File
@@ -447,6 +447,38 @@ func EditFlacFields(filePath string, fields map[string]string) error {
cmt = flacvorbis.New()
}
applyVorbisFieldEdits(cmt, fields)
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err == nil && len(coverData) > 0 {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picBlock, err := buildPictureBlock("", coverData)
if err == nil {
f.Meta = append(f.Meta, &picBlock)
}
}
}
return saveFlacFile(f, filePath)
}
// applyVorbisFieldEdits applies the editor's set-or-clear field semantics to a
// Vorbis comment block. Shared by the FLAC and Ogg/Opus editors so both
// formats interpret the fields map identically.
func applyVorbisFieldEdits(cmt *flacvorbis.MetaDataBlockVorbisComment, fields map[string]string) {
artistMode := fields["artist_tag_mode"]
// Mapping from fields-map key → one or more Vorbis Comment keys.
@@ -550,31 +582,6 @@ func EditFlacFields(filePath string, fields map[string]string) error {
removeCommentKey(cmt, "UNSYNCEDLYRICS")
}
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
} else {
f.Meta = append(f.Meta, &cmtBlock)
}
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) {
coverData, err := os.ReadFile(coverPath)
if err == nil && len(coverData) > 0 {
for i := len(f.Meta) - 1; i >= 0; i-- {
if f.Meta[i].Type == flac.Picture {
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
}
}
picBlock, err := buildPictureBlock("", coverData)
if err == nil {
f.Meta = append(f.Meta, &picBlock)
}
}
}
return saveFlacFile(f, filePath)
}
// writeVorbisMetadata writes all metadata fields to a Vorbis Comment block.
+372
View File
@@ -0,0 +1,372 @@
package gobackend
import (
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"os"
"path/filepath"
"strings"
flacvorbis "github.com/go-flac/flacvorbis/v2"
)
// Native Vorbis-comment editor for Ogg Opus/Vorbis files. Rebuilds only the
// comment header packet and repaginates the header pages; audio pages are
// copied verbatim (renumbered + re-CRC'd only when the header page count
// changes). No ffmpeg remux, so foreign comments survive and the container
// layout is untouched. Published via temp+fsync+rename.
// oggCRCTable implements the Ogg page checksum: CRC-32 with polynomial
// 0x04c11db7, no bit reflection, zero init, zero final xor.
var oggCRCTable = func() [256]uint32 {
var table [256]uint32
for i := range table {
r := uint32(i) << 24
for range 8 {
if r&0x80000000 != 0 {
r = (r << 1) ^ 0x04c11db7
} else {
r <<= 1
}
}
table[i] = r
}
return table
}()
func oggCRC(data []byte) uint32 {
var crc uint32
for _, b := range data {
crc = (crc << 8) ^ oggCRCTable[byte(crc>>24)^b]
}
return crc
}
type oggEditPage struct {
headerType byte
granule uint64
serial uint32
seq uint32
segments []byte
data []byte
}
func (p *oggEditPage) serialize(w io.Writer) error {
header := make([]byte, 27)
copy(header[0:4], "OggS")
header[5] = p.headerType
binary.LittleEndian.PutUint64(header[6:14], p.granule)
binary.LittleEndian.PutUint32(header[14:18], p.serial)
binary.LittleEndian.PutUint32(header[18:22], p.seq)
// CRC (bytes 22-26) is computed over the whole page with the field zeroed.
header[26] = byte(len(p.segments))
page := make([]byte, 0, len(header)+len(p.segments)+len(p.data))
page = append(page, header...)
page = append(page, p.segments...)
page = append(page, p.data...)
binary.LittleEndian.PutUint32(page[22:26], oggCRC(page))
_, err := w.Write(page)
return err
}
// readAllOggEditPages parses the entire file into pages, rejecting multiplexed
// (multi-serial) streams the editor cannot safely rewrite.
func readAllOggEditPages(f *os.File) ([]oggEditPage, error) {
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, err
}
var pages []oggEditPage
header := make([]byte, 27)
for {
if _, err := io.ReadFull(f, header); err != nil {
if err == io.EOF && len(pages) > 0 {
return pages, nil
}
return nil, fmt.Errorf("truncated ogg page header: %w", err)
}
if string(header[0:4]) != "OggS" || header[4] != 0 {
return nil, fmt.Errorf("invalid ogg page at page %d", len(pages))
}
page := oggEditPage{
headerType: header[5],
granule: binary.LittleEndian.Uint64(header[6:14]),
serial: binary.LittleEndian.Uint32(header[14:18]),
seq: binary.LittleEndian.Uint32(header[18:22]),
segments: make([]byte, int(header[26])),
}
if _, err := io.ReadFull(f, page.segments); err != nil {
return nil, err
}
size := 0
for _, s := range page.segments {
size += int(s)
}
page.data = make([]byte, size)
if _, err := io.ReadFull(f, page.data); err != nil {
return nil, err
}
if len(pages) > 0 && page.serial != pages[0].serial {
return nil, fmt.Errorf("multiplexed ogg streams are not supported")
}
pages = append(pages, page)
}
}
// assembleOggHeaderPackets extracts the first count packets and verifies the
// last one ends exactly at the end of its page (spec-required for Opus/Vorbis
// headers), returning the packets and the index of that last header page.
func assembleOggHeaderPackets(pages []oggEditPage, count int) ([][]byte, int, error) {
var packets [][]byte
var cur []byte
for pageIdx, page := range pages {
offset := 0
for segIdx, seg := range page.segments {
cur = append(cur, page.data[offset:offset+int(seg)]...)
offset += int(seg)
if seg < 255 { // packet complete
packets = append(packets, cur)
cur = nil
if len(packets) == count {
if segIdx != len(page.segments)-1 {
return nil, 0, fmt.Errorf("header packet shares a page with audio")
}
return packets, pageIdx, nil
}
}
}
}
return nil, 0, fmt.Errorf("incomplete ogg header packets")
}
// paginateOggPackets lays consecutive packets into pages (max 255 segments
// per page), setting the continued-packet flag on pages that begin mid-packet.
func paginateOggPackets(packets [][]byte, serial uint32, firstSeq uint32) []oggEditPage {
// Build the full lacing sequence with packet-spanning continuation info.
type lacing struct {
value byte
data []byte
}
var segs []lacing
for _, pkt := range packets {
rest := pkt
for {
n := len(rest)
if n >= 255 {
segs = append(segs, lacing{value: 255, data: rest[:255]})
rest = rest[255:]
continue
}
segs = append(segs, lacing{value: byte(n), data: rest})
break
}
}
var pages []oggEditPage
seq := firstSeq
for start := 0; start < len(segs); {
end := min(start+255, len(segs))
page := oggEditPage{granule: 0, serial: serial, seq: seq}
if start > 0 && segs[start-1].value == 255 {
page.headerType = 0x01 // continues a packet from the previous page
}
for _, s := range segs[start:end] {
page.segments = append(page.segments, s.value)
page.data = append(page.data, s.data...)
}
pages = append(pages, page)
seq++
start = end
}
return pages
}
// parseVorbisCommentBlock decodes vendor + raw comment strings.
func parseVorbisCommentBlock(data []byte) (string, []string, error) {
if len(data) < 4 {
return "", nil, fmt.Errorf("comment block too short")
}
vendorLen := int(binary.LittleEndian.Uint32(data[0:4]))
pos := 4 + vendorLen
if vendorLen < 0 || pos+4 > len(data) {
return "", nil, fmt.Errorf("invalid vendor length")
}
vendor := string(data[4 : 4+vendorLen])
count := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
pos += 4
comments := make([]string, 0, count)
for range count {
if pos+4 > len(data) {
return "", nil, fmt.Errorf("truncated comment list")
}
l := int(binary.LittleEndian.Uint32(data[pos : pos+4]))
pos += 4
if l < 0 || pos+l > len(data) {
return "", nil, fmt.Errorf("truncated comment entry")
}
comments = append(comments, string(data[pos:pos+l]))
pos += l
}
return vendor, comments, nil
}
func serializeVorbisCommentBlock(vendor string, comments []string) []byte {
size := 4 + len(vendor) + 4
for _, c := range comments {
size += 4 + len(c)
}
out := make([]byte, 0, size)
var num [4]byte
binary.LittleEndian.PutUint32(num[:], uint32(len(vendor)))
out = append(out, num[:]...)
out = append(out, vendor...)
binary.LittleEndian.PutUint32(num[:], uint32(len(comments)))
out = append(out, num[:]...)
for _, c := range comments {
binary.LittleEndian.PutUint32(num[:], uint32(len(c)))
out = append(out, num[:]...)
out = append(out, c...)
}
return out
}
// EditOggFields updates only the Vorbis comments whose keys are explicitly
// present in the fields map (same semantics as EditFlacFields) in an Ogg
// Opus or Vorbis file, preserving all other comments and the audio verbatim.
func EditOggFields(filePath string, fields map[string]string) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
pages, err := readAllOggEditPages(f)
if err != nil {
f.Close()
return err
}
if len(pages) < 2 {
f.Close()
return fmt.Errorf("ogg stream too short")
}
// Identify the codec from the first packet (BOS page).
first := pages[0].data
var headerPacketCount int
var isOpus bool
switch {
case len(first) >= 8 && string(first[0:8]) == "OpusHead":
isOpus = true
headerPacketCount = 2 // OpusHead, OpusTags
case len(first) >= 7 && first[0] == 0x01 && string(first[1:7]) == "vorbis":
headerPacketCount = 3 // id, comment, setup
default:
f.Close()
return fmt.Errorf("unsupported ogg codec")
}
packets, lastHeaderPage, err := assembleOggHeaderPackets(pages, headerPacketCount)
if err != nil {
f.Close()
return err
}
// Decode the comment packet.
commentPacket := packets[1]
var commentBody []byte
switch {
case isOpus && len(commentPacket) >= 8 && string(commentPacket[0:8]) == "OpusTags":
commentBody = commentPacket[8:]
case !isOpus && len(commentPacket) >= 7 && commentPacket[0] == 0x03 && string(commentPacket[1:7]) == "vorbis":
commentBody = commentPacket[7:] // trailing framing bit is ignored by the length-driven parser
default:
f.Close()
return fmt.Errorf("comment header not found")
}
vendor, comments, err := parseVorbisCommentBlock(commentBody)
if err != nil {
f.Close()
return err
}
cmt := flacvorbis.New()
cmt.Vendor = vendor
cmt.Comments = comments
applyVorbisFieldEdits(cmt, fields)
coverPath := strings.TrimSpace(fields["cover_path"])
if coverPath != "" && fileExists(coverPath) {
if coverData, err := os.ReadFile(coverPath); err == nil && len(coverData) > 0 {
if picBlock, err := buildPictureBlock("", coverData); err == nil {
removeCommentKey(cmt, "METADATA_BLOCK_PICTURE")
encoded := base64.StdEncoding.EncodeToString(picBlock.Data)
cmt.Comments = append(cmt.Comments, "METADATA_BLOCK_PICTURE="+encoded)
}
}
}
newBody := serializeVorbisCommentBlock(cmt.Vendor, cmt.Comments)
var newComment []byte
if isOpus {
newComment = append([]byte("OpusTags"), newBody...)
} else {
newComment = append([]byte{0x03}, []byte("vorbis")...)
newComment = append(newComment, newBody...)
newComment = append(newComment, 0x01) // framing bit
}
headerPackets := [][]byte{newComment}
if !isOpus {
headerPackets = append(headerPackets, packets[2]) // setup header, verbatim
}
serial := pages[0].serial
newHeaderPages := paginateOggPackets(headerPackets, serial, 1)
tmpPath := filePath + ".tag.partial"
os.Remove(tmpPath)
tmp, err := os.Create(tmpPath)
if err != nil {
f.Close()
return err
}
cleanup := func(err error) error {
tmp.Close()
os.Remove(tmpPath)
f.Close()
return err
}
if err := pages[0].serialize(tmp); err != nil {
return cleanup(err)
}
for i := range newHeaderPages {
if err := newHeaderPages[i].serialize(tmp); err != nil {
return cleanup(err)
}
}
nextSeq := uint32(1 + len(newHeaderPages))
for i := lastHeaderPage + 1; i < len(pages); i++ {
pages[i].seq = nextSeq
nextSeq++
if err := pages[i].serialize(tmp); err != nil {
return cleanup(err)
}
}
if err := tmp.Sync(); err != nil {
return cleanup(err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
f.Close()
return err
}
f.Close() // release before rename (required on Windows)
if err := os.Rename(tmpPath, filePath); err != nil {
os.Remove(tmpPath)
return err
}
syncDir(filepath.Dir(filePath))
return nil
}