feat(jobs): surface background jobs with progress, logs and controls

Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:42:22 -04:00
co-authored by Claude Opus 5
parent aead8eaef4
commit 01bc5f2094
48 changed files with 6656 additions and 2271 deletions
+42 -20
View File
@@ -23,6 +23,7 @@ import (
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
"yellowjacket/backend/metadata"
"yellowjacket/backend/system"
)
@@ -106,6 +107,10 @@ type Library struct {
scanPaused bool
scanPauseCh chan struct{}
// jobs is the background job registry. Nil in tests that do not
// exercise progress reporting; every use site must nil-check.
jobs *jobs.Registry
// Scan queue fields — protected by mu.
scanQueue []scanQueueEntry
currentScanLibraryID int64
@@ -218,6 +223,25 @@ func (l *Library) scanInternal(
metrics.LibraryName = libraryName
scanStart := time.Now()
// Register the background job before any work starts so the UI
// indicator appears immediately, even during the pre-walk count.
jobHandle := l.startScanJob(scanQueueEntry{
libraryID: libraryID,
libraryName: libraryName,
libraryPath: libraryPath,
})
// Stream non-fatal issues into the job log as they happen rather
// than dumping them all at completion — the point of the log pane
// is to answer "what is it doing right now".
metrics.onWarning = func(w ScanWarning) {
if jobHandle == nil {
return
}
jobHandle.LogDetail(jobs.LevelWarn, w.Phase+": "+w.Err, w.FilePath)
}
scanCtx, scanCancel := context.WithCancel(l.ctx)
defer scanCancel()
@@ -281,6 +305,14 @@ func (l *Library) scanInternal(
}
}
// emitProgress publishes one progress update to both consumers: the
// legacy LibraryScanProgress event and the shared job registry.
// Routing everything through here keeps the two from drifting.
emitProgress := func(p ScanProgress) {
runtime.EventsEmit(l.ctx, events.LibraryScanProgress, p)
reportScanProgress(jobHandle, p)
}
runtime.EventsEmit(l.ctx, events.LibraryScanStarted, map[string]any{
"libraryId": libraryID,
"libraryName": libraryName,
@@ -289,9 +321,7 @@ func (l *Library) scanInternal(
basePath := libraryPath
// --- Pre-walk: count audio files for progress reporting ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("counting", 0, 0, 0, 0, 0),
)
emitProgress(mkProgress("counting", 0, 0, 0, 0, 0))
totalFiles := countAudioFiles(basePath)
@@ -488,14 +518,10 @@ func (l *Library) scanInternal(
s := skipped.Load()
u := updated.Load()
runtime.EventsEmit(
l.ctx,
events.LibraryScanProgress,
mkProgress(
"scanning", totalFiles,
a+s+u, a, s, u,
),
)
emitProgress(mkProgress(
"scanning", totalFiles,
a+s+u, a, s, u,
))
case <-stopProgress:
return
}
@@ -618,18 +644,14 @@ func (l *Library) scanInternal(
s := skipped.Load()
u := updated.Load()
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("scanning", totalFiles, a+s+u, a, s, u),
)
emitProgress(mkProgress("scanning", totalFiles, a+s+u, a, s, u))
// Close thumbnail channel and wait for all thumbnail workers
// to finish. The DB writer has stopped sending work at this
// point so it is safe to close.
thumbStart := time.Now()
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("thumbnails", totalFiles, a+s+u, a, s, u),
)
emitProgress(mkProgress("thumbnails", totalFiles, a+s+u, a, s, u))
close(thumbChan)
thumbWg.Wait()
@@ -648,9 +670,7 @@ func (l *Library) scanInternal(
l.logger.Info("scan cancelled, skipping orphan cleanup")
} else {
// --- Phase 5: orphan cleanup ---
runtime.EventsEmit(l.ctx, events.LibraryScanProgress,
mkProgress("orphans", totalFiles, a+s+u, a, s, u),
)
emitProgress(mkProgress("orphans", totalFiles, a+s+u, a, s, u))
orphanStart := time.Now()
@@ -753,6 +773,8 @@ func (l *Library) scanInternal(
"total", metrics.Total,
)
finishScanJob(jobHandle, metrics, cancelled)
if cancelled {
runtime.EventsEmit(
l.ctx, events.LibraryScanCancelled, metrics,
+73 -5
View File
@@ -1,6 +1,8 @@
package library
import (
"strconv"
"strings"
"sync"
"time"
)
@@ -59,6 +61,11 @@ type ScanMetrics struct {
// Non-fatal issues encountered during scanning.
Warnings []ScanWarning `json:"warnings"`
// onWarning, when set, receives each warning as it is recorded so
// the scan's job log can show problems live instead of only in the
// completion payload. Invoked outside the metrics lock.
onWarning func(ScanWarning) `json:"-"`
}
// ScanProgress is the payload emitted periodically during a scan to
@@ -82,6 +89,59 @@ type ScanWarning struct {
Err string `json:"err"`
}
// timingBreakdown renders the full per-phase timing profile as plain
// text for the job log. This replaces the metrics table that used to
// live on the settings page: same numbers, but attached to the scan
// that produced them and copyable from the job's output pane.
func (m *ScanMetrics) timingBreakdown() string {
var b strings.Builder
line := func(label string, d time.Duration) {
b.WriteString(label)
b.WriteString(": ")
b.WriteString(d.Round(time.Millisecond).String())
b.WriteString("\n")
}
line("Total", m.Total)
// Full-rescan-only phases; zero on an incremental scan.
if m.ClearQueue > 0 || m.ClearDatabase > 0 || m.ClearCoverFiles > 0 {
line(" Clear queue", m.ClearQueue)
line(" Clear database", m.ClearDatabase)
line(" Clear cover files", m.ClearCoverFiles)
}
line(" Load existing files", m.LoadExisting)
line(" Directory walk", m.WalkDuration)
line(" Metadata extraction (wall clock)", m.ExtractionWallClock)
line(" Tag extraction (cumulative)", m.TagExtraction)
line(" Duration extraction (cumulative)", m.DurationExtraction)
for format, ms := range m.FormatExtraction {
b.WriteString(" ")
b.WriteString(format)
b.WriteString(" (")
b.WriteString(strconv.FormatInt(m.FormatCount[format], 10))
b.WriteString(" files): ")
b.WriteString((time.Duration(ms) * time.Millisecond).String())
b.WriteString("\n")
}
line(" DB writes (wall clock)", m.DBWritesWallClock)
line(" Batch commits", m.BatchCommits)
line(" Save cover originals", m.CoverArtSave)
line(" Thumbnails (wall clock)", m.ThumbnailWallClock)
line(" Cumulative CPU time", m.ThumbnailGeneration)
line(" Small", m.ThumbnailSmall)
line(" Medium", m.ThumbnailMedium)
line(" Large", m.ThumbnailLarge)
line(" Orphan cleanup", m.OrphanCleanup)
line(" Post-scan variants", m.PostScanVariants)
return b.String()
}
func newScanMetrics() *ScanMetrics {
return &ScanMetrics{
FormatExtraction: make(map[string]int64),
@@ -113,14 +173,22 @@ func (m *ScanMetrics) addCoverArtSave(d time.Duration) {
// addWarning records a non-fatal scan issue. Safe for concurrent use.
func (m *ScanMetrics) addWarning(filePath, phase string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
m.Warnings = append(m.Warnings, ScanWarning{
warning := ScanWarning{
FilePath: filePath,
Phase: phase,
Err: err.Error(),
})
}
m.mu.Lock()
m.Warnings = append(m.Warnings, warning)
notify := m.onWarning
m.mu.Unlock()
// Called outside the lock: the job registry takes its own locks and
// must never be able to deadlock against a scan worker.
if notify != nil {
notify(warning)
}
}
// addThumbnailTier records the time spent generating a single
+38 -2
View File
@@ -6,6 +6,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
// CancelScan cancels an in-progress scan. Returns immediately;
@@ -27,31 +28,66 @@ func (l *Library) CancelScan() {
// next pause checkpoint until ResumeScan is called.
func (l *Library) PauseScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanActive || l.scanPaused {
l.mu.Unlock()
return
}
l.scanPaused = true
l.scanPauseCh = make(chan struct{})
pausedID := l.currentScanLibraryID
reg := l.jobs
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanPaused)
// Confirm the pause on the job — the registry moved it to "pausing"
// when the request came in. Writing the durable pause record is a
// side effect of reaching StatePaused, so a scan paused now comes
// back paused after a restart. Done after releasing l.mu: this
// writes to the database, and workers take l.mu on every pause
// checkpoint.
if reg == nil {
return
}
if h := reg.Get(scanJobID(pausedID)); h != nil {
h.SetState(jobs.StatePaused)
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo, "Scan paused")
}
}
// ResumeScan unblocks a paused scan.
func (l *Library) ResumeScan() {
l.mu.Lock()
defer l.mu.Unlock()
if !l.scanPaused {
l.mu.Unlock()
return
}
l.scanPaused = false
close(l.scanPauseCh) // unblocks all waiting workers
resumedID := l.currentScanLibraryID
reg := l.jobs
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanResumed)
// Clears the durable pause record as a side effect of leaving
// StatePaused.
if reg == nil {
return
}
if h := reg.Get(scanJobID(resumedID)); h != nil {
h.SetState(jobs.StateRunning)
h.Logf(jobs.LevelInfo, "Scan resumed")
}
}
// IsScanActive returns whether a scan is currently running.
+348
View File
@@ -0,0 +1,348 @@
package library
import (
"strconv"
"time"
"yellowjacket/backend/jobs"
)
// scanJobPrefix namespaces library scan jobs in the shared registry.
const scanJobPrefix = "scan:"
// scanPhaseLabels maps internal scan phase identifiers to the labels
// shown in the jobs UI.
var scanPhaseLabels = map[string]string{
"counting": "Counting files",
"scanning": "Reading metadata",
"thumbnails": "Generating thumbnails",
"orphans": "Cleaning up removed files",
}
// SetJobRegistry wires the background job registry so scans report
// progress, logs, and pause/cancel controls to the frontend.
func (l *Library) SetJobRegistry(reg *jobs.Registry) {
l.mu.Lock()
l.jobs = reg
l.mu.Unlock()
}
// jobRegistry returns the registry, or nil when none is wired.
func (l *Library) jobRegistry() *jobs.Registry {
l.mu.Lock()
defer l.mu.Unlock()
return l.jobs
}
// scanJobID returns the stable registry ID for a library's scan job.
// It is stable across restarts so a durable pause can be matched back
// to the library it belongs to.
func scanJobID(libraryID int64) string {
return scanJobPrefix + strconv.FormatInt(libraryID, 10)
}
// libraryIDFromJobID parses a library ID back out of a scan job ID.
func libraryIDFromJobID(id string) (int64, bool) {
if len(id) <= len(scanJobPrefix) || id[:len(scanJobPrefix)] != scanJobPrefix {
return 0, false
}
parsed, err := strconv.ParseInt(id[len(scanJobPrefix):], 10, 64)
if err != nil {
return 0, false
}
return parsed, true
}
// scanJobSpec builds the registry spec for a library scan. Controls are
// bound to the library ID rather than "the current scan" so that a
// control arriving for a queued or stale job cannot disturb a different
// library's scan.
func (l *Library) scanJobSpec(
entry scanQueueEntry,
state jobs.State,
) jobs.Spec {
return jobs.Spec{
ID: scanJobID(entry.libraryID),
Kind: jobs.KindLibraryScan,
Title: "Scanning " + entry.libraryName,
Subtitle: entry.libraryPath,
State: state,
Caps: jobs.Caps{
Pausable: true,
Cancellable: true,
},
Durable: true,
Controls: jobs.Controls{
Pause: func() { l.pauseScanForLibrary(entry.libraryID) },
Resume: func() { l.resumeScanForLibrary(entry) },
Cancel: func() { l.cancelScanForLibrary(entry.libraryID) },
},
}
}
// registerQueuedScanJob records a library that is waiting behind another
// scan, so the user can see the whole pipeline rather than just the head.
func (l *Library) registerQueuedScanJob(entry scanQueueEntry) {
reg := l.jobRegistry()
if reg == nil {
return
}
h := reg.Start(l.scanJobSpec(entry, jobs.StateQueued))
h.SetPhase("Waiting for other scans")
h.Logf(jobs.LevelInfo, "Queued behind an in-progress scan")
}
// startScanJob registers (or re-registers, for a queued job now starting)
// the running job for a scan and returns its handle.
func (l *Library) startScanJob(entry scanQueueEntry) *jobs.Handle {
reg := l.jobRegistry()
if reg == nil {
return nil
}
h := reg.Start(l.scanJobSpec(entry, jobs.StateRunning))
h.SetProgress(0, 0)
h.Logf(jobs.LevelInfo, "Scan started for "+entry.libraryPath)
return h
}
// reportScanProgress mirrors a ScanProgress payload into the job
// registry. Called from the same places that emit LibraryScanProgress
// so the two views never disagree.
func reportScanProgress(h *jobs.Handle, p ScanProgress) {
if h == nil {
return
}
label, ok := scanPhaseLabels[p.Phase]
if !ok {
label = p.Phase
}
h.SetPhase(label)
// The counting pre-walk has no denominator to report against, so
// leave the job indeterminate until the total is known.
if p.Phase == "counting" {
h.SetProgress(0, 0)
} else {
h.SetProgress(p.Processed, p.Total)
}
h.SetStats([]jobs.Stat{
{Label: "Added", Value: strconv.FormatInt(p.Added, 10)},
{Label: "Updated", Value: strconv.FormatInt(p.Updated, 10)},
{Label: "Skipped", Value: strconv.FormatInt(p.Skipped, 10)},
})
}
// finishScanJob applies the terminal state and summary for a completed,
// cancelled, or paused-then-abandoned scan.
func finishScanJob(h *jobs.Handle, m *ScanMetrics, cancelled bool) {
if h == nil {
return
}
h.SetStats([]jobs.Stat{
{Label: "Added", Value: strconv.FormatInt(m.Added, 10)},
{Label: "Updated", Value: strconv.FormatInt(m.Updated, 10)},
{Label: "Skipped", Value: strconv.FormatInt(m.Skipped, 10)},
{Label: "Removed", Value: strconv.FormatInt(m.Removed, 10)},
{Label: "Duration", Value: m.Total.Round(time.Millisecond).String()},
{Label: "Walk", Value: m.WalkDuration.Round(time.Millisecond).String()},
{Label: "Metadata", Value: m.ExtractionWallClock.Round(time.Millisecond).String()},
{Label: "DB writes", Value: m.DBWritesWallClock.Round(time.Millisecond).String()},
{Label: "Thumbnails", Value: m.ThumbnailWallClock.Round(time.Millisecond).String()},
})
// The full timing breakdown goes into the log rather than the stats
// grid: it is a profiling aid, wanted rarely and in full, and the
// log pane already has a copy-to-clipboard button.
h.LogDetail(jobs.LevelInfo, "Timing breakdown", m.timingBreakdown())
if cancelled {
h.Logf(jobs.LevelInfo, "Scan cancelled")
h.Cancelled()
return
}
h.Logf(jobs.LevelInfo,
"Scan complete — "+
strconv.FormatInt(m.Added, 10)+" added, "+
strconv.FormatInt(m.Updated, 10)+" updated, "+
strconv.FormatInt(m.Removed, 10)+" removed",
)
h.Complete()
}
// pauseScanForLibrary pauses the running scan, but only when the library
// asking is the one currently being scanned.
func (l *Library) pauseScanForLibrary(libraryID int64) {
l.mu.Lock()
current := l.currentScanLibraryID
l.mu.Unlock()
if current != libraryID {
return
}
l.PauseScan()
}
// cancelScanForLibrary cancels a scan for one library. A queued library
// is dropped from the queue; the running one is cancelled outright.
func (l *Library) cancelScanForLibrary(libraryID int64) {
l.mu.Lock()
current := l.currentScanLibraryID
if current != libraryID {
// Not running — drop it from the queue if it is waiting there.
kept := l.scanQueue[:0]
for _, entry := range l.scanQueue {
if entry.libraryID != libraryID {
kept = append(kept, entry)
}
}
l.scanQueue = kept
l.mu.Unlock()
if reg := l.jobRegistry(); reg != nil {
if h := reg.Get(scanJobID(libraryID)); h != nil {
h.Cancelled()
}
}
return
}
cancel := l.scanCancel
paused := l.scanPaused
pauseCh := l.scanPauseCh
// Unblock paused workers so they observe the cancelled context
// instead of sitting on the pause channel forever.
if paused {
l.scanPaused = false
if pauseCh != nil {
close(pauseCh)
}
l.scanPauseCh = nil
}
l.mu.Unlock()
if cancel != nil {
cancel()
return
}
// No live scan goroutine — this is a scan that was paused before a
// restart and never resumed. Retire the adopted job directly.
if reg := l.jobRegistry(); reg != nil {
if h := reg.Get(scanJobID(libraryID)); h != nil {
h.Cancelled()
}
}
}
// resumeScanForLibrary continues a paused scan. When the scan goroutine
// is still alive it is simply unblocked. When the pause outlived the
// process, a fresh scan is queued instead: scans are incremental, so
// already-imported files are skipped on the second pass and the effect
// is a resume rather than a restart.
func (l *Library) resumeScanForLibrary(entry scanQueueEntry) {
l.mu.Lock()
live := l.scanActive && l.currentScanLibraryID == entry.libraryID
l.mu.Unlock()
if live {
l.ResumeScan()
return
}
if reg := l.jobRegistry(); reg != nil {
if h := reg.Get(scanJobID(entry.libraryID)); h != nil {
h.Logf(jobs.LevelInfo,
"Resuming from a previous session — already-imported "+
"files are skipped")
}
}
// Clear the durable pause record before restarting, otherwise
// RestorePausedScans would adopt it again on the next launch.
if reg := l.jobRegistry(); reg != nil {
reg.Remove(scanJobID(entry.libraryID))
}
if err := l.ScanLibrary(entry.libraryID); err != nil {
l.logger.Warn("could not resume scan",
"libraryID", entry.libraryID, "err", err)
}
}
// RestorePausedScans adopts scans that were paused when the app last
// shut down back into the job registry, still paused. Call during
// startup before SoftScanAllLibraries so the soft scan does not restart
// a library the user deliberately paused.
func (l *Library) RestorePausedScans() {
reg := l.jobRegistry()
if reg == nil {
return
}
for _, p := range reg.PausedEntries(jobs.KindLibraryScan) {
libraryID, ok := libraryIDFromJobID(p.ID)
if !ok {
reg.Remove(p.ID)
continue
}
lib, err := l.db.Queries.GetLibrary(l.ctx, libraryID)
if err != nil {
// The library was removed while the scan was paused.
l.logger.Info("dropping paused scan for missing library",
"libraryID", libraryID)
reg.Remove(p.ID)
continue
}
entry := scanQueueEntry{
libraryID: lib.ID,
libraryName: lib.Name,
libraryPath: lib.Path,
}
h := reg.Start(l.scanJobSpec(entry, jobs.StatePaused))
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo, "Paused in a previous session — resume to continue")
l.logger.Info("restored paused library scan",
"libraryID", lib.ID, "libraryName", lib.Name)
}
}
// isScanPausedPersistently reports whether a library's scan was left
// paused by a previous session.
func (l *Library) isScanPausedPersistently(libraryID int64) bool {
reg := l.jobRegistry()
if reg == nil {
return false
}
return reg.IsPersistentlyPaused(scanJobID(libraryID))
}
+26 -2
View File
@@ -26,16 +26,19 @@ func (l *Library) ScanLibrary(id int64) error {
}
l.mu.Lock()
defer l.mu.Unlock()
// Silent dedup: already scanning this library.
if l.currentScanLibraryID == id {
l.mu.Unlock()
return nil
}
// Silent dedup: already queued.
for _, entry := range l.scanQueue {
if entry.libraryID == id {
l.mu.Unlock()
return nil
}
}
@@ -50,6 +53,7 @@ func (l *Library) ScanLibrary(id int64) error {
l.scanActive = true
l.currentScanLibraryID = entry.libraryID
l.currentScanLibraryName = entry.libraryName
l.mu.Unlock()
go l.startScan(entry)
@@ -58,13 +62,19 @@ func (l *Library) ScanLibrary(id int64) error {
// A scan is already running — queue this library.
l.scanQueue = append(l.scanQueue, entry)
queueLength := len(l.scanQueue)
l.mu.Unlock()
runtime.EventsEmit(l.ctx, events.LibraryScanQueued, map[string]any{
"libraryId": lib.ID,
"libraryName": lib.Name,
"queueLength": len(l.scanQueue),
"queueLength": queueLength,
})
// Registering the queued job takes l.mu again, so it must happen
// after the unlock above.
l.registerQueuedScanJob(entry)
return nil
}
@@ -127,6 +137,20 @@ func (l *Library) SoftScanAllLibraries() error {
}
for _, lib := range libs {
// A scan the user paused stays paused across restarts — the
// soft scan must not quietly start it again behind their back.
// RestorePausedScans has already surfaced it in the jobs panel
// with a resume button.
if l.isScanPausedPersistently(lib.ID) {
l.logger.Info(
"soft scan: library scan is paused, skipping",
"libraryID", lib.ID,
"libraryName", lib.Name,
)
continue
}
dbCount, countErr := l.db.Queries.CountAudioFilesByLibrary(
l.ctx, lib.ID,
)