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,272 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import {
GetLibraryDirectory,
SetLibraryDirectory,
} from '@go/config/Config';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
/**
* First-run setup wizard.
*
* On startup it checks whether a library directory has already been
* configured. If one exists the wizard stays hidden and the app
* proceeds as normal. If none is set (fresh install), it presents a
* non-dismissable modal prompting the user to pick their music folder,
* saves it to the config, and dismisses itself. Saving the directory
* emits LibraryConfigChanged on the backend, which kicks off the
* initial scan automatically.
*/
@customElement('first-run-wizard')
export class FirstRunWizard extends LitElement {
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
/** Whether the wizard should be shown at all (no library configured). */
@state() private active = false;
/** Directory chosen in the picker, not yet saved. */
@state() private selectedDirectory = '';
/** True while SetLibraryDirectory is in flight. */
@state() private saving = false;
/** Error message from a failed pick/save, if any. */
@state() private errorMessage = '';
override async connectedCallback(): Promise<void> {
super.connectedCallback();
try {
const existing = await GetLibraryDirectory();
// A configured directory means setup is already complete.
if (existing) return;
} catch (err) {
console.error(
'First-run wizard: failed to read library directory:',
err,
);
return;
}
this.active = true;
await this.updateComplete;
if (this.dialog) this.dialog.open = true;
}
static override styles = css`
wa-dialog {
--width: 480px;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border, #444);
border-radius: 8px;
}
wa-dialog::part(body) {
padding: 24px;
}
.welcome {
text-align: center;
margin-bottom: 20px;
}
.welcome wa-icon {
font-size: 40px;
color: var(--yj-accent, #f5c518);
margin-bottom: 8px;
}
.welcome h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
}
.welcome p {
margin: 0;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
.chosen {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
font-size: 13px;
background: var(--yj-bg-elevated, #2a2f34);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
word-break: break-all;
}
.chosen wa-icon {
color: var(--yj-accent, #f5c518);
flex-shrink: 0;
}
.placeholder {
color: var(--yj-text-tertiary, #888);
}
.error {
font-size: 13px;
color: var(--yj-danger, #e5484d);
margin-bottom: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
background: transparent;
color: var(--yj-text-primary, #fff);
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--yj-bg-elevated, #2a2f34);
}
.btn-primary {
background: var(--yj-accent, #f5c518);
border-color: var(--yj-accent, #f5c518);
color: #000;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
override render() {
if (!this.active) return nothing;
return html`
<wa-dialog
label="Welcome to YellowJacket"
without-header
@wa-hide=${this.preventClose}
>
<div class="welcome">
<wa-icon name="music"></wa-icon>
<h2>Welcome to YellowJacket</h2>
<p>
Choose the folder where your music lives to
get started.
</p>
</div>
<div class="chosen">
<wa-icon name="folder"></wa-icon>
${this.selectedDirectory
? html`<span>${this.selectedDirectory}</span>`
: html`<span class="placeholder"
>No folder selected yet</span
>`}
</div>
${this.errorMessage
? html`<div class="error">${this.errorMessage}</div>`
: nothing}
<div class="actions">
<button
class="btn"
?disabled=${this.saving}
@click=${this.handleChoose}
>
${this.selectedDirectory
? 'Change Folder'
: 'Choose Folder'}
</button>
<button
class="btn btn-primary"
?disabled=${!this.selectedDirectory || this.saving}
@click=${this.handleFinish}
>
${this.saving ? 'Saving…' : 'Get Started'}
</button>
</div>
</wa-dialog>
`;
}
/** Set once setup is finished so we allow the dialog to close. */
private finished = false;
/**
* Block every close attempt (Escape, programmatic, backdrop) until
* setup is finished, so the user can't skip picking a folder.
* wa-hide is cancelable via preventDefault().
*/
private preventClose = (e: Event): void => {
if (!this.finished) e.preventDefault();
};
private handleChoose = async (): Promise<void> => {
this.errorMessage = '';
try {
const dir = await DirectoryPicker();
if (dir) this.selectedDirectory = dir;
} catch (err) {
this.errorMessage = `Could not open folder picker: ${err}`;
console.error('First-run wizard: directory picker failed:', err);
}
};
private handleFinish = async (): Promise<void> => {
if (!this.selectedDirectory) return;
this.saving = true;
this.errorMessage = '';
try {
await SetLibraryDirectory(this.selectedDirectory);
this.finished = true;
if (this.dialog) this.dialog.open = false;
this.active = false;
} catch (err) {
this.errorMessage = `Could not save the folder: ${err}`;
console.error('First-run wizard: save failed:', err);
} finally {
this.saving = false;
}
};
}
declare global {
interface HTMLElementTagNameMap {
'first-run-wizard': FirstRunWizard;
}
}