fixed common track scanning bugs with custom workarounds

This commit is contained in:
2026-02-20 17:14:03 -05:00
parent 84f027aedf
commit 089af5e299
7 changed files with 817 additions and 10 deletions
+6 -3
View File
@@ -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)
+148
View File
@@ -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
}
+263
View File
@@ -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
}
+50 -3
View File
@@ -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.
+156
View File
@@ -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()}
`;
}