`applySchema` is CREATE ... IF NOT EXISTS and there is no migration
chain, so a *changed* table never migrates: the statement silently
no-ops against the old shape. Two plans had already landed on that, and
neither showed up in a test because a fresh install is perfectly
healthy.
- 014 added `total_tracks` to explore_index and to `indexRowFields`,
the projection every explore read uses, so every search, browse,
artist page and album page failed with "no such column: total_tracks"
on any database that already had a catalog.
- 013 reshaped audio_files, so applySchema could not run at all and the
app did not open.
staleshape.go runs before applySchema and drops what disagrees, so the
create is a create. It parses sql/schemas/ for the expectation rather
than writing the column list down a second time, and it notices a
changed *type* as well as a missing column — 013 moved mbid TEXT to
BLOB, which no ALTER could express and which SQLite will not coerce, so
a query against 16 raw bytes returns no rows rather than an error.
Only Authored tables are exempt. Cache is rebuildable by definition,
Owned is what a rescan rebuilds (plan 013's stated "delete and
rescan"), and a table the schema no longer describes at all goes too --
013 left seven behind plus schema_migrations.
Three things in it are load-bearing, and each was a bug first:
- The parser read `UNIQUE(mbid)` as a column, which made a healthy
catalog look stale. That would have retired it on every launch and
cost every user an artifact download per start.
- The drops are one transaction with defer_foreign_keys. Those legacy
tables reference each other, so any order fails on whichever goes
first; turning foreign keys off instead would suppress
playlist_tracks.audio_file_id's ON DELETE SET NULL and leave entries
pointing at ids a rescan reissues to *different songs*. Nulled
entries are empty; stale ones are wrong, and wrong quietly.
- The order is sorted, so a failure reproduces. Map order is random,
and the foreign-key bug passed its own regression test on two runs in
three until the order was fixed.
Verified against a real pre-013 install: it opens, its 22 playlists
survive, 1,887 linked playlist entries become 0 rather than dangling,
and the legacy tables are swept.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.
## The local library is shaped like files, not like MusicBrainz
`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.
- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
`LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
`pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
orphaned recordings, 216 release groups and 260 artists that library
carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
view, one row type, one mapper. Nine hand-rolled copies had drifted
far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
are squashed away, along with the drift between them that had sqlc
generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
had been assembling the old FK chain each in its own order.
## The catalog stores its ids as bytes
`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.
- `backend/explore/mbid.go` is the only place the encoding is known;
everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
rather than silently returning no rows, since SQLite does not coerce
between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
on the way in, so the artifact already published keeps working and no
format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
list, and `TestStoredEncodingRoundTrips` sweeps every read path.
## An album page that says how much of the album is yours
- One question, asked once: is there a file. `filePaths` is filled by a
single batched lookup when the tracklist settles, and the badge, the
Play count, the dimmed rows and every menu item read it — replacing
four claims of decreasing confidence that could show a green tick on
an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
groups) and on `audio_files` from tags that have always carried it:
a complete MBID-matched album now makes no catalog call at all, where
it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
and the version list marks the release you own rather than standing a
synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
instead of the owned ones wearing a green tick and a legend.
## Caches and cover art get ceilings
- Only the three tiers of a cover are stored; the full-resolution copy
nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
the same install held art for 5,770 artists in a 1,301-artist
library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
directory, so it deleted the rows that were the only record of the
files it left behind. `explore.ArtistImageDir` is that layout's one
definition now.
## The autotag queue asks whether there is work
`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.
## Phantom playlist tracks resolve in place
An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.
## Playing a track plays the list it is in
Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
The post-scan backfills share MusicBrainz's rate limiters with every
page the user can open, and both were FIFO — so a thousand-artist
enrichment put an album page behind an hour of queued work.
WithBackgroundLane/WithBackgroundPriority add a slower second lane: a
marked wait takes no token while any interactive wait is outstanding.
It is a context marker rather than a parameter because a backfill calls
the same client methods a detail page does. A long backfill also has to
be visible and stoppable, so jobs.KindCatalogEnrich registers both with
progress and cancel — after the work is counted, since these passes are
a no-op on every launch once the library is covered.
What it does not fetch is the point. It ran for hours against a
900-artist library and marked nothing, because three of the four things
it did per artist were work nobody asked for: similar artists, which
the artist page already resolves on view, and a full GetArtistImage
(fanart.tv, TheAudioDB, Wikidata, Wikipedia, ten portraits) reached
only to warm the MB artist lookup EnsureArtistRels does alone. It was
also serial across artists while every limiter is per-host and idle.
The marks are a table rather than more explore_index columns, because
artifactimport merges by column list and a flag added there is a second
place to remember. BrowseReleaseGroupsAll pages to exhaustion, where
the old call silently cut a prolific artist at 100 release groups.
One portrait is downloaded now; the rest are remembered as URLs.
resolveAllSources downloaded every candidate, up to ten, full size,
while nothing reads anything but primary.jpg — 5.3 GB measured on a
real cache, 4.1 GB of it unreachable. OrphanedArtistImagesJob is why
that survived: it joined the bare MBID onto the images directory, but
artist directories are sharded under a two-character prefix, so it
named a path that never existed and deleted the rows that were the only
record of the files it left behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
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
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>
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
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
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.
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).
Add nullable TEXT mbid column to artists, release_groups, and
recordings tables. Partial indexes on each (WHERE mbid IS NOT NULL)
for fast MBID lookups without bloating the index for rows without
MBIDs.
Enables linking local library entities to MusicBrainz/ListenBrainz
explore data, artist image sharing, and 'In Library' badges.
Add explore_index table (entity_type, mbid, title, artist_name,
artist_mbid, popularity, extra_json) with a unique index on
(entity_type, mbid). FTS5 virtual table explore_index_fts backed
by the content table with auto-sync triggers for insert/update/delete.
explore_index_meta table tracks build timestamps.
Migration 10:
- play_history table (audio_file_id FK, played_at DATETIME, CASCADE delete)
- play_count + last_played columns on audio_files (denormalized)
- Recreated track_metadata VIEW with play_count and last_played columns
Play recording:
- queue.recordPlay() inserts play_history row + updates denormalized columns
- Called from OnPlaybackFinished after queue advance completes
- Mutex released before DB write to avoid MaxOpenConns(1) deadlock
- Natural finish only — skip/stop does not count
Tests:
- TestMigration10PlayHistory: schema, columns, VIEW, round-trip verification
- All 49 smart playlist + 15 service + existing DB tests still pass
- Add phantom_file_path column to playlist_tracks (migration 7)
- Store original file_path during RemoveLibrary phantom metadata population
- After each successful scan, UPDATE phantom tracks whose phantom_file_path
now matches an audio_files row, re-linking them and clearing phantom metadata
- Update schema file, sqlc generated code, and database test for new column
- Add backupDatabase() for timestamped .db file backup before migration
- Add migration6MultiLibrary() with all 14 steps: FK OFF, create libraries table,
insert default library from TOML config, add library_id to audio_files, rebuild
playlist_tracks with SET NULL FK and 6 phantom columns, backfill phantom metadata,
recreate track_metadata VIEW with library_id, FK ON, clean TOML config
- Add readLibraryDirFromTOML() and removeLibraryDirFromTOML() helpers
- Update runMigrations signature to accept dbPath for backup
- Add sentinel library row in NewTestDB for FK constraint satisfaction
- Migration 5 rebuilds release_groups with UNIQUE(name, album_artist_credit_id)
- Drops and recreates track_metadata VIEW during table rebuild
- Temporarily disables FK checks for safe table rebuild
- Entity cache now keys by album name + artist credit ID
- Update tests to use composite cache keys
- Extract inline foreign_keys PRAGMA into shared applyPRAGMAs function
- Add synchronous=NORMAL, cache_size=-8000, mmap_size=67108864 PRAGMAs
- NewDB now calls applyPRAGMAs instead of inline PRAGMA exec
- applyPRAGMAs will be reused by NewTestDB for production-mirroring tests
- Move startupErr from package-level var to YellowJacketApp struct field
- Update OnStartup and OnDomReady to reference yj.startupErr
- Change config.Save() file permissions from 0o666 to 0o644
- Fix nlreturn lint in database/errors.go (pre-existing, blocking commit hook)
Wire up Go's standard profiling toolkit so it's automatically available
in dev builds and completely absent from production. The profiling
package uses build tags (dev/!dev) to eliminate all pprof, trace, and
timing code from release binaries with zero new dependencies.
- backend/profiling: pprof HTTP server on :6060, /debug/trace endpoint,
block/mutex profiling, and TimeOp helper for structured operation timing
- scripts/profile.sh: interactive menu-driven script that auto-selects
free ports (8080-8089) so multiple profiles can be open simultaneously
- Instrumented key operations: app init, database init, player load/restore,
queue set/restore
- Makefile targets: profile, profile-cpu, profile-heap, profile-trace
- .gitignore: exclude trace-*.out and *.pprof artifacts
- Fix 10 err113 violations: extract dynamic errors to package-level sentinels
- Fix 12 errcheck violations: handle unchecked error returns in player,
metadata, and config packages
- Fix 4 revive stutter warnings: rename player.PlayerState to player.State,
player.PlayerVolume to player.Volume, queue.QueueTrack to queue.Track,
queue.QueueState to queue.State
- Fix 2 staticcheck SA4001: simplify *&x to x in assets handler
- Fix 5 unused constants: remove dead AudioFileType iota block in models
- Fix gci/gofumpt/wsl formatting issues across multiple files
- Add gofumpt module-path setting to .golangci.yml for correct import grouping
- Fix player test: gate integration test behind YELLOWJACKET_INTEGRATION env var
instead of only skipping in CI, and replace t.Errorf+t.Failed with t.Fatalf
- Remove continue-on-error from golangci-lint CI step so linting is now required
filepath.Join uses OS-specific separators (backslash on Windows), but
embed.FS always uses forward slashes. This caused schema file lookups
to fail during Wails binding generation on Windows.
* started schema
* db schemas beginning
* first db schema gen
* added sqlc generation with go generate and sqlite driver
* i think these dependencies are needed
* added IF NOT EXISTS to create and CRUD for each table
* fixed missing columns
* added missing field
* fixed code generation with sqlc