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
@@ -0,0 +1,440 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import {
jobIcon,
stateTone,
stateLabel,
jobStateStyles,
} from './job-format';
/** Circumference of the progress ring at r=9. */
const RING_CIRCUMFERENCE = 2 * Math.PI * 9;
/**
* Persistent background-job indicator for the top bar.
*
* Hidden entirely when nothing is running, so it costs no attention in
* the common case. When work is in flight it shows a determinate ring
* for a single job, or a count badge for several. Clicking opens a
* popover with inline pause/stop controls; "Details" opens the drawer.
*/
@customElement('job-indicator')
export class JobIndicator extends LitElement {
@state()
private jobs: Job[] = [];
@state()
private popoverOpen = false;
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: inline-flex;
align-items: center;
position: relative;
}
:host([hidden]) {
display: none;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.3em 0.7em 0.3em 0.35em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-size: var(--yj-text-sm);
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.trigger:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.trigger:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.ring-wrap {
position: relative;
width: 22px;
height: 22px;
flex-shrink: 0;
}
svg {
width: 22px;
height: 22px;
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 2.5;
}
.ring-value {
fill: none;
stroke: var(--job-tone);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 240ms ease;
}
/* Indeterminate work spins the whole ring instead of
* advancing it, so it never implies false precision. */
.ring-wrap.spin svg {
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(270deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ring-wrap.spin svg {
animation-duration: 3s;
}
}
.ring-glyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--job-tone);
font-variant-numeric: tabular-nums;
}
.label {
white-space: nowrap;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.alert-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff6b6b;
flex-shrink: 0;
}
.panel {
width: 24rem;
max-width: 92vw;
background: var(--yj-surface, #212529);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 0.4em;
max-height: 70vh;
overflow-y: auto;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4em 0.6em 0.5em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.panel-header button {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
padding: 0.15em 0.4em;
border-radius: 5px;
}
.panel-header button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.job-entry {
border-radius: 8px;
}
.job-entry + .job-entry {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.details-link {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--yj-accent, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0 0.75em 0.6em 3.2em;
}
.details-link:hover {
text-decoration: underline;
}
.empty {
padding: 0.8em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => this.syncJobs());
void jobStore.init();
this.syncJobs();
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onKeydown);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
document.removeEventListener('click', this.onDocumentClick);
document.removeEventListener('keydown', this.onKeydown);
}
private syncJobs() {
this.jobs = jobStore.jobs;
// The drawer stays mounted so it can animate closed; the pill
// itself disappears once nothing is happening.
this.hidden = !jobStore.shouldShowIndicator && !this.drawerOpen;
if (this.hidden) this.popoverOpen = false;
}
private onDocumentClick = (e: MouseEvent) => {
if (!this.popoverOpen) return;
if (e.composedPath().includes(this)) return;
this.popoverOpen = false;
};
private onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.popoverOpen) this.popoverOpen = false;
};
private onTriggerClick = (e: Event) => {
e.stopPropagation();
this.popoverOpen = !this.popoverOpen;
};
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
this.popoverOpen = false;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
this.syncJobs();
};
private async clearFinished(e: Event) {
e.stopPropagation();
await jobStore.clearFinished();
}
/** The job whose progress the ring represents. */
private get primaryJob(): Job | null {
const working = jobStore.workingJobs;
if (working.length > 0) return working[0] ?? null;
const active = jobStore.activeJobs;
return active[0] ?? null;
}
private renderRing(job: Job | null) {
const activeCount = jobStore.activeJobs.length;
const fraction = job ? progressFraction(job) : null;
const tone = job ? stateTone(job) : 'success';
// Only spin for work that is actually moving. A paused or
// queued job spinning would say "busy" when nothing is running.
const spin = Boolean(
job && job.state === 'running' && isIndeterminate(job),
);
const offset =
fraction === null
? RING_CIRCUMFERENCE * 0.72
: RING_CIRCUMFERENCE * (1 - fraction);
return html`
<div class="ring-wrap tone-${tone} ${spin ? 'spin' : ''}">
<svg viewBox="0 0 22 22" aria-hidden="true">
<circle class="ring-track" cx="11" cy="11" r="9"></circle>
<circle
class="ring-value"
cx="11"
cy="11"
r="9"
stroke-dasharray=${RING_CIRCUMFERENCE}
stroke-dashoffset=${offset}
></circle>
</svg>
<div class="ring-glyph">
${activeCount > 1
? activeCount
: html`<wa-icon
name=${job ? jobIcon(job) : 'check'}
></wa-icon>`}
</div>
</div>
`;
}
private renderTrigger() {
const job = this.primaryJob;
const activeCount = jobStore.activeJobs.length;
const hasFailure = jobStore.failedJobs.length > 0;
let label: string;
if (activeCount > 1) {
label = `${activeCount} background jobs`;
} else if (job && job.state === 'running') {
label = job.title;
} else if (job) {
// "Scanning Music" would be a lie for a job that is paused
// or queued, so lead with the state instead.
label = `${stateLabel(job)} · ${job.title}`;
} else {
label = 'Finished';
}
return html`
<button
class="trigger"
aria-haspopup="dialog"
aria-expanded=${this.popoverOpen}
title="Background jobs"
@click=${this.onTriggerClick}
>
${this.renderRing(job)}
<span class="label">${label}</span>
${hasFailure ? html`<span class="alert-dot"></span>` : nothing}
</button>
`;
}
private renderPanel() {
const finished = jobStore.finishedJobs;
return html`
<div class="panel" role="dialog" aria-label="Background jobs">
<div class="panel-header">
<span>Background jobs</span>
${finished.length > 0
? html`<button @click=${this.clearFinished}>
Clear finished
</button>`
: nothing}
</div>
${this.jobs.length === 0
? html`<div class="empty">Nothing running.</div>`
: this.jobs.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="compact"
open-on-click
@job-control=${applyJobControl}
@job-open=${() =>
this.openDetails(job.id)}
></job-row>
<button
class="details-link"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount} warning${job.warnCount === 1 ? '' : 's'}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
override render() {
return html`
<wa-popup
placement="bottom-end"
distance="8"
?active=${this.popoverOpen}
>
<span slot="anchor">${this.renderTrigger()}</span>
${this.popoverOpen ? this.renderPanel() : nothing}
</wa-popup>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-indicator': JobIndicator;
}
}