mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
refactor(go): split Ogg parsing and cover-art extraction out of audio_metadata.go
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user