Three events the frontend had no way to learn about:
- `PlaybackPositionChanged` carries `player.PositionInfo`, so the seek
bar can render what the player is doing instead of counting seconds
itself.
- `PlaybackFailed` carries the file and the reason, from both the load
and the play path, so a track that will not play stops being a
silent no-op.
- `TrackPlayCountChanged` carries everything needed to patch one track
in place. `TrackMetadataChanged` means "the tags on disk were
rewritten" and costs the frontend its entire library cache; finishing
a track used to emit it.
An event's cost is part of its meaning, and the expensive one must not
be reused for something cheap.
The sidebar had a Home item that fell through to "Coming soon". What
was missing was not another view of the library — four of those exist,
sorted and complete — but the opposite: a complete, sorted library is
exactly what gives you nothing to play, because every entry point into
it is alphabetical and identical every time you open the app.
So a shelf is a *reason*, not a filter. Each one answers a different
question you might be asking when you do not know what you want (what
was I listening to, what is new, what do I keep coming back to, what
have I forgotten, what fits, what would I never pick myself) and each
says which question it answered — a row of covers with no explanation
is just another grid.
Two consequences run through it. Shelves are built from what the user
actually did — play counts, last played, import order — with random
sampling only where there is no signal to use, so randomness is the
fallback rather than the design. And a shelf with nothing behind it is
omitted instead of rendered empty: a fresh library legitimately gets
three, and an empty row labelled "on repeat" would be a lie.
The queries return album ids and nothing else, joined back to
GetAllAlbumsWithDetails in Go, so the album projection keeps having one
definition rather than one per shelf.
The button ran a normal reconcile pass, which honours each request's
retry backoff — so a request searched an hour ago was not due, nothing
was searched, and the button looked broken. The backoff is a promise to
the providers, not to the user: a person pressing "check now" *is* the
schedule, so a user-initiated pass ignores it and the loop still does
not.
"Nothing happened" also needed a reason. Summary now carries how many
requests are still being looked for and whether any download client is
enabled at all, which is the one cause of silence the user can fix —
and the requests tab says so above the list rather than leaving an
inert list to be interpreted.
The rest is the retry schedule finally being admitted to: rows show
when the next check falls due, "Looking for" explains that a request
sitting there is waiting rather than failing, and the page header says
how often the list is worked.
The album and artist pages draw from two sources and rendered
identically either way. An album showing one track because that is all
you own was indistinguishable from an album that has one track, and
both were indistinguishable from a page still waiting on a background
catalog fetch — so the answer to "is more coming?" was to keep
reloading and find out.
<catalog-scope-notice> names the source in one line: silent for full
catalog data, "still loading" while a fetch may land, "library only"
for an entity with no MBID (which will never fill in, so it points at
Autotag), and a retryable notice when the catalog had nothing to say.
Both pages needed a new distinction to drive it. loadingReleases and
its artist-side equivalents mean "something is renderable", which a
library stand-in satisfies — so catalogPending/catalogLoaded track the
different question of whether the catalog has actually answered.
Also fixes the artist page clobbering its library-hydrated discography
with an empty catalog result. An empty BrowseReleaseGroups means the
index has not built this artist yet, not that they released nothing.
A name linked only when the entity carried an MBID — and for tracks,
only when it carried two. That rule is invisible, so a track list read
as randomly broken: some titles were clickable, most were not, and
nothing on screen said why.
A name now always goes somewhere. Tagged entities open their
MusicBrainz page as before; untagged ones open the *library* page for
the same album or artist, which both detail views already support via
a local id — they just had no caller passing one. An untagged track
highlights by title, since a recording MBID is exactly what it lacks.
Links now fire on a genuine single click only. Every list these appear
in also plays a row on double-click, and the title is the widest thing
in the row, so the first click of that gesture lands on the link:
navigating immediately meant double-clicking a track title opened a
page instead of playing it, which the e2e playback suite caught. The
navigation is held for one double-click interval and dropped if the
second click arrives, while the dblclick itself is left to bubble to
the row — so rows do not need to know links exist.
Muting does not change the volume level, and VolumeChanged carried
nothing but that level — so pressing M silenced playback and left the
indicator showing the volume it still had. The UI had nothing to react
to.
Mute rides on its own event rather than widening the volume payload,
since the two are genuinely independent: a muted player at 40% is a
different state from a player at 0%, and only one of them comes back
when you unmute. The icon crosses out and dims, and the popup gains an
explicit Mute/Unmute so the keyboard shortcut is not the only way in.
MuteToggle also now takes the speaker lock (it was mutating the effects
chain from outside it) and refuses politely rather than dereferencing a
nil streamer when nothing has been loaded yet.
A coding agent could develop this repo's Go packages and could not
develop the application: every path to running YellowJacket ended in a
blocking GTK window, so 265 bound methods, 46 events, 33 component
directories and 13 stores had exactly one form of verification
available — `tsc --noEmit`.
The unlock is that `wails dev`'s dev server on :34115 serves the real
frontend with the real generated bindings against the same Go backend a
desktop window attaches to, so a plain Chromium under Xvfb gets a fully
functional app. Four test tiers now exist, cheapest first:
- `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app,
no backend, no display. Works because `frontend/wailsjs/` is a pure
passthrough to `window.go`/`window.runtime`, so faking just those two
globals runs the real bindings and the real store code.
- `make test` — services in-process, asserting on the payload the
frontend would receive, via a new `events.Emit` wrapper.
- `make dev-headless` + `playwright-cli` — the real app, driven
interactively, with an event bridge on `window.__yjEvents` and a
dev-only control surface at `/__test/`.
- `make e2e` — 19 of those flows frozen as Playwright specs.
`events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call
sites: wails' `getEvents` `log.Fatalf`s on any context without its
runtime, so those paths could not run under test and a background
worker could take the app down. Four packages had each hand-rolled the
same guard; nine more guarded on `ctx != nil`, which does not help.
`TestNoDirectRuntimeEmits` fails the build on a new one.
Fixtures are generated, not committed (`make testdata`), and seeds are
built by *running the app* — never by hand-writing config and DB rows,
which would be a second description of a valid YJ_HOME.
`.gitea/workflows/ci.yml` is the first workflow here that tests
anything; the other three only package, so `gitea_ci` reported only
packaging jobs and misled anyone asking whether a push was healthy.
Both jobs were prototyped to green in a bare ubuntu:24.04 container
before the YAML was written, which immediately caught `make lint`
linting three configurations that nothing builds: all three passes
omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch
still ships and Ubuntu 24.04 dropped.
Operational instructions live in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`, and architecture in
`CLAUDE.md` — split by tense, not by topic, because a topical split
gives every new fact two plausible homes. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.
Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.
Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
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
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
Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.
- backend/jobs: registry with per-job ring-buffer logs, capability-driven
controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
page; delete the orphaned library-manager component
Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.
fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Smart playlists now evaluate and snapshot their rules at creation time
instead of only lazily on first open, so the playlist list can show a
real track count in place of the "Smart" label. A one-time idempotent
startup sweep backfills snapshots for smart playlists created before
creation-time materialization existed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wheel over the volume icon steps volume by 5. The slider now updates
live on drag (@input) instead of only on release, debounced 60ms to
avoid spamming SetVolume. A local pendingVolume tracks intent so rapid
events accumulate and UI stays responsive ahead of the backend echo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidates in-progress work across autotag, explore, and library:
- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
handling, recommendation tiers, and a merged distance/rank cascade, with
an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
legacy tier crawl; index-first local search with fuzzy matching and a
dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.
Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The parent is now a 2-column CSS grid with grid-template-rows:
auto 1fr. Headers go in row 1 (auto). Track list and releases
grid go in row 2 (1fr). The track list's natural height defines
row 2's height. The releases grid stretches to match via
align-self:stretch.
Inside the releases grid, cards use flex:1 on the art container
so album art fills available height (with object-fit:cover for
non-square crops). The art is no longer aspect-ratio:1 — it
adapts to whatever height the track list provides.
This guarantees both columns are always the same height regardless
of track count or release count.
Removed max-height/max-width constraints — art fills the grid
cell width naturally. Cards are centered in their cells via
justify-items:center and align-items:center on the card itself.
Text sits centered below the art. The 2-column grid cells size
based on the available column width, so art scales with the
layout rather than being fixed at 80px.
Restored the 2-column grid with square art on top, title+year
below (centered). Art is capped at 80px×80px so two rows of
cards fit within ~220px — close to the 5-track list height.
Text uses xs font size to keep cards compact.
Switched from square album art grid (each card ~180px tall) to
compact horizontal rows (40px thumbnail left, title+year right).
Each card is ~52px tall — 2 cards at ~110px matches the 5-track
list at ~220px without wasted space. Layout is a vertical flex
column instead of a 2-column grid.
Removed flex:1 from .top-releases-grid and flex column from
.top-section-column. Added align-items:start to .top-section-columns
so both columns align at the top. The releases grid now sizes
naturally based on its content — 2 cards in a 2-column grid,
matching the compact height of the track list.
Artists without dedicated images (from fanart.tv, TheAudioDB,
Wikidata, etc) now fall back to their most popular album's cover
art in the library artist grid. Uses the appropriate size tier
based on device pixel ratio — CoverArtSmall for small avatars,
CoverArtMedium/Large for larger ones. Only letter-initial
placeholder remains as the absolute last resort.
When no artist image is available from any source (library store,
explore cache, MB/Wikidata API), fall back to using the artist's
most popular album's cover art. Uses local library data first.
Applied in three contexts:
1. Search results (explore-view):
- After library image seed: checks library albums by artist name
- After API fetch loop: final fallback for unresolved artists
- Library-only search: checks album art in the seed pass
2. Artist detail page header (explore-artist-details):
- In hydrateFromCache: checks library albums after image sources
- After fetchArtistImage API call: fallback if API returned nothing
The album art is displayed as a circular crop in the artist avatar,
which naturally looks like an artist photo — no visual distinction
needed.
Library cache search was returning results in alphabetical order
with no ranking. 'massive' showed Blanck Mass before Massive Attack
because B comes before M.
Now all matches are collected, scored by match quality, and sorted:
Artists:
exact match = 100, starts-with = 90, substring = 70, fuzzy = 50
Albums (same tiers as remote rgMatchTier):
artist-exact = 100, artist-contains = 85, title-exact = 80,
title-starts-with = 75, title-contains = 60, fuzzy = 40
Results are sorted by score descending, then alphabetically as
tiebreaker. Cap increased from 5 to 10 per entity type to show
more library content.
Audit of all external calls across explore components, with local
sources checked first:
1. loadThumbnails: seeds thumbnailCache from library album cover
art (CoverArtMedium/Small by MBID) before building the API
request list. Library albums show cover art instantly; only
non-library albums hit the GetThumbnails API.
2. loadArtistImages: seeds artistImageCache from library store
(ImageMedium/Small by MBID) before the sequential API loop.
Library artists show images instantly; only non-library artists
hit GetArtistImageURL.
3. checkLibrary (explore-view): checks library store MBIDs
frontend-side for artists and albums. Only falls back to
CheckLibraryMBIDs API for recordings (not in library store).
4. checkLibrary (artist-details): same frontend-first approach
using library album MBIDs.
5. hydrateFromCache (artist-details): now also checks library
store directly for artist images when explore cache is empty
(handles direct navigation without prior search).
In library-only mode, loadArtistImages() is skipped (it calls
GetArtistImageURL which hits MB/Wikidata). But searchLibraryCache
already attaches _imageSmall/_imageMedium from the library store.
Now these are seeded into artistImageCache immediately after
setting results, so the search renderer finds them.
Audit found three leaks:
1. explore-view: loadThumbnails() and loadArtistImages() fired on
library search results. These call GetThumbnails (Wails RPC to
Cover Art Archive proxy) and GetArtistImageURL (MB/Wikidata).
Now skipped in library-only mode — library results already have
local cover art and artist images from the library store.
2. explore-album-details: always called LookupReleaseGroup (MB) and
BrowseReleases (MB) regardless of mode. Now skips both in
library-only mode — shows only cache-hydrated header with no
version selector or track listing.
3. explore-artist-details was already correct — the library-only
branch skips all external calls.
All three explore components now subscribe to exploreSettings:
- explore-view: re-runs the current search when toggled. In
library-only mode this means instant local-only results; toggling
off fires the full MB/LB pipeline.
- explore-artist-details: re-runs loadAllData which branches on
libraryOnly — switching modes live-swaps between the full API
view and the library-only view.
- explore-album-details: re-renders to pick up any mode-dependent
display changes.
All subscriptions are cleaned up in disconnectedCallback.
Backend:
- Migration 17: similar_artist_map table stores per-artist similar
artist relationships (source_mbid → similar_mbid + name + score)
- Tier 4 index build now persists similar artists to this table
- GetLibrarySimilarArtists(mbid) queries similar artists filtered
by JOIN with the artists table (library-only, no API calls)
- Added db field to explore.Service for direct queries
Frontend:
- ExploreSettingsStore with libraryOnly toggle, persisted to
localStorage
- Top bar toggle button with active/inactive styling
- Explore search: skips full MB/LB pipeline when library-only,
uses only searchLibraryCache (pure JS, instant)
- Artist detail page: in library-only mode, skips all API calls
(no top tracks, no top releases, no LB play count, no MB
artist lookup). Uses library store for discography, calls
GetLibrarySimilarArtists for similar artists.
- Similar artists section: changed from horizontal scroll to
wrapping flex layout with collapsible toggle (Show all N)
- Removed debug artist ranking log
Each release type group (Albums, EPs, Singles, etc) now shows only
the first row of items (~5 albums) by default. If there are more,
a 'Show all N' toggle appears below. Clicking it expands to show
every release in the group. 'Show less' collapses back to 1 row.
Each group tracks its expanded state independently via a Set of
type names. The toggle uses the same visual style as the top
section's 'Show more' button.
The fuzzy matcher's qw.includes(nw) check matched any artist with
'a' in their name against any query — 'shannon' contains 'a', so
'a silver mt. zion' and 'have a nice life' matched every search.
Added minimum length guards:
- qw.includes(nw): nw must be >= 3 chars (filters 'a', 'an', 'I')
- editDistance: both words must be >= 4 chars (prevents short-word
false positives like 'mt' matching 'me')
Added GetArtistPlayCount(mbid) — fetches ArtistPopularity from LB
for a single MBID and returns the total listen count. Fire-and-forget
call on the artist page, displays below the meta line as
'1.3M plays on ListenBrainz' (uses existing formatListenCount).
Views with contain:layout in shadow DOM need explicit height:100%
on :host so their internal flex layout fills the parent's flex
space. Without it, the component doesn't know its height and the
internal content doesn't stretch to fill the panel.
Added height:100% to: cover-grid, artists-view, genres-view,
playlist-view, track-list, config-page (also box-sizing).
explore-view and detail views already had it.
Three changes:
1. Added box-sizing: border-box to :host of explore-artist-details,
explore-album-details, and artist-details. The light DOM rule
.main-panel > * sets this but shadow DOM may not inherit it.
2. Changed .main-panel to display: flex; flex-direction: column.
In block layout, a height:0 hidden view could affect the
position of the detail view below it. In flex column layout,
the hidden view (flex:0) collapses completely and the active
view (flex:1) fills the remaining space.
3. Changed .main-panel > * from height:100% to flex:1; min-height:0.
In a flex column, flex:1 correctly fills available space while
min-height:0 allows the content to shrink below its intrinsic
size (needed for overflow:hidden to work).
Three search improvements:
1. MB queries now use AND + wildcard syntax instead of default OR.
'the teenagers' → 'the AND teenagers*'. This eliminates common-
word pollution: The Beatles no longer match because they only
contain 'the'. The trailing wildcard on the last term preserves
type-ahead behavior. Special Lucene characters are escaped.
2. mbSearchLimit increased from 20 to 50. Gives the ranking pipeline
more raw material — with AND filtering there's less noise, and
our name-match tiers + popularity reranking handle the rest.
Final display is still capped at 15.
3. Frontend library cache now uses fuzzy matching with Levenshtein
edit distance (max 2) as fallback. Exact substring match is
tried first, then per-word fuzzy matching for words >= 4 chars.
'florene and the machine' matches 'Florence and the Machine'.
Pure JS, no API cost — runs against the in-memory library arrays.
Added date field to LBTopReleaseGroup from the LB API's
release_group.date. Card now displays the 4-digit year
extracted via extractYear() instead of the release type.
Changed from auto-fill (which created a single row of 4) to fixed
2-column grid. Column is now a flex container so the grid stretches
to fill the section height alongside the track list.
When searching 'lord', library cache instantly showed Lord Huron,
Lorde, etc. But the full MB+LB search returned empty (filtered by
minBlendedScore) and overwrote the local results with 'no results'.
Now executeFullSearch preserves local results:
- If full search has results, merge library-only entries into them
(dedup by name) so local artists aren't lost
- If full search is empty but local results exist, keep local results
- Only show empty state when both are empty
Added mergeLocalIntoFull() for deduplicating local results against
the full search response by artist name and album title+artist.
Artist detail page:
- Checks exploreCache for pre-loaded artist image (from search)
- Checks libraryStore for albums by this artist (by name match)
and shows them as discography instantly before API calls
- Skips fetchArtistImage if cache already provided one
Album detail page:
- Checks exploreCache for cached album metadata (title, artist,
year) from search results and pre-populates the header
- API calls still run to get full data (releases, tracks)
Backend:
- Added mbid column to sqlc schemas for artists and release_groups
- Regenerated sqlc queries to SELECT mbid in artist/album queries
- Added MBID field to library.Artist and library.Album Go structs
- All GetAllArtists/GetAllAlbums variants now populate MBID
Frontend:
- Updated Wails models.ts with MBID fields on Artist and Album
- Added cachedArtists/cachedAlbums getters to LibraryStore
- searchLibraryCache now includes MBIDs and local cover art URLs
so library results can navigate to explore detail pages
- Added mergeWithLibrary() — when full MB results arrive, library
entries are enriched with local images and 'In Library' flags
rather than being replaced by MB-only versions
- Created ExploreCache store for cross-page data sharing: search
results populate the cache, detail pages can read from it to
avoid redundant API calls for already-fetched data
The HTTP endpoint approach still crashed due to Wails asset server
issues. Replaced with a pure frontend solution: searchLibraryCache()
does a substring match against the libraryStore's cached artists and
albums arrays. This is pure JS — zero Go calls, zero RPC, zero
network — guaranteed instant.
Results appear immediately as the user types. The full MB+LB search
pipeline still runs via Wails RPC and replaces the library matches
with richer results when done.
Added cachedArtists/cachedAlbums getters to LibraryStore for
synchronous read-only access to the already-loaded data.
Removed the /api/search-local HTTP handler from the backend.
Both SearchLocal RPC and Wails events were blocked by Wails v2's
Go call serialization. When the indexer or other Go calls were
in-flight, even a 1ms Go function couldn't return to JS.
New approach: registered /api/search-local as an HTTP handler on
the Wails asset server. The frontend fetches it directly via
fetch() — this runs on Go's HTTP server goroutine pool, completely
independent of Wails RPC serialization.
The fetch completes in milliseconds regardless of what other Go
calls are queued. The full Search() pipeline still runs via Wails
RPC and replaces the local results when done.
The SearchLocal RPC approach couldn't render results instantly
because Wails v2 serializes Go method calls — SearchLocal would
queue behind other in-flight calls.
Now Search() emits a 'search:local-results' Wails event at the
start of Phase 0 (before the slow MB/LB pipeline begins). The
frontend listens for this event in connectedCallback and renders
the local hits immediately. The event bypasses the RPC queue
since it's pushed from Go, not pulled by JS.
Removed the SearchLocal RPC call from the frontend entirely.
The full Search() call was blocking the render even after SearchLocal
returned results — both awaits ran in the same async function, and
Wails may serialize Go calls preventing the microtask yield from
triggering a render.
Split executeFullSearch into a separate async method invoked with
void (fire-and-forget). executeSearch now returns after SearchLocal
completes, letting Lit render the local results immediately. The
full pipeline results replace them when ready.
Three fixes:
1. SearchLocal no longer applies minBlendedScore filter — index hits
use scalePopularity scores that aren't comparable to blended
MB+LB scores. This prevents artists from appearing in local
results then disappearing when the full pipeline replaces them
with score-filtered results.
2. Top section columns now render independently — tracks show as
soon as they load, top releases show their own loading state or
appear when ready. Previously the entire section was blocked
until both finished loading.
3. Top releases column shows a loading spinner while its data is
still fetching, rather than being invisible.
Replaced the date-sorted release group approach with a dedicated
TopReleaseGroupsForArtist LB API call that returns releases ranked
by total listen count (popularity). Added LBTopReleaseGroup type
and Wails bindings.
Restyled the top-releases cards to match the library album view:
square cover art on top with title and type below, centered text,
auto-filling the available width with even spacing.
Added SearchLocal() — queries only the FTS5 index with no network
calls. The frontend now calls SearchLocal first, renders those
results immediately (clearing the loading spinner), then fires the
full Search() pipeline in the background. When full results arrive,
they replace the local hits seamlessly.
For indexed queries this means sub-100ms first results regardless
of how slow MB/LB are. Unindexed queries still show the loading
spinner until the full pipeline completes.
Replace full album cards (square art + text below) with compact
horizontal cards: 44px art thumbnail on left, title + year on right.
Cards fit ~56px tall each, so a 2×2 grid of 4 releases aligns with
the height of 5 track rows in the left column.
Replace full-width top tracks with a split layout: top tracks on the
left (5 default), top releases grid on the right (2×2 = 4 default).
A 'Show more' toggle below both columns expands to 10 tracks and 8
releases. Top releases are sorted newest-first from the existing
discography data.
The full discography section remains below for browsing by type.