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.
The full Search() call was blocking the render even after SearchLocal
returned results — both awaits ran in the same async function, and
Wails may serialize Go calls preventing the microtask yield from
triggering a render.
Split executeFullSearch into a separate async method invoked with
void (fire-and-forget). executeSearch now returns after SearchLocal
completes, letting Lit render the local results immediately. The
full pipeline results replace them when ready.
Three fixes:
1. SearchLocal no longer applies minBlendedScore filter — index hits
use scalePopularity scores that aren't comparable to blended
MB+LB scores. This prevents artists from appearing in local
results then disappearing when the full pipeline replaces them
with score-filtered results.
2. Top section columns now render independently — tracks show as
soon as they load, top releases show their own loading state or
appear when ready. Previously the entire section was blocked
until both finished loading.
3. Top releases column shows a loading spinner while its data is
still fetching, rather than being invisible.
Replaced the date-sorted release group approach with a dedicated
TopReleaseGroupsForArtist LB API call that returns releases ranked
by total listen count (popularity). Added LBTopReleaseGroup type
and Wails bindings.
Restyled the top-releases cards to match the library album view:
square cover art on top with title and type below, centered text,
auto-filling the available width with even spacing.
Added SearchLocal() — queries only the FTS5 index with no network
calls. The frontend now calls SearchLocal first, renders those
results immediately (clearing the loading spinner), then fires the
full Search() pipeline in the background. When full results arrive,
they replace the local hits seamlessly.
For indexed queries this means sub-100ms first results regardless
of how slow MB/LB are. Unindexed queries still show the loading
spinner until the full pipeline completes.
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.
Replace full album cards (square art + text below) with compact
horizontal cards: 44px art thumbnail on left, title + year on right.
Cards fit ~56px tall each, so a 2×2 grid of 4 releases aligns with
the height of 5 track rows in the left column.
Replace full-width top tracks with a split layout: top tracks on the
left (5 default), top releases grid on the right (2×2 = 4 default).
A 'Show more' toggle below both columns expands to 10 tracks and 8
releases. Top releases are sorted newest-first from the existing
discography data.
The full discography section remains below for browsing by type.
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.
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.
The local artists grid now shows Wikimedia artist photos in the
circular avatars. Each visible artist card triggers an async load:
GetArtistMBID(name) → GetArtistImageURL(mbid) → cached data URL.
Images load progressively — the initial letter placeholder shows
immediately, replaced by the photo when it resolves. Results are
cached in-memory per session. Artists without MBIDs or without
Wikimedia photos keep the initial letter fallback.
Uses the same disk-cached artist image pipeline as the explore
views — no extra network requests for previously resolved artists.
Remove the mixed Top Results section that showed a blend of artists
and recordings. Search results now show three clean categories:
Artists, Albums, Tracks — each sorted by their own scoring.
Removed: ScoredItem interface, TOP_RESULTS_COUNT, getTopResults,
renderTopResults, renderTopCard, and .top-card CSS.
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.
Add CheckLibraryMBIDs call after artist data loads. Checks all
release group MBIDs from the discography against the local library.
Matching albums show a green 'In Library' badge in the album meta
section alongside the year.
Fires after Promise.allSettled completes (same timing as artist
image fetch). Non-blocking — badge appears on re-render when the
check completes.
Three features wired together:
1. 'In Library' badges on explore search results:
CheckLibraryMBIDs Wails binding batch-checks which search result
MBIDs exist in the local library. Green badges render on matching
artist cards and album cards.
2. Artist images on local artist-details page:
Local artist pages now call GetArtistMBID(name) to resolve the
MBID from tags, then GetArtistImageURL(mbid) to fetch the cached
Wikimedia photo. Falls back to initial-letter avatar.
3. Tier 3 search index uses direct MBIDs from tags:
buildTier3Library now reads artists.mbid column (from audio tags)
for direct MBID matching, falling back to name matching for
untagged artists. Eliminates false matches and catches artists
that name matching misses.
Two changes:
1. Artist image disk cache: ArtistImageProvider now fetches the
actual image bytes from Wikimedia Commons and caches them on
disk (~/.local/share/yellowjacket/artist-image-cache/{mbid}.jpg).
Returns base64 data URLs, same pattern as CoverArtProxy.
First lookup: resolve URL via MB/Wikidata + fetch image (~2s).
Subsequent: instant from disk cache.
404s cached as empty files to avoid re-fetching.
2. Top results artist photos: the Top Results section now shows
artist images from the artistImageCache, same as the Artists
section. Also shows englishName in the top card display name.
Artist image resolution was hitting musicbrainz.org with 10
concurrent unthrottled requests per search — enough to trigger
MB's rate limit rejection. Two fixes:
Backend: add dedicated 1 req/s RateLimiter for MB url-rels fetches
in ArtistImageProvider. Each fetch waits on the limiter before
the HTTP call. Results are cached 30 days so repeat lookups are
instant.
Frontend: switch loadArtistImages from concurrent fire-all to
sequential await loop. Each artist image loads one at a time,
images appear progressively as they resolve instead of all
failing from rate limit rejection.
Add ArtistImageProvider that resolves artist MBIDs to photo URLs:
1. MB url-rels 'image' type → extract Commons filename → thumb URL
2. MB url-rels 'wikidata' type → Wikidata P18 property → thumb URL
3. No image → falls back to initial-letter avatar
Wikimedia Commons thumb URLs constructed via MD5 hash bucketing
(standard Commons URL scheme). Results cached in explore_cache
with 30-day TTL — subsequent lookups are instant.
Frontend: search results and artist detail page show artist photos
in the circular avatar when available. Images load async and
replace the initial-letter fallback on arrival. Artist detail page
fires the image fetch alongside the other 4 parallel data loads.
Architecture supports adding more sources (fanart.tv, etc.) by
extending the resolve() method's source chain.
Replace per-card GetThumbnail calls (10 round-trips) with a single
GetThumbnails batch call that fetches all visible album thumbnails
in one Wails bridge round-trip.
Backend GetThumbnails accepts []ThumbnailRequest and returns
map[mbid]→dataURL. Each request still checks library → disk cache
→ CAA in order, but the bridge overhead is 1 call instead of 10.
Frontend fires loadThumbnails() once after search results arrive.
Album cards render immediately with CAA URL fallback, then re-render
once the batch resolves with cached/local data URLs.
CoverArtProxy now checks three sources in order:
1. Local library (instant) — matches by album+artist name against
the release_groups/cover_art tables. Albums the user already
owns show their local cover art immediately.
2. Disk cache (instant) — previously fetched CAA thumbnails.
3. Cover Art Archive (network) — fetches and caches to disk.
Library index is built once on first access (sync.Once) from a
single SQL query joining release_groups → cover_art → artists.
Keyed by lowercased 'album\x00artist' for exact name matching.
GetThumbnail now takes (mbid, albumName, artistName) so the proxy
can check the library before falling back to CAA. Frontend passes
the album title and artist credit from the search result.
Add CoverArtProxy that fetches cover art from CAA, caches the image
bytes on disk (~/.local/share/yellowjacket/cover-art-cache/), and
returns base64 data URLs via the GetThumbnail Wails binding.
First load: fetches from CAA (rate-limited), caches to disk.
Subsequent loads: instant from disk cache, no network.
404s: cached as empty files to avoid re-fetching.
Frontend explore-view loads thumbnails async via GetThumbnail()
calls that fire during render. Cached thumbnails appear as data
URLs directly in img src, bypassing the browser's HTTP stack.
Uncached thumbnails fall back to the CAA URL while the proxy
fetches in the background, then re-render with the cached version.
Also stores caa_id and caa_release_mbid in the search index's
extra_json for future direct Internet Archive URL construction.
Extract primary English alias from MusicBrainz artist data when the
canonical name uses non-Latin script (CJK, Cyrillic, etc.). Display
it as the primary name in search results and artist detail header,
with the native script name as a subtitle beneath.
Example: 山下達郎 now shows 'Tatsuro Yamashita' prominently with
'山下達郎' as a subtitle. Artists with Latin names are unchanged.
Don't fire search for single-character queries — show 'Keep typing…'
instead. Cap rendered results at 10 per section (artists, albums,
tracks) to reduce noise. Top results already capped at 3.
Use MusicBrainz secondaryTypes to distinguish studio albums from
compilations, soundtracks, live albums, remixes, etc. Albums with
no non-studio secondary types show under 'Albums'; everything else
under 'Other Albums'. EPs and Singles remain their own sections.
Section order: Albums → EP → Single → Other Albums → ...rest.
- Column accessor shows '0' instead of empty string for unplayed tracks
- recordPlay emits TrackMetadataChanged event after updating DB
- Frontend library store invalidates on that event, refreshing play counts
Backend validation rejected 'playCount' as unknown column ID, silently
preventing it from being enabled or persisted. Added ColPlayCount to
the tracklist package's constant list and AllColumnIDs slice.
Backend:
- GetAllTracksWithFullMetadata queries now select play_count and last_played
- mapTrackRow accepts and passes through PlayCount/LastPlayed
- PlayCount + LastPlayed added to library.Track struct
- sqlcgen Row types updated with new fields
Frontend:
- PlayCount + LastPlayed added to library.Track TypeScript model
- 'Plays' column added to track-list column definitions (60px, right-aligned, sortable)
Queries without play data (search, genre, album) pass 0/empty defaults.
Backend:
- Added play_count and days_since_played to rule engine field whitelist
- days_since_played uses julianday() expression with COALESCE for NULL handling
- Never-played tracks (NULL last_played) match 'greater_than' but not 'less_than'
- Added PlayCount + LastPlayed to library.Track struct
- Evaluate query selects play_count and last_played from track_metadata
- Added play_count to sort field options
Frontend:
- Added play_count and days_since_played to field and numeric field lists
- Added play_count to sort options
All 49 rule engine + 15 service tests pass unchanged.
- Grid rows use align-items: stretch so all cells share the select's height
- Combobox host, wrapper, and input all set height: 100% to fill the cell
- Remove button uses align-self: center to stay centered instead of stretching
Details view:
- Skip evaluation on new playlists (was returning all 25k tracks)
- Go straight to editor on auto-edit instead of awaiting loadTracks
Editor:
- Default sort to Random instead of None, remove None option
- Hide sort direction button when sort is Random
- Fixed-width field (160px) and operator (140px) columns, value fills remaining space
- Preview fills remaining vertical space (flex layout) instead of fixed 200px max-height
- Preview scrolls independently while rules/options stay pinned
Combobox:
- Accept free-form text on blur and Enter — no longer requires selection from dropdown
- Enables typing values like 'indie' that may not be exact DB entries
Backend:
- Case-insensitive text matching: COLLATE NOCASE on is/is_not/is_any_of operators
- Applies to both regular fields and genre subqueries
- LIKE operators were already case-insensitive (SQLite default)
- track-list: branch on selection count, resolve tracks from this.tracks
- cover-grid: branch on selection count, resolve from expandedTracks
- queue-panel: branch on indices count, resolve via libraryStore.getCachedTracks
- playlist-details: branch on selection count, resolve via libraryStore
- Each view determines coverArt/coverArtMixed state and calls showBatch()
- showBatch() API for multi-track entry with merged field values
- Three-state field model: keep/set/clear via dirty tracking in editValues
- Confirmation dialog showing field changes before batch save
- Progress bar with live counter wired to BatchWriteProgress events
- Cancel button calling CancelBatchWrite during batch write
- Results view with success/failure counts and expandable failure details
- Batch cover art: pick, preview, or clear for all selected tracks
- Post-save data refresh returning to updated summary view
- Single-track show() path unchanged (batchMode = false)
- Add BatchFailure and BatchResult types for structured batch outcomes
- Add cancelBatch channel and suppressEvents flag to TagWriter struct
- Add CancelBatchWrite method for mid-batch cancellation from frontend
- Add BatchWriteTrackTags method processing tracks sequentially
- Emit BatchWriteProgress event per-track with current/total/succeeded/failed
- Suppress per-track TrackMetadataChanged; emit single event after batch
- Wails bindings auto-generated for BatchWriteTrackTags and CancelBatchWrite
- tagwriter namespace with BatchResult/BatchFailure in models.ts