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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user