fixed common track scanning bugs with custom workarounds
This commit is contained in:
@@ -15,12 +15,15 @@ import (
|
|||||||
func getTrackDuration(f *os.File) (int64, error) {
|
func getTrackDuration(f *os.File) (int64, error) {
|
||||||
ext := filepath.Ext(f.Name())
|
ext := filepath.Ext(f.Name())
|
||||||
|
|
||||||
if ext == ".mp3" {
|
switch ext {
|
||||||
|
case ".mp3":
|
||||||
return getMP3Duration(f)
|
return getMP3Duration(f)
|
||||||
|
case ".flac":
|
||||||
|
return getFlacDuration(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FLAC, OGG, and WAV: beep's Decode() + Len() is already
|
// OGG and WAV: beep's Decode() + Len() is already cheap
|
||||||
// cheap (reads headers/metadata only, no full audio decode).
|
// (reads headers/metadata only, no full audio decode).
|
||||||
streamer, format, err := DecodeFile(f)
|
streamer, format, err := DecodeFile(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("error decoding file: %w", err)
|
return 0, fmt.Errorf("error decoding file: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errInvalidFLACSignature is returned when the file does not contain
|
||||||
|
// a valid FLAC stream signature ("fLaC") at the expected position.
|
||||||
|
var errInvalidFLACSignature = errors.New(
|
||||||
|
"invalid FLAC signature",
|
||||||
|
)
|
||||||
|
|
||||||
|
// errInvalidStreamInfo is returned when the first metadata block is
|
||||||
|
// not a StreamInfo block or has an unexpected length.
|
||||||
|
var errInvalidStreamInfo = errors.New(
|
||||||
|
"invalid StreamInfo metadata block",
|
||||||
|
)
|
||||||
|
|
||||||
|
// errZeroSampleRate is returned when the StreamInfo block reports a
|
||||||
|
// sample rate of zero, which would cause a division by zero.
|
||||||
|
var errZeroSampleRate = errors.New(
|
||||||
|
"FLAC StreamInfo sample rate is zero",
|
||||||
|
)
|
||||||
|
|
||||||
|
// flacSignatureBytes is the four-byte marker that begins every FLAC
|
||||||
|
// stream.
|
||||||
|
var flacSignatureBytes = [4]byte{'f', 'L', 'a', 'C'}
|
||||||
|
|
||||||
|
// streamInfoLength is the fixed size of a FLAC StreamInfo body in
|
||||||
|
// bytes.
|
||||||
|
const streamInfoLength = 34
|
||||||
|
|
||||||
|
// streamInfoBlockType is the metadata block type for StreamInfo.
|
||||||
|
const streamInfoBlockType = 0
|
||||||
|
|
||||||
|
// getFlacDuration computes the duration of a FLAC file in
|
||||||
|
// milliseconds by reading only the StreamInfo metadata block header.
|
||||||
|
// It handles an optional prepended ID3v2 tag by seeking past it.
|
||||||
|
//
|
||||||
|
// This replaces the previous beep/mewkiz-flac decode path which has
|
||||||
|
// a bug in its ID3v2 skip logic (bufio over bufseekio causes a
|
||||||
|
// position overshoot).
|
||||||
|
//
|
||||||
|
// The file position is undefined after this call.
|
||||||
|
//
|
||||||
|
//nolint:mnd // byte offsets and bit shifts from the FLAC spec.
|
||||||
|
func getFlacDuration(f *os.File) (int64, error) {
|
||||||
|
audioStart, err := skipID3v2(f)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the 4-byte FLAC signature.
|
||||||
|
var sig [4]byte
|
||||||
|
|
||||||
|
if _, err := f.ReadAt(sig[:], audioStart); err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"reading FLAC signature: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sig != flacSignatureBytes {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: expected %q, got %q",
|
||||||
|
errInvalidFLACSignature, flacSignatureBytes, sig,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the metadata block header (4 bytes) immediately after
|
||||||
|
// the signature.
|
||||||
|
var mbh [4]byte
|
||||||
|
|
||||||
|
if _, err := f.ReadAt(
|
||||||
|
mbh[:], audioStart+4,
|
||||||
|
); err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"reading metadata block header: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
blockType := mbh[0] & 0x7F
|
||||||
|
|
||||||
|
blockLen := int64(mbh[1])<<16 |
|
||||||
|
int64(mbh[2])<<8 |
|
||||||
|
int64(mbh[3])
|
||||||
|
|
||||||
|
if blockType != streamInfoBlockType ||
|
||||||
|
blockLen != streamInfoLength {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: type=%d, length=%d",
|
||||||
|
errInvalidStreamInfo, blockType, blockLen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the 34-byte StreamInfo body.
|
||||||
|
var si [streamInfoLength]byte
|
||||||
|
|
||||||
|
if _, err := f.ReadAt(
|
||||||
|
si[:], audioStart+8,
|
||||||
|
); err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"reading StreamInfo block: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleRate, totalSamples := parseFlacStreamInfo(si)
|
||||||
|
|
||||||
|
if sampleRate == 0 {
|
||||||
|
return 0, errZeroSampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
durationMS := int64(totalSamples) * 1000 /
|
||||||
|
int64(sampleRate)
|
||||||
|
|
||||||
|
return durationMS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFlacStreamInfo extracts the sample rate (20 bits) and total
|
||||||
|
// sample count (36 bits) from a 34-byte FLAC StreamInfo body.
|
||||||
|
//
|
||||||
|
// StreamInfo layout (bytes 10-17 contain the fields we need):
|
||||||
|
//
|
||||||
|
// bits 0-19: sample rate in Hz (20 bits)
|
||||||
|
// bits 20-22: number of channels -1 (3 bits, unused here)
|
||||||
|
// bits 23-27: bits per sample -1 (5 bits, unused here)
|
||||||
|
// bits 28-63: total samples (36 bits)
|
||||||
|
//
|
||||||
|
//nolint:mnd // bit offsets from the FLAC spec.
|
||||||
|
func parseFlacStreamInfo(
|
||||||
|
si [streamInfoLength]byte,
|
||||||
|
) (sampleRate uint32, totalSamples uint64) {
|
||||||
|
// Bytes 10-13 packed as big-endian uint32 contain sample rate
|
||||||
|
// in the upper 20 bits.
|
||||||
|
packed := binary.BigEndian.Uint32(si[10:14])
|
||||||
|
sampleRate = packed >> 12
|
||||||
|
|
||||||
|
// Total samples: 4 low bits of byte 13, then bytes 14-17.
|
||||||
|
totalSamples = uint64(si[13]&0x0F)<<32 |
|
||||||
|
uint64(si[14])<<24 |
|
||||||
|
uint64(si[15])<<16 |
|
||||||
|
uint64(si[16])<<8 |
|
||||||
|
uint64(si[17])
|
||||||
|
|
||||||
|
return sampleRate, totalSamples
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testFlacFiles returns the paths to all .flac files in the
|
||||||
|
// test_data directory. It skips the test if none are found.
|
||||||
|
func testFlacFiles(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) == ".flac" {
|
||||||
|
files = append(files, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("walking test_data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
t.Skip("no .flac test fixtures found in test_data/")
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetFlacDuration_BasicParsing verifies that getFlacDuration
|
||||||
|
// returns a positive duration for every FLAC test fixture.
|
||||||
|
func TestGetFlacDuration_BasicParsing(t *testing.T) {
|
||||||
|
for _, path := range testFlacFiles(t) {
|
||||||
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
ms, err := getFlacDuration(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getFlacDuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ms <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"expected positive duration, got %d",
|
||||||
|
ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("duration: %dms", ms)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetFlacDuration_MatchesBeepDecode verifies that the fast
|
||||||
|
// header-only parser produces a duration within 1 second of the full
|
||||||
|
// decode via beep, for every FLAC test fixture.
|
||||||
|
func TestGetFlacDuration_MatchesBeepDecode(t *testing.T) {
|
||||||
|
for _, path := range testFlacFiles(t) {
|
||||||
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||||
|
refMS, err := GetTrackLengthMillis(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("beep decode failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
fastMS, err := getFlacDuration(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getFlacDuration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diffMS := refMS - fastMS
|
||||||
|
if diffMS < 0 {
|
||||||
|
diffMS = -diffMS
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetFlacDuration_WithPrependedID3v2 creates a temporary FLAC
|
||||||
|
// file with a synthetic ID3v2 tag prepended and verifies that
|
||||||
|
// getFlacDuration correctly skips it and parses the duration.
|
||||||
|
func TestGetFlacDuration_WithPrependedID3v2(t *testing.T) {
|
||||||
|
files := testFlacFiles(t)
|
||||||
|
|
||||||
|
// Use the first test fixture as our source.
|
||||||
|
src := files[0]
|
||||||
|
|
||||||
|
srcData, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal ID3v2.3 header with 256 bytes of padding.
|
||||||
|
//nolint:mnd // synthetic tag construction.
|
||||||
|
paddingSize := 256
|
||||||
|
|
||||||
|
id3Header := buildID3v2Header(paddingSize)
|
||||||
|
|
||||||
|
// Write: ID3v2 header + padding + original FLAC data.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpPath := filepath.Join(tmpDir, "test_id3v2.flac")
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(id3Header)+paddingSize+len(srcData))
|
||||||
|
out = append(out, id3Header...)
|
||||||
|
out = append(out, make([]byte, paddingSize)...)
|
||||||
|
out = append(out, srcData...)
|
||||||
|
|
||||||
|
if err := os.WriteFile(tmpPath, out, 0o644); err != nil {
|
||||||
|
t.Fatalf("writing temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reference duration from original file.
|
||||||
|
origF, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open original: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = origF.Close() }()
|
||||||
|
|
||||||
|
origMS, err := getFlacDuration(origF)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getFlacDuration on original: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the ID3v2-wrapped file.
|
||||||
|
tmpF, err := os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open temp: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tmpF.Close() }()
|
||||||
|
|
||||||
|
wrappedMS, err := getFlacDuration(tmpF)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"getFlacDuration on ID3v2-wrapped file: %v", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if origMS != wrappedMS {
|
||||||
|
t.Errorf(
|
||||||
|
"duration mismatch: original=%dms wrapped=%dms",
|
||||||
|
origMS, wrappedMS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf(
|
||||||
|
"original=%dms wrapped=%dms", origMS, wrappedMS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseFlacStreamInfo verifies the bit-level parsing of sample
|
||||||
|
// rate and total samples from a known StreamInfo block.
|
||||||
|
func TestParseFlacStreamInfo(t *testing.T) {
|
||||||
|
// Construct a 34-byte StreamInfo with known values.
|
||||||
|
// Layout of bytes 10-17 (64 bits, big-endian):
|
||||||
|
// bits 0-19: sample rate (20 bits)
|
||||||
|
// bits 20-22: channels - 1 (3 bits)
|
||||||
|
// bits 23-27: bps - 1 (5 bits)
|
||||||
|
// bits 28-63: total samples (36 bits)
|
||||||
|
//
|
||||||
|
// Test values:
|
||||||
|
// sample rate = 44100 (0x0AC44)
|
||||||
|
// channels = 2 (stored as 1, 0b001)
|
||||||
|
// bps = 16 (stored as 15, 0b01111)
|
||||||
|
// total samples = 11614366 (0x00B1389E)
|
||||||
|
//
|
||||||
|
// Packed: 0x0AC442F000B1389E
|
||||||
|
// byte 10 = 0x0A byte 14 = 0x00
|
||||||
|
// byte 11 = 0xC4 byte 15 = 0xB1
|
||||||
|
// byte 12 = 0x42 byte 16 = 0x38
|
||||||
|
// byte 13 = 0xF0 byte 17 = 0x9E
|
||||||
|
//
|
||||||
|
//nolint:mnd // byte values from manual FLAC spec packing.
|
||||||
|
var si [streamInfoLength]byte
|
||||||
|
|
||||||
|
si[10] = 0x0A
|
||||||
|
si[11] = 0xC4
|
||||||
|
si[12] = 0x42
|
||||||
|
si[13] = 0xF0
|
||||||
|
si[14] = 0x00
|
||||||
|
si[15] = 0xB1
|
||||||
|
si[16] = 0x38
|
||||||
|
si[17] = 0x9E
|
||||||
|
|
||||||
|
sr, total := parseFlacStreamInfo(si)
|
||||||
|
|
||||||
|
//nolint:mnd // expected test values.
|
||||||
|
const (
|
||||||
|
wantSR = 44100
|
||||||
|
wantTotal = 11614366
|
||||||
|
)
|
||||||
|
|
||||||
|
if sr != wantSR {
|
||||||
|
t.Errorf("sample rate: got %d, want %d", sr, wantSR)
|
||||||
|
}
|
||||||
|
|
||||||
|
if total != wantTotal {
|
||||||
|
t.Errorf(
|
||||||
|
"total samples: got %d, want %d",
|
||||||
|
total, wantTotal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildID3v2Header creates a minimal 10-byte ID3v2.3 header with
|
||||||
|
// the given payload size encoded as a syncsafe integer.
|
||||||
|
//
|
||||||
|
//nolint:mnd // byte offsets from the ID3v2 spec.
|
||||||
|
func buildID3v2Header(payloadSize int) []byte {
|
||||||
|
header := []byte{
|
||||||
|
'I', 'D', '3', // signature
|
||||||
|
3, 0, // version 2.3.0
|
||||||
|
0, // flags
|
||||||
|
0, 0, 0, 0, // size (syncsafe, filled below)
|
||||||
|
}
|
||||||
|
|
||||||
|
header[6] = byte((payloadSize >> 21) & 0x7F)
|
||||||
|
header[7] = byte((payloadSize >> 14) & 0x7F)
|
||||||
|
header[8] = byte((payloadSize >> 7) & 0x7F)
|
||||||
|
header[9] = byte(payloadSize & 0x7F)
|
||||||
|
|
||||||
|
return header
|
||||||
|
}
|
||||||
@@ -13,8 +13,14 @@ import (
|
|||||||
var errNoSyncWord = errors.New("could not find MP3 sync word")
|
var errNoSyncWord = errors.New("could not find MP3 sync word")
|
||||||
|
|
||||||
// maxSyncSearchBytes limits how far we scan for the first sync word
|
// maxSyncSearchBytes limits how far we scan for the first sync word
|
||||||
// after skipping any ID3v2 tag.
|
// after skipping any ID3v2 tags. 512 KB accommodates files with
|
||||||
const maxSyncSearchBytes = 64 * 1024
|
// large embedded artwork or multiple prepended ID3v2 tags.
|
||||||
|
const maxSyncSearchBytes = 512 * 1024
|
||||||
|
|
||||||
|
// maxID3v2Tags limits how many consecutive ID3v2 tags we skip.
|
||||||
|
// Some files contain multiple prepended tags from different tagging
|
||||||
|
// tools.
|
||||||
|
const maxID3v2Tags = 5
|
||||||
|
|
||||||
// MPEG version constants.
|
// MPEG version constants.
|
||||||
const (
|
const (
|
||||||
@@ -64,12 +70,20 @@ func samplesPerFrame(version int) int {
|
|||||||
//
|
//
|
||||||
// The file position is undefined after this call.
|
// The file position is undefined after this call.
|
||||||
func getMP3Duration(f *os.File) (int64, error) {
|
func getMP3Duration(f *os.File) (int64, error) {
|
||||||
// 1. Skip a leading ID3v2 tag if present.
|
// 1. Skip all leading ID3v2 tags. Some files have multiple
|
||||||
|
// consecutive tags from different tagging tools.
|
||||||
audioStart, err := skipID3v2(f)
|
audioStart, err := skipID3v2(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
return 0, fmt.Errorf("skipping ID3v2: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioStart, err = skipAdditionalID3v2(f, audioStart)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"skipping additional ID3v2 tags: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Find and parse the first MP3 frame header.
|
// 2. Find and parse the first MP3 frame header.
|
||||||
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,6 +152,39 @@ func skipID3v2(f *os.File) (int64, error) {
|
|||||||
return 10 + size, nil
|
return 10 + size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skipAdditionalID3v2 looks for further ID3v2 tags starting at
|
||||||
|
// offset and advances past each one found. This handles files
|
||||||
|
// where multiple tagging tools have each prepended their own ID3v2
|
||||||
|
// header.
|
||||||
|
//
|
||||||
|
//nolint:mnd // byte offsets from the ID3v2 spec.
|
||||||
|
func skipAdditionalID3v2(
|
||||||
|
f *os.File,
|
||||||
|
offset int64,
|
||||||
|
) (int64, error) {
|
||||||
|
var buf [10]byte
|
||||||
|
|
||||||
|
for range maxID3v2Tags {
|
||||||
|
if _, err := f.ReadAt(buf[:], offset); err != nil {
|
||||||
|
// EOF or short read means no more tags.
|
||||||
|
return offset, nil //nolint:nilerr
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(buf[:3]) != "ID3" {
|
||||||
|
return offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
size := int64(buf[6])<<21 |
|
||||||
|
int64(buf[7])<<14 |
|
||||||
|
int64(buf[8])<<7 |
|
||||||
|
int64(buf[9])
|
||||||
|
|
||||||
|
offset += 10 + size
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
// findFrameHeader scans from startOffset for the first valid MP3
|
// findFrameHeader scans from startOffset for the first valid MP3
|
||||||
// sync word and returns the parsed header plus the file offset
|
// sync word and returns the parsed header plus the file offset
|
||||||
// where the frame begins.
|
// where the frame begins.
|
||||||
|
|||||||
@@ -115,3 +115,159 @@ func TestGetMP3Duration_BasicParsing(t *testing.T) {
|
|||||||
t.Errorf("expected positive duration, got %d", ms)
|
t.Errorf("expected positive duration, got %d", ms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetMP3Duration_WithMultipleID3v2 creates a temporary MP3 file
|
||||||
|
// with two consecutive ID3v2 tags prepended and verifies that
|
||||||
|
// getMP3Duration correctly skips both and finds the audio.
|
||||||
|
func TestGetMP3Duration_WithMultipleID3v2(t *testing.T) {
|
||||||
|
files := testMP3Files(t)
|
||||||
|
src := files[0]
|
||||||
|
|
||||||
|
srcData, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading source: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reference duration from the original file.
|
||||||
|
origF, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open original: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = origF.Close() }()
|
||||||
|
|
||||||
|
origMS, err := getMP3Duration(origF)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getMP3Duration on original: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a file with two ID3v2 tags: 1 KB + 2 KB of padding.
|
||||||
|
//nolint:mnd // synthetic tag construction.
|
||||||
|
tag1Size := 1024
|
||||||
|
tag2Size := 2048
|
||||||
|
|
||||||
|
tag1 := buildID3v2Header(tag1Size)
|
||||||
|
tag2 := buildID3v2Header(tag2Size)
|
||||||
|
|
||||||
|
out := make(
|
||||||
|
[]byte,
|
||||||
|
0,
|
||||||
|
len(tag1)+tag1Size+len(tag2)+tag2Size+len(srcData),
|
||||||
|
)
|
||||||
|
out = append(out, tag1...)
|
||||||
|
out = append(out, make([]byte, tag1Size)...)
|
||||||
|
out = append(out, tag2...)
|
||||||
|
out = append(out, make([]byte, tag2Size)...)
|
||||||
|
out = append(out, srcData...)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpPath := filepath.Join(tmpDir, "multi_id3v2.mp3")
|
||||||
|
|
||||||
|
if err := os.WriteFile(
|
||||||
|
tmpPath, out, 0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("writing temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpF, err := os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open temp: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = tmpF.Close() }()
|
||||||
|
|
||||||
|
wrappedMS, err := getMP3Duration(tmpF)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf(
|
||||||
|
"getMP3Duration on multi-ID3v2 file: %v", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
diffMS := origMS - wrappedMS
|
||||||
|
if diffMS < 0 {
|
||||||
|
diffMS = -diffMS
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CBR calculation uses file size, so the prepended tags
|
||||||
|
// will cause a slight overestimate. Allow generous tolerance.
|
||||||
|
const toleranceMS = 5000
|
||||||
|
|
||||||
|
t.Logf(
|
||||||
|
"original=%dms wrapped=%dms diff=%dms",
|
||||||
|
origMS, wrappedMS, diffMS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if diffMS > toleranceMS {
|
||||||
|
t.Errorf(
|
||||||
|
"duration mismatch: original=%dms "+
|
||||||
|
"wrapped=%dms (diff %dms "+
|
||||||
|
"exceeds %dms tolerance)",
|
||||||
|
origMS, wrappedMS, diffMS, toleranceMS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkipAdditionalID3v2 verifies that skipAdditionalID3v2 handles
|
||||||
|
// files with no additional tags, one additional tag, and multiple
|
||||||
|
// additional tags.
|
||||||
|
func TestSkipAdditionalID3v2(t *testing.T) {
|
||||||
|
// Build a file: [ID3v2(100)] [ID3v2(200)] [ID3v2(50)] [data]
|
||||||
|
//nolint:mnd // synthetic tag sizes for test.
|
||||||
|
sizes := []int{100, 200, 50}
|
||||||
|
|
||||||
|
var buf []byte
|
||||||
|
|
||||||
|
for _, sz := range sizes {
|
||||||
|
buf = append(buf, buildID3v2Header(sz)...)
|
||||||
|
buf = append(buf, make([]byte, sz)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = append(buf, []byte("audio data here")...)
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpPath := filepath.Join(tmpDir, "multi_id3.bin")
|
||||||
|
|
||||||
|
if err := os.WriteFile(
|
||||||
|
tmpPath, buf, 0o644,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("writing temp file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
// skipID3v2 handles the first tag.
|
||||||
|
firstEnd, err := skipID3v2(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("skipID3v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:mnd // expected offset after first tag.
|
||||||
|
expectedFirst := int64(10 + 100)
|
||||||
|
if firstEnd != expectedFirst {
|
||||||
|
t.Fatalf(
|
||||||
|
"first tag end: got %d, want %d",
|
||||||
|
firstEnd, expectedFirst,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipAdditionalID3v2 handles the remaining tags.
|
||||||
|
finalOffset, err := skipAdditionalID3v2(f, firstEnd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("skipAdditionalID3v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected: 10+100 + 10+200 + 10+50 = 380
|
||||||
|
//nolint:mnd // expected offset after all tags.
|
||||||
|
expectedAll := int64(10 + 100 + 10 + 200 + 10 + 50)
|
||||||
|
if finalOffset != expectedAll {
|
||||||
|
t.Errorf(
|
||||||
|
"final offset: got %d, want %d",
|
||||||
|
finalOffset, expectedAll,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export class ConfigPage extends LitElement {
|
|||||||
@state() private statusMessage = '';
|
@state() private statusMessage = '';
|
||||||
@state() private metrics: ScanMetrics | null = null;
|
@state() private metrics: ScanMetrics | null = null;
|
||||||
@state() private copied = false;
|
@state() private copied = false;
|
||||||
|
@state() private errorsCopied = false;
|
||||||
|
@state() private scanErrors = '';
|
||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
|
|
||||||
private cancelScanStarted?: () => void;
|
private cancelScanStarted?: () => void;
|
||||||
@@ -293,6 +295,49 @@ export class ConfigPage extends LitElement {
|
|||||||
color: var(--yj-accent, #ffd43b);
|
color: var(--yj-accent, #ffd43b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Error block */
|
||||||
|
.error-block {
|
||||||
|
margin-top: 1em;
|
||||||
|
border: 1px solid var(--yj-error, #e03131);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--yj-error, #e03131) 15%,
|
||||||
|
var(--yj-bg-elevated, #343a40)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--yj-error, #e03131);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-body {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
background: var(--yj-bg-elevated, #343a40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-body pre {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* Metrics tree */
|
/* Metrics tree */
|
||||||
.metrics-wrapper {
|
.metrics-wrapper {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
@@ -472,6 +517,8 @@ export class ConfigPage extends LitElement {
|
|||||||
this.statusMessage = 'Scanning...';
|
this.statusMessage = 'Scanning...';
|
||||||
this.metrics = null;
|
this.metrics = null;
|
||||||
this.copied = false;
|
this.copied = false;
|
||||||
|
this.scanErrors = '';
|
||||||
|
this.errorsCopied = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleScanComplete = (
|
private handleScanComplete = (
|
||||||
@@ -536,7 +583,9 @@ export class ConfigPage extends LitElement {
|
|||||||
try {
|
try {
|
||||||
await Scan();
|
await Scan();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.statusMessage = `Scan failed: ${err}`;
|
this.statusMessage =
|
||||||
|
'Scan completed with errors.';
|
||||||
|
this.scanErrors = String(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -550,7 +599,9 @@ export class ConfigPage extends LitElement {
|
|||||||
try {
|
try {
|
||||||
await FullRescan();
|
await FullRescan();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.statusMessage = `Full rescan failed: ${err}`;
|
this.statusMessage =
|
||||||
|
'Full rescan completed with errors.';
|
||||||
|
this.scanErrors = String(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -573,6 +624,27 @@ export class ConfigPage extends LitElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleCopyErrors =
|
||||||
|
async (): Promise<void> => {
|
||||||
|
if (!this.scanErrors) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
this.scanErrors,
|
||||||
|
);
|
||||||
|
this.errorsCopied = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.errorsCopied = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to copy errors:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// THEME HANDLERS
|
// THEME HANDLERS
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -817,6 +889,29 @@ export class ConfigPage extends LitElement {
|
|||||||
${this.statusMessage || 'Ready.'}
|
${this.statusMessage || 'Ready.'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.scanErrors
|
||||||
|
? html`
|
||||||
|
<div class="error-block">
|
||||||
|
<div class="error-header">
|
||||||
|
<span class="error-title">
|
||||||
|
Scan Errors
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn-ghost ${this.errorsCopied ? 'copied' : ''}"
|
||||||
|
@click=${this.handleCopyErrors}
|
||||||
|
>
|
||||||
|
${this.errorsCopied
|
||||||
|
? 'Copied!'
|
||||||
|
: 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="error-body">
|
||||||
|
<pre>${this.scanErrors}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
|
||||||
${this.renderMetrics()}
|
${this.renderMetrics()}
|
||||||
</config-section>
|
</config-section>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ export class LibraryManager extends LitElement {
|
|||||||
@state() private statusMessage = '';
|
@state() private statusMessage = '';
|
||||||
@state() private metrics: ScanMetrics | null = null;
|
@state() private metrics: ScanMetrics | null = null;
|
||||||
@state() private copied = false;
|
@state() private copied = false;
|
||||||
|
@state() private errorsCopied = false;
|
||||||
|
@state() private scanErrors = '';
|
||||||
@state() private concurrencyMode = 'auto';
|
@state() private concurrencyMode = 'auto';
|
||||||
private cancelScanStarted?: () => void;
|
private cancelScanStarted?: () => void;
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
@@ -436,6 +438,49 @@ export class LibraryManager extends LitElement {
|
|||||||
color: var(--yj-accent, #ffd43b);
|
color: var(--yj-accent, #ffd43b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Error block --- */
|
||||||
|
.error-block {
|
||||||
|
margin-top: 1em;
|
||||||
|
border: 1px solid var(--yj-error, #e03131);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--yj-error, #e03131) 15%,
|
||||||
|
var(--yj-bg-elevated, #1a1d20)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--yj-error, #e03131);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-body {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
background: var(--yj-bg-elevated, #1a1d20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-body pre {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--yj-text-secondary, #adb5bd);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Metrics tree --- */
|
/* --- Metrics tree --- */
|
||||||
.metrics-section {
|
.metrics-section {
|
||||||
margin-top: 1.5em;
|
margin-top: 1.5em;
|
||||||
@@ -593,6 +638,8 @@ export class LibraryManager extends LitElement {
|
|||||||
this.statusMessage = 'Scanning...';
|
this.statusMessage = 'Scanning...';
|
||||||
this.metrics = null;
|
this.metrics = null;
|
||||||
this.copied = false;
|
this.copied = false;
|
||||||
|
this.scanErrors = '';
|
||||||
|
this.errorsCopied = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleScanComplete = (
|
private handleScanComplete = (
|
||||||
@@ -647,7 +694,9 @@ export class LibraryManager extends LitElement {
|
|||||||
try {
|
try {
|
||||||
await Scan();
|
await Scan();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.statusMessage = `Scan failed: ${err}`;
|
this.statusMessage =
|
||||||
|
'Scan completed with errors.';
|
||||||
|
this.scanErrors = String(err);
|
||||||
console.error('Soft scan failed:', err);
|
console.error('Soft scan failed:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -663,7 +712,9 @@ export class LibraryManager extends LitElement {
|
|||||||
try {
|
try {
|
||||||
await FullRescan();
|
await FullRescan();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.statusMessage = `Full rescan failed: ${err}`;
|
this.statusMessage =
|
||||||
|
'Full rescan completed with errors.';
|
||||||
|
this.scanErrors = String(err);
|
||||||
console.error(
|
console.error(
|
||||||
'Full rescan failed:',
|
'Full rescan failed:',
|
||||||
err,
|
err,
|
||||||
@@ -694,6 +745,27 @@ export class LibraryManager extends LitElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleCopyErrors =
|
||||||
|
async (): Promise<void> => {
|
||||||
|
if (!this.scanErrors) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
this.scanErrors,
|
||||||
|
);
|
||||||
|
this.errorsCopied = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.errorsCopied = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
'Failed to copy errors:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private get directoryChanged(): boolean {
|
private get directoryChanged(): boolean {
|
||||||
return (
|
return (
|
||||||
this.selectedDirectory !==
|
this.selectedDirectory !==
|
||||||
@@ -1006,6 +1078,29 @@ export class LibraryManager extends LitElement {
|
|||||||
${this.statusMessage || 'Ready.'}
|
${this.statusMessage || 'Ready.'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.scanErrors
|
||||||
|
? html`
|
||||||
|
<div class="error-block">
|
||||||
|
<div class="error-header">
|
||||||
|
<span class="error-title">
|
||||||
|
Scan Errors
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn-ghost ${this.errorsCopied ? 'copied' : ''}"
|
||||||
|
@click=${this.handleCopyErrors}
|
||||||
|
>
|
||||||
|
${this.errorsCopied
|
||||||
|
? 'Copied!'
|
||||||
|
: 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="error-body">
|
||||||
|
<pre>${this.scanErrors}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: ''}
|
||||||
|
|
||||||
${this.renderMetrics()}
|
${this.renderMetrics()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user