Files
yellowjacket/backend/library/metrics.go
T
yonluandClaude Opus 5 01bc5f2094 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>
2026-07-25 14:42:22 -04:00

215 lines
6.9 KiB
Go

package library
import (
"strconv"
"strings"
"sync"
"time"
)
// ScanMetrics holds timing and count data collected during a library scan.
// Worker-pool fields are protected by a mutex; DB-writer fields are
// single-threaded and use plain addition.
type ScanMetrics struct {
mu sync.Mutex
// Top-level phases (wall-clock).
Total time.Duration `json:"total"`
LoadExisting time.Duration `json:"loadExisting"`
WalkDuration time.Duration `json:"walkDuration"`
ExtractionWallClock time.Duration `json:"extractionWallClock"`
DBWritesWallClock time.Duration `json:"dbWritesWallClock"`
OrphanCleanup time.Duration `json:"orphanCleanup"`
PostScanVariants time.Duration `json:"postScanVariants"`
// Per-format extraction (cumulative across workers).
FormatExtraction map[string]int64 `json:"formatExtraction"`
FormatCount map[string]int64 `json:"formatCount"`
// Sub-operation cumulative times (across workers).
TagExtraction time.Duration `json:"tagExtraction"`
DurationExtraction time.Duration `json:"durationExtraction"`
// DB sub-operations (cumulative, single-threaded DB writer).
BatchCommits time.Duration `json:"batchCommits"`
CoverArtSave time.Duration `json:"coverArtSave"`
// Thumbnail generation (async worker pool).
ThumbnailWallClock time.Duration `json:"thumbnailWallClock"`
ThumbnailGeneration time.Duration `json:"thumbnailGeneration"`
ThumbnailSmall time.Duration `json:"thumbnailSmall"`
ThumbnailMedium time.Duration `json:"thumbnailMedium"`
ThumbnailLarge time.Duration `json:"thumbnailLarge"`
// Full-rescan-specific phases.
ClearQueue time.Duration `json:"clearQueue"`
ClearDatabase time.Duration `json:"clearDatabase"`
ClearCoverFiles time.Duration `json:"clearCoverFiles"`
// File counts.
Added int64 `json:"added"`
Updated int64 `json:"updated"`
Skipped int64 `json:"skipped"`
Removed int64 `json:"removed"`
// Cancelled is true when the scan was stopped via CancelScan.
Cancelled bool `json:"cancelled"`
// Library identification.
LibraryID int64 `json:"libraryId"` // library that was scanned
LibraryName string `json:"libraryName"` // display name of scanned library
// 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
// report live progress to the frontend.
type ScanProgress struct {
Phase string `json:"phase"` // "counting", "scanning", "orphans", "thumbnails"
Total int64 `json:"total"` // total audio files from pre-walk count
Processed int64 `json:"processed"` // added + skipped + updated so far
Added int64 `json:"added"`
Skipped int64 `json:"skipped"`
Updated int64 `json:"updated"`
LibraryID int64 `json:"libraryId"` // library being scanned
LibraryName string `json:"libraryName"` // display name of library being scanned
QueuedCount int `json:"queuedCount"` // number of libraries still queued after this one
}
// ScanWarning represents a non-fatal issue encountered during scanning.
type ScanWarning struct {
FilePath string `json:"filePath"`
Phase string `json:"phase"`
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),
FormatCount: make(map[string]int64),
}
}
// addExtraction records per-file extraction timing from a worker
// goroutine. It is safe for concurrent use.
func (m *ScanMetrics) addExtraction(
fileType string,
tagTime, durationTime time.Duration,
) {
m.mu.Lock()
defer m.mu.Unlock()
total := tagTime + durationTime
m.FormatExtraction[fileType] += total.Milliseconds()
m.FormatCount[fileType]++
m.TagExtraction += tagTime
m.DurationExtraction += durationTime
}
// addCoverArtSave records the time spent saving an original cover
// art file. Called from the single-threaded DB writer.
func (m *ScanMetrics) addCoverArtSave(d time.Duration) {
m.CoverArtSave += d
}
// addWarning records a non-fatal scan issue. Safe for concurrent use.
func (m *ScanMetrics) addWarning(filePath, phase string, err error) {
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
// thumbnail tier. Safe for concurrent use from the thumbnail
// worker pool.
func (m *ScanMetrics) addThumbnailTier(
suffix string,
d time.Duration,
) {
m.mu.Lock()
defer m.mu.Unlock()
m.ThumbnailGeneration += d
switch suffix {
case "_sm":
m.ThumbnailSmall += d
case "_md":
m.ThumbnailMedium += d
case "_lg":
m.ThumbnailLarge += d
}
}