docs(11-02): complete frontend scan UI plan

- SUMMARY.md documents per-library progress display and queue-aware cancel dialog
- STATE.md updated: plan 2/3, session continuity, new decisions
- ROADMAP.md updated: phase 11 progress 2/3
- REQUIREMENTS.md: LSCAN-03 marked complete
This commit is contained in:
2026-03-09 16:13:45 -04:00
parent d61f122b56
commit 1aaf53630d
7 changed files with 177 additions and 106 deletions
+8 -86
View File
@@ -26,8 +26,6 @@ import (
"yellowjacket/backend/system"
)
var errLibraryDirNotConfigured = errors.New("library directory not configured")
// scanBatchSize controls how many files are committed in a single
// database transaction during a scan. Larger batches amortize
// SQLite's fsync cost but increase the blast radius of a failed commit.
@@ -107,8 +105,8 @@ func (l *Library) SetRescanHooks(h RescanHooks) {
}
// NewLibrary creates a new library with the given configuration.
// A nil config is permitted; the library will be inert until a valid
// configuration is supplied via the LibraryConfigChanged event.
// A nil config is permitted; scan paths come from the database
// rather than from the config's DirectoryPath.
func NewLibrary(
ctx context.Context,
logger *slog.Logger,
@@ -142,63 +140,16 @@ func (l *Library) SetContext(ctx context.Context) {
l.registerEventHandlers()
}
// registerEventHandlers sets up Wails runtime event listeners.
// The legacy LibraryConfigChanged handler was removed — in the
// multi-library model, libraries are managed through the CRUD
// API (Phase 12) and scanned via ScanLibrary/ScanAllLibraries.
func (l *Library) registerEventHandlers() {
if l.ctx == nil {
l.logger.Error("Context is nil, cannot register event handlers")
return
}
runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
l.logger.Info("Received LibraryConfigChanged event")
if len(data) == 0 {
l.logger.Error("LibraryConfigChanged event received with no data")
return
}
configMap, ok := data[0].(map[string]any)
if !ok {
l.logger.Error("LibraryConfigChanged event data is not a map", "data", data[0])
return
}
dir, ok := configMap["DirectoryPath"].(string)
if !ok {
l.logger.Error("DirectoryPath not found or not a string in config event")
return
}
updatedConfig := Config{DirectoryPath: Directory(dir)}
if err := l.handleConfigUpdate(updatedConfig); err != nil {
l.logger.Error("Failed to handle config update", "err", err)
}
})
}
// 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,
l.logger.Error(
"Context is nil, cannot register event handlers",
)
}
return l.scanInternal(lib.ID, lib.Name, lib.Path), nil
}
// scanInternal performs the full scan pipeline for a single library.
@@ -1580,32 +1531,3 @@ func toNullString(v string) sql.NullString {
return sql.NullString{String: v, Valid: true}
}
func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
l.logger.Info("handling config update", "updated", updatedConfigValues)
var updateErr error
if l.conf.DirectoryPath != updatedConfigValues.DirectoryPath {
l.logger.Info("new library, scanning")
l.conf.DirectoryPath = updatedConfigValues.DirectoryPath
if scanMetrics, err := l.Scan(); err != nil {
updateErr = errors.Join(
updateErr,
fmt.Errorf(
"problem scanning library on config update: %w",
err,
),
)
} else if len(scanMetrics.Warnings) > 0 {
l.logger.Warn(
"library scan completed with warnings",
"warningCount", len(scanMetrics.Warnings),
)
}
}
return updateErr
}
+27 -5
View File
@@ -1,6 +1,7 @@
package library
import (
"errors"
"fmt"
"os"
"path/filepath"
@@ -9,13 +10,33 @@ import (
"yellowjacket/backend/coverart"
)
var errNoLibrariesConfigured = errors.New(
"no libraries configured for rescan",
)
// FullRescan clears the queue and player, wipes all library data
// (database records and cover art files), and performs a fresh
// scan from scratch. The returned ScanMetrics includes timing
// scan of the first library from the database. Per-library full
// rescan will be added in Phase 12; for now this rescans the
// first/only library. The returned ScanMetrics includes timing
// for the clear phases in addition to the normal scan metrics.
func (l *Library) FullRescan() (*ScanMetrics, error) {
l.logger.Info("beginning full library rescan")
// Resolve the first library from the database.
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
if err != nil {
return nil, fmt.Errorf(
"could not get libraries for rescan: %w", err,
)
}
if len(libs) == 0 {
return nil, errNoLibrariesConfigured
}
lib := libs[0]
// Run the pre-clear hook (e.g. clear queue / stop playback)
// before wiping data so the player is not referencing
// now-deleted tracks.
@@ -50,9 +71,10 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
l.logger.Info("library data cleared successfully")
// Run the full scan and merge clear-phase times into
// the metrics it returns.
metrics, err := l.Scan()
// Scan the first library directly via scanInternal,
// bypassing the queue coordinator.
metrics := l.scanInternal(lib.ID, lib.Name, lib.Path)
if metrics != nil {
metrics.ClearQueue = clearQueueDur
metrics.ClearDatabase = clearDBDur
@@ -70,7 +92,7 @@ func (l *Library) FullRescan() (*ScanMetrics, error) {
l.rescanHooks.PostScan()
}
return metrics, err
return metrics, nil
}
// clearLibraryTables deletes all library-related rows in FK-safe