Files
yellowjacket/frontend/src/components/download-picker/download-picker.ts
T
logan 12af6ec1f7 feat(settings): scan from Settings, and watch every job in place
Scanning goes back where libraries are managed -- the Jobs tab's own
comment said the per-library controls had been taken out of Settings to
build it. "Scan All" and "Full Rescan" join "Add Library", "Scan now"
joins the per-library overflow menu beside Rename and Remove, and a
library being scanned says so where its track count goes.

Each surface then gets the job rows for its own kind: scans under
Libraries, index and enrichment under Search Index, downloads under the
download clients, and the autotag apply in the Autotag view -- where
stopping a run matters most, since applying rewrites tags on disk and
that view had no cancel at all.

The autotag panel shares the header's grid row through a wrapper rather
than taking a third row: it is display:none while nothing is applying,
which is nearly always, and a grid row would still spend the
container's gap on it.

The Launch Page select is derived from `VIEW_META` instead of listing
its ten options, since removing a destination is exactly the change
that leaves two hand-written copies disagreeing.
2026-08-19 20:27:33 -04:00

309 lines
9.1 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import type { DownloadCandidate } from '@store/download-store';
import { downloadStore } from '@store/download-store';
import type * as download from '@go/download/models.js';
import './candidate-row';
import { explainError } from '@utils/describe-error';
import { nameDialogsIn } from '@utils/name-dialog';
/**
* The "find this album" dialog: searches every enabled download client,
* ranks what comes back, and asks the user to choose.
*
* When the pipeline finds a clear winner it starts on its own and this
* dialog reports that rather than asking a question with one obvious
* answer. When it does not — two equally good candidates, or a free-text
* request with nothing to verify against — the choice is the user's,
* because guessing wrong puts the wrong files in their library.
*/
@customElement('download-picker')
export class DownloadPicker extends LitElement {
@property({ type: Boolean, reflect: true })
open = false;
/** Library the imported files belong to. */
@property({ type: Number, attribute: 'library-id' })
libraryId = 0;
@property({ type: String })
artist = '';
@property({ type: String })
album = '';
/** MusicBrainz release-group ID, when the caller has one. */
@property({ type: String, attribute: 'release-group-mbid' })
releaseGroupMbid = '';
@property({ type: String, attribute: 'release-mbid' })
releaseMbid = '';
/**
* Expected tracklist. Supplying it is what makes the result
* trustworthy: without it there is nothing to check a candidate
* against, and the pipeline will never auto-pick.
*/
@property({ type: Array })
expected: download.ExpectedTrack[] = [];
@state()
private searching = false;
@state()
private candidates: DownloadCandidate[] = [];
@state()
private downloadId = '';
@state()
private autoPicked = false;
@state()
private picking = false;
@state()
private errorMessage = '';
static override styles = [
designTokens,
css`
:host {
display: contents;
}
.heading {
display: flex;
flex-direction: column;
gap: 0.15em;
margin-bottom: 1em;
}
.album {
font-size: 1.05em;
font-weight: 600;
}
.artist {
opacity: 0.75;
font-size: 0.9em;
}
.status {
display: flex;
align-items: center;
gap: 0.6em;
padding: 1.5em 0;
justify-content: center;
opacity: 0.85;
}
.list {
display: flex;
flex-direction: column;
gap: 0.6em;
max-height: 55vh;
overflow-y: auto;
}
.footnote {
margin-top: 1em;
font-size: 0.8em;
opacity: 0.65;
}
`,
];
override updated(changed: Map<string, unknown>) {
// Web Awesome renders `label` into a heading it never points
// the `<dialog>` at, so the dialog has no accessible name until
// something sets one. See `utils/name-dialog.ts`.
nameDialogsIn(this.shadowRoot);
if (changed.has('open') && this.open) {
void this.search();
}
}
/**
* Monotonic search version. Closing and reopening the dialog for a
* different album used to let the first search's result overwrite
* the second's (errors.m8); the guard is `explore-view`'s.
*/
private searchVersion = 0;
/** Runs the search that populates the dialog. */
private async search(): Promise<void> {
const version = ++this.searchVersion;
this.searching = true;
this.errorMessage = '';
this.candidates = [];
this.autoPicked = false;
try {
const result = await downloadStore.start({
libraryId: this.libraryId,
releaseMbid: this.releaseMbid,
releaseGroupMbid: this.releaseGroupMbid,
artist: this.artist,
album: this.album,
query: '',
expected: this.expected ?? [],
} as download.SearchRequest);
if (version !== this.searchVersion) return;
this.downloadId = result.downloadId;
this.candidates = result.candidates ?? [];
this.autoPicked = result.autoPicked;
} catch (err) {
if (version !== this.searchVersion) return;
console.error('download search failed', err);
this.errorMessage = explainError(
err,
'The search did not finish.',
);
} finally {
if (version === this.searchVersion) {
this.searching = false;
}
}
}
private async onPick(event: CustomEvent<{ candidateId: string }>) {
if (this.picking) return;
this.picking = true;
this.errorMessage = '';
try {
await downloadStore.pick(this.downloadId, event.detail.candidateId);
this.close();
} catch (err) {
console.error('download pick failed', err);
this.errorMessage = explainError(
err,
'That download could not be started.',
);
} finally {
this.picking = false;
}
}
private close() {
this.open = false;
this.dispatchEvent(
new CustomEvent('picker-close', { bubbles: true, composed: true }),
);
}
override render() {
return html`
<wa-dialog
label="Find this album"
?open=${this.open}
@wa-hide=${() => this.close()}
>
<div class="heading">
<span class="album">${this.album || 'Unknown album'}</span>
<span class="artist">${this.artist}</span>
</div>
${this.renderBody()}
<wa-button slot="footer" variant="neutral" @click=${() => this.close()}>
Close
</wa-button>
</wa-dialog>
`;
}
private renderBody() {
if (this.errorMessage) {
return html`
<wa-callout variant="danger">${this.errorMessage}</wa-callout>
`;
}
if (this.searching) {
return html`
<div class="status">
<wa-spinner></wa-spinner>
<span>Searching your download clients…</span>
</div>
`;
}
if (this.autoPicked) {
return html`
<wa-callout variant="success">
Found a clear match and started downloading it. Progress is
on the Downloads page.
</wa-callout>
`;
}
if (this.candidates.length === 0) {
return html`
<wa-callout variant="neutral">
Nothing found. Try a different spelling, or connect more
download clients in Settings.
</wa-callout>
`;
}
return html`
<div class="list">
${this.candidates.map(
(candidate, index) => html`
<candidate-row
.candidate=${candidate}
?is-best=${index === 0}
?busy=${this.picking}
@candidate-pick=${this.onPick}
></candidate-row>
`,
)}
</div>
${this.renderFootnote()}
`;
}
private renderFootnote() {
const best = this.candidates[0];
if (!best?.match) return nothing;
// Say plainly why nothing was auto-picked, so the dialog does
// not look like it is asking a question it could have answered.
if (!best.match.anchored) {
return html`
<div class="footnote">
This search had no MusicBrainz match to verify against, so
these results could not be checked automatically.
</div>
`;
}
return html`
<div class="footnote">
Downloads are checked and tagged before they are added to your
library.
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'download-picker': DownloadPicker;
}
}