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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user