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
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.
Added defer/recover to SearchLocalHandler to prevent panics from
crashing the app. Removed the now-unused Wails event emission from
Search() and the runtime import — local results are served via the
HTTP endpoint exclusively.
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.
[unknown] (MBID 125ec42a-...) is a MusicBrainz placeholder for
unattributed recordings. It has thousands of recordings and massive
aggregate listen counts on ListenBrainz, causing it to rank above
real artists in popularity-boosted search results.
Added a blocklist of 8 MB Special Purpose Artist MBIDs (including
[unknown], [anonymous], [data], [dialogue], [no artist],
[traditional], [Church bells], and Various Artists) that are now
filtered from both full search and local index search results.
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.
Previously, the MusicBrainzClient had no proactive rate limiter —
it relied on the musicbrainzws2 library's retry-on-429 backoff.
When the background indexer was resolving artist images (hitting MB
at 1.5 req/sec) and a user search fired 3+ concurrent MB calls,
the combined burst triggered 429s with cascading retries up to 60s.
Now a single shared RateLimiter (1 req/sec) gates all MB API calls:
- MusicBrainzClient search/lookup/browse methods
- ArtistImageProvider fetchMBRels (was on a separate 1.5 req/sec limiter)
The limiter serializes access proactively, preventing 429s entirely.
The musicbrainzws2 retry logic remains as a safety net.
Also split the old shared limiter into separate lbLimiter (for
ListenBrainz + CoverArt) and mbLimiter (for MusicBrainz) so the
two APIs don't block each other.
The cross-referencing phase browses 3 artist discographies via MB API.
When the background indexer is also making MB requests, 429 retries
with exponential backoff can stack up to 20+ seconds.
Added a 3s context.WithTimeout covering both LB popularity and
cross-referencing. If LB popularity exhausts the budget, cross-ref
is skipped entirely. If cross-ref is running when the deadline hits,
the BrowseReleaseGroups calls are cancelled mid-flight.
Worst case search time is now ~7s (4s MB search + 3s slow path)
instead of unbounded.
indexOneArtist only wrote the artist entry when aliases were non-empty.
Artists without MB aliases (common for smaller/niche artists) never got
an entity_type='artist' row, so indexedArtistMBIDs() couldn't see them.
filterUnindexed then treated them as new on every startup, triggering
redundant LB API calls for top-release-groups and top-recordings.
Now the artist row is always written, with aliases as an empty string
when none exist. Subsequent builds will correctly skip these artists.
Each search now logs:
- Phase 0 (index): FTS5 query time + hit count
- Phase 1 (MB): per-entity elapsed + cache hit detection + total wall time
- Phase 2-3 (rerank): whether index was used, elapsed
- Total: breakdown of all phases
Also adds a 4s context.WithTimeout on the MusicBrainz API calls so
a slow MB server degrades to index-only results rather than blocking
indefinitely.
SimilarArtists() was calling api.listenbrainz.org/1/explore/similar-artists/{mbid}
which doesn't exist (404). Changed to labs.api.listenbrainz.org/similar-artists/json
with artist_mbids + algorithm query params — the same Labs API that searchindex.go
already uses for Tier 4 indexing.
Also added snake_case → camelCase wire conversion via existing lbSimilarArtistWire type.
After a library scan completes, OnAllScansComplete now calls
IndexNewArtists() instead of StartIndexBuild(). This skips the
full tier pipeline (sitewide top artists, similar artists, freshness
checks) and only indexes library artists whose MBIDs are not yet
in the search index.
Flow after scan:
1. Query indexed artist MBIDs (fast, in-memory set)
2. Query library artist MBIDs
3. Diff → only new artists
4. Fetch discographies + images for new artists only
The full StartIndexBuild() still runs on initial launch (when
SoftScanAllLibraries finds no work to do) to handle the tier
pipeline with freshness-based refresh. But adding 5 new albums
to your library no longer triggers a 60-minute index rebuild.
Root cause: OnAllScansComplete called InvalidateIndexDiscographies()
which deleted the discog_built timestamp. SoftScanAllLibraries
triggers a scan whenever file counts differ (even by 1 file), so
on most launches the hook fired and wiped the cache.
The invalidation was unnecessary — Tiers 2-4 are already incremental.
filterUnindexed skips artists that are already indexed, so new
library artists with freshly-populated MBIDs get picked up naturally
without forcing a full rebuild.
InvalidateIndexDiscographies is kept as a public API for manual
rebuild (future UI button) but no longer called automatically.
Combined with per-tier timestamps from the previous commit, the
index build now correctly skips completed tiers on restart.
Root cause: discog_built meta timestamp was only written after ALL
of Tiers 2-4 completed. If the app was closed mid-build (context
cancelled), the timestamp was never set, so the next launch re-ran
everything from Tier 2.
Fix: track each tier independently with tier2_built, tier3_built,
tier4_built timestamps. Each tier's timestamp is written immediately
after it completes, so progress survives app restarts.
On next launch, already-completed tiers are skipped. Combined with
the incremental filterUnindexed logic, a build interrupted at
Tier 4 with 80% of similar artists done will resume from the
remaining 20%.
Also adds getLibraryArtistMBIDs helper for Tier 4 when Tier 3
was skipped (needs library MBIDs without re-running Tier 3).
InvalidateDiscographies now clears all three per-tier timestamps.
Three changes to reduce subsequent index build times:
1. Positive image/rels cache TTL: 30 days → 365 days
Artist url-rels and resolved image URLs rarely change.
Already-indexed artists make zero API calls on rebuild.
2. Negative cache (misses) stays at 30 days so new images
are discovered within a month of being added upstream.
3. MB rate limiter for background indexing: 1.0 → 1.5 req/s
url-rels lookups are lightweight; 1.5/s is well within
what MB handles (Picard and Kodi both use similar rates).
Cuts the MB-bound portion of index build by ~33%.
Also adds NewRateLimiterF for fractional rates and caches
MB rels fetch failures (30-day miss TTL) to avoid retrying
unreachable artists every build.
TheAudioDB (theaudiodb.com) added as source #1, between fanart.tv
and Wikimedia. Uses free public API key (2) with MBID-based lookup
so matching is guaranteed — no name-based search ambiguity.
Fetches up to 4 images per artist: thumb (portrait), fanart1-3
(wider shots). Results cached 30 days in explore_cache.
Source priority order is now:
0. fanart.tv artistthumb
1. TheAudioDB thumb + fanart
2. MusicBrainz direct image rels (Wikimedia Commons)
3. Wikidata P18 (Wikimedia Commons)
4. Wikipedia lead image
Fanart.tv artistthumb images are now the primary source for artist
photos. Up to 5 thumbnails fetched per artist (sorted by community
likes). Falls through to Wikimedia/Wikidata/Wikipedia if fanart.tv
has no images for the artist.
API key handling per fanart.tv project key terms:
- Project key loaded from FANART_TV_API_KEY env var (or build-time
ldflags via fanartTVProjectKey variable)
- Users can provide their own personal key via FANART_TV_PERSONAL_KEY
env var for higher rate limits (sent as client_key parameter)
- Results cached 30 days in explore_cache
- No bulk downloading — only fetched per-artist during index build
Source priority order:
1. fanart.tv artistthumb (best quality, community-curated)
2. MusicBrainz direct image rels (Wikimedia Commons)
3. Wikidata P18 (Wikimedia Commons)
4. Wikipedia lead image
Attribution: fanart.tv images are CC-BY-SA, contributed by the
fanart.tv community (https://fanart.tv).
Complete rewrite of the artist image pipeline:
STORAGE:
- Migration 16: artist_images table tracking source, URL, path,
primary flag, dimensions per image (up to 10 per artist)
- Directory structure: artist-images/{mbid[:2]}/{mbid}/ with
primary.jpg + primary_sm.jpg/_md.jpg/_lg.jpg thumbnails
- Miss marker (.miss file) prevents re-fetching artists with no image
SOURCES (priority order):
1. MusicBrainz direct image relations (Wikimedia Commons)
2. Wikidata P18 property (Wikimedia Commons)
3. Wikipedia lead image (NEW — via Wikidata sitelinks → Wikipedia API)
Each source is checked, deduplicated, and the first available
image becomes the primary with sm/md/lg thumbnail generation
(100px/200px/400px, matching cover art tier sizes).
ASSET SERVING:
- /artist-images/ path registered with Wails asset handler
- Serves files via http.FileServer from the artist-images directory
- Same pattern as /covers/ for cover art
ARTIST MODEL:
- Artist struct gains ImageSmall/ImageMedium/ImageLarge fields
- resolveArtistImages does bulk MBID lookup → disk stat for each
- Populated in GetAllArtists and GetAllArtistsByLibrary
GRID VIEW:
- artists-view uses model URLs directly (no more base64 data URLs)
- Size selection based on imageSize * devicePixelRatio (like cover-grid)
- Removed batch GetArtistImages call and in-memory cache — no longer needed
Replace per-artist sequential GetArtistMBID + GetArtistImageURL
calls (2 Wails round-trips × N artists) with a single batch
GetArtistImages(names[]) call that:
1. Resolves all names → MBIDs via AllArtistMBIDs() (one DB query)
2. Checks disk cache for each MBID via GetCachedImage (no network)
3. Returns map[name]→dataURL in one Wails bridge round-trip
Only returns already-cached images from the disk cache populated
by the index build. No network fetches triggered — artists whose
images haven't been cached yet keep the initial letter fallback
until the index build resolves them in the background.
Result: all cached artist images appear simultaneously on first
render instead of loading one-by-one over several seconds.
Migration 15: add in_library and is_similar INTEGER columns to
explore_index. Backfills in_library from existing library MBIDs.
Search index FTS5 query now includes personalization in scoring:
ORDER BY bm25(...) - (ln(pop+1) * 1.5)
- (in_library * 3.0) - (is_similar * 1.5)
For equal text+popularity scores:
- Library artist beats unrelated by 3.0 points
- Similar artist beats unrelated by 1.5 points
- Library > Similar > Neither
Tier 3 (library) entries get in_library=1 via markInLibrary.
Tier 4 (similar) entries get is_similar=1 via markSimilar.
MB result reranking (boostWithIndexPopularity) adds a 10M
popularity bonus for library artists, ensuring they always
rank above non-library artists with equal text relevance.
The search index's Tier 3 (library artists) depends on MBIDs from
the artists table. If the index built before a rescan populated
those MBIDs, library artists like Flatbush Zombies wouldn't be
indexed — they're not in the sitewide top 1000 and their name
didn't match via fuzzy matching.
Fix: OnAllScansComplete hook now calls InvalidateIndexDiscographies
before StartIndexBuild. This clears the discog_built timestamp so
Tiers 2-4 re-run incrementally, picking up any new library artists
whose MBIDs were just populated by the scan.
The rebuild is incremental — only artists not already in the index
get their discographies fetched.
Two changes:
1. Rerank MB results using index popularity (no API calls):
When the index is ready, boostWithIndexPopularity looks up each
MB result's MBID in the local index to get cached listen counts,
then reranks using the same blended score formula. This was
previously skipped entirely for speed, leaving MB results sorted
by text relevance only — obscure exact matches beat popular
partial matches.
2. Increase popularity weight across both scoring systems:
- Blended score: 60% popularity / 40% relevance (was 40/60)
- FTS5 index: ln(pop+1) * 1.5 factor (was 0.5)
Result: 'flatbush' → Flatbush Zombies (97) beats 'Flatbush'
nobody (61). Popular artists with partial name matches now
reliably outrank obscure exact matches.
FullRescan calls scanInternal directly (not via startScan) to get
ScanMetrics back. But startScan is what calls drainQueue when it
finishes. Without drainQueue, any libraries queued via ScanLibrary
sat in the queue forever — scanActive remained true, the queued
library never scanned.
Fix: call drainQueue in a goroutine after queuing the remaining
libraries. This processes the queue sequentially and eventually
sets scanActive=false + fires OnAllScansComplete.
FullRescan scans the first library directly, then queues the rest.
The PostScan hook was restarting the index build after the FIRST
library, which starved the queued libraries for DB access — they
never scanned, leaving the library with only 6 tracks.
Fix: move StartIndexBuild to the OnAllScansComplete hook, which
fires when drainQueue finds no more libraries to scan. This ensures
ALL libraries finish scanning before the index build starts.
For startup soft scans: if no scans were queued (library unchanged),
start the index build directly. If scans WERE queued, the hook
handles it.
Added OnAllScansComplete callback to ScanHooks. Called from
drainQueue when the scan pipeline goes idle.
Track details dialog now shows:
1. Green checkmark badge next to the track title when the recording
has a MusicBrainz ID (hover: 'Metadata verified by MusicBrainz')
2. MusicBrainz section at the bottom with clickable MBID links for:
- Recording (track) → musicbrainz.org/recording/{mbid}
- Release Group (album) → musicbrainz.org/release-group/{mbid}
- Artist → musicbrainz.org/artist/{mbid}
Links open in the system browser. Only shown for entities that
have MBIDs from audio file tags.
Backend: GetTrackMBIDs(filePath) Wails binding queries recording,
release_group, and artist mbid columns via a single JOIN query.
Frontend: loaded async when the dialog opens, non-blocking.
Migration 14: add aliases TEXT column to explore_index, rebuild FTS5
with 3 columns (title, artist_name, aliases), recreate sync triggers.
Clears index build timestamps to force alias population on next build.
Artist image provider fetches inc=url-rels+aliases (single call, no
extra cost). GetAliases() extracts alias names from cached MB rels.
indexOneArtist stores aliases as space-separated text after image
resolution populates the cache.
Search query now uses BM25 blended scoring:
ORDER BY bm25(fts, 3.0, 1.0, 0.5) - (ln(popularity+1) * 0.5)
Column weights: title=3.0, artist_name=1.0, aliases=0.5
- Title matches score 3x higher than artist name matches
- Alias matches are helpful but don't dominate
- Popularity is a log-scaled boost, not an override
- Exact title match on niche entity beats weak match on mega-popular
Enables: 'rhcp' → Red Hot Chili Peppers, 'gnr' → Guns N' Roses,
'sabbath' → Black Sabbath (once index build runs with aliases).
The search index build and library scan both write to the same
single-connection SQLite DB. The index build runs continuous batch
transactions that can starve the scan's clearLibraryTables call,
causing the scan to silently hang without logging.
Fix: decouple index build from SetContext. The build now starts
AFTER the soft scan completes on startup. For full rescans, the
PreClear hook stops the index build, and PostScan restarts it.
Also made StartBuild/StopBuild safe for multiple calls:
- StartBuild is a no-op if already running
- StopBuild is a no-op if not running (no deadlock on done channel)
- done channel created per-build, not in constructor
The dhowden/tag library's Comm.Text and UFID.Identifier fields
include trailing null bytes from the C-style strings in the ID3v2
binary format. strings.TrimSpace doesn't strip \x00, so MBIDs
stored from MP3 files had invisible null bytes appended.
This caused WHERE mbid = ? queries to fail — the stored value
'uuid\x00' didn't match the clean 'uuid' from search results.
FLAC files (Vorbis comments with plain strings) were unaffected.
Fix: use strings.TrimRight with explicit \x00 in the cutset.
Requires a full rescan to fix existing corrupted MBIDs.
The dhowden/tag library returns ID3v2 TXXX frames as *tag.Comm
structs (key='TXXX_N', Description='MusicBrainz Artist Id',
Text='uuid'), not plain strings. Vorbis comments are plain strings
(key='musicbrainz_artistid', value='uuid').
Previous code only handled the string case — all MP3 files silently
got empty MBIDs. Now handles three value types:
- string: Vorbis comments (FLAC/OGG) — key is the tag name
- *tag.Comm: ID3v2 TXXX frames (MP3) — Description is the tag name
- *tag.UFID: ID3v2 UFID frame (MP3) — MusicBrainz recording ID
Requires a full rescan to backfill MBIDs for MP3 files.