mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
960 lines
25 KiB
Go
960 lines
25 KiB
Go
package gobackend
|
|
|
|
// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and
|
|
// cover-art extraction. These containers are not handled by go-flac, so chunks
|
|
// are parsed/written by hand here.
|
|
//
|
|
// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase
|
|
// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because
|
|
// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe
|
|
// frame sizes and UTF-8 text, so anything we write is read back losslessly.
|
|
//
|
|
// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files
|
|
// that carry only RIFF INFO tags (common from other taggers).
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes.
|
|
type WAVQuality struct {
|
|
SampleRate int
|
|
BitDepth int
|
|
Channels int
|
|
Duration int
|
|
}
|
|
|
|
const (
|
|
wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk
|
|
id3ChunkWAV = "id3 "
|
|
id3ChunkAIFF = "ID3 "
|
|
wavFormatPCM = 0x0001
|
|
wavFormatFloat = 0x0003
|
|
wavFormatExtensn = 0xFFFE
|
|
)
|
|
|
|
func putUint32(dst []byte, le bool, v uint32) {
|
|
if le {
|
|
binary.LittleEndian.PutUint32(dst, v)
|
|
} else {
|
|
binary.BigEndian.PutUint32(dst, v)
|
|
}
|
|
}
|
|
|
|
func readUint32(b []byte, le bool) uint32 {
|
|
if le {
|
|
return binary.LittleEndian.Uint32(b)
|
|
}
|
|
return binary.BigEndian.Uint32(b)
|
|
}
|
|
|
|
func synchsafeEncode(n int) []byte {
|
|
return []byte{
|
|
byte((n >> 21) & 0x7f),
|
|
byte((n >> 14) & 0x7f),
|
|
byte((n >> 7) & 0x7f),
|
|
byte(n & 0x7f),
|
|
}
|
|
}
|
|
|
|
func synchsafeDecode(b []byte) int {
|
|
if len(b) < 4 {
|
|
return 0
|
|
}
|
|
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
|
|
}
|
|
|
|
// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the
|
|
// AIFF COMM chunk for the sample rate).
|
|
func parseExtendedFloat80(b []byte) float64 {
|
|
if len(b) < 10 {
|
|
return 0
|
|
}
|
|
sign := 1.0
|
|
if b[0]&0x80 != 0 {
|
|
sign = -1.0
|
|
}
|
|
exponent := int(b[0]&0x7f)<<8 | int(b[1])
|
|
var mantissa uint64
|
|
for i := 2; i < 10; i++ {
|
|
mantissa = mantissa<<8 | uint64(b[i])
|
|
}
|
|
if exponent == 0 && mantissa == 0 {
|
|
return 0
|
|
}
|
|
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
|
|
}
|
|
|
|
type wavProbe struct {
|
|
sampleRate int
|
|
bitDepth int
|
|
channels int
|
|
byteRate int
|
|
dataSize int64
|
|
id3 []byte
|
|
info map[string]string
|
|
}
|
|
|
|
// streamProbeWAV walks the top-level RIFF chunks, buffering only the small
|
|
// metadata chunks (fmt/id3/LIST) and skipping the large data chunk.
|
|
func streamProbeWAV(f *os.File) (*wavProbe, error) {
|
|
header := make([]byte, 12)
|
|
if _, err := io.ReadFull(f, header); err != nil {
|
|
return nil, err
|
|
}
|
|
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
|
|
return nil, fmt.Errorf("not a WAVE file")
|
|
}
|
|
|
|
p := &wavProbe{info: map[string]string{}}
|
|
hdr := make([]byte, 8)
|
|
for {
|
|
if _, err := io.ReadFull(f, hdr); err != nil {
|
|
break
|
|
}
|
|
id := string(hdr[0:4])
|
|
size := readUint32(hdr[4:8], true)
|
|
pad := int64(size) & 1
|
|
|
|
switch id {
|
|
case "fmt ":
|
|
buf := make([]byte, size)
|
|
if _, err := io.ReadFull(f, buf); err != nil {
|
|
return p, nil
|
|
}
|
|
if len(buf) >= 16 {
|
|
format := binary.LittleEndian.Uint16(buf[0:2])
|
|
p.channels = int(binary.LittleEndian.Uint16(buf[2:4]))
|
|
p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8]))
|
|
p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12]))
|
|
p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16]))
|
|
if format == wavFormatExtensn && len(buf) >= 26 {
|
|
// Valid bits per sample lives in the extension; the real
|
|
// PCM format tag is in the GUID, but bitDepth from the
|
|
// container field is sufficient for display.
|
|
if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 {
|
|
p.bitDepth = vb
|
|
}
|
|
}
|
|
}
|
|
if pad == 1 {
|
|
f.Seek(pad, io.SeekCurrent)
|
|
}
|
|
case "data":
|
|
p.dataSize = int64(size)
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
case id3ChunkWAV, "ID3 ":
|
|
if size > 0 && size <= wavMaxMetaChunk {
|
|
buf := make([]byte, size)
|
|
if _, err := io.ReadFull(f, buf); err == nil {
|
|
p.id3 = buf
|
|
}
|
|
if pad == 1 {
|
|
f.Seek(pad, io.SeekCurrent)
|
|
}
|
|
} else {
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
}
|
|
case "LIST":
|
|
if size > 0 && size <= wavMaxMetaChunk {
|
|
buf := make([]byte, size)
|
|
if _, err := io.ReadFull(f, buf); err == nil {
|
|
parseRIFFInfo(buf, p.info)
|
|
}
|
|
if pad == 1 {
|
|
f.Seek(pad, io.SeekCurrent)
|
|
}
|
|
} else {
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
}
|
|
default:
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART).
|
|
func parseRIFFInfo(buf []byte, out map[string]string) {
|
|
if len(buf) < 4 || string(buf[0:4]) != "INFO" {
|
|
return
|
|
}
|
|
pos := 4
|
|
for pos+8 <= len(buf) {
|
|
id := string(buf[pos : pos+4])
|
|
size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8]))
|
|
pos += 8
|
|
if size <= 0 || pos+size > len(buf) {
|
|
break
|
|
}
|
|
val := strings.TrimRight(string(buf[pos:pos+size]), "\x00")
|
|
out[id] = strings.TrimSpace(val)
|
|
pos += size
|
|
if size&1 == 1 {
|
|
pos++
|
|
}
|
|
}
|
|
}
|
|
|
|
func wavMetadataFromProbe(p *wavProbe) *AudioMetadata {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
if len(p.id3) > 0 {
|
|
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
|
|
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
|
|
return meta
|
|
}
|
|
}
|
|
if len(p.info) > 0 {
|
|
meta := &AudioMetadata{
|
|
Title: p.info["INAM"],
|
|
Artist: p.info["IART"],
|
|
Album: p.info["IPRD"],
|
|
Genre: cleanGenre(p.info["IGNR"]),
|
|
Date: p.info["ICRD"],
|
|
Comment: p.info["ICMT"],
|
|
Copyright: p.info["ICOP"],
|
|
Composer: p.info["IMUS"],
|
|
}
|
|
if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil {
|
|
meta.TrackNumber = n
|
|
}
|
|
if meta.Date != "" && len(meta.Date) >= 4 {
|
|
meta.Year = meta.Date[:4]
|
|
}
|
|
if meta.Title != "" || meta.Artist != "" || meta.Album != "" {
|
|
return meta
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetWAVQuality probes PCM parameters and computes duration from the data size.
|
|
func GetWAVQuality(filePath string) (*WAVQuality, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
p, err := streamProbeWAV(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q := &WAVQuality{
|
|
SampleRate: p.sampleRate,
|
|
BitDepth: p.bitDepth,
|
|
Channels: p.channels,
|
|
}
|
|
if p.byteRate > 0 && p.dataSize > 0 {
|
|
q.Duration = int(p.dataSize / int64(p.byteRate))
|
|
} else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 {
|
|
bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8)
|
|
if bytesPerSec > 0 {
|
|
q.Duration = int(p.dataSize / bytesPerSec)
|
|
}
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback).
|
|
func ReadWAVTags(filePath string) (*AudioMetadata, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
p, err := streamProbeWAV(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta := wavMetadataFromProbe(p)
|
|
if meta == nil {
|
|
return nil, fmt.Errorf("no WAV tags found")
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
type aiffProbe struct {
|
|
sampleRate int
|
|
bitDepth int
|
|
channels int
|
|
numFrames int64
|
|
id3 []byte
|
|
nameChunk string
|
|
authChunk string
|
|
annoChunk string
|
|
copyrightChunk string
|
|
}
|
|
|
|
func streamProbeAIFF(f *os.File) (*aiffProbe, error) {
|
|
header := make([]byte, 12)
|
|
if _, err := io.ReadFull(f, header); err != nil {
|
|
return nil, err
|
|
}
|
|
form := string(header[8:12])
|
|
if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") {
|
|
return nil, fmt.Errorf("not an AIFF file")
|
|
}
|
|
|
|
p := &aiffProbe{}
|
|
hdr := make([]byte, 8)
|
|
for {
|
|
if _, err := io.ReadFull(f, hdr); err != nil {
|
|
break
|
|
}
|
|
id := string(hdr[0:4])
|
|
size := readUint32(hdr[4:8], false)
|
|
pad := int64(size) & 1
|
|
|
|
switch id {
|
|
case "COMM":
|
|
buf := make([]byte, size)
|
|
if _, err := io.ReadFull(f, buf); err != nil {
|
|
return p, nil
|
|
}
|
|
if len(buf) >= 18 {
|
|
p.channels = int(binary.BigEndian.Uint16(buf[0:2]))
|
|
p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6]))
|
|
p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8]))
|
|
p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5)
|
|
}
|
|
if pad == 1 {
|
|
f.Seek(pad, io.SeekCurrent)
|
|
}
|
|
case id3ChunkAIFF, "id3 ":
|
|
if size > 0 && size <= wavMaxMetaChunk {
|
|
buf := make([]byte, size)
|
|
if _, err := io.ReadFull(f, buf); err == nil {
|
|
p.id3 = buf
|
|
}
|
|
if pad == 1 {
|
|
f.Seek(pad, io.SeekCurrent)
|
|
}
|
|
} else {
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
}
|
|
case "NAME", "AUTH", "ANNO", "(c) ":
|
|
if size > 0 && size <= wavMaxMetaChunk {
|
|
buf := make([]byte, size)
|
|
if _, err := io.ReadFull(f, buf); err == nil {
|
|
val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00")
|
|
switch id {
|
|
case "NAME":
|
|
p.nameChunk = val
|
|
case "AUTH":
|
|
p.authChunk = val
|
|
case "ANNO":
|
|
p.annoChunk = val
|
|
case "(c) ":
|
|
p.copyrightChunk = val
|
|
}
|
|
}
|
|
if pad == 1 {
|
|
f.Seek(pad, io.SeekCurrent)
|
|
}
|
|
} else {
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
}
|
|
default:
|
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
|
}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
if len(p.id3) > 0 {
|
|
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
|
|
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
|
|
return meta
|
|
}
|
|
}
|
|
if p.nameChunk != "" || p.authChunk != "" {
|
|
meta := &AudioMetadata{
|
|
Title: p.nameChunk,
|
|
Artist: p.authChunk,
|
|
Comment: p.annoChunk,
|
|
Copyright: p.copyrightChunk,
|
|
}
|
|
return meta
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAIFFQuality probes PCM parameters and computes duration from frame count.
|
|
func GetAIFFQuality(filePath string) (*WAVQuality, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
p, err := streamProbeAIFF(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q := &WAVQuality{
|
|
SampleRate: p.sampleRate,
|
|
BitDepth: p.bitDepth,
|
|
Channels: p.channels,
|
|
}
|
|
if p.sampleRate > 0 && p.numFrames > 0 {
|
|
q.Duration = int(p.numFrames / int64(p.sampleRate))
|
|
}
|
|
return q, nil
|
|
}
|
|
|
|
// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback).
|
|
func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
p, err := streamProbeAIFF(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta := aiffMetadataFromProbe(p)
|
|
if meta == nil {
|
|
return nil, fmt.Errorf("no AIFF tags found")
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
|
|
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
|
|
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
|
|
if len(data) < 10 || string(data[0:3]) != "ID3" {
|
|
return nil, fmt.Errorf("no ID3v2 header")
|
|
}
|
|
majorVersion := data[3]
|
|
flags := data[5]
|
|
unsync := (flags & 0x80) != 0
|
|
extendedHeader := (flags & 0x40) != 0
|
|
footerPresent := (flags & 0x10) != 0
|
|
|
|
size := synchsafeDecode(data[6:10])
|
|
if size <= 0 || 10+size > len(data) {
|
|
size = len(data) - 10
|
|
}
|
|
tagData := data[10 : 10+size]
|
|
|
|
if footerPresent && len(tagData) >= 10 {
|
|
footerStart := len(tagData) - 10
|
|
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
|
|
tagData = tagData[:footerStart]
|
|
}
|
|
}
|
|
if extendedHeader {
|
|
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
|
|
tagData = tagData[skip:]
|
|
}
|
|
}
|
|
|
|
metadata := &AudioMetadata{}
|
|
if majorVersion == 2 {
|
|
parseID3v22Frames(tagData, metadata, unsync)
|
|
} else {
|
|
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME.
|
|
func extractAPICFromID3(tag []byte) ([]byte, string) {
|
|
if len(tag) < 10 || string(tag[0:3]) != "ID3" {
|
|
return nil, ""
|
|
}
|
|
ver := tag[3]
|
|
size := synchsafeDecode(tag[6:10])
|
|
if size <= 0 || 10+size > len(tag) {
|
|
size = len(tag) - 10
|
|
}
|
|
data := tag[10 : 10+size]
|
|
|
|
pos := 0
|
|
for {
|
|
if ver == 2 {
|
|
if pos+6 > len(data) || data[pos] == 0 {
|
|
break
|
|
}
|
|
id := string(data[pos : pos+3])
|
|
fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
|
|
if fsz <= 0 || pos+6+fsz > len(data) {
|
|
break
|
|
}
|
|
if id == "PIC" {
|
|
return parseAPICFrame(data[pos+6:pos+6+fsz], ver)
|
|
}
|
|
pos += 6 + fsz
|
|
continue
|
|
}
|
|
|
|
if pos+10 > len(data) || data[pos] == 0 {
|
|
break
|
|
}
|
|
id := string(data[pos : pos+4])
|
|
var fsz int
|
|
if ver == 4 {
|
|
fsz = synchsafeDecode(data[pos+4 : pos+8])
|
|
} else {
|
|
fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
|
|
}
|
|
if fsz <= 0 || pos+10+fsz > len(data) {
|
|
break
|
|
}
|
|
if id == "APIC" {
|
|
return parseAPICFrame(data[pos+10:pos+10+fsz], ver)
|
|
}
|
|
pos += 10 + fsz
|
|
}
|
|
return nil, ""
|
|
}
|
|
|
|
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
|
|
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
|
|
var frames bytes.Buffer
|
|
|
|
writeFrame := func(id string, payload []byte) {
|
|
frames.WriteString(id)
|
|
frames.Write(synchsafeEncode(len(payload)))
|
|
frames.Write([]byte{0, 0})
|
|
frames.Write(payload)
|
|
}
|
|
writeText := func(id, val string) {
|
|
if strings.TrimSpace(val) == "" {
|
|
return
|
|
}
|
|
payload := append([]byte{0x03}, []byte(val)...)
|
|
writeFrame(id, payload)
|
|
}
|
|
|
|
writeText("TIT2", meta.Title)
|
|
writeText("TPE1", meta.Artist)
|
|
writeText("TALB", meta.Album)
|
|
writeText("TPE2", meta.AlbumArtist)
|
|
writeText("TCON", meta.Genre)
|
|
writeText("TCOM", meta.Composer)
|
|
writeText("TPUB", meta.Label)
|
|
writeText("TCOP", meta.Copyright)
|
|
writeText("TSRC", meta.ISRC)
|
|
|
|
date := meta.Date
|
|
if date == "" {
|
|
date = meta.Year
|
|
}
|
|
writeText("TDRC", date)
|
|
|
|
if meta.TrackNumber > 0 {
|
|
if meta.TotalTracks > 0 {
|
|
writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks))
|
|
} else {
|
|
writeText("TRCK", strconv.Itoa(meta.TrackNumber))
|
|
}
|
|
}
|
|
if meta.DiscNumber > 0 {
|
|
if meta.TotalDiscs > 0 {
|
|
writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs))
|
|
} else {
|
|
writeText("TPOS", strconv.Itoa(meta.DiscNumber))
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(meta.Comment) != "" {
|
|
// COMM: encoding + language(3) + short desc(null) + text
|
|
payload := []byte{0x03}
|
|
payload = append(payload, []byte("eng")...)
|
|
payload = append(payload, 0x00) // empty description
|
|
payload = append(payload, []byte(meta.Comment)...)
|
|
writeFrame("COMM", payload)
|
|
}
|
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
|
payload := []byte{0x03}
|
|
payload = append(payload, []byte("eng")...)
|
|
payload = append(payload, 0x00)
|
|
payload = append(payload, []byte(meta.Lyrics)...)
|
|
writeFrame("USLT", payload)
|
|
}
|
|
|
|
// ReplayGain as TXXX (description\0value), UTF-8.
|
|
writeTXXX := func(desc, val string) {
|
|
if strings.TrimSpace(val) == "" {
|
|
return
|
|
}
|
|
payload := []byte{0x03}
|
|
payload = append(payload, []byte(desc)...)
|
|
payload = append(payload, 0x00)
|
|
payload = append(payload, []byte(val)...)
|
|
writeFrame("TXXX", payload)
|
|
}
|
|
writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain)
|
|
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
|
|
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
|
|
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
|
|
|
|
if len(coverData) > 0 {
|
|
if strings.TrimSpace(coverMIME) == "" {
|
|
coverMIME = "image/jpeg"
|
|
}
|
|
// APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data
|
|
payload := []byte{0x03}
|
|
payload = append(payload, []byte(coverMIME)...)
|
|
payload = append(payload, 0x00)
|
|
payload = append(payload, 0x03)
|
|
payload = append(payload, 0x00)
|
|
payload = append(payload, coverData...)
|
|
writeFrame("APIC", payload)
|
|
}
|
|
|
|
body := frames.Bytes()
|
|
var out bytes.Buffer
|
|
out.WriteString("ID3")
|
|
out.Write([]byte{0x04, 0x00}) // v2.4.0
|
|
out.WriteByte(0x00) // flags
|
|
out.Write(synchsafeEncode(len(body)))
|
|
out.Write(body)
|
|
return out.Bytes()
|
|
}
|
|
|
|
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
|
|
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
|
|
// The audio data and all other chunks are preserved; container size is patched.
|
|
func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error {
|
|
in, err := os.Open(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
header := make([]byte, 12)
|
|
if _, err := io.ReadFull(in, header); err != nil {
|
|
return err
|
|
}
|
|
if string(header[0:4]) != expectMagic {
|
|
return fmt.Errorf("unexpected container magic %q", string(header[0:4]))
|
|
}
|
|
|
|
tmpPath := filePath + ".tagtmp"
|
|
out, err := os.Create(tmpPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cleanup := func() {
|
|
out.Close()
|
|
os.Remove(tmpPath)
|
|
}
|
|
|
|
if _, err := out.Write(header); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
|
|
var bodyLen int64 = 4 // the 4-byte form type after the size field
|
|
hdr := make([]byte, 8)
|
|
for {
|
|
n, rerr := io.ReadFull(in, hdr)
|
|
if n < 8 {
|
|
break
|
|
}
|
|
if rerr != nil {
|
|
break
|
|
}
|
|
id := string(hdr[0:4])
|
|
size := readUint32(hdr[4:8], le)
|
|
pad := int64(size) & 1
|
|
|
|
if strings.EqualFold(id, chunkID) {
|
|
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if _, err := out.Write(hdr); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
if _, err := io.CopyN(out, in, int64(size)+pad); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
bodyLen += 8 + int64(size) + pad
|
|
}
|
|
|
|
newSize := len(id3)
|
|
chunkHdr := make([]byte, 8)
|
|
copy(chunkHdr[0:4], chunkID)
|
|
putUint32(chunkHdr[4:8], le, uint32(newSize))
|
|
if _, err := out.Write(chunkHdr); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
if _, err := out.Write(id3); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
if newSize&1 == 1 {
|
|
if _, err := out.Write([]byte{0}); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
}
|
|
bodyLen += 8 + int64(newSize) + int64(newSize&1)
|
|
|
|
// Patch the container size field (bytes 4..8).
|
|
sizeBuf := make([]byte, 4)
|
|
putUint32(sizeBuf, le, uint32(bodyLen))
|
|
if _, err := out.WriteAt(sizeBuf, 4); err != nil {
|
|
cleanup()
|
|
return err
|
|
}
|
|
|
|
if err := out.Close(); err != nil {
|
|
os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
in.Close()
|
|
|
|
return os.Rename(tmpPath, filePath)
|
|
}
|
|
|
|
func loadCoverForTag(fields map[string]string) ([]byte, string) {
|
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
|
if coverPath == "" {
|
|
return nil, ""
|
|
}
|
|
data, err := os.ReadFile(coverPath)
|
|
if err != nil || len(data) == 0 {
|
|
return nil, ""
|
|
}
|
|
mime := "image/jpeg"
|
|
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
|
mime = "image/png"
|
|
}
|
|
return data, mime
|
|
}
|
|
|
|
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
|
|
atoi := func(k string) int {
|
|
n := 0
|
|
if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" {
|
|
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
|
|
}
|
|
return n
|
|
}
|
|
return &AudioMetadata{
|
|
Title: fields["title"],
|
|
Artist: fields["artist"],
|
|
Album: fields["album"],
|
|
AlbumArtist: fields["album_artist"],
|
|
Date: fields["date"],
|
|
TrackNumber: atoi("track_number"),
|
|
TotalTracks: atoi("track_total"),
|
|
DiscNumber: atoi("disc_number"),
|
|
TotalDiscs: atoi("disc_total"),
|
|
ISRC: fields["isrc"],
|
|
Lyrics: fields["lyrics"],
|
|
Genre: fields["genre"],
|
|
Label: fields["label"],
|
|
Copyright: fields["copyright"],
|
|
Composer: fields["composer"],
|
|
Comment: fields["comment"],
|
|
ReplayGainTrackGain: fields["replaygain_track_gain"],
|
|
ReplayGainTrackPeak: fields["replaygain_track_peak"],
|
|
ReplayGainAlbumGain: fields["replaygain_album_gain"],
|
|
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
|
|
}
|
|
}
|
|
|
|
// mergeWAVEditFields merges edit fields onto existing tags so untouched fields
|
|
// (and cover art, when no new cover is provided) are preserved.
|
|
func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata {
|
|
meta := audioMetadataFromEditFields(fields)
|
|
if existing == nil {
|
|
return meta
|
|
}
|
|
// Only overwrite fields that are present as keys in the edit set; otherwise
|
|
// keep the existing value. An empty value with the key present clears it.
|
|
keep := func(key, newVal, oldVal string) string {
|
|
if _, ok := fields[key]; ok {
|
|
return newVal
|
|
}
|
|
return oldVal
|
|
}
|
|
meta.Title = keep("title", meta.Title, existing.Title)
|
|
meta.Artist = keep("artist", meta.Artist, existing.Artist)
|
|
meta.Album = keep("album", meta.Album, existing.Album)
|
|
meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist)
|
|
meta.Genre = keep("genre", meta.Genre, existing.Genre)
|
|
meta.Composer = keep("composer", meta.Composer, existing.Composer)
|
|
meta.Label = keep("label", meta.Label, existing.Label)
|
|
meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright)
|
|
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
|
|
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
|
|
meta.Comment = keep("comment", meta.Comment, existing.Comment)
|
|
meta.Date = keep("date", meta.Date, existing.Date)
|
|
if _, ok := fields["track_number"]; !ok {
|
|
meta.TrackNumber = existing.TrackNumber
|
|
}
|
|
if _, ok := fields["track_total"]; !ok {
|
|
meta.TotalTracks = existing.TotalTracks
|
|
}
|
|
if _, ok := fields["disc_number"]; !ok {
|
|
meta.DiscNumber = existing.DiscNumber
|
|
}
|
|
if _, ok := fields["disc_total"]; !ok {
|
|
meta.TotalDiscs = existing.TotalDiscs
|
|
}
|
|
if _, ok := fields["replaygain_track_gain"]; !ok {
|
|
meta.ReplayGainTrackGain = existing.ReplayGainTrackGain
|
|
}
|
|
if _, ok := fields["replaygain_track_peak"]; !ok {
|
|
meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak
|
|
}
|
|
if _, ok := fields["replaygain_album_gain"]; !ok {
|
|
meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain
|
|
}
|
|
if _, ok := fields["replaygain_album_peak"]; !ok {
|
|
meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk.
|
|
func WriteWAVTags(filePath string, fields map[string]string) error {
|
|
existing, _ := ReadWAVTags(filePath)
|
|
meta := mergeEditFieldsOntoExisting(existing, fields)
|
|
|
|
coverData, coverMIME := loadCoverForTag(fields)
|
|
if coverData == nil {
|
|
// Preserve an existing embedded cover when no new one is supplied.
|
|
if f, err := os.Open(filePath); err == nil {
|
|
if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 {
|
|
coverData, coverMIME = extractAPICFromID3(p.id3)
|
|
}
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
tag := buildID3v24Tag(meta, coverData, coverMIME)
|
|
return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag)
|
|
}
|
|
|
|
// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk.
|
|
func WriteAIFFTags(filePath string, fields map[string]string) error {
|
|
existing, _ := ReadAIFFTags(filePath)
|
|
meta := mergeEditFieldsOntoExisting(existing, fields)
|
|
|
|
coverData, coverMIME := loadCoverForTag(fields)
|
|
if coverData == nil {
|
|
if f, err := os.Open(filePath); err == nil {
|
|
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
|
|
coverData, coverMIME = extractAPICFromID3(p.id3)
|
|
}
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
tag := buildID3v24Tag(meta, coverData, coverMIME)
|
|
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
|
|
}
|
|
|
|
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
|
|
applyAudioMetadataToScan(metadata, result)
|
|
}
|
|
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
|
|
result.BitDepth = quality.BitDepth
|
|
result.SampleRate = quality.SampleRate
|
|
result.Duration = quality.Duration
|
|
}
|
|
result.Bitrate = 0 // lossless PCM
|
|
result.Format = "wav"
|
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
return result, nil
|
|
}
|
|
|
|
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
|
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
|
|
applyAudioMetadataToScan(metadata, result)
|
|
}
|
|
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
|
|
result.BitDepth = quality.BitDepth
|
|
result.SampleRate = quality.SampleRate
|
|
result.Duration = quality.Duration
|
|
}
|
|
result.Bitrate = 0 // lossless PCM
|
|
result.Format = "aiff"
|
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
|
return result, nil
|
|
}
|
|
|
|
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
|
|
result.TrackName = metadata.Title
|
|
result.ArtistName = metadata.Artist
|
|
result.AlbumName = metadata.Album
|
|
result.AlbumArtist = metadata.AlbumArtist
|
|
result.ISRC = metadata.ISRC
|
|
result.TrackNumber = metadata.TrackNumber
|
|
result.TotalTracks = metadata.TotalTracks
|
|
result.DiscNumber = metadata.DiscNumber
|
|
result.TotalDiscs = metadata.TotalDiscs
|
|
if metadata.Date != "" {
|
|
result.ReleaseDate = metadata.Date
|
|
} else {
|
|
result.ReleaseDate = metadata.Year
|
|
}
|
|
result.Genre = metadata.Genre
|
|
result.Composer = metadata.Composer
|
|
result.Label = metadata.Label
|
|
result.Copyright = metadata.Copyright
|
|
}
|
|
|
|
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
|
|
// WAV or AIFF file, or an error when none is present.
|
|
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
var id3 []byte
|
|
switch ext {
|
|
case ".aiff", ".aif", ".aifc":
|
|
if p, perr := streamProbeAIFF(f); perr == nil {
|
|
id3 = p.id3
|
|
}
|
|
default:
|
|
if p, perr := streamProbeWAV(f); perr == nil {
|
|
id3 = p.id3
|
|
}
|
|
}
|
|
if len(id3) == 0 {
|
|
return nil, "", fmt.Errorf("no embedded cover")
|
|
}
|
|
data, mime := extractAPICFromID3(id3)
|
|
if len(data) == 0 {
|
|
return nil, "", fmt.Errorf("no embedded cover")
|
|
}
|
|
return data, mime, nil
|
|
}
|