feat(02-02): add ScanWarning type and reclassify scan errors as warnings

- Add ScanWarning struct with FilePath/Phase/Err to ScanMetrics
- Add mutex-protected addWarning method for concurrent use
- Reclassify walk, extraction, commit, orphan, variant, FTS failures as warnings
- Update commitBatch to return only fatal tx.Commit errors
- Update cachedLinkArtist to check errors via database.IsUniqueViolation
- Update handleConfigUpdate to capture and log scan warning count
This commit is contained in:
2026-03-02 19:18:11 -05:00
parent 0d5b76cb0f
commit e6866ded9d
2 changed files with 83 additions and 16 deletions
+61 -16
View File
@@ -317,15 +317,13 @@ func (l *Library) Scan() (*ScanMetrics, error) {
) )
if walkErr != nil { if walkErr != nil {
errMu.Lock() metrics.addWarning(
scanErr = errors.Join( "", "walk",
scanErr,
fmt.Errorf( fmt.Errorf(
"problem walking library directory: %w", "problem walking library directory: %w",
walkErr, walkErr,
), ),
) )
errMu.Unlock()
} }
}() }()
@@ -352,6 +350,10 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"hash", work.hashStr, "hash", work.hashStr,
"err", err, "err", err,
) )
metrics.addWarning(
"", "variant", err,
)
} }
} }
}() }()
@@ -433,9 +435,10 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"err", err, "err", err,
) )
errMu.Lock() metrics.addWarning(
scanErr = errors.Join(scanErr, err) work.absolutePath,
errMu.Unlock() "extraction", err,
)
return nil return nil
} }
@@ -491,6 +494,8 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"err", err, "err", err,
) )
metrics.addWarning(path, "orphan", err)
return true return true
} }
@@ -503,6 +508,8 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"id", audioFile.ID, "id", audioFile.ID,
"err", err, "err", err,
) )
metrics.addWarning(path, "orphan", err)
} }
removed.Add(1) removed.Add(1)
@@ -520,6 +527,8 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"could not generate missing sized variants", "could not generate missing sized variants",
"err", err, "err", err,
) )
metrics.addWarning("", "variant", err)
} }
metrics.PostScanVariants = time.Since(variantStart) metrics.PostScanVariants = time.Since(variantStart)
@@ -663,8 +672,6 @@ func (l *Library) commitBatch(
txq := l.db.Queries.WithTx(tx) txq := l.db.Queries.WithTx(tx)
var batchErr error
for i := range batch { for i := range batch {
result := &batch[i] result := &batch[i]
@@ -695,7 +702,9 @@ func (l *Library) commitBatch(
"err", saveErr, "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). // saveAudioFile writes audio file metadata to the database (new files).
@@ -795,6 +804,8 @@ func (l *Library) saveAudioFile(
"path", result.absolutePath, "path", result.absolutePath,
"err", err, "err", err,
) )
metrics.addWarning(result.absolutePath, "commit", err)
} }
l.logger.Debug( l.logger.Debug(
@@ -873,6 +884,8 @@ func (l *Library) updateAudioFileMetadata(
"id", result.existingFileID, "id", result.existingFileID,
"err", err, "err", err,
) )
metrics.addWarning(result.absolutePath, "commit", err)
} }
if _, err := tx.ExecContext( if _, err := tx.ExecContext(
@@ -890,6 +903,8 @@ func (l *Library) updateAudioFileMetadata(
"path", result.absolutePath, "path", result.absolutePath,
"err", err, "err", err,
) )
metrics.addWarning(result.absolutePath, "commit", err)
} }
l.logger.Debug( 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. // 3. Get or create artist credit for album artist.
albumArtistCreditID := l.resolveAlbumArtistCredit( albumArtistCreditID := l.resolveAlbumArtistCredit(
q, cache, tags, artistCredit.ID, q, cache, metrics, tags, artistCredit.ID,
) )
// 4. Get or create release group (album). // 4. Get or create release group (album).
@@ -1071,9 +1086,12 @@ func (l *Library) cachedUpsertArtistCredit(
// cachedLinkArtist upserts the artist record and creates the // cachedLinkArtist upserts the artist record and creates the
// artist-credit-artist link, skipping work already done. // 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( func (l *Library) cachedLinkArtist(
q *sqlcgen.Queries, q *sqlcgen.Queries,
cache *entityCache, cache *entityCache,
metrics *ScanMetrics,
name string, name string,
creditID int64, creditID int64,
) { ) {
@@ -1098,13 +1116,34 @@ func (l *Library) cachedLinkArtist(
return return
} }
_, _ = q.CreateArtistCreditArtist( _, err := q.CreateArtistCreditArtist(
l.ctx, l.ctx,
sqlcgen.CreateArtistCreditArtistParams{ sqlcgen.CreateArtistCreditArtistParams{
ArtistID: artist.ID, ArtistID: artist.ID,
CreditID: creditID, 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{}{} cache.linkedCredits[linkKey] = struct{}{}
} }
@@ -1176,6 +1215,7 @@ func (l *Library) linkRecordingGenres(
func (l *Library) resolveAlbumArtistCredit( func (l *Library) resolveAlbumArtistCredit(
q *sqlcgen.Queries, q *sqlcgen.Queries,
cache *entityCache, cache *entityCache,
metrics *ScanMetrics,
tags *metadata.TrackMetadata, tags *metadata.TrackMetadata,
trackArtistCreditID int64, trackArtistCreditID int64,
) sql.NullInt64 { ) sql.NullInt64 {
@@ -1197,7 +1237,7 @@ func (l *Library) resolveAlbumArtistCredit(
} }
l.cachedLinkArtist( l.cachedLinkArtist(
q, cache, tags.AlbumArtist, albumArtistCredit.ID, q, cache, metrics, tags.AlbumArtist, albumArtistCredit.ID,
) )
return sql.NullInt64{ return sql.NullInt64{
@@ -1322,7 +1362,7 @@ func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
l.conf.DirectoryPath = updatedConfigValues.DirectoryPath l.conf.DirectoryPath = updatedConfigValues.DirectoryPath
if _, err := l.Scan(); err != nil { if scanMetrics, err := l.Scan(); err != nil {
updateErr = errors.Join( updateErr = errors.Join(
updateErr, updateErr,
fmt.Errorf( fmt.Errorf(
@@ -1330,6 +1370,11 @@ func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
err, err,
), ),
) )
} else if len(scanMetrics.Warnings) > 0 {
l.logger.Warn(
"library scan completed with warnings",
"warningCount", len(scanMetrics.Warnings),
)
} }
} }
+22
View File
@@ -49,6 +49,16 @@ type ScanMetrics struct {
Updated int64 `json:"updated"` Updated int64 `json:"updated"`
Skipped int64 `json:"skipped"` Skipped int64 `json:"skipped"`
Removed int64 `json:"removed"` 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 { func newScanMetrics() *ScanMetrics {
@@ -80,6 +90,18 @@ func (m *ScanMetrics) addCoverArtSave(d time.Duration) {
m.CoverArtSave += d 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 // addThumbnailTier records the time spent generating a single
// thumbnail tier. Safe for concurrent use from the thumbnail // thumbnail tier. Safe for concurrent use from the thumbnail
// worker pool. // worker pool.