Files
yellowjacket/backend/explore/indexjob.go
T
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

387 lines
10 KiB
Go

package explore
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"yellowjacket/backend/jobs"
)
// errIndexStageFailed wraps the per-stage error text reported by the
// dump importer so the job carries a typed failure.
var errIndexStageFailed = errors.New("index build stage failed")
// indexJobID is the stable registry ID for the search index build.
// There is only ever one, so the ID is a constant.
const indexJobID = "index:build"
// indexPausedKey marks a build the user paused. It lives in
// explore_index_meta alongside the import's other checkpoints so a
// paused build stays paused across a restart instead of resuming on
// the next launch.
const indexPausedKey = "index_build_paused"
// SetJobRegistry wires the background job registry so index builds
// report progress, stage state, logs, and pause/cancel controls.
func (si *SearchIndex) SetJobRegistry(reg *jobs.Registry) {
si.mu.Lock()
si.jobs = reg
si.mu.Unlock()
}
// jobRegistry returns the registry, or nil when none is wired.
func (si *SearchIndex) jobRegistry() *jobs.Registry {
si.mu.RLock()
defer si.mu.RUnlock()
return si.jobs
}
// logIndexJob appends a line to the index build's job log, if a build
// job is currently registered.
func (si *SearchIndex) logIndexJob(level jobs.Level, message string) {
reg := si.jobRegistry()
if reg == nil {
return
}
if h := reg.Get(indexJobID); h != nil {
h.Logf(level, message)
}
}
// indexJobSpec builds the registry spec for the index build.
//
// The build is genuinely pausable rather than merely cancellable: the
// dump importer checkpoints its listen-count offset to counts.bin and
// its stage to state.json, so stopping and restarting picks up where it
// left off instead of re-downloading multiple gigabytes.
func (si *SearchIndex) indexJobSpec(state jobs.State) jobs.Spec {
return jobs.Spec{
ID: indexJobID,
Kind: jobs.KindIndexBuild,
Title: "Building search index",
Subtitle: "MusicBrainz catalog + ListenBrainz popularity",
State: state,
Caps: jobs.Caps{
Pausable: true,
Cancellable: true,
},
Durable: true,
Controls: jobs.Controls{
Pause: si.PauseBuild,
Resume: si.ResumeBuild,
Cancel: si.CancelBuild,
},
}
}
// syncIndexJob mirrors an IndexStatus snapshot into the job registry.
// It is driven from emitStatus, which every status mutation already
// funnels through, so there is no path that updates one view and not
// the other.
func (si *SearchIndex) syncIndexJob(status IndexStatus) {
reg := si.jobRegistry()
if reg == nil {
return
}
si.mu.RLock()
paused := si.buildPaused
si.mu.RUnlock()
h := reg.Get(indexJobID)
// A build with no stages is the early-return path in runDumpBuild
// (the catalog import is already done). Nothing to show.
if h == nil {
if !status.Building || len(status.Tiers) == 0 {
return
}
h = reg.Start(si.indexJobSpec(jobs.StateRunning))
h.Logf(jobs.LevelInfo, "Index build started")
}
// A finished job is immutable. Without this guard the 3-second
// status ticker would keep touching it forever, re-emitting
// JobsChanged long after the build ended.
if h.State().IsTerminal() {
return
}
si.applyStagesToJob(h, status)
if status.Building {
// Don't stomp a pause or cancel that is still settling; those
// transitions are confirmed by their own control paths.
if h.State() == jobs.StateQueued {
h.SetState(jobs.StateRunning)
}
return
}
si.finishIndexJob(h, paused)
}
// applyStagesToJob maps index tiers onto job stages and derives the
// headline progress bar from whichever tier is currently running.
func (si *SearchIndex) applyStagesToJob(h *jobs.Handle, status IndexStatus) {
stages := make([]jobs.Stage, 0, len(status.Tiers))
var (
phase string
current, total int64
foundRunningTier bool
)
for _, t := range status.Tiers {
stages = append(stages, jobs.Stage{
Name: t.Name,
State: t.State,
Current: int64(t.Completed),
Total: int64(t.Total),
Error: t.Error,
})
if t.State == "running" && !foundRunningTier {
foundRunningTier = true
phase = t.Name
current = int64(t.Completed)
total = int64(t.Total)
// The listens stream's raw completed/total is a bare
// percentage; the detail line is what makes it legible.
if t.Detail != "" {
phase = t.Name + " — " + t.Detail
}
}
}
h.SetStages(stages)
if foundRunningTier {
h.SetPhase(phase)
h.SetProgress(current, total)
}
h.SetStats([]jobs.Stat{
{Label: "Artists", Value: strconv.Itoa(status.Artists)},
{Label: "Release groups", Value: strconv.Itoa(status.ReleaseGroups)},
{Label: "Recordings", Value: strconv.Itoa(status.Recordings)},
{Label: "Total rows", Value: strconv.Itoa(status.TotalRows)},
})
}
// finishIndexJob resolves a build that is no longer running into the
// right terminal (or paused) state. A stopped build that never wrote
// the done marker is reported as cancelled rather than complete —
// claiming success for a half-finished import would be a lie.
func (si *SearchIndex) finishIndexJob(h *jobs.Handle, paused bool) {
if h.State().IsTerminal() || h.State() == jobs.StatePaused {
return
}
// A stage that errored means the build failed; reporting that as
// "stopped" would hide a real failure behind a neutral word.
if !paused {
for _, stage := range h.Snapshot().Stages {
if stage.State == "error" {
h.Fail(fmt.Errorf("%w: %s: %s",
errIndexStageFailed, stage.Name, stage.Error))
return
}
}
}
if paused {
h.SetPhase("Paused")
h.SetState(jobs.StatePaused)
h.Logf(jobs.LevelInfo,
"Build paused — progress is checkpointed and will resume "+
"from here")
return
}
if si.hasMeta(dumpImportDoneKey) {
h.Logf(jobs.LevelInfo, "Index build complete")
h.Complete()
return
}
h.Logf(jobs.LevelInfo, "Build stopped before finishing")
h.Cancelled()
}
// PauseBuild stops the in-flight index build and remembers that the
// user asked for it, so it is not restarted automatically — including
// on the next launch. Blocks until the build goroutine exits; the job
// registry invokes controls on their own goroutine.
func (si *SearchIndex) PauseBuild() {
si.mu.Lock()
si.buildPaused = true
si.mu.Unlock()
si.setMeta(indexPausedKey, "1")
si.StopBuild()
si.emitStatus()
}
// ResumeBuild clears the pause and restarts the build, which picks up
// from the importer's last checkpoint.
func (si *SearchIndex) ResumeBuild() {
si.mu.Lock()
si.buildPaused = false
ctx := si.runtimeCtx
si.mu.Unlock()
si.deleteMeta(indexPausedKey)
if reg := si.jobRegistry(); reg != nil {
if h := reg.Get(indexJobID); h != nil {
h.SetState(jobs.StateRunning)
h.Logf(jobs.LevelInfo, "Resuming from last checkpoint")
}
}
if ctx != nil {
si.StartBuild(ctx)
}
}
// CancelBuild stops the build without marking it paused. The importer's
// on-disk checkpoints are left in place, so starting a new build later
// still resumes rather than re-downloading — cancel here means "stop
// working now", not "throw away the progress".
func (si *SearchIndex) CancelBuild() {
si.mu.Lock()
si.buildPaused = false
si.mu.Unlock()
si.deleteMeta(indexPausedKey)
si.StopBuild()
si.emitStatus()
}
// ImportComplete reports whether the dump import wrote its done marker,
// meaning every stage finished. A resumable import that was interrupted
// leaves this false even though the index may already be queryable.
func (si *SearchIndex) ImportComplete() bool {
return si.hasMeta(dumpImportDoneKey)
}
// BaselineSeries returns the incremental listens series the index's
// popularity numbers are currently caught up to, or 0 when no baseline
// import has completed. A change in this value between two runs is the
// signal that a refresh actually folded in new data.
func (si *SearchIndex) BaselineSeries() int {
series, _ := si.metaInt(listensAppliedSeriesKey)
return series
}
// LastImported returns when the dump import last completed, or the zero
// time if it never has. Drives the rebuild cadence.
func (si *SearchIndex) LastImported() time.Time {
rows, err := si.db.QueryContext(
"SELECT value FROM explore_index_meta WHERE key = ?", dumpImportDoneKey,
)
if err != nil {
return time.Time{}
}
defer func() { _ = rows.Close() }()
if !rows.Next() {
return time.Time{}
}
var raw string
if err := rows.Scan(&raw); err != nil {
return time.Time{}
}
parsed, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}
}
return parsed
}
// PrepareRebuild clears the completion marker so the next StartBuild
// re-imports from the newest published dump instead of short-circuiting.
//
// The importer deletes its staging directory on completion, so there is
// no stale checkpoint to clear as well — a rebuild rediscovers the
// current dump and starts from offset zero. Existing rows are left in
// place: assembly upserts by MBID, so the index stays queryable
// throughout rather than going empty for the length of a re-import.
func (si *SearchIndex) PrepareRebuild() {
si.deleteMeta(dumpImportDoneKey)
si.logger.Info("search index: cleared completion marker for rebuild")
}
// RefreshNow folds any newly published incremental listens dumps into
// the index's popularity numbers, synchronously. Pass 0 to bypass the
// cadence gate.
func (si *SearchIndex) RefreshNow(ctx context.Context, minInterval time.Duration) {
si.RefreshListenCounts(ctx, minInterval)
}
// buildPausedByUser reports whether a build was paused and not resumed,
// including by a previous session.
func (si *SearchIndex) buildPausedByUser() bool {
si.mu.RLock()
paused := si.buildPaused
si.mu.RUnlock()
if paused {
return true
}
return si.hasMeta(indexPausedKey)
}
// AdoptPausedBuild re-registers a build that was paused when the app
// last shut down, so it shows up in the jobs panel with a resume button
// instead of silently not running. Called during startup.
func (si *SearchIndex) AdoptPausedBuild() {
reg := si.jobRegistry()
if reg == nil {
return
}
// A completed import cannot be meaningfully paused; clear a stale
// marker rather than showing a job that would never do anything.
if si.hasMeta(dumpImportDoneKey) {
si.deleteMeta(indexPausedKey)
reg.Remove(indexJobID)
return
}
if !si.hasMeta(indexPausedKey) {
return
}
si.mu.Lock()
si.buildPaused = true
si.mu.Unlock()
h := reg.Start(si.indexJobSpec(jobs.StatePaused))
h.SetPhase("Paused")
h.Logf(jobs.LevelInfo,
"Paused in a previous session — resume to continue from the "+
"last checkpoint")
si.logger.Info("search index: restored paused build")
}