diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index cbd7f12..bc8c709 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -1,5 +1,5 @@ -- name: CreateAudioFile :one -INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: GetAudioFile :one diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index 509b7b4..d8e0f79 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -34,7 +34,7 @@ func (q *Queries) CountAudioFilesByLibrary(ctx context.Context, libraryID int64) } const createAudioFile = `-- name: CreateAudioFile :one -INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id ` @@ -49,6 +49,7 @@ type CreateAudioFileParams struct { Bitrate int64 FileSize int64 Basename string + LibraryID int64 } func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) { @@ -63,6 +64,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams arg.Bitrate, arg.FileSize, arg.Basename, + arg.LibraryID, ) var i AudioFile err := row.Scan( diff --git a/backend/events/events.go b/backend/events/events.go index 1a08660..2c8ae23 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -54,3 +54,9 @@ const ( LibraryScanPaused = "LibraryScanPaused" LibraryScanResumed = "LibraryScanResumed" ) + +// Scan queue events. +const ( + LibraryScanQueued = "LibraryScanQueued" + LibraryScanQueueDrained = "LibraryScanQueueDrained" +) diff --git a/backend/library/library.go b/backend/library/library.go index e52854f..e7936d4 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -90,6 +90,11 @@ type Library struct { scanCancel context.CancelFunc scanPaused bool scanPauseCh chan struct{} + + // Scan queue fields — protected by mu. + scanQueue []scanQueueEntry + currentScanLibraryID int64 + currentScanLibraryName string } // SetRescanHooks provides optional hooks for cross-cutting @@ -174,12 +179,40 @@ func (l *Library) registerEventHandlers() { }) } -// Scan syncs the library by adding new files and removing deleted ones. -// Files that exist but have incomplete metadata (recording_id = 0) -// will be updated. The returned ScanMetrics contains timing and -// count data for every phase of the scan. +// Scan syncs the library using the legacy DirectoryPath config. +// Retained for backward compatibility with handleConfigUpdate. +// +// Deprecated: Use ScanLibrary(id) for per-library scanning. func (l *Library) Scan() (*ScanMetrics, error) { + if len(l.conf.DirectoryPath) == 0 { + return newScanMetrics(), errLibraryDirNotConfigured + } + + lib, err := l.db.Queries.GetLibraryByPath( + l.ctx, string(l.conf.DirectoryPath), + ) + if err != nil { + return newScanMetrics(), fmt.Errorf( + "could not resolve library for path %s: %w", + l.conf.DirectoryPath, err, + ) + } + + return l.scanInternal(lib.ID, lib.Name, lib.Path), nil +} + +// scanInternal performs the full scan pipeline for a single library. +// It is called from the scan queue coordinator (startScan) or the +// legacy Scan() wrapper. The caller is responsible for goroutine +// management; this method blocks until the scan completes. +func (l *Library) scanInternal( + libraryID int64, + libraryName string, + libraryPath string, +) *ScanMetrics { metrics := newScanMetrics() + metrics.LibraryID = libraryID + metrics.LibraryName = libraryName scanStart := time.Now() scanCtx, scanCancel := context.WithCancel(l.ctx) @@ -195,7 +228,6 @@ func (l *Library) Scan() (*ScanMetrics, error) { 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 @@ -208,28 +240,54 @@ func (l *Library) Scan() (*ScanMetrics, error) { l.mu.Unlock() }() - if len(l.conf.DirectoryPath) == 0 { - return metrics, errLibraryDirNotConfigured - } - workerCount := resolveScanWorkerCount( - l.conf.ScanConcurrency, - string(l.conf.DirectoryPath), + ScanConcurrencyAuto, + libraryPath, ) l.logger.Info( "beginning library scan", + "libraryID", libraryID, + "libraryName", libraryName, + "libraryPath", libraryPath, "workers", workerCount, - "concurrencyMode", l.conf.ScanConcurrency, ) - runtime.EventsEmit(l.ctx, events.LibraryScanStarted) + // Helper to build a ScanProgress with library identification. + queuedCount := func() int { + l.mu.Lock() + defer l.mu.Unlock() - basePath := string(l.conf.DirectoryPath) + return len(l.scanQueue) + } + + mkProgress := func( + phase string, + total, processed, a, s, u int64, + ) ScanProgress { + return ScanProgress{ + Phase: phase, + Total: total, + Processed: processed, + Added: a, + Skipped: s, + Updated: u, + LibraryID: libraryID, + LibraryName: libraryName, + QueuedCount: queuedCount(), + } + } + + runtime.EventsEmit(l.ctx, events.LibraryScanStarted, map[string]any{ + "libraryId": libraryID, + "libraryName": libraryName, + }) + + basePath := libraryPath // --- Pre-walk: count audio files for progress reporting --- runtime.EventsEmit(l.ctx, events.LibraryScanProgress, - ScanProgress{Phase: "counting"}, + mkProgress("counting", 0, 0, 0, 0, 0), ) totalFiles := countAudioFiles(basePath) @@ -239,14 +297,20 @@ func (l *Library) Scan() (*ScanMetrics, error) { "total", totalFiles, ) - // --- Phase 1: load existing files from DB --- + // --- Phase 1: load existing files from DB (per-library) --- loadStart := time.Now() - existingFiles, err := l.db.Queries.GetAllAudioFiles(l.ctx) + existingFiles, err := l.db.Queries.GetAudioFilesByLibrary( + l.ctx, libraryID, + ) if err != nil { - return metrics, fmt.Errorf( - "could not load existing audio files: %w", err, + l.logger.Error( + "could not load existing audio files", + "libraryID", libraryID, + "err", err, ) + + return metrics } existingPaths := &sync.Map{} @@ -259,7 +323,8 @@ func (l *Library) Scan() (*ScanMetrics, error) { l.logger.Debug( "loaded existing files from database", "count", len(existingFiles), - "library-directory", l.conf.DirectoryPath, + "libraryID", libraryID, + "libraryPath", libraryPath, ) workChan := make(chan scanWork, 100) @@ -424,14 +489,10 @@ func (l *Library) Scan() (*ScanMetrics, error) { runtime.EventsEmit( l.ctx, events.LibraryScanProgress, - ScanProgress{ - Phase: "scanning", - Total: totalFiles, - Processed: a + s + u, - Added: a, - Skipped: s, - Updated: u, - }, + mkProgress( + "scanning", totalFiles, + a+s+u, a, s, u, + ), ) case <-stopProgress: return @@ -477,6 +538,9 @@ func (l *Library) Scan() (*ScanMetrics, error) { } for result := range resultChan { + // Thread library ID into each result for saveAudioFile. + result.libraryID = libraryID + if !dbStarted { dbStartVal = time.Now() dbStarted = true @@ -553,14 +617,7 @@ func (l *Library) Scan() (*ScanMetrics, error) { u := updated.Load() runtime.EventsEmit(l.ctx, events.LibraryScanProgress, - ScanProgress{ - Phase: "scanning", - Total: totalFiles, - Processed: a + s + u, - Added: a, - Skipped: s, - Updated: u, - }, + mkProgress("scanning", totalFiles, a+s+u, a, s, u), ) // Close thumbnail channel and wait for all thumbnail workers @@ -568,14 +625,9 @@ func (l *Library) Scan() (*ScanMetrics, error) { // point so it is safe to close. thumbStart := time.Now() - runtime.EventsEmit(l.ctx, events.LibraryScanProgress, ScanProgress{ - Phase: "thumbnails", - Total: totalFiles, - Processed: a + s + u, - Added: a, - Skipped: s, - Updated: u, - }) + runtime.EventsEmit(l.ctx, events.LibraryScanProgress, + mkProgress("thumbnails", totalFiles, a+s+u, a, s, u), + ) close(thumbChan) thumbWg.Wait() @@ -594,10 +646,9 @@ func (l *Library) Scan() (*ScanMetrics, error) { l.logger.Info("scan cancelled, skipping orphan cleanup") } else { // --- Phase 5: orphan cleanup --- - runtime.EventsEmit(l.ctx, events.LibraryScanProgress, ScanProgress{ - Phase: "orphans", Total: totalFiles, - Processed: a + s + u, Added: a, Skipped: s, Updated: u, - }) + runtime.EventsEmit(l.ctx, events.LibraryScanProgress, + mkProgress("orphans", totalFiles, a+s+u, a, s, u), + ) orphanStart := time.Now() @@ -669,26 +720,36 @@ func (l *Library) Scan() (*ScanMetrics, error) { metrics.Removed = removed.Load() metrics.Total = time.Since(scanStart) + if scanErr != nil { + l.logger.Warn( + "scan completed with errors", + "err", scanErr, + ) + } + l.logger.Info( "library scan complete", + "libraryID", libraryID, + "libraryName", libraryName, "added", metrics.Added, "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) + runtime.EventsEmit( + l.ctx, events.LibraryScanCancelled, metrics, + ) } else { runtime.EventsEmit( l.ctx, events.LibraryScanComplete, metrics, ) } - return metrics, scanErr + return metrics } // progressInterval controls how often scan progress events are @@ -765,6 +826,7 @@ type importResult struct { audioProps *metadata.AudioProperties existingFileID int64 // non-zero if this is an update needsUpdate bool + libraryID int64 // library this file belongs to } // extractAudioMetadata reads and extracts metadata from an audio file. @@ -939,6 +1001,7 @@ func (l *Library) saveAudioFile( Bitrate: int64(props.Bitrate), FileSize: props.FileSize, Basename: basename, + LibraryID: result.libraryID, }) if err != nil { return fmt.Errorf( diff --git a/backend/library/metrics.go b/backend/library/metrics.go index 6635609..0aaefb1 100644 --- a/backend/library/metrics.go +++ b/backend/library/metrics.go @@ -53,6 +53,10 @@ type ScanMetrics struct { // Cancelled is true when the scan was stopped via CancelScan. Cancelled bool `json:"cancelled"` + // Library identification. + LibraryID int64 `json:"libraryId"` // library that was scanned + LibraryName string `json:"libraryName"` // display name of scanned library + // Non-fatal issues encountered during scanning. Warnings []ScanWarning `json:"warnings"` } @@ -60,12 +64,15 @@ type ScanMetrics struct { // ScanProgress is the payload emitted periodically during a scan to // report live progress to the frontend. type ScanProgress struct { - Phase string `json:"phase"` // "counting", "scanning", "orphans", "thumbnails" - Total int64 `json:"total"` // total audio files from pre-walk count - Processed int64 `json:"processed"` // added + skipped + updated so far - Added int64 `json:"added"` - Skipped int64 `json:"skipped"` - Updated int64 `json:"updated"` + Phase string `json:"phase"` // "counting", "scanning", "orphans", "thumbnails" + Total int64 `json:"total"` // total audio files from pre-walk count + Processed int64 `json:"processed"` // added + skipped + updated so far + Added int64 `json:"added"` + Skipped int64 `json:"skipped"` + Updated int64 `json:"updated"` + LibraryID int64 `json:"libraryId"` // library being scanned + LibraryName string `json:"libraryName"` // display name of library being scanned + QueuedCount int `json:"queuedCount"` // number of libraries still queued after this one } // ScanWarning represents a non-fatal issue encountered during scanning. diff --git a/backend/library/scan_control.go b/backend/library/scan_control.go index 4ba93ec..5a8b311 100644 --- a/backend/library/scan_control.go +++ b/backend/library/scan_control.go @@ -10,6 +10,9 @@ import ( // CancelScan cancels an in-progress scan. Returns immediately; // scan goroutines stop at their next checkpoint. +// +// Deprecated: Use CancelCurrentScan or CancelAllScans for +// queue-aware cancellation. func (l *Library) CancelScan() { l.mu.Lock() cancel := l.scanCancel diff --git a/backend/library/scan_queue.go b/backend/library/scan_queue.go new file mode 100644 index 0000000..bc2a348 --- /dev/null +++ b/backend/library/scan_queue.go @@ -0,0 +1,173 @@ +package library + +import ( + "fmt" + + "github.com/wailsapp/wails/v2/pkg/runtime" + + "yellowjacket/backend/events" +) + +// scanQueueEntry holds the metadata needed to scan a single library. +type scanQueueEntry struct { + libraryID int64 + libraryName string + libraryPath string +} + +// ScanLibrary queues a scan for the library with the given database ID. +// If no scan is active the library is scanned immediately; otherwise it +// is appended to the queue. Duplicate requests (same library already +// scanning or already queued) are silently ignored. +func (l *Library) ScanLibrary(id int64) error { + lib, err := l.db.Queries.GetLibrary(l.ctx, id) + if err != nil { + return fmt.Errorf("could not get library %d: %w", id, err) + } + + l.mu.Lock() + defer l.mu.Unlock() + + // Silent dedup: already scanning this library. + if l.currentScanLibraryID == id { + return nil + } + + // Silent dedup: already queued. + for _, entry := range l.scanQueue { + if entry.libraryID == id { + return nil + } + } + + entry := scanQueueEntry{ + libraryID: lib.ID, + libraryName: lib.Name, + libraryPath: lib.Path, + } + + if !l.scanActive { + l.scanActive = true + l.currentScanLibraryID = entry.libraryID + l.currentScanLibraryName = entry.libraryName + + go l.startScan(entry) + + return nil + } + + // A scan is already running — queue this library. + l.scanQueue = append(l.scanQueue, entry) + + runtime.EventsEmit(l.ctx, events.LibraryScanQueued, map[string]any{ + "libraryId": lib.ID, + "libraryName": lib.Name, + "queueLength": len(l.scanQueue), + }) + + return nil +} + +// ScanAllLibraries queries all libraries from the database and queues +// each one for scanning. Existing dedup logic ensures no duplicates. +func (l *Library) ScanAllLibraries() error { + libs, err := l.db.Queries.GetAllLibraries(l.ctx) + if err != nil { + return fmt.Errorf("could not get all libraries: %w", err) + } + + for _, lib := range libs { + if err := l.ScanLibrary(lib.ID); err != nil { + l.logger.Warn( + "could not queue library for scan", + "libraryID", lib.ID, + "libraryName", lib.Name, + "err", err, + ) + } + } + + return nil +} + +// CancelCurrentScan cancels only the currently scanning library. +// The next queued library (if any) starts automatically when the +// current scan's goroutine completes. +func (l *Library) CancelCurrentScan() { + l.mu.Lock() + cancel := l.scanCancel + l.mu.Unlock() + + if cancel != nil { + cancel() + } +} + +// CancelAllScans cancels the current scan and clears the entire +// queue so no further libraries are scanned. +func (l *Library) CancelAllScans() { + l.mu.Lock() + l.scanQueue = nil + cancel := l.scanCancel + l.mu.Unlock() + + if cancel != nil { + cancel() + } +} + +// GetScanQueueLength returns the number of libraries waiting in the +// scan queue (excludes the currently scanning library). +func (l *Library) GetScanQueueLength() int { + l.mu.Lock() + defer l.mu.Unlock() + + return len(l.scanQueue) +} + +// QueuedLibraryNames returns the display names of libraries waiting +// in the scan queue, in FIFO order. +func (l *Library) QueuedLibraryNames() []string { + l.mu.Lock() + defer l.mu.Unlock() + + names := make([]string, len(l.scanQueue)) + for i, entry := range l.scanQueue { + names[i] = entry.libraryName + } + + return names +} + +// startScan runs the scan for a single library entry and then drains +// the queue. It is always called in a new goroutine. +func (l *Library) startScan(entry scanQueueEntry) { + l.scanInternal(entry.libraryID, entry.libraryName, entry.libraryPath) + l.drainQueue() +} + +// drainQueue is called after each scan completes. If the queue is +// non-empty the next entry is popped and scanned; otherwise the +// scan pipeline is marked idle. +func (l *Library) drainQueue() { + l.mu.Lock() + + if len(l.scanQueue) > 0 { + next := l.scanQueue[0] + l.scanQueue = l.scanQueue[1:] + l.currentScanLibraryID = next.libraryID + l.currentScanLibraryName = next.libraryName + l.mu.Unlock() + + go l.startScan(next) + + return + } + + l.currentScanLibraryID = 0 + l.currentScanLibraryName = "" + l.scanActive = false + l.mu.Unlock() + + runtime.EventsEmit(l.ctx, events.LibraryScanQueueDrained) +} diff --git a/frontend/src/events.ts b/frontend/src/events.ts index 9b43501..7890c9c 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -38,6 +38,10 @@ export const Events = { LibraryScanCancelled: "LibraryScanCancelled", LibraryScanPaused: "LibraryScanPaused", LibraryScanResumed: "LibraryScanResumed", + + // Scan queue events + LibraryScanQueued: "LibraryScanQueued", + LibraryScanQueueDrained: "LibraryScanQueueDrained", } as const; export type EventName = (typeof Events)[keyof typeof Events]; diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index dc0c1cd..15ea18b 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -3,6 +3,10 @@ import {library} from '../models'; import {context} from '../models'; +export function CancelAllScans():Promise; + +export function CancelCurrentScan():Promise; + export function CancelScan():Promise; export function FullRescan():Promise; @@ -19,6 +23,8 @@ export function GetAllGenresWithCounts():Promise>; export function GetAllTracks():Promise>; +export function GetScanQueueLength():Promise; + export function GetTracksByGenre(arg1:string):Promise>; export function IsScanActive():Promise; @@ -27,10 +33,16 @@ export function IsScanPaused():Promise; export function PauseScan():Promise; +export function QueuedLibraryNames():Promise>; + export function ResumeScan():Promise; export function Scan():Promise; +export function ScanAllLibraries():Promise; + +export function ScanLibrary(arg1:number):Promise; + export function SearchTracks(arg1:string):Promise>; export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index 4de6f44..a0d718e 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -2,6 +2,14 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CancelAllScans() { + return window['go']['library']['Library']['CancelAllScans'](); +} + +export function CancelCurrentScan() { + return window['go']['library']['Library']['CancelCurrentScan'](); +} + export function CancelScan() { return window['go']['library']['Library']['CancelScan'](); } @@ -34,6 +42,10 @@ export function GetAllTracks() { return window['go']['library']['Library']['GetAllTracks'](); } +export function GetScanQueueLength() { + return window['go']['library']['Library']['GetScanQueueLength'](); +} + export function GetTracksByGenre(arg1) { return window['go']['library']['Library']['GetTracksByGenre'](arg1); } @@ -50,6 +62,10 @@ export function PauseScan() { return window['go']['library']['Library']['PauseScan'](); } +export function QueuedLibraryNames() { + return window['go']['library']['Library']['QueuedLibraryNames'](); +} + export function ResumeScan() { return window['go']['library']['Library']['ResumeScan'](); } @@ -58,6 +74,14 @@ export function Scan() { return window['go']['library']['Library']['Scan'](); } +export function ScanAllLibraries() { + return window['go']['library']['Library']['ScanAllLibraries'](); +} + +export function ScanLibrary(arg1) { + return window['go']['library']['Library']['ScanLibrary'](arg1); +} + export function SearchTracks(arg1) { return window['go']['library']['Library']['SearchTracks'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index af39a0f..33aa647 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -109,6 +109,8 @@ export namespace library { skipped: number; removed: number; cancelled: boolean; + libraryId: number; + libraryName: string; warnings: ScanWarning[]; static createFrom(source: any = {}) { @@ -143,6 +145,8 @@ export namespace library { this.skipped = source["skipped"]; this.removed = source["removed"]; this.cancelled = source["cancelled"]; + this.libraryId = source["libraryId"]; + this.libraryName = source["libraryName"]; this.warnings = this.convertValues(source["warnings"], ScanWarning); }