refactor(go): split Ogg parsing and cover-art extraction out of audio_metadata.go

This commit is contained in:
zarzet
2026-07-26 22:39:58 +07:00
parent 2dbf8f6f9b
commit 9dc44ecd88
3 changed files with 848 additions and 827 deletions
-827
View File
@@ -2,13 +2,10 @@ package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strconv"
"strings"
)
@@ -775,440 +772,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
return quality, nil
}
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 == "" {
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":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
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 the writer's replayGainDbToR128.
func r128ToReplayGainDb(raw string) (string, bool) {
q, err := strconv.Atoi(strings.TrimSpace(raw))
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
}
var id3v1Genres = []string{
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge",
"Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B",
@@ -1238,393 +801,3 @@ var id3v1Genres = []string{
"Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
"Thrash Metal", "Anime", "J-Pop", "Synthpop",
}
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
header := make([]byte, 10)
if _, err := io.ReadFull(file, header); err != nil {
return nil, "", err
}
if string(header[0:3]) != "ID3" {
return nil, "", fmt.Errorf("no ID3v2 header")
}
majorVersion := header[3]
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
tagData := make([]byte, size)
if _, err := io.ReadFull(file, tagData); err != nil {
return nil, "", err
}
pos := 0
var frameIDLen, headerLen int
if majorVersion == 2 {
frameIDLen = 3
headerLen = 6
} else {
frameIDLen = 4
headerLen = 10
}
for pos+headerLen < len(tagData) {
frameID := string(tagData[pos : pos+frameIDLen])
if frameID[0] == 0 {
break
}
var frameSize int
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
break
}
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
pos += headerLen + frameSize
}
return nil, "", fmt.Errorf("no cover art found")
}
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
if len(data) < 4 {
return nil, ""
}
pos := 0
encoding := data[pos]
pos++
var mimeType string
if version == 2 {
if pos+3 > len(data) {
return nil, ""
}
format := string(data[pos : pos+3])
pos += 3
switch format {
case "JPG":
mimeType = "image/jpeg"
case "PNG":
mimeType = "image/png"
default:
mimeType = "image/jpeg"
}
} else {
end := pos
for end < len(data) && data[end] != 0 {
end++
}
mimeType = string(data[pos:end])
pos = end + 1
}
if pos >= len(data) {
return nil, ""
}
pos++
if encoding == 0 || encoding == 3 {
for pos < len(data) && data[pos] != 0 {
pos++
}
pos++
} else {
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos++
}
}
if pos >= len(data) {
return nil, ""
}
return data[pos:], mimeType
}
func extractOggCoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
packets, err := collectOggPackets(file, 30, 80)
if err != nil && len(packets) == 0 {
return nil, "", err
}
streamType := detectOggStreamType(packets)
for _, pkt := range packets {
var comments []byte
if streamType == oggStreamOpus {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
}
} else {
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) == 0 && streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) > 0 {
imageData, mimeType := extractPictureFromVorbisComments(comments)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
}
return nil, "", fmt.Errorf("no cover art found")
}
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
if len(data) < 8 {
return nil, ""
}
reader := bytes.NewReader(data)
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return nil, ""
}
if vendorLen > uint32(len(data)-4) {
return nil, ""
}
reader.Seek(int64(vendorLen), io.SeekCurrent)
var commentCount uint32
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
return nil, ""
}
for i := uint32(0); i < commentCount && i < 100; i++ {
var commentLen uint32
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
break
}
if commentLen > 10000000 {
break
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
break
}
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, string(comment[len(key):]))
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
}
if err != nil {
continue
}
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
return imageData, mimeType
}
}
}
return nil, ""
}
func parseFLACPictureBlock(data []byte) ([]byte, string) {
if len(data) < 32 {
return nil, ""
}
reader := bytes.NewReader(data)
var pictureType uint32
binary.Read(reader, binary.BigEndian, &pictureType)
var mimeLen uint32
binary.Read(reader, binary.BigEndian, &mimeLen)
if mimeLen > 256 {
return nil, ""
}
mimeBytes := make([]byte, mimeLen)
reader.Read(mimeBytes)
mimeType := string(mimeBytes)
var descLen uint32
binary.Read(reader, binary.BigEndian, &descLen)
if descLen > 10000 {
return nil, ""
}
reader.Seek(int64(descLen), io.SeekCurrent)
reader.Seek(16, io.SeekCurrent)
var dataLen uint32
binary.Read(reader, binary.BigEndian, &dataLen)
if dataLen > 10000000 {
return nil, ""
}
imageData := make([]byte, dataLen)
reader.Read(imageData)
return imageData, mimeType
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
ext = strings.ToLower(filepath.Ext(displayNameHint))
}
switch ext {
case ".flac":
data, err := ExtractCoverArt(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) > 8 && string(data[1:4]) == "PNG" {
mimeType = "image/png"
}
return data, mimeType, nil
case ".mp3":
return extractMP3CoverArt(filePath)
case ".opus", ".ogg":
return extractOggCoverArt(filePath)
case ".m4a":
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
return explicitKey
}
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
}
return cacheKey
}
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
return jpgPath, pngPath
}
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath
}
return ""
}
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
if len(imageData) == 0 {
return "", fmt.Errorf("cover data is empty")
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
cachePath := jpgPath
if strings.Contains(mimeType, "png") {
cachePath = pngPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
}
+402
View File
@@ -0,0 +1,402 @@
package gobackend
import (
"bytes"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func extractMP3CoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
header := make([]byte, 10)
if _, err := io.ReadFull(file, header); err != nil {
return nil, "", err
}
if string(header[0:3]) != "ID3" {
return nil, "", fmt.Errorf("no ID3v2 header")
}
majorVersion := header[3]
size := int(header[6])<<21 | int(header[7])<<14 | int(header[8])<<7 | int(header[9])
tagData := make([]byte, size)
if _, err := io.ReadFull(file, tagData); err != nil {
return nil, "", err
}
pos := 0
var frameIDLen, headerLen int
if majorVersion == 2 {
frameIDLen = 3
headerLen = 6
} else {
frameIDLen = 4
headerLen = 10
}
for pos+headerLen < len(tagData) {
frameID := string(tagData[pos : pos+frameIDLen])
if frameID[0] == 0 {
break
}
var frameSize int
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
if frameSize <= 0 || pos+headerLen+frameSize > len(tagData) {
break
}
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
pos += headerLen + frameSize
}
return nil, "", fmt.Errorf("no cover art found")
}
func parseAPICFrame(data []byte, version byte) ([]byte, string) {
if len(data) < 4 {
return nil, ""
}
pos := 0
encoding := data[pos]
pos++
var mimeType string
if version == 2 {
if pos+3 > len(data) {
return nil, ""
}
format := string(data[pos : pos+3])
pos += 3
switch format {
case "JPG":
mimeType = "image/jpeg"
case "PNG":
mimeType = "image/png"
default:
mimeType = "image/jpeg"
}
} else {
end := pos
for end < len(data) && data[end] != 0 {
end++
}
mimeType = string(data[pos:end])
pos = end + 1
}
if pos >= len(data) {
return nil, ""
}
pos++
if encoding == 0 || encoding == 3 {
for pos < len(data) && data[pos] != 0 {
pos++
}
pos++
} else {
for pos+1 < len(data) {
if data[pos] == 0 && data[pos+1] == 0 {
pos += 2
break
}
pos++
}
}
if pos >= len(data) {
return nil, ""
}
return data[pos:], mimeType
}
func extractOggCoverArt(filePath string) ([]byte, string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, "", err
}
defer file.Close()
packets, err := collectOggPackets(file, 30, 80)
if err != nil && len(packets) == 0 {
return nil, "", err
}
streamType := detectOggStreamType(packets)
for _, pkt := range packets {
var comments []byte
if streamType == oggStreamOpus {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
}
} else {
if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) == 0 && streamType == oggStreamUnknown {
if len(pkt) > 8 && string(pkt[0:8]) == "OpusTags" {
comments = pkt[8:]
} else if len(pkt) > 7 && pkt[0] == 0x03 && string(pkt[1:7]) == "vorbis" {
comments = pkt[7:]
}
}
if len(comments) > 0 {
imageData, mimeType := extractPictureFromVorbisComments(comments)
if len(imageData) > 0 {
return imageData, mimeType, nil
}
}
}
return nil, "", fmt.Errorf("no cover art found")
}
func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
if len(data) < 8 {
return nil, ""
}
reader := bytes.NewReader(data)
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return nil, ""
}
if vendorLen > uint32(len(data)-4) {
return nil, ""
}
reader.Seek(int64(vendorLen), io.SeekCurrent)
var commentCount uint32
if err := binary.Read(reader, binary.LittleEndian, &commentCount); err != nil {
return nil, ""
}
for i := uint32(0); i < commentCount && i < 100; i++ {
var commentLen uint32
if err := binary.Read(reader, binary.LittleEndian, &commentLen); err != nil {
break
}
if commentLen > 10000000 {
break
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
break
}
key := "METADATA_BLOCK_PICTURE="
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
cleaned := strings.Map(func(r rune) rune {
switch r {
case '\n', '\r', ' ', '\t':
return -1
}
return r
}, string(comment[len(key):]))
decoded, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
}
if err != nil {
continue
}
imageData, mimeType := parseFLACPictureBlock(decoded)
if len(imageData) > 0 {
return imageData, mimeType
}
}
}
return nil, ""
}
func parseFLACPictureBlock(data []byte) ([]byte, string) {
if len(data) < 32 {
return nil, ""
}
reader := bytes.NewReader(data)
var pictureType uint32
binary.Read(reader, binary.BigEndian, &pictureType)
var mimeLen uint32
binary.Read(reader, binary.BigEndian, &mimeLen)
if mimeLen > 256 {
return nil, ""
}
mimeBytes := make([]byte, mimeLen)
reader.Read(mimeBytes)
mimeType := string(mimeBytes)
var descLen uint32
binary.Read(reader, binary.BigEndian, &descLen)
if descLen > 10000 {
return nil, ""
}
reader.Seek(int64(descLen), io.SeekCurrent)
reader.Seek(16, io.SeekCurrent)
var dataLen uint32
binary.Read(reader, binary.BigEndian, &dataLen)
if dataLen > 10000000 {
return nil, ""
}
imageData := make([]byte, dataLen)
reader.Read(imageData)
return imageData, mimeType
}
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == "" {
ext = strings.ToLower(filepath.Ext(displayNameHint))
}
switch ext {
case ".flac":
data, err := ExtractCoverArt(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) > 8 && string(data[1:4]) == "PNG" {
mimeType = "image/png"
}
return data, mimeType, nil
case ".mp3":
return extractMP3CoverArt(filePath)
case ".opus", ".ogg":
return extractOggCoverArt(filePath)
case ".m4a":
data, err := extractCoverFromM4A(filePath)
if err != nil {
return nil, "", err
}
mimeType := "image/jpeg"
if len(data) >= 8 &&
data[0] == 0x89 &&
data[1] == 0x50 &&
data[2] == 0x4E &&
data[3] == 0x47 {
mimeType = "image/png"
}
return data, mimeType, nil
case ".wav", ".aiff", ".aif", ".aifc":
return extractWAVAIFFCover(filePath)
default:
return nil, "", fmt.Errorf("unsupported format: %s", ext)
}
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
return explicitKey
}
cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
}
return cacheKey
}
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
return jpgPath, pngPath
}
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
if _, err := os.Stat(jpgPath); err == nil {
return jpgPath
}
if _, err := os.Stat(pngPath); err == nil {
return pngPath
}
return ""
}
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
if len(imageData) == 0 {
return "", fmt.Errorf("cover data is empty")
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return "", fmt.Errorf("failed to create cache dir: %w", err)
}
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
cachePath := jpgPath
if strings.Contains(mimeType, "png") {
cachePath = pngPath
}
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover: %w", err)
}
return cachePath, nil
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
return existing, nil
}
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
if err != nil {
return "", err
}
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
}
+446
View File
@@ -0,0 +1,446 @@
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 == "" {
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":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
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 the writer's replayGainDbToR128.
func r128ToReplayGainDb(raw string) (string, bool) {
q, err := strconv.Atoi(strings.TrimSpace(raw))
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
}