mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
403 lines
9.0 KiB
Go
403 lines
9.0 KiB
Go
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)
|
|
}
|