feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import type { DownloadCandidate } from '@store/download-store';
|
||||
import { candidateSummary, scorePercent } from '@store/download-store';
|
||||
|
||||
/**
|
||||
* One candidate in the download picker.
|
||||
*
|
||||
* The row shows match and quality as two separate meters rather than
|
||||
* one blended score, because they fail differently: a flawless copy of
|
||||
* the wrong album is useless, a mediocre copy of the right one is
|
||||
* merely disappointing, and only the user knows which they will accept.
|
||||
* Collapsing them into a single number would make the ranking
|
||||
* impossible to argue with.
|
||||
*/
|
||||
@customElement('candidate-row')
|
||||
export class CandidateRow extends LitElement {
|
||||
@property({ type: Object })
|
||||
candidate!: DownloadCandidate;
|
||||
|
||||
/** Marks the row the ranking put first. */
|
||||
@property({ type: Boolean, attribute: 'is-best' })
|
||||
isBest = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
busy = false;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
padding: 0.75em 0.9em;
|
||||
border: 1px solid var(--wa-color-surface-border, #333);
|
||||
border-radius: 8px;
|
||||
background: var(--wa-color-surface-raised, #1c1c1c);
|
||||
}
|
||||
|
||||
.row.best {
|
||||
border-color: var(--wa-color-brand-fill-loud, #d9a441);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
margin-top: 0.15em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
margin-top: 0.4em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72em;
|
||||
padding: 0.1em 0.45em;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge.best {
|
||||
background: var(--wa-color-brand-fill-loud, #d9a441);
|
||||
color: #111;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.warn {
|
||||
background: rgba(217, 119, 65, 0.25);
|
||||
}
|
||||
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.3em 0.5em;
|
||||
align-items: center;
|
||||
margin-top: 0.5em;
|
||||
font-size: 0.75em;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 150ms ease;
|
||||
}
|
||||
|
||||
.fill.match {
|
||||
background: var(--wa-color-success-fill-loud, #4c9f70);
|
||||
}
|
||||
|
||||
.fill.match.low {
|
||||
background: var(--wa-color-warning-fill-loud, #d97741);
|
||||
}
|
||||
|
||||
.fill.quality {
|
||||
background: var(--wa-color-brand-fill-loud, #6a8cc7);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.85;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/** Match below this reads as "probably not what you asked for". */
|
||||
private static readonly LOW_MATCH = 0.7;
|
||||
|
||||
override render() {
|
||||
const candidate = this.candidate;
|
||||
if (!candidate) return nothing;
|
||||
|
||||
const match = candidate.match?.overall ?? 0;
|
||||
const quality = candidate.quality?.overall ?? 0;
|
||||
|
||||
return html`
|
||||
<div class="row ${this.isBest ? 'best' : ''}">
|
||||
<div class="info">
|
||||
<div class="title" title=${candidate.title}>
|
||||
${candidate.title}
|
||||
</div>
|
||||
<div class="summary">${candidateSummary(candidate)}</div>
|
||||
${this.renderBadges()}
|
||||
<div class="meters">
|
||||
<span class="meter-label">Match</span>
|
||||
<div class="track">
|
||||
<div
|
||||
class="fill match ${match < CandidateRow.LOW_MATCH
|
||||
? 'low'
|
||||
: ''}"
|
||||
style="width: ${Math.round(match * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="value">${scorePercent(match)}</span>
|
||||
|
||||
<span class="meter-label">Quality</span>
|
||||
<div class="track">
|
||||
<div
|
||||
class="fill quality"
|
||||
style="width: ${Math.round(quality * 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="value">${scorePercent(quality)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<wa-button
|
||||
variant=${this.isBest ? 'brand' : 'neutral'}
|
||||
size="small"
|
||||
?disabled=${this.busy}
|
||||
@click=${this.onPick}
|
||||
>
|
||||
Download
|
||||
</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBadges() {
|
||||
const candidate = this.candidate;
|
||||
const badges = [];
|
||||
|
||||
if (this.isBest) {
|
||||
badges.push(html`<span class="badge best">Best match</span>`);
|
||||
}
|
||||
|
||||
// An unanchored match is a guess: there was no MusicBrainz ID to
|
||||
// check the result against, so the score cannot mean much and
|
||||
// saying so is more honest than showing a confident number.
|
||||
if (candidate.match && !candidate.match.anchored) {
|
||||
badges.push(
|
||||
html`<span class="badge warn" title="No MusicBrainz match to verify against">
|
||||
Unverified
|
||||
</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.quality?.mixed) {
|
||||
badges.push(
|
||||
html`<span class="badge warn" title="Files are not all the same format">
|
||||
Mixed formats
|
||||
</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeness = candidate.match?.completeness ?? 1;
|
||||
|
||||
if (completeness < 1 && completeness > 0) {
|
||||
badges.push(
|
||||
html`<span class="badge warn">
|
||||
${scorePercent(completeness)} of tracks
|
||||
</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate.protocol && candidate.protocol !== 'direct') {
|
||||
badges.push(html`<span class="badge">${candidate.protocol}</span>`);
|
||||
}
|
||||
|
||||
return badges.length > 0
|
||||
? html`<div class="badges">${badges}</div>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private onPick() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('candidate-pick', {
|
||||
detail: { candidateId: this.candidate.id },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'candidate-row': CandidateRow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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 { download } from '@go/models';
|
||||
import './candidate-row';
|
||||
|
||||
/**
|
||||
* 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 requestId = '';
|
||||
|
||||
@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>) {
|
||||
if (changed.has('open') && this.open) {
|
||||
void this.search();
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the search that populates the dialog. */
|
||||
private async search(): Promise<void> {
|
||||
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);
|
||||
|
||||
this.requestId = result.requestId;
|
||||
this.candidates = result.candidates ?? [];
|
||||
this.autoPicked = result.autoPicked;
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
} finally {
|
||||
this.searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async onPick(event: CustomEvent<{ candidateId: string }>) {
|
||||
if (this.picking) return;
|
||||
|
||||
this.picking = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
try {
|
||||
await downloadStore.pick(this.requestId, event.detail.candidateId);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
this.errorMessage = String(err);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user