diff --git a/backend/library/library.go b/backend/library/library.go index f30d4c4..4f19927 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -317,15 +317,13 @@ func (l *Library) Scan() (*ScanMetrics, error) { ) if walkErr != nil { - errMu.Lock() - scanErr = errors.Join( - scanErr, + metrics.addWarning( + "", "walk", fmt.Errorf( "problem walking library directory: %w", walkErr, ), ) - errMu.Unlock() } }() @@ -352,6 +350,10 @@ func (l *Library) Scan() (*ScanMetrics, error) { "hash", work.hashStr, "err", err, ) + + metrics.addWarning( + "", "variant", err, + ) } } }() @@ -433,9 +435,10 @@ func (l *Library) Scan() (*ScanMetrics, error) { "err", err, ) - errMu.Lock() - scanErr = errors.Join(scanErr, err) - errMu.Unlock() + metrics.addWarning( + work.absolutePath, + "extraction", err, + ) return nil } @@ -491,6 +494,8 @@ func (l *Library) Scan() (*ScanMetrics, error) { "err", err, ) + metrics.addWarning(path, "orphan", err) + return true } @@ -503,6 +508,8 @@ func (l *Library) Scan() (*ScanMetrics, error) { "id", audioFile.ID, "err", err, ) + + metrics.addWarning(path, "orphan", err) } removed.Add(1) @@ -520,6 +527,8 @@ func (l *Library) Scan() (*ScanMetrics, error) { "could not generate missing sized variants", "err", err, ) + + metrics.addWarning("", "variant", err) } metrics.PostScanVariants = time.Since(variantStart) @@ -663,8 +672,6 @@ func (l *Library) commitBatch( txq := l.db.Queries.WithTx(tx) - var batchErr error - for i := range batch { result := &batch[i] @@ -695,7 +702,9 @@ func (l *Library) commitBatch( "err", saveErr, ) - batchErr = errors.Join(batchErr, saveErr) + metrics.addWarning( + result.absolutePath, "commit", saveErr, + ) } } @@ -706,7 +715,7 @@ func (l *Library) commitBatch( ) } - return batchErr + return nil } // saveAudioFile writes audio file metadata to the database (new files). @@ -795,6 +804,8 @@ func (l *Library) saveAudioFile( "path", result.absolutePath, "err", err, ) + + metrics.addWarning(result.absolutePath, "commit", err) } l.logger.Debug( @@ -873,6 +884,8 @@ func (l *Library) updateAudioFileMetadata( "id", result.existingFileID, "err", err, ) + + metrics.addWarning(result.absolutePath, "commit", err) } if _, err := tx.ExecContext( @@ -890,6 +903,8 @@ func (l *Library) updateAudioFileMetadata( "path", result.absolutePath, "err", err, ) + + metrics.addWarning(result.absolutePath, "commit", err) } l.logger.Debug( @@ -938,11 +953,11 @@ func (l *Library) processMetadata( ) } - l.cachedLinkArtist(q, cache, artistName, artistCredit.ID) + l.cachedLinkArtist(q, cache, metrics, artistName, artistCredit.ID) // 3. Get or create artist credit for album artist. albumArtistCreditID := l.resolveAlbumArtistCredit( - q, cache, tags, artistCredit.ID, + q, cache, metrics, tags, artistCredit.ID, ) // 4. Get or create release group (album). @@ -1071,9 +1086,12 @@ func (l *Library) cachedUpsertArtistCredit( // cachedLinkArtist upserts the artist record and creates the // artist-credit-artist link, skipping work already done. +// UNIQUE constraint violations are silently ignored (link already +// exists in the database). Other errors are recorded as scan warnings. func (l *Library) cachedLinkArtist( q *sqlcgen.Queries, cache *entityCache, + metrics *ScanMetrics, name string, creditID int64, ) { @@ -1098,13 +1116,34 @@ func (l *Library) cachedLinkArtist( return } - _, _ = q.CreateArtistCreditArtist( + _, err := q.CreateArtistCreditArtist( l.ctx, sqlcgen.CreateArtistCreditArtistParams{ ArtistID: artist.ID, CreditID: creditID, }, ) + if err != nil { + if !database.IsUniqueViolation(err) { + l.logger.Warn( + "could not link artist to credit", + "artist", name, + "creditID", creditID, + "err", err, + ) + + metrics.addWarning( + name, "commit", + fmt.Errorf( + "artist-credit link failed for %q: %w", + name, err, + ), + ) + } + + // UNIQUE violation: link already exists in DB, not an error. + return + } cache.linkedCredits[linkKey] = struct{}{} } @@ -1176,6 +1215,7 @@ func (l *Library) linkRecordingGenres( func (l *Library) resolveAlbumArtistCredit( q *sqlcgen.Queries, cache *entityCache, + metrics *ScanMetrics, tags *metadata.TrackMetadata, trackArtistCreditID int64, ) sql.NullInt64 { @@ -1197,7 +1237,7 @@ func (l *Library) resolveAlbumArtistCredit( } l.cachedLinkArtist( - q, cache, tags.AlbumArtist, albumArtistCredit.ID, + q, cache, metrics, tags.AlbumArtist, albumArtistCredit.ID, ) return sql.NullInt64{ @@ -1322,7 +1362,7 @@ func (l *Library) handleConfigUpdate(updatedConfigValues Config) error { l.conf.DirectoryPath = updatedConfigValues.DirectoryPath - if _, err := l.Scan(); err != nil { + if scanMetrics, err := l.Scan(); err != nil { updateErr = errors.Join( updateErr, fmt.Errorf( @@ -1330,6 +1370,11 @@ func (l *Library) handleConfigUpdate(updatedConfigValues Config) error { err, ), ) + } else if len(scanMetrics.Warnings) > 0 { + l.logger.Warn( + "library scan completed with warnings", + "warningCount", len(scanMetrics.Warnings), + ) } } diff --git a/backend/library/metrics.go b/backend/library/metrics.go index 0f3e2c2..e5435aa 100644 --- a/backend/library/metrics.go +++ b/backend/library/metrics.go @@ -49,6 +49,16 @@ type ScanMetrics struct { Updated int64 `json:"updated"` Skipped int64 `json:"skipped"` Removed int64 `json:"removed"` + + // Non-fatal issues encountered during scanning. + Warnings []ScanWarning `json:"warnings"` +} + +// ScanWarning represents a non-fatal issue encountered during scanning. +type ScanWarning struct { + FilePath string `json:"filePath"` + Phase string `json:"phase"` + Err error `json:"err"` } func newScanMetrics() *ScanMetrics { @@ -80,6 +90,18 @@ func (m *ScanMetrics) addCoverArtSave(d time.Duration) { m.CoverArtSave += d } +// addWarning records a non-fatal scan issue. Safe for concurrent use. +func (m *ScanMetrics) addWarning(filePath, phase string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.Warnings = append(m.Warnings, ScanWarning{ + FilePath: filePath, + Phase: phase, + Err: err, + }) +} + // addThumbnailTier records the time spent generating a single // thumbnail tier. Safe for concurrent use from the thumbnail // worker pool.