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
+148
View File
@@ -777,6 +777,154 @@ export namespace explore {
}
export namespace jobs {
export class Caps {
pausable: boolean;
cancellable: boolean;
static createFrom(source: any = {}) {
return new Caps(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.pausable = source["pausable"];
this.cancellable = source["cancellable"];
}
}
export class Stat {
label: string;
value: string;
static createFrom(source: any = {}) {
return new Stat(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.value = source["value"];
}
}
export class Stage {
name: string;
state: string;
current: number;
total: number;
error?: string;
static createFrom(source: any = {}) {
return new Stage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.state = source["state"];
this.current = source["current"];
this.total = source["total"];
this.error = source["error"];
}
}
export class Job {
id: string;
kind: string;
title: string;
subtitle?: string;
state: string;
phase?: string;
current: number;
total: number;
caps: Caps;
stages: Stage[];
stats: Stat[];
error?: string;
startedAt: number;
updatedAt: number;
endedAt?: number;
logCount: number;
warnCount: number;
errorCount: number;
static createFrom(source: any = {}) {
return new Job(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.kind = source["kind"];
this.title = source["title"];
this.subtitle = source["subtitle"];
this.state = source["state"];
this.phase = source["phase"];
this.current = source["current"];
this.total = source["total"];
this.caps = this.convertValues(source["caps"], Caps);
this.stages = this.convertValues(source["stages"], Stage);
this.stats = this.convertValues(source["stats"], Stat);
this.error = source["error"];
this.startedAt = source["startedAt"];
this.updatedAt = source["updatedAt"];
this.endedAt = source["endedAt"];
this.logCount = source["logCount"];
this.warnCount = source["warnCount"];
this.errorCount = source["errorCount"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class LogEntry {
time: number;
level: string;
message: string;
detail?: string;
static createFrom(source: any = {}) {
return new LogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.time = source["time"];
this.level = source["level"];
this.message = source["message"];
this.detail = source["detail"];
}
}
export class Registry {
static createFrom(source: any = {}) {
return new Registry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
}
export namespace library {
export class Album {