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
+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,
)