Commit Graph
1080 Commits
Author SHA1 Message Date
yonlu 23f3d4b3b0 fix(explore): clear in_library on a row that has no local id
CI / check (push) Skipped
CI / e2e (push) Skipped
`in_library = 1 AND local_*_id IS NULL` was a fixed point.
upsertBatch's conflict clause is `MAX(in_library, excluded.in_library)`,
so it can only ever raise the flag, and pruneStaleLocalCrossReferences —
which its own comment calls the only place a removal from the library is
reflected back into the index — was gated on the id being present. So
nothing in the app could clear such a row, ever: a permanent claim of
ownership with no local row to check it against.

The gate is now the flag *or* the id, for all three entity types. A NULL
id fails the existence test on its own, so this needs no second clause to
say what "not owned" means.

Nothing in the tree writes that shape today — collectLibraryEntities sets
both together — which is why this is worth closing rather than leaving:
the exposure is a database written by a version whose local-id columns
were populated differently, and the next writer that sets the flag
without an id, which nothing structurally prevents and which this shape
made permanent rather than merely wrong until the next scan.

The test seeds the row with raw SQL on purpose. upsertBatch writes a zero
LocalArtistID as literal 0, and 0 satisfies `IS NOT NULL`, so the old
gate already caught that shape — a fixture built through the upsert
cannot reproduce this at all. NULL is what the artifact importer and any
older writer leave behind, the columns being nullable with no default.
Reverted against the old gate, it fails on all three types.

Closes #118
2026-08-19 14:08:06 -04:00
logan bb7dde1963 Merge pull request 'A CI-only change is ci:, not fix(ci):' (#112) from docs/ci-commit-type into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 6m31s
2026-08-19 16:23:20 +00:00
yonlu 446380e3a9 docs: a CI-only change is ci:, not fix(ci):
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / e2e (pull_request) Successful in 6m41s
CI / check (pull_request) Successful in 2m28s
The commit-analyzer reads the type and ignores the scope, so `fix` is a
patch whatever sits in the brackets. Two commits touching nothing but
.gitea/workflows/unclaim.yml were written `fix(ci):` and cut v0.2.1 and
v0.2.2 -- real releases, published to Arch, Homebrew and the APK
registry, containing no user-facing change.

CLAUDE.md already warned that a mistyped feat ships a minor version.
That was not enough, because this was not a mistyped type: `fix` was
chosen deliberately, in the belief that the (ci) scope qualified it.

The version bump is the small half, which is why this gets a paragraph
rather than a clause. A merge to main starts two workflows; if
release.yml then pushes a tag, that tag push starts four more --
arch-package, homebrew-formula, android-apk and desktop-assets -- on a
runner with capacity 1, where the APK build alone is tens of minutes
and publishes a signed artifact to a public registry. So a mistyped
type is six workflow runs, not an odd-looking changelog.

`make release-dry` answers this before the merge instead of after, and
is cheaper than any one of those runs.

The two releases are staying: they are already published, and a version
that vanishes is worse for whoever pulled it than one that turns out to
be empty.

Closes #111
2026-08-19 16:03:38 +00:00
logan e07f248cc8 Merge pull request 'Wait for the scroll range the assertion needs' (#134) from fix/133-album-dropdown-scroll-race into main
CI / e2e (push) Successful in 6m39s
CI / check (push) Successful in 2m33s
2026-08-19 16:03:17 +00:00
logan 90ac6e0825 test(e2e): wait for the scroll range the assertion needs
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m33s
CI / e2e (pull_request) Successful in 6m22s
The guard polled for `scrollHeight > clientHeight + 40` and the next
line asserted the container could be scrolled to 80, so any range in
41-79 satisfied the precondition and could not satisfy the assertion.
The grid passes through exactly that while it settles, because it
recomputes its columns after a viewport change rather than during it,
so the test read a clamped scrollTop and reported 10 against 80.

It failed CI on a pull request that changes one paragraph of CLAUDE.md
and nothing else, while WebKit passed in the same run. Reproduced
locally: 0 failures in 6 runs before #132, 2 in 9 after, 0 in 10 with
this change.

#132 is what made it reachable rather than what broke it. The queue
panel's mode is measured rather than media-queried, so a viewport
change at this width costs one more layout pass, and cover-grid settles
after it instead of before. The settled range is 330 and stable, the
main panel is 700px, and the panel is correctly display:none while
closed — there is no user-visible defect, only a wider window for a
race the spec already had.

A threshold below the value its caller depends on is not a guard, so
the target is one constant that both the guard and the assertion read.

Closes #133
2026-08-19 11:50:46 -04:00
logan 4e3c953acf Merge pull request 'Decide the supported sizes, and stop the queue taking the page's width' (#132) from feat/24-supported-sizes-queue-model into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 6m20s
2026-08-19 15:22:39 +00:00
logan ede183d026 test(shell): check 900x600, which is narrower than the minimum
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 6m42s
The sidebar collapses to icons *below* 900, so the main panel is 843px
at 899 and 700px at 900: the narrowest content area any desktop width
produces is at the top of the Compact band, not at the enforced floor.
A viewport list that stopped at "the minimum" was missing its own worst
case.

MinWidth's comment loses both reasons it used to give, because neither
mechanism can happen any more — the subtitle is display:none from 899
down, and the sidebar host is overflow-y:auto (at 600x460 its
scrollHeight is 434 against a 332px client, and Settings is reachable
after scrolling). The value does not change: 800x600 is where desktop
chrome stops being comfortable, not where the app breaks, and below
600 the phone layout takes over. A floor defended by two expired
mechanisms is a number nobody can argue with, which is worse than
either answer.

Closes #24
2026-08-19 10:54:52 -04:00
logan 481c9dca65 docs: record the size bands and what the queue model cost to find
CLAUDE.md gains the three bands as a promise (Phone <600, Compact
600-899, Desktop >=900, and "no action is ever unreachable at any
supported size"), the computed queue rule and why it cannot be a media
query, and the correction that 900 — not the 800x600 minimum — is the
worst desktop width.

NOTES.md gets the measurements, including two things worth more than
the fix. My first probe for the sidebar's scroller searched
shadowRoot.querySelectorAll('*') and reported "no scroller, items are
unreachable", which reads exactly like a live Settings-unreachable bug;
the scroller is the host, and a host is not inside its own shadow root.
And the plan's first draft claimed the overlay "removes the desktop
half of #69", which the screenshot disproved: open and closed are now
identical at 900x600, so the queue's contribution is gone, but the
header's own overflow remains and is still a live defect.

Refs #24
2026-08-19 10:54:52 -04:00
logan 4025106234 fix(queue): overlay the content instead of taking its width
The panel is flex-shrink: 0 in the flow of .content-area, so an open
queue was paid for by the main panel rather than covering it. Measured
on Playlists: 379px of content left at 900x600 with all three of the
page header's actions clipped, 69px at 390px, and 0px at 320px — where
the content was not degraded but gone.

It goes to an overlay with a scrim when the content cannot spare the
width, and the rule is computed rather than breakpointed:
`available - panelWidth < 480`, where available is .content-area's
width and so already accounts for the sidebar's collapse at 900. A
media query cannot express this, which is the reason for the property:
the panel is drag-resizable between 200 and 500px and persisted, so a
viewport breakpoint silently assumes the default 320 and is wrong by up
to 180px for a user who widened it — in the direction that hurts, since
a wider queue is exactly when the content can least afford it.

480 is a judgement and the comment says so: there is no cliff to derive
it from (the track list rescales continuously, 213px to 124px columns
with no row overflow), so it is anchored to keep the default 1100px
window inline while putting every measured-broken case on the overlay
side.

The overlay is a presentation and not a fork — #55 asks for one
component with two mount points — so the roving tab stop, Alt+Arrow
reorder, drag reorder and selection semantics are untouched. Escape
closes it and returns focus, attached only while the overlay is up: it
is a dismissal rather than a shortcut, which is why it is not a
panel-scoped binding. The scrim covers the content area only, not the
sidebar or the transport, because the queue is not modal.

Refs #24
2026-08-19 10:54:52 -04:00
logan a3134f997f docs(planning): decide the supported sizes and the queue panel's model
#24 asks for a design pass, and #73 hangs the rest of Phase 2 off the
answer, so the decision is written down before any CSS moves.

Measured against the running app, and five things are not in the issue:
the Playlists header clips at 800x600 with the queue *closed* — the
minimum window is the only size this app promises; 900x600 is worse
than 800x600, because the sidebar expands at 900, so the worst desktop
case is not the minimum and every test that stops at the minimum misses
it; at 320px with the queue open the main panel is 0px wide, because
the panel is in the flow rather than over it; only Playlists overflows,
so #69 is one view's action set and not a systemic header failure; and
both reasons in MinWidth's comment describe mechanisms that no longer
exist.

The queue's mode cannot be a media query: its width is drag-resizable
between 200 and 500px and persisted, so a fixed breakpoint assumes the
default 320 and is wrong by 180px in the direction that hurts. It is
computed from the measured widths instead.

#69 stays its own PR on a finding rather than an estimate: page-header
cannot collapse actions that arrive as arbitrary light-DOM markup
through a slot, so the fix needs an actions API across all three hosts.

A very small window becomes the phone layout, which already exists and
is already tested, rather than the mini-player: #12 is a second
always-on-top window, and making it a mode of the main window would
discard navigation state on a resize and put the process-level MPRIS
question on a path a drag can trigger.
2026-08-19 10:54:52 -04:00
logan 3607fe445e Merge pull request 'Fix the player states that report one track's progress against another' (#129) from fix/player-playing-state into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 6m14s
2026-08-19 14:53:46 +00:00
yonlu 61d549a9d5 ci: run the pre-push hooks sequentially
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / e2e (pull_request) Successful in 6m23s
CI / check (pull_request) Successful in 2m30s
`go test -race ./...` saturates every core for ~47s, and the UI tier
it was sharing them with is a real Chromium with wall-clock timeouts.
So the browser lost, at random: setup took 106s inside the hook
against 63s standalone, and a different suite failed on each run --
three failing to fetch setup.ts from Vitest's own dev server once, a
15s "did not mount itself" the next time -- against a suite that
passes 898/898 five times running on its own.

That reads as "your branch broke the frontend" when nothing is wrong,
which is the most expensive kind of false negative: the next person
bisects a change that was never at fault. It cost two pushes here
before the summary line gave it away.

Sequential costs about 15s.

Closes #128
2026-08-19 09:16:43 -04:00
yonlu 2b84bc53e9 fix(player): stop reporting one track's state against another
Five faults found while auditing the play/pause and position path for
a desktop report of the pause icon showing over a seek bar that was
not moving. They are one commit because they are one file's worth of
tangled state, and two of them do not compile apart.

The finished callback did not know which chain it came from. It is
dispatched as a goroutine from the beep callback and then queues for
p.mu, so a user pressing Next in the last second of a track had it
wake up holding the lock for a player that had loaded something else
-- and rewind it, stop it, and hand a stale finish to the queue's
auto-advance. updateStreamers now stamps a chainID and the callback
carries the one it was registered with. (#123)

It also emitted PlaybackFinished and PlaybackStateChanged(stopped)
*after* releasing p.mu, alone in this file, so a Play() taking the
lock in that gap emitted `playing` first and the stale `stopped`
landed last -- the button showing play over a track that was audibly
running. Both emits are back under the lock. (#123)

A source that failed mid-track was reported to the queue as a natural
end, so a broken file auto-advanced in silence and was counted as
played. The handler takes the reason now: the player cannot name the
track, because the metadata is the queue's, so the queue emits
PlaybackFailed and skips recording the play. (#123)

p.format was assigned once, in the constructor, to the *speaker's*
rate, and never again -- so it claimed 44.1 kHz for every file. The
replay-after-finish path resamples from it, meaning a finished track
played a second time was resampled from a rate the decoder never
produced: audibly wrong speed and pitch, and the length and position
fallbacks wrong with it. The fixtures are 22050 Hz, which is what lets
a test see this at all. (#124)

p.trackLengthMs was written only when the database had a row and
cleared only by UnloadTrack, so a file with no row inherited the
previous track's duration -- and every position report is scaled by
it, so the bar reported one track's progress on another's scale.
(#125)

Queue.OnPlaybackFinished indexed q.tracks[currentIndex] having checked
only that the queue was non-empty. currentIndex is -1 whenever the
queue has been exhausted, and onQueueExhausted deliberately leaves the
finished track loaded -- so playing it from there and letting it end
panicked, on a goroutine with no caller to recover it. (#126)

The position readers guarded the decoder with the speaker lock, which
the read-ahead goroutine has no reason to hold and never takes -- so
Position() raced readAhead's Stream() on every position emit, once a
second for the whole of playback. srcMu is the lock that excludes that
goroutine, and taking it naively deadlocks, because seekLocked already
holds it and then emits the landing position from inside that region.
seekSourceLocked is that region extracted, so the lock is released
before anything is emitted. Found by the race detector, via the test
added here for the chain guard: the existing suite never loads a file
outside the integration guard, so make test was green over it. (#127)

OnPlaybackFinished picks up //wails:ignore along with its error
parameter: v3's generator segfaults on a bound method taking an error,
and this was never IPC. That removes a binding the frontend could have
called to force an auto-advance.

Closes #123
Closes #124
Closes #125
Closes #126
Closes #127
2026-08-19 09:06:43 -04:00
yonlu 282dab43eb fix(player): end the stream when the audio source stops producing
BufferedStreamer.Stream treated an empty ring buffer as a momentary
underrun and answered with silence and ok. That is right while the
read-ahead is still going to deliver something, and two of its three
exit paths left it never going to: a Close, and a source returning
(0, true) in a loop. Neither set done, so the ring drained and every
call after it was silence claiming to be audio, for the life of the
process.

Nothing above this type could tell that from healthy playback. The
beep.Seq chain never ended, so the player stayed in Playing with the
button showing pause; the decoder's position never moved, so the 1 Hz
report pinned the seek bar at a constant -- and since every report
resets the bar's interpolation, the report actively suppressed the one
thing that would still have moved it. A frozen bar over a track that
was not playing, with no watchdog anywhere to notice.

Every exit now marks the stream done, and the silence fill is bounded
by a duration *and* a run of calls. It needs both. Wall clock is the
real measure, because the speaker paces itself and a stall is a
question about time -- but a caller draining in a tight loop makes
hundreds of calls in microseconds and would outrun a duration alone.
A call count alone is the opposite failure, and not a hypothetical
one: the first attempt used one and spent the whole budget before the
read-ahead goroutine had been scheduled once, ending a perfectly good
stream at sample zero and breaking TestBufferedStreamer_BasicStream.

Err is plumbed out at the same time, because a drained source and a
failed one both arrive as (0, false) and are not the same event.
Reading it is a separate change; without it there is nothing to read.

Closes #122
2026-08-19 09:05:55 -04:00
logan cc9df4004c Merge pull request 'Surface a confident autotag match on the album page' (#121) from feat/28-autotag-match-on-album into main
CI / check (push) Successful in 2m26s
CI / e2e (push) Successful in 6m9s
The album page says when the autotagger has a confident match for what
you are looking at, and can apply it. The tier behind "confident" is
one name shared with strict auto-accept (#90), and the lookup costs no
MusicBrainz request.

Closes #28
2026-08-19 06:49:49 +00:00
logan b5d70ac1cd feat(explore): offer the autotag match on the album page
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m49s
CI / e2e (pull_request) Successful in 6m12s
The complaint was having to notice the metadata was missing, then go
and hunt the album down on the Autotag page. The album page now says it
while you are looking at the thing: "MusicBrainz has a match for this
album: <release> by <artist>", with Apply tags and Review in Autotag.

Four things about it are load-bearing.

**Applying is offered only where it would do the whole album.** A
tagging group is a folder, so a multi-disc album is several, and one
button that applied to the best-scoring group would leave the album
holding a mix of old and new tags — the exact case the app's Blocking
notification level exists for. `groupCount` is the test, and the answer
there is review rather than apply.

**It rewrites files, so it asks.** `confirmAction()` with an impact
line that says it cannot be undone and that nothing is moved or
deleted, because "rewrites your files" reads worse than it is. The
apply goes through `ApplyAsync`, the registered-job path, so progress
belongs to the jobs indicator and this page does not grow a second one
— what it owes the user is the acknowledgement, because the button is
here. The suggestion clears itself on success rather than inviting a
second click while the job runs.

**The banner does not quote a percentage.** The backend has a score and
deliberately keeps it out of the sentence: 0.95 reads as a probability
and is not one. Which release it is, is the part a person can judge.

**"Review in Autotag" lands on that album.** The queue is sorted by
score so the intended folder is often near the top, and "often" is a
link that sometimes opens a different album. Autotag is a cached
primary view, so there is no construction to hand a payload to: the
request goes on as an attribute and the view *consumes* it, or every
later visit would reopen a folder the user finished with long ago.

`ICON_AUTOTAG` joins the vocabulary at the same time, on the rule
`ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a
suggestion pointing at Autotag wears the Autotag destination's own
mark. It was written inline in the sidebar; two call sites is where a
name stops being one component's detail, so the sweep governs it now.

Verified against the running app with a staged match: the banner, the
confirm dialog's wording, and the navigation landing on the right
folder with the attribute consumed.

Closes #28
2026-08-19 02:34:11 -04:00
logan 9118c16fe3 feat(autotag): answer whether an album has a confident match
`MatchForAlbum(albumID)` is the question the album detail page needs to
ask on open: does the autotagger already have something confident to
say about this album, and what would applying it do.

**It costs no MusicBrainz request.** Everything it needs is on disk —
`tagging_items` carries the top score and release from the background
prefetch, `tagging_candidates` durably holds the scored list. The rate
limiters here are shared with every page the user can open, so a lookup
that fires on page load must not join that queue; a folder nobody has
scored yet answers "nothing", rather than scoring it now.

**The tier is computed, not read.** `tagging_items.score` is the raw
number and `Recommend` is what turns it into a claim, capping it for an
ambiguous runner-up, an incomplete alignment or a folder too small to
corroborate itself. Filtering on the stored score would promise
confidence the scorer had explicitly withheld — which the two-track
test pins.

**Nothing is said about an album the user has already answered for.**
Only a `pending` group qualifies: `confirmed` covers both a finished
apply and an explicit "leave as is", and arguing with the second would
be actively wrong.

The join is `audio_files.group_key`, not a key derived from the folder
path, because a group carved out of a mixed-bag folder is keyed on its
tags — so a path-derived key would find nothing for exactly the
messiest libraries this helps. `GroupCount` is returned because a
multi-disc album is one group per disc: a caller that applied to "the
album" from a single button would retag one disc of three.
2026-08-19 02:34:11 -04:00
logan fe67849e57 feat(autotag): name the confidence tier two features have to share
`ConfidentTier` and `Confident()` are a name for what was about to be
written as `== RecommendationStrong` at two call sites: the album page
telling the user unprompted that there is a match for what they are
looking at (#28), and strict auto-accept rewriting files without asking
(#90). A page that claims confidence the auto-accept pass would decline
is the app contradicting itself, and #90 asks for exactly this — that
the two agree on what "high confidence" means rather than computing it
twice.

What they do not share is written down beside it. Surfacing a match is
a suggestion with a confirm dialog behind it; auto-accept is an
irreversible on-disk rewrite gated on further conditions the tier
cannot express — exact track count, every title matching, lengths
within a couple of seconds, no cover replacement, no MBID conflict. So
this is the floor both stand on, not the whole of either test.

`Confident` is a rank comparison rather than an equality, so a tier
added above "strong" later does not silently stop qualifying.
2026-08-19 02:34:11 -04:00
logan 21b303ba7c fix(ui): stop a closing dialog answering the next question
`confirm-dialog` is one singleton for every confirmation in the app,
and `wa-dialog` reports its close asynchronously: `open = false` starts
an animation and `wa-hide` arrives after it. So a hide belonging to a
question already answered can land after the *next* question has
opened, and cancel it — the user is asked something, the dialog
vanishes on its own, and the call site is told they said no.

Each ask now carries an id. `close` ignores an id that no longer names
the question on screen, the button handlers pass none (they always mean
the current one), and only the `wa-hide` handler carries one, because
only `wa-hide` can arrive late.

Found by writing two `confirmAction()` tests in one file: the second
could not be accepted at all, because the first one's hide had
cancelled it before the click landed. Reaching it in the app needs two
confirmations close together, which the album page's "Apply tags" makes
possible.
2026-08-19 02:34:11 -04:00
logan 9375f25629 Merge pull request 'Demote the album page version selector to a disclosure' (#120) from feat/17-demote-version-selector into main
CI / check (push) Successful in 2m25s
CI / e2e (push) Successful in 6m0s
Choosing a pressing is a repair job, not the album page's headline.
The selector is a collapsed disclosure below the tracklist; the two
unguarded blocks that shared its slot are gone, and the catalog error
now belongs to the list that is missing because of it.

Closes #17
2026-08-19 05:38:26 +00:00
logan 905654cc84 feat(explore): demote the album page's version selector to a disclosure
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 5m59s
Choosing which pressing you are looking at is an advanced,
metadata-repair task, and it sat directly above the tracklist with a
heading, a `<select>` and a paragraph explaining how our clustering
picks a "standard version" by weighing release count, status and date.
That is a sentence about our own heuristic in the most valuable space
on the page.

It is now "Other versions of this album (N)" below the tracklist: a
real `<button aria-expanded aria-controls>` inside the heading that
names the section, with the body rendered unconditionally and toggled
with `hidden`, because `aria-controls` has to name an element that is
in the DOM. Both rules are `config-section`'s rather than new ones.
It is demoted, not removed — matching the wrong release is a real
problem and this is how it gets fixed.

**Two more blocks shared that slot and neither was guarded.** The
selector at least had `distinctTracklistCount() <= 1`; the
`Versions / Loading releases…` spinner and the `Versions / <error>`
block did not, so both took the primary position on every album
regardless of whether there was ever going to be a choice. The spinner
said what `renderTracklist` was already saying about the same fetch, so
it is gone. The error was the one `catalog-scope-notice` shows at the
top of the page with a retry — every path that sets `errorReleases`
also sets `catalogFailed`, the only route to `unavailable`.

That error is what made this a rewrite rather than a move.
`renderTracklist` returned `nothing` on `errorReleases` and leaned on
the selector's own block to have said it, and a control inside a
collapsed disclosure cannot be a page's error surface. The failure
belongs to the list that is missing because of it, so that is where it
is drawn.

**What must not be lost is which version is on screen.** The default is
what the header already describes, so saying it on every album would be
this issue's own complaint one size smaller. `defaultVersionKey` is the
test: a line appears above the tracklist only once someone has chosen
another, naming it and offering the way back. The ★ and the words "in
your library" survive unchanged inside the panel, and the panel does
not close when the selection changes — a panel that shuts on use cannot
be used twice.

The `<select>` also loses an `aria-label` of "Select release version"
that outranked its own visible `<label>Version</label>`, which is a
label not in the name.

Verified against the running app as well as the suite: the collapsed
page, the open panel, a chosen version and 390px width all read
correctly, and the shell still measures 390 in a 390 viewport.

Closes #17
2026-08-19 01:24:49 -04:00
logan 219fa3c615 Merge pull request 'Make it obvious everywhere when you are looking at things you do not own' (#117) from feat/38-ownership-visibility into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 6m3s
Owned is plain; unowned is dimmed, named and requestable; a partly-held
album says how partly. Ownership is a file (`localId`), never the
`in_library` ratchet.

Closes #38
2026-08-19 05:11:43 +00:00
logan c4e055ce51 docs: write down which of the two ownership columns to read
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m15s
The `localId` / `inLibrary` choice outlives #38 — every future catalog
surface has to make it, and the code read them as an OR at eight call
sites precisely because nothing said they were different kinds of
thing. CLAUDE.md gets the rule and its four load-bearing details;
NOTES.md gets the measurement, the card that used both answers at once,
and the alternative that was rejected.
2026-08-19 00:50:53 -04:00
logan 10eca353ab fix(explore): gate playback on the same answer the row is drawn from
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m53s
CI / e2e (pull_request) Successful in 6m24s
Two play paths still accepted `inLibrary`, so a row drawn dimmed and
`aria-disabled` by the new rule would still attempt to play and fail
with "this track could not be found in your library" — the disagreement
this pass exists to remove, one layer down from the badge.
2026-08-19 00:39:18 -04:00
logan 88fc50afb8 feat(explore): mark what is not owned, everywhere it can be shown
`explore-album-details` had the rule right for one tracklist and
nothing else did: Explore's cards, `top-results-row` and the artist
page's three card shapes all mixed owned and unowned with a small badge
as the only difference, and drew a green tick on the *common* case —
which is the treatment that tracklist's own green ticks were removed
for.

`utils/ownership.ts` is the rule written once, so eight call sites
stop each holding their own version:

- owned is plain, and draws no badge at all;
- unowned is dimmed *and* says so in its accessible name, because
  dimming is a colour and cannot be the only signal;
- a partly-held album says how partly.

**Ownership is a file, and `localId` is the flag that says so.** The
album page answers with `filePaths`, a real file per displayed track; a
card grid cannot afford that and does not need to, because
`local_*_id` is built by queries that all join `audio_files` and
cleared by a prune whose existence test is a file test in every case.
`inLibrary` is written by the same pass, so the two agree in a healthy
database — but it is a one-way ratchet (`MAX(in_library, excluded)`)
whose only clearing pass is gated on a non-null local id, so it cannot
be un-set on its own.

Where they already diverged was the client. Both `explore-view` and
`explore-artist-details` kept a `libraryMBIDs` set that accumulated
every MBID ever seen with `inLibrary` and cleared it never, in views
that never unmount. Both are deleted.

And one card answered the question twice and got two answers:
`renderReleaseMenuItems` gates Play on `localId > 0` while the badge
and `albumTarget.owned` used `inLibrary`, so an album with the flag and
no local row drew a tick saying it was in your library, offered no
Play, and — the request item being gated on *not* owned — offered no
way to ask for it either.

The count comes from `completenessStore`, shaped like `credit-store`:
`request()` is per-card and coalesces a screenful into one
`GetAlbumsCompleteness`, absence is cached as an answer, and the whole
cache is dropped on a scan, a retag or a removal rather than aged.

`aria-disabled` goes on rows that cannot be activated and deliberately
not on cards: an unowned card still navigates to the catalog page for
it, which is a perfectly good thing to do with something you do not
own.

Audited and unchanged: `home-view`, `downloads-view`, `cover-grid`,
`artist-details` and `genre-details` cannot show catalog content, so
everything on them is owned and "owned is plain" is already what they
do. The album page's own header badge stays, because that page is about
one entity and the badge is its answer rather than a mark on one of
many.

Closes #38
2026-08-19 00:38:22 -04:00
logan 19c68d73a7 fix(ui): keep the count in a partial badge that can act
A control is named after what activating it does, so an actionable
badge said "Request album X" — and `partial` is actionable, because an
album you hold nine of twelve tracks of has three left to ask for.
That made the one state the ring exists for the one state whose name
did not mention it.

The argument the `partial` branch already carries does not stop
applying because the badge became clickable: a ring says "some" to a
sighted user and nothing to anyone else. The name is now the action and
the count.
2026-08-19 00:38:05 -04:00
logan 41c41a860e feat(explore): carry the local row id on a top result
`TopResult` was the one projection here that shipped `inLibrary` and no
local id, so the top-results cards had no choice but to read the weaker
flag. Every sibling model — `MBArtist`, `MBReleaseGroup`, `MBRecording`
— already carries `LocalID`, and the candidate builders had the value
in hand at every construction site.

`LocalID` is set and cleared by a test against `audio_files`, so it
means "there is something of mine here". `InLibrary` is written by the
same pass but is a one-way ratchet the prune can only clear alongside a
local id; it stays for scoring, which is where an approximate answer is
fine.
2026-08-19 00:37:57 -04:00
logan 4bf59b45b7 feat(library): answer album completeness for a screenful in one query
A card grid has to know how much of an album is here — an album held 2
tracks of 10 wearing the same green tick as one held whole is the
complaint the badge-accuracy work was filed about — and
`GetAlbumCompleteness` is one query per album, which is fifty round
trips for a grid of fifty.

`GetAlbumsCompleteness` is the same question over a slice. It is two
grouping levels rather than the single-album form's correlated
subqueries, because a correlated subquery in the FROM clause is not
something SQLite will reliably do, and because the slice may only be
spelled once or sqlc expands it twice with independently numbered
placeholders.

An album with no files is absent from the result rather than zeroed:
"I have none of this" and "I have no idea" are the third state `Known`
exists to keep apart.

The test that matters is that the two spellings never disagree — they
are genuinely different SQL, so the risk is a drift in meaning (a
disc's total counted once per file, a duplicate counted twice) rather
than a typo.
2026-08-19 00:37:46 -04:00
logan fc99d9e0d7 Merge pull request 'Make a release a shipment rather than a merge' (#116) from ci/115-manual-release into main
CI / check (push) Successful in 2m24s
CI / e2e (push) Successful in 6m11s
Closes #115
2026-08-19 02:36:01 +00:00
logan 90f1239fba ci: make a release a shipment rather than a merge
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 6m13s
release.yml fired on every push to main, so the trigger was "a PR was
merged" and nothing else decided. That is a version per unit of *work*
rather than per *shipment*: eight releases in twenty-two hours, v0.0.1
through v0.3.1, for one session -- each fanning out to four publishers on
a runner with capacity 1, so roughly forty packaging jobs shipped three
issues while ordinary PR CI queued behind them. pacman, Homebrew and
Obtainium see every one.

The push trigger is gone and workflow_dispatch, which was already there
and already worked, is the whole mechanism. Nothing else had to change to
batch releases, because semantic-release already reads every commit since
the last tag: five fixes and two feats become one minor release with all
seven in the notes. Release frequency was only ever how often this file
fired.

This is the rule index-artifact.yml states and is the other instance of:
a job that mutates state which cannot be rebuilt in ten minutes is
triggered deliberately, not by a push. A release here is a tag, a Gitea
release, an Arch package, a Homebrew formula, a signed APK and desktop
assets -- and an Android version going backwards costs the user their
library.

`dry_run` is what makes a manual trigger usable: the point of pulling a
lever by hand is being able to look first, so the input runs
semantic-release --dry-run -- the version and the notes, no tag, no
release, no publishers. Anything but the literal string "true" releases
for real, because a typo in a dispatch box must not silently turn a
shipment into a green no-op.

Two alternatives were considered and rejected, both recorded on the
issue. A `beta` integration branch relocates the trigger rather than
removing one: it needs a second protected branch carrying the same
required checks, and it *adds* a full check + e2e run per batch on the
very runner whose queue is the complaint. A schedule batches without
anyone having to remember, but puts the decision back on a timer, which
is the thing being removed.

Closes #115
2026-08-18 22:25:11 -04:00
logan b2fe1cb1e0 ci: skip a prerelease tag in all four publishers
Their trigger is `v*`, which matches `v0.4.0-beta.1`. They guarded
`v0.0.0` -- the version floor -- and nothing else, so the first
prerelease tag would have published a beta everywhere.

Nothing produces one today. The guard is here because the thing that
would is `prerelease: true` in .releaserc.yml, a one-line change whose
blast radius is four public channels and which nothing in those four
files mentions. That is the same argument release.yml's `chore(release):`
guard is kept on: cheap, against something a future edit turns on
somewhere else entirely.

android-apk is the worst of the four twice over. Its APK goes to the
*generic* registry, which is readable without credentials so Obtainium
can poll a plain URL, so a beta would be offered to every device on it.
And its versionCode maths splits on dots: it would read "1" out of
"0-beta" and produce a wrong number rather than a failed build, which
matters because Android orders releases by that integer and refuses
anything not greater than what is installed.

Each is a clean skip rather than a failure, matching the v0.0.0 guard
beside it: a red run against a tag that was never meant to ship is noise.
2026-08-18 22:25:11 -04:00
logan 065a879190 Merge pull request 'Give the icons one vocabulary and sweep the call sites' (#114) from feat/34-icon-language into main
Release / release (push) Successful in 32s
Build & publish Arch package / arch-package (push) Successful in 2m35s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 6s
CI / e2e (push) Successful in 6m17s
CI / check (push) Successful in 3m8s
Build & publish the Android APK / apk (push) Successful in 1m29s
Closes #34
v0.3.1
2026-08-19 01:37:34 +00:00
logan 89882b4863 refactor(ui): give the icons one vocabulary and sweep the call sites
CI / check (pull_request) Successful in 2m27s
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / e2e (pull_request) Successful in 6m42s
`plus` meant "add to the queue", "add to a playlist", "make a new
playlist" and "you do not own this" -- the first two adjacent in the
same context menu, so two neighbouring items were the same glyph doing
different things. `list` meant the queue (the button that opens it), the
Playlists destination, and adding to the queue in `queue-panel` alone.
Two icons carrying seven meanings is not a vocabulary, and nothing
catches it: a wrong-but-real icon renders perfectly.

`utils/icon-language.ts` is the table, beside `library-status.ts` as the
issue suggested. The rule it is built on is that an icon names the
**noun** it acts on, not the verb: "add to queue" and "add to playlist"
are one verb on two nouns, so the noun is what differs -- which is why
adding to a playlist wears the Playlists destination's own icon, and why
the queue took `bars-staggered` and stopped wearing Playlists'. `plus`
keeps the one meaning it is unambiguous about, making something that is
not there yet, which covers New Playlist and the drop zones.

`bars-staggered` is the only new glyph, vendored through names.txt and
fetch-icons.mjs after confirming it is in Font Awesome **Free** 7.3.1.

Two things this found rather than changed:

- The request toggle's outline/solid pair was already in the app and
  already right -- `explore-album-details`'s "Request this" button has
  used `regular/bookmark` -> `solid/bookmark` since it was written --
  while the badge forty pixels away showed a **plus** for the same
  state. That is `utils/library-status.ts`'s fault one layer down: it
  made the two surfaces agree on what wanting *means* and left them
  disagreeing on what it looks like.
- `explore-artist-details`'s Follow button was `bookmark-check`, which
  is Font Awesome **Pro** and has never been bundled, so it has drawn
  the missing-icon fallback -- a circled question mark -- for every
  followed artist since it was written. `requested-badge.spec.ts` was
  written for exactly this bug on the album button and says so in its
  docstring; this is the same bug one component over, still live,
  because `offline-icons.spec.ts` sweeps `__yjIconMisses` and no spec
  had ever followed an artist.

So the test does what reaching the state cannot. `icon-language.test.ts`
reads every `src/**/*.ts` as raw text and fails on a governed name
written outside the table, and separately asserts every `ICON_*` is a
*bundled* name -- which is what makes a Pro name a failing test rather
than a runtime report from a state something has to reach first. Its
first assertion is that it read any source at all, because a sweep over
an empty glob passes.

`chrome.test.ts` asserted `['check', 'bookmark', 'plus']` and so pinned
the badge's glyphs against the vocabulary they were meant to follow; it
names them from the table now, and keeps the assertion that the three
differ, which is the property the states actually need.

Downloads keeps the solid bookmark on purpose. That is one word twice,
not two words: the badge says the entity is on your list and the nav
item is that list.

Closes #34
2026-08-18 21:18:36 -04:00
logan 18a08daa91 Merge pull request 'Let the album page be asked for the whole tracklist' (#113) from feat/7-full-tracklist-toggle into main
Release / release (push) Successful in 33s
Build & publish Arch package / arch-package (push) Successful in 2m45s
Attach the desktop build to the release / linux (push) Successful in 1m2s
Sync Homebrew formula / sync-formula (push) Successful in 7s
CI / e2e (push) Successful in 6m18s
CI / check (push) Successful in 2m25s
Build & publish the Android APK / apk (push) Successful in 1m34s
Closes #7
v0.3.0
2026-08-19 01:10:34 +00:00
logan aa59773d22 feat(explore): let the album page be asked for the whole tracklist
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 6m12s
An album the user holds part of showed only the tracks on disk, with
nothing to say the rest existed. The page could already draw the full
release with the missing rows dimmed -- it just could not be asked: the
automatic rule fires on `completeness.known`, which depends on the files
declaring a per-disc total, or failing that on the catalog's own
`total_tracks`.

Neither reaches most albums. #16 fixed the first input for anything
tagged from now on, and the second is worse than it looks: the published
artifact is from 2026-08-10 and the column landed on 08-16, so
`completenessAnswer()`'s catalog fallback answers 0 for every user until
the index job republishes. Measured, and noted on #88, which is the
publish that carries it.

So the control is explicit. A "Show the whole album" switch flips the
synthetic "Your Library" entry between the local files and the release,
which is the same rendering, reached deliberately rather than inferred.

Three things about it are load-bearing:

- `showFullTracklist` is a tri-state, `null` meaning "follow the
  automatic rule". The rule is right when it fires, and the switch has
  to agree with the page it is sitting on rather than starting out
  contradicting it -- a plain boolean would need its default recomputed
  every time the completeness answer moved underneath it. The user
  outranks the rule in both directions.
- `fullReleaseCluster()` falls back to the highest-scoring cluster.
  `findLibraryCluster` is a guess over the `inLibrary` flags and returns
  nothing at all when none are set, which is exactly the untagged
  library this exists for -- without the fallback the control would be
  absent precisely where it is needed. The sublabel names the release
  either way rather than leaving the user to wonder whose tracklist they
  are reading.
- It appears only where it can change what is on screen: against the
  library entry, with a release to switch to, and only when the two
  tracklists differ. A complete album's release has the same rows as its
  files, so the switch would redraw the same list and read as broken --
  the same test the version dropdown one section up already answers.

The accessible name is asserted rather than assumed, through the
browser's own computation. `wa-switch` happens to get it right, and for
a third reason again: its `<input role="switch">` sits inside a native
`<label>` that also holds the `<slot>`, so the name is computed across
the flattened tree from light-DOM text. This app has shipped the
opposite twice.

Closes #7
2026-08-18 20:49:08 -04:00
logan a4777f26b6 Merge pull request 'Declare the track and disc totals when tagging' (#105) from fix/16-tagwriter-totals into main
Release / release (push) Successful in 30s
CI / e2e (push) Successful in 6m8s
CI / check (push) Successful in 2m23s
Build & publish the Android APK / apk (push) Successful in 1m25s
Build & publish Arch package / arch-package (push) Successful in 2m31s
Attach the desktop build to the release / linux (push) Successful in 54s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Closes #16
v0.2.3
2026-08-19 00:45:07 +00:00
logan 92faa9741b Merge branch 'main' into fix/16-tagwriter-totals
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m22s
CI / e2e (pull_request) Successful in 6m8s
2026-08-18 20:32:08 -04:00
yonlu bf0a53e64c Merge pull request 'Give the unclaim step a CA bundle' (#110) from fix/unclaim-ca-certs into main
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m3s
CI / check (push) Successful in 2m22s
Build & publish the Android APK / apk (push) Successful in 1m24s
Build & publish Arch package / arch-package (push) Successful in 2m29s
Attach the desktop build to the release / linux (push) Successful in 52s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Reviewed-on: #110
v0.2.2
2026-08-18 23:30:31 +00:00
yonlu 7be4a02e31 fix(ci): give the unclaim step a CA bundle
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m14s
Second defect in the same workflow. The shell fix took -- the step ran
under `bash --noprofile --norc -e -o pipefail` -- and got one layer
further before failing:

  curl: (77) error setting certificate file: /etc/ssl/certs/ca-certificates.crt

ubuntu:24.04 ships no CA bundle, and --no-install-recommends skips the
ca-certificates that curl recommends, so curl came up unable to verify
TLS against our own Gitea.

This was avoidable by reading the repo rather than reasoning about it:
ci.yml (twice), desktop-assets.yml, android-apk.yml and release.yml all
spell out `ca-certificates curl ... jq` for exactly this reason. The
convention was written down five times already.

Validated in the real image this time rather than by extracting the
script and running it on the host, which is what missed this: the step
now succeeds inside `docker run ubuntu:24.04` against a scratch issue --
label present, 204, label gone -- and the previous version reproduces
`curl: (77)` in the same image. Both checked, then the scratch issue was
deleted.

The DELETE also keeps its response body now and prints it on a non-204.
Whether the automatic token carries issue-write scope is still unproven,
because both failures happened before the API call, and "403" without
Gitea's own sentence would cost another merge to interpret.

Closes #102
2026-08-18 19:08:50 -04:00
yonlu ad9c25a5a2 Merge pull request 'Run the unclaim step under bash' (#108) from fix/unclaim-shell into main
Release / release (push) Successful in 32s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 2m23s
Build & publish the Android APK / apk (push) Successful in 1m23s
Build & publish Arch package / arch-package (push) Successful in 2m37s
Attach the desktop build to the release / linux (push) Successful in 52s
Sync Homebrew formula / sync-formula (push) Successful in 7s
Reviewed-on: #108
v0.2.1
2026-08-18 23:05:38 +00:00
yonlu a83a127e31 fix(ci): run the unclaim step under bash
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m28s
CI / e2e (pull_request) Successful in 6m14s
The workflow shipped in #103 and failed on every close, on its second
line, before reaching the API:

  shell: sh -e {0}
  /var/run/act/workflow/0.sh: 2: set: Illegal option -o pipefail

Inside `container:` the act runner selects sh, not bash, and
`set -o pipefail` is a bashism. homebrew-formula.yml carries the same
line without trouble because it runs with no container, on the host
image where bash is the default -- so "another workflow does it" was
not the evidence it looked like, and the comment now says so where the
next person will read it.

pipefail is kept rather than dropped for POSIX's sake: the lookup is
`curl -sSf ... | jq`, so without it an API error yields empty output,
an empty label id, and a cheerful "nothing to do" on every close. A
silent no-op is the one outcome worse than a failing job here.

Validated end to end against scratch issues rather than by reading it:
with the label present the step returns 204 and the label is gone, and
against an issue that never carried it the step also returns 204 and
exits 0 -- which is what makes it safe to run on every close rather
than only claimed ones.

Still untested: whether secrets.GITEA_TOKEN carries issue-write scope.
The old run never got far enough to find out. If it 403s, the fix is
one line -- secrets.PACKAGE_TOKEN, which is a user PAT.

Closes #102
2026-08-18 18:54:39 -04:00
logan 4b9114fd8d fix(tagwriter): declare the track and disc totals when tagging
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 6m18s
An album the user holds 2 of 10 tracks of showed a green tick reading
"is in your library", and the mechanism was our own writer. tagwriter
wrote track and disc *numbers* and dropped the totals, so autotagging a
folder made the release MBID-matched -- which is what earns the tick --
while erasing the one field GetAlbumCompleteness reads. The evidence
for "2 of 10" was destroyed by the act that produced the tick.

FieldTotalTracks and FieldTotalDiscs are written as the ID3 "n/N" form
and as Vorbis TRACKTOTAL/DISCTOTAL; the autotag apply pass and the
download importer fill them from the release's own tracklist; and
dbsync persists the track total to audio_files.total_tracks so the
album page agrees with the file without waiting for a rescan.

Five things about it are load-bearing, and four fail silently:

- The total is per *disc*, not per release, because that is what the
  tag form declares and what GetAlbumCompleteness sums per disc. A
  release total on every file multiplies a two-disc album's expectation
  by two, which no library can satisfy. backend/tagtotals is that
  derivation once, since the two callers must not import the writer or
  each other.
- The Vorbis names are TRACKTOTAL and DISCTOTAL and no other spelling.
  dhowden/tag reads exactly those two keys, so TOTALTRACKS -- which
  xiph lists and several taggers write -- or a "1/12" packed into
  TRACKNUMBER writes successfully and reads back as no total at all.
  The tests therefore assert the round trip through the reader the scan
  uses, not through the bytes.
- ID3's number and total share one frame, so writing either alone must
  read the other off the existing tag or discard it. A total with no
  number is not written: "/12" parses as track 0.
- The totals are written unconditionally rather than on a diff. The
  case this exists for is a file declaring no total at all, which
  compares equal to nothing and is exactly what a "only if it changed"
  guard skips.
- A single-track download is not totalled. A RecordingMBID anchor
  resolves Expected to that one track, so the same code would tag a
  track off a twelve-track album "1 of 1" -- and a declared total
  outranks the catalog total that would have answered correctly.

autotag's field constants are a second copy of tagwriter's, deliberately
so autotag stays out of the write pipeline's import graph. A key that
drifts neither fails to compile nor fails to write -- the writer simply
finds nothing under the name it looks for -- so autotagservice, the one
package importing both, now pins them.

Steps 2 and 3 of the issue stay open under #38: the catalog fallback
already landed as completenessAnswer(), and the badge call-site audit is
the part that overlaps it.

Closes #16
2026-08-18 18:19:54 -04:00
yonlu e049a71458 Merge pull request 'Drop the claim label when an issue closes' (#103) from ci/unclaim-on-close into main
CI / check (push) Successful in 2m30s
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m6s
Reviewed-on: #103
2026-08-18 21:57:11 +00:00
yonlu 0c944f2382 ci: drop the claim label when an issue closes
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m17s
A `Closes #N` footer closes the issue on merge and leaves
`Status/In Progress` on it, because Gitea's auto-close touches state
and nothing else. #100 was closed and simultaneously marked as being
actively worked on. `scripts/issue.sh close` does drop the label, and
is exactly the call the footer exists to avoid making.

This hooks the close rather than the merge. Stripping the label in the
PR would work and would be a per-PR habit, which is what the footer
removed in the first place; `issues: [closed]` covers the footer,
issue.sh close and a click in the web UI alike, and asks nothing of
anyone at any of them.

Reopening deliberately does not restore the label: reopening says the
work was not finished, not that somebody is at a keyboard now.

Two costs, both stated in the file rather than discovered later. The
runner has capacity 1 and is shared with an index build that can hold
it for three hours, so this is not instant -- stale for an afternoon
beats stale forever, which is what it was. And it is an eighth
workflow, so CLAUDE.md's count moves with it.

The audit stays, because a workflow that silently stops firing is the
failure mode this area has already produced once:

  ./scripts/issue.sh list --state closed --label "Status/In Progress"

Closes #102
2026-08-18 17:36:05 -04:00
yonlu 75525b67e4 Merge pull request 'Put the closing keyword where Gitea will actually read it' (#101) from docs/closing-keyword into main
CI / check (push) Successful in 2m27s
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m15s
Reviewed-on: #101
2026-08-18 21:30:35 +00:00
yonlu 85768dc489 docs: put the closing keyword where Gitea will actually read it
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m7s
CLAUDE.md said the Closes list was unreliable and to close by hand. It
is unreliable for a specific reason, and the rule can say what works.

Gitea parses commit messages that reach main. It does not parse the PR
body, which closes something only if the merge happens to copy it into
the merge commit message. Both halves were measured here: #83's merge
commit carried "Closes #9, #13, #14, ..." and closed five of the ten,
because a comma list is only partially matched; #93's merge commit body
was a lone Reviewed-on: trailer, so #92 stayed open behind a perfectly
correct Closes line in the PR description.

So the keyword goes in the commit body as a footer, one issue per line.
That costs nothing elsewhere -- Conventional Commits allows a footer,
commit-check only regexes the subject, and semantic-release reads the
type from the subject, so no release decision changes. The existing
rule that the issue number stays out of the subject is untouched and
was never about the body.

The verification step stays, because a squash or a hand-edited merge
message still drops the footer.

This commit is the experiment: if #98 and #100 close when this branch
merges without anyone touching them, the mechanism is confirmed.

Closes #98
Closes #100
2026-08-18 17:15:28 -04:00
yonlu 1a221a40d3 Merge pull request 'Delete four documents that contradict the code' (#99) from docs/retire-stale-planning-docs into main
CI / check (push) Successful in 2m26s
Release / release (push) Successful in 29s
CI / e2e (push) Successful in 6m14s
Reviewed-on: #99
2026-08-18 21:06:59 +00:00
yonlu 0821deb877 Merge branch 'main' into docs/retire-stale-planning-docs
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m17s
2026-08-18 20:55:52 +00:00
yonlu 31ada14111 docs: delete four documents that contradict the code
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m9s
They do not merely lag it, they contradict it, which is worse than
absent: the only way to find out one is wrong is to trust it.

docs/dev/roadmap.md, 1648 lines. Its "Current State" describes an app
with basic playback, a track list and an album grid. Phases 1, 2, 3 and
5 have shipped entire -- playlists, shuffle and repeat, shortcuts,
virtualised lists, search, custom columns, smart playlists, the
MusicBrainz client, autotag. Its Decision Log records "Testing:
Deferred" against 836 Vitest tests, a Playwright suite on two engines
and race-detector passes in three build configurations.

.pi/journal.md, "what happened and what's next", frozen at plan 005 and
still saying everything from phase 1 onward is uncommitted. That is the
tracker's job now, and a work log that is wrong about what is committed
is a hazard rather than a stale file.

docs/dev/overview.md, a shallower and partly incorrect CLAUDE.md
architecture section. README pointed at it and points at CLAUDE.md now.

docs/dev/config-suggestions.md, eight suggestions of which one survived
-- the rest were overtaken by rewrites: applyDefaults exists, scan
concurrency is configurable with SSD/HDD detection, and httphandler.go
and the WriteHeader-after-render bug it described are gone entirely.

Nothing is lost: the genuinely unbuilt work was filed first, as #94
device sync, #95 layout customisation, #96 AcoustID and #97 the config
setters that take no lock.

Refs #98
2026-08-18 16:39:21 -04:00
yonlu 20139394f3 Merge pull request 'Make the issue tracker the source of truth' (#93) from docs/issue-driven-workflow into main
CI / check (push) Successful in 2m27s
CI / e2e (push) Successful in 6m14s
Release / release (push) Successful in 30s
Reviewed-on: #93
2026-08-18 20:35:44 +00:00