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
+7
View File
@@ -2,6 +2,11 @@
// This file is automatically generated. DO NOT EDIT
import {explore} from '../models';
import {context} from '../models';
import {jobs} from '../models';
export function AdoptPausedIndexBuild():Promise<void>;
export function BackfillLibraryDiscographies():Promise<void>;
export function BackfillLibraryLyrics():Promise<void>;
@@ -81,6 +86,8 @@ export function SearchLyrics(arg1:string):Promise<Array<explore.LyricsResult>>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
export function SimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
export function StartIndexBuild():Promise<void>;
+12
View File
@@ -2,6 +2,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AdoptPausedIndexBuild() {
return window['go']['explore']['Service']['AdoptPausedIndexBuild']();
}
export function BackfillLibraryDiscographies() {
return window['go']['explore']['Service']['BackfillLibraryDiscographies']();
}
export function BackfillLibraryLyrics() {
return window['go']['explore']['Service']['BackfillLibraryLyrics']();
}
@@ -158,6 +166,10 @@ export function SetContext(arg1) {
return window['go']['explore']['Service']['SetContext'](arg1);
}
export function SetJobRegistry(arg1) {
return window['go']['explore']['Service']['SetJobRegistry'](arg1);
}
export function SimilarArtists(arg1) {
return window['go']['explore']['Service']['SimilarArtists'](arg1);
}
+17
View File
@@ -0,0 +1,17 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {jobs} from '../models';
export function CancelJob(arg1:string):Promise<void>;
export function ClearFinishedJobs():Promise<void>;
export function DismissJob(arg1:string):Promise<void>;
export function GetJobLog(arg1:string):Promise<Array<jobs.LogEntry>>;
export function GetJobs():Promise<Array<jobs.Job>>;
export function PauseJob(arg1:string):Promise<void>;
export function ResumeJob(arg1:string):Promise<void>;
+31
View File
@@ -0,0 +1,31 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CancelJob(arg1) {
return window['go']['jobs']['Service']['CancelJob'](arg1);
}
export function ClearFinishedJobs() {
return window['go']['jobs']['Service']['ClearFinishedJobs']();
}
export function DismissJob(arg1) {
return window['go']['jobs']['Service']['DismissJob'](arg1);
}
export function GetJobLog(arg1) {
return window['go']['jobs']['Service']['GetJobLog'](arg1);
}
export function GetJobs() {
return window['go']['jobs']['Service']['GetJobs']();
}
export function PauseJob(arg1) {
return window['go']['jobs']['Service']['PauseJob'](arg1);
}
export function ResumeJob(arg1) {
return window['go']['jobs']['Service']['ResumeJob'](arg1);
}
+5
View File
@@ -3,6 +3,7 @@
import {sqlcgen} from '../models';
import {library} from '../models';
import {context} from '../models';
import {jobs} from '../models';
export function AcquirePipelineLock():Promise<void>;
@@ -66,6 +67,8 @@ export function RemoveLibrary(arg1:number):Promise<library.RemovalSummary>;
export function RenameLibrary(arg1:number,arg2:string):Promise<void>;
export function RestorePausedScans():Promise<void>;
export function ResumeScan():Promise<void>;
export function ScanAllLibraries():Promise<void>;
@@ -78,6 +81,8 @@ export function SearchTracksByLibrary(arg1:string,arg2:number):Promise<Array<lib
export function SetContext(arg1:context.Context):Promise<void>;
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
export function SetRemovalHooks(arg1:library.RemovalHooks):Promise<void>;
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
+8
View File
@@ -126,6 +126,10 @@ export function RenameLibrary(arg1, arg2) {
return window['go']['library']['Library']['RenameLibrary'](arg1, arg2);
}
export function RestorePausedScans() {
return window['go']['library']['Library']['RestorePausedScans']();
}
export function ResumeScan() {
return window['go']['library']['Library']['ResumeScan']();
}
@@ -150,6 +154,10 @@ export function SetContext(arg1) {
return window['go']['library']['Library']['SetContext'](arg1);
}
export function SetJobRegistry(arg1) {
return window['go']['library']['Library']['SetJobRegistry'](arg1);
}
export function SetRemovalHooks(arg1) {
return window['go']['library']['Library']['SetRemovalHooks'](arg1);
}
+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 {
+2
View File
@@ -44,6 +44,8 @@ export function ImportPlaylist(arg1:string):Promise<playlist.Summary>;
export function ImportPlaylists(arg1:Array<string>):Promise<Array<playlist.Summary>>;
export function MaterializeUnmaterializedSmartPlaylists():Promise<void>;
export function PreviewSmartPlaylist(arg1:string):Promise<Array<library.Track>>;
export function RefreshSmartPlaylist(arg1:number):Promise<void>;
+4
View File
@@ -82,6 +82,10 @@ export function ImportPlaylists(arg1) {
return window['go']['playlist']['Service']['ImportPlaylists'](arg1);
}
export function MaterializeUnmaterializedSmartPlaylists() {
return window['go']['playlist']['Service']['MaterializeUnmaterializedSmartPlaylists']();
}
export function PreviewSmartPlaylist(arg1) {
return window['go']['playlist']['Service']['PreviewSmartPlaylist'](arg1);
}