Commit Graph
757 Commits
Author SHA1 Message Date
yonlu 649e5bde9b fix: use 100K reference floor for popularity normalization
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.
2026-03-30 03:32:24 -04:00
yonlu 45e87b938e debug: log artist ranking before filterAndCap 2026-03-30 03:29:55 -04:00
yonlu 67d99a985e fix: simplify filtering — let ranking + cap handle zero-pop artists
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.
2026-03-30 03:27:09 -04:00
yonlu e8fdf8dc54 fix: library bonus as post-normalization additive, not pop contamination
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.
2026-03-30 03:23:45 -04:00
yonlu 5b8034edca fix: distinguish unknown popularity from confirmed zero
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.
2026-03-30 03:14:53 -04:00
yonlu 33087974fa feat: popularity-scaled filter threshold replaces hard cutoffs
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).
2026-03-30 03:12:38 -04:00
yonlu fc2a50e853 fix: revert to minBlendedScore=25, add separate zero-popularity filter
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.
2026-03-30 03:06:09 -04:00
yonlu 071e0cb490 fix: raise minBlendedScore from 25 to 50 to filter zero-popularity artists
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.
2026-03-30 03:00:54 -04:00
yonlu 4567b21e79 tweak: bump starts-with tier bonus from +8% to +12%
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%).
2026-03-30 02:56:02 -04:00
yonlu 354c9caf5a fix: penalize substring matches (tier 2) with -5% multiplier
'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.
2026-03-30 02:53:55 -04:00
yonlu e481968f56 fix: switch tier bonuses from additive to percentage-based
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.
2026-03-30 02:43:16 -04:00
yonlu 80d7123478 feat: soft tier bonuses + library bonus on slow path
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.
2026-03-30 02:39:25 -04:00
yonlu 52632b470c feat: AND + wildcard Lucene queries, fuzzy library search, limit 50
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.
2026-03-30 02:26:55 -04:00
yonlu 920a96d9b1 fix: release group ranking uses blended score + artist credit matching
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.
2026-03-30 02:07:49 -04:00
yonlu 636020d1bc fix: track durations showing 0:00 — stop merging index recordings
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.
2026-03-30 01:53:42 -04:00
yonlu 713b2b54af fix: always disambiguate same-named artists, remove score guard
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.
2026-03-30 01:49:07 -04:00
yonlu 6adfd5a68c fix: disambiguate same-named artists via targeted LB popularity lookup
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.
2026-03-30 01:46:27 -04:00
yonlu 0a72455f37 fix: sort same-tier artists by blended score, not original MB score
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.
2026-03-30 01:41:25 -04:00
yonlu b613e03398 fix: name-match boost prevents popular unrelated artists from dominating
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.
2026-03-30 01:27:25 -04:00
yonlu 2f211eef58 fix: remove main-panel padding gap + add box-sizing for scroll cutoff
Two layout issues:
1. Gap above content: .main-panel had padding: 0.25em which created
   a visible gap above views. Removed — views control their own
   internal padding.

2. Scroll cutoff at bottom: .main-panel > * had height: 100% but
   no box-sizing: border-box. Views with their own padding (like
   config-page) overflowed because padding added to the 100% height.
   Added box-sizing: border-box to the global rule so padding is
   included in the height calculation.
2026-03-30 00:54:07 -04:00
yonlu 078db73e75 feat: show release year instead of type on top release cards
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.
2026-03-30 00:20:20 -04:00
yonlu 1f9b3452fa fix: show 2 top releases by default, 4 when expanded 2026-03-30 00:10:26 -04:00
yonlu 7addee575a fix: top releases grid always 2×2, fills available column height
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.
2026-03-30 00:01:13 -04:00
yonlu 3d0349a427 perf: batch popularity lookups in single SQLite query (100+ → 1)
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.
2026-03-29 23:55:26 -04:00
yonlu 8a138797dd fix: mark search index ready at service creation, not just during build
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.
2026-03-29 21:56:52 -04:00
yonlu 6274b74940 fix: show sub-ms precision for Phase 0 timing in search logs 2026-03-29 21:54:09 -04:00
yonlu fd29a6c99d perf: burst-friendly MB rate limiter for interactive search
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).
2026-03-29 21:52:48 -04:00
yonlu cd0e7cea28 fix: preserve local search results when full search returns empty
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.
2026-03-29 20:45:14 -04:00
yonlu 92cd6f268c feat: wire detail pages to explore cache for instant hydration
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)
2026-03-29 19:03:30 -04:00
yonlu 8096b28d17 feat: MBIDs in library models + local-first search + explore cache
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
2026-03-29 18:54:22 -04:00
yonlu 1999fdb0f4 fix: instant search via frontend library cache — no Go calls at all
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.
2026-03-29 18:02:59 -04:00
yonlu 33d42febf1 fix: add panic recovery to search-local handler, remove unused event emission
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.
2026-03-29 17:52:33 -04:00
yonlu c3e16a9fd2 fix: bypass Wails RPC entirely for local search via HTTP endpoint
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.
2026-03-29 17:48:01 -04:00
yonlu c22066e5d5 fix: use Wails event for instant local search results
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.
2026-03-29 17:42:10 -04:00
yonlu 0ce53fb87a fix: local search results now render before full pipeline completes
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.
2026-03-29 17:34:40 -04:00
yonlu 943b126f98 fix: filter MusicBrainz Special Purpose Artists from search results
[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.
2026-03-29 17:29:53 -04:00
yonlu 50df4676f8 fix: artist flash on search + independent top section columns
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.
2026-03-29 17:23:13 -04:00
yonlu cef6709d9a feat: top releases sorted by popularity with library-style album cards
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.
2026-03-29 17:12:30 -04:00
yonlu 4138fe593d feat: instant local search results while full pipeline runs
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.
2026-03-29 16:45:25 -04:00
yonlu a93a83c4d2 fix: shared MB rate limiter prevents search/indexer 429 collisions
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.
2026-03-29 16:37:51 -04:00
yonlu 0f2a82256c fix: cap slow-path search at 3s to prevent 20s+ searches
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.
2026-03-29 16:34:02 -04:00
yonlu fb3a340fa2 fix: compact top-releases cards to match track list height
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.
2026-03-29 16:26:02 -04:00
yonlu a312c3d2cf feat: two-column top section — tracks + releases side-by-side
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.
2026-03-29 16:21:01 -04:00
yonlu 7472e31545 fix: always write artist row in index to prevent redundant re-indexing
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.
2026-03-29 16:11:30 -04:00
yonlu 10d016f9d7 perf: add LB popularity vs cross-ref breakdown to slow path logging 2026-03-29 16:04:31 -04:00
yonlu 261d506e65 perf: add per-phase timing logs and 4s MB timeout to search pipeline
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.
2026-03-29 16:02:43 -04:00
yonlu 4e9df89f93 feat: add artist images to similar artists section
After similar artists load, fetch images for each via GetArtistImageURL
in parallel. Images pop in as they resolve — letter avatars remain as
fallback for artists without images. Each image is cached on disk after
first resolution, so subsequent views are instant.
2026-03-29 15:53:45 -04:00
yonlu 2825128456 fix: use Labs API for similar artists (was hitting nonexistent endpoint)
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.
2026-03-29 14:58:46 -04:00
yonlu d47270e0b8 perf: lightweight post-scan indexing — only index new artists
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.
2026-03-29 13:04:21 -04:00
yonlu 91214f9d54 fix: stop invalidating discography cache after every library scan
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.
2026-03-29 12:58:05 -04:00