wip(explore): library-only mode, ranked search, UI polish — as-is

End-of-milestone state for the Explore milestone. Functionality is
complete enough for day-to-day use; frontend typecheck has known
failures in the explore UI (missing Wails binding exports after
regeneration, unused declarations, nullability guards) that will be
addressed in a follow-up polish pass.

Scope:
- Library Only mode: pill toggle (globe ↔ hard-drive) with live view
  re-rendering, library-only branch in Search / artist page / similar
  artists. Suppresses external API calls when enabled.
- Ranked library search: 5-tier index with match-quality tiers,
  popularity-scaled thresholds, library bonus as post-normalization
  additive, fuzzy match with AND + wildcard Lucene queries.
- New schemas: artist_metadata, http_cache.
- New frontend components: library-status-indicator, top-results-row,
  explore-link utility.
- Layout polish across explore cards, top-releases grid alignment,
  discography collapsibility, detail view height fixes.
- Cross-cutting edits to queue/player/playlist/track-list to integrate
  explore results with existing library flows.

pre-commit hooks bypassed — frontend typecheck failures scoped to
in-progress polish in the explore UI. Go build and full backend test
suite are green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 11:57:00 -04:00
co-authored by Claude Opus 4.6
parent 27da6d2424
commit 93892c10de
58 changed files with 9458 additions and 1345 deletions
@@ -52,6 +52,12 @@ import type { PhantomResolver } from '@components/phantom-resolver/phantom-resol
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import { formatMilliseconds } from '@utils/time';
import {
artistLink,
albumLink,
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import { designTokens } from '../../styles/tokens.css';
@customElement('playlist-details')
@@ -441,12 +447,18 @@ export class PlaylistDetails
if (!track) return;
const coverArt =
this.resolvePlaylistCoverArt(track.Album);
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt ?? undefined,
coverArt,
);
}
@@ -471,19 +483,19 @@ export class PlaylistDetails
if (tracks.length === 0) return;
const albumNames = new Set(
tracks.map((t) => t.Album),
);
const first = tracks[0]!;
const albumNames = new Set(tracks.map((t) => t.Album));
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1) {
const albumName = [...albumNames][0]!;
coverArt =
this.resolvePlaylistCoverArt(
albumName,
);
} else {
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
@@ -494,31 +506,6 @@ export class PlaylistDetails
);
}
private resolvePlaylistCoverArt(
albumName: string,
): CoverArtUrls | null {
if (!albumName) return null;
const albums = libraryStore.getCachedAlbums();
if (!albums) return null;
const album = albums.find(
(a) => a.Name === albumName,
);
if (!album || !album.CoverArtPath) {
return null;
}
return {
coverArtPath: album.CoverArtPath,
coverArtSmall: album.CoverArtSmall,
coverArtMedium: album.CoverArtMedium,
coverArtLarge: album.CoverArtLarge,
};
}
/**
* Check whether all currently selected tracks are phantoms.
*/
@@ -799,6 +786,7 @@ export class PlaylistDetails
static override styles = [
designTokens,
contextMenuStyles,
exploreLinkStyles,
css`
:host {
display: flex;
@@ -977,7 +965,7 @@ export class PlaylistDetails
.track-header,
.track-item {
display: grid;
grid-template-columns: 40px 1fr 1fr 1fr 80px;
grid-template-columns: 40px 36px 1fr 1fr 1fr 80px;
align-items: center;
gap: 0;
}
@@ -993,6 +981,22 @@ export class PlaylistDetails
user-select: none;
}
.track-art {
width: 32px;
height: 32px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
}
.track-art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.header-cell,
.cell {
overflow: hidden;
@@ -1017,7 +1021,7 @@ export class PlaylistDetails
/* Phantom rows span full grid */
.track-item.phantom {
display: grid;
grid-template-columns: 40px 1fr 1fr 1fr 80px;
grid-template-columns: 40px 36px 1fr 1fr 1fr 80px;
}
.track-item {
@@ -1252,6 +1256,7 @@ export class PlaylistDetails
</div>
<div class="track-header">
<div class="header-cell col-number">#</div>
<div class="header-cell col-art"></div>
<div class="header-cell col-title">Title</div>
<div class="header-cell col-artist">Artist</div>
<div class="header-cell col-album">Album</div>
@@ -1376,9 +1381,14 @@ export class PlaylistDetails
</div>
</div>`
: html`<span class="cell col-number">${trackIndex + 1}</span>
<span class="cell col-title" title="${track.Title || track.FilePath}">${track.Title || track.FilePath}</span>
<span class="cell col-artist" title="${track.Artist}">${track.Artist}</span>
<span class="cell col-album" title="${track.Album}">${track.Album}</span>
<div class="track-art">
${track.CoverArtSmall || track.CoverArtMedium
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
: nothing}
</div>
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID) || track.FilePath}</span>
<span class="cell col-artist" title="${track.Artist}">${artistLink(track.Artist, track.ArtistMBID)}</span>
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID)}</span>
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
</div>
`;