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) {
|
||||
ext := filepath.Ext(f.Name())
|
||||
|
||||
if ext == ".mp3" {
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
return getMP3Duration(f)
|
||||
case ".flac":
|
||||
return getFlacDuration(f)
|
||||
}
|
||||
|
||||
// FLAC, OGG, and WAV: beep's Decode() + Len() is already
|
||||
// cheap (reads headers/metadata only, no full audio decode).
|
||||
// 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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
// maxSyncSearchBytes limits how far we scan for the first sync word
|
||||
// after skipping any ID3v2 tag.
|
||||
const maxSyncSearchBytes = 64 * 1024
|
||||
// after skipping any ID3v2 tags. 512 KB accommodates files with
|
||||
// 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.
|
||||
const (
|
||||
@@ -64,12 +70,20 @@ func samplesPerFrame(version int) int {
|
||||
//
|
||||
// The file position is undefined after this call.
|
||||
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)
|
||||
if err != nil {
|
||||
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.
|
||||
hdr, frameOffset, err := findFrameHeader(f, audioStart)
|
||||
if err != nil {
|
||||
@@ -138,6 +152,39 @@ func skipID3v2(f *os.File) (int64, error) {
|
||||
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
|
||||
// sync word and returns the parsed header plus the file offset
|
||||
// where the frame begins.
|
||||
|
||||
@@ -115,3 +115,159 @@ func TestGetMP3Duration_BasicParsing(t *testing.T) {
|
||||
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 metrics: ScanMetrics | null = null;
|
||||
@state() private copied = false;
|
||||
@state() private errorsCopied = false;
|
||||
@state() private scanErrors = '';
|
||||
@state() private concurrencyMode = 'auto';
|
||||
|
||||
private cancelScanStarted?: () => void;
|
||||
@@ -293,6 +295,49 @@ export class ConfigPage extends LitElement {
|
||||
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-wrapper {
|
||||
margin-top: 1em;
|
||||
@@ -472,6 +517,8 @@ export class ConfigPage extends LitElement {
|
||||
this.statusMessage = 'Scanning...';
|
||||
this.metrics = null;
|
||||
this.copied = false;
|
||||
this.scanErrors = '';
|
||||
this.errorsCopied = false;
|
||||
};
|
||||
|
||||
private handleScanComplete = (
|
||||
@@ -536,7 +583,9 @@ export class ConfigPage extends LitElement {
|
||||
try {
|
||||
await Scan();
|
||||
} 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 {
|
||||
await FullRescan();
|
||||
} 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
|
||||
// ===================================================================
|
||||
@@ -817,6 +889,29 @@ export class ConfigPage extends LitElement {
|
||||
${this.statusMessage || 'Ready.'}
|
||||
</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()}
|
||||
</config-section>
|
||||
`;
|
||||
|
||||
@@ -234,6 +234,8 @@ export class LibraryManager extends LitElement {
|
||||
@state() private statusMessage = '';
|
||||
@state() private metrics: ScanMetrics | null = null;
|
||||
@state() private copied = false;
|
||||
@state() private errorsCopied = false;
|
||||
@state() private scanErrors = '';
|
||||
@state() private concurrencyMode = 'auto';
|
||||
private cancelScanStarted?: () => void;
|
||||
private cancelScanComplete?: () => void;
|
||||
@@ -436,6 +438,49 @@ export class LibraryManager extends LitElement {
|
||||
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-section {
|
||||
margin-top: 1.5em;
|
||||
@@ -593,6 +638,8 @@ export class LibraryManager extends LitElement {
|
||||
this.statusMessage = 'Scanning...';
|
||||
this.metrics = null;
|
||||
this.copied = false;
|
||||
this.scanErrors = '';
|
||||
this.errorsCopied = false;
|
||||
};
|
||||
|
||||
private handleScanComplete = (
|
||||
@@ -647,7 +694,9 @@ export class LibraryManager extends LitElement {
|
||||
try {
|
||||
await Scan();
|
||||
} catch (err) {
|
||||
this.statusMessage = `Scan failed: ${err}`;
|
||||
this.statusMessage =
|
||||
'Scan completed with errors.';
|
||||
this.scanErrors = String(err);
|
||||
console.error('Soft scan failed:', err);
|
||||
}
|
||||
};
|
||||
@@ -663,7 +712,9 @@ export class LibraryManager extends LitElement {
|
||||
try {
|
||||
await FullRescan();
|
||||
} catch (err) {
|
||||
this.statusMessage = `Full rescan failed: ${err}`;
|
||||
this.statusMessage =
|
||||
'Full rescan completed with errors.';
|
||||
this.scanErrors = String(err);
|
||||
console.error(
|
||||
'Full rescan failed:',
|
||||
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 {
|
||||
return (
|
||||
this.selectedDirectory !==
|
||||
@@ -1006,6 +1078,29 @@ export class LibraryManager extends LitElement {
|
||||
${this.statusMessage || 'Ready.'}
|
||||
</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()}
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user