Files
yellowjacket/frontend/src/components/download-picker/download-picker.ts
T
yonluandClaude Opus 5 162c68769f feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place —
a real TypeScript module tree nested by Go import path, generated by
wails3's static analyser rather than by building the app and running
it.  The @go alias absorbs the constant prefix, so a call site imports
'@go/library/library.js' and the codemod over all 93 sites was a
specifier rewrite plus splitting @go/models' namespaces into one
import per package.

The 12 SetContext bindings and the fake `context` model are gone, as
Phase 2's ServiceStartup port promised: 272 methods across 12
services, none of them plumbing.

@runtime/runtime is now a local shim (src/wails/runtime.ts) over
@wailsio/runtime, so the 22 EventsOn imports are untouched.  It
unwraps v3's WailsEvent into v2's callback shape, which is exact here:
nothing in backend/events passes more than one data argument, and v3
only packs arguments into a slice when there is more than one.

v3 tells the truth about two things v2 lied about, and that is most of
the diff.  A Go nil slice really does arrive as JSON null, and a Go
named string type really is an enum; v2 typed them as T[] and string.
utils/binding.ts states the app's actual contract — an absent list is
an empty list — once, at the boundary where it is true, and also drops
the CancellablePromise the app never cancels.  Four test fixtures
widen an enum field back to its value union.

Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts
still fakes window.go, which v3 does not have, so `make ui-test` is
broken and harness.test.ts fails to compile on EventsEmit.  That test
also asserts v2 ordering that no longer holds — v3's Events.Emit calls
the backend and does not notify in-page listeners at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 17:48:38 -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
in the background jobs panel.
</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;
}
}