feat(09-01): add scan control fields and per-scan cancellable context

- Add scanActive, scanCancel, scanPaused, scanPauseCh fields to Library struct
- Create scan_control.go with CancelScan, PauseScan, ResumeScan, IsScanActive, IsScanPaused
- Thread per-scan scanCtx through walk and worker pipeline
- Add waitIfPaused checkpoint before each worker extraction
- Skip orphan cleanup and variant generation on cancelled scan
- Emit LibraryScanCancelled instead of LibraryScanComplete when cancelled
This commit is contained in:
2026-03-06 21:30:49 -05:00
parent c695024241
commit cf22e52a64
2 changed files with 199 additions and 59 deletions
+60 -8
View File
@@ -84,6 +84,12 @@ type Library struct {
conf *Config
db *database.DB
rescanHooks RescanHooks
// Scan control fields — protected by mu.
scanActive bool
scanCancel context.CancelFunc
scanPaused bool
scanPauseCh chan struct{}
}
// SetRescanHooks provides optional hooks for cross-cutting
@@ -176,6 +182,32 @@ func (l *Library) Scan() (*ScanMetrics, error) {
metrics := newScanMetrics()
scanStart := time.Now()
scanCtx, scanCancel := context.WithCancel(l.ctx)
defer scanCancel()
l.mu.Lock()
l.scanCancel = scanCancel
l.scanActive = true
l.scanPaused = false
l.scanPauseCh = nil
l.mu.Unlock()
defer func() {
l.mu.Lock()
l.scanCancel = nil
l.scanActive = false
// If still paused, unpause so no dangling channel.
if l.scanPaused {
l.scanPaused = false
if l.scanPauseCh != nil {
close(l.scanPauseCh)
}
}
l.scanPauseCh = nil
l.mu.Unlock()
}()
if len(l.conf.DirectoryPath) == 0 {
return metrics, errLibraryDirNotConfigured
}
@@ -294,8 +326,8 @@ func (l *Library) Scan() (*ScanMetrics, error) {
needsUpdate: true,
existingLength: audioFile.LengthMilliseconds,
}:
case <-l.ctx.Done():
return l.ctx.Err()
case <-scanCtx.Done():
return scanCtx.Err()
}
return nil
@@ -321,8 +353,8 @@ func (l *Library) Scan() (*ScanMetrics, error) {
absolutePath: absoluteFilePath,
fileType: fileType,
}:
case <-l.ctx.Done():
return l.ctx.Err()
case <-scanCtx.Done():
return scanCtx.Err()
}
return nil
@@ -473,6 +505,10 @@ func (l *Library) Scan() (*ScanMetrics, error) {
for work := range workChan {
g.Go(func() error {
if err := l.waitIfPaused(scanCtx); err != nil {
return err
}
result, err := l.extractAudioMetadata(
work, metrics,
)
@@ -493,8 +529,8 @@ func (l *Library) Scan() (*ScanMetrics, error) {
select {
case resultChan <- result:
case <-l.ctx.Done():
return l.ctx.Err()
case <-scanCtx.Done():
return scanCtx.Err()
}
return nil
@@ -546,6 +582,16 @@ func (l *Library) Scan() (*ScanMetrics, error) {
metrics.ThumbnailWallClock = time.Since(thumbStart)
// Skip orphan cleanup if the scan was cancelled — existingPaths
// still contains unvisited files that would be incorrectly deleted.
cancelled := scanCtx.Err() != nil
var removed atomic.Int64
if cancelled {
metrics.Cancelled = true
l.logger.Info("scan cancelled, skipping orphan cleanup")
} else {
// --- Phase 5: orphan cleanup ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress, ScanProgress{
Phase: "orphans", Total: totalFiles,
@@ -554,8 +600,6 @@ func (l *Library) Scan() (*ScanMetrics, error) {
orphanStart := time.Now()
var removed atomic.Int64
existingPaths.Range(func(key, value any) bool {
path := key.(string)
audioFile := value.(sqlcgen.AudioFile)
@@ -599,8 +643,10 @@ func (l *Library) Scan() (*ScanMetrics, error) {
})
metrics.OrphanCleanup = time.Since(orphanStart)
}
// --- Phase 6: post-scan variant generation ---
if !cancelled {
variantStart := time.Now()
if err := l.generateMissingSizedVariants(); err != nil {
@@ -613,6 +659,7 @@ func (l *Library) Scan() (*ScanMetrics, error) {
}
metrics.PostScanVariants = time.Since(variantStart)
}
// --- Finalize ---
metrics.Added = added.Load()
@@ -627,13 +674,18 @@ func (l *Library) Scan() (*ScanMetrics, error) {
"updated", metrics.Updated,
"removed", metrics.Removed,
"skipped", metrics.Skipped,
"cancelled", cancelled,
"total", metrics.Total,
"library", l.conf.DirectoryPath,
)
if cancelled {
runtime.EventsEmit(l.ctx, events.LibraryScanCancelled, metrics)
} else {
runtime.EventsEmit(
l.ctx, events.LibraryScanComplete, metrics,
)
}
return metrics, scanErr
}
+88
View File
@@ -0,0 +1,88 @@
package library
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
)
// CancelScan cancels an in-progress scan. Returns immediately;
// scan goroutines stop at their next checkpoint.
func (l *Library) CancelScan() {
l.mu.Lock()
cancel := l.scanCancel
l.mu.Unlock()
if cancel != nil {
cancel()
}
}
// PauseScan pauses an in-progress scan. Workers block at their
// next pause checkpoint until ResumeScan is called.
func (l *Library) PauseScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanActive || l.scanPaused {
return
}
l.scanPaused = true
l.scanPauseCh = make(chan struct{})
runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
}
// ResumeScan unblocks a paused scan.
func (l *Library) ResumeScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanPaused {
return
}
l.scanPaused = false
close(l.scanPauseCh) // unblocks all waiting workers
runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
}
// IsScanActive returns whether a scan is currently running.
func (l *Library) IsScanActive() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.scanActive
}
// IsScanPaused returns whether the scan is currently paused.
func (l *Library) IsScanPaused() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.scanPaused
}
// waitIfPaused blocks the calling goroutine if the scan is paused.
// Returns ctx.Err() if the context is cancelled while waiting.
func (l *Library) waitIfPaused(ctx context.Context) error {
l.mu.Lock()
ch := l.scanPauseCh
paused := l.scanPaused
l.mu.Unlock()
if !paused || ch == nil {
return nil
}
select {
case <-ch: // closed = unpaused
return nil
case <-ctx.Done():
return ctx.Err()
}
}