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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user