A list pays per row, and only while scrolling — and none of this is
visible to any test tier: nothing renders differently and nothing
fails, the app is just slower.
- The track list's Art column rendered `CoverArtPath`, the original
artwork, into a 24 px box while `CoverArtSmall` sat unused on the
same model, with no `loading="lazy"`. 26 of 26 image requests asked
for the full-size tier; now 0.
- `artists-view`'s avatar fallback linear-scanned every cached album
per card per frame, lowercasing two strings per comparison, inside
the virtualizer's renderItem — the common case, since a locally
tagged library has no artist images at all. Measured at 5 000 albums
and 24 visible cards: 1.46 ms/frame -> 0.01 ms/frame.
- Five components resolved selected file paths back to tracks with
`tracks.find(...)`; they share `utils/track-index.ts` now. "Select
all -> Edit tags" at 50 000 tracks: 3 051-6 298 ms -> 68 ms.
- "Play this artist", "play these albums" and the album drag cache
resolve paths in one call instead of one per album.
- The column-resize drag registers its document listeners on mousedown.
Two things here are load-bearing and read as sloppiness. The per-render
arrow functions in `artists-view` and `genres-view` are the *only*
thing changing a property of their virtualizer on a host update, and
therefore the only thing repainting the cards: hoisting them to stable
fields takes a selection from 1 highlighted card to 0. And a row inside
a virtualizer needs `width: 100%`, because the virtualizer positions
its children absolutely and a grid row otherwise shrinks to fit its
content and stops lining up with the header above it.
`seek-bar` renders `PlaybackPositionChanged` instead of counting: its
setInterval survives only as interpolation *between* reports, stopped
and restarted by every one of them, so its error is bounded by a
second and is discarded rather than carried. Measured after: UI 00:34
/ backend 34 across two keyboard seeks, against 00:44 / 73 before.
The bar also stops lying about smaller things: the right-hand clock
carries a minus sign and toggles to total duration on click, and the
now-playing column starts at 320 px instead of 200, which is where
"The Orchestra Of" came from.
`now-playing.updated()` used to measure and rewrite its text geometry
on every pass — six querySelectors and a read/write interleave — while
the player store notifies at 1 Hz. It now runs only when its geometry
key changes: the rendered title, the rendered artist, both scroll
flags, or the ResizeObserver reporting a resize, with every read
before every write. Over six seconds of playback: 52 forced layouts
-> 2, and 3.2 ms -> 0.9 ms inside updated().
The scroll flags are in that key because `.will-scroll .scroll-content`
carries `padding-right: 2em`, so applying the class changes the
distance the marquee travels — -128 px before it, -158 px after. A
guard on the text alone leaves every first hover scrolling short, and
nothing in any test tier would have caught it.
The resize's document listeners now attach on mousedown and detach on
mouseup, rather than running on every pointer move in the app for the
life of the process.
An event carries what a consumer needs so it never has to invalidate.
- `library-store` answers `TrackPlayCountChanged` by patching one
track, replacing the tracks array (consumers key memoized caches on
its identity) while sharing every unchanged Track — instead of
discarding four collections and refetching 25 MB per song.
- `playlist-store` answers `PlaylistTracksChanged` by refetching the
one playlist the event names, plus the summaries, since `UpdatedAt`
is a sort key. 2 668 kB and 172 ms for one heart, against 2.0 kB. It
falls back to a full invalidate only where a patch cannot be shown
to be equivalent: no id, a cold cache, an unknown id, or a fetch
already in flight. And a store with no subscriber fetches nothing —
the singleton's constructor used to put every track of every
playlist on the path to first paint for a view the user might never
open.
- `library-store` guards every fetch with a cache generation and holds
the request itself instead of deriving a promise from subscriber
notifications, which fixes the library-filter race and the
never-settling waiter together: they are the same bug seen from
either end.
- `explore-cache`'s two art caches are bounded, sharing one exported
cap constant — the artist photo's data URL is held by both, so
capping either alone frees nothing at all and reads as a fix that
did not work.
- `search-store` deliberately does *not* coalesce its notify: deferring
makes a subscriber that unsubscribes synchronously after a `setTerm`
miss the notification entirely, which is a semantic change rather
than an optimisation, and this is the store on the keystroke path.
- `selection-controller` retains its keys across a refetch rather than
clearing them, since they are file paths and those survive one, and
`getSelectedKeysOrdered()` gains an early exit. It stays a walk of
the list: an index goes stale on any re-sort, re-filter or refetch
while a file path survives all three, and 3 ms does not buy a
silently mis-ordered queue insert.
One 1.18 MB chunk containing all 27 views, every one eagerly imported
and side-effect-evaluated before first paint. `index.ts` now holds a
loader table per view and awaits the right chunk before creating the
element. JS evaluated before first paint: 1 480 kB -> 772.9 kB, in 27
chunks instead of one, with the slowest first open of a view at 19 ms
against 21 ms — both halves of the trade, and the second did not get
worse.
Two things it has to get right. `document.createElement` on an
undefined tag yields an inert HTMLElement rather than throwing, so a
missing entry in the table is a blank page and not an error; and
navigations are numbered, so a slow chunk cannot land on top of a
faster navigation. `notification-host`, `inline-notice` and
`confirm-dialog` stay eager on purpose: a failure surface that has to
fetch a chunk before it can speak is not a failure surface, and the
moment it is most needed is the likeliest moment loading one fails.
Four small modules the views below adopt:
- `lru-map.ts` — a Map re-inserted on read and trimmed from the front.
`explore-view` never unmounts and its two art caches were plain
Maps: twenty-four searches retained 20.58 MB and were still
accelerating, a cover thumbnail being ~27 kB of base64 and an artist
photo ~128 kB.
- `cache-stats.ts` — a bound has to stay checkable, so caches register
and `window.__yjCacheStats()` reports entries, retained chars and cap
in one eval, rather than the next session having to rebuild the
twenty-four-search reproduction first.
- `track-index.ts` — a WeakMap from the tracks array's identity to a
Map<FilePath, Track>. Five components turned selected file paths back
into tracks with `filePaths.map(fp => tracks.find(...))`, so "Select
all -> Edit tags" at 50 000 tracks blocked the main thread for 3.0 to
6.3 s. 68 ms after. Keying on the array's identity is safe for the
same reason the memoized filter caches are, and it is collected for
free when the store drops the array.
- `lazy-track-details.ts` — one memoised dynamic import, because
`track-details` (42 kB) was imported for side effect by all five
components that open it and so was evaluated before first paint
however the routes were split.
Every <wa-icon> was fetched from ka-f.fontawesome.com at runtime —
confirmed from `performance.getEntriesByType('resource')`, 36 requests
— so offline the app had no icons at all. `setBasePath()` does not
affect the icon resolver; only the component autoloader reads it.
Overriding Web Awesome's `default` icon library fixes all 165 call
sites without changing one of them. Cross-origin requests at startup:
22 -> 0.
Three things about it are load-bearing. The set is Font Awesome Free
(CC BY 4.0, vendored with its licence by `scripts/fetch-icons.mjs`)
because the kit CDN serves Pro, which cannot be redistributed. The
names are a committed list rather than anything derived, because
twenty call sites compute their icon name from state and no static
pass can enumerate them. And a name that is not bundled is reported at
runtime to `window.__yjIconMisses` and drawn as a fallback, since a
missing icon used to be impossible — the CDN having had everything.
There was no app-level notification surface: two components had grown
private toasts and the other 84 catch blocks ended at console.error,
so a user with a moved file, a locked database or an offline network
saw a button that did nothing. Where errors did surface, eight sites
printed the raw Go string.
Four levels, chosen by the call site from one rule — a failure is only
worth interrupting for if the user can do something about it that they
are not already doing: Blocking (data at risk), Persistent (something
asked for that did not happen, worth retrying), Transient (a small
action whose state visibly reverted anyway), Inline (rendered in the
panel that failed).
Three things about it are load-bearing. Coalescing lives in the store,
keyed by (level, region, key) within a window, so 200 unplayable files
are one message with a count and no future caller has to remember that.
An inline notification carries a *region*, because "inline" says not
global, not where. And the bottom band belongs to the player, so the
app-level stack sits under the header — the player's own floating
notice grows upward by however many lines it needs.
`utils/describe-error.ts` maps the causes a user can act on to copy;
`explainError` repeats a backend message when it is one of our own
sentinels rather than a Go wrapping chain, since mapping "a library
with that name already exists" to something generic is a regression.
`confirmAction()` is a wa-dialog, so destructive actions inherit the
focus trap and Escape the hand-rolled overlays do not have.
`index.ts` caches primary views and hides them with a class so
scrollTop survives navigation. Nothing else was told: `disconnectedCallback`
never fires for one, so everything written to clean up there never
cleans up. The worst case was not a leak — pressing `s` on Settings
skipped two albums out of the Autotag queue, and `a` on that same live
handler rewrites tags on disk.
- `utils/view-lifecycle.ts` is the missing half: `viewActivated` /
`viewDeactivated`, with `listenWhileActive`, `intervalWhileActive`
and `whileActive` torn down on the way out, and an off-screen view
that does not render. `registerViewAware` gives a shared reactive
controller the same treatment, because a controller cannot know
whether its host is a cached view — `ContextMenuController` bound
three document listeners in `hostConnected`, which for a cached host
is "forever".
- `services/shortcut-scope.ts` publishes the ambient scope. Resolving
scope from focus alone was not enough: this app is driven with the
mouse, focus sits on `<body>`, and a focus-only rule would have made
the panel keys work only after a click landed inside the panel.
- Global bindings yield to a focused control that owns the key —
button, select, slider, checkbox, menu, grid row, or anything inside
an open dialog — so the unmodified single-key bindings stop stealing
Space and the arrows.
- `utils/roving-grid.ts` gives a card grid one tab stop moved with the
arrows, since a card per tab stop makes a library-length tab
sequence.
`data-shortcut-scope` was read by the shortcut service and set nowhere,
so the two panel-scoped bindings were dead while Settings advertised
them as configurable. These are the bindings the scope mechanism was
built for: autotag's A/S/L/U/F and the arrows, and the track list's
play.
"Play this artist" awaited `GetAlbumTracks` inside a for loop — 13
sequential round trips for a 12-album artist — and every one of the
four sites doing that asked for whole track rows to read `FilePath`
off them. Five genres cost 6 MB across the IPC.
`GetFilePathsByAlbums(ids, libraryID)` and `GetFilePathsByGenres(names,
libraryID)` answer once and carry only the paths. Measured at 50 000
tracks: an artist 13 calls / 74.2 kB -> 2 / 19.2 kB, twenty albums
20 / 117.5 kB / 7.8 ms -> 1 / 26.0 kB / 1.7 ms, five genres
5 / 6 014 kB / 213 ms -> 1 / 1 291 kB / 32.6 ms, with the returned path
lists identical.
They return the paths grouped by album id or genre name rather than
flattened, because the caller owns the order — an album list is sorted
by name, not by id, and a flattened result would silently reorder a
queue — and because the album drag cache stores them per album. A
libraryID of 0 means "every library", matching an unset filter.
`CreateSmartPlaylist` issued its `INSERT ... RETURNING` through
`QueryContext`, which routes to the query-only read pool, and failed
with "attempt to write a readonly database (8)". No smart playlist
could be created at all, in any real build.
It was invisible because `NewTestDB` shares one in-memory connection
and leaves `readDB` nil, so `reader()` hands back the *writer* under
test: every unit test of that path exercised a handle production does
not have. `TestNoWritesOnTheReadPool` walks the tree for the whole
class, in the same spirit as `TestNoDirectRuntimeEmits` and for the
same reason — a lint pass only sees one build configuration.
`IndexStatusChanged` was pushed on a 3 s ticker for the life of the
process, byte-identical once the index was ready, and `config-page`
assigns it to a @state field — so a user who had once opened Settings
paid a full re-render of a 2 000-line template every 3 s, forever, for
no news. Measured sitting on Settings: 5 events and 5 re-renders per
15 s, against 0 and 0.
`emitStatus` drops a status equal to the last one it sent, which is
the rule stated once instead of at twenty call sites. The corollary is
load-bearing: every mutation of something the status derives must now
call `emitStatus` itself. Two were relying on the ticker — `si.ready`
when an existing index is adopted, and `si.cancel` when a build ends —
and without them the header badge said "Building search index" over an
index the settings page called ready. A polling loop is a hidden
dependency for every state transition that forgot to announce itself.
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.
`recordPlay` emitted `TrackMetadataChanged`, which the frontend
correctly reads as "tags were rewritten" and answers by discarding
every cached collection: measured at 8 binding calls, 71.18 MB across
the IPC and a 765 ms longest task per two track changes at 50 000
tracks — once per song, while clearing the user's selection.
It now emits `TrackPlayCountChanged` with everything needed to patch
the one track in place, read back with `UPDATE ... RETURNING` so the
count cannot drift from the stored one. Measured after: 0 calls, 0 MB,
0 ms.
The seek bar was a setInterval counter reconciled only on track
change: measured 3 s behind during steady playback and 30 s behind
after four keyboard seeks, because the seek shortcut never told it.
And `loadCurrentTrack`/`playCurrentTrack` logged, returned false and
emitted nothing, so double-clicking a moved file did nothing, twice,
forever — while auto-advance onto a bad file stopped playback dead.
- A 1 Hz position ticker while playing, plus an immediate report on
load, play, pause, seek and natural finish. The payload carries a
`trackChangeId` (the store is a singleton, so a bar mounting later
must not adopt a report about the previous track) and a `seq` (the
same second reported twice still has to reset interpolation).
- `PlaybackFailed` from both failure paths, and `playCurrentOrSkip`
steps over tracks that will not load — bounded by the queue length,
so a disconnected drive stops after one pass instead of spinning
through a RepeatAll wrap. `PlayIndex` still reverts: the user picked
that track.
- `SeekFailed` is emitted when the seek itself fails, not only when
nothing is loaded, and is followed by a position report so the
optimistic move is taken back by the mechanism that fixed the drift.
- A queue that simply ran out no longer unloads the player, so the
finished track stays on the bar at 0:00.
Three events the frontend had no way to learn about:
- `PlaybackPositionChanged` carries `player.PositionInfo`, so the seek
bar can render what the player is doing instead of counting seconds
itself.
- `PlaybackFailed` carries the file and the reason, from both the load
and the play path, so a track that will not play stops being a
silent no-op.
- `TrackPlayCountChanged` carries everything needed to patch one track
in place. `TrackMetadataChanged` means "the tags on disk were
rewritten" and costs the frontend its entire library cache; finishing
a track used to emit it.
An event's cost is part of its meaning, and the expensive one must not
be reused for something cheap.
genevents prefixed only the *first* line of a const block's doc
comment with `//`, so a comment that ran to a second paragraph emitted
bare prose into the TypeScript object literal — a generated file that
does not parse.
Nothing had noticed because nobody had run the generator since the
comments were written, and `make generate` is a pre-commit hook: the
failure was waiting for whoever next touched a .sql, a .templ or an
event constant. A generator is only verified by running it.
Plan 007 phase 4 is verified by measurement, not by assertion, and
there was no way to produce a number: the fixture library is a few
dozen tracks and cannot show any of the findings.
- `cmd/gentestdata -bulk N` (`make bulkdata`) writes a ~50 000-track
library in 11 s / 466 MB by encoding six clips once and copying
them, while still tagging every file through `backend/tagwriter` —
a library the app cannot read back measures nothing.
- `make sandbox-seed-bulk` seeds from it through the same script and
the same discipline as any other seed: by running the app and
waiting for the real scan.
- `e2e/perf/measure.mjs` (`make perf LABEL=x`, `make perf-compare`)
takes fourteen measurements against a running app and writes them
to a gitignored `.dev/perf/<label>.json`. It wraps every bound Go
method, so "did that refetch the library" is a fact rather than an
inference, and records `longtask` entries, which is where a 25 MB
JSON parse on the main thread shows up and nowhere else.
It is not a spec and does not run in CI.
CLAUDE.md gains backend/home and the two cross-cutting frontend pieces
a list or detail view now has to know about: explore-link's
always-navigate rule with its double-click grace, and
<catalog-scope-notice> with the catalogPending/catalogLoaded
distinction behind it.
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.
The button ran a normal reconcile pass, which honours each request's
retry backoff — so a request searched an hour ago was not due, nothing
was searched, and the button looked broken. The backoff is a promise to
the providers, not to the user: a person pressing "check now" *is* the
schedule, so a user-initiated pass ignores it and the loop still does
not.
"Nothing happened" also needed a reason. Summary now carries how many
requests are still being looked for and whether any download client is
enabled at all, which is the one cause of silence the user can fix —
and the requests tab says so above the list rather than leaving an
inert list to be interpreted.
The rest is the retry schedule finally being admitted to: rows show
when the next check falls due, "Looking for" explains that a request
sitting there is waiting rather than failing, and the page header says
how often the list is worked.
The album and artist pages draw from two sources and rendered
identically either way. An album showing one track because that is all
you own was indistinguishable from an album that has one track, and
both were indistinguishable from a page still waiting on a background
catalog fetch — so the answer to "is more coming?" was to keep
reloading and find out.
<catalog-scope-notice> names the source in one line: silent for full
catalog data, "still loading" while a fetch may land, "library only"
for an entity with no MBID (which will never fill in, so it points at
Autotag), and a retryable notice when the catalog had nothing to say.
Both pages needed a new distinction to drive it. loadingReleases and
its artist-side equivalents mean "something is renderable", which a
library stand-in satisfies — so catalogPending/catalogLoaded track the
different question of whether the catalog has actually answered.
Also fixes the artist page clobbering its library-hydrated discography
with an empty catalog result. An empty BrowseReleaseGroups means the
index has not built this artist yet, not that they released nothing.
A name linked only when the entity carried an MBID — and for tracks,
only when it carried two. That rule is invisible, so a track list read
as randomly broken: some titles were clickable, most were not, and
nothing on screen said why.
A name now always goes somewhere. Tagged entities open their
MusicBrainz page as before; untagged ones open the *library* page for
the same album or artist, which both detail views already support via
a local id — they just had no caller passing one. An untagged track
highlights by title, since a recording MBID is exactly what it lacks.
Links now fire on a genuine single click only. Every list these appear
in also plays a row on double-click, and the title is the widest thing
in the row, so the first click of that gesture lands on the link:
navigating immediately meant double-clicking a track title opened a
page instead of playing it, which the e2e playback suite caught. The
navigation is held for one double-click interval and dropped if the
second click arrives, while the dblclick itself is left to bubble to
the row — so rows do not need to know links exist.
Muting does not change the volume level, and VolumeChanged carried
nothing but that level — so pressing M silenced playback and left the
indicator showing the volume it still had. The UI had nothing to react
to.
Mute rides on its own event rather than widening the volume payload,
since the two are genuinely independent: a muted player at 40% is a
different state from a player at 0%, and only one of them comes back
when you unmute. The icon crosses out and dims, and the popup gains an
explicit Mute/Unmute so the keyboard shortcut is not the only way in.
MuteToggle also now takes the speaker lock (it was mutating the effects
chain from outside it) and refuses politely rather than dereferencing a
nil streamer when nothing has been loaded yet.
main.go embeds frontend/dist, so lint, test and bindings-check all fail
on a fresh clone until pnpm build has run. Invisible locally because
anyone who has started the app has a dist/ lying around, and the
container prototype missed it because both job scripts shared one
mounted directory, so job 1 consumed a dist/ that job 2's dev-headless
had built on an earlier run.
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
actions/checkout is a JS action and needs node inside the job container,
which the golang image does not carry — the step failed with
"exec: node: executable file not found in $PATH". Clone with git and the
package token instead, matching arch-package.yml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
wails names the bundle after outputfilename (yellowjacket.app), not the
hardcoded YellowJacket.app the formula assumed. Glob for build/bin/*.app
and its inner executable so casing/rename can't break `brew install`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the Homebrew tap formula and Gitea release-sync workflow, plus
smartplaylist materialize-on-creation, volume-control scroll/drag, and
smartplaylist cover-art batch loading.
Co-Authored-By: Claude Opus 4.8 <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>
Build-from-source formula for macOS/Linuxbrew, mirroring the Arch
PKGBUILD. A Gitea workflow recomputes the tarball checksum on each
version tag and syncs the formula into the homebrew-yellowjacket tap
repo, so releases need no manual formula edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wheel over the volume icon steps volume by 5. The slider now updates
live on drag (@input) instead of only on release, debounced 60ms to
avoid spamming SetVolume. A local pendingVolume tracks intent so rapid
events accumulate and UI stays responsive ahead of the backend echo.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The autotag overhaul added cover-art and MusicBrainz-ID columns to
leanTrackQuery to support the new track-row styling, reintroducing the
per-row correlated subquery anti-pattern (artist_mbid) plus a cover_art
join inside the whole-library derived table. Both ran for every track
before WHERE/LIMIT, so smart-playlist evaluation cost scaled with
library size rather than result size — several seconds for a 500-track
playlist that was previously sub-second.
Move these presentation-only fields into a batched fetchArtwork pass
keyed by the matched recording_ids, mirroring the existing fetchGenres
batch. Cost is now proportional to results. Add TestEvaluate_ArtworkEnrichment
(no prior coverage of these fields) and an artwork_ms debug metric.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add packaging/arch/README.md covering the one-time GPG key import
(public key C061B6267CF9D820), pacman.conf repo block, and install, plus
notes on the Never fallback, debug-package skip, versioning, and repo
priority.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TestBufferedStreamer_BasicStream flaked under -race (got 1256/1512 vs
1000 expected). The streamer injects silence frames by design when its
ring buffer momentarily underruns; under the race detector the consumer
outran read-ahead and received mid-stream 256-sample silence frames. The
collection loop only skipped leading silence, so those frames were
counted as data.
Skip all zero frames, matching the test's own drain loop — real samples
always start at 1.0, so any zero is injected silence, never source data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the protect-main pre-push guard so main can be pushed directly,
and update the CLAUDE.md git-workflow note to match.
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>
The glob matched both the main and -debug packages and passed both to a
single curl --upload-file, producing a newline-joined filename curl could
not open (exit 26). Loop over the matches and skip the -debug package,
which end users don't need.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>