feat(server): targeted ScanFiles + fsnotify library watcher

Add Scanner.ScanFiles (watcher-driven targeted scan returning changed album
IDs) and a recursive fsnotify Watcher that debounces filesystem events and
enriches just the affected albums inline. Pure classifyEvent/drainPending
seams unit-tested; ScanFiles covered in the scanner integration test.
This commit is contained in:
2026-06-06 21:43:27 -04:00
parent 06e155abb6
commit c39a9ca18f
6 changed files with 407 additions and 11 deletions
+56 -11
View File
@@ -87,7 +87,7 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
return nil
}
if err := s.scanFile(ctx, q, path, &stats); err != nil {
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
s.logger.Warn("library scan file error", "path", path, "err", err)
stats.Errored++
}
@@ -114,19 +114,24 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
return stats, nil
}
func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, stats *Stats) error {
// scanFile upserts a single audio file. Returns the album ID the track
// belongs to and whether the file was added/updated (false = skipped as
// unchanged), so watcher-driven callers can enrich just the changed albums.
func (s *Scanner) scanFile(
ctx context.Context, q *dbq.Queries, path string, stats *Stats,
) (pgtype.UUID, bool, error) {
stats.Scanned++
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("stat: %w", err)
}
mtime := info.ModTime()
existing, err := q.GetTrackByPath(ctx, path)
knownTrack := err == nil
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lookup: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("lookup: %w", err)
}
// Incremental skip: only when the file hasn't changed AND we already have
// a real duration. The second clause lets older scans that recorded
@@ -135,18 +140,18 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
// scans short-circuit as before.
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
stats.Skipped++
return nil
return pgtype.UUID{}, false, nil
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("open: %w", err)
}
defer func() { _ = f.Close() }()
meta, err := tag.ReadFrom(f)
if err != nil {
return fmt.Errorf("tag read: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("tag read: %w", err)
}
albumMBID, artistMBID := extractMBIDs(meta)
recordingMBID := extractRecordingMBID(meta)
@@ -166,11 +171,11 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
artist, err := s.resolveArtist(ctx, q, artistName, artistMBID)
if err != nil {
return fmt.Errorf("artist: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("artist: %w", err)
}
album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID)
if err != nil {
return fmt.Errorf("album: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("album: %w", err)
}
trackNum, _ := meta.Track()
@@ -213,7 +218,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
track, err := q.UpsertTrack(ctx, params)
if err != nil {
return fmt.Errorf("upsert track: %w", err)
return pgtype.UUID{}, false, fmt.Errorf("upsert track: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityTrack,
syncpkg.FormatUUID(track.ID), syncpkg.OpUpsert); err != nil {
@@ -227,7 +232,47 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
} else {
stats.Added++
}
return nil
return album.ID, true, nil
}
// ScanFiles processes a specific set of audio file paths (watcher-driven),
// applying the same upsert + delta-skip logic as a full Scan. Non-audio or
// unreadable paths are skipped (logged), never fatal. Returns the distinct
// album IDs whose tracks were added or updated, so the caller can enrich
// just those albums inline rather than waiting for a batch pass.
func (s *Scanner) ScanFiles(ctx context.Context, paths []string) ([]pgtype.UUID, error) {
q := dbq.New(s.pool)
seen := make(map[[16]byte]struct{})
changed := make([]pgtype.UUID, 0)
var stats Stats
for _, path := range paths {
if ctx.Err() != nil {
return changed, ctx.Err()
}
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
continue
}
albumID, didChange, err := s.scanFile(ctx, q, path, &stats)
if err != nil {
s.logger.Warn("library watch: scan file error", "path", path, "err", err)
continue
}
if didChange && albumID.Valid {
if _, ok := seen[albumID.Bytes]; !ok {
seen[albumID.Bytes] = struct{}{}
changed = append(changed, albumID)
}
}
}
if stats.Added > 0 || stats.Updated > 0 {
s.logger.Info("library watch: scan batch",
"added", stats.Added,
"updated", stats.Updated,
"skipped", stats.Skipped,
"errored", stats.Errored,
)
}
return changed, nil
}
func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid string) (dbq.Artist, error) {