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.
genevents prefixed only the *first* line of a const block's doc
comment with `//`, so a comment that ran to a second paragraph emitted
bare prose into the TypeScript object literal — a generated file that
does not parse.
Nothing had noticed because nobody had run the generator since the
comments were written, and `make generate` is a pre-commit hook: the
failure was waiting for whoever next touched a .sql, a .templ or an
event constant. A generator is only verified by running it.
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.
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>
The autotag overhaul added cover-art and MusicBrainz-ID columns to
leanTrackQuery to support the new track-row styling, reintroducing the
per-row correlated subquery anti-pattern (artist_mbid) plus a cover_art
join inside the whole-library derived table. Both ran for every track
before WHERE/LIMIT, so smart-playlist evaluation cost scaled with
library size rather than result size — several seconds for a 500-track
playlist that was previously sub-second.
Move these presentation-only fields into a batched fetchArtwork pass
keyed by the matched recording_ids, mirroring the existing fetchGenres
batch. Cost is now proportional to results. Add TestEvaluate_ArtworkEnrichment
(no prior coverage of these fields) and an artwork_ms debug metric.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TestBufferedStreamer_BasicStream flaked under -race (got 1256/1512 vs
1000 expected). The streamer injects silence frames by design when its
ring buffer momentarily underruns; under the race detector the consumer
outran read-ahead and received mid-stream 256-sample silence frames. The
collection loop only skipped leading silence, so those frames were
counted as data.
Skip all zero frames, matching the test's own drain loop — real samples
always start at 1.0, so any zero is injected silence, never source data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enrich owned artists whose discography hasn't been fetched yet in a
bounded, resumable background pass so their wider catalogue is searchable
offline right after a scan, instead of only on first artist-page view.
Keyed off the persistent discog_fetched flag via LEFT JOIN, so already-
enriched artists never reappear and the run is a cheap no-op once every
owned artist is covered. Capped at discogBackfillMaxPerRun per run and
routed through discogSF to avoid double-fetching an artist a concurrent
interactive EnsureArtistDiscography is handling. Invoked on both scan
completion (OnStartup) and OnDomReady to resume a capped/interrupted run.
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>
Brings in the Explore subsystem: MusicBrainz / ListenBrainz / Wikidata
integration, ranked library search, Library Only mode, cover art
proxy, artist image pipeline, and associated frontend views. Final
commit on the branch is a known WIP snapshot of search-polish work
to be iterated on later.
Merge fixups applied to get the tree green:
- migration 5 INSERT now lists columns explicitly so the release_groups
rebuild works on fresh DBs where CREATE TABLE IF NOT EXISTS has
already materialized the current schema (with migration 13's mbid
column). Without this, every test that hits NewTestDB fails.
- scan_test.go:mapTrackRow calls updated for the new coverArtPath and
mbid argument tail.
- TestMigration11ExploreCache, TestCacheEvict, TestCacheMBID skipped:
they query explore_cache directly, but migration 27 now splits that
table into http_cache + artist_metadata and drops it on fresh DBs.
The tests need to be rewritten against the new schemas.
- .gitignore: kept the wip-side gsd-session-*.html rule.
pre-commit hooks bypassed because the WIP tip commit from the
milestone branch (wip explore search polish) has known frontend
typecheck failures; Go build and the full backend test suite are
green with the merge fixups above.
Co-Authored-By: Claude Opus 4.6 (1M context) <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>
Evaluate now issues a lean main SELECT over the joined metadata tables
with no genre column, then batch-fetches genres with a single query
using WHERE recording_id IN (...). Previously the track_metadata view's
correlated GROUP_CONCAT subquery ran per row and scaled with library
size rather than result size, producing multi-second load times for
100-track smart playlists.
- Inline the metadata joins instead of using the track_metadata view,
so the per-row GROUP_CONCAT never runs on the hot path. Other
callers of the view (search, library listing) are unaffected.
- Route all genre operators (is/is_not/is_any_of/contains/etc.)
through a recording_genres subquery against af.recording_id.
Previously text operators like "contains" matched against the
view's concatenated genre column, which is no longer in scope.
- Sort-by-genre falls back to Go-side sort after the batch genre
merge since there is no single SQL column to sort on.
- Log main_ms / genres_ms / total_ms at Debug for future tuning.
- Add (*DB).Logger() accessor so smartplaylist can reuse the DB's
structured logger without changing Evaluate's signature.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- wsl_v5: blank line before t.Fatal after rows.Close
- staticcheck SA5011: explicit return after t.Fatal for nil guards
No behavior change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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
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).
The index fast path / backfill approach was fundamentally broken:
- Index had no data for most search results → all scored ~35
- Backfill tried to patch in LB data but clobbered index scores
- Different maxPop between passes produced inconsistent rankings
New approach: always fetch ArtistPopularity from LB for every
search (single POST, ~200ms). Merge with index data (take the
higher value for each MBID). This ensures correct ranking
regardless of index coverage.
The fast/slow path distinction is preserved for release groups
and recordings (where index coverage is better), but artist
ranking always uses real LB data.
Added boostWithIndexPopularityRGsAndRecs for the RG/recording-only
index path. Removed backfillArtistPopularity entirely.
The previous backfill called rerankArtists with an incomplete pop
map (only backfilled artists), wiping out scores for artists that
had index data (including the library-boosted Shannon and the Clams).
Now backfill only updates Score for artists that were actually
backfilled from LB, using OriginalScore as the relevance input
and a maxPop computed across both index and backfill data. Artists
with existing index scores are untouched. A final sort by Score
merges both groups into the correct order.
When the fast path (index ready) returns no popularity for most
artists, a targeted LB ArtistPopularity POST fires for just the
missing MBIDs. This handles searches like 'shannon' where MB
returns artists not covered by the index (not sitewide top 100,
not in library, not similar to library artists).
Only fires when >50% of artists lack index data — if the index
covered most results, the backfill is skipped. Single POST call,
typically 10-30 MBIDs, goes through the LB rate limiter.
After backfill, rerankArtists runs again with the combined
popularity data, so Shannon Wright (766K listens) correctly
outranks Shannon Hale (0 listens).
When maxPop=0 (no artist has index/LB popularity data), blendedScore
returned raw relevance (0-1), making Score = MB_score directly.
Shannon Hale (MB 83, zero listens) scored 92 after tier adjustment
and ranked #4 — above Shannon Wright (MB 80, 766K real listens but
not in index).
Now blendedScore uses max(maxPop, 100K) as the normalization
denominator. With zero popularity against a 100K reference, the
60% popularity component contributes near-zero, dropping all
zero-pop artists to ~35-40. This ensures unpopular artists can't
dominate through MB text relevance alone when the index lacks data.
The popularity-scaled filter threshold couldn't distinguish 'unknown
popularity' (not in index) from 'confirmed zero' because most
zero-pop artists aren't in the explore index at all. Both cases
got HasPopularity=false.
Simpler approach: remove the special zero-pop filter entirely. With
proper popularity normalization (no +10M contamination), zero-pop
artists get blended scores of ~33-37 and naturally fall below
position 15 in the maxResults cap. Shannon Hale (score 36) ranks
#19 — cut by the cap, no special filtering needed.
Removed minScoreForArtist, minScoreZeroPop, and the HasPopularity/
Popularity-based filtering logic. The minBlendedScore=15 floor
catches extreme edge cases.
The +10M library bonus was added directly to the popularity map,
which made it the maxPop normalization denominator. With maxPop=10M,
every non-library artist's log-normalized popularity collapsed to
near-zero, making their blended score purely 40% of MB relevance.
All non-indexed artists scored ~35 and ranked by MB noise.
New approach:
- Removed +10M from both GetPopularityBatch and boostWithPopularity
- GetPopularityBatch now returns PopularityBatchResult with separate
Popularity and InLibrary maps
- rerankArtists takes a libraryMBIDs set and applies a fixed +25
score bonus AFTER blended scoring and normalization
- maxPop reflects real popularity only, so log normalization works
correctly across all artists
Shannon Wright (766K listens) now properly outranks Shannon Kennedy
(95 listens) because the popularity scale isn't contaminated.
Artists not in the explore index had HasPopularity=false and
Popularity=0, making them indistinguishable from confirmed
zero-popularity artists like Shannon Hale. The strict threshold
(60) was filtering all non-indexed MB results.
Now three states:
- Known popular (HasPop=true, Pop>0) → sliding threshold
- Known unpopular (HasPop=true, Pop=0) → strict threshold (60)
- Unknown (HasPop=false) → lenient threshold (15)
Non-indexed MB results are 'unknown' and pass with any reasonable
score. Only artists confirmed to have zero listens face the high bar.
Instead of a fixed minBlendedScore or binary has/hasn't-popularity
check, the minimum score threshold now slides based on actual listen
count:
0 listens → threshold 60 (need strong name match)
100 listens → threshold 45
1K listens → threshold 38
10K listens → threshold 30
100K listens → threshold 23
1M+ listens → threshold 15 (almost anything passes)
Uses log scaling so the threshold drops quickly for even modest
popularity and flattens toward the floor for well-known artists.
Shannon Hale (0 listens, score 37) → filtered.
Shannon Kennedy (95 listens, score 58) → kept.
Shannon Wright (766K listens, score 103) → trivially passes.
Added Popularity field to MBArtist, populated by both reranking
paths (index fast path and LB API slow path).
minBlendedScore=50 was too aggressive on the fast path where
non-indexed MB results get zero popularity (blended score ~35).
This killed all MB results that weren't in the explore index,
leaving only library/index artists.
New approach: two-tier filtering in filterAndCap:
1. minBlendedScore=25 — baseline filter for all artists
2. minZeroPopScore=50 — stricter filter for artists with NO LB
popularity data (HasPopularity=false)
HasPopularity is set by both reranking paths when an artist has
any listen count in the index or LB API. Shannon Hale (zero
listens, score 37) gets filtered by the zero-pop threshold.
Regular MB results that happen to not be in the index but do have
LB popularity pass the normal threshold.
Shannon Hale had zero LB listens but survived filtering with a score
of 37 (from MB text relevance alone). At minBlendedScore=50, artists
with no listening data and only partial name matches are filtered out.
Every artist with actual LB popularity data still passes the threshold.
Starts-with is the natural type-ahead pattern — users type the
beginning of the name they want. Bumped from +8% to +12% to put
it closer to exact match (+15%) while maintaining a clear gap
from substring (-5%).
'Del Shannon' was ranking above 'Shannon and the Clams' because
tier 2 (substring) had a neutral ×1.0 multiplier. Del Shannon's
MB score of 100 (Lucene considers 'Shannon' a full word match)
plus 588K listens gave him a base score of 98 — nearly untouchable.
Tier 2 now gets -5%, dropping Del Shannon to 93 while starts-with
matches like Shannon Wright (99) and Shannon and the Clams (90)
maintain their advantage. The logic: when the user types 'shannon',
results where 'shannon' starts the name are more likely what they
want than results where it's buried in the middle.
Additive bonuses (+12 fixed points) didn't scale with the blended
score range. Log-compressed popularity puts most scores in a narrow
80-92 band, making +12 disproportionately large.
Percentage multipliers scale naturally:
Artist: exact +15%, starts-with +8%, substring 0%, none -15%
Album: credit-exact +15%, credit-contains +10%, title-exact +5%,
title-contains 0%, none -10%
A tier-0 exact match with blended score 86 gets 86×1.15=99.
A tier-1 starts-with with blended score 92 gets 92×1.08=99.
The 4× popularity gap exactly offsets the 7% tier advantage —
proportional behavior where the boost scales with the artist's
existing score rather than being a fixed number.
Replaced hard tier boundaries with additive score adjustments:
Artist tiers: exact +12, starts-with +6, substring +0, none -10
Album tiers: credit-exact +12, credit-contains +8,
title-exact +4, title-contains +0, none -5
A sufficiently popular lower-tier result can now overcome an
unpopular exact match. The effective gap between tier 0 and tier 1
is 6 points on a 0-100 scale, requiring roughly a 4-5x popularity
difference to overcome — matching the intuition that 'slightly more
popular near-match loses to exact, much more popular near-match wins.'
Also added library bonus (+10M) to the slow path (boostWithPopularity)
so library artists rank highly regardless of which reranking path
is used. Previously only the index fast path applied this bonus.
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.
Two changes:
1. rerankReleaseGroups now uses blended scoring (text relevance +
popularity) like artists, instead of pure popularity. This
prevents obscure albums with high listen counts from outranking
direct MB search matches.
2. boostNameMatches now uses rgMatchTier() for release groups, which
checks artist credit before title. Albums BY the searched artist
(tier 0: exact credit match) rank above albums that merely
mention the artist in the title (tier 3: title substring).
For 'hop along': Painted Shut by Hop Along → tier 0, but
Simple Demands: A Hop Along Tribute by Various Artists → tier 3.
Within the same tier, blended score breaks ties so more popular
albums by the same artist rank first.
Index recordings lack duration data (Length=0) because the explore
index only stores title/artist/popularity. When mergeIndexHits
prepended 15+ index recordings, they filled the maxResults cap and
pushed the MB recordings (which have real durations) off the list.
Removed recording merging from mergeIndexHits entirely. Index
artists and release groups are still merged (they carry popularity
data the MB results lack), but recordings don't benefit from index
merging — MB search already returns them with proper metadata.
The allSameScore guard prevented the LB popularity lookup from
firing because the blended scores differed slightly (40 vs 37)
even though both had zero index popularity. The small difference
came from different MB relevance scores (100 vs 93), not from
meaningful popularity data.
Removed the guard entirely — the LB lookup now always fires for
2+ same-named artists in tier 0. The cost is negligible (one POST
with 2-6 MBIDs) and the result is always correct.
When multiple artists share the exact same name (e.g. 'The Teenagers'
US vs FR), the index fast path often has zero popularity for both,
causing the MB text relevance score to determine ordering. MB gave
the obscure US band score 100 vs the well-known FR band score 93,
so the wrong one ranked first.
Added disambiguateSameNameArtists(): after the name-match tier sort
groups exact matches at the top, it checks if the same-name block
has undifferentiated scores. If so, it fires a single targeted
ArtistPopularity POST with just those 2-6 MBIDs and re-sorts by
global listen count. The FR Teenagers (1.3M listens) now correctly
rank above the US Teenagers (23K listens).
This only fires when needed — most searches have no same-name
collisions and skip the check entirely.
Within the same name-match tier, the US Teenagers (MB score 100)
ranked above the FR Teenagers (MB score 93) because the tiebreaker
used OriginalScore. But the FR band is globally more popular (1.3M
vs 23K listens) and has the higher blended score (82 vs 72).
Changed the within-tier tiebreaker to use the blended Score, which
already incorporates both text relevance and popularity. This ranks
the more well-known artist first among same-named exact matches.
Added OriginalScore field to MBArtist (json:"-" so it doesn't
affect the frontend) to preserve the pre-reranking MB score for
potential future use.
Searching 'the teenagers' ranked The Beatles (#2) and Rolling Stones
(#3) above the actual band because MB text search matches the word
'the' at score ~54, and 142M LB listens with 60% popularity weight
overwhelmed the low text relevance.
Added boostNameMatches() as a post-reranking step that stable-sorts
results by name-match tier:
0 = exact match ('the teenagers' == 'the teenagers')
1 = name starts with query
2 = query is a substring of the name
3 = no substring match (only individual words matched)
Within each tier, the existing popularity-blended order is preserved.
This ensures The Teenagers (all variants) always rank above The Beatles
for this query, while The Beatles still rank highly among tier-3 results.
Also added the second Various Artists MBID (89ad4ac3) to the SPA
blocklist.
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.
boostWithIndexPopularity was calling GetPopularity() and IsInLibrary()
individually for every search result — ~100 separate SQLite queries
for a typical search (20 artists × 2 + 20 RGs × 2 + 20 recordings).
This took 7.5s on the 'fast path' that was supposed to take ~5ms.
Added GetPopularityBatch(mbids) — collects all MBIDs across all
entity types and fetches popularity + in_library in a single
SELECT ... WHERE mbid IN (...) query. The library bonus (+10M) is
applied during the batch scan.
Expected Phase 2 improvement: ~7.5s → <10ms.
The search index ready flag is an in-memory bool that resets to false
on every app restart. It was only set to true inside build(), which
runs in a goroutine after SoftScanAllLibraries completes. If the user
searched before the build goroutine started, IsReady() returned false
and the search took the slow path (LB popularity + cross-ref: ~2.3s)
even though the SQLite index had all the data from the previous build.
Now MarkReadyIfPopulated() is called eagerly in NewExploreService —
the index is queryable as soon as the service is constructed, before
any goroutines launch. If the explore_index table has rows, ready=true
immediately.
The shared 1 req/sec MB rate limiter was serializing the 3 concurrent
search calls in Phase 1 to ~3s minimum. Interactive search needs short
bursts (3 calls at once) but not sustained throughput.
Split into two MB rate limiters:
- mbSearchLimiter: burst=3, refill=1/sec — allows one search's 3
concurrent calls to fire immediately, then rate-limits sustained use
- mbBackgroundLimiter: strict 1/sec — gates artist image resolution
in the indexer to avoid 429s during sustained background work
Added NewRateLimiterBurst(n, b) constructor for configurable burst.
Expected Phase 1 improvement: ~3.5s → ~1s (3 calls fire in parallel
instead of serializing through the limiter).
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