mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 22:35:36 +02:00
Preserve audio and artwork while replacing conflicting Opus gain tags with R128 comments. Verify manual, download, and album writes, handle SAF failures, and refresh playback normalization after saving.
484 lines
14 KiB
Go
484 lines
14 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"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)
|
|
if isOpus {
|
|
if err := applyOpusReplayGainEdits(cmt, fields); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Opus uses R128 gain comments relative to -23 LUFS, in signed Q7.8 units.
|
|
// The scan already includes OpusHead's output gain; preserve that header and
|
|
// store only the additional adjustment. Remove legacy tags for edited scopes
|
|
// so players cannot select conflicting gains or peaks (RFC 7845 section 5.2).
|
|
func applyOpusReplayGainEdits(cmt *flacvorbis.MetaDataBlockVorbisComment, fields map[string]string) error {
|
|
for _, scope := range []string{"track", "album"} {
|
|
raw, present := fields["replaygain_"+scope+"_gain"]
|
|
if !present {
|
|
continue
|
|
}
|
|
value := ""
|
|
if raw = strings.TrimSpace(raw); raw != "" {
|
|
db, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(raw, "dB")), 64)
|
|
q := math.Round((db - 5) * 256)
|
|
if err != nil || math.IsNaN(q) || math.IsInf(q, 0) || q < -32768 || q > 32767 {
|
|
return fmt.Errorf("invalid Opus %s ReplayGain: %q", scope, raw)
|
|
}
|
|
value = strconv.Itoa(int(q))
|
|
}
|
|
upper := strings.ToUpper(scope)
|
|
setOrClearComment(cmt, "R128_"+upper+"_GAIN", value)
|
|
removeCommentKey(cmt, "REPLAYGAIN_"+upper+"_GAIN")
|
|
removeCommentKey(cmt, "REPLAYGAIN_"+upper+"_PEAK")
|
|
}
|
|
return nil
|
|
}
|