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