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:
@@ -121,6 +121,45 @@ func TestScanner_Integration(t *testing.T) {
|
||||
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
|
||||
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
|
||||
}
|
||||
|
||||
// ScanFiles (watcher-driven targeted scan): a newly-added file is picked
|
||||
// up by path without walking the whole tree, and its album ID is returned
|
||||
// so the watcher can enrich just that album. A non-audio path in the batch
|
||||
// is silently ignored.
|
||||
newFile := filepath.Join(root, "artistX/albumA/03.mp3")
|
||||
writeTestMP3(t, newFile, map[string]string{
|
||||
"TIT2": "Track Three", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "3", "TYER": "2020",
|
||||
})
|
||||
notAudio := filepath.Join(root, "artistX/albumA/cover.txt")
|
||||
if err := os.WriteFile(notAudio, []byte("nope"), 0o644); err != nil {
|
||||
t.Fatalf("write non-audio: %v", err)
|
||||
}
|
||||
changed, err := scanner.ScanFiles(ctx, []string{newFile, notAudio})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFiles: %v", err)
|
||||
}
|
||||
if len(changed) != 1 || !changed[0].Valid {
|
||||
t.Fatalf("ScanFiles changed albums = %+v, want exactly 1 valid", changed)
|
||||
}
|
||||
|
||||
// The targeted scan upserts only the named file; the rest of the library
|
||||
// is untouched (no full walk).
|
||||
if _, err := q.GetTrackByPath(ctx, newFile); err != nil {
|
||||
t.Fatalf("GetTrackByPath after ScanFiles: %v", err)
|
||||
}
|
||||
|
||||
// Re-running ScanFiles on the now-unchanged file (duration probed) skips it,
|
||||
// so no album is reported as changed.
|
||||
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
|
||||
t.Fatalf("seed durations (2): %v", err)
|
||||
}
|
||||
changed2, err := scanner.ScanFiles(ctx, []string{newFile})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFiles (rescan): %v", err)
|
||||
}
|
||||
if len(changed2) != 0 {
|
||||
t.Errorf("ScanFiles on unchanged file changed = %+v, want none", changed2)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
|
||||
|
||||
Reference in New Issue
Block a user