mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-19 08:12:14 +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.
458 lines
10 KiB
Go
458 lines
10 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
func ReadOggVorbisComments(filePath string) (*AudioMetadata, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
metadata := &AudioMetadata{}
|
|
|
|
packets, err := collectOggPackets(file, 30, 80)
|
|
if err != nil && len(packets) == 0 {
|
|
return nil, err
|
|
}
|
|
|
|
streamType := detectOggStreamType(packets)
|
|
for _, pkt := range packets {
|
|
if streamType == oggStreamOpus {
|
|
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
|
parseVorbisComments(pkt[8:], metadata)
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
if streamType == oggStreamVorbis || streamType == oggStreamUnknown {
|
|
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
|
|
parseVorbisComments(pkt[7:], metadata)
|
|
break
|
|
}
|
|
}
|
|
if streamType == oggStreamUnknown {
|
|
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
|
|
parseVorbisComments(pkt[8:], metadata)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if metadata.Title == "" && metadata.Artist == "" &&
|
|
metadata.ReplayGainTrackGain == "" && metadata.ReplayGainAlbumGain == "" {
|
|
return nil, fmt.Errorf("no Vorbis comments found")
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
type oggPage struct {
|
|
headerType byte
|
|
segmentTable []byte
|
|
data []byte
|
|
}
|
|
|
|
func readOggPageWithHeader(file *os.File) (*oggPage, error) {
|
|
header := make([]byte, 27)
|
|
if _, err := io.ReadFull(file, header); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if string(header[0:4]) != "OggS" {
|
|
return nil, fmt.Errorf("not an Ogg page")
|
|
}
|
|
|
|
headerType := header[5]
|
|
numSegments := int(header[26])
|
|
|
|
segmentTable := make([]byte, numSegments)
|
|
if _, err := io.ReadFull(file, segmentTable); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var pageSize int
|
|
for _, seg := range segmentTable {
|
|
pageSize += int(seg)
|
|
}
|
|
|
|
pageData := make([]byte, pageSize)
|
|
if _, err := io.ReadFull(file, pageData); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &oggPage{
|
|
headerType: headerType,
|
|
segmentTable: segmentTable,
|
|
data: pageData,
|
|
}, nil
|
|
}
|
|
|
|
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
|
|
const maxPacketSize = 10 * 1024 * 1024
|
|
var packets [][]byte
|
|
var cur []byte
|
|
skipPacket := false
|
|
|
|
for pageNum := 0; pageNum < maxPages && len(packets) < maxPackets; pageNum++ {
|
|
page, err := readOggPageWithHeader(file)
|
|
if err != nil {
|
|
if len(packets) > 0 {
|
|
return packets, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if page.headerType&0x01 == 0 && len(cur) > 0 {
|
|
cur = nil
|
|
skipPacket = false
|
|
}
|
|
|
|
offset := 0
|
|
for _, seg := range page.segmentTable {
|
|
segLen := int(seg)
|
|
if offset+segLen > len(page.data) {
|
|
return packets, fmt.Errorf("invalid ogg segment size")
|
|
}
|
|
|
|
if skipPacket {
|
|
offset += segLen
|
|
if segLen < 255 {
|
|
skipPacket = false
|
|
}
|
|
continue
|
|
}
|
|
|
|
if len(cur)+segLen > maxPacketSize {
|
|
cur = nil
|
|
skipPacket = true
|
|
offset += segLen
|
|
if segLen < 255 {
|
|
skipPacket = false
|
|
}
|
|
continue
|
|
}
|
|
|
|
cur = append(cur, page.data[offset:offset+segLen]...)
|
|
offset += segLen
|
|
|
|
if segLen < 255 {
|
|
if len(cur) > 0 {
|
|
packets = append(packets, cur)
|
|
}
|
|
cur = nil
|
|
if len(packets) >= maxPackets {
|
|
return packets, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return packets, nil
|
|
}
|
|
|
|
type oggStreamType int
|
|
|
|
const (
|
|
oggStreamUnknown oggStreamType = iota
|
|
oggStreamOpus
|
|
oggStreamVorbis
|
|
)
|
|
|
|
func detectOggStreamType(packets [][]byte) oggStreamType {
|
|
for _, p := range packets {
|
|
if len(p) >= 8 && string(p[0:8]) == "OpusHead" {
|
|
return oggStreamOpus
|
|
}
|
|
if len(p) > 7 && p[0] == 0x01 && string(p[1:7]) == "vorbis" {
|
|
return oggStreamVorbis
|
|
}
|
|
}
|
|
return oggStreamUnknown
|
|
}
|
|
|
|
func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|
if len(data) < 4 {
|
|
return
|
|
}
|
|
|
|
reader := bytes.NewReader(data)
|
|
artistValues := make([]string, 0, 1)
|
|
albumArtistValues := make([]string, 0, 1)
|
|
|
|
var vendorLen uint32
|
|
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
|
|
return
|
|
}
|
|
|
|
if vendorLen > uint32(len(data)-4) {
|
|
return
|
|
}
|
|
vendor := make([]byte, vendorLen)
|
|
if _, err := reader.Read(vendor); err != nil {
|
|
return
|
|
}
|
|
|
|
var commentCount uint32
|
|
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
|
|
return
|
|
}
|
|
|
|
for i := uint32(0); i < commentCount && i < 100; i++ {
|
|
var commentLen uint32
|
|
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
|
|
break
|
|
}
|
|
|
|
remaining := uint32(reader.Len())
|
|
if commentLen > remaining {
|
|
break
|
|
}
|
|
if commentLen > 512*1024 {
|
|
reader.Seek(int64(commentLen), io.SeekCurrent)
|
|
continue
|
|
}
|
|
|
|
comment := make([]byte, commentLen)
|
|
if _, err := reader.Read(comment); err != nil {
|
|
break
|
|
}
|
|
|
|
parts := strings.SplitN(string(comment), "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
|
|
key := strings.ToUpper(parts[0])
|
|
value := parts[1]
|
|
|
|
switch key {
|
|
case "TITLE":
|
|
metadata.Title = value
|
|
case "ARTIST":
|
|
artistValues = append(artistValues, value)
|
|
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
|
|
albumArtistValues = append(albumArtistValues, value)
|
|
case "ALBUM":
|
|
metadata.Album = value
|
|
case "DATE", "YEAR":
|
|
metadata.Date = value
|
|
if len(value) >= 4 {
|
|
metadata.Year = value[:4]
|
|
}
|
|
case "GENRE":
|
|
metadata.Genre = value
|
|
case "TRACKNUMBER", "TRACK":
|
|
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
|
case "DISCNUMBER", "DISC":
|
|
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
|
case "ISRC":
|
|
metadata.ISRC = value
|
|
case "COMPOSER":
|
|
metadata.Composer = value
|
|
case "COMMENT", "DESCRIPTION":
|
|
metadata.Comment = value
|
|
case "LYRICS", "UNSYNCEDLYRICS", "SYNCEDLYRICS":
|
|
if metadata.Lyrics == "" {
|
|
metadata.Lyrics = value
|
|
}
|
|
case "ORGANIZATION", "LABEL", "PUBLISHER":
|
|
metadata.Label = value
|
|
case "COPYRIGHT":
|
|
metadata.Copyright = value
|
|
case "ITUNESADVISORY":
|
|
metadata.Explicit = isTruthyTagValue(value)
|
|
case "RELEASETYPE":
|
|
metadata.AlbumType = value
|
|
case "BARCODE", "UPC":
|
|
metadata.UPC = value
|
|
case "COMPILATION":
|
|
if isTruthyTagValue(value) && metadata.AlbumType == "" {
|
|
metadata.AlbumType = "compilation"
|
|
}
|
|
case "REPLAYGAIN_TRACK_GAIN":
|
|
metadata.ReplayGainTrackGain = value
|
|
case "REPLAYGAIN_TRACK_PEAK":
|
|
metadata.ReplayGainTrackPeak = value
|
|
case "REPLAYGAIN_ALBUM_GAIN":
|
|
metadata.ReplayGainAlbumGain = value
|
|
case "REPLAYGAIN_ALBUM_PEAK":
|
|
metadata.ReplayGainAlbumPeak = value
|
|
// Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS
|
|
// reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so
|
|
// consumers see one representation; explicit REPLAYGAIN_* wins.
|
|
case "R128_TRACK_GAIN":
|
|
if metadata.ReplayGainTrackGain == "" {
|
|
if db, ok := r128ToReplayGainDb(value); ok {
|
|
metadata.ReplayGainTrackGain = db
|
|
}
|
|
}
|
|
case "R128_ALBUM_GAIN":
|
|
if metadata.ReplayGainAlbumGain == "" {
|
|
if db, ok := r128ToReplayGainDb(value); ok {
|
|
metadata.ReplayGainAlbumGain = db
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(artistValues) > 0 {
|
|
metadata.Artist = joinVorbisCommentValues(artistValues)
|
|
}
|
|
if len(albumArtistValues) > 0 {
|
|
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
|
|
}
|
|
}
|
|
|
|
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
|
|
// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference):
|
|
// rg = q/256 + 5. Inverse of applyOpusReplayGainEdits.
|
|
func r128ToReplayGainDb(raw string) (string, bool) {
|
|
q, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 16)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true
|
|
}
|
|
|
|
func GetOggQuality(filePath string) (*OggQuality, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
quality := &OggQuality{}
|
|
|
|
packets, err := collectOggPackets(file, 5, 10)
|
|
if err != nil && len(packets) == 0 {
|
|
return nil, err
|
|
}
|
|
|
|
streamType := detectOggStreamType(packets)
|
|
if streamType == oggStreamUnknown {
|
|
if strings.HasSuffix(strings.ToLower(filePath), ".opus") {
|
|
streamType = oggStreamOpus
|
|
} else {
|
|
streamType = oggStreamVorbis
|
|
}
|
|
}
|
|
|
|
isOpus := streamType == oggStreamOpus
|
|
var preSkip int
|
|
|
|
if isOpus {
|
|
for _, pkt := range packets {
|
|
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
|
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
|
if quality.SampleRate == 0 {
|
|
quality.SampleRate = 48000
|
|
}
|
|
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
for _, pkt := range packets {
|
|
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
|
|
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
return quality, nil
|
|
}
|
|
fileSize := stat.Size()
|
|
|
|
granule := readLastOggGranulePosition(file, fileSize)
|
|
if granule > 0 {
|
|
if isOpus {
|
|
totalSamples := granule - int64(preSkip)
|
|
if totalSamples > 0 {
|
|
durationSec := float64(totalSamples) / 48000.0
|
|
if durationSec > 0 {
|
|
quality.Duration = int(math.Round(durationSec))
|
|
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
|
}
|
|
}
|
|
} else if quality.SampleRate > 0 {
|
|
durationSec := float64(granule) / float64(quality.SampleRate)
|
|
if durationSec > 0 {
|
|
quality.Duration = int(math.Round(durationSec))
|
|
quality.Bitrate = int(float64(fileSize*8) / durationSec)
|
|
}
|
|
}
|
|
}
|
|
|
|
if quality.Bitrate <= 0 && quality.Duration > 0 {
|
|
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
|
|
}
|
|
if quality.Duration > 24*60*60 {
|
|
quality.Duration = 0
|
|
quality.Bitrate = 0
|
|
}
|
|
if quality.Bitrate > 0 && quality.Bitrate < 8000 {
|
|
quality.Bitrate = 0
|
|
}
|
|
|
|
return quality, nil
|
|
}
|
|
|
|
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
|
|
searchSize := int64(65536)
|
|
if searchSize > fileSize {
|
|
searchSize = fileSize
|
|
}
|
|
|
|
buf := make([]byte, searchSize)
|
|
offset := fileSize - searchSize
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
n, err := file.ReadAt(buf, offset)
|
|
if err != nil && n == 0 {
|
|
return 0
|
|
}
|
|
buf = buf[:n]
|
|
|
|
for i := n - 4; i >= 0; i-- {
|
|
if buf[i] != 'O' || buf[i+1] != 'g' || buf[i+2] != 'g' || buf[i+3] != 'S' {
|
|
continue
|
|
}
|
|
if i+27 > n {
|
|
continue
|
|
}
|
|
version := buf[i+4]
|
|
headerType := buf[i+5]
|
|
if version != 0 || headerType > 0x07 {
|
|
continue
|
|
}
|
|
segmentCount := int(buf[i+26])
|
|
headerLen := 27 + segmentCount
|
|
if i+headerLen > n {
|
|
continue
|
|
}
|
|
payloadLen := 0
|
|
for s := 0; s < segmentCount; s++ {
|
|
payloadLen += int(buf[i+27+s])
|
|
}
|
|
if i+headerLen+payloadLen > n {
|
|
continue
|
|
}
|
|
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
|
|
}
|
|
return 0
|
|
}
|