feat: autotag mixed-bag splitting, search relevance fixes, and multi-library download imports
Autotag: detect "junk drawer" folders with no artist/album consensus and split them into synthetic per-cluster groups instead of forcing one match on an unrelated pile of tracks; repair tagging_items rows left behind by a prior scan orphan-cleanup gap. Explore: fix an exact artist-name search being drowned out by its own catalog entries in intent-prior scoring, and prune stale in_library bookkeeping left behind when a referenced library row is deleted. Download: fix a multi-library regression where every import failed with "no library root configured" — the importer resolved the library root from a legacy single-library config field that nothing populates in the current multi-library model. It now resolves the destination library per-request from the request's own library_id. Also widen the Soulseek search window (12s -> 20s), measured against real request history to be missing available peers on live queries. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -11,8 +11,10 @@ import { designTokens } from '../../styles/tokens.css';
|
||||
import type {
|
||||
DownloadDescriptor,
|
||||
DownloadProvider,
|
||||
ProviderField,
|
||||
} from '@store/download-store';
|
||||
import { downloadStore } from '@store/download-store';
|
||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||
import './config-section';
|
||||
|
||||
/**
|
||||
@@ -162,6 +164,20 @@ export class DownloadClients extends LitElement {
|
||||
.add-row {
|
||||
margin-top: 0.8em;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.field-row wa-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-row .browse-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -257,7 +273,7 @@ export class DownloadClients extends LitElement {
|
||||
<wa-select
|
||||
label="Client type"
|
||||
.value=${this.newKind}
|
||||
@wa-change=${this.onKindChange}
|
||||
@change=${this.onKindChange}
|
||||
>
|
||||
${this.descriptors.map(
|
||||
(d) => html`<wa-option value=${d.kind}>${d.name}</wa-option>`,
|
||||
@@ -277,7 +293,7 @@ export class DownloadClients extends LitElement {
|
||||
<wa-input
|
||||
label="Name"
|
||||
.value=${this.draftName}
|
||||
@wa-input=${(e: Event) => {
|
||||
@input=${(e: Event) => {
|
||||
this.draftName = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
></wa-input>
|
||||
@@ -311,25 +327,25 @@ export class DownloadClients extends LitElement {
|
||||
<wa-input
|
||||
label="Name"
|
||||
.value=${this.draftName}
|
||||
@wa-input=${(e: Event) => {
|
||||
@input=${(e: Event) => {
|
||||
this.draftName = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
></wa-input>
|
||||
|
||||
${descriptor ? this.renderFields(descriptor) : nothing}
|
||||
${descriptor ? this.renderFields(descriptor, provider) : nothing}
|
||||
|
||||
<wa-input
|
||||
label="Priority"
|
||||
type="number"
|
||||
.value=${String(provider.priority)}
|
||||
@wa-input=${(e: Event) => {
|
||||
@input=${(e: Event) => {
|
||||
this.draft['__priority'] = (e.target as HTMLInputElement).value;
|
||||
}}
|
||||
></wa-input>
|
||||
|
||||
<wa-switch
|
||||
?checked=${provider.enabled}
|
||||
@wa-change=${(e: Event) => {
|
||||
@change=${(e: Event) => {
|
||||
this.draft['__enabled'] = (e.target as HTMLInputElement)
|
||||
.checked
|
||||
? '1'
|
||||
@@ -355,28 +371,61 @@ export class DownloadClients extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Renders one input per descriptor field. */
|
||||
private renderFields(descriptor: DownloadDescriptor) {
|
||||
return (descriptor.fields ?? []).map(
|
||||
(field) => html`
|
||||
<wa-input
|
||||
label=${field.label}
|
||||
placeholder=${field.placeholder ?? ''}
|
||||
type=${field.secret ? 'password' : 'text'}
|
||||
.value=${this.draft[field.key] ?? ''}
|
||||
@wa-input=${(e: Event) => {
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
[field.key]: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}
|
||||
>
|
||||
${field.help ? html`<span slot="hint">${field.help}</span>` : nothing}
|
||||
</wa-input>
|
||||
`,
|
||||
);
|
||||
/** Renders one input per descriptor field, plus a folder browse
|
||||
* button for path fields and an "already set" placeholder for
|
||||
* secrets the provider already has a stored value for. */
|
||||
private renderFields(descriptor: DownloadDescriptor, provider?: DownloadProvider) {
|
||||
return (descriptor.fields ?? []).map((field) => {
|
||||
const isSet = field.secret && provider?.setSecrets?.[field.key];
|
||||
const placeholder = isSet
|
||||
? '•••••••• (unchanged — enter a new value to replace it)'
|
||||
: (field.placeholder ?? '');
|
||||
|
||||
return html`
|
||||
<div class="field-row">
|
||||
<wa-input
|
||||
label=${field.label}
|
||||
placeholder=${placeholder}
|
||||
type=${field.secret ? 'password' : 'text'}
|
||||
.value=${this.draft[field.key] ?? ''}
|
||||
@input=${(e: Event) => {
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
[field.key]: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
}}
|
||||
>
|
||||
${field.help ? html`<span slot="hint">${field.help}</span>` : nothing}
|
||||
</wa-input>
|
||||
${field.path
|
||||
? html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
class="browse-button"
|
||||
@click=${() => this.browseForFolder(field)}
|
||||
>
|
||||
Browse
|
||||
</wa-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
private browseForFolder = async (field: ProviderField) => {
|
||||
try {
|
||||
const dir = await DirectoryPicker();
|
||||
|
||||
if (dir) {
|
||||
this.draft = { ...this.draft, [field.key]: dir };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open directory picker:', err);
|
||||
}
|
||||
};
|
||||
|
||||
private descriptorFor(kind: string): DownloadDescriptor | undefined {
|
||||
return this.descriptors.find((d) => d.kind === kind);
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ export class DuplicateTracksDialog extends LitElement {
|
||||
<wa-switch
|
||||
size="small"
|
||||
?checked=${this.applyToAll}
|
||||
@wa-change=${(e: Event) => {
|
||||
@change=${(e: Event) => {
|
||||
this.applyToAll = (
|
||||
e.target as HTMLInputElement
|
||||
).checked;
|
||||
|
||||
@@ -123,6 +123,16 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
/** True when this album is already on the wanted list. */
|
||||
@state() private isWanted = false;
|
||||
|
||||
/**
|
||||
* Library to attach downloads/wants to. The library-filter UI that
|
||||
* would normally set libraryStore's selection isn't mounted anywhere
|
||||
* currently, so that selection is always null here — falling back to
|
||||
* `?? 0` would send a library id that doesn't exist and fail the
|
||||
* download_requests/download_wants foreign key. Resolved to the
|
||||
* selected library, or the first one if none is selected.
|
||||
*/
|
||||
@state() private targetLibraryId: number | null = null;
|
||||
|
||||
/** Unsubscribe handle for the download store. */
|
||||
private downloadUnsub: (() => void) | null = null;
|
||||
|
||||
@@ -494,6 +504,8 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
this.syncWanted();
|
||||
});
|
||||
|
||||
void this.resolveTargetLibraryId();
|
||||
|
||||
// A background BrowseReleases fetch (cold album, versions +
|
||||
// tracklist not cached yet) finished — re-fetch the versions once
|
||||
// per release group so they fill in without the initial request
|
||||
@@ -1511,7 +1523,7 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
@click=${() => {
|
||||
this.pickerOpen = true;
|
||||
void this.openPicker();
|
||||
}}
|
||||
>
|
||||
<wa-icon slot="start" name="download"></wa-icon>
|
||||
@@ -1553,6 +1565,11 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Resolves the library to attach downloads/wants to. */
|
||||
private async resolveTargetLibraryId(): Promise<void> {
|
||||
this.targetLibraryId = await libraryStore.getDefaultLibraryId();
|
||||
}
|
||||
|
||||
/** Reflects the store's view of whether this album is wanted. */
|
||||
private syncWanted(): void {
|
||||
this.isWanted = this.releaseGroupMBID
|
||||
@@ -1567,10 +1584,18 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
} else {
|
||||
if (!this.targetLibraryId) {
|
||||
await this.resolveTargetLibraryId();
|
||||
}
|
||||
if (!this.targetLibraryId) {
|
||||
console.error('Could not update the wanted list: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
mbid: this.releaseGroupMBID,
|
||||
entity: 'release-group',
|
||||
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
|
||||
libraryId: this.targetLibraryId,
|
||||
artist: this.releaseGroup?.artistCredit ?? '',
|
||||
title: this.albumName,
|
||||
scope: 'future',
|
||||
@@ -1584,15 +1609,28 @@ export class ExploreAlbumDetails extends LitElement {
|
||||
this.syncWanted();
|
||||
}
|
||||
|
||||
private async openPicker(): Promise<void> {
|
||||
if (!this.targetLibraryId) {
|
||||
await this.resolveTargetLibraryId();
|
||||
}
|
||||
|
||||
if (!this.targetLibraryId) {
|
||||
console.error('Cannot search for downloads: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
this.pickerOpen = true;
|
||||
}
|
||||
|
||||
private renderPicker() {
|
||||
if (!this.pickerOpen) return nothing;
|
||||
if (!this.pickerOpen || !this.targetLibraryId) return nothing;
|
||||
|
||||
const tracks = this.currentTracks();
|
||||
|
||||
return html`
|
||||
<download-picker
|
||||
?open=${this.pickerOpen}
|
||||
library-id=${libraryStore.getSelectedLibraryId() ?? 0}
|
||||
library-id=${this.targetLibraryId}
|
||||
artist=${this.releaseGroup?.artistCredit ?? ''}
|
||||
album=${this.albumName}
|
||||
release-group-mbid=${this.releaseGroupMBID ?? ''}
|
||||
|
||||
@@ -1933,10 +1933,16 @@ export class ExploreArtistDetails extends LitElement {
|
||||
if (wantId) {
|
||||
await downloadStore.removeWant(wantId);
|
||||
} else {
|
||||
const libraryId = await libraryStore.getDefaultLibraryId();
|
||||
if (!libraryId) {
|
||||
console.error('Could not update the wanted list: no library available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
mbid: this.artistMBID,
|
||||
entity: 'artist',
|
||||
libraryId: libraryStore.getSelectedLibraryId() ?? 0,
|
||||
libraryId,
|
||||
artist: this.displayName,
|
||||
title: this.displayName,
|
||||
scope: 'future',
|
||||
|
||||
@@ -325,10 +325,19 @@ export class WantedView extends LitElement {
|
||||
/** Widens or narrows what an artist subscription covers. */
|
||||
private async toggleScope(want: Want): Promise<void> {
|
||||
try {
|
||||
const libraryId =
|
||||
want.libraryId || (await libraryStore.getDefaultLibraryId());
|
||||
if (!libraryId) {
|
||||
console.error(
|
||||
'Could not change what this subscription covers: no library available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadStore.addWant({
|
||||
mbid: want.mbid,
|
||||
entity: 'artist',
|
||||
libraryId: want.libraryId || (libraryStore.getSelectedLibraryId() ?? 0),
|
||||
libraryId,
|
||||
artist: want.artist,
|
||||
title: want.title,
|
||||
scope: want.scope === 'all' ? 'future' : 'all',
|
||||
|
||||
Reference in New Issue
Block a user