improved library scan speeds, added library manager component
-libraries tab now opens a library manager -choose library directory, manually soft scan and rescan -batched db queues -mp3 header-based duration extraction
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// getTrackDuration returns the duration of an audio file in
|
||||
// milliseconds. For MP3 files it uses a fast header-only parser
|
||||
// (Xing/VBRI/CBR); for other formats it falls back to a full
|
||||
// decode via beep which is already O(1) for FLAC, OGG, and WAV.
|
||||
//
|
||||
// The file position is undefined after this call.
|
||||
func getTrackDuration(f *os.File) (int64, error) {
|
||||
ext := filepath.Ext(f.Name())
|
||||
|
||||
if ext == ".mp3" {
|
||||
return getMP3Duration(f)
|
||||
}
|
||||
|
||||
// FLAC, OGG, and WAV: beep's Decode() + Len() is already
|
||||
// cheap (reads headers/metadata only, no full audio decode).
|
||||
streamer, format, err := DecodeFile(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error decoding file: %w", err)
|
||||
}
|
||||
|
||||
lengthMillis := int64(
|
||||
float64(streamer.Len()*1000) /
|
||||
float64(format.SampleRate),
|
||||
)
|
||||
_ = streamer.Close()
|
||||
|
||||
return lengthMillis, nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package metadata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -50,3 +51,48 @@ func GetTrackLengthMillis(path string) (int64, error) {
|
||||
|
||||
return lengthMillis, nil
|
||||
}
|
||||
|
||||
// ExtractAllMetadata opens the file once and extracts both tags and duration.
|
||||
// This avoids the overhead of opening the file twice when both are needed.
|
||||
// If skipDuration is true, only tags are extracted and lengthMillis is 0.
|
||||
func ExtractAllMetadata(
|
||||
path string,
|
||||
skipDuration bool,
|
||||
) (*TrackMetadata, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf(
|
||||
"could not open file: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
// Extract tags first (reads only headers, fast).
|
||||
tags, err := ExtractTagsFromReader(f)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf(
|
||||
"could not extract tags from %s: %w", path, err,
|
||||
)
|
||||
}
|
||||
|
||||
if skipDuration {
|
||||
return tags, 0, nil
|
||||
}
|
||||
|
||||
// Seek back to the beginning for duration extraction.
|
||||
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||
return tags, 0, fmt.Errorf(
|
||||
"could not seek file for duration: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
lengthMillis, err := getTrackDuration(f)
|
||||
if err != nil {
|
||||
return tags, 0, fmt.Errorf(
|
||||
"error getting duration for %s: %w", path, err,
|
||||
)
|
||||
}
|
||||
|
||||
return tags, lengthMillis, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// errNoSyncWord is returned when no valid MP3 frame sync word
|
||||
// is found within the search window.
|
||||
var errNoSyncWord = errors.New("could not find MP3 sync word")
|
||||
|
||||
// maxSyncSearchBytes limits how far we scan for the first sync word
|
||||
// after skipping any ID3v2 tag.
|
||||
const maxSyncSearchBytes = 64 * 1024
|
||||
|
||||
// MPEG version constants.
|
||||
const (
|
||||
mpegVersion1 = 3 // 0b11
|
||||
mpegVersion2 = 2 // 0b10
|
||||
mpegVersion2_5 = 0 // 0b00 (unofficial extension)
|
||||
)
|
||||
|
||||
// bitrateTable maps [versionIndex][bitrateIndex] to kbps.
|
||||
// versionIndex 0 = MPEG1, 1 = MPEG2/2.5.
|
||||
// bitrateIndex 0 and 15 are invalid.
|
||||
//
|
||||
//nolint:mnd // lookup table values are from the MPEG spec.
|
||||
var bitrateTable = [2][16]int{
|
||||
// MPEG1 Layer 3
|
||||
{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
|
||||
// MPEG2/2.5 Layer 3
|
||||
{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
|
||||
}
|
||||
|
||||
// sampleRateTable maps [versionIndex][sampleRateIndex] to Hz.
|
||||
// versionIndex: 0 = MPEG1, 1 = MPEG2, 2 = MPEG2.5.
|
||||
//
|
||||
//nolint:mnd // lookup table values are from the MPEG spec.
|
||||
var sampleRateTable = [3][4]int{
|
||||
{44100, 48000, 32000, 0}, // MPEG1
|
||||
{22050, 24000, 16000, 0}, // MPEG2
|
||||
{11025, 12000, 8000, 0}, // MPEG2.5
|
||||
}
|
||||
|
||||
// samplesPerFrame returns the number of PCM samples per MP3 frame
|
||||
// for the given MPEG version (Layer 3 only).
|
||||
//
|
||||
//nolint:mnd // constants from the MPEG spec.
|
||||
func samplesPerFrame(version int) int {
|
||||
if version == mpegVersion1 {
|
||||
return 1152
|
||||
}
|
||||
|
||||
return 576 // MPEG2 / MPEG2.5
|
||||
}
|
||||
|
||||
// getMP3Duration computes the duration of an MP3 file in
|
||||
// milliseconds by reading only the first frame's header and any
|
||||
// Xing/VBRI VBR header it contains. For CBR files (no VBR header)
|
||||
// it falls back to fileSize / bitrate.
|
||||
//
|
||||
// The file position is undefined after this call.
|
||||
func getMP3Duration(f *os.File) (int64, error) {
|
||||
// 1. Skip a leading ID3v2 tag if present.
|
||||
audioStart, err := skipID3v2(f)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
||||
}
|
||||
|
||||
// 2. Find and parse the first MP3 frame header.
|
||||
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 3. Attempt to read a VBR header (Xing/Info or VBRI) from
|
||||
// inside the first frame.
|
||||
vbrFrames, found, err := readVBRHeader(f, hdr, frameOffset)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if found && vbrFrames > 0 {
|
||||
spf := samplesPerFrame(hdr.version)
|
||||
durationMS := int64(vbrFrames) *
|
||||
int64(spf) * 1000 / int64(hdr.sampleRate)
|
||||
|
||||
return durationMS, nil
|
||||
}
|
||||
|
||||
// 4. CBR fallback: duration = audioBytes * 8 / bitrate.
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat file for CBR duration: %w", err)
|
||||
}
|
||||
|
||||
audioBytes := fi.Size() - audioStart
|
||||
durationMS := audioBytes * 8 * 1000 /
|
||||
(int64(hdr.bitrateKbps) * 1000)
|
||||
|
||||
return durationMS, nil
|
||||
}
|
||||
|
||||
// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio
|
||||
// frame header.
|
||||
type mpegFrameHeader struct {
|
||||
version int // mpegVersion1, mpegVersion2, mpegVersion2_5
|
||||
bitrateKbps int
|
||||
sampleRate int
|
||||
channelMode int // 0-3; 3 = mono
|
||||
padding int // 0 or 1
|
||||
}
|
||||
|
||||
// skipID3v2 checks for an ID3v2 tag at the start of f and returns
|
||||
// the byte offset where audio data begins.
|
||||
//
|
||||
//nolint:mnd // byte offsets from the ID3v2 spec.
|
||||
func skipID3v2(f *os.File) (int64, error) {
|
||||
var buf [10]byte
|
||||
|
||||
if _, err := f.ReadAt(buf[:], 0); err != nil {
|
||||
return 0, fmt.Errorf("reading ID3v2 header: %w", err)
|
||||
}
|
||||
|
||||
if string(buf[:3]) != "ID3" {
|
||||
return 0, nil // no ID3v2 tag
|
||||
}
|
||||
|
||||
// Syncsafe integer: 4 bytes, each using 7 bits.
|
||||
size := int64(buf[6])<<21 |
|
||||
int64(buf[7])<<14 |
|
||||
int64(buf[8])<<7 |
|
||||
int64(buf[9])
|
||||
|
||||
return 10 + size, nil
|
||||
}
|
||||
|
||||
// findFrameHeader scans from startOffset for the first valid MP3
|
||||
// sync word and returns the parsed header plus the file offset
|
||||
// where the frame begins.
|
||||
//
|
||||
//nolint:mnd,cyclop // bit manipulation from the MPEG spec.
|
||||
func findFrameHeader(
|
||||
f *os.File,
|
||||
startOffset int64,
|
||||
) (mpegFrameHeader, int64, error) {
|
||||
if _, err := f.Seek(startOffset, io.SeekStart); err != nil {
|
||||
return mpegFrameHeader{}, 0, fmt.Errorf(
|
||||
"seeking to audio start: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Read a chunk large enough to contain the first frame.
|
||||
buf := make([]byte, maxSyncSearchBytes)
|
||||
|
||||
n, err := io.ReadAtLeast(f, buf, 4)
|
||||
if err != nil {
|
||||
return mpegFrameHeader{}, 0, fmt.Errorf(
|
||||
"reading audio data: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
buf = buf[:n]
|
||||
|
||||
for i := 0; i <= len(buf)-4; i++ {
|
||||
// Sync word: 11 set bits (0xFF followed by 0xE0 mask).
|
||||
if buf[i] != 0xFF || buf[i+1]&0xE0 != 0xE0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hdr, ok := parseFrameHeader(buf[i : i+4])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
return hdr, startOffset + int64(i), nil
|
||||
}
|
||||
|
||||
return mpegFrameHeader{}, 0, errNoSyncWord
|
||||
}
|
||||
|
||||
// parseFrameHeader decodes a 4-byte MPEG audio frame header.
|
||||
// Returns false if the header contains invalid field combinations.
|
||||
//
|
||||
//nolint:mnd,cyclop // bit manipulation from the MPEG spec.
|
||||
func parseFrameHeader(b []byte) (mpegFrameHeader, bool) {
|
||||
version := int((b[1] >> 3) & 0x03)
|
||||
layer := int((b[1] >> 1) & 0x03)
|
||||
|
||||
// We only handle Layer 3.
|
||||
if layer != 1 { // Layer encoding: 1 = Layer 3
|
||||
return mpegFrameHeader{}, false
|
||||
}
|
||||
|
||||
// Determine version index for the bitrate table.
|
||||
var bitrateIdx int
|
||||
|
||||
switch version {
|
||||
case mpegVersion1:
|
||||
bitrateIdx = 0
|
||||
case mpegVersion2, mpegVersion2_5:
|
||||
bitrateIdx = 1
|
||||
default:
|
||||
return mpegFrameHeader{}, false // reserved
|
||||
}
|
||||
|
||||
brIndex := int((b[2] >> 4) & 0x0F)
|
||||
bitrate := bitrateTable[bitrateIdx][brIndex]
|
||||
|
||||
if bitrate == 0 {
|
||||
return mpegFrameHeader{}, false
|
||||
}
|
||||
|
||||
// Sample rate.
|
||||
var srVersionIdx int
|
||||
|
||||
switch version {
|
||||
case mpegVersion1:
|
||||
srVersionIdx = 0
|
||||
case mpegVersion2:
|
||||
srVersionIdx = 1
|
||||
case mpegVersion2_5:
|
||||
srVersionIdx = 2
|
||||
}
|
||||
|
||||
srIndex := int((b[2] >> 2) & 0x03)
|
||||
sampleRate := sampleRateTable[srVersionIdx][srIndex]
|
||||
|
||||
if sampleRate == 0 {
|
||||
return mpegFrameHeader{}, false
|
||||
}
|
||||
|
||||
padding := int((b[2] >> 1) & 0x01)
|
||||
channelMode := int((b[3] >> 6) & 0x03)
|
||||
|
||||
return mpegFrameHeader{
|
||||
version: version,
|
||||
bitrateKbps: bitrate,
|
||||
sampleRate: sampleRate,
|
||||
channelMode: channelMode,
|
||||
padding: padding,
|
||||
}, true
|
||||
}
|
||||
|
||||
// readVBRHeader tries to read a Xing/Info or VBRI header from the
|
||||
// first frame at frameOffset. Returns the total frame count and
|
||||
// whether a VBR header was found.
|
||||
//
|
||||
//nolint:mnd // byte offsets from Xing/VBRI specs.
|
||||
func readVBRHeader(
|
||||
f *os.File,
|
||||
hdr mpegFrameHeader,
|
||||
frameOffset int64,
|
||||
) (uint32, bool, error) {
|
||||
// Xing/Info header offset depends on version and channel mode.
|
||||
var sideInfoSize int
|
||||
|
||||
switch {
|
||||
case hdr.version == mpegVersion1 && hdr.channelMode != 3:
|
||||
sideInfoSize = 32
|
||||
case hdr.version == mpegVersion1 && hdr.channelMode == 3:
|
||||
sideInfoSize = 17
|
||||
case hdr.channelMode != 3:
|
||||
sideInfoSize = 17
|
||||
default:
|
||||
sideInfoSize = 9
|
||||
}
|
||||
|
||||
// The Xing header sits right after the 4-byte frame header +
|
||||
// side information.
|
||||
xingOffset := frameOffset + 4 + int64(sideInfoSize)
|
||||
|
||||
// Read enough bytes for Xing header (magic + flags + frames).
|
||||
var xingBuf [12]byte
|
||||
|
||||
if _, err := f.ReadAt(xingBuf[:], xingOffset); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
return 0, false, fmt.Errorf(
|
||||
"reading Xing header: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
magic := string(xingBuf[:4])
|
||||
if magic == "Xing" || magic == "Info" {
|
||||
flags := binary.BigEndian.Uint32(xingBuf[4:8])
|
||||
|
||||
// Bit 0 of flags indicates the frames field is present.
|
||||
if flags&0x01 != 0 {
|
||||
frames := binary.BigEndian.Uint32(xingBuf[8:12])
|
||||
|
||||
return frames, true, nil
|
||||
}
|
||||
|
||||
// Xing header present but no frame count — fall through
|
||||
// to CBR fallback.
|
||||
return 0, true, nil
|
||||
}
|
||||
|
||||
// VBRI header is always at a fixed offset of 36 bytes from
|
||||
// the frame start (regardless of version/channel mode).
|
||||
vbriOffset := frameOffset + 36
|
||||
|
||||
var vbriBuf [26]byte
|
||||
|
||||
if _, err := f.ReadAt(vbriBuf[:], vbriOffset); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
return 0, false, fmt.Errorf(
|
||||
"reading VBRI header: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if string(vbriBuf[:4]) == "VBRI" {
|
||||
// Total frames at offset 14 from VBRI magic.
|
||||
frames := binary.BigEndian.Uint32(vbriBuf[14:18])
|
||||
|
||||
return frames, true, nil
|
||||
}
|
||||
|
||||
return 0, false, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testMP3Files returns the paths to all .mp3 files in the test_data
|
||||
// directory. It skips the test if none are found.
|
||||
func testMP3Files(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
root := filepath.Join("..", "..", "test_data")
|
||||
|
||||
var files []string
|
||||
|
||||
err := filepath.Walk(root, func(
|
||||
path string, info os.FileInfo, err error,
|
||||
) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() && filepath.Ext(path) == ".mp3" {
|
||||
files = append(files, path)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walking test_data: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Skip("no .mp3 test fixtures found in test_data/")
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// TestGetMP3Duration_MatchesBeepDecode verifies that the fast
|
||||
// header-only parser produces a duration within 1 second of the
|
||||
// full decode via beep, for every test MP3 file.
|
||||
func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
|
||||
for _, path := range testMP3Files(t) {
|
||||
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||
// Reference value: full beep decode.
|
||||
refMS, err := GetTrackLengthMillis(path)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"beep decode failed: %v", err,
|
||||
)
|
||||
}
|
||||
|
||||
// Fast path.
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
fastMS, err := getMP3Duration(f)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"getMP3Duration failed: %v", err,
|
||||
)
|
||||
}
|
||||
|
||||
diffMS := refMS - fastMS
|
||||
if diffMS < 0 {
|
||||
diffMS = -diffMS
|
||||
}
|
||||
|
||||
// Allow up to 1 second of difference to account
|
||||
// for rounding and the slight inaccuracy of the
|
||||
// CBR fallback for VBR-without-Xing files.
|
||||
const toleranceMS = 1000
|
||||
|
||||
t.Logf(
|
||||
"beep=%dms fast=%dms diff=%dms",
|
||||
refMS, fastMS, diffMS,
|
||||
)
|
||||
|
||||
if diffMS > toleranceMS {
|
||||
t.Errorf(
|
||||
"duration mismatch: beep=%dms fast=%dms "+
|
||||
"(diff %dms exceeds %dms tolerance)",
|
||||
refMS, fastMS, diffMS, toleranceMS,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMP3Duration_BasicParsing exercises the parser on a single
|
||||
// file and verifies a positive duration is returned.
|
||||
func TestGetMP3Duration_BasicParsing(t *testing.T) {
|
||||
files := testMP3Files(t)
|
||||
|
||||
f, err := os.Open(files[0])
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
ms, err := getMP3Duration(f)
|
||||
if err != nil {
|
||||
t.Fatalf("getMP3Duration: %v", err)
|
||||
}
|
||||
|
||||
if ms <= 0 {
|
||||
t.Errorf("expected positive duration, got %d", ms)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user