diff --git a/backend/metadata/duration.go b/backend/metadata/duration.go index 147d603..2393c73 100644 --- a/backend/metadata/duration.go +++ b/backend/metadata/duration.go @@ -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) diff --git a/backend/metadata/flacduration.go b/backend/metadata/flacduration.go new file mode 100644 index 0000000..6aafbab --- /dev/null +++ b/backend/metadata/flacduration.go @@ -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 +} diff --git a/backend/metadata/flacduration_test.go b/backend/metadata/flacduration_test.go new file mode 100644 index 0000000..f6529e5 --- /dev/null +++ b/backend/metadata/flacduration_test.go @@ -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 +} diff --git a/backend/metadata/mp3duration.go b/backend/metadata/mp3duration.go index 541b879..218e4ae 100644 --- a/backend/metadata/mp3duration.go +++ b/backend/metadata/mp3duration.go @@ -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. diff --git a/backend/metadata/mp3duration_test.go b/backend/metadata/mp3duration_test.go index e3a9dc0..dec83b8 100644 --- a/backend/metadata/mp3duration_test.go +++ b/backend/metadata/mp3duration_test.go @@ -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, + ) + } +} diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index c9d29c0..dc4fca6 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -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 => { + 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.'} + ${this.scanErrors + ? html` +
+
+ + Scan Errors + + +
+
+
${this.scanErrors}
+
+
+ ` + : ''} + ${this.renderMetrics()} `; diff --git a/frontend/src/components/library-manager/library-manager.ts b/frontend/src/components/library-manager/library-manager.ts index b281492..a7b8974 100644 --- a/frontend/src/components/library-manager/library-manager.ts +++ b/frontend/src/components/library-manager/library-manager.ts @@ -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 => { + 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.'} + ${this.scanErrors + ? html` +
+
+ + Scan Errors + + +
+
+
${this.scanErrors}
+
+
+ ` + : ''} + ${this.renderMetrics()} `; }