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.
309 lines
9.1 KiB
TypeScript
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;
|
|
}
|
|
}
|