mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 23:38:50 +02:00
449 lines
12 KiB
Go
449 lines
12 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"errors"
|
|
"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
|
|
}
|
|
|
|
// readNextOggEditPage parses a single page from r. Returns io.EOF at a clean
|
|
// page boundary.
|
|
func readNextOggEditPage(r *bufio.Reader) (*oggEditPage, error) {
|
|
header := make([]byte, 27)
|
|
if _, err := io.ReadFull(r, header); err != nil {
|
|
if err == io.EOF {
|
|
return nil, io.EOF
|
|
}
|
|
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")
|
|
}
|
|
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(r, 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(r, page.data); err != nil {
|
|
return nil, err
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
func (p *oggEditPage) byteSize() int64 {
|
|
return int64(27 + len(p.segments) + len(p.data))
|
|
}
|
|
|
|
// readAllOggEditPages parses the entire file into pages, rejecting multiplexed
|
|
// (multi-serial) streams the editor cannot safely rewrite. Test helper; the
|
|
// editor itself streams and never holds the whole file.
|
|
func readAllOggEditPages(f *os.File) ([]oggEditPage, error) {
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
return nil, err
|
|
}
|
|
br := bufio.NewReader(f)
|
|
var pages []oggEditPage
|
|
for {
|
|
page, err := readNextOggEditPage(br)
|
|
if err == io.EOF {
|
|
if len(pages) > 0 {
|
|
return pages, nil
|
|
}
|
|
return nil, fmt.Errorf("truncated ogg page header: %w", io.EOF)
|
|
}
|
|
if 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, errOggHeaderIncomplete
|
|
}
|
|
|
|
var errOggHeaderIncomplete = errors.New("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
|
|
}
|
|
// Read only the header pages into memory; audio pages are streamed to the
|
|
// temp file later — a full-album Opus file would otherwise sit whole on
|
|
// the heap.
|
|
br := bufio.NewReaderSize(f, 64<<10)
|
|
firstPage, err := readNextOggEditPage(br)
|
|
if err != nil {
|
|
f.Close()
|
|
if err == io.EOF {
|
|
return fmt.Errorf("ogg stream too short")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Identify the codec from the first packet (BOS page).
|
|
first := firstPage.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")
|
|
}
|
|
|
|
headerPages := []oggEditPage{*firstPage}
|
|
headerBytes := firstPage.byteSize()
|
|
var packets [][]byte
|
|
for {
|
|
var assembleErr error
|
|
packets, _, assembleErr = assembleOggHeaderPackets(headerPages, headerPacketCount)
|
|
if assembleErr == nil {
|
|
break
|
|
}
|
|
if !errors.Is(assembleErr, errOggHeaderIncomplete) {
|
|
f.Close()
|
|
return assembleErr
|
|
}
|
|
if len(headerPages) >= 1024 {
|
|
f.Close()
|
|
return fmt.Errorf("ogg header spans too many pages")
|
|
}
|
|
page, readErr := readNextOggEditPage(br)
|
|
if readErr != nil {
|
|
f.Close()
|
|
if readErr == io.EOF {
|
|
return assembleErr
|
|
}
|
|
return readErr
|
|
}
|
|
if page.serial != firstPage.serial {
|
|
f.Close()
|
|
return fmt.Errorf("multiplexed ogg streams are not supported")
|
|
}
|
|
headerPages = append(headerPages, *page)
|
|
headerBytes += page.byteSize()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
newHeaderPages := paginateOggPackets(headerPackets, firstPage.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 := firstPage.serialize(tmp); err != nil {
|
|
return cleanup(err)
|
|
}
|
|
for i := range newHeaderPages {
|
|
if err := newHeaderPages[i].serialize(tmp); err != nil {
|
|
return cleanup(err)
|
|
}
|
|
}
|
|
if len(newHeaderPages)+1 == len(headerPages) {
|
|
// Header page count unchanged: audio pages' sequence numbers and CRCs
|
|
// are still valid, copy them through verbatim.
|
|
if _, err := f.Seek(headerBytes, io.SeekStart); err != nil {
|
|
return cleanup(err)
|
|
}
|
|
if _, err := io.Copy(tmp, f); err != nil {
|
|
return cleanup(err)
|
|
}
|
|
} else {
|
|
nextSeq := uint32(1 + len(newHeaderPages))
|
|
for {
|
|
page, readErr := readNextOggEditPage(br)
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
return cleanup(readErr)
|
|
}
|
|
if page.serial != firstPage.serial {
|
|
return cleanup(fmt.Errorf("multiplexed ogg streams are not supported"))
|
|
}
|
|
page.seq = nextSeq
|
|
nextSeq++
|
|
if err := page.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
|
|
}
|