The apply was a bare goroutine whose progress lived in a component
field discarded on navigation, with no cancel and no record of where
it stopped if the app quit while it was rewriting tags — beside a
registry that gives every other long-running operation exactly those
things.
`jobs.KindAutotagApply` now carries progress, a cancel wired to the
apply's context, and a terminal state that tells cancelled from
failed. `OnBeforeClose` returns false unconditionally today; it now
asks while a file-writing job is in flight.
Still not durable: quitting cancels cleanly but nothing records where
it stopped for the next launch. That belongs with the deferred
download/jobs work.
The sidebar had a Home item that fell through to "Coming soon". What
was missing was not another view of the library — four of those exist,
sorted and complete — but the opposite: a complete, sorted library is
exactly what gives you nothing to play, because every entry point into
it is alphabetical and identical every time you open the app.
So a shelf is a *reason*, not a filter. Each one answers a different
question you might be asking when you do not know what you want (what
was I listening to, what is new, what do I keep coming back to, what
have I forgotten, what fits, what would I never pick myself) and each
says which question it answered — a row of covers with no explanation
is just another grid.
Two consequences run through it. Shelves are built from what the user
actually did — play counts, last played, import order — with random
sampling only where there is no signal to use, so randomness is the
fallback rather than the design. And a shelf with nothing behind it is
omitted instead of rendered empty: a fresh library legitimately gets
three, and an empty row labelled "on repeat" would be a lie.
The queries return album ids and nothing else, joined back to
GetAllAlbumsWithDetails in Go, so the album projection keeps having one
definition rather than one per shelf.
A coding agent could develop this repo's Go packages and could not
develop the application: every path to running YellowJacket ended in a
blocking GTK window, so 265 bound methods, 46 events, 33 component
directories and 13 stores had exactly one form of verification
available — `tsc --noEmit`.
The unlock is that `wails dev`'s dev server on :34115 serves the real
frontend with the real generated bindings against the same Go backend a
desktop window attaches to, so a plain Chromium under Xvfb gets a fully
functional app. Four test tiers now exist, cheapest first:
- `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app,
no backend, no display. Works because `frontend/wailsjs/` is a pure
passthrough to `window.go`/`window.runtime`, so faking just those two
globals runs the real bindings and the real store code.
- `make test` — services in-process, asserting on the payload the
frontend would receive, via a new `events.Emit` wrapper.
- `make dev-headless` + `playwright-cli` — the real app, driven
interactively, with an event bridge on `window.__yjEvents` and a
dev-only control surface at `/__test/`.
- `make e2e` — 19 of those flows frozen as Playwright specs.
`events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call
sites: wails' `getEvents` `log.Fatalf`s on any context without its
runtime, so those paths could not run under test and a background
worker could take the app down. Four packages had each hand-rolled the
same guard; nine more guarded on `ctx != nil`, which does not help.
`TestNoDirectRuntimeEmits` fails the build on a new one.
Fixtures are generated, not committed (`make testdata`), and seeds are
built by *running the app* — never by hand-writing config and DB rows,
which would be a second description of a valid YJ_HOME.
`.gitea/workflows/ci.yml` is the first workflow here that tests
anything; the other three only package, so `gitea_ci` reported only
packaging jobs and misled anyone asking whether a push was healthy.
Both jobs were prototyped to green in a bare ubuntu:24.04 container
before the YAML was written, which immediately caught `make lint`
linting three configurations that nothing builds: all three passes
omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch
still ships and Ubuntu 24.04 dropped.
Operational instructions live in `.pi/skills/yellowjacket-dev/`,
measured discoveries in `.planning/NOTES.md`, and architecture in
`CLAUDE.md` — split by tense, not by topic, because a topical split
gives every new fact two plausible homes. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.
Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.
Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
Autotag: detect "junk drawer" folders with no artist/album consensus
and split them into synthetic per-cluster groups instead of forcing
one match on an unrelated pile of tracks; repair tagging_items rows
left behind by a prior scan orphan-cleanup gap.
Explore: fix an exact artist-name search being drowned out by its own
catalog entries in intent-prior scoring, and prune stale in_library
bookkeeping left behind when a referenced library row is deleted.
Download: fix a multi-library regression where every import failed
with "no library root configured" — the importer resolved the
library root from a legacy single-library config field that nothing
populates in the current multi-library model. It now resolves the
destination library per-request from the request's own library_id.
Also widen the Soulseek search window (12s -> 20s), measured against
real request history to be missing available peers on live queries.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.
Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.
- backend/jobs: registry with per-job ring-buffer logs, capability-driven
controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
page; delete the orphaned library-manager component
Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.
fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Smart playlists now evaluate and snapshot their rules at creation time
instead of only lazily on first open, so the playlist list can show a
real track count in place of the "Smart" label. A one-time idempotent
startup sweep backfills snapshots for smart playlists created before
creation-time materialization existed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enrich owned artists whose discography hasn't been fetched yet in a
bounded, resumable background pass so their wider catalogue is searchable
offline right after a scan, instead of only on first artist-page view.
Keyed off the persistent discog_fetched flag via LEFT JOIN, so already-
enriched artists never reappear and the run is a cheap no-op once every
owned artist is covered. Capped at discogBackfillMaxPerRun per run and
routed through discogSF to avoid double-fetching an artist a concurrent
interactive EnsureArtistDiscography is handling. Invoked on both scan
completion (OnStartup) and OnDomReady to resume a capped/interrupted run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidates in-progress work across autotag, explore, and library:
- autotag: beets/Picard-informed scoring engine — ID-first matching, VA
handling, recommendation tiers, and a merged distance/rank cascade, with
an eval harness for regression tracking.
- explore: offline MusicBrainz dump import/incremental refresh replaces the
legacy tier crawl; index-first local search with fuzzy matching and a
dedicated ranker; disk-free guards for dump downloads.
- library: artist-credit extraction and matching.
- lyrics: owned-library lyric search (FTS) with LRCLIB backfill.
Also: rewrite README to be user-focused, and migrate upstream to
git.ljones.me/yonlu/yellowjacket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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.
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.
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.
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
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.
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.
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
- Add ScanHooks callback struct to library package (follows RemovalHooks pattern)
- Move phantom resolution from library to playlist service via hook
- New ResolvePhantomTracksAfterScan reads M3U8 files and resolves paths
against current audio_files using multi-root resolution
- Handles pre-existing phantoms (match by M3U8 position) and new ones
(match by phantom_file_path)
- Delete old resolvePhantomTracks method that required phantom_file_path
- Wire ScanHooks in app.go OnStartup
SoftScanAllLibraries compares audio file count on disk vs DB track count
per library. Unchanged libraries are silently skipped (no progress bar,
no events). Only libraries where files were added or removed since the
last scan get queued for a full scan.
- Add CompactAfterLibraryRemoval on Queue: reloads surviving tracks from DB, resets currentIndex, unloads player if current track was removed, clears shuffle order
- Wire RemovalHooks in app.go OnStartup: StopPlayback → player.UnloadTrack, CompactQueue → queue.CompactAfterLibraryRemoval
- All existing tests pass with no regressions
- OnPause, OnPlayPause, OnStop, OnSeek now check errors and log at Warn level
- All four MPRIS closures use yj.logger.Warn for non-fatal error reporting
- No more silently discarded player.Pause() / player.Seek() errors
- 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