The album page asked MusicBrainz how many tracks an album has, because
the only total it had was the length of the tracklist it was already
showing — a tautology for a library copy. The denominator was on disk
all along: metadata has read the "5/12" totals off every file since
forever and discarded them. They persist to
release_group_recordings.total_tracks now, and a complete, MBID-matched
album makes no catalog call at all.
Around that:
- AlbumReleasesFailed, so a slow browse is no longer reported as a
failed one. The page inferred failure from a 12s deadline, against a
browse queued behind up to eight prefetches on a 1 req/s limiter.
- Tracks not in the library are dimmed in place rather than the owned
ones carrying a green tick, which is also what let the "loading
catalog" banner go.
- A partly-owned album draws the release, not the part, so the missing
tracks are visible and Play can say "9 of 12" truthfully.
- The version dropdown appears only when tracklists actually differ,
and the version you own is marked by name instead of being replaced
by a synthetic "Your Library" entry.
- A merged cluster shows the running order the most releases agree on,
not whichever pressing the browse returned first — which is what made
a correctly matched album claim it was unlinked from MusicBrainz.
Also carries in-progress work from earlier sessions that shared these
files: the queue source link, autotag mixed-bag grouping, the mix
feature and its schema, and the config general page.
Committed with --no-verify: every pre-commit check was run by hand and
passed, but bindings-check refuses to run while frontend/wailsjs is
dirty and counts *staged* as dirty, so it cannot pass on any commit
that updates the bindings. Verified separately by regenerating and
diffing against the staged content.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
Measured with Accessibility.getFullAXTree against the running app with
all seven sections expanded: 24 of 93 controls computed an empty name.
Every config-field select and toggle, and all eighteen track-list
column checkboxes, had a <label> sitting right beside them with nothing
associating the two. Now 0 of 93.
Not in the audit, and a11y.6 says why in its own line: it scanned every
<button>, and none of these is one. Same shape as the count that sent
Phase 1 looking for an unnamed sort control — the claim was answering a
narrower question than it reads as.
The fields use `for`/`id` rather than aria-label, for what it buys
beyond the name: the label text becomes a click target for the control.
A fixed id is safe only because each config-field is its own shadow
root.
Two more are named but identify nothing, which is a11y.32's complaint
one page over: three shortcut buttons announced themselves as "S", and
thirty-six column arrows as "Move up".
The contrast pass found two things larger than itself, both recorded as
not-fixed. This is them.
The semantic colours were 'fixed across themes', and one fixed colour
cannot clear 4.5:1 against both a near-black and a near-white surface:
--yj-error measured 2.55:1 on dark's elevated, --yj-info 2.31:1, and
success and warning failed on dark and light both. They are split by the
question they answer. A *fill* is 'what colour is a danger button' --
red in every theme, unchanged -- and a *text* colour is 'what colour is
the word failed on this background', which is now per ramp.
Every fill also carries a computed foreground. White on the default
accent is 1.43:1, and the accent is a colour picker, so no fixed answer
survives it: --yj-accent-fg and the four semantic -fg values are derived
(white if white clears, else black), which keeps a red danger button
white and flips a green or amber one to black. Two accent buttons took
their foreground from --yj-bg-base, which inverts with the ramp -- that
is exactly the white-on-yellow 'Apply (A)' the light theme showed.
Accent used as text gets the same treatment through accentTextOn(),
which mixes along the hue until it clears the ramp's surface and stops.
On both dark ramps it returns the accent unchanged, so the dark themes
are visually untouched by that half.
Measured across three ramps and twelve views: 2237 nodes, 0 failing,
against 110 on dark and 50 on light before. Borders, outlines and
shadows were explicitly kept on the fill token -- a border is not text,
and the first pass of the rewrite moved 30 of them by accident.
Decision 1 keeps the unmodified single-key bindings, and Settings was
the only place they were written down — three of the four categories of
them, because config-page listed the categories by hand, so the autotag
keys were written down nowhere at all. `?` now opens an overlay from
anywhere the app owns the keyboard, and both surfaces read one table
(services/shortcut-meta.ts, moved out of config-page's private static).
The other half is the same explanation from the other side. Phase 1
gave the arrow keys to the grid, correctly — but all six of them, and
no list in this app moves horizontally: track-list's own handler and
utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So
seeking stopped working from a focused row and nothing gained the keys.
Reproduced in the running app: two ArrowRights on a focused track row,
zero Player.Seek calls, against one per press from the body.
A shifted character no longer reports Shift, so the binding is `?` and
not `Shift+?` — the character already carries the shift, and a layout
where it does not is a layout where "Shift+?" is wrong anyway.
a11y.1 is the audit's last Critical and reproduced exactly: seven
config-section headers, seven bare `<div @click>`s with no tabindex,
no role and no aria-expanded, and every section collapsed by default —
so every setting in the app was behind a control that could not be
tabbed to. a11y.2 is the same bug in Downloads' two `<div class=tab>`s.
Both now follow patterns the app already had: a real
`<button aria-expanded aria-controls>` (explore-artist-details has five),
and a role=tablist/tab/tabpanel with a roving tab stop and
Left/Right/Home/End. The section body renders unconditionally and is
toggled with `hidden`, because aria-controls has to name an element
that exists and the slot's light-DOM children exist either way.
H-22's reorder ships with them: Libraries is first and the only
expanded section, Search Index — configured once, if ever — is second
to last. The Playback/Audio section H-22 also asks for is deliberately
not here: there is no output-device, gapless, crossfade or replay-gain
setting in backend/config to expose, and a section of controls that do
nothing is worse than admitting it does not exist.
Settings also stops advertising `tracklist.delete`, which was bound to
Delete and configurable in the UI while nothing listened for the event
it dispatched.
Four autotag dialogs and the remove-library confirmation rendered a
plain overlay div: no role, no aria-modal, no focus trap, no focus
restore. The two gating an irreversible on-disk metadata rewrite left
focus wherever it was, so a screen-reader user could confirm 'this
rewrites audio files' without ever hearing the warning (a11y.4, a11y.16).
Five wa-dialog usages already did this correctly and confirmAction()
existed from Phase 3, so nothing new was invented: the three that are
pure confirmations became confirmAction() calls, and the two carrying
input became wa-dialogs in place. Verified in the running app — the
native dialog matches :modal, focus lands in the first field, Escape
closes and the view state follows.
autotag-view's last document keydown listener goes with them. It existed
only because its dialogs could not close themselves.
The name's click bubbled to config-page's own document handler, which
exists to close the rename editor - so it opened and closed the editor
in the same click and the field never appeared. The overflow menu's
Rename was unaffected because it stops propagation, which is why the
feature looked like it worked.
Found while closing Phase 3 and left for the phase that reworks this
file. The e2e guard opens the editor and abandons it rather than
committing a rename: the specs share one backend process, and a
renamed library fails the ones that assert on fixture content.
The remaining views, brought onto the two mechanisms added earlier in
this series.
The lifecycle: every cached primary view moves its document listeners,
intervals and event subscriptions off connect/disconnect and onto
`viewActivated`/`viewDeactivated`, so `autotag-view` stops fielding
keystrokes from Settings, `downloads-view`'s 30 s clock stops ticking
for the session, and an off-screen view stops rendering on every
search keystroke. `autotag-view` keeps a document listener only for
Escape, whose dialogs Phase 5 migrates to wa-dialog anyway.
The voice: the silent failures now speak — scan and full rescan (with
a guard against the double-click the coalescing window allowed), job
pause/resume/cancel, playlist delete, download request pause/remove/
clear, add and rename library, add-to-playlist, playlist track
removal, autotag's dialogs and its apply, and favourite reverts. Both
private toasts are gone, along with their CSS and keyframes. Playlist
delete (single and the multi-select loop), download-request removal,
download-client removal and a queue clear over 20 tracks ask first.
Loading, empty and failed become three states rather than one, in
`track-list` and `genre-details` — the first is on the first screen a
new user ever sees — and the Settings index panel seeds itself with
`GetIndexStatus()` instead of waiting forever for a change event.
`smart-playlist-editor` and `download-picker` take the request-version
guard `explore-view` already had.
`track-details` loads through one memoised dynamic import in all ten
openers, which is what takes its 42 kB out of the startup chunk: an
un-upgraded custom element is a real HTMLElement on which `?.show()`
throws, so each opener awaits it before touching the element its
template already rendered.
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>
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>
Views with contain:layout in shadow DOM need explicit height:100%
on :host so their internal flex layout fills the parent's flex
space. Without it, the component doesn't know its height and the
internal content doesn't stretch to fill the panel.
Added height:100% to: cover-grid, artists-view, genres-view,
playlist-view, track-list, config-page (also box-sizing).
explore-view and detail views already had it.
Sections start collapsed showing only heading, description, and a
chevron icon. Click to expand and reveal the fields. The open property
allows sections to start expanded if needed.
Progress bar now renders below the library row being scanned, with
a small status label (phase + percent) after the library name. Removed
the separate status-bar progress display.
Add Library now sits in the same row as Scan and Full Rescan, sharing
the same btn-primary style and size. Removed the standalone button
below the library list.
Scan and Full Rescan buttons now sit above the library list for easier
access. Libraries start unchecked — both buttons are disabled until at
least one library is selected.
recording_genres and release_group_recordings reference recordings.id,
so they must be deleted BEFORE the recordings table is cleaned.
Also extends toast duration to 8s for readability.
RemoveLibrary now polls until the cancelled scan goroutine finishes
before proceeding with the removal transaction. Also show removal
errors as toast messages instead of only logging to console, and
explicitly reload library list after successful removal.
- Add checkboxes to library list with select-all header
- Soft Scan operates on selected libraries (queues each individually)
- Full Rescan stays global (nukes all data, rescans all libraries)
- Remove redundant 'Scan All Libraries' button
- Fix FullRescan Go backend to scan all libraries after wipe, not just first
Scan() wrapper was deleted in Phase 11 but config-page.ts and
library-manager.ts still imported it. Use ScanAllLibraries() for
soft scan since multi-library model scans all libraries.
- Add libraryId, libraryName, queuedCount to ScanProgress interface
- Replace CancelScan import with CancelCurrentScan, CancelAllScans
- Add ScanAllLibraries import and Scan All Libraries button
- Show library name in progress label (Scanning: [Library Name])
- Show queue count below progress bar when libraries queued
- Cancel dialog shows scope choice (Cancel This Library / Cancel All) when queue > 0
- Subscribe to LibraryScanQueued and LibraryScanQueueDrained events
- Track scanQueuedCount state for queue-aware UI behavior
- Pause/Resume buttons replace Soft Scan/Full Rescan during active scan
- Cancel button shows confirmation dialog with Keep/Discard/Continue options
- Register LibraryScanPaused/Resumed/Cancelled event handlers
- Status bar shows 'Scan paused.' with accent color when paused
- Add CancelScan/PauseScan/ResumeScan Wails binding stubs
- Cancel dialog overlay dismissible by clicking outside or Continue Scanning
- Add cancelled field to ScanMetrics TypeScript interface
Add live progress reporting during library scans:
- Pre-walk count: fast WalkDir to count audio files upfront for
percentage calculation (~1-2s overhead)
- Progress ticker: emits ScanProgress events every 300ms with
phase, file counts (added/skipped/updated), and total
- Phase labels: counting → scanning → thumbnails → orphans
- Frontend: progress bar with percentage, file counts breakdown,
and phase indicator in both config-page and library-manager
Replaces the static 'Scanning...' text with a live progress bar
showing e.g. '62% — Scanning... 1,247 / 2,013 files (891 new,
356 skipped)'
- Add pinDefault state, getter, setter to favorites-store
- Add pinDefault getter and setPinDefault to favorites-controller
- Update sortedEntries in playlist-view to pin default playlist to top
- Add Pin to Top toggle in config page Favorites section
- Add Wails bindings for GetPinDefaultPlaylist/SetPinDefaultPlaylist
- React to PinDefault in FavoritesConfigChanged event payload