185eb1b1253fa2d5b28111d9ed3c8f323ed3a2f2
1001
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
185eb1b125 |
feat(smartplaylist): let a rule set match any rule, not only all of them
The conditions were joined with " AND " and nothing else, so a smart playlist could only ever narrow: "jazz released after 1960" was expressible and "jazz or blues" was not, which is most of what anyone reaches for a second rule to say. `RuleSet.Match` is "all" or "any", and an empty match is "all" — which is what every playlist saved before the field existed carries, so an upgrade cannot silently widen one. ParseRuleSet rejects anything else rather than falling through to AND, since a playlist quietly returning the wrong tracks is worse than one that refuses to be saved. Under OR each condition is parenthesised and under AND it is not: AND is the tighter operator, so an OR-join has to protect a condition carrying a top-level AND of its own — `days_since_played less_than` is two predicates belonging to one rule. The editor shows the choice as a sentence with the control in the middle, and hides it while there is one rule: with nothing to combine, all and any are the same query. Closes #35 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b3556d825c |
fix(mediacontrols): always send an art URL, even when there is no art
Every other key in the MPRIS metadata map can be omitted safely, because a client reading it renders a track with no title as a track with no title. Art is different: KDE's applet treats an absent mpris:artUrl as no news about the art and keeps drawing whatever the last track had, so playing something without a cover left the previous album's sleeve on screen — which reads as the wrong track playing rather than as missing artwork. The map's construction moves out of UpdateMetadata into a pure metadataMap so it can be asserted on at all: everything else in this file needs a live session bus, which is the same reason the Android contract lives in an untagged file. Closes #41 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bf4f352117 |
fix(queue): stop claiming a queue came from somewhere it no longer does
`q.source` was written by SetQueue and cleared in exactly one place, Clear, so no append path touched it: adding a track to a queue built from an album left the page still offering "Playing from <that album>", and since the source is persisted alongside the queue state the wrong label outlived the session that earned it. Every add and insert path drops it now. Removing and reordering deliberately do not — a queue with a track taken out of it is still that album, and the link still goes somewhere true. Only the arrival of a track from elsewhere makes the claim false. The delta event carries the source for the same reason it carries the current index: an append emits nothing else, so the frontend would keep the label it was last given until something forced a full state. Closes #14 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1062b7c0bc |
fix(explore): tell Lit that a track request changed something
The album page's tracklist badges read `libraryStatusFor(false, track.mbid)` at render time, which is a dependency on `downloadStore` that Lit cannot see. The page did subscribe to that store, but its callback only assigned `canDownload` and `isRequested` — neither of which a *track* request changes — so no reactive field moved and the component never re-rendered. The request was filed, the plus stayed a plus, and clicking again cancelled it. The other three hosts rendering these badges have always asked for the repaint in the same place, which is what made this one look correct on inspection. Closes #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e1c07438e9 | docs: record what shipping the release pipeline taught us (#4) | ||
|
|
6e563f3846 |
docs: record what shipping the release pipeline taught us
Moves plan 017 to completed with a recap, and lifts the three findings that generalise into NOTES.md: a preset major that renders empty notes with everything green, a 403 that looks like branch protection and is a token scope, and tag-triggered workflows running the tagged commit's own definitions. |
||
|
|
186f6a5839 |
fix(release): seed the version floor on the parent, not on HEAD (#3)
Release / release (push) Successful in 32s
CI / e2e (push) Successful in 6m7s
CI / check (push) Successful in 2m29s
Build & publish the Android APK / apk (push) Successful in 1m24s
Build & publish Arch package / arch-package (push) Successful in 2m26s
Attach the desktop build to the release / linux (push) Successful in 2m29s
Sync Homebrew formula / sync-formula (push) Successful in 6s
|
||
|
|
786d9c6110 |
fix(release): seed the version floor on the parent, not on HEAD
The floor tag marks what has already been released, so tagging the
commit being pushed leaves nothing between the floor and HEAD --
semantic-release then correctly reports there is nothing to release.
That is what the first run did: it seeded v0.0.0 on the merge commit
itself and cut no release.
HEAD^ is the first parent, so on a merge commit it is main as it was
before the merge and everything the merge brought in is releasable.
The tag has been moved to
|
||
|
|
0019310ca4 |
ci(release): cut releases from main automatically (#2)
Implements .planning/plans/active/017-release-automation.md. Merges to main now compute the version from Conventional Commits, cut the tag and the Gitea release, and the four v* workflows publish and attach their artifacts. First release is v0.0.1. |
||
|
|
1940cb548f |
fix(test): stop asserting a cache hit against a one-second deadline
TestCacheTTLExpiry set a 1s TTL and immediately asserted a hit, so it depended on an upper bound of elapsed wall-clock time between Set and Get. Nothing can promise that: on the capacity-1 runner, with the rest of the suite running in parallel, the goroutine can be descheduled for longer than the TTL and the entry is then correctly gone. It failed that way on this PR while passing five times out of five locally, and it touches no code this branch changed. Two entries now: one with an hour to live carries the presence assertions, one with a second carries the expiry. Sleeping past a TTL is always safe, so only the direction that cannot flake is timed. |
||
|
|
37e3373db9 |
docs: correct the workflow counts these comments name
Adding release.yml and desktop-assets.yml made 'the three workflows a tag fires' wrong in three files that each said it slightly differently. |
||
|
|
8d5d8af297 |
ci(release): keep the changelog out of a protected branch
CI / check (push) Skipped
CI / e2e (push) Skipped
main is protected (enable_push: false, empty whitelist), so @semantic-release/git's commit-back is rejected by the pre-receive hook -- and it would be rejected *after* the tag was pushed, leaving a tagged release the run then reports as failed. Found by trying to push this branch to main. Whitelisting the CI user was the alternative and is declined: it weakens a protection someone set deliberately and lets a bot push to main without the checks every human PR has to pass. So the release page is the changelog. The changelog plugin now writes a gitignored .release-notes.md, which exists only to carry the notes into gitea-release.sh without interpolating them into a shell command, and CHANGELOG.md is a signpost -- a file claiming to be a changelog while silently never updating is worse than no file. Tags are not protected, so the tag push is unaffected. |
||
|
|
9ce79ee416 |
ci(release): release from a branch, not a detached HEAD
semantic-release resolves the release branch and then pushes a commit and a tag to it, so a local branch named main is a better starting point than the --detach the other five workflows use. Still pinned to the pushed commit rather than to whatever main points at by the time the container starts. The floor tag falls back to the PAT when GITEA_TOKEN is unset, which is safe rather than merely convenient: all four publishers skip v0.0.0 explicitly, so the worst case is four jobs that start and immediately say there is nothing to build. |
||
|
|
b3a0814f24 |
docs: describe the release pipeline where the claims used to be wrong
CLAUDE.md said .releaserc.yml was a config nothing ran and that there were five workflows; both stop being true with this branch. The CI section now names release.yml as the entry point and records the four things in it that are load-bearing, including the two silent failure modes worth pinning against. packaging/homebrew/README.md and docs/android-release.md say where a user would actually look that upgrading from 1.x needs a reinstall -- Homebrew offers nothing silently, and Android refuses outright. |
||
|
|
2c576fa1e8 |
ci(release): attach the Linux, Arch and Android builds to the release
A release page with nothing to download is one nobody can use. The Arch package and the APK are already built and merely go unattached; the plain Linux binary is new, and is what answers 'get the latest version' without a package manager. scripts/release-asset.sh waits for the release to exist first. semantic-release pushes the tag in prepare and creates the release in publish, so the tag push that starts these workflows happens before there is an id to upload to -- and a capacity-1 runner serialises that into working by accident, which is the worst kind of bug. macOS is absent because it cannot be built here: GOOS=darwin CGO_ENABLED=0 fails at wails/v3/pkg/mac, the darwin backend being Objective-C behind cgo. Homebrew builds from source on the user's Mac and stays the macOS channel. Windows cross-compiles cleanly and is still withheld: no build of it has ever been run. All three skip v0.0.0, which is semantic-release's version floor rather than a shipment. |
||
|
|
544dbdb4db |
fix(packaging): stop publishing an Arch package on every merge to main
arch-package.yml ran on push to main and took its version from `git describe`, so the pacman registry accumulated one package per merge and not one of them corresponded to a version a user could be told to install. It builds the tag release.yml cuts instead. pkgver's literal drops to 0.0.1 with it. That is a downgrade from the 1.x already in the registry, so pacman offers no upgrade and an existing install has to be removed once; epoch=1 would have avoided that and is declined in a comment, because an epoch can never be removed again. |
||
|
|
087eb77875 |
ci(release): cut a release from main with semantic-release
The config has been sitting in .releaserc.yml complete and uninvoked; this is the workflow that runs it, and the one Gitea-shaped adaptation it needs. @semantic-release/github speaks GitHub's API, not Gitea's /api/v1, so @semantic-release/exec calls scripts/gitea-release.sh instead. That script reads the notes out of CHANGELOG.md rather than taking them as an argument: release notes are rendered commit messages, so interpolating the notes into a shell command would be an injection whose input is the commit log. The tag is pushed with a user PAT because Gitea does not start a workflow from a ref pushed by a workflow's own token, and the three publishing workflows are keyed on it. |
||
|
|
6fb7b5ea11 |
Merge pull request 'ci: trigger the catalog job deliberately, pin agent docs to one file' (#1) from chore/workflow-guardrails into main
Reviewed-on: #1v0.0.0 |
||
|
|
369810e06b |
ci: stop testing every commit twice on a runner there is one of
A branch push and its pull request are the same commit. With `branches: ['**']` alongside `pull_request:`, opening a PR booked four runs -- check and e2e for the branch, then both again for refs/pull/N/head -- and this host has capacity 1, shared with an index build that can hold it for three hours. PR #1's own checks queued two runs deep behind exactly that. `pull_request` covers feature branches. `main` stays because a post-merge run is the record of the trunk's health, and now that main refuses direct pushes it happens exactly once per merge. The trade is that a branch pushed with no PR open gets no CI. That matches the workflow this repo just committed to, and the signal returns on the same commit the moment a PR exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
e51cb13662 |
ci: trigger the catalog job deliberately, pin agent docs to one file
Two guardrails for the 2026-08-17 incident, and one is not about CI. index-artifact.yml's `push` trigger was commented out that day with a note to restore it once the rebuild completed. Restoring it is the bug. A refresh is individually cheap, which is what made the trigger look free; what it actually did was put an unattended job that mutates the only copy of a ~205 GB catalog on the same trigger as an ordinary code change, on a runner with capacity 1. The rule the file now states is the general one -- a job that mutates state which cannot be rebuilt in ten minutes is triggered deliberately -- so the next such job has somewhere to look. The cron and workflow_dispatch lose nothing: indexbuild resumes from its checkpoint either way. Note what no branching or PR gate would have caught here. That change was green on its branch, green on the merge and green on main; the fault existed only against the persistent /cache database, which no fixture reproduces. Code is gated by CI, irreplaceable state by refusing to touch it and by docs/index-cache.md's restore. The other half is the mismatch that started this: two harnesses reading two files. AGENTS.md is a symlink to CLAUDE.md and skill-check asserts the symlink rather than comparing contents, because a copy would satisfy every other check in this repo while silently drifting -- which is the failure being prevented. The same check now scans CLAUDE.md for make targets, which it never did: 27 targets named in the file agents trust most, none of them verified. Coverage goes 19 -> 46. Scanning prose meant the line-start rule needed a fence. "Two green branches do not / make a green merge" wrapped onto a line beginning `make a` and duly failed on a target called `a`. Inside a fence it is code; outside one it is a sentence that broke there, and a check that fails on reflow gets disabled rather than fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
3d65da0529 |
test(download): stop racing a download these tests never wanted
`check` failed on main with two failures in one package, and they are one
cause wearing two shapes:
service_test.go:66: state = "satisfied", want wanted
testing.go:1369: TempDir RemoveAll cleanup: ... directory not empty
Every test in service_test.go is about the durable Request that
StartDownload leaves behind, and none is about the download. But the
fixture is an anchored four-track request with a healthy provider, which
is precisely what AutoPickable says yes to -- so Manager.Start fired
`go m.grab(...)`, detached and with context.WithoutCancel, and the tests
raced it. Measured: the request reaches "satisfied" about 100ms after
StartDownload returns, so the first failure is the assertion reading the
next state, and the second is that same goroutine still writing into
t.TempDir() after the test returned.
The fixture now puts the candidate outside the auto-pick size window, so
the grab never starts. That is better than waiting for it: with no
goroutine there is nothing to be slow, and the tests state what they mean
without a timing assumption underneath. A test that does want the
download uses managerFixture and sets its own preferences.
It passed 20 runs under CPU load, but so did the broken version -- this
is a CI-only failure locally, so the cause was proved directly instead:
with the fixture's old preferences the request is observably "satisfied"
within 100ms of StartDownload, which is what CI read.
|
||
|
|
52cbef27c4 |
docs: name the guard that covers every cache table
The bullet added with the credit work names `TestTheCatalogSurvivesAStaleShape`, which pins the table and shape that failed. The general guard landed the same day and is the one that covers a table nobody remembered -- flipping the policy back fails it on five, including both artist-credit tables. |
||
|
|
c03c0b8ec4 |
test(database): the next destructive repair fails a test, not a volume
The fix for the dropped catalog pins one table in one wrong shape, which is the failure that happened. What cost the rebuild was more general: a destructive repair added at `database.NewDB` -- the chokepoint every binary in this project shares -- without asking which binary it runs in. The next one will have a different name and a different reason. So `TestNoCacheTableIsRetiredHere` asserts the outcome instead: put every `datamap` Cache table into a shape the schema has moved past, open the database the way cmd/indexbuild does, and require all of them to still be there. Driving it from `datamap.ByKind` is what makes it cover tables nobody remembered -- flipping the policy back fails on five, including the two artist-credit tables added the same day, where the existing test fails on one. It asserts the rows survive too, because SQLite does an implicit DELETE before a DROP and a repair that recreated the table would look identical. And it accepts an error from `NewDB`, because that is the documented trade: loud is recoverable, gone is not. `scripts/index-cache-snapshot.sh` covers the half no test can reach. The volume holds the only copy of a catalog that costs hours of someone else's bandwidth to re-derive. `VACUUM INTO` rather than `cp`, since a byte copy of a live SQLite file is a corrupt file of plausible size; the resumable staging directory is skipped; and each snapshot is reopened and asked for its catalog row count before anything is rotated out. A corrupt source and an empty catalog were both exercised: each exits non-zero, removes its own output, and leaves the previous snapshots alone. docs/index-cache.md is the restore, and the reason to bother: a restored snapshot resolves to `refresh` and folds in the listens since, which is minutes against the 3-23h this rebuild has been estimating. |
||
|
|
8c48105ca3 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
1c4d6ca9a1 |
ci: stop booking three hours of runner on every push
The catalog this job derives was dropped by the stale-shape repair (see `fix(database): never retire the catalog the index build derives`, which prevents a recurrence but cannot undo it), so `mode=auto` now resolves to a full ~205 GB import from the dumps. That import runs on every push to main with a 3h budget, on a runner of capacity 1 -- so ordinary CI has been queuing behind it since the merge, and each further push books another three hours. The damage is the repetition, not the single job. The `push` trigger is commented out until a run reports `complete=true`. The weekly cron and workflow_dispatch still resume the build, which is all it needs: indexbuild picks up from its checkpoint, so nothing already imported is re-fetched. Restoring the two commented lines is the entire revert, and the comment beside them says so. NOTES.md carries the incident, including the two things worth changing regardless: a destructive repair running inside `database.NewDB` has to ask which binary it is in, and the only copy of a 205 GB derived asset is a single Docker volume with no snapshot. |
||
|
|
6bf832a4ba |
docs: record what credits are, and what the repair must never touch
Two mechanisms shipped today whose invariants are not visible from the code, and one of them has already cost a rebuild. Credits: why join phrases are assembly instructions rather than disassembly ones, why credited_name is stored per row instead of joined from artists, why the lookup is keyed on the recording MBID (and so needed no local table), why an absent credit is cached as an answer, and why the decomposition comes from a third dump at all — the canonical dump has no join phrases and the JSON dumps overlap a real library by zero rows. The measurements that justify the feature are here too, including the correction that the "3 of 2,823" figure behind plan 013 measured our own writer rather than any library. The stale-shape repair gains the paragraph it should have shipped with: retiring a Cache table is a build-tag decision, because the app downloads its catalog and cmd/indexbuild derives it. Written as what happened rather than as advice, since it dropped the real CI catalog on its first run and the shape mismatch it found was there by design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
4f8257ef72 |
fix(database): never retire the catalog the index build derives
The stale-shape repair dropped the CI catalog on its first run:
retiring a table ... table=explore_index
reason="column entity_type is TEXT, schema declares INTEGER"
index maintenance mode=build reason="no completed import yet"
The mismatch was real and the drop was correct by the app's rule: a
client's catalog is *downloaded*, so a wrong shape costs a minute of
re-fetching the artifact, while keeping it costs every Explore read.
It is the wrong rule for one database. cmd/indexbuild's catalog is not
downloaded, it is what the artifact is cut from — the only way back is
the ~205 GB dump stream the /cache volume exists to avoid. And that
database is deliberately kept in the older encoding, which
`fix(indexexport): read an index older than the binary` exists to
tolerate, so the shape does not match by design and would have been
dropped on every run.
retireLibraryTables, right beside it, never touches the catalog for
exactly this reason. The repair reached past that protection because it
runs inside database.NewDB, which cmd/indexbuild also calls.
So the policy is a build tag, which is how this project already tells
the index tools apart (runtime_indexbuild.go, servicestartup.go,
dumpbuild_stub.go): Cache tables are rebuilt in the app and never in
cmd/indexbuild. Owned and Derived are still repaired in both — that is
the half this database can safely discard, and retireLibraryTables
already discards it.
The residual trade is deliberate: a future explore_index column will
now fail the index job loudly on applySchema rather than silently
costing it a 205 GB rebuild. A human should decide that one.
TestTheCatalogSurvivesAStaleShape is the accident, symptom first, with
the shape the real database is in — every current column, ids and
entity type still text. It fails with "the catalog was retired" when
the policy is flipped back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
|
||
|
|
b505959934 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
d0250a2133 |
docs: confirm the phone track list on the phone
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 6m12s
Build & publish the Android APK / apk (push) Successful in 1m46s
Sync Homebrew formula / sync-formula (push) Successful in 7s
The arrangement and the width fix, measured on the device with the build installed rather than at the same viewport in a browser: `24px 304px 80px`, 52px rows, no header, the title untruncated, no overflow. Same numbers both places, which is why both were measured. |
||
|
|
de2b324e20 |
feat(explore): refuse 0.6 GB on someone's mobile data
Plan 016 B4. The catalog artifact is about 0.6 GB and the app fetched it with no awareness of the connection: on a desktop that is a minute of bandwidth, on a phone it can be a month's allowance. It is now skipped on a cellular connection unless `AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search Index section, where the text explaining what the catalog is already lives. The file layout is dictated by the cgo rule rather than by taste. `explore` is imported by `cmd/indexbuild`, which builds with CGO_ENABLED=0 and must not link Wails, so `netpolicy.go` holds the policy and the JSON parsing -- tested on every platform -- and the single platform call is a closure injected from `app.go`, which already names `application` legitimately. Three rules in it are load-bearing. An unknown answer is not a metered one: only mobile answers at all, and treating silence as metered would have disabled the download for every desktop user in the world. Cellular is the only signal available, because the runtime reports `wifi|cellular|ethernet|none` and no metered flag -- so a metered Wi-Fi cannot be detected and is not refused, which is documented rather than implied. And the gate runs before the first status write, so declining is a no-op instead of a job in the indicator and an error tier to dismiss. Two corrections to the plan while implementing it: the portable API is `application.Mobile.NetworkJSON()`, not `application.Android`'s, which exists only under the `android` build tag; and the permission is read at the moment a download would start, so enabling it takes effect on the next attempt rather than the next launch. |
||
|
|
2c78b58207 |
feat(ui): the track list a phone can read
B2 phase 4, and the last of it. Measured on the device: at 424 CSS px the four configured columns fit the row *exactly* -- `--grid-cols` came out `24px 102px 101px 101px 80px` -- and not one of them fit its content, with "Duration" too narrow for its own header. The columns were never too wide; there were too many of them. So a phone draws `titleArtist` (the title with the artist under it, across the row's whole width) plus the duration, and drops the column headers and the resize handles, which are a click-to-sort and a drag with no touch equivalent. It is a **column set, not a second row template**: the row, its delegated events, the selection semantics, the playing marker and the virtualizer never learn anything changed, because from their side only the number of columns did. Three rules come with it. The row height is in two places (`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the virtualizer positions rows from that number and a taller row overlaps its neighbour. What is drawn and what can be sorted are different questions, so the sort list is built from `configuredColumns` -- a phone has no headers either, and building it from the drawn columns would leave it able to sort by title and duration alone. And a phone's column widths are neither loaded nor saved. That third rule is the bug the device found with the arrangement already passing five component tests and five e2e specs at the phone's own viewport. `loadColumnWidths` is keyed by column *id* and fills a gap with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever have saved a width for -- came out at 148px beside a duration column of 236. The mirror image was worse and unreachable from a phone at all: saving would have written those widths back under the same ids, replacing the width the user dragged on a desktop. The specs asserted shape, and the fault depended on what `localStorage` held for a different column set; the unit test now carries that map as a fixture. Verified: 809 component tests, 112 e2e specs, and on the phone at 424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow. One full e2e run of three saw an unrelated autotag keypress spec flake and pass on retry. |
||
|
|
a9852c18a0 |
docs: the device answered both open questions, and neither as expected
Both faults reported from the phone are now measured rather than inferred, with the installed build and current main compared on the same device. "The controls are off screen" was literal and already fixed: the installed build predates B2 phase 2, so its player bar still carried the seek bar and volume at 424px and the transport ran past the right edge. Current main measures no horizontal overflow and the controls at 200..380 inside 424, on the phone's own engine. "No icons" was my own screenshot: taken six seconds after a cold start, before the icon fetches landed. On the settled app every icon paints, and the earlier black `fill` was the svg root rather than the path that carries `fill="currentColor"`. Two conclusions from one misread node, both corrected. Chrome 113's missing Popover API does not break the menus, which was the standing worry: a long-press opens the real panel with seven items, positioned and painted -- so long-press is now verified on hardware over a 1,744-track library, not just in a browser at a phone-shaped viewport. What the device does add is a measurement for phase 4: the track list's columns fit the host exactly and are simply too many for 424px. |
||
|
|
409bfd5e89 |
test(download): wait for the work, not for the state that precedes it
TestManagerEndToEndAutoPick waits for StateComplete and then asserts
that staging was released and the library was rescanned. Those happen
*after* the state is recorded: manager.go sets StateComplete, then
satisfies the request, then releases staging, then scans. So waiting on
the state is not waiting on either assertion, and on a loaded machine
the worker is descheduled in between and the test reads the world one
step too early:
manager_test.go:209: staging not released: 1 dirs remain
manager_test.go:218: library scans = 0, want 1
It passed alone every time and failed three times under a full-suite
run, which is the signature of a test race rather than a broken
manager — nothing here is wrong except what the test chose to wait on.
It blocks pushes, since the pre-push hook is exactly the loaded run.
It polls for the side effects now, through the waitFor this package
already has and already uses for the same reason one file over
(service_test.go waits for a request to become satisfied after the same
StateComplete).
Not reproduced on demand: eight spinners and -count=5 did not provoke
it with or without the fix, so this rests on the ordering being plain
in the code rather than on a red-to-green demonstration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
|
||
|
|
0eeef6048e |
feat(frontend): credit the artists on the full-screen now playing too
The phone shell's now-playing view landed on main while the credit rendering was being written, so it arrived with the one call site that still showed a multi-artist credit as a single link with the other artists as punctuation inside it. It is the same fix as the other ten: render from the parts, fall back to the single link when there are fewer than two. The subscription is what makes it show up at all — credits arrive after the track does, so the name already on screen has to be re-rendered when they land. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
4fc0cdeab7 | Merge remote-tracking branch 'origin/main' into wails-v3 | ||
|
|
eb059a3d71 |
fix(database): retire a table whose shape the schema moved past
`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 |
||
|
|
dcabec8b1d |
feat(frontend): render a multi-artist credit as one link per artist
Every artist name in the app went through `artistLink(name, mbid)`, so a track credited to several artists rendered one link and the rest as punctuation — "2Pac feat. Snoop Dogg" linked 2Pac and left Snoop Dogg as text inside it. `creditLink(parts, fallbackName, fallbackMbid)` renders the credit from its parts: one link per credited artist, join phrases as plain text between them. The link boundaries are known by construction, which is the point — locating a name inside the stored credit string would reintroduce the mismatch the catalog exists to avoid, since that string may come from the file's tags while the parts come from MusicBrainz and the two disagree for ~1 in 3 multi-artist credits. Fewer than two parts falls through to the previous behaviour exactly, so a single-artist credit, a file with no recording MBID and a catalog that has not answered yet all render as they did before. Nothing tries to split the fallback string: "Simon & Garfunkel" is one artist, which is why primaryArtist() does not split on "&" either. The lookup is keyed on the recording MBID, which both sides already carry — a catalog row has one and so does a local file — so one binding serves Explore and the library's own lists, and no local table is needed for this. credit-store.ts, and three things in it are load-bearing: - A miss is cached as an empty array. The backend returns nothing for a single-artist credit, which is ~87% of tracks, and caching only the hits would re-request the rest on every render forever. - request() is per-row and coalesces into one call per frame. A virtualized list cannot hand over "the whole list": 50,000 rows would be 100 queries for the ~30 on screen. - It is an LRU with a counted retainedChars probe, because a cache that grows with use is a leak with a schedule. The virtualized lists push requestUpdate() into the virtualizer rather than only the host, since its rows come from its own properties — a host update alone would leave them exactly as they were. now-playing marks its geometry dirty instead, because the marquee measures the text it is about to scroll. track-list keeps the single link while a search term is active: the highlight spans are computed against the flat credit string, and mapping them onto decomposed parts is a different problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
b3737d30af |
feat(explore): carry multi-artist credits in the catalog
A track credited to more than one artist has exactly one navigable artist in this app and the rest are punctuation. `primaryArtist()` string-parses the credit, strips a " feat. " clause and discards the guest; it deliberately does not split on "&", "with" or "," because those live inside real artist names. Measured on a real 26,069-file library plus an 80+80 MusicBrainz sample: 13% of recordings are multi-artist upstream, while only 0.86% of files carry any structured multi-artist tag — mp3 carries zero files with multiple MUSICBRAINZ_ARTISTID across 19,840. Of 1,286 files saying "feat.", 90% have nothing structured behind it, and a sample of 80 such files was multi-artist in MB 80 times out of 80. CLAUDE.md justified plan 013's removal of the credit tables with "3 credits of 2,823 listed more than one artist". That measured our own *writer* — cachedLinkArtist was called once per credit, so a collaboration could never have been recorded. Dropping the join table was still right on cost; the evidence for "multi-artist is rare" was not. A credit is ordered parts and the credit string is derived from them, so join phrases are assembly instructions, not disassembly ones. Nothing here reconstructs a credit by searching a name inside a credit string: the stored text may come from tags while the parts come from the catalog, and those disagree for ~1 in 3 multi-artist credits. Where it comes from, after two dead ends: the canonical dump CI already streams has no join phrases and no as-credited names, and the JSON dumps cover 153,691 recordings of ~35M with *zero* overlap against a real library. So mbdump.tar.bz2 — 7.1 GB, ~13.7 min in pure-Go bzip2, whose members are alphabetical, which is what lets one pass resolve an entity's credit without buffering 35M recordings. - artist_credit_part / artist_credit_ref, multi-artist credits only: a single-artist credit is already explore_index's own artist_name. - Column layouts verified against the real 20260815 export; ErrDumpShape makes a wrong guess a failed build, not a wrong catalog. - The pass runs on every mode, not just a build. The job picks its mode from the index's own state, and a complete import means "refresh", which never enters the importer — so credits could otherwise only arrive via a rebuild that re-downloads ~205 GB. It reports whether it populated anything, which is what flips `changed` and republishes. - The importer asks whether an artifact carries the tables, on the writer where `core` is attached, so the artifact already published still imports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh |
||
|
|
0bfa2136be |
feat(dev): ask the phone instead of looking at it
The device tier could only take a screenshot and read what Go chose to log, and a screenshot cannot tell a dropped CSS declaration from a missing asset. This adds the third thing: the page's own answer, from the engine that is really rendering it. `make android-screenshot` grabs the screen, `make android-inspect` forwards the WebView's devtools socket, and `make android-eval EXPR=...` evaluates in the real page. Four details are load-bearing. Only a `debuggable` build opens that socket, so the debug build type takes `applicationIdSuffix ".dev"` and installs *beside* the release app -- the two carry different signing certificates, and Android's only remedy for a changed certificate is an uninstall, which takes the user's library with it. Playwright cannot drive a WebView (`connectOverCDP` calls `Browser.setDownloadBehavior`, which it answers "Browser context management is not supported"), so the eval is raw CDP over Node's built-in WebSocket. The socket name carries the pid, so it is resolved per launch rather than written down. And `exec-out`, not `shell`, for the screenshot: a pty translates LF and corrupts the PNG. What it immediately established is why it was worth having. The phone renders in Chrome 113 at 424x439 CSS px -- two years behind every browser the other tiers use, with no Popover API and no relaxed CSS nesting -- so a spec passing at that viewport says nothing about the device, and two conclusions drawn from version numbers alone were wrong. Both are corrected in NOTES.md and the plan. |
||
|
|
b1cdef8769 |
docs: record what a phone said that no tier could
The first device run of the published APK, and the first runtime evidence any of the Android work has ever had -- A4 shipped entirely reasoned from source. It confirms A4 whole: playback survives the screen locking, and the transport notification appears with cover art, which settles four open questions at once (the service starts, the permission was granted and the notification is visible, the lock screen picks up the session, and art decoded from a MANAGE_EXTERNAL_STORAGE path by a service is readable -- the one nobody could argue from documentation). It also found the two faults fixed in the preceding commits, and the lesson worth keeping is why *those two*: both are things the platform adds rather than things the app draws. So the skill's Android tier now says to ask a device about system bars, the back gesture, focus and audio interruptions, permissions and the keyboard -- and not about layout, which the other five tiers already cover. |
||
|
|
d661836347 |
fix(android): keep the app out from under the system bars
Reported from the first device run: the playback controls are off screen. `targetSdk 35` is Android 15, which lays every app out edge-to-edge and ignores the deprecated `statusBarColor` and `navigationBarColor` the scaffold's theme still sets -- so a `match_parent` WebView draws the page's bottom band, which on a phone is the transport *and* the tab bar, underneath the gesture bar. `applyWindowInsets()` pads the container by `systemBars | displayCutout | ime` and returns the insets rather than consuming them, so the WebView is laid out inside them. The keyboard is in the mask because a search box the keyboard covers is the same bug one surface over. The window background goes black to match the app's own default ramp: that padding is what shows through, and a band of the scaffold's blue-grey above and below reads as the app failing to fill the screen. No tier we have can see this class of fault -- a browser viewport has no system bars, so `phone-shell.spec.ts` at 390x844 renders a shell that fits at the moment the device is clipping it. Verified only as far as the APK building; the insets need the next build on a phone. |
||
|
|
28eecf0a97 |
fix(ui): the Android back button had nowhere to go
Reported from the first device run: back does not navigate back in the app. The scaffold's `MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the activity otherwise -- and this app had never touched `history`, so that was false at every depth and back quit from anywhere. The fix is here rather than in Java, because the mechanism the scaffold already uses is the one we were failing to feed: a navigation is a history entry now, and `popstate` replays it. Nothing on the Android side changes, and the behaviour becomes assertable in a browser with `page.goBack()` instead of only on a phone. The entry keeps the same URL -- the app has no routes, and a path a reload cannot resolve is worse than none -- and carries the destination in its state. Two rules keep the stacks from disagreeing. The first navigation *replaces* the launch entry rather than pushing one, or every launch costs a back press before the app will close. And the in-app back buttons go through `history.back()` rather than popping a stack of their own: `navStack` is deleted, not kept alongside, because two stacks is precisely how a detail view's own button and the phone's gesture come to disagree about how far one press goes. The third spec pins that invariant. |
||
|
|
e8690476bd |
feat(ui): long-press opens the menus a right-click opens
Every context menu in the app opens from a `contextmenu` event, bound three different ways across six components -- delegated on a virtualizer, per row, per card. A phone has no right-click, so a phone reached none of them (plan 016 B2 phase 3). This is one document-capture listener installed once from `index.ts`, not six components' worth of touch handling: a touch that holds still for 500ms dispatches a synthetic `contextmenu` at the touch point, and every existing handler runs unchanged. A seam no component has to opt into is one no future component can forget. Four details are load-bearing, each a way the obvious version fails. The target is `composedPath()[0]`, not `elementFromPoint`, which stops at the outermost shadow host -- every menu here is bound inside one, so a host-targeted event reaches a delegated listener and no per-row one. A browser that fires its own long-press `contextmenu` (Chromium does; WebKit and the Android WebView vary) wins, and ours is told from theirs by identity rather than `isTrusted`: `isTrusted` works in the app and is untestable, which would leave the suppression path as the one thing with no coverage. And the click ending the gesture is swallowed, keyed on the gesture rather than a time window, or the first tap on the menu it just opened is eaten too. The e2e spec presses `.track-row`, not `[role="row"]`: the column header is a row too, and it is the first one -- a press on it is correctly ignored, which reads exactly like the gesture not working. |
||
|
|
7e0be8fa30 |
fix(indexexport): read an index older than the binary
`maintain-index` failed with
indexexport: copy rows: SQL logic error: no such column: total_tracks
three minutes into the one job that owns the ~205 GB checkpoint and
publishes the catalog every user downloads.
The cause is the exception that keeps that checkpoint alive. The job's
/cache is a real YJ_HOME that survives between runs, so its
explore_index is classified Cache and is deliberately *not* dropped and
recreated by cmd/indexbuild's schema repair -- which means a column
added to the schema afterwards is absent from it. total_tracks arrived
with the album-completeness work; the exporter selected it regardless.
The fix is the rule the importing side already follows.
artifactHasTotals exists because "adding a column to the importer's
SELECT is how you break every artifact already published"; the mirror
image, reading an index older than the binary, had no such guard.
sourceColumns asks pragma_table_info and selects a literal 0 when the
column is absent -- which is what that column already means by "the
catalog does not say", and what the app renders as unknown rather than
as incomplete. The artifact keeps every column, so an importer needs no
second shape.
The test reproduces the failure symptom first: with the fix removed it
fails with the CI message verbatim. Its own first version proved
nothing, though, and that is worth the comment it now carries --
`strings.Replace(catalogColumns, "total_tracks, ", …)` matches nothing,
because the list is formatted across lines and the name is followed by
a newline, so the "old" index was built with every current column.
|
||
|
|
1b05dde382 |
feat(ui): the full-screen now playing a phone needs
Plan 016 B2, phase 2. Phase 1 took the seek bar and the volume out of the phone's bottom bar -- 4px of height is not a thumb target, and a phone's volume belongs to its hardware keys -- and promised them a full-screen view. This is it, reached from a button over the mini player's cover art. **It composes the transport rather than reimplementing it.** The same `seek-bar`, `player-controls` and `volume-control` the desktop bar uses; a phone layout that copies them is a second transport to fix every bug in, and the seek bar in particular carries interpolation rules that took a plan of their own to get right. The seek bar thickens its own track below the breakpoint, in its own stylesheet, because the track size lives on a wa-slider inside its shadow root where a custom property from the host cannot reach. **It is a detail view, not a primary one.** It is somewhere you go and come back from, so index.ts pushes the current view and Back pops it -- which is also why it is not a fifth tab: a tab you cannot leave by pressing it again is not a tab. Two things came from reading a screenshot rather than from a failing test, and both were invisible to assertions that were individually correct. **The mini player was still under the full-screen view**, repeating it in 4em of an 844px phone. index.css hides the bottom bar while `#main-content[data-active-view="now-playing"]`, through `:has()` rather than a class toggled from index.ts, because the active view is already published as an attribute. That takes the queue button with it, so the view carries its own. **And phase 1's shell rules had never applied.** A media query adds no specificity, and the phone block sat above the plain rules it meant to override, so at 390px the header kept its 2em gutters (32px), its 16px gap and its 24px title, and the bottom bar kept a fixed 320px first column. Nothing failed: the shell fits because of `min-width: 0` and each component's own media query, which live in their own stylesheets and have no later rule to lose to -- so what was dead was exactly the cosmetic half no assertion looks at. The phone rules are one section at the end of the file now, and it says why it is last. Measured after: 12px, 8px, 17.6px, `154px 187px 33px`. |
||
|
|
29299d17da |
fix(dev): run the local e2e tier against the app CI runs
Two specs failed locally and passed in CI, which is the least useful direction for a disagreement to point. **`dev-headless.sh` was the only launcher not stubbing out the catalog.** `seed-sandbox.sh` and `ci.yml` both send `YJ_CORE_INDEX_URL` to a dead address; the dev launcher did not, so the app downloaded and built the real ~1M-row Explore catalog into the run's YJ_HOME and every local `make e2e` after that ran against a world CI never sees. Found by reading the failure screenshot: the spec had searched Explore for its fixture album and the page was full of real ones. It defaults to the dead address now and takes an explicit one for exploring by hand. **And the shared backend carries spec state between runs.** `explore-shelves` staged its catalog only `IfEmpty`, so one album row left behind by `requested-badge` satisfied that gate: the shelves were drawn from a single foreign row and the artist card the spec clicks did not exist. It failed on the *second* local run and passed on the first, and never in CI, where every run gets a fresh home. "Is the catalog empty" was the wrong question and "are my rows there" is the right one, so staging is unconditional (INSERT OR IGNORE keyed on the MBID) and the assertion moved from *this insert wrote a row* to *every fixture row is present*. That is both idempotent and stronger: an MBID that fails CHECK(length(mbid) = 16) is silently dropped by OR IGNORE, which the old per-insert count caught only on a cold catalog and the new one catches always. Verified by running the whole suite twice against one app: 97/3 before, 100 passed both times after. |
||
|
|
57fbbdf0d2 |
feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for. |
||
|
|
df2e9ea777 |
docs: record what the Android work established and disproved
Section A of plan 016 is closed and B1 is decided, so the three tenses move together: CLAUDE.md for what mediacontrols now is, the skill for what to run, NOTES.md for what was measured and when. The entry worth reading is the one that disproves a claim written here earlier in the same session. Dropping x86_64 was expected to make make android-install fail with INSTALL_FAILED_NO_MATCHING_ABIS. Measured, it installs and launches: Google's google_apis x86_64 images carry arm64 translation (abilist = x86_64,arm64-v8a), so the loader maps lib/arm64/libwails.so and runs it. It dies before any of our code with SIGILL, and the disassembly names the reason exactly -- `mrs x0, ID_AA64ISAR0_EL1`, Go's internal/cpu reading the arm64 feature register at runtime init, which the translator does not implement. So no Go binary starts under it, and that is not a property of this app. Which closes the last plausible shortcut. There are now three distinct ways this app fails on an x86_64 Android -- seccomp on the x86_64 build, an unimplemented system register on the translated arm64 one, and a real device still unverified -- and none of them is a bug in it. A phone remains the only verification path. Plan 016 also carries the B2 scope, now decided rather than recommended: option 1's data model with option 2's surface. The phone gets home, library browse, now-playing-as-a-view, the queue, search and playlists; it does not get autotag, downloads, Explore or the 93-control Settings page, and each of those has a reason written beside it. One rule for the work: no view forks, because a phone template that copies a view's is two templates to fix every bug in. |
||
|
|
c99c8efa11 |
ci(android): tell a wrong password apart from a wrong keystore
The v1.5.0 run reported that the keystore did not open, and the diagnostics could not say why. They now clear the two causes that look identical to a wrong password. **A password pasted with its shell quotes** is two characters longer than the password and nothing in keytool's error says so. The step retries with the surrounding quotes stripped and, if *that* opens the keystore, says exactly that. It does not strip them and carry on: a password may legitimately contain a quote, so this reports a diagnosis rather than guessing at a fix. **A password that is right for a different keystore** is the other one, and it is the one currently in play -- the secret decodes to a valid 2280-byte PKCS12 and the password is the length the owner expects, which leaves "is this the keystore I have locally?" as the open question. The step prints the decoded file's sha256 so that is answerable by comparing one line against sha256sum. Hashing a certificate store gives nothing away. |
||
|
|
904786b941 |
fix(dev): the Android harness did not parse, and then chose any device
Two bugs, and the first had made every make android-* target dead since the commit that introduced it. **The script did not parse at all.** A case pattern read `*signatures do not match*)`, and `do` is a reserved word: bash rejects the *whole file*, so android-emulator, android-install, android-smoke and android-logs all died with "line 190: syntax error near unexpected token `do'" -- a message that points at a line nobody had reason to suspect, in a file that had been working. Quoting the inner words fixes it. A shell script only ever run by hand can carry a syntax error indefinitely; nothing in the pre-commit hooks runs bash -n. **A bare adb addresses whatever is attached.** With a second emulator present -- another project's, or this one's own corpse left `offline` by a previous run -- every adb call fails with "more than one device", and cmd_install reported that as "no device - run 'make android-emulator' first" *directly after* that had printed "waiting for boot ok". Which is the harness's own house rule broken: a failure that names the wrong cause is worse than one that names none. pick_device resolves ANDROID_SERIAL from ro.boot.qemu.avd_name before any device command. The AVD name is the identity because serials are assigned in boot order and change between runs; a caller's own ANDROID_SERIAL wins, and a single device that is not ours is taken as the target, since that is a phone and a phone is what this tier actually wants. Verified with both emulators running. |