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:
+56
-11
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user