Compare commits

..
Author SHA1 Message Date
yonlu 63ec068add Merge branch 'main' into fix/small-issue-batch
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Canceled after 0s
2026-08-18 15:22:56 +00:00
yonlu 3bf27e3fd5 Merge pull request 'Fix/explore art scanner requests' (#21) from fix/explore-art-scanner-requests into main
CI / check (push) Skipped
CI / e2e (push) Skipped
Release / release (push) Successful in 32s
Build & publish the Android APK / apk (push) Successful in 1m26s
Build & publish Arch package / arch-package (push) Successful in 2m35s
Attach the desktop build to the release / linux (push) Successful in 1m12s
Sync Homebrew formula / sync-formula (push) Successful in 9s
CI / check (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 0s
Reviewed-on: #21
2026-08-18 13:48:37 +00:00
yonluandClaude Opus 5 185eb1b125 feat(smartplaylist): let a rule set match any rule, not only all of them
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 0s
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>
2026-08-18 08:06:16 -04:00
yonluandClaude Opus 5 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>
2026-08-18 08:06:01 -04:00
yonluandClaude Opus 5 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>
2026-08-18 08:05:43 -04:00
yonluandClaude Opus 5 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>
2026-08-18 08:04:39 -04:00
yonlu 48abecb830 Merge remote-tracking branch 'origin/main' into fix/explore-art-scanner-requests
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m13s
2026-08-18 07:43:25 -04:00
logan e1c07438e9 docs: record what shipping the release pipeline taught us (#4)
CI / check (push) Successful in 2m21s
Release / release (push) Successful in 31s
CI / e2e (push) Successful in 6m4s
2026-08-18 03:49:00 +00:00
logan 6e563f3846 docs: record what shipping the release pipeline taught us
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m27s
CI / e2e (pull_request) Successful in 6m8s
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.
2026-08-17 23:35:59 -04:00
logan 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
2026-08-18 03:10:31 +00:00
yonluandClaude Opus 5 590a0d86dd perf(library): size the scan to the drive, and prefetch what it reads
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 5m59s
Every parser in `backend/metadata` is header-only -- a few hundred
bytes and return -- so on a spinning disk a scan is not waiting on CPU
or on bytes, it is waiting on the head to arrive. Two things follow,
and the drive says which.

**How many reads should be in flight.** This was a flat 2 for anything
rotational, which is a pre-NCQ assumption: a modern SATA disk reports a
queue depth of 32 and reorders outstanding reads into the order its
head passes over them, and was being handed a quarter of what it can
use. It gets 4 now. A drive that reports 1 -- a USB bridge, a pre-2004
disk -- services one command at a time in the order given, where every
extra worker is one more seek competing for one head and the scan gets
*slower* the harder it is pushed; that keeps 2.

**And that the next seek should already be queued.** A prefetch stage
between the walk and the workers issues `POSIX_FADV_WILLNEED` over the
first 512 KB of each file -- enough for an ID3v2 tag carrying cover
art, or FLAC's STREAMINFO and PICTURE blocks. The buffered channel *is*
the lookahead: the goroutine runs 16 files ahead of the workers,
hinting as it goes, so the read a worker needs has been in flight for
sixteen files' worth of parsing by the time it asks. Rotational only;
an SSD gets the channel back unwrapped and pays nothing, since it has
no seek to hide and already has one worker per core.

`workersForProfile` is the policy on its own so it can be tested
against drives this machine does not have, and the scan logs the
device, its rotational flag and its queue depth, so the decision is
inspectable rather than inferred.

Also: `ScanConcurrency` has been a validated three-value config field
with exactly one caller, passing the constant `auto` -- so choosing
`ssd` or `hdd` by hand did nothing at all. It reads the config now.
The two modes overrule detection about the *disk* and not about its
queue, since a user who picks `hdd` on a queueing drive still wants
that drive's queue used.

What is not here is inode-ordered dispatch. It needs the streaming walk
restructured to buffer per directory, and with queueing the drive is
already reordering what the hints put in front of it; that wants a
measurement on real hardware before the complexity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:12:15 -04:00
yonluandClaude Opus 5 36af7090d9 fix(system): resolve a path to its own disk, not the first on its major
`deviceForPath` scanned `/sys/block` comparing device numbers and, when
no entry matched exactly, took the first one whose *major* agreed. Every
SATA disk is major 8. A filesystem's `st_dev` is its **partition**, so
the exact match never hits for anything on one, and the fallback then
resolved `/dev/sdb3` to whatever `/sys/block` listed first -- which is
alphabetical, which is `sda`.

On the machine this was found on that is a Samsung SSD sitting next to
the 6 TB spinning disk the library is actually on, so
`IsRotationalDisk` answered false and the scanner ran one worker per
core across a drive with one head. Matching on major alone cannot be
right on any machine with two disks, which is the case this exists for.

It goes through `/sys/dev/block/<major>:<minor>` instead -- a symlink
the kernel maintains to the device's own sysfs directory -- and climbs
to the parent when that turns out to be a partition. One readlink, no
scan, no ambiguity. The dev_t decode goes with it: Linux packs 12 bits
of major and 20 of minor split across the word, and masking the low
byte of each is right only for the first 256 of either.

`ProfileForPath` returns what the scanner needs to ask next, and the
new half is `queue_depth`: how many commands the drive will accept and
reorder at once. A SATA disk with NCQ enabled reports 31 or 32 and one
without reports 1, which is the difference between concurrency helping
and hurting. An absent file is read as "queues", because everything
that does not publish it -- NVMe, virtio, device-mapper -- is a device
where concurrency is fine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:11:51 -04:00
yonluandClaude Opus 5 3e142f8c35 test(downloads): guard the service fixture on something the fake sets
`newServiceFixture` stops auto-pick from starting a grab, because none
of its tests is about the download and a detached `go m.grab(...)`
racing `t.TempDir()`'s cleanup is how they fail. It did that with
`MaxSizeMB: 1` -- and the size gates read `Candidate.TotalSize`, which
real providers fill and the fake leaves at zero. Zero is under every
ceiling, so the guard never fired and the race it was written to
prevent kept happening, roughly one run in fifteen:

    TempDir RemoveAll cleanup: unlinkat ... : directory not empty

The guard is a format the fake never produces. Thirty consecutive
whole-package runs, none.

`TestManualDownloadSatisfiesRequestOnSuccess` was relying on the guard
being broken -- it is the one test here that wants the download -- so
it now clears the preferences itself rather than depending on a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:11:10 -04:00
yonluandClaude Opus 5 3d375adab1 feat(downloads): bound auto-pick by bitrate, and take a good copy
Three faults, one subsystem, and the middle one is why a request that
looked obviously satisfiable came back refused.

**The guardrails were in megabytes, which cannot mean anything.** 300 MB
is a generous FLAC single and a suspiciously small boxset, and whoever
fills the field in has no idea which release the pipeline will apply it
to. `MinKbps`/`MaxKbps`/`PreferredKbps` are the same statement divided
by how long the music is, so one number holds across a nine-minute EP
and a three-hour opera. The runtime comes from `Download.Expected`,
which every anchored request already carries, so this costs no lookup;
the rate is audio bytes over that, falling back to the mean stated
per-file bitrate when the runtime is unknown. Artwork is excluded from
the numerator, or a folder with 30 MB of scans reads as a better rip.

An unknown runtime *passes* the window rather than failing it: the
window is a statement about quality, and refusing everything the moment
MusicBrainz is missing a track length would be a silent embargo.
`MaxFileSizeMB` survives as a separate ceiling, still in megabytes on
purpose -- it is a question about disk space, and it has to apply to a
candidate whose bitrate cannot be worked out at all.

**Auto-pick required daylight over the runner-up**, 0.08 on the
combined score, and so fired hardest in the case it was never written
for: a popular album turns up five *correct* copies, all matching the
tracklist at 95%+ and differing only in format and seeders, their
scores land within a point of each other, and it refused forever on the
grounds that the choice was the user's. It was not. There was no
question about what to fetch, only about which copy -- and abundance is
the condition under which that matters least. A candidate no longer has
to beat the field, only clear the bars on its own terms; where several
do, ranking puts the one closest to the preferred bitrate first.

That tie-break needed the preference to carry weight or it would have
been decorative in a new unit: `BitrateFit` was 0.05 against format's
0.42, so asking for 320 and being handed a FLAC every time was the
designed behaviour. When a preference is set the weights shift to fit
0.40 / format 0.20 / bitrate 0.10, taking it off the two heuristics
that exist as stand-ins for the preference the user has now given.
Health and priority are untouched. And the fit spans 0.5 to 1.0 rather
than 0 to 1, so a preference can promote the copy that matches it and
can never push the others under `minQuality` -- turning "I like 320"
into "never take anything else" silently is what `MinKbps`/`MaxKbps`
are for, out loud.

**And a refusal quoted numbers that passed.** The request list built its
message from `ranked[0]` -- the best candidate *before* the guardrails
and before the lead check -- so a request killed by the size window, or
by having too many good copies, reported "best of 12 found is not a
confident enough match (match 96%, quality 88%)". `AutoPickVeto` names
the gate that actually refused, and `AutoPickable` is that returning
empty.

Existing configs: the old `MinFileSizeMB`/`PreferredFileSizeMB` are not
migrated. A number meaning "300 MB" cannot be reinterpreted as a rate
without knowing the album it was aimed at, so carrying it over would be
inventing an intent nobody expressed. Those two fall back to no window,
which is the permissive default and what a fresh install gets;
`MaxFileSizeMB` carries over unchanged, because a ceiling on bytes
still means exactly what it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:51 -04:00
yonluandClaude Opus 5 e3d492e130 fix(downloads): call a request a request, and mark it with a bookmark
The feature was renamed to requests and the copy was not. The badge on
every Explore card and track row still offered "Want track X", the
album page's button read "Want this" / "Wanted", the artist page's
release menu said "Want This", and the Downloads empty state told the
user to look for a control by a name nothing rendered.

The `queued` badge is a bookmark rather than an hourglass. An hourglass
says "wait, this is under way", which overstates what a request is:
nothing may be downloading, nothing may ever be found, and the list is
somewhere a user can leave one indefinitely. A bookmark says the honest
thing -- it is on your list -- and reads as the opposite of the plus
that put it there, which is what a toggle's two states have to do.

The backend's `'wanted'` request state is deliberately untouched: it is
a stored enum, not copy.

Also removes a dead duplicate branch in the badge's `render()`. The
first `if (this.actionable)` returned before the ring was built, so a
partly-held album that could still be requested drew a plus instead of
its progress arc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:23 -04:00
yonluandClaude Opus 5 e6f30b6e43 fix(a11y): draw an unfavourited track as an outline, not a dimmer fill
`favCtrl.iconName` returned the solid glyph in both states, so "not a
favourite" was a filled heart in a duller colour and the only thing
separating the two states was hue. That fails outright for anyone who
cannot tell the two colours apart (WCAG 1.4.1), and reads as
"everything is a favourite" to everyone else.

`iconFor(favorited)` returns the outline or the fill, and the nine
`<wa-icon>` call sites split into the two cases they always were. The
three that show a *state* -- the mini player, the phone's now-playing
view, and the sidebar's marker for the favourites playlist itself --
pass it. The rest are context-menu items, which are actions rather than
states and take the outline `iconName` still returns.

`track-list` and `album-dropdown` already had this right, from inline
SVG paths of their own; this is the same rule for the call sites that
go through the icon library. `regular/star` is vendored to go with
`regular/heart`, which was already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:10:06 -04:00
yonluandClaude Opus 5 351798fd66 fix(ui): spend a row's leftover space on the gaps, not the margins
The three card grids -- albums, artists, genres -- laid out with
`justify: 'center'` and a fixed 8px gap and padding, which gives the
row a fixed width and pushes everything left over to the two margins.
Measured on a 1440px window: cards 16px apart inside 78px of nothing
down each side. The outside was five times the inside.

`utils/grid-spacing.ts` computes one number instead, from what the row
could not spend on another card: the same value between two cards,
between two rows, and down each edge. That window now reads 30px
outside against 34px between, and it holds at any width.

The virtualizer has a word for this -- `justify: 'space-evenly'` with
`gap: 'auto'` -- and it cannot be used. It fits `floor(width /
cardWidth)` columns without reserving the gap it is about to need, so a
width one card short of exact leaves seven cards a pixel apart. On the
window above it would fit 7 columns with 1px between them. Deciding the
column count here is what puts a floor under the spacing.

Two consequences. The layout is rebuilt when the container width
changes the spacing rather than only when the cover size changes, so
each grid observes its own scroller -- keyed on the spacing, or every
pixel of a drag rebuilds a layout that comes out the same. And
`cover-grid`'s ScrollManager took `GRID_GAP`/`GRID_PADDING` as
constants, which stopped describing anything the moment the spacing
became elastic: it asks the host for the geometry now, since a scroll
position rebuilt from a stale 8px lands in the wrong row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:09:50 -04:00
yonluandClaude Opus 5 40984f6086 fix(explore): let a slow archive node finish, and read the 404 back
Explore's album art was almost entirely missing: 5 of 24 cards on the
shelves had a cover, and those five were the ones already on disk.

The Cover Art Archive answers `front-250` with a 307 to an Internet
Archive storage node, and those nodes are slow. Measured against the
twelve albums on Explore's own shelves, a successful fetch took 14-16 s
and a failing one 13-17 s, against a client timeout of 10. So every
live fetch died, and a timeout writes nothing and says nothing -- which
is why this reads as "Explore has no album art" rather than as a slow
upstream. The timeout is 30 s, chosen to clear the measured range: the
fetch is off the critical path, so waiting costs nothing and giving up
early costs the whole page.

Two things beside it, both found on the way.

`writeCache(mbid, nil)` has recorded "the archive has no art for this"
as an empty file since it was written, and nothing has ever read it
back: `readCache` returns "" for an empty file, which is
indistinguishable from a miss. So every art-less release group was
re-fetched from CAA on every render that asked about it. A third of the
shelves are art-less, so that was a third of the page spending a live
request to be told again what the last one said. `knownMissing` reads
it, on both the release-group and the release path.

And the frontend marked a failed fetch as permanently answered for the
session, so a timed-out cover never retried within it. It drops the
marker instead; a genuine 404 is now answered from disk, so re-asking
one costs nothing.

Measured after: 23 of 24.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:09:14 -04:00
logan 786d9c6110 fix(release): seed the version floor on the parent, not on HEAD
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m38s
CI / e2e (pull_request) Successful in 6m14s
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 6fb7b5e by hand; this is so the next repo
never needs that.
2026-08-17 20:37:43 -04:00
logan 0019310ca4 ci(release): cut releases from main automatically (#2)
Release / release (push) Failing after 40s
CI / check (push) Successful in 3m6s
CI / e2e (push) Successful in 7m16s
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.
2026-08-18 00:22:13 +00:00
logan 1940cb548f fix(test): stop asserting a cache hit against a one-second deadline
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 3m51s
CI / e2e (pull_request) Successful in 6m21s
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.
2026-08-17 19:55:22 -04:00
logan 37e3373db9 docs: correct the workflow counts these comments name
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Failing after 1m35s
CI / e2e (pull_request) Skipped
Adding release.yml and desktop-assets.yml made 'the three workflows a
tag fires' wrong in three files that each said it slightly differently.
2026-08-17 19:47:55 -04:00
t 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.
2026-08-17 19:46:48 -04:00
logan 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.
2026-08-17 19:21:19 -04:00
logan 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.
2026-08-17 18:43:17 -04:00
logan 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.
2026-08-17 18:39:06 -04:00
logan 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.
2026-08-17 18:39:06 -04:00
logan 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.
2026-08-17 18:38:55 -04:00
yonlu 6fb7b5ea11 Merge pull request 'ci: trigger the catalog job deliberately, pin agent docs to one file' (#1) from chore/workflow-guardrails into main
Build & publish Arch package / arch-package (push) Successful in 2m40s
CI / check (push) Skipped
CI / e2e (push) Skipped
Build & publish the Android APK / apk (push) Failing after 56s
Sync Homebrew formula / sync-formula (push) Successful in 5s
Reviewed-on: #1
2026-08-17 20:19:38 +00:00
yonluandClaude Opus 5 369810e06b ci: stop testing every commit twice on a runner there is one of
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m53s
CI / e2e (pull_request) Successful in 6m26s
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
2026-08-17 15:44:00 -04:00
yonluandClaude Opus 5 e51cb13662 ci: trigger the catalog job deliberately, pin agent docs to one file
CI / check (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 0s
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
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
2026-08-17 15:30:38 -04:00
logan 3d65da0529 test(download): stop racing a download these tests never wanted
Build & publish Arch package / arch-package (push) Successful in 2m36s
CI / e2e (push) Successful in 6m25s
CI / check (push) Successful in 2m43s
`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.
2026-08-17 14:16:45 -04:00
logan 52cbef27c4 docs: name the guard that covers every cache table
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
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.
2026-08-17 14:01:28 -04:00
logan c03c0b8ec4 test(database): the next destructive repair fails a test, not a volume
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 3m14s
CI / e2e (push) Canceled after 3m3s
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.
2026-08-17 13:56:08 -04:00
yonlu 8c48105ca3 Merge remote-tracking branch 'origin/main' into wails-v3
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m31s
2026-08-17 13:52:34 -04:00
logan 1c4d6ca9a1 ci: stop booking three hours of runner on every push
Build & publish Arch package / arch-package (push) Successful in 2m34s
CI / check (push) Canceled after 53s
CI / e2e (push) Canceled after 0s
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.
2026-08-17 13:29:58 -04:00
yonluandClaude Opus 5 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
2026-08-17 13:13:05 -04:00
yonluandClaude Opus 5 4f8257ef72 fix(database): never retire the catalog the index build derives
Search index maintenance / maintain-index (push) Canceled after 0s
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
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
2026-08-17 12:24:49 -04:00
yonlu b505959934 Merge remote-tracking branch 'origin/main' into wails-v3
Build & publish Arch package / arch-package (push) Successful in 2m32s
CI / check (push) Successful in 3m10s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 1h31m25s
2026-08-17 11:38:49 -04:00
logan 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.
2026-08-17 10:51:23 -04:00
logan de2b324e20 feat(explore): refuse 0.6 GB on someone's mobile data
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
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.
2026-08-17 10:48:00 -04:00
logan 2c78b58207 feat(ui): the track list a phone can read
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m15s
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.
2026-08-17 10:36:29 -04:00
logan a9852c18a0 docs: the device answered both open questions, and neither as expected
Build & publish Arch package / arch-package (push) Successful in 2m39s
CI / check (push) Successful in 2m39s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m57s
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.
2026-08-17 10:15:01 -04:00
yonluandClaude Opus 5 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
2026-08-17 09:22:51 -04:00
yonluandClaude Opus 5 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
2026-08-17 08:34:27 -04:00
yonlu 4fc0cdeab7 Merge remote-tracking branch 'origin/main' into wails-v3 2026-08-17 08:29:21 -04:00
yonluandClaude Opus 5 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
2026-08-17 08:27:05 -04:00
yonluandClaude Opus 5 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
2026-08-17 08:26:34 -04:00
yonluandClaude Opus 5 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
2026-08-17 08:25:36 -04:00
logan 0bfa2136be feat(dev): ask the phone instead of looking at it
Build & publish Arch package / arch-package (push) Successful in 2m29s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m53s
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.
2026-08-17 05:13:21 -04:00
logan b1cdef8769 docs: record what a phone said that no tier could
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 5s
CI / e2e (push) Successful in 6m10s
CI / check (push) Successful in 3m28s
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.
2026-08-17 02:02:10 -04:00
logan 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.
2026-08-17 02:02:10 -04:00
logan 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.
2026-08-17 02:01:57 -04:00
logan 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.
2026-08-17 02:01:46 -04:00
logan 7e0be8fa30 fix(indexexport): read an index older than the binary
Build & publish Arch package / arch-package (push) Successful in 2m24s
CI / check (push) Successful in 2m29s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m38s
`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.
2026-08-17 00:39:19 -04:00
logan 1b05dde382 feat(ui): the full-screen now playing a phone needs
CI / check (push) Successful in 2m25s
Search index maintenance / maintain-index (push) Failing after 2m53s
Build & publish Arch package / arch-package (push) Successful in 2m27s
CI / e2e (push) Successful in 5m55s
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`.
2026-08-17 00:22:58 -04:00
logan 29299d17da fix(dev): run the local e2e tier against the app CI runs
Build & publish Arch package / arch-package (push) Successful in 2m26s
CI / check (push) Successful in 2m30s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m5s
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.
2026-08-16 23:47:24 -04:00
logan 57fbbdf0d2 feat(ui): a shell a phone can be held in
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
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.
2026-08-16 23:19:26 -04:00
logan df2e9ea777 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m5s
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.
2026-08-16 22:26:39 -04:00
logan 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.
2026-08-16 22:26:29 -04:00
logan 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.
2026-08-16 22:26:21 -04:00
logan b6651310ea build(android): drop the x86_64 ABI, which no Android can run
The fat APK's second half was 31 MB that cannot execute on any Android
device. modernc.org/libc's Xlstat64 issues a raw lstat syscall on
linux/amd64, and Android's seccomp policy forbids it because bionic
never issues it, so the process takes SIGSYS the first time anything
touches the database -- which for this app is startup. That is every
x86_64 Android, x86 Chromebooks included, not merely the emulator.
arm64 is structurally unaffected: the architecture has no lstat syscall
at all, so modernc routes through fstatat.

27,059,130 bytes to 15,898,465, and one lib/ entry.

Three places had to agree, and the third is what would have made this a
silent no-op: abiFilters (what Gradle packages), android:package rather
than package:fat (what Go *compiles* -- otherwise the library is still
built and then discarded), and the native-code assertion in CI. That
assertion is anchored, `native-code: 'arm64-v8a'$`, because without the
anchor it also matches the fat APK's line and would pass on exactly the
thing it exists to catch. Checked against a real artifact.

Adding the ABI back, if modernc ever fixes Xlstat64, is those same
three edits.
2026-08-16 22:26:11 -04:00
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an offset and re-applies the user's level through setVolumeLocked,
so it cannot accumulate across repeated ducks and getUserVolume -- which
feeds the event, the persisted state and every relative change -- still
reports what the user chose. Writing through to the volume would let
one notification tone permanently turn the music down.

**The duck path is pre-Oreo only.** From API 26 the framework ducks the
app itself and sends no CAN_DUCK focus change; asking to be told
instead (setWillPauseWhenDucked) would mean pausing for every
notification tone, and doing both would attenuate twice.

**An unchanged payload is not an event**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00
logan ced537ecf2 docs: record which Android blockers are now cleared
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m57s
CI / check (push) Successful in 2m37s
2026-08-16 17:18:22 -04:00
logan e14a34fccf fix(android): let the app reach the user's music
Three of plan 016's four blockers. Each is a different reason the app
could not work at all on a phone.

**It had no permission to read anything.** The generated manifest asked
for INTERNET, VIBRATE, biometrics, location and a camera, and nothing
whatever about storage -- so at targetSdk 35 the app could see its own
private directory and no music. It now declares READ_MEDIA_AUDIO, the
two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE.

That last one is deliberate and is the load-bearing choice. This app is
a library manager: audio_files.file_path is the primary key of
ownership, the scanner walks a directory the user chose, and tagwriter
rewrites files in place. MediaStore offers no stable directory to walk
and no in-place write, so scoped storage is not "more work" here, it is
a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted,
which is acceptable only because this ships as an APK through the
package registry -- if it ever targets Play, that line is what has to
go, and plan 016 says what replaces it.

It is granted on a Settings screen rather than in a dialog, so it
cannot be requested with requestPermissions(). MainActivity opens that
screen on every cold start until access exists -- there is no degraded
mode worth offering -- and re-checks in onResume, because the way back
from another task is a resume, emitting android:storageAccess so the
frontend can react.

**The first-run flow could not complete.** All three call sites asked
for a folder through the Wails dialog, which returns an error on
Android: SAF yields tree URIs and this app is keyed on paths. So the
app browses the filesystem itself, which it can now do. ListDirectories
lists directories only (the thing being chosen is a library root),
skips what it cannot stat rather than failing the listing (Android's
storage root holds directories no app may enter), follows symlinks
(os.DirEntry reports the link, so a symlinked music folder would
silently vanish), and hides dotted entries.

utils/pick-directory.ts is the one place that chooses between the two,
so the three call sites changed by one line each. **Which platform is
asked of the backend**, not of System.IsAndroid(): the dialog is
backend code, so the backend is what knows whether it can open one; it
answers for iOS at the same time; and it keeps the fallback testable
through the ordinary transport fake rather than a module mock of the
Wails runtime, whose platform helpers read build constants.

**And MPRIS was compiled into the Android build**, because android
implies the linux build tag, so it went looking for a session bus that
does not exist. mpris_linux.go is `linux && !android` now and the stub
covers Android, which means no lock-screen transport there yet -- a
missing feature rather than a broken one, and the remaining blocker.

The foreground service is typed mediaPlayback rather than the
scaffold's dataSync, with the matching permission, so playback can
survive the screen locking once there is a MediaSession to drive it.
The type in the manifest and the one passed to startForeground must
agree or startForeground throws.
2026-08-16 17:18:03 -04:00
logan 78576b8da9 docs: assess what Android parity would take
Build & publish Arch package / arch-package (push) Successful in 2m40s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 3s
CI / check (push) Successful in 2m18s
Plan 015 shipped a pipeline; this is what stands between that and an
app worth installing. Verified against the source and the generated
manifest rather than guessed.

Four blockers, and none of them is porting work. The manifest requests
no storage or media permission at all, so the app can read no music --
and READ_MEDIA_AUDIO would not be enough, because it grants access
through MediaStore while this app's whole model is absolute paths:
audio_files.file_path is the primary key of ownership and every
GetFilePathsBy... query exists to hand paths to the player. The
first-run wizard calls DirectoryPicker, which Wails documents as
returning an error on Android, and the wizard intercepts pointer events
until a library exists, so the app is inert rather than merely empty.
mpris_linux.go is compiled in, because android implies linux. And the
scaffold's foreground service is typed dataSync rather than
mediaPlayback, with no MediaSession and no audio focus, so playback
dies at screen lock and there are no lock-screen controls.

They are all the same question: is the Android app a librarian or a
player? The desktop app is a librarian -- it scans folders, dedupes
covers, rewrites tags on disk -- and that model rests on owning a
filesystem, which is exactly what Android declines to give. So the plan
argues that parity is the wrong target and lays out three coherent
products instead, recommending a MediaStore-backed player.

Four things are worth doing whatever is decided, and the highest
information-per-minute one needs no code: run the published APK on a
real phone. Nothing in sections A or B has been observed on Android,
because the x86_64 emulator cannot run the app and emulator 37 refuses
arm64 images on an x86_64 host.
2026-08-16 17:02:53 -04:00
logan 01706c6053 ci(android): say why the keystore did not open
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m41s
"the keystore did not open — is ANDROID_KEYSTORE_PASSWORD right?" is a
guess, and there are three quite different reasons behind it. The step
distinguishes them now.

**A secret pasted into a web form very often carries a trailing
newline**, and a password is compared byte for byte, so the run failed
with a password that was correct. Reproduced exactly: keytool rejects
`Correct123\n` against a keystore whose password is `Correct123`. CR
and LF are stripped from the password, the alias and the key password
now, and the step says when that mattered.

**A wrong alias failed a minute later, inside Gradle.** It defaults to
`yellowjacket`, so any keystore created with another alias got there.
The alias is checked up front and the failure lists the aliases the
keystore actually holds.

**And a truncated or mis-pasted base64 is a different problem from a
bad password**, so the artifact is described before it is opened: size
and its first four bytes, named as PKCS12 or legacy JKS, with a warning
when the header is neither. A truncation shows up as 300 bytes against
2564.

Verified against real keystores for all five cases: correct, trailing
newline, wrong password, wrong alias, truncated base64.

Decode and build are one step now. Splitting them would mean either
handing the password to a later step through $GITHUB_ENV -- where the
env dump is only masked for values that are verbatim a secret, so a
trimmed one could print in clear -- or repeating the trimming in both.
The failure message also prints the password's length, which is the
one thing that distinguishes "wrong value" from "invisible whitespace",
and only on failure.
2026-08-16 17:00:36 -04:00
logan f7dc76c955 docs(android): an arm64 image will not run on an x86_64 host
Build & publish Arch package / arch-package (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m42s
CI / check (push) Successful in 2m22s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Build & publish the Android APK / apk (push) Failing after 50s
Emulator 37 refuses cross-architecture emulation outright -- "Avd's CPU
Architecture 'arm64' is not supported by the QEMU2 emulator on x86_64
host" -- and there is no flag for it. Google dropped it.

That matters because the previous commit's finding points at arm64 as
the ABI that works, so the obvious next move is to boot an arm64 AVD,
and the obvious next move costs a 3.8 GB download before it fails.
Written down so the next session does not spend it.

The consequence is stated rather than hidden: the claim that arm64
avoids the seccomp trap rests on reading modernc's two code paths, not
on having run it. Verifying it needs an arm64 host, a physical device
or adb connect.
2026-08-16 16:27:52 -04:00
logan ed975019dc fix(dev): the smoke target died silently on a genuinely dead app
Two harness bugs and the finding that exposed them.

**`pidof` exits 1 when it finds nothing**, and under `set -e` a failing
command substitution killed the script before it could print anything
-- rc=1, no output. That was invisible for as long as the app
crash-*looped*, because there is always some pid in that state. It
appeared the moment the app died for good and ActivityManager stopped
respawning it, which is precisely the run you most want output from.

**And an install failure said nothing useful.** Both ways it fails are
about identity rather than the build: INSTALL_FAILED_VERSION_DOWNGRADE
when a bare `make android` (versionCode 1) meets something a versioned
build left behind, and a signature mismatch when a debug-signed local
build meets a release-signed one. Both were hit in one session, and
both are fixed by uninstalling. The target says so now instead of
leaving someone to read the constant name.

The finding: with the startup bug fixed the app reaches the database
and takes SIGSYS on the x86_64 emulator, because modernc.org/libc's
Xlstat64 issues a raw lstat syscall on linux/amd64 and Android's
seccomp filter forbids it -- bionic never issues it. arm64 has no lstat
syscall at all, so ccgo_linux_arm64.go routes Xlstat through fstatat
and is structurally unaffected; Go's own syscall package already used
fstatat on both.

So the default emulator cannot verify this app, and the skill says so
rather than letting the next session read a tombstone as a regression.
2026-08-16 16:25:33 -04:00
logan 0c7f34ab90 fix(android): give the app a home directory so it starts
backend/system resolves config and data from $HOME or the OS
equivalent, and Android has neither: buildUserDirPath switches on
runtime.GOOS with cases for darwin, linux and windows and a default
returning errUnsupportedOS. So NewYellowJacketApp failed and main()
called os.Exit(1) about six milliseconds after the JNI bridge came up.

That failure is invisible in all three places anyone would look. There
is no panic, no AndroidRuntime stack and no tombstone, because os.Exit
is not a crash; Go's stdout does not reach logcat, so the slog line
naming the error is discarded; and ActivityManager respawns the process
fast enough that pidof always answers, so a crash-looping app looks
alive.

main() now sets the override before anything asks for a path.
application.Mobile.StoragePath() is the platform's own answer --
getFilesDir() on Android, Application Support on iOS -- and returns ""
on desktop, where UseHomeOverride is a no-op, so this needs no build
tag and changes nothing off mobile. resolveUserDirPath already honours
YJ_HOME on every OS, so there was a seam for it.

The knowledge stays in main(): backend/system gains no import of the
Wails application package, for the same reason backend/events is split
by the indexbuild tag.

UseHomeOverride's two rules are tested because nothing else would
notice them breaking. An empty base does nothing, which is exactly the
desktop case. And an override already set wins, so YJ_HOME still
relocates a sandbox on the one platform that would otherwise decide for
itself.

This is not the end of the port. The app now reaches the database and
takes SIGSYS on the x86_64 emulator -- modernc.org/libc issues a raw
lstat syscall on linux/amd64 and Android's seccomp forbids it. arm64,
which is what ships to phones, has no lstat syscall at all and routes
through fstatat, so it is structurally unaffected. See NOTES.md.
2026-08-16 16:25:20 -04:00
logan a7a33527c4 docs: record what the Android work established and disproved
Build & publish Arch package / arch-package (push) Successful in 2m26s
CI / e2e (push) Successful in 5m51s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CLAUDE.md said `wails3 task common:update:build-assets` regenerates
build/ios/ and build/android/. It does not: in beta.8 that command
extracts only updatable_build_assets, which is darwin/ios/linux/windows,
and the android tree comes from `generate build-assets`. It also said
nfpm's homepage and license are left alone by the refresh -- a comment
in that file says the same -- and a refresh reset them to wails.io and
MIT. Both corrected, and the CI section now describes five workflows.

NOTES.md gains the measurements: what cross-compiles and what does not,
the emulator environment, the Wails Android documentation's own two
errors, and the one line that stops the app at runtime --
buildUserDirPath switches on runtime.GOOS and Android takes the default
branch returning errUnsupportedOS, so main() calls os.Exit(1) six
milliseconds after the JNI bridge comes up.

The fix is a documented, build-tag-free API:
application.Mobile.StoragePath() returns the app's private files
directory and returns "" on desktop, and resolveUserDirPath already
lets YJ_HOME override the path on every OS. Deliberately not taken here
-- plan 015 is a pipeline, not a port, and the larger question it does
not answer is that open-directory dialogs return an error on Android
while this app's entire first run is "choose your music folder".
2026-08-16 15:31:18 -04:00
logan 0c6ca72cf1 ci(android): publish a signed APK on every version tag
Builds the fat APK and puts it in Gitea's *generic* package registry,
which unlike the repository is readable without credentials -- the
reason an Obtainium client can poll a plain URL with no token and no
public mirror of the source. A versioned copy for history, a fixed
`latest` URL to watch.

**Its own workflow, not a job in ci.yml.** That workflow runs on every
branch push and is the one that gates; this takes tens of minutes on a
cold cache and the runner has capacity 1, so hanging it off the gate
would put every push behind an SDK download.

**Keyed on the tag.** The ljos pipeline this is modelled on computes a
version in CI and cuts the release itself, then gates its Android job
on needs.release.outputs.version with an always() whose absence
silently kills the manual path. This repo has no release automation --
tags are pushed by hand and homebrew-formula.yml already keys on v* --
so the tag is the version and none of that machinery, or its failure
modes, is needed.

**No continue-on-error**, which that pipeline does carry: there the
Android job shares a workflow with a server deploy that must never go
red over a phone build. Here it is standalone and can neither delay nor
redden anything, so a release step that fails silently would be
strictly worse than one that fails visibly.

Four gates before anything is published, each checked against a real
APK: a non-empty artifact, both ABIs present, a versionCode equal to
the one derived from the tag, and -- verified by pointing it at a
deliberately debug-signed build, which it refused -- **not signed with
the debug key**. Android refuses to update an app whose signing
certificate changed and the only remedy is an uninstall that takes the
user's library with it, so the job also refuses to *build* without the
keystore secret rather than falling through to Gradle's debug default.

The keystore is opened with `keytool -list` before Gradle runs, because
Gradle only notices a bad password at :app:validateSigningRelease, a
minute of build time in, and reports it as a missing file. And nothing
pipes into `head`: under pipefail it exits after one line, the producer
takes SIGPIPE and the step fails with 141 having already printed a
perfectly good APK.

Two secrets, not four. keytool has produced PKCS12 by default since
JDK 9 regardless of the .jks extension, and PKCS12 cannot hold a key
password distinct from the store password -- given one it says so and
ignores it. So ANDROID_KEY_PASSWORD defaults to the store password and
the alias to a documented default.

The Wails CLI needs no caching hack here: it is a vendored `go tool`
and the runner already bind-mounts GOCACHE for every job, so it is warm
from ci.yml's own bindings-check. A fourth cache volume for
GRADLE_USER_HOME saves ~700MB a run.
2026-08-16 15:31:18 -04:00
logan 68468e5378 feat(dev): an Android failure looks exactly like a success
The APK installs and launches. It also dies six milliseconds later, and
finding that out cost a cycle for three reasons that have nothing to do
with the bug itself:

**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
to /dev/null, so every slog line -- including the one naming the error
the app is about to exit on -- is discarded. `setprop
log.redirect-stdio true` does not help: that redirects the Java
runtime's System.out, and our code is a c-shared native library.

**os.Exit leaves no evidence.** No panic, no AndroidRuntime stack,
nothing in /data/tombstones, nothing in `logcat -b crash` or dropbox.
All three places anyone would look are empty, and the one signal that
is present -- "Zygote: exited due to signal 9" -- reads as "the system
killed it" and sends you after the low-memory killer.

**ActivityManager restarts it faster than you can observe.** pidof
always answers and `am start` always reports Status: ok, so a
crash-looping app looks alive. "Did it start" is the wrong question;
`make android-smoke` asks whether it is the *same pid* N seconds later,
and prints the filtered logcat plus how to read it when it is not.

The tell, once known: "I/WailsBridge: Wails bridge initialized"
followed immediately by a new pid doing the same thing.

scripts/android-emulator.sh follows dev-headless.sh's shape --
background start, saved-PID stop, filtered log tail, never pkill -f.
Two scaffold tasks are deliberately not wrapped: `android:logs` greps
logcat for (Wails|yellowjacket), which catches the WailsBridge tag but
misses the app's own process tag (app.yellowjacket is lowercase) and
misses ActivityManager's "has died" line, which is the one that says it
crashed; and `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be sequenced.

One environment note that is not obvious on Arch: Gradle needs a
platform and /opt/android-sdk has none, so ANDROID_SDK defaults to
~/Android/Sdk while ANDROID_NDK points at /opt/android-ndk. Two SDKs,
one for each half of the build.
2026-08-16 15:31:18 -04:00
logan 6fbb62730d fix(android): build a release APK that is releasable
Three edits to the scaffold, each of which the generated tree gets
wrong for a shipped app.

**The phone ABI got a debug library.** Upstream's `build` task forwards
ARCH to compile:go:shared but not PRODUCTION, so the arm64 leg
recomputed BUILD_FLAGS against an unset variable and took the debug
branch -- while amd64, which package:fat calls directly with
PRODUCTION: "true", was correct. A release APK therefore shipped a 40MB
unstripped debug library for the only ABI a release is for, beside a
31MB production one for the emulator. 34MB APK before, 27MB after.

**The APK could be installed once and never updated.** Android orders
releases by versionCode and refuses anything not greater than what is
installed; the scaffold hardcodes 1, so the first install would have
been the last and the only way out is an uninstall, which takes the
user's library with it. It comes from YJ_VERSION_CODE now, which CI
derives from the tag (1.3.1 -> 10301, monotonic while minor and patch
stay under 100), with a default that keeps a local build working.

Integer.parseInt, not `(...) as Integer`: Groovy binds the call
parentheses to versionCode before the cast, so the latter reads as
`versionCode("1") as Integer` -- it sets a String, then casts the
setter's null return, and Gradle fails the whole project with "Value is
null" pointing at that line.

**And it identified itself as com.wails.app.** applicationId is
app.yellowjacket now, matching build/config.yml's productIdentifier,
and the label is YellowJacket rather than "Wails App".

Two things follow from that rename and both bite:

The identity is declared twice. applicationId is what Gradle installs;
APP_ID in build/android/Taskfile.yml is what every adb-driven task
uninstalls, launches and filters, and nothing enforces agreement.
ANDROID.md says to set APP_ID in build/config.yml -- that does nothing
in beta.8, checked both ways: `wails3 task` builds its var set from CLI
KEY=VALUE arguments and the Taskfile tree and never reads config.yml,
and even when set it feeds only those adb commands, never Gradle.

And `namespace` deliberately stays com.wails.app, because that is the
Java package MainActivity and WailsBridge live in and renaming it means
renaming their source. So the launcher activity is
app.yellowjacket/com.wails.app.MainActivity, and the short
`.MainActivity` form resolves the dot against the applicationId and
fails with a class-not-found that reads like a broken build.
2026-08-16 15:30:35 -04:00
logan 48b37f6301 build(android): carry the Wails Android scaffolding verbatim
Plan 015 phase 0 established that this app cross-compiles for Android
with no source changes at all. A CGO_ENABLED=0 probe of the whole tree
for android/arm64 fails on exactly two packages -- ebitengine/oto/v3
and wails/v3/pkg/application -- and both fail only because their
Android implementation is cgo, which is what the NDK supplies. Notably
modernc.org/sqlite, the entire database layer and the thing most likely
to have no Android target, is clean. The fat APK (arm64-v8a + x86_64)
builds in about 25 seconds.

So build/android/ stops being ignored. This commit is the tree exactly
as `wails3 generate build-assets` emits it, so that the next commit is
a readable diff of what we changed and a future refresh has something
to compare against.

Two things about how it is carried:

`wails3 update build-assets` does NOT generate it, contrary to what
CLAUDE.md has claimed since the v3 migration. In beta.8 that command
extracts only internal/commands/updatable_build_assets, which is
darwin/ios/linux/windows; the android tree comes from `generate
build-assets`, which rewrites the whole of build/. It was generated
once into a scratch directory and copied across, so from here it is
committed and hand-edited like source. Only its output is ignored --
jniLibs (~60MB of per-ABI c-shared libraries), gen/, overlay.json and
Gradle's own directories.

And it brings one Go file into ./... -- scripts/deps/install_deps.go,
the interactive SDK installer behind `task android:install:deps`, which
trips 24 of our strict linters. golangci excludes the directory rather
than reformatting upstream's file, which the next refresh would undo
and which would make the diff against upstream unreadable. This repo
uses `make android-setup` instead.
2026-08-16 15:30:13 -04:00
logan 66182f82cd fix(indexbuild): repair the one database a squash cannot reach
Build & publish Arch package / arch-package (push) Successful in 2m34s
CI / check (push) Successful in 2m36s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 5m32s
The index job's /cache volume is a real YJ_HOME that outlives every
run, so plan 013's reshaped audio_files met a database still in the
old shape: `CREATE INDEX ... album_id` against a table without that
column, on every launch. "Delete and rescan" is the squash's answer
and is free everywhere except here, where half the file is the catalog
and deleting it costs ~205GB of downloading.

indexbuild now drops every table datamap does not classify as Cache
before the schema is applied. Nothing scans, plays or authors in that
database, so its non-catalog half is empty by construction and a shape
the schema stopped describing is pure liability; the catalog is never
touched.

TestRetireLibraryTables reproduces the failure symptom-first: build
the real schema, put audio_files back the way the volume had it,
assert the open fails, then assert the repair makes it open with the
catalog row still there.
2026-08-16 15:09:09 -04:00
logan 18aba34c08 test(e2e): a track plays the list it is in, not a queue of one
Build & publish Arch package / arch-package (push) Successful in 2m31s
CI / check (push) Successful in 2m46s
Search index maintenance / maintain-index (push) Failing after 5s
CI / e2e (push) Successful in 6m30s
e7748f1 made double-click and single-row Play queue the list as
displayed with startIndex on that row; this frozen spec still asserted
a queue of one, and was the only failure in both the chromium and
webkit runs on main.

It asserts the new contract instead: more than one track queued,
currentIndex on the row that was activated, and the panel showing that
queue rather than some other one.
2026-08-16 14:51:07 -04:00
logan b98840ee37 fix(build): keep the index tools free of the Wails application
The v3 migration put application.Get() in backend/events and a
ServiceStartup hook in backend/explore, both of which cmd/indexbuild
reaches. v3's application package is GTK/WebKit bindings on Linux, so
the index-artifact job — a plain golang container with CGO_ENABLED=0,
on the stated grounds that neither command imports the app — stopped
compiling with "undefined: pointer". That job owns the ~205 GB dump
checkpoint, so it is the worst place to learn this.

Both are behind the indexbuild tag now: the one app.Event.Emit lives in
runtime_wails.go, runtime_indexbuild.go answers ErrNoRuntime (what the
app itself returns before Run, so Deliver's callers need no second
path), and explore's ServiceStartup moves to its own tagged file.

TestIndexToolsDoNotImportWails walks `go list -deps -tags indexbuild`
so the claim the workflow makes is checked rather than assumed.
2026-08-16 14:51:01 -04:00
yonluandClaude Opus 5 dd17a4d8eb Merge origin/main into wails-v3
Build & publish Arch package / arch-package (push) Successful in 2m39s
Search index maintenance / maintain-index (push) Failing after 23s
CI / e2e (push) Failing after 6m17s
CI / check (push) Successful in 2m37s
21 conflicts, all from the same cause: three features were developed on
both lines and this branch's copies are the ones adapted to v3's
bindings and to the file-shaped schema. Resolutions:

- `frontend/wailsjs/` stays deleted — v2's generated bindings, replaced
  by `frontend/bindings/`.
- remove-from-library, `library-status.ts`, the requested-badge spec and
  its component test: took this branch's copies, which differ from
  main's only in calling `pruneEmptyEntities`/`CountAudioFiles`,
  importing `@go/download/models.js`, and staging a real UUID for the
  catalog's `CHECK(length(mbid) = 16)`.
- `GetFilePathsByRecordingMBIDsByLibrary` dropped: it joined
  `recordings`, which no longer exists, and `library_id = 0` answers
  both scoped and unscoped now. `GetAudioFilesByPaths` was already here.
- The album page, the artist page and the library badge kept this
  branch's versions, which supersede main's: ownership asked once from
  the files, the partial-completeness ring, and the request action.
- Docs: no migration chain (013) over main's two-file column rule and
  its pre-1.0 squashing note, both of which 013 retired. Kept main's
  `CreateSmartPlaylist` read-pool example, which is a real second
  instance of that bug.

Verified on the merge result, not on either parent: lint clean in all
three build configurations, `make test` green in all three, 776 Vitest
tests, `tsc --noEmit`, bindings-check and skill-check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 14:09:12 -04:00
yonluandClaude Opus 5 e7748f1fd5 feat(database): shape the library like files, and shrink the catalog
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s
Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 13:58:15 -04:00
logan c94c97f604 docs: move plan 009 to completed
Build & publish Arch package / arch-package (push) Successful in 2m4s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m59s
2026-08-13 15:18:22 -04:00
logan 4801ba4480 docs: close plan 009, and what a decision phase found
Two of Phase 2's three judgement calls were answered by reading the
code rather than by choosing: there is no artist badge to make a
button, and a track badge stops reading as noise the moment it means
something. The third went the other way — `EntityRecording` reads like
a placeholder and is real work.
2026-08-13 15:18:17 -04:00
logan 40bc968cf8 test(e2e): a real click on the badge acts without opening the card
Only this tier can say it: the badge sits inside a card whose own click
navigates, so what matters is that a real gesture files the request
*and* leaves the page where it was.

It clicks a locator rather than a measured point. The first version
read a bounding box the moment the search settled, but cover art is
still arriving then and a card that grows moves the badge — so the
click landed on the card and opened the album, which is precisely the
regression the test exists to catch, reported as a failure to file a
request.

The phase 1 label assertion moves with the component: a control is
named after what activating it does, so the badge that said "is queued
for download" now says "Cancel the request for …".
2026-08-13 15:17:14 -04:00
logan e61b7456df feat(explore): make the library badge request what it is on
007 turned this badge from a `<button>` whose handler was a
`stopPropagation()` and a TODO into `role="img"`, on the rule that a
control which cannot act is worse than none — and wrote down what would
change the answer: a `<button>` again *with* a handler, never a handler
bolted onto something already shaped like one. This is that.

A call site opts in by passing `request-mbid`, so where a badge is
redundant it stays a badge: `explore-album-details`'s header has "Want
this" in words directly below it, and its template says so by not
opting in. An `in-library` badge is never a button either, because
there is nothing left to ask for — that is what keeps the tab stops 007
gave back from being spent on nothing.

The copy is the action, not the state, and it is deliberately about the
request list rather than the library: "Want album X" / "Cancel the
request for album X". Clicking still adds nothing to the library, which
is what made the original "Add … to library" a promise the control
could not keep.

Tracks are requestable too. `EntityRecording` is not a placeholder in
the request model — `Reconciler.tracklistFor` has a deliberate branch
for it, because one expected title is what lets filename matching score
a single-track download at all. Artists are not: there is no artist
badge anywhere, and a discography subscription belongs on the Follow
button that can say what it commits to.

The click is swallowed again, for the opposite reason to before: with
an action of its own, a click on the badge no longer means what the
card means. Enter and Space are stopped for the same reason — every
card holding one is a role=button or role=option with its own handler.
2026-08-13 15:17:06 -04:00
logan 979c6e83ed docs: open plan 009 and record what phase 1 found
Build & publish Arch package / arch-package (push) Successful in 2m4s
Search index maintenance / maintain-index (push) Successful in 7s
CI / check (push) Successful in 2m32s
CI / e2e (push) Successful in 6m21s
The plan's own framing was wrong in a way worth keeping: the badge was
not waiting on the download client, which had largely landed already —
it was waiting on somebody looking at a state nothing produced.
2026-08-13 14:14:39 -04:00
logan 48f7795687 test(e2e): pin the requested badge and the state it renders in
Two assertions, and the second is why this is at this tier at all.
Reaching the requested state is the only way to render the requested
icon, so the sweep that already asserts `__yjIconMisses` is empty can
finally see a name computed from state.

Both were watched failing on the pre-fix build by neutering one line
each: the badge reported `not-in-library` where `queued` was expected,
and the sweep returned `["bookmark-check"]`.

The spec gives back what it spends — the request is dropped in
`afterAll`, and cleared in `beforeAll` too, since a run that dies
between the two would otherwise fail the next one. That cleanup uses
the raw binding rather than `callBinding`: a bare `browser.newPage()`
has no init script, so the event bridge is undefined and the first
version threw where nobody was looking.

Its 60 s search budget is not paranoia either. A freshly launched app
spends ~40 s merging the core catalog artifact and Explore's search
returns nothing until it lands, including for rows staged directly
into `explore_index`.
2026-08-13 14:13:46 -04:00
logan c400f681c2 fix(icons): the "Wanted" button asked for a Pro icon
`bookmark-check` is Font Awesome **Pro**, so it was never bundled and
`window.__yjIconMisses` has held it for as long as anything could be
requested — the button rendered the missing-icon fallback in the one
state it exists to show.

`offline-icons.spec.ts` asserts that array is empty and passed anyway:
no spec had ever put the app in a state where an album is requested. A
name computed from state is only checkable from that state, which is
the case `names.txt` exists for.

Outline and solid of the same Free glyph carry the toggle instead,
which is what the vendoring script tells you to do when a name is
missing: pick one that is Free, never reach for the Pro file.
2026-08-13 14:13:38 -04:00
logan 451b46e63c fix(explore): show a requested album as queued, not absent
`library-status-indicator` has had three states since it was written
and produced two: all eight call sites were a two-way ternary between
`in-library` and `not-in-library`, so the `queued` state it styles and
labels was unreachable.

The result was the app contradicting itself on one page. An album added
to the request list showed a plus and announced "is not in your
library", forty pixels from a filled button reading "Wanted".

The rule was written at eight places, which is why none of them had all
of it, so it is `utils/library-status.ts` now: owning outranks wanting,
a satisfied request is not queued, and a request is by MBID — a track
inside a requested album is not itself requested and still says so.

`explore-view` gains the `downloadStore` subscription both detail views
already had, registered `whileActive` because it is a cached view that
never unmounts. `top-results-row` needs its own: its host re-rendering
sets the same `results` array back, so Lit stops at the property and
the row never hears about a change.
2026-08-13 14:13:30 -04:00
logan d33dfb2264 docs: record phase 4, and the counts a new guard has to agree with
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 2m47s
Search index maintenance / maintain-index (push) Successful in 8s
CI / e2e (push) Successful in 6m11s
Plan 008 is complete and moves to completed/. The two findings worth
carrying forward are that a new table needs one schema file rather than
two (and a datamap entry, which is a gate nobody remembers), and that
excluding a path has to reach every place that counts what is in the
library — the soft scan's disk-vs-database comparison above all, which
would otherwise have rescanned the whole library on every launch with
nothing failing anywhere.
2026-08-13 13:30:37 -04:00
logan 41a4dd7148 feat(shortcuts): bind tracklist.delete to the confirmation
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m5s
The binding has been in the defaults and in Settings since it was
written, with nothing on the other end of it, because "remove from
library" did not exist. It does now — and Delete only *opens* the
dialog, never performs the removal, which is the only version
defensible one keystroke from a focused row.

The e2e case asserts the two things that matter and neither is the row
count: the file is still on disk, and a real scan of the real directory
does not bring the row back. It watches a control path survive the same
scan, because a guard that excluded everything would pass the negative
assertion for free — and it restores the database it spends.
2026-08-13 13:28:38 -04:00
logan 6d97e3c872 feat(tracks): remove from library behind a confirmation
The context menu's one destructive command. Its impact line says the
files are not deleted, because a user who reads "remove" as "delete"
and finds their music gone was failed by the copy rather than by the
operation.

The store patches rather than invalidates: the event carries the paths,
so the tracks array — the expensive collection — is spliced in place
and only the album/artist/genre summaries, whose counts really did
change, are refetched. It falls back to a full invalidate when a tracks
fetch is already in flight, which is the one case a patch cannot be
shown to be equivalent to.

Deleting an audio_files row cascades to queue_tracks, so the removal
also compacts the queue — the same reload RemoveLibrary does, which
unloads the player if the removed track was the one playing.
2026-08-13 13:11:31 -04:00
logan acbe7c4676 feat(library): remove tracks from the library without touching the file
"Remove from library" deletes the audio_files row and records the path
as excluded, so the next scan does not import it again. Without the
exclusion the operation undoes itself on the next scan, which is worse
than not having it at all; the file on disk is never touched, which is
the promise the confirmation copy will make.

The soft scan compares the number of audio files on disk against the
number of rows, so both walks now skip excluded paths — otherwise the
two counts disagree forever and every launch queues a full scan of the
whole library. A full rescan clears the exclusions, which is the only
way back for a path removed by mistake until there is a UI for it.
2026-08-13 13:04:59 -04:00
logan 91bab4e73e docs: record the a11y tail, and four names written where nothing reads
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 2m32s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Successful in 6m20s
Phase 3 shipped in six landings and a11y.md is closed, which closes all
four audits from 2026-08-11.

The pass's one lesson is that an accessible name is computed on the
element carrying the role, and every tier we check with looks somewhere
else: the audit read the source and credited a name that was never
computed, an AX sweep read the tree and reported a placeholder-only box
as clean, and a component test asserted the attribute and pinned the
bug it existed to prevent.

Six of the audit's claims turned out to be wrong or smaller than
written, and one of the plan's own findings was false — the page
header's sort control is named on all nine views. All of them are
written down, which is where a third of the value of the last two plans
came from.
2026-08-13 02:32:33 -04:00
logan f1c46b6a8e fix(a11y): label Explore's search box with more than a placeholder
a11y.26, the half of it that was still open — `search-bar` gained a
computed aria-label some phases ago and this one did not.

It is why the finding survived: a placeholder *is* an accname fallback,
so the box was never unnamed and a sweep of the accessibility tree
reported the whole view clean. It is a weak name all the same, since it
disappears the moment anyone types, and it is the only thing that
distinguishes catalog search from lyric search.
2026-08-13 02:30:05 -04:00
388 changed files with 33005 additions and 13114 deletions
+474
View File
@@ -0,0 +1,474 @@
name: Build & publish the Android APK
# The fifth workflow, and the second that publishes. It builds a signed
# arm64-v8a APK on every version tag and puts it in
# Gitea's *generic* package registry, which — unlike the repository — is
# readable without credentials. That is what lets an Obtainium client
# poll a plain URL with no token and no public mirror of the source.
#
# **Why its own file rather than a job in ci.yml.** `ci.yml` runs on
# every branch push and is the workflow that gates; this one runs on
# tags only, takes tens of minutes on a cold cache, and the runner has
# capacity 1. Hanging it off the gate would put every push behind an
# SDK download.
#
# **Why it is keyed on the tag.** The ljos pipeline this is modelled on
# computes a version in CI and cuts the release itself, then gates the
# Android job on `needs.release.outputs.version != ''` with an
# `always()` whose absence silently kills the manual path. This repo
# has no release automation — tags are pushed by hand and
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
# version and none of that machinery, or its failure modes, is needed.
#
# It deliberately does **not** carry `continue-on-error`. In ljos the
# Android job shared a pipeline with a server deploy that must never go
# red over a phone build; here it is standalone and can neither delay
# nor redden anything, so a release step that fails silently would be
# strictly worse than one that fails visibly.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build (default: the latest v* tag)"
required: false
concurrency:
group: android-${{ github.ref }}
cancel-in-progress: true
jobs:
apk:
runs-on: ubuntu-latest
timeout-minutes: 60
container:
image: ubuntu:24.04
# /cache/tool holds the Go toolchain ci.yml already downloads.
# The other three are this workflow's own and are ~4 GB between
# them, which is most of its wall clock on a cold run:
# android-sdk the SDK, the NDK and the platform (~2 GB)
# gradle GRADLE_USER_HOME — the wrapper distribution and
# the AGP dependency graph (~700 MB)
# pnpm-store shared with ci.yml
# Every path must be inside the runner's `valid_volumes` allowlist:
# a directory outside it makes the job **fail to start**, rather
# than silently skipping the mount.
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
- /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
DEBIAN_FRONTEND: noninteractive
GO_VERSION: '1.25.0'
npm_config_store_dir: /cache/pnpm-store
# The Go half wants the NDK; the Gradle half wants a platform.
ANDROID_HOME: /cache/android-sdk
ANDROID_SDK_ROOT: /cache/android-sdk
GRADLE_USER_HOME: /cache/gradle
# Pinned, not "whatever sdkmanager installs": newer NDKs have
# broken the Wails Android build before, and r26d is what plan
# 015 phase 0 was verified against.
NDK_VERSION: 26.3.11579264
# The registry package name. Obtainium watches
# <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
PACKAGE_NAME: yellowjacket-android
steps:
# libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
# nothing in this job builds a desktop app: `wails3` is the task
# runner the whole Android build goes through, and the CLI links
# the GTK/WebKit bindings, so `go tool wails3` cannot compile
# without them. libasound2-dev is oto's `pkg-config -- alsa`
# probe, for the same reason (the *Android* build uses oboe, not
# ALSA — this is the host toolchain only).
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git jq unzip zip \
build-essential pkg-config \
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
openjdk-21-jdk-headless
# By hand rather than actions/checkout: that is a JS action and
# needs node inside the container before any step has installed
# it. Same approach as the other four workflows.
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
git -C /src checkout --quiet --detach "$SHA"
git config --global --add safe.directory /src
git -C /src log --oneline -1
# A tag push carries the version in its own name. A manual run has
# no tag, so it takes the input or falls back to the latest v* tag,
# which is what a hand-triggered rebuild wants anyway.
- name: Resolve the version
id: version
working-directory: /src
run: |
set -eu
v="${{ inputs.version }}"
if [ -z "$v" ]; then
case "$REF_NAME" in
v*) v="$REF_NAME" ;;
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;;
esac
fi
v="${v#v}"
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. It is skipped cleanly
# rather than failing the guard below, because a 45-minute red
# run against a tag that was never meant to ship is noise, and
# this is the most expensive of the four workflows a tag fires.
if [ "$v" = "0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
# Android orders releases by an integer and refuses anything
# not greater than what is installed. 1.3.1 -> 10301, which
# increases as long as minor and patch stay below 100.
IFS=. read -r maj min pat <<EOF
$v
EOF
code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
if [ "$code" -le 0 ]; then
echo "refusing to build version '$v' (versionCode $code)" >&2
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "code=$code" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "building $v (versionCode $code)"
# Releases restarted at 0.0.1 when they became automatic (plan
# 017), so versionCode restarted at 1 — *below* the 10300 an
# installed 1.3.0 build carries. Android refuses a downgrade
# outright, and the only remedy is an uninstall, which takes the
# user's library with it. Said here because this is the file
# that computes the number.
if [ "$code" -lt 10600 ]; then
echo
echo "note: versionCode $code is below the 10600 that v1.6.0 shipped."
echo " An existing install must be removed before this one will"
echo " install, and that removal takes its library with it."
fi
- name: Go toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
mkdir -p /cache/tool && rm -rf /cache/tool/go
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
fi
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
/cache/tool/go/bin/go version
- name: Node toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
corepack enable
node --version
# Idempotent by directory check. sdkmanager is itself idempotent
# but still spends minutes verifying, so the guards are what make
# this cheap on every run after the first.
- name: Android SDK and NDK (cached)
if: steps.version.outputs.skip == 'false'
run: |
set -eu
mkdir -p "$ANDROID_HOME/cmdline-tools"
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
echo "command line tools: installing"
cd /tmp
curl -fsSL -o tools.zip \
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip -q tools.zip
rm -rf "$ANDROID_HOME/cmdline-tools/latest"
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
else
echo "command line tools: cached"
fi
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
yes | sdkmanager --licenses >/dev/null 2>&1 || true
install_if_missing() {
if [ -d "$ANDROID_HOME/$2" ]; then
echo "$1: cached"
else
echo "$1: installing"
yes | sdkmanager --install "$1" >/dev/null
fi
}
# android-35 matches compileSdk/targetSdk in
# build/android/app/build.gradle. No system image and no
# emulator: this job builds, it does not run.
install_if_missing "platform-tools" "platform-tools"
install_if_missing "platforms;android-35" "platforms/android-35"
install_if_missing "build-tools;34.0.0" "build-tools/34.0.0"
install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
du -sh "$ANDROID_HOME" || true
# **Signing is not optional past the first install.** Android
# refuses to update an app whose signing key changed and the only
# remedy is an uninstall, which takes the user's library with it.
# build.gradle falls back to the *debug* keystore when these are
# absent, and that key differs between every machine and every
# runner — so publishing an unsigned build is a decision to
# reinstall by hand for ever. Fail instead.
# **Signing is not optional past the first install.** Android
# refuses to update an app whose signing key changed and the only
# remedy is an uninstall, which takes the user's library with it.
# build.gradle falls back to the *debug* keystore when these are
# absent, and that key differs between every machine and every
# runner — so publishing an unsigned build is a decision to
# reinstall by hand for ever. Fail instead.
#
# Decode, check and build are one step on purpose. Splitting them
# would mean either handing the password to a later step through
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
# that are *verbatim* a secret, so a trimmed one could print in
# clear — or repeating the trimming logic in both.
- name: Build the signed APK
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
YJ_VERSION: ${{ steps.version.outputs.version }}
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
run: |
set -eu
if [ -z "${KEYSTORE_B64:-}" ]; then
echo "ANDROID_KEYSTORE_B64 is not set."
echo
echo "Building without it signs with the debug key, and every future"
echo "update then fails with a signature mismatch. See"
echo "docs/android-release.md for the keytool command and the secrets."
exit 1
fi
if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
exit 1
fi
# The path is decided here rather than composed in an `env:`
# block: `${{ env.HOME }}` evaluates to an empty string in
# Gitea's expression context, which turns "$HOME/x.jks" into
# "/x.jks" — reported by Gradle as a missing file, a minute in.
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
chmod 600 "$keystore"
# **A secret pasted into a web form very often carries a
# trailing newline**, and a password is compared byte for byte.
# Trim CR and LF from all three, and say so when it mattered —
# "the keystore did not open" with a correct password is an
# unpleasant thing to debug blind.
pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
fi
alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')
# Describe the artifact before trying to open it. A truncated
# or mis-pasted base64 yields a file that is the wrong size or
# has no keystore header at all, and that is a different
# problem from a wrong password.
size=$(stat -c %s "$keystore")
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
echo "keystore: $size bytes, first four bytes: $magic"
# The fingerprint of the decoded file, so "is the secret the
# keystore I have locally?" is answerable without guessing.
# A hash of a *public* certificate store gives nothing away,
# and the alternative is comparing byte counts by eye.
#
# sha256sum ~/path/to/yellowjacket-release.jks
#
# A password that is right for one keystore and wrong for
# another is indistinguishable from a wrong password, and this
# is the line that distinguishes them.
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
case "$magic" in
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
"fe ed fe ed") echo " header: legacy JKS" ;;
*) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;;
esac
# Open it here rather than letting Gradle discover the problem
# at :app:validateSigningRelease, a minute of build time in and
# reported as a missing file rather than a bad password.
if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
echo " password length after trimming: ${#pass}" >&2
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
echo >&2
# A password pasted *with its shell quotes* is the one
# remaining cause that looks identical to a wrong password:
# the secret is two characters longer than the password and
# nothing in the error says so. Naming it is safe --
# stripping the quotes and carrying on would not be, since a
# password may legitimately contain them.
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
if [ "$unquoted" != "$pass" ] &&
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
echo " ** it opens with the surrounding quotes removed. **" >&2
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
echo >&2
fi
echo "Check it locally with the same two values:" >&2
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
exit 1
fi
echo "keystore opens with the supplied password"
# And check the alias now, for the same reason. It defaults to
# `yellowjacket`, so a keystore created with any other alias
# would otherwise fail deep inside Gradle.
if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
echo "alias '$alias_want' is not in this keystore. It holds:" >&2
sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2
echo "Set ANDROID_KEY_ALIAS to one of those." >&2
exit 1
fi
echo "alias '$alias_want': present"
ANDROID_KEYSTORE_FILE="$keystore"
ANDROID_KEYSTORE_PASSWORD="$pass"
ANDROID_KEY_ALIAS="$alias_want"
ANDROID_KEY_PASSWORD="$keypass"
export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD
# ANDROID_SDK is passed explicitly: the Makefile defaults it to
# ~/Android/Sdk, which is the developer-machine layout and not
# this container's.
make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"
- name: Verify the APK
id: apk
if: steps.version.outputs.skip == 'false'
working-directory: /src
run: |
set -eu
apk=bin/yellowjacket.apk
[ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
bt="$ANDROID_HOME/build-tools/34.0.0"
ls -la "$apk"
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
# arm64 and *only* arm64. x86_64 Android cannot run this app
# (modernc's raw lstat against Android's seccomp filter, which
# is every x86_64 device and not merely the emulator), so an
# x86_64 slice would be ~31 MB that runs nowhere -- and its
# reappearance would mean someone had put the ABI back in
# app/build.gradle without knowing that.
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
# The identity the pipeline exists to keep stable.
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }
echo
"$bt/apksigner" verify --print-certs "$apk" |
grep -E 'Signer #1 certificate (DN|SHA-256 digest)'
# A build signed with the debug key installs once and can never
# be updated. It must never reach the registry.
if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
exit 1
fi
echo
echo "Record that SHA-256. If it ever changes, updates will fail."
# Two copies: a versioned one for history and a fixed `latest` URL
# for Obtainium to watch. Gitea refuses to overwrite an existing
# file, so `latest` is deleted first. Credentials are the same
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
- name: Publish to the Gitea package registry
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
apk=bin/yellowjacket.apk
put() {
code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
--user "${OWNER}:${PACKAGE_TOKEN}" \
--upload-file "$apk" "$1")
echo " -> $1 : $code"
# 409 is "already there", which is the correct outcome for a
# re-run of the same tag and not a failure.
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
cat /tmp/put.out >&2
return 1
fi
}
echo "publishing the versioned copy"
put "$base/$VERSION/yellowjacket-$VERSION.apk"
echo "clearing the previous latest"
curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \
--user "${OWNER}:${PACKAGE_TOKEN}" \
-X DELETE "$base/latest/yellowjacket.apk" || true
echo "publishing latest"
put "$base/latest/yellowjacket.apk"
echo
echo "Obtainium URL:"
echo " $base/latest/yellowjacket.apk"
# The generic registry is what Obtainium polls; the release page is
# what a person looks at. Same file, already built and already
# verified by the step above — so this cannot publish something the
# signature check would have refused.
- name: Attach the APK to the release
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \
"yellowjacket-${VERSION}-android-arm64.apk"
+65 -2
View File
@@ -1,8 +1,23 @@
name: Build & publish Arch package name: Build & publish Arch package
# Keyed on the tag, not on main. It used to publish on every push,
# deriving a version from `git describe` — so the registry accumulated a
# package per merge and none of them corresponded to anything a user
# could be told to install. release.yml decides what a release is now,
# and this builds the tag it cuts.
on: on:
push: push:
branches: [main] tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build (default: the latest v* tag)"
required: false
concurrency:
group: arch-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
arch-package: arch-package:
@@ -17,6 +32,7 @@ jobs:
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label. # Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
ARCH_REPO: stable ARCH_REPO: stable
steps: steps:
@@ -26,8 +42,9 @@ jobs:
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch. # gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
# These must match the PKGBUILD's depends=() — makepkg installs # These must match the PKGBUILD's depends=() — makepkg installs
# nothing itself, so a mismatch fails at link time, not at check time. # nothing itself, so a mismatch fails at link time, not at check time.
# jq is scripts/release-asset.sh's, not the build's.
pacman -Syu --noconfirm --needed \ pacman -Syu --noconfirm --needed \
base-devel git go nodejs pnpm curl sudo \ base-devel git go nodejs pnpm curl sudo jq \
webkitgtk-6.0 gtk4 alsa-lib webkitgtk-6.0 gtk4 alsa-lib
- name: Create unprivileged build user - name: Create unprivileged build user
@@ -36,15 +53,43 @@ jobs:
install -d -o builder -g builder /build install -d -o builder -g builder /build
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
# v0.0.0 is semantic-release's version floor, not a shipment — see
# the bootstrap step in release.yml. A clean skip rather than a
# failure: a red run against a tag that was never meant to ship is
# noise, and this is one of the four workflows that would otherwise
# fire on it.
- name: Resolve the version
id: version
run: |
set -eu
v="${{ inputs.version }}"
[ -n "$v" ] || v="$REF_NAME"
case "$v" in v*) ;; *) v="v$v" ;; esac
if [ "$v" = "v0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "building $v"
- name: Clone repo at the pushed commit - name: Clone repo at the pushed commit
if: steps.version.outputs.skip == 'false'
run: | run: |
# Token auth works for private repos and needs no SSH key in CI. # Token auth works for private repos and needs no SSH key in CI.
sudo -u builder git clone \ sudo -u builder git clone \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
/build/yellowjacket /build/yellowjacket
# A tag push carries the tag's own commit in $SHA, so this checks
# out exactly what was tagged. pkgver() then reads the tag from
# the clone's own git history.
sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA" sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA"
- name: Build package with makepkg - name: Build package with makepkg
if: steps.version.outputs.skip == 'false'
run: | run: |
cd /build/yellowjacket/packaging/arch cd /build/yellowjacket/packaging/arch
# Point the PKGBUILD at this local clone / exact commit; pkgver() then # Point the PKGBUILD at this local clone / exact commit; pkgver() then
@@ -54,6 +99,7 @@ jobs:
makepkg -f --noconfirm --cleanbuild makepkg -f --noconfirm --cleanbuild
- name: Publish to the Gitea Arch registry - name: Publish to the Gitea Arch registry
if: steps.version.outputs.skip == 'false'
run: | run: |
cd /build/yellowjacket/packaging/arch cd /build/yellowjacket/packaging/arch
# makepkg also produces a -debug package (detached symbols); end users # makepkg also produces a -debug package (detached symbols); end users
@@ -67,3 +113,20 @@ jobs:
--upload-file "$pkg" \ --upload-file "$pkg" \
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}" "${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
done done
# The pacman registry is for people who have added it to pacman.conf;
# the release page is for everyone else. Same file, and it is
# already built.
- name: Attach the package to the release
if: steps.version.outputs.skip == 'false'
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
cd /build/yellowjacket/packaging/arch
for pkg in yellowjacket-*.pkg.tar.zst; do
case "$pkg" in
yellowjacket-debug-*) continue ;;
esac
/build/yellowjacket/scripts/release-asset.sh "$TAG" "$(pwd)/$pkg"
done
+17 -3
View File
@@ -1,6 +1,6 @@
name: CI name: CI
# The other three workflows package and publish; none of them test # The other five workflows package, publish or release; none of them test
# anything, so a green tick on this repo used to mean "the Arch package # anything, so a green tick on this repo used to mean "the Arch package
# built", which is not the question anyone was asking. This is the # built", which is not the question anyone was asking. This is the
# workflow that gates. # workflow that gates.
@@ -9,9 +9,23 @@ name: CI
# before being written here, so every step below is a transcription of # before being written here, so every step below is a transcription of
# something observed working rather than something expected to. # something observed working rather than something expected to.
# **A branch push and its PR are the same commit, and testing it twice
# costs the only runner there is.** `branches: ['**']` here meant every
# PR booked four runs — `check` and `e2e` for the branch push, then both
# again for `refs/pull/N/head` — on a host with capacity 1, where the
# queue is shared with an index build that can hold it for three hours.
#
# `pull_request` covers feature branches, and `main` is kept because a
# post-merge run is the record of the trunk's health. Since main now
# refuses direct pushes, that run happens exactly once per merge.
#
# The trade is explicit: a branch pushed with **no** PR open gets no CI.
# That is consistent with the workflow this repo committed to — every
# change goes through a PR — and the signal returns the moment one is
# opened, on the same commit.
on: on:
push: push:
branches: ['**'] branches: [main]
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -78,7 +92,7 @@ jobs:
# Cloned by hand rather than with actions/checkout: that is a JS # Cloned by hand rather than with actions/checkout: that is a JS
# action and needs node inside the job container before any step # action and needs node inside the job container before any step
# has had a chance to install it. Same approach as the other # has had a chance to install it. Same approach as the other
# three workflows in this directory. # other workflows in this directory.
- name: Clone repo at this commit - name: Clone repo at this commit
run: | run: |
set -eu set -eu
+176
View File
@@ -0,0 +1,176 @@
name: Attach the desktop build to the release
# The Arch package goes to the pacman registry and the APK to the generic
# one, but a release page with nothing on it to download is a release page
# nobody can use. This builds the plain Linux x86_64 binary and attaches
# it, so "get the latest version" has an answer that needs no package
# manager at all.
#
# **Linux only, and macOS is not an oversight.** `GOOS=darwin
# CGO_ENABLED=0` fails at `wails/v3/pkg/mac: build constraints exclude all
# Go files` — the darwin backend is Objective-C behind cgo, so a .app
# needs a macOS host, and the runner is a Linux container. That is
# exactly why the Homebrew formula builds from source on the user's own
# Mac, and it stays the macOS channel.
#
# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a
# couple of seconds — nothing in the audio, database or webview path needs
# cgo there), and is deliberately not published: no Windows build of this
# app has ever been run, and no tier here can exercise one. Shipping it
# would be a promise nothing in this repo can keep. Revisit when someone
# has actually booted it.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build and attach (default: the latest v* tag)"
required: false
concurrency:
group: desktop-assets-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
DEBIAN_FRONTEND: noninteractive
GO_VERSION: '1.25.0'
npm_config_store_dir: /cache/pnpm-store
steps:
# The same set ci.yml's check job installs: the app is cgo, and
# without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa`
# before anything is compiled.
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git jq build-essential pkg-config \
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
git -C /src checkout --quiet --detach "$SHA"
git config --global --add safe.directory /src
git -C /src log --oneline -1
- name: Resolve the version
id: version
working-directory: /src
run: |
set -eu
v="${{ inputs.version }}"
if [ -z "$v" ]; then
case "$REF_NAME" in
v*) v="$REF_NAME" ;;
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;;
esac
fi
case "$v" in v*) ;; *) v="v$v" ;; esac
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. Nothing is built for
# it, and this is a clean skip rather than a failure because a
# red run against a tag that was never meant to ship is noise.
if [ "$v" = "v0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
echo "building $v"
- name: Go toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
mkdir -p /cache/tool && rm -rf /cache/tool/go
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
fi
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
/cache/tool/go/bin/go version
- name: Node toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
corepack enable
node --version
# `make build-prod` is the production task: -trimpath and -w -s are
# already in it, so only the version stamp is passed, through the
# LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml.
# (`wails3 build` has no -ldflags of its own; that was v2.)
- name: Build
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
export PATH="/src/scripts/toolbin:$PATH"
commit=$(git rev-parse --short HEAD)
go generate ./...
go tool wails3 task build \
LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'"
# Described, never run: main.go has no flag parsing, so any
# invocation here would try to open a window in a container with
# no display and hang the job rather than printing a version.
test -x bin/yellowjacket
ls -la bin/yellowjacket
file bin/yellowjacket || true
# The .desktop file and the icon go in the tarball because without
# them the binary is a window with no menu entry — the Arch package
# installs both, and this is the same app for people not using it.
- name: Package the tarball
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
dir="yellowjacket-${VERSION}-linux-amd64"
mkdir -p "/tmp/$dir"
cp bin/yellowjacket "/tmp/$dir/"
cp packaging/arch/yellowjacket.desktop "/tmp/$dir/"
cp frontend/src/assets/images/icons/music/compact-disc.svg \
"/tmp/$dir/yellowjacket.svg"
tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir"
ls -la "/tmp/${dir}.tar.gz"
- name: Attach it to the release
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
./scripts/release-asset.sh "$TAG" \
"/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz"
+28 -2
View File
@@ -14,6 +14,15 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
workflow_dispatch:
inputs:
version:
description: "Version to sync (default: the pushed tag)"
required: false
concurrency:
group: homebrew-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
sync-formula: sync-formula:
@@ -30,10 +39,25 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Compute version and tarball checksum - name: Compute version and tarball checksum
id: version
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0 TAG="${{ inputs.version }}"
VERSION="${TAG#v}" # e.g. 1.3.0 [ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1
case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac
VERSION="${TAG#v}" # e.g. 0.0.1
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. Skipped cleanly rather
# than failing: this one would otherwise push a formula for a
# version that does not exist into a *public* tap.
if [ "$VERSION" = "0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to sync"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz" TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
echo "Fetching ${TARBALL}" echo "Fetching ${TARBALL}"
@@ -53,6 +77,7 @@ jobs:
echo "SHA256=${SHA256}" >> "$GITHUB_ENV" echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
- name: Render the formula with the new version and checksum - name: Render the formula with the new version and checksum
if: steps.version.outputs.skip == 'false'
run: | run: |
set -euo pipefail set -euo pipefail
src="packaging/homebrew/Formula/yellowjacket.rb" src="packaging/homebrew/Formula/yellowjacket.rb"
@@ -66,6 +91,7 @@ jobs:
cat yellowjacket.rb cat yellowjacket.rb
- name: Push to the Homebrew tap repo - name: Push to the Homebrew tap repo
if: steps.version.outputs.skip == 'false'
run: | run: |
set -euo pipefail set -euo pipefail
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
+30 -5
View File
@@ -7,11 +7,29 @@ name: Search index maintenance
# import older than 6mo -> rebuild (re-import from the newest dump) # import older than 6mo -> rebuild (re-import from the newest dump)
# otherwise -> refresh (fold in new incremental listens) # otherwise -> refresh (fold in new incremental listens)
# #
# A refresh is cheap and no-ops when nothing new has been published, so # **There is deliberately no `push` trigger, and restoring one is a
# running it on every push to main is safe. # decision rather than a cleanup.** A refresh is individually cheap, so
# running it on every push to main looked free; what it actually does is
# 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.
#
# That is not hypothetical. On 2026-08-17 `fix(database): retire a table
# whose shape the schema moved past` landed on main, green — the CI
# database is deliberately in the older encoding, so the stale-shape
# repair judged its `explore_index` stale and dropped it, and this job
# fell back to a full import from the dumps. `fix(database): never
# retire the catalog the index build derives` stops that specific repair
# and cannot undo it. Every push to main then booked another `budget`
# (3h) of the one runner while ordinary CI queued behind it.
#
# So the rule this file is an instance of: **a job that mutates state
# which cannot be rebuilt in ten minutes is triggered deliberately, not
# by a push.** The weekly cron keeps the catalog current, and
# workflow_dispatch resumes or forces a build — indexbuild picks up from
# its checkpoint either way, so nothing is lost by not running on every
# merge. See docs/index-cache.md for the snapshot and the restore.
on: on:
push:
branches: [main]
schedule: schedule:
# Weekly update pass. The 6-month rebuild is triggered by the same # Weekly update pass. The 6-month rebuild is triggered by the same
# command when it notices the import has aged out. # command when it notices the import has aged out.
@@ -33,6 +51,10 @@ on:
# Runs share one persistent working directory, so they must not overlap. # Runs share one persistent working directory, so they must not overlap.
# A push landing mid-build waits rather than corrupting the checkpoint. # A push landing mid-build waits rather than corrupting the checkpoint.
#
# That directory holds the only copy of a catalog nothing can cheaply
# re-derive: see docs/index-cache.md for the snapshot it takes and the
# restore, which is minutes against the hours a rebuild costs.
concurrency: concurrency:
group: search-index group: search-index
cancel-in-progress: false cancel-in-progress: false
@@ -42,7 +64,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
# CGO is not needed: the project uses the pure-Go modernc sqlite # CGO is not needed: the project uses the pure-Go modernc sqlite
# driver, and neither command imports the Wails app. # driver, and neither command imports the Wails app — which is a
# claim with a test behind it now (cmd/indexbuild/deps_test.go),
# because the v3 migration quietly broke it and this job was where
# that surfaced.
image: golang:1.25 image: golang:1.25
# This host path must exist on the runner and be listed verbatim in # This host path must exist on the runner and be listed verbatim in
# act_runner's container.valid_volumes. It holds explore-staging/ # act_runner's container.valid_volumes. It holds explore-staging/
+182
View File
@@ -0,0 +1,182 @@
name: Release
# The sixth workflow, and the one that decides whether the other three
# run at all. On every push to main it reads the Conventional Commits
# since the last tag, and if any of them is releasable it writes the
# changelog, pushes the tag, and creates the Gitea release whose body is
# that changelog section. The publishing workflows are keyed on `v*`, so
# the tag push is what starts them.
#
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
# workflow's own token (go-gitea#33123). The token is what decides this,
# not the workflow — so semantic-release is handed a repositoryUrl
# carrying a *user* PAT, and the resulting push is attributed to a person
# and triggers the `v*` workflows normally.
#
# That limitation is used deliberately in the bootstrap step below, where
# a tag that must *not* trigger anything is pushed with the Actions token
# instead.
on:
push:
branches: [main]
workflow_dispatch:
# Cutting a tag is not a thing to cancel halfway: a superseded run must
# finish, not be killed between `git push --tags` and the release POST.
concurrency:
group: release-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
env:
SERVER_URL: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
DEBIAN_FRONTEND: noninteractive
steps:
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends ca-certificates curl git jq
- name: Node toolchain
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
node --version
# By hand rather than actions/checkout, like the other five: that is
# a JS action and needs node inside the container before any step has
# installed it. The full history is required — semantic-release
# reads tags and walks commits, and a shallow clone silently makes
# every release look like the first one.
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
# -B main rather than --detach, which the other five workflows
# use: semantic-release resolves the release branch and then
# pushes a commit and a tag to it, and a detached HEAD is a
# worse starting point for both than a local branch named after
# the one being released. Pinned to this commit, not to
# whatever main points at by the time the container started.
git -C /src checkout --quiet -B main "${{ github.sha }}"
git config --global --add safe.directory /src
git -C /src log --oneline -1
# Nothing currently pushes a `chore(release):` commit — main is a
# protected branch, so .releaserc.yml carries no @semantic-release/git
# and the release page is the changelog. This guard is kept for the
# day someone adds that plugin back: without it the commit-back is a
# push to the branch this workflow runs on, and the loop is a release
# per release. Six lines against that is cheap.
- name: Skip a changelog commit, if one ever exists
id: guard
working-directory: /src
run: |
set -eu
subject=$(git log -1 --format='%s')
case "$subject" in
"chore(release):"*)
echo "this is the release commit itself; nothing to do"
echo "skip=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "skip=false" >> "$GITHUB_OUTPUT"
;;
esac
# semantic-release calls the first release of a repo with no tags
# 1.0.0, and offers no option to say otherwise. A floor tag is the
# only way to start at 0.0.1, so this creates one — once, ever.
#
# **It is pushed with the Actions token on purpose.** v0.0.0 is a
# floor, not a shipment: pushing it with a user PAT would start the
# Arch, Homebrew and Android workflows for a version that does not
# exist. The very limitation the header describes is what makes
# this inert.
- name: Seed the version floor
if: steps.guard.outputs.skip == 'false'
working-directory: /src
env:
ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git fetch --quiet --tags origin
if [ -n "$(git tag --list 'v[0-9]*')" ]; then
echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
exit 0
fi
# Prefer the Actions token because a ref it pushes starts no
# workflow, which is the whole point for a tag that is a floor
# rather than a shipment. Falling back to the PAT is safe
# rather than merely convenient: all four publishing workflows
# skip v0.0.0 explicitly, so the worst case is four jobs that
# start and immediately say there is nothing to build.
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
# **On the parent, not on HEAD.** The floor 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, which is
# exactly what the first run of this workflow did. HEAD^ is the
# first parent, so on the merge commit this fires for it is main
# as it was before the merge, and everything the merge brought
# in is releasable.
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
if [ -z "$floor" ]; then
echo "HEAD has no parent, so no commit can precede the floor" >&2
exit 1
fi
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
git tag v0.0.0 "$floor"
git push --quiet \
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
refs/tags/v0.0.0
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
# Pinned rather than installed into the repo: this is a Go project
# and a package.json at its root invites the npm plugin and every
# tool that looks for one. conventional-changelog-conventionalcommits
# is in the list because both the analyzer and the notes generator
# name that preset and neither depends on it.
#
# **That preset is held at 9 and the reason is worth keeping.** At
# 10 it is silently incompatible with the writer that
# release-notes-generator@14 pulls in (^8): every release note comes
# out as a bare `## 0.0.1 (date)` heading with **no sections and no
# commits under it**, and nothing errors. The version would have
# been right, the tag would have been right, every job would have
# been green, and the release body would have been empty. Check the
# notes, not the exit code, before moving any of these.
- name: Run semantic-release
if: steps.guard.outputs.skip == 'false'
working-directory: /src
run: |
set -eu
git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app"
npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
-p @semantic-release/release-notes-generator@14 \
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release \
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
+24 -6
View File
@@ -63,10 +63,28 @@ bin/
# packaging tasks that depend on it. A derived file with one source. # packaging tasks that depend on it. A derived file with one source.
build/linux/yellowjacket.desktop build/linux/yellowjacket.desktop
# `wails3 task common:update:build-assets` regenerates the mobile trees # iOS is not carried. `wails3 update build-assets` regenerates the tree
# whether or not anything asks for them. This is a desktop player and # whether or not anything asks for it, so it is ignored rather than
# cannot target iOS/Android, so their includes: entries are dropped from # deleted-and-rediscovered on every asset refresh, and its includes:
# Taskfile.yml and the trees themselves are not carried — ignored rather # entry is dropped from Taskfile.yml.
# than deleted-and-rediscovered on every asset refresh. #
# build/android/ *is* carried — see plan 015. Note that `update
# build-assets` does NOT regenerate it (only `generate build-assets`
# does, and that rewrites the whole of build/), so the tree is committed
# and edited by hand like any other source. Only its output is ignored,
# below.
build/ios/ build/ios/
build/android/
# Android build output. jniLibs holds the ~30 MB per-ABI c-shared
# libraries the Go build produces; gen/ and overlay.json are written by
# `wails3 android overlay:gen`; the rest is Gradle's.
build/android/app/src/main/jniLibs/
build/android/app/build/
build/android/build/
build/android/.gradle/
build/android/gen/
build/android/overlay.json
# Written by @semantic-release/changelog purely to carry the release notes
# into scripts/gitea-release.sh; the release page is the changelog.
.release-notes.md
+11
View File
@@ -29,6 +29,17 @@ linters:
- usetesting - usetesting
- whitespace - whitespace
- wsl_v5 - wsl_v5
exclusions:
paths:
# Wails scaffold, not ours. `build/android/` is generated by
# `wails3 generate build-assets` and carried verbatim (plan 015),
# and it contains one Go file -- scripts/deps/install_deps.go, the
# interactive SDK installer behind `task android:install:deps`.
# It trips 24 of the strict linters above, and reformatting
# upstream's file to our house style would be undone by the next
# refresh and would make the diff against upstream unreadable.
# `make android-setup` is what this repo uses instead.
- build/android/
formatters: formatters:
enable: enable:
- gci - gci
+16
View File
@@ -96,6 +96,15 @@ reference, because you need them *before* the failure, not after.
Run against the `bulk` seed a measurement session left behind and a Run against the `bulk` seed a measurement session left behind and a
third of them fail (13 of 36, when it was measured), in a list that third of them fail (13 of 36, when it was measured), in a list that
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first. reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
- **The catalog is stubbed out locally now, like CI.**
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
because it was the only launcher that did not — `seed-sandbox.sh` and
`ci.yml` always have. Without it the app downloads the real ~1M-row
Explore catalog into the run's `YJ_HOME`, and specs that stage their
own catalog rows then search a million real ones and fail *locally
only*, which reads as a regression and is an environment. Pass
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
explore by hand.
- **…and the suite spends state it cannot always give back.** - **…and the suite spends state it cannot always give back.**
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out `view-lifecycle.spec.ts` **skips an autotag album** on every run, out
of the eleven the seed has, and does not put it back — so around the of the eleven the seed has, and does not put it back — so around the
@@ -151,6 +160,7 @@ only climb when it cannot.
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive | | Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup | | Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | | | A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
| Anything that has to survive on a phone | `make android-smoke` against a booted emulator | ~1 min + setup |
Two targets are once-per-clone prerequisites that are **not** Two targets are once-per-clone prerequisites that are **not**
dependencies of the targets needing them, so on a fresh checkout each dependencies of the targets needing them, so on a fresh checkout each
@@ -426,3 +436,9 @@ fails the build otherwise, including in files no lint pass compiles.
and what breaks in it. and what breaks in it.
- [schema-change.md](references/schema-change.md) — the two-file - [schema-change.md](references/schema-change.md) — the two-file
schema/migration checklist. schema/migration checklist.
- [android-tier.md](references/android-tier.md) — the emulator tier,
and the three reasons a failure there looks like a success. **Read
its first section before running anything on Android**: Go's stdout
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
and ActivityManager restarts a dying app fast enough that `pidof`
always answers.
@@ -0,0 +1,350 @@
# The Android tier
A sixth tier, and the only one where **the app failing looks exactly
like the app working**. Read the first section before you run anything;
it is the difference between a diagnosis and an afternoon.
This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Three facts that make failure invisible
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
dropbox. All three of the places you would look are empty, and the one
signal that is present — SIGKILL — reads as "the system killed it",
which is the wrong hypothesis.
**ActivityManager restarts it, so a dead app looks alive.** A
crash-looping app is respawned several times a second, so `pidof` always
answers and `am start` always reports `Status: ok`. "Did it start" is
the wrong question. `make android-smoke` asks the right one — is it the
*same pid* a few seconds later.
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths.
## What to run
One-time, ~3.5 GB:
```bash
make android-setup # SDK pieces + the yj-test AVD, idempotent
```
Then:
```bash
make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
make android-emulator # boot headless in the background, wait for boot
make android-install # adb install -r
make android-smoke # launch, then assert the same pid survives 10s
make android-logs # filtered logcat, follow
make android-emulator-stop # console kill, then the saved PID
```
`make android-smoke SECONDS=30` for a longer window. On failure it
prints the last 40 app-relevant logcat lines and how to read them.
Never `pkill -f emulator` — the pattern matches the invoking shell's own
command line and kills it, silently dropping the rest of your compound
command. The emulator is addressed by its saved pid in
`.dev/emulator.pid`, same discipline as `make dev-stop`.
**adb is addressed by AVD name, not by whatever is plugged in.** The
script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before
any device command, because a second emulator (another project's, or
this one's own corpse left `offline` by a previous run) makes a bare
`adb` fail with "more than one device" — which `cmd_install` reported
as *"no device — run 'make android-emulator' first"* immediately after
that had succeeded. Serials are assigned in boot order and change
between runs, so the AVD name is the identity. Set `ANDROID_SERIAL`
yourself and it is honoured; one device that is not ours (a phone) is
taken as the target.
## Things that cost a cycle
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
build-tools but `platforms/` is *empty*, so Gradle fails with a
compileSdk error that reads like a version mismatch. The Makefile
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
separately, because the Go half wants the NDK and the Gradle half
wants the platform and they are in different places.
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
`android-ndk-26`). Newer NDKs have broken the Wails Android build
before. CI pins the same one.
- **Without KVM the emulator still works and is unusably slow** — a 30 s
boot becomes tens of minutes, which reads as a hung target rather than
a slow one. `make android-setup` checks and warns.
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
the previous run's app state, and a smoke result that depends on what
the last run left behind is not a result.
- **The logcat filter is not optional.** The emulator emits thousands of
lines a second, nearly all WindowManager transitions; an unfiltered
`adb logcat` buries the six lines that matter. `make android-logs`
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
`DEBUG` and `libc:F`.
- **`run-as` does not work on a release-signed APK** (`package not
debuggable`), so you cannot read the app's data directory or its
environment that way. Ask the device instead, or build a debug variant.
- **The `google_apis` system image, not `default`.** This app is a
WebView app; `google_apis` ships the Chrome-based WebView that
actually renders it.
## The current state of the build
**The app starts. The x86_64 emulator cannot run it, and that is not a
bug in the app.**
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
database layer, sits on — issues a **raw `lstat` syscall on
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
syscall 6 on x86_64, because bionic never issues it, so the process
takes `SIGSYS` the first time anything touches the database:
```
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
```
**arm64 is unaffected, and structurally so.** There is no `lstat`
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
`syscall` package already uses `fstatat` on both architectures, which
is why this is *only* the modernc path.
So: **verify on arm64, and on this machine that means a real device.**
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
says nothing about your change.
**Do not reach for an arm64 system image — it will not run here, and
finding that out costs a 3.8 GB download.** Emulator 37 refuses
outright:
```
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
emulator on x86_64 host. System image must match the host
architecture.
```
Google dropped cross-architecture emulation; there is no flag. The
options are an arm64 host, a physical device, or `adb connect` to one.
**The x86_64 ABI is therefore gone from the build** (`abiFilters` in
`build/android/app/build.gradle`, `android:package` rather than
`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$`
assertion in `android-apk.yml` that fails if it comes back). It could
not run on any Android until modernc fixes this — x86 Chromebooks
included — and dropping it took the artifact from 27 MB to 15.9 MB.
The tombstone was at least honest while it lasted: unlike the
`os.Exit` that came before it, it left a real crash record with a
backtrace.
### The emulator still installs it, and it still does not run
The obvious guess about dropping x86_64 — that `make android-install`
would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong,
and was measured wrong before it was written down.** Google's
`google_apis` x86_64 images carry arm64 translation:
```
ro.product.cpu.abilist = x86_64,arm64-v8a
```
So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so`
and runs it (the tombstone says `Guest architecture: 'arm64'`). It then
dies **before any of our code**, with SIGILL rather than SIGSYS:
```
signal 4 (SIGILL), code -6 (SI_TKILL)
#00 pc 00000000015911d0 .../lib/arm64/libwails.so
```
Disassembling that offset names the reason exactly:
```
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
```
That is Go's `internal/cpu` reading the arm64 CPU-feature ID register
at runtime init, which the translator does not implement. So it is not
"our Go program is unlucky": **no Go binary starts under this
translation layer**, and no amount of work on this app changes it.
The three failures are worth holding side by side, because each looks
like the app's fault and none is:
| build | on x86_64 Android | signal |
|---|---|---|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
| arm64, real device | — | unverified, still |
**A physical arm64 device remains the only verification path.**
### What was fixed to get here
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
`default:` returning `errUnsupportedOS`, so Android failed at startup
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
StoragePath())` before anything asks for a path — a documented,
build-tag-free API that returns `""` on desktop, where the setter is a
no-op. `backend/system` gained no import of the Wails application
package, which matters for the same reason `backend/events` is split by
the `indexbuild` tag.
### What is still not done
The shell is still a desktop shell, and the x86_64 half of the APK is
still dead weight. Everything in plan 016's section A is now built:
storage access, an in-app folder picker (Android's directory dialog
returns an error, since the Storage Access Framework yields tree URIs
rather than paths), MPRIS excluded, and a MediaSession with a transport
notification and audio focus.
### Compiling the `android`-tagged Go by hand
`make lint` and `make test` never see it: their three tag sets are all
linux/amd64, so the only thing that compiles `backend/mediacontrols/
android.go` is `make android` — a full APK build for a Go type error.
The short way round:
```bash
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
```
**`CXX` is not optional.** Without it the oboe C++ sources in `oto`
compile against the host sysroot and fail on `android/log.h` and
`sys/system_properties.h`, which reads like a broken or missing NDK.
Restrict it to `./backend/...`: `./...` additionally builds
`build/android/gen`, a scaffold shim that only resolves inside the
wails task and fails with `undefined: main` on its own.
A Go method added to a bound service also reaches the frontend unless
it says not to — `//wails:ignore` above the func, which `make bindings`
then honours. `Player.SetDuck` is driven by OS audio focus and carries
one.
## The scaffold's own tasks
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
they are the right thing to reach for when you want something one-off:
```
wails3 task android:run # debug build + emulator install + launch
wails3 task android:run:device # same, first connected physical device
wails3 task android:deploy-device # production APK to a device
wails3 task android:bundle:fat # AAB, for a Play Store upload
wails3 task android:studio # open build/android/ in Android Studio
wails3 task android:device:list
wails3 task android:logs:all
wails3 task android:clean
```
Two are deliberately **not** wrapped. `android:logs` greps logcat for
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
does not match it) and misses `ActivityManager`'s "has died" line, which
is the one that tells you it crashed; `make android-logs` filters by tag
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced.
## The identity is declared twice
`applicationId` in `build/android/app/build.gradle` is what Gradle
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
adb-driven task uninstalls, launches and filters. **Nothing enforces
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
that file (verified with `--dry`), and even when set it feeds only the
adb commands, never Gradle. Change both or the official `run`/`deploy`
tasks address a package that is not installed.
Related, and it will bite once: the launcher activity is
`com.wails.app.MainActivity` and the applicationId is
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
resolves the leading dot against the *applicationId* and fails with a
class-not-found that reads like a broken build. Always the
fully-qualified form.
## What only a device can answer
The emulator cannot run this app (three separate reasons, none of them
ours — see plan 016), so the phone in someone's pocket is a tier, and
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
the whole of A4 and found two faults **no other tier can see**:
- **The back gesture.** `MainActivity.onBackPressed` asks
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
so no spec had ever called `page.goBack()` and the app had never
pushed a history entry — back quit from any depth. It is a history
entry per navigation now, which is also what made it assertable in the
browser tier (`e2e/specs/back-navigation.spec.ts`).
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
transport and the tab bar sat under the gesture bar. **A browser
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
keep passing on a build the device is clipping 48dp off. Insets are
handled in `applyWindowInsets()`.
So when asking for a device run, ask about what the platform *adds* —
system bars, the back gesture, focus and audio interruptions,
permission dialogs, the keyboard — not about what the app draws. The
drawing is what the other five tiers already cover.
## Asking the device, not just looking at it
A real phone can be inspected, and that turns this tier from "reported
symptoms" into evidence. Three commands:
```bash
make android-screenshot # what the screen shows (.dev/ by default)
make android-inspect # forward the WebView's devtools socket
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
```
Four things about it, each of which costs an hour if met cold:
- **Only a `debuggable` build has a devtools socket**, and a debug build
carries `applicationIdSuffix ".dev"` so it installs **beside** the
release app. That matters more than convenience: the two are signed by
different certificates, and Android's only remedy for a changed
certificate is an uninstall, which takes the user's library with it.
Never uninstall to make room for a build.
- **Playwright cannot drive it.** `connectOverCDP` calls
`Browser.setDownloadBehavior`, a WebView answers "Browser context
management is not supported", and the connection dies before the first
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
WebSocket for that reason.
- **Wireless adb drops when the screen sleeps.** The symptoms are
`device offline` mid-session and a `fetch failed` from the eval
script. Plug in over USB for anything longer than a couple of probes.
- **The socket name carries the pid**, which changes on every launch, so
it is resolved rather than remembered.
**And the reason to bother: the phone is an engine, not a screen.** The
first device here renders in **Chrome 113** at 424x439 CSS px. Every
other tier runs a current Chromium or WebKit, so a spec that passes at
that viewport says nothing about the phone — 113 has no Popover API and
no relaxed CSS nesting, and a dropped CSS declaration renders as
"present but wrong", which is the hardest failure to read from a
picture. Get the version first; it reframes every other symptom.
@@ -63,5 +63,24 @@ Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead
address on purpose, so no seed depends on what the explore artifact address on purpose, so no seed depends on what the explore artifact
server happened to be serving. server happened to be serving.
Rebuild a seed after any schema change, or the restored database is Rebuild a seed after any schema change. Nothing migrates a restored
migrated on open in a way the seed's author never saw. database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed
keeps its old columns, the app starts, and the first query dies on
`no such column`.
**Restoring the seed does not disable the artifact fetch — only
*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone,
so on a developer machine the restored app immediately downloads and
imports the real ~1.1M-row catalog, through the one writer connection,
while whatever you started it for is running. A full `make e2e` against
that reported **14 failures** that were all contention; the same suite
against the same seed with
```bash
YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default
```
is the configuration CI runs (`ci.yml` sets exactly that address) and is
what to use before believing a failure. The tell is in `.dev/app.log`
an import logging progress — and in how the failures look: timeouts
spread across unrelated specs rather than one surface being wrong.
@@ -1,66 +1,76 @@
# Changing the database schema # Changing the database schema
The reasoning — why there are two files, what the old 48-step migration The reasoning — why the local library is shaped like files rather than
chain got wrong, and when squashing is legitimate — is in `CLAUDE.md` like MusicBrainz, and what the metadata tables cost before they went —
under *Backend packages → database*. Read it once. This is the is in `CLAUDE.md` under *Backend packages → database*. Read it once.
checklist. This is the checklist.
**A brand-new table needs one file, not two.** The rule below is about **There is one description of the schema and no migration chain.**
a *column added to a table that already exists*. `applySchema` runs `sql/schemas/*.sql` declares the current shape; `applySchema` runs every
every file in `sql/schemas/` on every open, so a file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent.
`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a `sql/migrations/`, `applyMigrations` and `schema_migrations` were
migration for it would be a second description of the same table — the squashed away with plan 013. So:
thing the third rule forbids. Its indexes go in the schema file too,
because the column and the index arrive together. **Adding a table or a column is one edit to one file.**
```bash
make generate # sqlc + templ
go test ./backend/database/ ./backend/datamap/
make test
```
A new table has a second gate: **`backend/datamap`**. Add an entry A new table has a second gate: **`backend/datamap`**. Add an entry
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate` if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
wants an explicit exemption with a note, because authored data is what wants an explicit exemption with a note, because authored data is what a
a user cannot get back. user cannot get back. If a *column* holds a different Kind from its
table (an authored flag on an owned projection, a fetched value beside a
tag-derived one), say so in the entry's note; `audio_files` and `lyrics`
are the worked examples.
Adding a **column** to an existing table needs **two** files, not one: **Existing databases are not migrated.** Nothing upgrades a database
from an older shape — delete your dev `YJ_HOME` and rescan, and rebuild
any seed you rely on (`make sandbox-seed NAME=default`). Revisit this
once real user databases exist in the wild.
1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT **A stale one fails at the first query, not at open**, which is worth
EXISTS`, the literal target shape, what sqlc reads and what a fresh knowing before you read the error. `applySchema` is
install gets verbatim. Add the new column **last** in the `CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns
`CREATE TABLE`. and gains nothing; the app then starts fine and dies on
2. **`backend/database/sql/migrations/NNNN_description.sql`** — the `no such column: title`. Every tier that does not *run the app* — unit
`ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an tests, `make ui-test`, `tsc` — is green while this is true, because
existing database to the same shape. Schema files are a no-op against they build their database from the current schema. `make e2e` and
a table that already exists, so without this an upgrade never gets `make dev` are the two that will tell you, and only after the seed has
the column. been rebuilt.
Then: ## The four ways this goes wrong
```bash - **A query file must be ASCII.** sqlc's parameter rewriter works on
make generate # sqlc + templ byte offsets, so a single non-ASCII character in a *query* comment
go test ./backend/database/ # migration + column-order tests (an em dash, a curly quote) shifts every placeholder and generates
make test garbage like `SELECid` — a parse error a long way from its cause.
``` Schema files are not rewritten and may contain anything.
- **A slice and a named parameter do not compose.** `sqlc.slice`
expands to N placeholders, but `sqlc.arg` is numbered independently,
so the two in one query bind the wrong values —
`GetFilePathsByAlbums([1,2], 0)` read album id 2 as the library id.
Where a query needs both, return the column and filter in Go.
- **A write wearing a query's shape still needs the writer.**
`QueryContext`/`QueryRow` route to the query-only read pool, so an
`INSERT ... RETURNING` through one fails at runtime with "attempt to
write a readonly database (8)". Use `ExecContext`, or
`QueryRowWriter`. `TestNoWritesOnTheReadPool` walks the tree for it.
- **A view is dropped and recreated.** `CREATE VIEW IF NOT EXISTS`
no-ops against a database holding the old definition, so
`track_metadata.sql` opens with `DROP VIEW IF EXISTS`.
Rebuild any seed you rely on (`make sandbox-seed NAME=default`) and ## Where things go
delete your own dev `YJ_HOME` if you want to see the fresh-install path
rather than the migrated one.
## The three ways this goes wrong
- **Column order must match between the two paths.** `ADD COLUMN`
always appends, so a migrated column declared anywhere but last in
`CREATE TABLE` leaves fresh and upgraded installs disagreeing on
order — and sqlc binds `SELECT *` positionally, so one of them
silently reads the wrong field.
`TestMigrations_ColumnOrderMatchesFreshInstall` is the regression test.
- **Do not put an index on a migrated column in `sql/schemas/`.**
Schema files run *before* migrations, against a database that may not
have the column yet, and the predicate fails. Declare the index in the
migration, after the `ALTER TABLE`.
- **Do not add a third description of the schema anywhere.** A
migration's `ADD COLUMN` failing with "duplicate column name" against
an already-current database is expected and tolerated, not an error to
route around.
New queries go in `backend/database/sql/queries/`; generated Go lands in New queries go in `backend/database/sql/queries/`; generated Go lands in
`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests `backend/database/sql/sqlcgen/`, which is never edited by hand. Anything
use `database.NewTestDB(t)`, built by the same `applySchema` production returning a track selects from the `track_metadata` view rather than
uses, so the two cannot diverge. re-joining — that is why there is one row type and one mapper.
Tests use `database.NewTestDB(t)`, built by the same `applySchema`
production uses, and seed rows with `database.InsertTestTrack(t, db,
database.TestTrack{...})` rather than assembling inserts by hand.
+1129
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,383 @@
# 015 — Android release pipeline
Ship an Android APK from CI on every version tag, published to the Gitea
generic package registry so Obtainium can poll a plain URL.
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
`android:` job has been through the failure modes already. Most of what
follows is a transcription of that job onto this repo's conventions;
where it differs, the difference is argued.
## What this is not
**This ships a pipeline, not a usable Android music player.** The
success criterion is a signed, installable APK that launches — not an
app anyone would want. Explicitly out of scope, and each is real:
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
linux` file is in the build and MPRIS will look for a session bus that
does not exist. It compiles; it will error at runtime.
- `backend/system` resolves XDG paths. Android has no XDG.
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
measured minimum, a transport bar. None of that is a phone layout.
- The library scanner walks a filesystem Android does not grant.
Those are the *next* plan, if there is one. Conflating them with this one
is how a build pipeline takes six weeks.
## Phase 0 — the gate [DONE 2026-08-16]
**Passed, further than asked.** No source changes were needed; a full
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
the environment and four non-obvious findings are in
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
library in the release APK's phone ABI, fixed here.
**It also installs and launches on an emulator, and then exits.** One
line stops it: `backend/system/buildUserDirPath` switches on
`runtime.GOOS` and Android takes the `default:` branch returning
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
after the JNI bridge comes up. That is the *first* thing that stops it,
not the only one — see the "not this" section above, all of which is
still true and still out of scope.
The emulator tier that found it is now part of the harness:
`scripts/android-emulator.sh`, the `make android-*` targets, and
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
because the failure is invisible in all three places anyone would look
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
the app fast enough that `pidof` always answers — so the tier's
assertion is "same pid after N seconds", not "it started".
Original phase 0 text follows, kept because its reasoning is what the
later phases rest on.
Everything downstream is wasted if the c-shared link fails. Establish it
by hand, locally, before writing a line of YAML.
Already established, by probe rather than by assumption:
```
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
```
compiles the entire tree. Exactly two packages fail, and both fail only
because their Android implementation is cgo:
- `ebitengine/oto/v3``driver_android.go` needs the bundled **oboe**
C++ backend. Oto supports Android natively; there is no Java audio
glue to write.
- `wails/v3/pkg/application``mobile_features_android.go` needs the
JNI bridge.
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
every `backend/` package are clean. **No source changes are known to be
required**, which is the single most surprising finding here and the
reason this plan is worth doing at all.
What Phase 0 must actually verify:
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
this build.
2. Generate the scaffolding (Phase 1) and run
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
exists and is an ARM64 shared object.
4. Repeat for `amd64` (the emulator ABI).
**If the link fails, stop and re-plan.** The likely culprits, in order:
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
combined with the generated `main_android.gen.go` overlay.
Deliverable: a note in `.planning/NOTES.md` recording the exact command
and the NDK version that produced a `.so`, or the reason it cannot.
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
Done as a side-effect of phase 0, which could not run without it. One
correction to the text below: **step 1 is wrong.** `update
build-assets` does not generate the android tree (NOTES.md explains);
it was generated with `generate build-assets` into a scratch dir and
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
were done as written.
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
entry was dropped from `Taskfile.yml` during plan 009. That was correct
when nothing could target Android and is what has to be undone.
1. `wails3 task common:update:build-assets` — beta.8 embeds
`internal/commands/build_assets/android/`, so this generates the tree.
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
to a comment so the asymmetry is explained rather than looking like an
oversight.
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
`includes:`.
4. **Gitignore the tree's own output**, or the repo grows a few hundred
Gradle intermediates. ljos has exactly this problem — its
`app/build/android/app/build/**` is committed. Ignore:
- `build/android/app/build/`
- `build/android/app/src/main/jniLibs/`
- `build/android/overlay.json` and `build/android/gen/`
5. `make build-prod` and `make test` still pass — the new include must
not perturb the desktop path.
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
section already says `build/`'s platform metadata is regenerated from
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
by hand. Extend that paragraph to name `build/android/app/build.gradle`
specifically, because the loss is silent and the symptom (a debug-signed
APK) appears months later as a failed update.
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
beta.3: the release signing config already exists** and reads the four
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
phase was identity and versioning only. Verified end to end:
| | |
|---|---|
| package | `app.yellowjacket` (was `com.wails.app`) |
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
| label | `YellowJacket` |
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
| ABIs | arm64-v8a + x86_64, both production-stripped |
Installs and launches under the new identity. Still exits on the known
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
Two things this phase learned that the text below did not know:
- **The identity has to be declared twice.** `applicationId` in
`app/build.gradle` is what Gradle installs; `APP_ID` in
`build/android/Taskfile.yml` is what every adb-driven task targets.
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
nothing in beta.8, verified with `--dry`. Both are set, each
commented pointing at the other.
- **The launcher activity is not under the applicationId.** It stays
`com.wails.app.MainActivity` (the scaffold's Java package), so
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
the wrong package and fails. `scripts/android-emulator.sh` carries the
fully-qualified name and a comment saying why.
The `keytool` PKCS12 note below was confirmed verbatim: given a
`-keypass` differing from `-storepass` it prints "Different store and
key passwords not supported for PKCS12 KeyStores. Ignoring
user-specified -keypass value."
Original phase 2 text follows.
Edit `build/android/app/build.gradle`, following ljos's, whose comments
are worth reading before writing this:
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
Java package, not the app identity).
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
**`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
parentheses to `versionCode` first, so the cast reads as
`versionCode("1") as Integer`, which sets a String and then casts the
setter's null return; Gradle fails the whole project with "Value is
null" at that line.
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
- `abiFilters 'arm64-v8a', 'x86_64'`.
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
back to the debug keystore only when no keystore is supplied.
**Android orders releases by an integer and refuses anything not greater
than what is installed.** A hardcoded `versionCode 1` means the first
install is the last: every later build is rejected as a downgrade and the
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
and patch stay under 100.
**Signing is not optional past the first install.** Android refuses to
update an app whose signing key changed, and the debug keystore differs
between every machine and every runner — so an unsigned CI build is a
decision to reinstall by hand forever. The job must **refuse to build**
without the keystore rather than quietly produce an APK that can never be
updated.
There is **one password and two required secrets**. keytool has defaulted
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
cannot hold a separate key password — given `-keypass` it warns and
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
cannot exist is how someone sets a wrong value and debugs Gradle at
midnight.
Add `make android``PATH="$(TOOLBIN):$$PATH" go tool wails3 task
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
documented target that does not exist, so document it only once it does.
## Phase 3 — the workflow [DONE 2026-08-16]
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
the operating document its error messages point at (phase 4's
documentation half; the secrets themselves still have to be created by
hand — see the table there).
Three departures from the text below, all argued in the file:
- **No `continue-on-error`.** The plan inherited it from ljos, where
the Android job shares a pipeline with a server deploy that must
never go red over a phone build. Here it is standalone and can
neither delay nor redden anything, so a release step that fails
silently is strictly worse than one that fails visibly.
- **No cached `wails3` binary.** The plan budgeted for ljos's
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
WebKit *dev* headers are still installed, because `go tool wails3`
links them.
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
~700 MB a run.
Four publish-gates were added and each was checked against a real APK:
both ABIs present, `versionCode` equal to the one derived from the tag,
a non-empty artifact, and **not signed with the debug key** — verified
by pointing the check at a deliberately debug-signed build, which it
refused.
Rehearsed locally with the exact CI invocation
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
Original phase 3 text follows.
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
`ci.yml` runs on every branch push and is the workflow that gates; the
runner is capacity 1, and a 45-minute Android build in it would put every
push behind an SDK download.
```yaml
on:
push:
tags: ["v*"]
workflow_dispatch:
```
This is where the baseline genuinely diverges. ljos computes its version
in CI (`scripts/next-version.sh`) and gates the Android job on
`needs.release.outputs.version != ''`, with an `always()` whose absence
would silently kill the manual path. **This repo has no release
automation** — tags are pushed by hand and `homebrew-formula.yml` already
keys on `v*`. So there is no `needs:`, no `always()`, and no status
function to get wrong: the tag *is* the version, and a dispatch falls
back to `git describe --tags --abbrev=0`.
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
needing node before any step has installed it):
```yaml
container:
image: ubuntu:24.04
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
```
The SDK path must be inside the runner's `valid_volumes` allowlist —
a directory outside it makes the job **fail to start**, not silently skip
the mount. `/cache/tool` is already allowed and already holds the Go
toolchain `ci.yml` downloads.
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
tag's other three workflows must not go red over a phone build, and a
backstop because a wedged SDK download must not hold the only runner slot
for hours.
Steps:
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
Android cross-build, which uses oboe.
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
sdkmanager is itself idempotent but still spends minutes verifying,
which is why the explicit directory guards are there. ~3 GB and most of
the job's wall clock on the first run; a directory listing after.
4. **wails3.** Cheaper here than in ljos, which pins
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
already pinned by `go.mod` and there is nothing to drift. It still
*links* GTK and WebKit, so cache the built binary in
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
ljos's finding that **caching the binary alone turned a slow job into
a broken one**: `wails3` is dynamically linked, so the runtime
packages are needed even on a cache hit. Here they are already in
step 1.
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
`frontend/dist`, so nothing Go-side typechecks without it.
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
unset, with the sentence explaining why (Phase 2). Decide the absolute
path *here* and export it via `$GITHUB_ENV`**`${{ env.HOME }}`
evaluates to an empty string in Gitea's expression context**, which
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
fifty-five seconds into a Gradle run.
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
opens with `keytool -list` *before* Gradle does (Gradle only notices at
`:app:validateSigningRelease`, a minute in, and reports it as a missing
file), then `make android`.
8. **Verify the signature.** `apksigner verify --print-certs`, and print
the SHA-256 with the note that a change to it breaks every future
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
`head -1` exits early, the producer takes SIGPIPE, and the step fails
with 141 *after* printing a perfectly good APK. Use `find … -print
-quit` and a captured variable.
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
credential pair `arch-package.yml` already uses, not ljos's
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
Gitea refuses to overwrite, so delete `latest` first. The generic
registry is readable **without credentials**, which is what lets
Obtainium poll a plain URL with no token and no public source mirror.
## Phase 4 — secrets and documentation
Secrets to create on the repo (all under Settings → Actions → Secrets):
| Secret | Required | Note |
|---|---|---|
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
Write the keytool command, the Obtainium URL and the signing-key warning
into a docs page — this is the part of ljos's setup that lives in
`docs/clients.md` and is referenced from the workflow's error messages,
so the messages have somewhere to point.
Then extend CLAUDE.md's CI section: it currently says "four workflows,
three of them package and publish; only `ci.yml` gates". That becomes
five, with the same sentence still true.
## Order and stopping points
Phase 0 gates everything. Phases 12 are one commit's worth of work and
are verifiable locally without CI. Phase 3 is the only part that needs a
runner, and its first run will be slow and will probably fail once on
something in the SDK step — budget for that rather than treating it as a
setback.
**Stop after Phase 0 if the c-shared link does not work.** Every later
phase is scaffolding for a build that does not exist, and the honest
outcome is a NOTES.md entry saying which package cannot cross-compile and
what it would take.
@@ -0,0 +1,337 @@
# 015 — Multi-artist credits, navigable
## The problem
A track credited to more than one artist has exactly one navigable
artist in this app, and the others are punctuation.
`audio_files` carries `artist_credit` (the credit as tagged, for
display) and `artist_id` (one artist, for grouping and browsing).
`primaryArtist()` (`backend/library/artistcredit.go:53`) resolves that
one artist by *string-parsing* the credit: it strips a " feat. "
clause, and deliberately does not split on `&`, `x`, `with` or `,`
because those appear inside real artist names. So "Lana Del Rey ft.
Sean Lennon" stores Lana Del Rey and discards Sean Lennon entirely,
and "Alina Baraz & Galimatias" stores one artist whose name is the
whole credit.
### What the measurement says
Measured 2026-08-16 against a real 26,069-file library (19,840 mp3,
6,229 flac; 57 unreadable, m4a/ogg not examined), plus an 80+80
MusicBrainz `inc=artist-credits` sample.
- **13%** of a random sample of the library's recordings have more
than one credited artist in MusicBrainz (10 of 79 resolved).
Extrapolates to ~3,250 of the 24,989 files carrying a recording
MBID.
- **0.86%** of files (224) carry any structured multi-artist signal in
their own tags. mp3 carries **zero** files with multiple
`MUSICBRAINZ_ARTISTID` values across 19,840 files; flac has 87.
- **1,286** files say "feat." in `ARTIST`; **1,159 of them (90%)**
have nothing structured behind it. A sample of 80 such files was
multi-artist in MB **80 of 80 times**.
CLAUDE.md currently justifies plan 013's removal of `artist_credit` /
`artist_credit_artist` with "3 credits of 2,823 listed more than one
artist". That figure measured **our own writer**, not the library:
`cachedLinkArtist` was called exactly once per credit
(`e7748f1^:backend/library/library.go:1842`), so a collaboration could
never have been recorded, and the three were resolution collisions on
shared credit text. Dropping the join table was still correct — it only
ever held one row, so it was pure join cost — but the stated evidence
does not support "multi-artist is rare". Correcting that claim is part
of this plan.
### Why the tags cannot answer it
Deriving the decomposition locally, with no network, works **79% of the
time** (169 of 215 files with a multi-value `ARTISTS` tag: mp3 69/105,
flac 100/110), and the failures are systematic rather than random:
```
ARTIST = '2Pac feat. Snoop Dogg, Nate Dogg, Hussein Fatal & Yaki Kadafi'
ARTISTS = ['2Pac', 'Snoop Doggy Dogg', 'Nate Dogg', 'Fatal', 'Yaki Kadafi']
```
`ARTISTS` holds **canonical** artist names; `ARTIST` holds
**as-credited** names. Locating one inside the other fails on
"Snoop Doggy Dogg" vs "Snoop Dogg", on "Fatal" vs "Hussein Fatal", and
on Unicode (`Michel'le` vs `Michelle`, `K-Ci` vs `KCi` — U+2010, not
a hyphen). That distinction is precisely what a join phrase encodes,
and it is why this cannot be a tag-parsing feature.
Two format details that will mislead anyone re-running the probe:
Picard writes `ARTISTS` **slash-joined into one TXXX frame** on mp3 and
as **true repeated Vorbis keys** on flac, so a probe splitting only on
NUL undercounts mp3 to zero.
## The shape
MusicBrainz models a credit as ordered parts, and the credit *string*
is derived from them — `artist_credit.name` is a cached render, nothing
more. Each participant is `(position, artist, name, join_phrase)`,
where `artist` is the MBID (canonical, what you navigate to) and `name`
is the credited spelling (what you display).
**Join phrases are assembly instructions, not disassembly
instructions.** Rendering is a concatenation, never a search:
```
for each (position, artist_mbid, credited_name, join_phrase):
emit link(credited_name -> artist_mbid)
emit text(join_phrase)
```
The link positions are known **by construction**. This is load-bearing:
if we instead located each `credited_name` inside the stored
`artist_credit` text, we would reintroduce the mismatch above — the
stored string may have come from the tags while the parts come from the
catalog, and those **disagree for ~1 in 3 multi-artist files** (61 of
90 sampled credits rendered exactly equal to the tag string).
Divergences seen: `'Skrillex feat. Swae Lee'` tagged vs
`'Skrillex & Swae Lee'` in MB; `'STRFKR'` vs `'Starfucker'`;
`'Zedd feat. Hayley Williams'` vs `'... of Paramore'`. Either MB was
edited after tagging or Picard versions differ; either way the search
would miss or match the wrong span.
So `audio_files.artist_credit` stops being the source of truth and
becomes the **fallback**, used only where there are no parts.
## Where the data comes from
The catalog carries the decomposition; no user ever makes a
per-recording call. Two sources were ruled out first, both cheaply:
- **The canonical dump — which is what CI already pulls
(`dumpimport.go:84-85`) — does not have it.**
`canonical_musicbrainz_data.csv` gives `artist_mbids` (ordered list)
and `artist_credit_name`, but that last column is the *rendered*
string. Splitting it on CI needs the as-credited names, so CI would
fail exactly the way a local parse does.
- **The JSON dumps do not cover the catalog.**
`json-dumps/recording.tar.xz` is 31 MB / 368 MB uncompressed and
holds **153,691 recordings**, not ~35M. Measured against the test
library's 24,885 recording MBIDs: **0.00% overlap, zero rows**. It is
some other subset and is not usable.
That leaves the core dump, **`mbdump.tar.bz2`** (7.1 GB compressed at
the 20260815 export), from
`https://data.metabrainz.org/pub/musicbrainz/data/fullexport/`. Four
members are needed:
| member | why | approx rows |
| --- | --- | --- |
| `mbdump/artist_credit_name` | `(artist_credit, position, artist, name, join_phrase)` — the payload | ~4M |
| `mbdump/artist` | `id -> gid`, since the above references artist *row ids* | ~2.6M |
| `mbdump/recording` | `gid -> artist_credit`, to key credits by recording MBID | ~35M |
| `mbdump/release_group` | same, for album credits | ~2M |
### Coverage is not a concern
Of 24,885 distinct recording MBIDs in the test library, **24,808
(99.7%)** already have an `explore_index` recording row, measured
against a database at 2,052,200 rows — i.e. shipped-artifact coverage,
not a local build's. The popularity filter does not strand the long
tail here.
## Status
- **Phase 1 — done.** `backend/explore/dumpcredits.go` +
`dumpcreditswrite.go`, wired into `dumpimport.go`'s `run` behind its
own `credits_import_done` marker.
- **Phase 2 — done.** `cmd/indexexport` writes the two tables;
`artifactimport.go` reads them behind `artifactHasCredits()`.
- **Phase 4 — done, and it does not need Phase 3.** `explore.GetCredits`
reads the catalog tables keyed on the *recording* MBID, which both
sides of the app already carry — a catalog row has one and so does a
local file (`library.Track.RecordingMBID`). So one binding serves the
Explore pages and the library's own lists, and all ten artist-link
call sites render credits today without a local table.
- **Phase 3 (`file_artists`) — not started, and now an
offline-resilience task rather than a prerequisite.** The table is
deliberately *not* declared yet: nothing writes or reads it, and a
schema file plus a datamap note describing behaviour that does not
exist is a claim the code cannot back. Its remaining
value is that credits currently vanish when the catalog is absent or
still downloading, which is precisely the `no-index` state
`ShelfPage.State` exists to describe. Materialising into
`file_artists` is what makes a library stand on its own.
**Nothing renders yet in practice**, because no published artifact
carries credit tables — every credit falls back to its single link
until an index build with Phase 1 runs and is exported.
**Column layouts are verified against the real 20260815 export**, not
taken from the schema docs — `artist(id, gid, …)`,
`artist_credit(id, name, artist_count, …)`,
`artist_credit_name(credit, position, artist, name, join_phrase)` and
`recording(id, gid, name, artist_credit, …)` were each read out of the
dump. `release_group` shares `recording`'s first four columns and is
the one layout still taken on trust; `ErrDumpShape` turns a wrong guess
into a loud failure rather than a quietly wrong catalog.
**Still unrun: the ingest against the real 7.1 GB dump.** Everything is
covered by tests over a synthetic tar, which cannot catch a surprise in
the other ~35M rows.
### Phase 1 — Ingest credits on CI
New dump stage in `cmd/indexbuild`, behind the `indexbuild` tag with
the rest of `dumpimport.go`'s stages.
**Constraint from `b98840e`:** `cmd/indexbuild` is built
`CGO_ENABLED=0` in a plain `golang` container and must not reach the
Wails `application` package — `TestIndexToolsDoNotImportWails` walks
`go list -deps -tags indexbuild`. Nothing here should need it, but a
new `ServiceStartup` hook on a package this imports is how it comes
back. Go's `compress/bzip2` is pure Go and decompress-only, which is
all this needs.
**Measured, 20260815 export.** Tar members are **alphabetical**, and
that is favourable: `artist` (435 MB), `artist_credit` (414 MB) and
`artist_credit_name` (237 MB) all fall inside the first ~900 MB
compressed, while `recording` and `release_group` come later. So the
maps are complete before the rows that consume them arrive, and no
recording data is ever buffered.
Pure-Go `compress/bzip2` decompresses at **26 MB/s uncompressed /
8.7 MB/s compressed** (measured on a 250 MB prefix, 3.01x ratio) —
**~13.7 min** for the whole file single-threaded, and less because the
stream can stop after `release_group` rather than reading the
`series`/`tag`/`track`/`url`/`work` tail. The 2 MB/s origin throttle
dominates, as it already does for every other dump here.
Do not, however, *depend* on the ordering: assert it and fall back to
buffering if a future export reorders, rather than silently emitting
nothing.
- `artist` -> `map[int32]uuid16` (~2.6M x ~20 B = ~60 MB)
- `artist_credit_name` -> `map[int32][]creditPart` (~4M x ~40 B =
~200 MB)
- `recording` / `release_group` -> emit `gid -> credit_id` **only for
MBIDs already in `explore_index`** (the kept set is ~1.4M x 16 B =
~22 MB), which is what keeps 35M rows from being held
Peak ~300 MB, one sequential pass.
**Only multi-artist credits are stored.** A single-artist credit is
`(name, "")` and is already fully described by `explore_index`'s
`artist_name` / `artist_mbid`; storing it would triple the table for
nothing. Post-filter after loading, once the row count per credit is
known.
New tables (and `datamap` entries, or `TestCatalogCoversSchema` fails
the build — both are `Cache`, matching `explore_index`):
```
artist_credit_part(credit_id, position, artist_mbid, credited_name, join_phrase)
```
with `explore_index.artist_credit_id` as the link. Credits are
**shared** — an album's twelve tracks by one artist share one credit
row — which is the opposite of 013's local verdict, and correctly so:
1:1 in a local library, genuinely many-to-one at 2M-row catalog scale.
### Phase 2 — Ship them in the artifact
`cmd/indexexport` currently creates exactly two tables in the artifact
(`explore_index`, `artifact_meta`, at `cmd/indexexport/*.go:147,170`),
so this is a structural addition, not a column.
Estimated size: ~13% of 1.4M recordings, deduplicated by shared credit,
at ~2.3 parts each — order 400k rows, ~18 MB uncompressed. Against a
~0.6 GB install that is acceptable; it must be measured rather than
assumed before merge.
`artifactimport.go` must read it **only if present**, on the writer
handle where `core` is attached — the `artifactHasTotals()` /
`artifactStoresText()` pattern (`artifactimport.go:145-175`), one step
up from a column to a table. An artifact published before this exists
is still a perfectly good catalog and must import as one that declines
to answer. Adding this to the importer's SELECT list without the probe
is how every already-published artifact starts failing.
`artifactCatalogColumns` gains `artist_credit_id`; it is kept in sync
with the exporter by `TestArtifactColumnsMatchExporter`.
### Phase 3 — Materialize locally
```
file_artists(audio_file_id, position, artist_id, credited_name, join_phrase)
```
`credited_name` is stored **per row**, not looked up from
`artists.name` — that is the Snoop-Doggy-Dogg distinction, and it is
the whole point.
Filled at scan/import time by joining `audio_files.recording_mbid`
against the catalog. **Materialized rather than resolved live**,
because the catalog is a downloaded artifact that can be absent or
still arriving — that is why `ShelfPage.State` has a `no-index` value —
and a library whose track rows lose their artists when the catalog is
missing is worse than today.
That implies a backfill for the case where the catalog arrives *after*
the library was scanned. It registers with `jobs` (progress, cancel)
like every other long pass, and takes a **distinct kind** from
`index-build`, since `job-controls.ts` keys its "you will discard hours
of downloading" confirmation on that kind.
`artists` gains rows for guests who own no files. **This changes what
the artists grid shows** and is an open question below.
### Phase 4 — Render
`utils/explore-link.ts` gains a credit-rendering entry point taking
ordered parts and returning a `TemplateResult`. Every row and detail
view already renders artist names through it, so they inherit
multi-artist links without individually knowing credits exist — the
property that made centralising it worthwhile.
Its existing fallback philosophy already covers the no-parts case: "a
list where some rows are clickable and others silently are not reads as
a bug, not as a statement about metadata." Where there are no parts
(no recording MBID, or no catalog row — ~4% of the test library) render
today's behaviour: the flat `artist_credit` string with one link to the
primary artist. **Do not split the string there.** There is genuinely
no information to split on, and that is the one place the temptation
returns.
`primaryArtist()` stays exactly as it is. It remains the fallback and
is still what `artist_id` means.
## Open questions
1. **Catalog credit vs tagged credit, when they disagree** (~1 in 3
multi-artist files). Rendering the catalog's decomposition is what
makes names navigable; preserving the file's is what makes the app
reflect the user's files. Leaning toward: render the catalog
decomposition, keep `artist_credit` as the fallback string. Wants a
deliberate decision, not an accident.
2. **Do guest artists appear in the artists grid?** Phase 3 creates
`artists` rows for people who own no files. The grid currently means
"artists in your library" and joins `audio_files`. A guest on one
track is arguably in the library and arguably not. Whichever way,
the ownership question stays "is there a file" — that rule does not
bend.
3. **`release_group` credits** are ingested in the same pass for
nearly nothing, but album-artist rendering is a separate surface.
Ship the data in phase 1, render in a follow-up rather than widening
phase 4.
4. **Our own `tagwriter`** does not write `ARTISTS` or multiple
`MUSICBRAINZ_ARTISTID` frames, so autotagging a folder degrades the
very field this rests on — the same shape as the existing
track-totals note. Out of scope here; worth recording.
## Verification
- Coverage: re-run the library probe and assert `file_artists` is
populated for ~13% of files, not ~0.9%.
- `TestCatalogCoversSchema` / `TestLifetimesMatchSchema` for the new
tables.
- `TestIndexToolsDoNotImportWails` still passes with the new stage.
- An artifact **without** the credits table imports cleanly (the
`artifactHasTotals` regression shape).
- Round-trip: a known multi-artist recording renders each name as a
separate link with the correct join phrases between them.
@@ -0,0 +1,639 @@
# 013 — The database audit
**Status:** **complete** (2026-08-16). R1R10 landed, the album page
that prompted the audit with them, and the one part of R5 that ships
*in the artifact* — a per-release-group track denominator — landed as
plan 014.
The audit below is unchanged from when it was written — the measurements
describe the *old* shape and are the reason for the new one.
**Branch:** none
**Created:** 2026-08-15
**Supersedes:** the four-part album-page fix sketched in conversation
(it survives, reduced, as R1 and R3 below)
**Related:** 010 (owned albums offline), 011 (owned artists'
discography), 012 (API call audit), 002 (data lifecycle)
---
## Method
Every number here is measured against the **real 25,966-track library**
at `~/.local/share/yellowjacket/yj.db` (copied read-only), not against
a fixture and not inferred from the code. Where a claim rests on a
capability rather than a count — "sqlc can do X" — it was executed, not
assumed.
The brief: *efficiency and simplicity — the minimum required to achieve
our featureset*, with fewer lines and a smaller database as evidence
rather than as the goal. Two named sources of confusion to resolve:
**local versus remote** versions of a thing, **files versus tracks**,
and **indexed versus live** lookups. One added constraint: **avoid
hitting APIs by storing intelligently, without a ridiculous base
install.**
---
## The measurements
### The database is 1.00 GB, and 78% of it is one table
| object | size | rows |
|---|---|---|
| `explore_index` | 383 MB | 2,052,200 |
| its five indexes + `UNIQUE(mbid)` | 395 MB | — |
| its two FTS tables | 85 MB | 2,052,200 + 96,451 |
| `recordings` | 38 MB (27 MB of it lyrics) | 26,778 |
| `lyrics_index` | 18 MB | 24,294 |
| `artist_metadata` | 12 MB | 7,673 |
| `http_cache` | 9 MB | 2,930 |
| `audio_files` | 5 MB | 25,966 |
| everything else | < 10 MB | — |
The local library — the part that is *the user's* — is about 50 MB.
The catalog and its indexes are 780 MB.
### Inside `explore_index`, half the bytes are three text columns
| column | bytes | note |
|---|---|---|
| `mbid` | 70 MB | 36-char text; 16 bytes as a blob |
| `artist_mbid` | 70 MB | same, and it is a foreign key in disguise |
| `caa_release_mbid` | 62 MB | same |
| `entity_type` | 18 MB | three distinct values, stored as words |
| `title` / `artist_name` / `release_name` | 74 MB | real data |
Five columns are declared, shipped in the artifact, selected in every
query, and **empty**: `aliases` (0 rows), `sort_name` (0),
`disambiguation` (0), `country` (69 rows of 2.05 M), `artist_type`
(72). `aliases` is additionally a column in *both* FTS tables, so the
tokenizer indexes nothing, twice.
### Two 50 MB indexes have a `WHERE` clause that excludes 0.3% of rows
`idx_explore_title_lower` (53 MB) and `idx_explore_artist_lower`
(48 MB) are `WHERE popularity > 0`. 2,046,645 of 2,052,200 rows satisfy
that. They are full indexes wearing a partial index's clothes, and they
exist to serve one exact-match tier (`ExactMatches`,
`searchindex.go:1298`) that the champion FTS — 96,451 rows, 2 MB —
already covers the popular half of.
### The local library models many-to-many relationships that are all 1:1
| claim | measured |
|---|---|
| recordings with more than one file | **0** |
| recordings in more than one release group | **0** |
| artist credits with more than one artist | **3** of 2,823 |
| files sharing a recording | **0** |
`recordings` (26,778) is one row per file. `release_group_recordings`
(26,778) is one row per file. `artist_credit` (2,823) and
`artist_credit_artist` (2,826) differ by three.
### …and it leaks rows that outlive the files
| orphan | count |
|---|---|
| `recordings` with no `audio_files` row | **812** (218 carry MBIDs) |
| `release_groups` with no file underneath | **216** |
| `artists` credited on no file | **260** |
| `explore_index` rows flagged **`in_library` with no file behind them** | **129** recordings, 2 release groups, 1 artist |
That last row is the bug reported today, in the user's own data.
### The query surface
| surface | count |
|---|---|
| sqlc queries | 235 (7,850 generated Go lines) |
| raw SQL call sites outside sqlc | 188 |
| bound IPC methods | 272 |
| `X` / `XByLibrary` query twins | 14 (8 of them exposed as separate bindings) |
| copies of the "one row per file with its metadata" projection | **9**, plus the view that already defines it |
`mapTrackRow` takes **22 positional arguments** and is called from 9
places, because each duplicated query generates its own row struct.
### The data directory is 8.5 GB — the database is the small part
| path | size | of which |
|---|---|---|
| `artist-images/` | 5.4 GB | **4,125 MB is candidate images no code path reads**; 1,222 MB is primaries + tiers for **5,770 artists** in a library with **1,301** |
| `covers/` | 1.4 GB | **1,134 MB is originals**; all three rendered tiers together are 110 MB |
| `ffmpeg/` | 283 MB | bundled binary |
| `yj.db` | 1.0 GB | above |
| `yj.db.bak` + `.bak.20260309` | 452 MB | nothing deletes these |
| art caches (`cover-art-cache`, `artist-image-cache`) | 81 MB | catalog art, fine |
The 4.1 GB of unreachable artist candidates is the bug `CLAUDE.md`
records as fixed; this install still carries it, so **the janitor jobs
have never run here**. Worth confirming they run at all before
declaring that one closed.
---
## The diagnosis
Everything below is downstream of one thing.
**There are three different notions of "a track" in this app, and the
code keeps asking the wrong one.**
1. **A file** — a row in `audio_files`. The only thing that is
unambiguously *yours*: it has a path, it plays.
2. **A local entity** — a row in `recordings` / `release_groups` /
`artists`. Created by a scan *from* a file, but with an independent
lifetime: nothing deletes it when the file goes, and retagging a
file **creates a new one and abandons the old**
(`library.go:1722` repoints `audio_files.recording_id` at a fresh
recording; `pruneOrphanedMetadata` only runs on the scan's
*deleted-file* branch, `library.go:982`). This is where the 812
orphans come from — and autotagging is the machine that makes them.
3. **A catalog entity** — a row in `explore_index`, downloaded, global,
identical for every user.
"Is this mine" is asked of **(2)** almost everywhere, and answered by
**(1)** whenever the user actually does something:
- `LibraryMBIDIndex.CheckMBIDs` (`librarymbid.go:64`) is literally
`SELECT mbid FROM recordings WHERE mbid IN (…)`. It sets `inLibrary`
on every catalog tracklist.
- `pruneStaleLocalCrossReferences` (`searchindex.go:2480`) clears
`explore_index.in_library` when the **`recordings` row** disappears —
not when the file does. Hence 129 phantom "you own this" rows.
- `albumLibraryStatus()` in `explore-album-details.ts` ORs four claims
of decreasing confidence, none of which is "a file exists".
- But `GetFilePathsByRecordingMBIDs`, which every *action* goes
through, joins `audio_files`. It is the only one that tells the
truth.
So a retagged file leaves behind a recording carrying the **old** MBID;
the catalog matches that MBID; the row renders owned, undimmed, with a
Play button; and every action on it fails with "could not be found in
your library" — on a fully-tagged library. The user's instinct that the
check is fragile is correct, and the fragility is not the live lookup.
**The live lookup is the only part that is right.**
The same confusion explains "files vs tracks" and "local vs remote":
tables (2) exist to be a local mirror of the catalog's shape, so a
"track" is sometimes a file, sometimes a mirror row, sometimes a
catalog row, and the three are joined by MBID — a key that **two of the
three can lack or lie about**.
---
## Findings and recommendations
### R1 — Ownership is "a file exists". Say it once, in SQL.
*Cheap, immediate, and it fixes the reported bug.*
- `CheckMBIDs`' `recordings` and `release_groups` branches gain a join
to `audio_files`. (`artists` too, via credit.)
- `pruneStaleLocalCrossReferences` tests for a file, not for a local
row.
- `pruneOrphanedMetadata` runs after the retag path as well as the
delete path — or, better, is deleted along with the tables that need
it (R2).
- One-shot cleanup of the 812/216/260 existing orphans at open.
**Effect:** 129 lying rows in this library become honest; the class
cannot recur while (2) exists.
### R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one
*The big one. It is what makes R1 structural rather than a patch.*
The local model imitates MusicBrainz's normalization — `artist_credit`
is an MB concept — for a dataset in which **every relationship it
models is 1:1** (measured above). The cost of that imitation:
- 5 tables (`recordings`, `release_group_recordings`, `artist_credit`,
`artist_credit_artist`, `release_to_rg` — the last has **0 rows** and
no schema-file writer) and ~12 indexes.
- A 6-way join in every read, including a `MIN(release_group_id)`
subquery repeated in **11 places** to undo a many-to-many that never
happens, and a "first credited artist" subquery in **9** to undo
another (the row-multiplication bug class documented at length in
`CLAUDE.md`, which serves 3 rows).
- An orphan-cleanup subsystem (`GetOrphaned*IDs` ×3, `Count*References`
×2, `pruneOrphanedMetadata`) that exists only because these rows can
outlive their file — and which does not actually work (812 orphans).
- The entire phantom-ownership class above.
Proposed shape:
```
audio_files id, path, library_id, …, title, track_no, disc_no, year,
composer, comment, artist_credit TEXT, artist_id→artists,
album_id→albums, recording_mbid, modified_at, …
albums id, name, artist_id, mbid, year, original_year,
cover_art_id, total_tracks… (genuinely many files→1)
artists id, name, mbid (genuinely many→1)
genres + file_genres (genuinely many↔many:
107k rows / 26k files)
```
`artist_credit` survives as **text on the file** (display: "A feat.
B") plus `artist_id` (the primary artist, for grouping) — which is
everything the UI does with it today, minus the join that multiplies
rows.
**Effect:** a row exists iff a file exists, so R1 becomes a foreign key
rather than a rule anyone can forget. Removes 5 tables, ~12 indexes,
~30 sqlc queries, the orphan subsystem, both repeated subqueries, and
the `AUTOMATIC COVERING INDEX` SQLite builds on every library load.
Estimated 1,500 to 2,500 lines across `backend/library`,
`backend/database/sql/*` and `sqlcgen`.
**Cost:** one real migration of user data (not an `ADD COLUMN`), and it
touches autotag, tagwriter, playlist matching and the explore xref.
This is the item to sequence carefully; everything else is independent
of it.
### R3 — One projection, one row type, one mapper
`track_metadata` (the view) already *is* the canonical "one row per
file" definition, and **only the raw-SQL search paths use it**
(`search.go`, `lyrics_search.go`). Every sqlc query re-implements it —
9 copies, which have already drifted: the view prefers
`rg.original_year` for `year`, `GetAllTracksWithFullMetadata` uses
`r.year`. The same library shows a different year depending on which
screen you are on.
**Verified, not assumed:** sqlc generates cleanly against the view —
`SELECT * FROM track_metadata WHERE …` yields one `TrackMetadatum`
struct with correct types (run during this audit).
And the 14 `X`/`XByLibrary` twins collapse into one query each:
```sql
WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))
```
**Measured cost of the collapse: none.** Scoped-with-OR 23 ms, scoped
direct 21 ms, unscoped 145 ms over the full 26k rows.
**Effect:** 14 queries, 8 bindings, 8 frontend branches, 9 row
structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly 2,000
generated lines and 300 hand-written ones, and the year inconsistency
cannot exist.
### R4 — Put `explore_index` on a diet (~200 MB, no feature loss)
| change | saved |
|---|---|
| `mbid`, `artist_mbid`, `caa_release_mbid` as 16-byte blobs | ~110 MB in the table |
| …and the same keys in `UNIQUE(mbid)` (99 MB) and `idx_explore_index_artist_mbid` (131 MB) | ~70100 MB |
| `entity_type` → INTEGER | 18 MB + index |
| drop `aliases`, `sort_name`, `disambiguation` (0 rows); reconsider `country`/`artist_type` (69/72 rows) | small bytes, real clarity — and one fewer empty FTS column |
| make the two `LOWER()` indexes' partial predicate *mean* something (`popularity >= championPopThreshold OR in_library`), or retire the tier onto the champion FTS | up to 101 MB |
Better still for `artist_mbid`: it is a foreign key spelled as text.
An integer reference to the artist row is 8 bytes instead of 36 and
makes the 131 MB index a fraction of its size.
**Also worth separating:** `in_library`, `local_*_id`, `is_similar` and
`discog_fetched` are *personalization* stored inside the *shipped
catalog* table, which is why the artifact import has to merge by
explicit column list and why `artist_enrichment` had to become its own
table for exactly this reason. Measured: `in_library` and
`local_*_id IS NOT NULL` agree on **every one of 2,052,200 rows**
they are the same fact stored twice. A `library_xref(mbid, kind,
local_id)` side table would make the catalog table purely the artifact
and delete the merge-by-column-list rule.
### R5 — Ask the network less, without a bigger install
Present state (from `musicbrainz.go:17-27`): search 24 h, **entity 7
days**, releases 90 days. MusicBrainz entity data changes on the order
of *never* for the fields we read, and 251 of 2,930 cache rows are
already expired on this install — so a fully-populated artist page
re-fetches itself weekly, forever.
- **Raise `cacheTTLEntity` to a year** (or drop expiry and revalidate
in the background). Cost: bytes already stored. Benefit: the
steady-state network cost of browsing your own library goes to
roughly zero.
- **Ship a per-release-group `total_tracks` in the artifact.** 010
correctly rejects shipping *tracklists* (the per-artist track budget
would truncate them, and "Play 7 of 9" for a twelve-track album is a
confident lie). But the **denominator** is one small integer per
release group — 400,677 rows, ~2 bytes — and it is exactly what
`albumLibraryStatus`/`ownership()` needs to say complete /
incomplete / unknown for a catalog album with no local tags. Tiny,
honest, and it does not depend on coverage.
- **Keep 010's per-user backfill** for the tracklists themselves; this
does not replace it, it shrinks what it has to cover.
- `http_cache` has no size bound and no vacuum beyond expiry. Give it a
ceiling.
### R6 — The 5.3 GB on disk that no feature needs
- **4,125 MB of artist candidate images** that nothing reads (the
documented bug — but the janitors have not run on this install;
verify they run at all).
- Artist images exist for **5,770 artists** in a **1,301-artist**
library. Fetching art for artists you do not own is the same
"prefetch everything" instinct as the discography backfill 011
corrected.
- **1,134 MB of cover originals** versus 110 MB for all three rendered
tiers. Nothing renders the original; and it is re-derivable from the
audio file itself, which is on disk by definition. Keep `_lg` as the
largest and drop originals — that is 1.1 GB with no visible change.
- `yj.db.bak` (394 MB) and `yj.db.bak.20260309` (58 MB) accumulate with
nothing to clean them.
This is the largest single win available and it does not touch the
schema.
### R7 — Redundant indexes and dead columns
Five indexes are prefixes of an existing UNIQUE/PK and can be dropped
outright (they cost write time on every insert):
`idx_recording_genres_recording_id``UNIQUE(recording_id, genre_id)` ·
`idx_similar_artist_map_source``PK(source, similar)` ·
`idx_artist_credit_artist_artist_id``UNIQUE(artist_id, credit_id)` ·
`idx_artist_metadata_mbid``PK(mbid, source)` ·
`idx_artist_images_mbid``UNIQUE(artist_mbid, source, source_url)`.
Dead data:
- **`recordings.genre`** — populated on 25,619 rows at every scan and
**read by nothing**. Every genre read goes through
`recording_genres` + `genres`. Write-only column.
- **`release_groups.total_tracks` / `total_discs`** — 0 rows populated;
the feature that needed them put the number on
`release_group_recordings` instead.
- **`release_to_rg`** — 0 rows, no writer in any schema file.
- `libraries.sql` carries a doc comment about `download_requests`,
pasted from another file. Small, but it is the kind of drift the
two-file schema rule exists to catch.
### R8 — One genuine N+1
`mixCandidates` (`explore/mix.go:181`) issues
`GetGenreNamesByFilePath` **per candidate path**, inside a loop over
similar artists, inside a loop over seed artists. Twenty seeds × twenty
similar × thirty paths is 12,000 single-row queries for one mix. It is
one query with an `IN` clause, or one query for the whole weighted set.
(`mixSeedProfile` above it is the same shape, bounded by seed size.)
Nothing else in the tree matches this pattern — a scan of every query
issued inside a loop turned up 72 candidates and this is the only real
one.
### R9 — The IPC surface has internals in it
Bound and reachable from the frontend today: `AcquirePipelineLock`,
`ReleasePipelineLock`, `SetJobRegistry`, `SetScanHooks`,
`SetRescanHooks`, `SetRemovalHooks`, `MusicBrainz`, `CAALimiter`,
`PopulateLocalCrossReferences`. v3's generator binds every exported
method; these want to be unexported or moved off the service type.
Free lines, and one less way to wedge the app from a console.
### R10 — The test DB is not the shape production runs
`NewTestDB` shares one in-memory connection and leaves `readDB` nil, so
`reader()` returns the writer. That is why the read-pool write bug
(documented in `CLAUDE.md`) reached a user, and why
`TestNoWritesOnTheReadPool` had to be a tree-walk instead of a test.
Giving the test DB two handles over one shared in-memory file would let
that be an ordinary test.
---
## What I recommend leaving alone
- **The download subsystem** (requests / downloads / items). Three
tables, clean lifetimes, well argued in the schema comments. The
`download_wants` table in this install is the pre-rename name; the
rename migration will clear it on next launch.
- **The champion FTS.** 96k rows, 2 MB, a real latency tier.
- **The dual write/read handle**, WAL, and the persist-writer queues.
These are recent, measured, and correct.
- **File paths as the frontend's identity for a track.** Integer ids
would be cheaper over IPC, but `CLAUDE.md`'s argument (an index goes
stale on re-sort/refilter, a path does not) is right, and the cost is
bounded.
- **Storing lyrics locally** (27 MB + 18 MB index for 24k tracks). That
is the API-avoidance trade working exactly as intended.
---
## What landed (2026-08-15 / 16)
### The third pass: the album page, which is where the report came from
The audit started from a user report — a fully-tagged library saying
"not in your library", on hover rather than on click — and R1 fixed the
half of that which lives in SQL. The other half was the page: ownership
was four claims OR'd into a tick, and the context menu asked the backend
per row, as the menu opened.
`explore-album-details` now resolves the displayed tracklist's file
paths **once**, from `updated()`, into one `filePaths` map that the
badge, the Play count, the dimmed rows and every menu item read. The
synthesised local tracks carry their own `FilePath`, so a library album
costs no lookup at all; a catalog tracklist costs one batched
`GetFilePathsByRecordingMBIDs`. `catalogScope()` no longer returns
`'library'` here — that was the second complaint in the same report, and
the artist page keeps it because a library-only *artist* really is
missing sections.
Two bugs fell out of doing it this way, and neither is the one that was
reported:
- The render loop. Guarding the lookup on `filePaths` (answered) rather
than on `askedFor` (asked) re-requests every *unowned* MBID forever,
because an unowned MBID never lands in the map.
- "No release data available" over a tracklist held in memory.
`loadLocalTracks` rebuilt the version list only when catalog releases
existed, but the "Your Library" entry is synthesised *from* the local
tracks — so the no-releases case was the one case it skipped. Nothing
caught it because the old ownership check answered from the local
album id and never needed the tracklist to exist.
### The second pass: R5R10
| | before | after |
|---|---|---|
| the two exact-match indexes | 101 MB | **3 MB** (predicate narrowed to the champion set; plan unchanged, measured) |
| cover art on disk | original + 3 tiers | **3 tiers** — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it |
| browsed artist art | 90-day expiry, no ceiling | expiry **plus a 256 MB budget**, oldest evicted first; owned artists never in it |
| MusicBrainz entity TTL | 7 days | **1 year**, with a 128 MB ceiling on the response cache |
| redundant indexes | 5 | **0** (3 dropped here, 2 went with their tables) |
| internal methods on the IPC surface | 24 | **0** (`//wails:ignore`; 272 → 248 bound methods) |
| test DB | one handle, `readDB` nil | **two handles**, the shape production runs |
The catalog line is R4, finished the day after: MBIDs stored as 16 raw
bytes and entity types as codes, measured by converting the real
2,052,200-row catalog through the shipped schema. It needed no artifact
rebuild — the importer asks the artifact which encoding it carries and
converts the older text form on the way in. Plan 014 has the detail.
Two of those repaid immediately. Giving the test database its own
read pool **caught three tests writing through it** on the first run —
the exact bug class that reached a user as "attempt to write a readonly
database" and that `TestNoWritesOnTheReadPool` had to walk the source
tree to find. And the artist-image sweep's own test turned out to seed
an `artists` row with no file and call it owned: the phantom this whole
audit is about, sitting in the fixture of the test that guards it.
**One finding in this audit was wrong.** `aliases`, `sort_name`,
`disambiguation`, `country` and `artist_type` are not dead columns. They
are empty on that install because the artist-enrichment pass had barely
run (which is finding 011's subject), but `indexOneArtist` writes all
five, and `aliases` is an FTS column that makes an artist findable by
alias. They stay.
### The first pass: R2, carrying R1 and R3
R2 shipped with R1 and R3 inside it, because the collapse made them
free rather than separate work. No migration: fresh installs only, by
the user's decision, so `sql/migrations/` went with it.
| | before | after |
|---|---|---|
| local tables | 9 | 5 (`audio_files`, `albums`, `artists`, `genres`, `file_genres`) |
| sqlc queries | 235 | 185 |
| generated Go | 7,850 | 6,023 |
| bound IPC methods | 272 | 264 |
| copies of the track projection | 9 + the view | the view |
| `X`/`XByLibrary` query twins | 14 | 0 |
| migration files + runner | 7 + ~120 lines | 0 |
| **net** | | **5,070 lines** across 122 files |
Gone: `recordings`, `release_group_recordings`, `artist_credit`,
`artist_credit_artist`, `pruneOrphanedMetadata`'s four sweeps,
`RemoveLibrary`'s eight, `mapTrackRow`'s 22 positional arguments, and
340 lines of `tagwriter/dbsync.go` that existed to relink and then
un-orphan those tables.
Ownership is now a file in every one of the places that used to ask a
metadata table: `CheckMBIDs`, `collectLibraryEntities`,
`pruneStaleLocalCrossReferences` and `GetFilePathsByRecordingMBIDs`.
Three things found on the way, each written down where it can be hit
again (`CLAUDE.md`, `references/schema-change.md`):
- **sqlc's parameter rewriter is byte-offset based**, so one em dash in
a *query* comment corrupts generation into `SELECid`.
- **`sqlc.slice` and `sqlc.arg` do not compose** — slice expansion
renumbers, so `GetFilePathsByAlbums([1,2], 0)` read album id 2 as the
library id. Caught by a test, not by a type.
- **`release_to_rg` looked dead and was not**: 0 rows on any ordinary
install, because only a local `indexbuild` fills it, and the daily
incremental refresh reads it. Restored.
Verified: `make lint` (3 configurations), `go test ./...` plus the
`indexbuild` and `dev` tag passes, `tsc --noEmit`, `make ui-test`
(768), and a new end-to-end test that scans the real fixture library
and asserts no row outlives its file
(`TestScan_FixtureLibraryLeavesNothingBehind`).
---
## Sequence
**Revised 2026-08-15, after the compatibility constraint was lifted:**
breaking changes are acceptable and the schema may be squashed. That
inverts the order — R2 was last only because of the migration, and it
*subsumes* R1 (ownership becomes a foreign key) and reshapes R3 (the
projection is defined over the new tables). Doing R1 and R3 against the
old shape first would be work thrown away.
1. **R2** — the schema collapse, with the rebuild below. It carries R1
and R3 with it.
2. **R6** — reclaim the 5.3 GB on disk; confirm the janitors run.
3. **R7 / R9 / R8 / R10** — the small correctness and hygiene items.
4. **R4** — the `explore_index` diet. Artifact rebuild + format bump.
5. **R5** — cache TTLs (trivial) and the shipped denominator (rides
along with R4's artifact change).
### "Break everything" has a floor, and it is not the schema
Reshaping tables freely is fine. **Dropping the database is not**, and
the numbers say so — a wipe-and-rescan would destroy:
| | count | why a rescan does not restore it |
|---|---|---|
| files marked `user_confirmed` | **25,014** | the user's autotag review decisions |
| reviewed tagging folders (`confirmed`/`skipped`) | **2,109** | ditto, plus every `skipped` becomes pending again |
| rows in `recordings.lyrics` | **24,294** | an unknown share came from **LRCLIB**, not from tags — re-fetching them is precisely the API traffic we are trying to avoid |
| playlists / playlist tracks | 22 / 1,917 | `Authored`; nothing else has them |
So the change ships as a **one-shot in-place rebuild**: create the new
tables, `INSERT … SELECT` across, drop the old ones, in a single
transaction at open. Seconds on 26k rows, ~40 lines of SQL, no
migration *chain* and no rollback path — which is the freedom that was
actually being asked for. `sql/migrations/` gets squashed into
`sql/schemas/` at the same time (`NOTES.md` already blesses this
pre-1.0).
### Two tables are classified as one Kind and hold another
`backend/datamap` already encodes what is safe to lose (`Owned` and
`Derived` rebuild from the files; `Cache` is expensive; `Authored` is
irreplaceable). The audit found two places where the *column* disagrees
with the *table's* entry, which is exactly why a wipe looked cheaper
than it is:
- **`audio_files.tag_status`** — the table is `Owned` (a projection of
the files), but `user_confirmed` / `user_skipped_permanent` are
**`Authored`**: a decision the user made that exists nowhere else.
- **`recordings.lyrics`** — the table is `Owned`, but lyrics fetched by
the LRCLIB backfill are **`Cache`**, and nothing records which of the
24,294 rows came from a tag and which from the network.
The new schema fixes both by construction: lyrics move to their own
MBID-keyed table with a `source` column (so they survive any rebuild of
the owned tables, and the provenance question becomes answerable), and
`tag_status`' authored values are carried across explicitly rather than
recomputed.
**Expected outcome if all of it lands:** database ~1.0 GB → ~0.75 GB,
data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go
7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters —
one definition of "this is mine" that a file either satisfies or does
not.
## The open questions, answered
1. **R2's migration** — the user's call, and it was "just assume this
new version will only be installed by a new user". So there is no
in-place rebuild and no chain: `sql/schemas/` is the whole
description. An existing `YJ_HOME` does not open (its `audio_files`
has `recording_id` and none of the tag columns, and
`CREATE TABLE IF NOT EXISTS` cannot add them) — delete and rescan,
and rebuild any seed with `make sandbox-seed`.
2. **R4's artifact format** — no break was needed. The importer asks
the artifact what it carries rather than trusting a version, so the
published text-form artifact still imports. Plan 014 has it.
3. **Yes, the janitors run.** `Runner.Start` calls `RunDue` immediately
and `lastRun` is in-memory, so every launch runs everything due.
The 4.1 GB survived because `OrphanedArtistImagesJob` joined a bare
MBID onto a *sharded* directory — deleting the rows and leaving the
files, which is worse than not running — and because
`StrayArtistImageFilesJob` did not exist. Both are fixed; it was a
bug report, not a cleanup.
## Measured on the finished refactor
| | expected | actual |
|---|---|---|
| sqlc queries | ~180 | **185** |
| generated Go | ~5,000 | **6,024** |
| bound methods | ~255 | **248** |
| `explore_index` + indexes | — | **780 MB → 405 MB** |
## The one recommendation not taken
R4's "better still" for `artist_mbid`: an integer reference to the
artist row (8 bytes) rather than the 16 raw bytes it now stores. It is
a further ~30 MB on `idx_explore_index_artist_mbid`, and the reason to
stop short is that the *artifact* carries MBIDs and not local ids, so
the import would have to resolve every row against a table it is in the
middle of filling. Worth its own argument, not a footnote to this one.
@@ -0,0 +1,99 @@
# 014 — The catalog's compact encoding, and the denominator it owed
**Status:** **complete** (2026-08-16). The encoding landed first; the
per-release-group `total_tracks` denominator landed with the album page
that spends it.
**Branch:** none
**Created:** 2026-08-16
**Depends on:** nothing
**Related:** 013 (the database audit, which measured all of this), 010
(owned albums offline), 001 (ship core index)
---
## The encoding
Measured on the real 2,052,200-row catalog, converting it through the
shipped schema (not a projection):
| object | before | after |
|---|---|---|
| `explore_index` | 383 MB | **242 MB** |
| `idx_explore_index_artist_mbid` | 131 MB | **65 MB** |
| `UNIQUE(mbid)` | 99 MB | **54 MB** |
| `idx_explore_index_entity_pop` | 47 MB | **28 MB** |
| `idx_explore_caa_release` | 17 MB | **11 MB** |
| the two `LOWER()` indexes | 101 MB | **3 MB** (013) |
| **total** | **780 MB** | **405 MB** |
Every row converted with the `CHECK` constraints live, which is also a
result: no MBID in a real 2 M-row catalog is malformed.
**No format bump, and no rebuilt artifact needed.** The importer asks
the artifact what encoding it carries (`typeof(mbid)`) and converts on
the way in if it is the old text form, so the artifact already
published keeps working and the exporter switches whenever CI next
runs. That is strictly better than the version negotiation this plan
originally proposed.
The silent-failure risk the plan was written around was handled by
making the failure loud instead of by avoiding the change: a `CHECK` on
the column turns a stringly write into an error at the insert, the
22-column projection became one constant and one scanner instead of
four copies, and `TestStoredEncodingRoundTrips` sweeps every read path
in the package. It found one real bug on its first run — the artifact
probe was asking the read pool, where the attached artifact does not
exist.
## The denominator
`total_tracks` on `explore_index`, ~2 bytes across 400,677 release
groups. It makes "do I have all of this" answerable offline for an
album whose **files declared no total**, which is a great deal of any
untagged library and the one thing `GetAlbumCompleteness` cannot answer
from tags. 010 rightly rejected shipping whole tracklists — the
per-artist track budget truncates them, and a truncated tracklist is a
confident lie about which tracks exist. A denominator has no such
problem, and the album page spends it as one: the numerator stays
local (distinct track numbers on disk), only the denominator is
borrowed, and only where the tags have none.
Four things about it are load-bearing.
**It is counted before the popularity filter.** `cmd/indexbuild` counts
the canonical dump's rows per kept release, which is that release's
track count because the dump carries one row per recording per
canonical release. Counting the *kept* recordings instead would say
"9" about a twelve-track album whose other three nobody has played —
worse than saying nothing, and the same class of lie as the truncated
tracklist. `TestDumpImportEndToEnd` has an unplayed track on a fixture
album for exactly this: three tracks in the total, two indexed as
recordings.
**Zero means "the catalog does not say"**, which is the same third
state the local answer already has. An album neither side can total
wears no ring rather than a wrong one.
**Adding a column to the importer's SELECT is how you break every
artifact already published.** `artifactHasTotals()` asks the attached
artifact whether the column exists, the same way and on the same handle
as `artifactStoresText()`, and selects a literal `0` when it does not.
Verified by forcing the probe true: the older shape then fails with
`no such column: total_tracks`, which is what a shipped build would
have done to a file nobody can re-cut retroactively.
**A test seeder that binds the upsert's parameters by hand is not
"breaking where the app breaks".** Three of them did, on the argument
that a schema change should fail the tests in the same place — and what
it actually produced was `missing argument with index 25`, three files
at a time, for a column none of them cares about. They go through
`upsertBatch` now, which is the one writer, and keep the property they
wanted: a field written to the wrong column still fails there.
## Done when
- [x] `GetAlbumCompleteness`'s gap is answerable for a catalog album the
library has no tags for, with no network call.
- [x] The artifact grows by less than a megabyte (~800 kB at 400,677
release groups).
- [x] An artifact published before the column still imports.
@@ -0,0 +1,87 @@
# 017 — Releases that happen by themselves
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
Commits since the last tag, cuts the tag and the Gitea release whose body
is the generated changelog, and the four publishing workflows build that
tag and attach their artifacts. Nothing is released by hand.
## What it looks like now
`release.yml` on push to `main` → semantic-release → tag → four `v*`
workflows in parallel (serialised in practice by the capacity-1 runner):
| workflow | publishes | attaches |
| --- | --- | --- |
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
| `homebrew-formula` | the public tap | — (builds from source) |
Verified on the real thing: all five green, three assets on the release,
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
## The five decisions, and what they cost
1. **semantic-release, not a shell script.** The first draft of this plan
proposed hand-rolling it and the argument did not survive checking:
`@semantic-release/exec` is first-party and current, and the
Gitea-shaped part is one `curl`. What I would have hand-rolled —
commit parsing, semver ordering, note rendering — is the part with the
edge cases and none of it is Gitea-shaped.
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
before it was checked: last published 2022, `got@10`, and no peer
dependency on semantic-release at all.
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
commit-back is rejected by the pre-receive hook — and would be
rejected *after* the tag was pushed, leaving a tagged release the run
reports as failed. The release page is the changelog;
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
signpost.
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
once. Documented in `packaging/homebrew/README.md` and
`docs/android-release.md`.
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
because no build of it has ever been *run*.
## Four things that only showed up by running it
- **`conventional-changelog-conventionalcommits@10` renders empty
notes.** Silently: right version, right tag, every step green, and a
release body that is a bare `## 0.0.1 (date)` heading with nothing
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
the reason beside both. **Check the rendered notes, never the exit
code.**
- **semantic-release core dry-run-pushes to the release branch** as a
permission check, independently of any plugin. `PACKAGE_TOKEN` had
package-write and repo-*read* — enough to clone, not enough for this —
and it failed with a flat `403 Forbidden` that reads exactly like
branch protection. It is not: a `--dry-run` push never reaches the
pre-receive hook, which a one-line experiment settled. The token needed
`write:repository`.
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
commit itself it leaves nothing between the floor and HEAD, and
semantic-release correctly reports there is nothing to release. The
first run did exactly that and cut nothing.
- **A tag-triggered workflow runs from the tagged commit's tree.**
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
general: a guard added today does not protect a tag pointing at
yesterday.
## Two mechanisms confirmed, having been assumptions
- **A tag pushed with a user PAT does start the `v*` workflows**; one
pushed with the Actions token does not (go-gitea#33123). Both halves
are load-bearing and both were observed: the floor seed triggered
nothing, and the release tag triggered all four.
- **Tags are not protected** on this repo, only `main` — which is what
lets semantic-release tag at all.
## Left behind deliberately
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
four publishers skip it by name.
@@ -0,0 +1,410 @@
# 016 — What Android parity would actually take
> **Status: all of section A is done.** A1A3 landed with "let the app
> reach the user's music"; A4 (MediaSession, transport notification,
> audio focus) landed with "survive the screen locking". The direction
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
> plus an in-app folder browser, which keeps the path-keyed model
> intact. B1/B2 remain, both awaiting a decision rather than work. The
> sections below are kept as written, because they are the argument the
> decision rests on — see "What is left" at the end for the current
> state.
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
versioned, and publishes from CI. This is the assessment of what stands
between that and an Android app worth installing.
**The headline: parity is the wrong target, and choosing it would be
the expensive mistake.** Four of the blockers below are not porting work
— they are the Android platform declining to support the model this app
is built on. The decision to make first is in "The fork in the road" at
the end; everything before it is evidence for that decision.
Severity is what the app *does* today, verified against the source and
the generated manifest, not guessed.
## A. It cannot work at all until these are fixed
### A1. The app can read no music. (deepest)
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
**There is no storage or media permission of any kind.** At
`targetSdk 35` that means the app can see its own private directory and
nothing else.
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
grants access through **MediaStore**, not through the filesystem. This
app's entire model is absolute paths: `audio_files.file_path` is the
primary key of ownership, `AddLibrary(path)` takes a directory, the
scanner walks it with `os.ReadDir`, and every one of
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
paths to the player. Scoped storage does not offer a stable directory
to walk.
The honest options are three, and they are not close in cost:
- **MediaStore as the library source.** Query the content resolver,
keep MediaStore IDs (or content URIs) beside or instead of paths, and
open audio through a `ContentResolver` file descriptor. This is the
Android-native answer and it touches the schema, the scanner, the
player's file opening and every path-keyed query.
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
effectively barred from Google Play except for genuine file managers.
Viable *only* because we distribute through Obtainium — which is a
real point in its favour here, and worth stating plainly rather than
dismissing.
- **App-private storage only**, i.e. the user copies music into the
app's sandbox. Trivial to build, and nobody wants it.
### A2. The first-run flow cannot complete.
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
`frontendutil.DirectoryPicker``app.Dialog.OpenFile().
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
paths"**. So the one action the wizard exists to perform fails, and
`<first-run-wizard>` intercepts all pointer events until a library
exists — so the app is not merely empty, it is inert.
Whatever A1 resolves to decides this: a MediaStore library needs no
picker at all, and a SAF tree needs the picker to return a URI the
backend can use.
### A3. MPRIS is compiled into the Android build.
`mpris_linux.go` is `//go:build linux`, and **`android` implies
`linux`** (documented, and the reason it is in the APK). It will look
for a session bus that does not exist. It needs `//go:build linux &&
!android`, and its Android counterpart is A4.
This one is cheap and should be done regardless — it is a two-character
build-tag change plus whatever `mediacontrols.New` returns instead.
### A4. Playback will be killed the moment the screen locks.
The scaffold's `WailsForegroundService` is typed **`dataSync`**
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
`MediaSession` for lock-screen and notification transport controls,
plus **audio focus** — pause on a phone call, duck for a notification,
pause on headphone unplug. None of that exists today. `oto` will happily
keep writing to a stream nobody can hear.
This is the difference between "an app that plays audio" and "a music
player", and it is Java-side work in the scaffold plus a Go-side bridge.
## B. It works, but wrongly
### B1. The x86_64 half of the APK cannot run on any Android.
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
takes `SIGSYS` the first time it touches the database. arm64 is
structurally unaffected (no `lstat` syscall exists; it routes through
`fstatat`).
So ~31 MB of the artifact is dead weight on *every* Android device,
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
(smaller APK, no emulator target — which does not work anyway), or
carry it against a future modernc fix. **Dropping it is the honest
default**; it is also the only item in this plan that is a five-minute
change.
### B2. The UI is a desktop shell.
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
header subtitle wraps the title out of its bar. A phone is ~360430 CSS
px wide. The sidebar collapses to icons below 900px, which is a
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
on hover (the marquee's `hover` mode, tooltips), right-click context
menus, a keyboard shortcut layer with its own overlay and settings page,
multi-select with ctrl/shift, and a resizable-column track list. None of
those are gestures.
This is not a stylesheet pass. It is a second front end for the views
worth having on a phone, sharing the stores and bindings — which the
architecture supports, since a view is already a lazily-loaded chunk
behind `VIEW_LOADERS`.
### B3. Tag writing cannot reach the user's files.
`tagwriter` rewrites tags in place, and autotag's whole purpose is
applying them to a folder. Under scoped storage that is impossible
outside the sandbox without a SAF write grant per tree. If A1 lands on
MediaStore, in-place tag writing needs `MediaStore` write requests and
user confirmation per file on Android 11+.
Autotagging is arguably a desktop-only feature and saying so is a
legitimate answer.
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
It works — but with no awareness of a metered connection and no
accounting for a device where that is a meaningful fraction of free
space. At minimum it needs to be opt-in on mobile and to refuse a
metered network by default. `Android.NetworkJSON()` reports
`{connected,type}`, so the signal is available.
## C. Inert, and fine
Window geometry, menus and the system tray are documented no-ops on
mobile. The keyboard shortcut layer is harmless but its Settings page
is dead weight. `profiling` is already compiled out of production
builds. These cost nothing and need no work.
## D. Unknown until it runs on a device
**Nothing in section A or B has been observed on Android**, because the
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
images on an x86_64 host. Everything above is read from the source, the
generated manifest and Wails' own documentation. The first real device
run will find things this list does not have, and the most likely
places are audio latency and buffering under `oto`/oboe, and SQLite
behaviour on app-private storage.
## The fork in the road
The four blockers in section A are all the same question wearing
different clothes: **is the Android app a librarian, or a player?**
YellowJacket on the desktop is a *librarian*. It scans folders,
deduplicates covers, detects duplicate tracks, reconciles against
MusicBrainz, rewrites tags on disk, and manages downloads. That model
rests on owning a filesystem, which is precisely what Android declines
to give.
Three coherent products, and only the first is "parity":
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
(Obtainium-only distribution, which we already have), a phone UI for
every view, and media-session playback. Largest scope by far; the
result is an app almost nobody has asked for on a phone.
2. **A player for music already on the phone.** MediaStore as the
source, no scanner, no autotag, no downloads; the library, queue,
playlists, favourites and Explore-as-browsing all still make sense.
This is a genuinely good Android app and it is *not* parity — it is
a subset with a different data source.
3. **A companion to the desktop app.** The phone browses and controls
the desktop's library over the network, or syncs a subset. Smallest
Android surface, and it leans on the thing that already works.
**Option 2 is the recommendation** if the goal is an app people use;
option 3 if the goal is the least work for the most value. Option 1 is
the only one that answers "feature parity" literally, and it is the one
worth arguing hardest against.
> **Decided:** option 1's *data model* (the librarian keeps its
> filesystem and its scanner — A1 shipped that) with option 2's
> *surface*. The phone is a player over the library this app already
> builds; it does not get every view. The list is below.
## The phone gets a subset (decided)
B2 is not a stylesheet pass and not a second front end either. A view
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
so the phone build is **a different loader table and a different
chrome**, over the same stores.
**In**, because each is something a person does with a phone in their
hand:
- **Home** — the shelves are already a phone-shaped surface.
- **Library browse** — albums, artists, genres. The grids are already
virtualized and card-shaped.
- **Now playing** — which on a phone is a *view*, not a 4em bar.
- **The queue.**
- **Search** — the header box, scoped as it already is.
- **Playlists**, including smart ones, as lists to play rather than to
edit.
**Out**, and each for a reason rather than by omission:
- **Autotag** — the review UI is a wide table and the action rewrites
files on disk; B3 has not been verified even as *possible* yet.
- **Downloads** — two tab panels of client configuration.
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
the last thing to earn a phone's storage.
- **Settings** — not the page. The phone needs a handful of settings
(theme, the library folder, playback) and not the 93 controls the
desktop page carries.
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
has no keyboard and no resizable columns, and the jobs indicator is
enough.
What the shell has to lose, from the audit at the top of this section:
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
bar over the five things above), hover as a route to anything,
right-click as the only route to a context menu (long-press is the
gesture), and ctrl/shift multi-select.
One rule for the work: **no view forks.** A phone layout that copies a
view's template is two templates to fix every bug in. Where a view
cannot serve both, the split belongs at the chunk boundary that already
exists.
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
inside the drawer means reusing its `data-testid`s too, and a second
copy standing by in the DOM broke 30 specs that had nothing to do with
the phone. The rule holds — a second list of destinations would be
worse — but a shared component must be rendered only when it is wanted,
and the guard belongs in a test that names the reason.
## What is worth doing regardless of that decision
Cheap, independently useful, and each unblocks measurement:
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
why. Five minutes.
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
Android build stops carrying a D-Bus client. Small.
3. **A device smoke run**, which needs someone's phone and the published
APK. Everything in D depends on it, and it is the single highest
information-per-minute action available.
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
— the picker's error already routes through `describeError`, but the
wizard still blocks pointer events, so an Android user sees a dead
screen rather than a sentence. Even under option 3 this is the right
behaviour.
## What is left (updated after A4)
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
beside the MPRIS one, and the Java half is
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
transport notification and audio focus. It needed no new JNI and no new
Gradle dependency — `application.Android.StartForegroundService(json)`
going out, `WailsBridge.emitEvent` → the application event bus coming
back, and the platform `android.media.session` API rather than
androidx.media, which minSdk 21 makes available anyway.
Four decisions in it are worth keeping:
- **Ducking is a player concept, not a volume change.**
`Player.SetDuck` re-applies the *user's* level with an attenuation
offset, so `getUserVolume` still reports what the user chose and
nothing is persisted or emitted. A duck that wrote through to the
volume would let one notification tone permanently turn the music
down.
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
the app itself and sends no `CAN_DUCK` focus change, so asking to be
told instead (`setWillPauseWhenDucked`) would mean pausing for every
notification tone, and doing both would attenuate twice.
- **An unchanged payload is not an event here either.** Every push
crosses JNI and re-delivers an Intent, and the player pushes state on
several paths that can agree.
- **After the first start, updates use `startService`.** From Android
12 an app in the background may not *start* a foreground service, but
it may keep delivering intents to one it already has — which is every
track change with the screen off.
The contract with Java — the payload keys, the state words, the command
names — is in `androidpayload.go`, deliberately *without* the `android`
build tag, so `go test` exercises it on every platform. Everything left
in `android.go` is untested by construction: it compiles only under a
cross-compiler and runs only on a phone.
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
places had to agree — `abiFilters`, the Makefile's `android:package`
(or Go still compiles a library Gradle then discards) and the
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below); **all four phases are
done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
exactly — measured, from 652px in a 360px viewport before.
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
and volume went. A detail view, so Back pops the nav stack; it
composes the real transport components rather than copying them; and
it hides the bottom bar while it is up, so it carries its own queue
button.
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
listener, installed once from `index.ts`, which turns a 500 ms
stationary touch into a synthetic `contextmenu` at the touch point.
Every menu in the app opens from that event, so all six components
gained the gesture without one of them changing — which is the same
argument `ContextMenuController` rests on, one layer lower. The
details that are not obvious are in `NOTES.md` (2026-08-17); the one
worth repeating is that ours is told from the browser's own
long-press event by **identity**, not `isTrusted`, because a test
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
artist) plus the duration, and drops the column headers and the resize
handles — a column set rather than a second row template, so the row
and everything delegated on it is unchanged. Verified at the device's
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
overflow. The device also found the bug in it, which no browser
viewport would have: saved *desktop* column widths reached the phone
through an id-keyed store and gave the duration column 55% of the row.
**B2 and B4 are complete.** B4 is `backend/explore/netpolicy.go`: the
catalog download is skipped on a cellular connection unless
`AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search
Index section. The policy and the JSON parsing are in `explore` (tested
on every platform) and only the platform call is injected from `app.go`,
because `cmd/indexbuild` imports `explore` and must not link Wails. Two
things the plan got slightly wrong: the portable API is
`application.Mobile.NetworkJSON()` rather than `Android`'s, and it
reports no metered flag — so cellular is the signal and a metered Wi-Fi
cannot be seen.
What is left in this plan is B3 (tag writing, which needs a device) and
the standing question of the Light Phone's Chrome 113 — which so far has
cost nothing: menus, dialogs and long-press all work on it.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
### What the first device run answered (2026-08-17)
A4 **works**: playback survives the screen locking, and the transport
notification appears with cover art — which also settles the service's
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
the lock-screen session in one observation. Everything below in "what
none of section A answered" was written before this and is now answered
except the OEM permission-flow variance.
It also found two faults no browser tier can see, both fixed and both
awaiting the next APK for confirmation (`NOTES.md`, same date):
- **Back quit the app from any depth.** The scaffold asks
`webView.canGoBack()`; the frontend had never used `history`. A
navigation is a history entry now, and `navStack` is gone rather than
kept beside it.
- **The transport was under the gesture bar** — or so the version
number said. `applyWindowInsets()` in `MainActivity` is right and
stays, but the phone is **Android 14**, where the system still insets
the window: the fix is pre-emptive and the symptom has another cause.
Still open, along with icons that do not appear at all. The phone's
WebView is **Chrome 113**, which is the lead (no Popover API, no
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
how it gets asked.
The standing item is unchanged in kind: **B3 (tag writing) and the
permission flow still need a device**, and so does confirming these two.
### What none of section A answered
Nothing here has been observed on a device. The permission flow in
particular is the kind of thing that behaves differently across OEM
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
implemented inconsistently, which is why there is a fallback to the
global list, and neither path has been exercised.
A4 adds its own list of things only a device can answer, and they are
the likely first failures: whether the notification appears at all
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
user who declines gets a service with an invisible notification),
whether audio focus arrives while `oto`/oboe holds the output, whether
the lock screen picks up the session, and whether cover art decoded
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
+52 -16
View File
@@ -1,6 +1,20 @@
# semantic-release configuration # semantic-release configuration.
# Runs on main branch pushes to auto-determine version from conventional commits. #
# Creates a git tag + GitHub Release draft; a separate workflow builds binaries. # Runs on pushes to main from .gitea/workflows/release.yml: determine the
# version from the Conventional Commits since the last tag, write the
# changelog, commit it, push the tag, and create the Gitea release.
#
# **There is no `@semantic-release/github` plugin here and there must not
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
# community plugin (@saithodev/semantic-release-gitea) was considered and
# rejected: last published 2022, depends on got@10, and declares no peer
# dependency on semantic-release at all — i.e. untested against anything
# since v19, against a core now at v25. `exec` is first-party, current,
# and the Gitea-shaped part is one curl.
#
# The type list below is the one scripts/commit-check.sh enforces the
# grammar for — keep the two in step, or semantic-release will silently
# decline to release something the commit hook accepted.
branches: branches:
- main - main
@@ -63,19 +77,41 @@ plugins:
section: Build section: Build
hidden: true hidden: true
# Write CHANGELOG.md. # Render the notes to a file.
#
# **This plugin is here to carry the notes, not to maintain a document.**
# It is how they reach the Gitea API *without being interpolated into a
# shell command*: release notes are rendered commit messages — arbitrary
# text carrying backticks, quotes and `$` — so templating
# ${nextRelease.notes} into `publishCmd` would be a shell injection with
# the commit log as its input. scripts/gitea-release.sh reads the top
# section of this file instead, and the only thing interpolated below is
# a semver string.
#
# The target is a gitignored build artifact rather than CHANGELOG.md,
# because nothing commits it back — see below.
- - "@semantic-release/changelog" - - "@semantic-release/changelog"
- changelogFile: CHANGELOG.md - changelogFile: .release-notes.md
changelogTitle: "# Release notes"
# Commit the changelog back to the repo. # Create the Gitea release, whose body is that section.
- - "@semantic-release/git" # `publish` runs after `prepare`, so the tag already exists by here.
- assets: - - "@semantic-release/exec"
- CHANGELOG.md - publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}"
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
# **There is deliberately no @semantic-release/git here.**
#
# `main` is a protected branch with `enable_push: false` and an empty
# push whitelist, so a changelog commit-back would be rejected by the
# pre-receive hook — *after* the tag had already been pushed, leaving a
# tagged release the run then reported as failed. The alternative was to
# whitelist the CI user, which weakens a protection someone set on
# purpose and lets a bot push to main without passing the checks every
# human PR has to.
#
# So the release page is the changelog. Tags are not protected, so the
# tag push semantic-release does itself is unaffected. CHANGELOG.md in
# the repo is a signpost to the releases page and is not written by any
# of this; a file that claimed to be a changelog and silently stopped
# updating would be worse than no file at all.
# Create the GitHub Release (draft, so the build workflow can attach binaries).
- - "@semantic-release/github"
- draft: true
successComment: false
failComment: false
releasedLabels: false
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+16 -372
View File
@@ -1,377 +1,21 @@
## [1.3.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.3...v1.3.0) (2026-03-20) # Changelog
### Features The changelog is the releases page:
* **09-01:** add scan control events and cancelled metrics field ([c695024](https://github.com/onion-4-dinner/yellowjacket/commit/c695024241a7513b8fedb3fbf7ff364d0515b392)) <https://git.ljones.me/yonlu/yellowjacket/releases>
* **09-01:** add scan control fields and per-scan cancellable context ([cf22e52](https://github.com/onion-4-dinner/yellowjacket/commit/cf22e52a64850a80b9fcc63c21d81313e6bd56ab))
* **09-02:** add frontend keyboard shortcut service, store, and controller ([40d4815](https://github.com/onion-4-dinner/yellowjacket/commit/40d48151dd798b57eed9f54a572ae4735356d09e))
* **09-02:** add shortcuts config package with default bindings and Wails persistence ([6285ca9](https://github.com/onion-4-dinner/yellowjacket/commit/6285ca9dc4e6f211197e377d01c485b1ef65c300))
* **09-03:** add scan control UI with pause/resume/cancel and confirmation dialog ([3914369](https://github.com/onion-4-dinner/yellowjacket/commit/391436927c826f2f17a4523be7829aefc04a6b12))
* **09-04:** add keyboard shortcuts section to config page with conflict detection ([0451fb3](https://github.com/onion-4-dinner/yellowjacket/commit/0451fb38805ff2c27e43deb152daa892e733d2db))
* **10-01:** implement migration 6 and pre-migration backup ([1179f56](https://github.com/onion-4-dinner/yellowjacket/commit/1179f56c3680112692e71e8dc7ce946446fa8a8a))
* **10-01:** update SQL schema files for multi-library fresh installs ([535855b](https://github.com/onion-4-dinner/yellowjacket/commit/535855b383a457dd2be3298b4361313bef22b39d))
* **10-02:** add migration 6 integration tests and NewTestDBWithLibrary helper ([bc15189](https://github.com/onion-4-dinner/yellowjacket/commit/bc151891b50e59e41da2e00dbfafbecaad11b4ac))
* **10-02:** add sqlc queries for libraries and update playlist queries for phantom support ([02548dd](https://github.com/onion-4-dinner/yellowjacket/commit/02548dd55e59b28f3d6c8d9614f209140c979250))
* **11-01:** per-library scan pipeline with queue coordinator ([943db1c](https://github.com/onion-4-dinner/yellowjacket/commit/943db1cf274bdf59daf28ab6c20f78ef5ef53105))
* **11-02:** update config-page with per-library progress display and queue-aware cancel dialog ([d01591d](https://github.com/onion-4-dinner/yellowjacket/commit/d01591d6cc054a63b832c05a3164a72fdcaba342))
* **11-02:** update library-manager with per-library progress and Scan All button ([d61f122](https://github.com/onion-4-dinner/yellowjacket/commit/d61f122b567e8ac2b30fa96c637cbebc14493c89))
* **12-01:** add queue compaction method and wire removal hooks ([5995dfd](https://github.com/onion-4-dinner/yellowjacket/commit/5995dfd01d61cd4d2c0749eeeee2a1f93b739d68))
* **12-01:** implement library CRUD methods and orphan cleanup pipeline ([bd44f83](https://github.com/onion-4-dinner/yellowjacket/commit/bd44f8306c9129b9420ad81938bcf8105a1cb55a))
* **12-02:** make config sections collapsible with chevron dropdown ([12c6782](https://github.com/onion-4-dinner/yellowjacket/commit/12c678284c7582bd85cd52722f4d405b0bd0e20f))
* **12-02:** remove Libraries sidebar nav item and view routing ([e199712](https://github.com/onion-4-dinner/yellowjacket/commit/e199712a56e1cb3c0fc43d3340abb892a6f5fa7b))
* **12-02:** replace config-page library section with full library management UI ([ffc5d96](https://github.com/onion-4-dinner/yellowjacket/commit/ffc5d9639cf7c916a4f846590ae0d67cf13afe27))
* **12-02:** selectable library list with checkbox scan targeting ([13a42ae](https://github.com/onion-4-dinner/yellowjacket/commit/13a42aea2287d7ed0ec9ff9856f52c1fa7767338))
* **12-02:** show scan progress bar inline in library list entry ([df824c6](https://github.com/onion-4-dinner/yellowjacket/commit/df824c6989e92b2aefaa1ddf05b131ee319612d8))
* **13-01:** add library-filtered Go query methods and FTS search ([5f7de50](https://github.com/onion-4-dinner/yellowjacket/commit/5f7de5060a5bc557b96203267de694ef366ed507))
* **13-01:** add library-filtered sqlc queries for all browse views ([5cc58ce](https://github.com/onion-4-dinner/yellowjacket/commit/5cc58ce66ab70d8d5a570df5067f79ae2201037e))
* **13-02:** add library filter dropdown and wire all views to respect active filter ([42b8cf9](https://github.com/onion-4-dinner/yellowjacket/commit/42b8cf9f52133499ffcd7363bd39dd0c1069e091))
* **15-01:** migrate FTS5 search_index to contentless_delete=1 ([cb5155b](https://github.com/onion-4-dinner/yellowjacket/commit/cb5155b8906357ff77c5c579d57d02cf2eec6abe))
* **15-02:** create backend/fileutil package with AtomicWrite ([4d64b5d](https://github.com/onion-4-dinner/yellowjacket/commit/4d64b5dcfe43951e8ec63383bbf72c99107c63c4))
* **16-01:** add selectAll() to SelectionController and dispatch shortcut:select-all event ([f567762](https://github.com/onion-4-dinner/yellowjacket/commit/f5677628ef283b67370630b564f23178e43da3d2))
* **16-01:** wire shortcut:select-all listener in track-list, queue-panel, and playlist-view ([906ea28](https://github.com/onion-4-dinner/yellowjacket/commit/906ea28751ce9f96fdeeb9410ab5f6518f09fcb9))
* **16-02:** add go-flac dependencies and implement FLAC tag writer ([3642cbe](https://github.com/onion-4-dinner/yellowjacket/commit/3642cbe0d58f8912a786a4fc5380c40403add94a))
* **16-03:** implement DB sync module for tag write pipeline ([2966079](https://github.com/onion-4-dinner/yellowjacket/commit/2966079625cd42412411429af02184d015526e9b))
* **16-03:** WriteTrackTags pipeline with player safety, scan mutex, events, and app wiring ([64322f9](https://github.com/onion-4-dinner/yellowjacket/commit/64322f93538515d5a3e486dc14691b9c9dcf6f66))
* **17-01:** add TrackMetadataChanged handler and remove selection gate on Track Details ([fc5cf70](https://github.com/onion-4-dinner/yellowjacket/commit/fc5cf70e4c1be3d3f1545c140db5202601a08109))
* **17-01:** add WriteTrackTagsByPath and ImageFilePicker backend methods ([4235b4a](https://github.com/onion-4-dinner/yellowjacket/commit/4235b4a4d555882ce86628a88dd4e4eeee2c9097))
* **17-02:** implement save flow, cover art editing, and error handling ([265a9ea](https://github.com/onion-4-dinner/yellowjacket/commit/265a9ea8ceba893f956a03546e9ac4189adc7716))
* **18-01:** add BatchWriteProgress event constant ([3dba0e1](https://github.com/onion-4-dinner/yellowjacket/commit/3dba0e143c091327d305d39d2fa7a687ec47e172))
* **18-01:** add BatchWriteTrackTags with progress, cancellation, and partial failure ([f557ffd](https://github.com/onion-4-dinner/yellowjacket/commit/f557ffd652179b7cf8f8ff4a06824f30edf08007))
* **18-02:** add batch edit mode to track-details component ([6dab32b](https://github.com/onion-4-dinner/yellowjacket/commit/6dab32b36b497d54e8645e969aa79737ad3523ab))
* **18-02:** wire batch track-details to all 4 view context menus ([656985a](https://github.com/onion-4-dinner/yellowjacket/commit/656985add92663440baebb871f8cd6d5723117fd))
* **19-01:** implement WAV RIFF parser/writer and writeWavTags ([e6610ff](https://github.com/onion-4-dinner/yellowjacket/commit/e6610ff15e041213b6898ad48ff63b7060b312e7))
* **20-01:** implement OGG Vorbis tag writer with custom page parser and CRC32 ([5e98c03](https://github.com/onion-4-dinner/yellowjacket/commit/5e98c036342b9e174abdc6d00db21c2e2901f18b))
* **quick-17:** create playlist-details subpage component ([dc5c7d6](https://github.com/onion-4-dinner/yellowjacket/commit/dc5c7d6ca6cfbfac15546c048f1b33aaf47209c6))
* **quick-18:** replace track-info with multi-column grid layout in playlist-details ([ce23177](https://github.com/onion-4-dinner/yellowjacket/commit/ce2317722870f932792dc6456a63235ff4611466))
### Bug Fixes Every release there is generated from the Conventional Commits it
contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one
carries its notes as its body, grouped by change type, with a link to the
commit behind every line.
* **09-05:** emit VolumeChanged event and persist state in ChangeVolume and MuteToggle ([bb3fd20](https://github.com/onion-4-dinner/yellowjacket/commit/bb3fd204f0895f357a14479b40754f397aae74c4)) **This file is not generated and is not a copy of that.** `main` is a
* **10-01:** move library_id index to migration 6 to fix existing DB startup ([75b2a34](https://github.com/onion-4-dinner/yellowjacket/commit/75b2a349ebd6fada5cbc92bfae9854cc2cd53c63)) protected branch, so nothing pushes a changelog commit back to it — and a
* **12-02:** claim orphaned tracks when adding library with matching path ([f60b6b5](https://github.com/onion-4-dinner/yellowjacket/commit/f60b6b525546ef77a3329fe92f03f336b7435a0e)) file that claimed to be a changelog while silently never updating would
* **12-02:** count failed saves as skipped so scan progress bar advances ([b36e472](https://github.com/onion-4-dinner/yellowjacket/commit/b36e472212957ff089f4f5d35f3978a754e23502)) be worse than no file at all. `make release-dry` prints what the next
* **12-02:** delete artist_credit_artist before artist_credit in removal pipeline ([890284d](https://github.com/onion-4-dinner/yellowjacket/commit/890284ddb1d0fb95e423bddf27b40fb0db2d11e5)) merge would release.
* **12-02:** dismiss inline rename on click outside ([9272b06](https://github.com/onion-4-dinner/yellowjacket/commit/9272b060bf98118e37f19a8c0834034691bfe6a2))
* **12-02:** downgrade per-file save error to Debug, add warning count to scan summary ([cf18c39](https://github.com/onion-4-dinner/yellowjacket/commit/cf18c39dbd849d60218228cf1d2285ab2071e788))
* **12-02:** invalidate library store cache on LibraryRemoved event ([b093fbb](https://github.com/onion-4-dinner/yellowjacket/commit/b093fbb10a24054c4ef62b0bd13f28d9bfe6f121))
* **12-02:** keep Add Library button visible during scan ([649e516](https://github.com/onion-4-dinner/yellowjacket/commit/649e516aa30090665e9f10e89c1ccce378e36b96))
* **12-02:** move Add Library button inline with scan buttons ([771345d](https://github.com/onion-4-dinner/yellowjacket/commit/771345dd9d3870b3a907e1cce09c7456ab7ccd85))
* **12-02:** move scan buttons above library list, default to none selected ([ba3f840](https://github.com/onion-4-dinner/yellowjacket/commit/ba3f840a28fe2c6ca40c558305814d29c233d6e0))
* **12-02:** refresh library track counts after scan completes ([1f872aa](https://github.com/onion-4-dinner/yellowjacket/commit/1f872aa005a9405d9bc1f64a4b1dd2f1f1d4a16c))
* **12-02:** reorder orphan cleanup to delete FK children before recordings ([1d735c3](https://github.com/onion-4-dinner/yellowjacket/commit/1d735c3a5f5a78996d6ddbe5c787adf040fe2f21))
* **12-02:** replace removed Scan() import with ScanAllLibraries() ([0559822](https://github.com/onion-4-dinner/yellowjacket/commit/05598224e4d5532d2e2a3a7e5d3b5411240b1024))
* **12-02:** resolve phantom tracks caused by empty library root after TOML cleanup ([717e249](https://github.com/onion-4-dinner/yellowjacket/commit/717e249c368fd1cc8d5c8f945c352175708691cf))
* **12-02:** serialize ScanWarning.Err as string instead of error interface ([ac8cbb3](https://github.com/onion-4-dinner/yellowjacket/commit/ac8cbb3296bd561a305627668c211dce7209df25))
* **12-02:** soft scan claims orphaned library_id=0 tracks on startup ([1ad099a](https://github.com/onion-4-dinner/yellowjacket/commit/1ad099a9d35fc722475e238d3443fd5473566acd))
* **12-02:** soft scan on launch — only scan libraries with changed file counts ([92c4d23](https://github.com/onion-4-dinner/yellowjacket/commit/92c4d23a9a1e545fab497816ee3dce43a181cded))
* **12-02:** wait for scan to stop before library removal, surface errors in UI ([cf00498](https://github.com/onion-4-dinner/yellowjacket/commit/cf004986c95732d00208e83467267904ea3f2ef6))
* **13-02:** auto-resolve phantom playlist tracks after library scan ([93262b9](https://github.com/onion-4-dinner/yellowjacket/commit/93262b9ae0f737d2893839ac585776207b3b44b6))
* **13-02:** defer virtualizer event delegation until element exists ([f05d2bb](https://github.com/onion-4-dinner/yellowjacket/commit/f05d2bb603f5ea827164466fd0795a6c6e662529))
* **13-02:** resolve phantom playlist tracks using M3U8 paths after scan ([9f595b7](https://github.com/onion-4-dinner/yellowjacket/commit/9f595b7ac10c2191b5469004901cbbc1331c1abb))
* **14-01:** downgrade main-panel from contain:strict to layout+style+paint ([4b7d35d](https://github.com/onion-4-dinner/yellowjacket/commit/4b7d35d7ec4c8b14453a8f8250cd154b8c4c2537))
* **14-perf:** fix scroll jumping and input latency ([3b2e189](https://github.com/onion-4-dinner/yellowjacket/commit/3b2e189e7d0e6d00393d087565190fd307774257))
* **17-02:** fix cover art replace and remove ([d7c2965](https://github.com/onion-4-dinner/yellowjacket/commit/d7c2965752ae0ac9009d00f2431d5919a24558b7))
* **17-02:** handle float64 numeric values from Wails JSON deserialization ([900db2e](https://github.com/onion-4-dinner/yellowjacket/commit/900db2e56cca254873a3a5a7a384008feac4211b))
* **17-02:** refresh cover art URLs after save ([8cd4914](https://github.com/onion-4-dinner/yellowjacket/commit/8cd4914842f61c0c6b49e0216c7816e201a3c94a))
* **17-02:** refresh track-details dialog data after successful save ([ffcdc41](https://github.com/onion-4-dinner/yellowjacket/commit/ffcdc41b0d4fad8ed428dbaa55f6cdd38c096822))
* **18-02:** add field labels above title/artist/album inputs in batch edit mode ([9df2d67](https://github.com/onion-4-dinner/yellowjacket/commit/9df2d6764a0b0566dda33cff675debea4a61dea8))
* **18-02:** add field labels to all track-details states (single/batch, read/edit) ([d430ad8](https://github.com/onion-4-dinner/yellowjacket/commit/d430ad884bfd38bea93389d8be730ff00388a7be))
* **19-01:** add album_artist TPE2 mapping to applyTextChanges ([8f4c4a0](https://github.com/onion-4-dinner/yellowjacket/commit/8f4c4a0c2b14eeeaeccb972a40addb11f3d65437))
* preserve scroll position in cached grid views ([54df917](https://github.com/onion-4-dinner/yellowjacket/commit/54df917ffdd69c4f7ffaeccf2d161261ca80d84e))
* **queue-panel:** set flow layout _itemSize to match actual track item height ([288d9de](https://github.com/onion-4-dinner/yellowjacket/commit/288d9deae22d437fcd7857b368827db7b62c24f6))
* **queue-panel:** suppress virtualizer scroll corrections during scrollbar drag ([0bd8cef](https://github.com/onion-4-dinner/yellowjacket/commit/0bd8cefa00dcae2f8bd9579de2aefd58e0a9e6c9))
* **quick-19:** multi-root path resolution for playlist M3U8 tracks ([9144ded](https://github.com/onion-4-dinner/yellowjacket/commit/9144dedc2742925dc252d491763b4f2929238d0e))
* **S21/T01:** fix all lint warnings and upgrade wsl to wsl_v5 ([f16157a](https://github.com/onion-4-dinner/yellowjacket/commit/f16157a2134cbeb1787ff851d4875d77f2f3f86b))
### Performance History before `v0.0.1` is in `git log`. The versions before it were cut
by hand and are not on the releases page; the entries this file used to
* **12-02:** increase scan batch size from 50 to 300 ([21ea71e](https://github.com/onion-4-dinner/yellowjacket/commit/21ea71e2575d76258bd81d89ab8ac883aa3bed36)) hold were generated against a GitHub remote this project no longer has,
* **12-02:** skip FTS5 rebuild during library removal ([30f4461](https://github.com/onion-4-dinner/yellowjacket/commit/30f4461e6957e20d3dc607fa0886a75b5c21b3cf)) and every link in them was dead.
* **14-01:** add CSS containment to app shell layout boundaries ([efa06f7](https://github.com/onion-4-dinner/yellowjacket/commit/efa06f7edf1e4acdc3d8865cad264403257ae40d))
* **14-01:** add GPU promotion and containment to all scroll containers ([ac8a52e](https://github.com/onion-4-dinner/yellowjacket/commit/ac8a52e110f9f8ebdc3433b60594370352126a18))
* **14-02:** replace innerHTML navigation with view caching system ([ad91043](https://github.com/onion-4-dinner/yellowjacket/commit/ad9104374a628342e0ea30cf409ff43de2c2f86e))
* **14-03:** add notification batching to queue store and granular change tracking to library store ([d0c05dc](https://github.com/onion-4-dinner/yellowjacket/commit/d0c05dc1d43a4fe12cc07f3cff25375b08a74ba0))
* **14-03:** eliminate per-item closure allocation in scroll render paths ([2f7ed70](https://github.com/onion-4-dinner/yellowjacket/commit/2f7ed7030425ed0ebb7a1a186917a79a7b26b850))
* **14-04:** RAF-throttle scroll position saves and add overflow-anchor to queue panel ([6ca0b3c](https://github.com/onion-4-dinner/yellowjacket/commit/6ca0b3c5a84769af064ebe45a6eaac014d1a270a))
* auto-detect NVIDIA+Wayland for DMABuf workaround ([915591a](https://github.com/onion-4-dinner/yellowjacket/commit/915591aea962beb60da2e96ac0f57307f646f675))
* inline SVGs, memoize grid slices, batch store notifications ([a4eac39](https://github.com/onion-4-dinner/yellowjacket/commit/a4eac394cebefd29d0ebcb4b1e331444dcb8fbaf))
* reduce software rendering overhead for NVIDIA+Wayland ([199c910](https://github.com/onion-4-dinner/yellowjacket/commit/199c91013fd806f6aefce49357df8a32b46faaa0))
### Refactoring
* **quick-17:** simplify playlist-view to navigate instead of expand ([955cd68](https://github.com/onion-4-dinner/yellowjacket/commit/955cd68be2dbf7a9071ef1c93084d687b59b6bd7))
## [1.2.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.1...v1.2.2) (2026-03-06)
### Bug Fixes
* recover from go-mp3 seek panic on startup ([#86](https://github.com/onion-4-dinner/yellowjacket/issues/86)) ([2f9d9f8](https://github.com/onion-4-dinner/yellowjacket/commit/2f9d9f8508b90b6188fe894c282c5b8e330e8046))
## [1.2.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.0...v1.2.1) (2026-03-06)
### Bug Fixes
* **deps:** pin go-webview2 to v1.0.21 for Wails v2 compat ([25f0fe8](https://github.com/onion-4-dinner/yellowjacket/commit/25f0fe81560eeff36a0b2beb52ce1bdf13d5e122))
## [1.2.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.3...v1.2.0) (2026-03-06)
### Features
* **02-02:** add ScanWarning type and reclassify scan errors as warnings ([e6866de](https://github.com/onion-4-dinner/yellowjacket/commit/e6866ded9dc0ea30ff942cd31b6c5ea3269e9584))
* **03-01:** create NewTestDB helper for in-memory SQLite test databases ([bae9d70](https://github.com/onion-4-dinner/yellowjacket/commit/bae9d70d23157ef4e79e60dd713d9a02ab63790b))
* **03-01:** extract shared applyPRAGMAs and add production PRAGMAs to NewDB ([d348815](https://github.com/onion-4-dinner/yellowjacket/commit/d34881530adda7fb75be84737798da46d17bfa8c))
* **06-01:** create track_metadata VIEW schema and migration 4 ([9c7e5a9](https://github.com/onion-4-dinner/yellowjacket/commit/9c7e5a96344a81bf132de487b4763f1dc3ff6df9))
* **06-02:** create Go→TypeScript event constant codegen tool ([3e9edd0](https://github.com/onion-4-dinner/yellowjacket/commit/3e9edd05e87395499ac24e456640d1f6d9b97f04))
* **06-03:** migrate lookupChunk to sqlc-generated LookupTrackMetaByPaths query ([2221a68](https://github.com/onion-4-dinner/yellowjacket/commit/2221a68459850a837c996c6e6d2bc95d41b20fb3))
* **08-01:** define design token CSS custom properties for icon sizes and type scale ([1444a66](https://github.com/onion-4-dinner/yellowjacket/commit/1444a66bb201ce5fdf16552a32bcd281089c64ed))
* **08-04:** apply design tokens to cover-grid, track-list, queue-panel, and detail components ([1303422](https://github.com/onion-4-dinner/yellowjacket/commit/1303422e69c27d528363900b3ca5287a48cc9f8e))
* **08-04:** convert sidebar em-based spacing to px and apply icon/type tokens ([aed90d7](https://github.com/onion-4-dinner/yellowjacket/commit/aed90d7b1710d0c5cece2e4956c0a6ce77b9a999))
* add scan progress bar with phase indicator ([a28b4d1](https://github.com/onion-4-dinner/yellowjacket/commit/a28b4d1e0673658824750d4c702359321dc9a78e))
* **quick-001:** add multi-file picker and batch import support ([c34e4ad](https://github.com/onion-4-dinner/yellowjacket/commit/c34e4ad029c119bff8f70a07ccc6bca58b11ea3c))
* **quick-001:** regenerate bindings and update frontend for multi-import ([2a542bf](https://github.com/onion-4-dinner/yellowjacket/commit/2a542bf3bcdc7772edb1aceb41f488774494f656))
* **quick-002:** add CountPlaylistsByName SQL query and regenerate sqlc ([04b2088](https://github.com/onion-4-dinner/yellowjacket/commit/04b2088b28b84a4d4df25b23d97112c5a955dff1))
* **quick-002:** add uniquePlaylistName helper and wire into ImportPlaylist ([8ba8bbe](https://github.com/onion-4-dinner/yellowjacket/commit/8ba8bbe7bed2ecff97613ebaa42a49a662050353))
* **quick-006:** remove list icon from playlists, add favorites icon to default ([3c19766](https://github.com/onion-4-dinner/yellowjacket/commit/3c19766fd0885d4171cf9929db6d69a3d5c1a3ff))
* **quick-11:** add configurable log level via YJ_LOG_LEVEL env var ([55b4902](https://github.com/onion-4-dinner/yellowjacket/commit/55b4902fac7b7f2c04ad5efac398ecedc5fedc2f))
* **quick-11:** add make dev-debug target for verbose logging ([c45bca4](https://github.com/onion-4-dinner/yellowjacket/commit/c45bca411ba1d4f32deea6027acf91237173dd15))
* **quick-12:** add favorite icon to album dropdown track rows ([12a0bbc](https://github.com/onion-4-dinner/yellowjacket/commit/12a0bbc89c19128485d597a61bd16bd0786450ad))
* **quick-15:** add BufferedStreamer with goroutine read-ahead ([85b23ac](https://github.com/onion-4-dinner/yellowjacket/commit/85b23acb24a048d2f7b85808e477bb991ae124e6))
* **quick-15:** insert BufferedStreamer into player pipeline and increase speaker buffer ([8a0b16a](https://github.com/onion-4-dinner/yellowjacket/commit/8a0b16a4ec08a95bfd3834c8216e21dce854432d))
* **quick-3:** add playlist-level multi-select state and selection handling ([e13151f](https://github.com/onion-4-dinner/yellowjacket/commit/e13151ffa5dc86e41ce242421679d65a740c3af0))
* **quick-3:** wire playlist context menu for batch delete of selected playlists ([c92ced2](https://github.com/onion-4-dinner/yellowjacket/commit/c92ced2c74e72bfc123c880c047462dc969cde34))
* **quick-4:** add 'Set as Default Playlist' context menu option ([9971b63](https://github.com/onion-4-dinner/yellowjacket/commit/9971b635b81fe3f8621c80a6664eccb3e1fc4bb8))
* **quick-5:** add CreatedAt/UpdatedAt to playlist Summary struct ([bdaff47](https://github.com/onion-4-dinner/yellowjacket/commit/bdaff478e802ee5c0745327c52dd9b190fcfef7d))
* **quick-5:** add sort dropdown UI and client-side sorting to playlist view ([5c07485](https://github.com/onion-4-dinner/yellowjacket/commit/5c074855351f1363cc7918837a78bbd3c0b7ebf5))
* **quick-7:** add PinDefault config field with backend getter/setter ([6e123bd](https://github.com/onion-4-dinner/yellowjacket/commit/6e123bd47f55e6d565f20bf7f19950e65f80787f))
* **quick-7:** wire frontend pin-default-playlist feature end-to-end ([e6378e1](https://github.com/onion-4-dinner/yellowjacket/commit/e6378e1f0d3b0f2a7604b8ef6097dba9050cdd16))
* **quick-8:** add FindDuplicateTracksInPlaylist backend method ([83de934](https://github.com/onion-4-dinner/yellowjacket/commit/83de934c39ca7d850a8b5925c90e6d0b3fe0a487))
* **quick-8:** create duplicate-tracks-dialog component ([9f3ba2b](https://github.com/onion-4-dinner/yellowjacket/commit/9f3ba2b9d474fa30dcb4934b01d4650e0d0d3cba))
* **quick-8:** wire duplicate detection into playlist-picker and playlist-view ([917a79a](https://github.com/onion-4-dinner/yellowjacket/commit/917a79a8d6e30dddd2170323bb26692386794872))
### Bug Fixes
* **01-01:** add mutex protection to Queue, Library, and Playlist SetContext methods ([daaa6b7](https://github.com/onion-4-dinner/yellowjacket/commit/daaa6b7f9779385979fe9dddae4e7bb388b3e5fb))
* **01-01:** collapse Player.SetContext double-lock into single acquisition ([3abaeba](https://github.com/onion-4-dinner/yellowjacket/commit/3abaeba3afb0f4d0edb81e26ca55b31bf59990ac))
* **02-01:** eliminate package-level startupErr and fix config file permissions ([2a86408](https://github.com/onion-4-dinner/yellowjacket/commit/2a864082017e489ffa086c136f1002277a77a7c4))
* **02-01:** log MPRIS callback errors instead of discarding them ([0860b2f](https://github.com/onion-4-dinner/yellowjacket/commit/0860b2fd4b2250da1eeb80c21f14fdf341697501))
* **08-02:** revert repeat() inside lit-virtualizer, restore .renderItem + .keyFunction ([72ef719](https://github.com/onion-4-dinner/yellowjacket/commit/72ef719ba70eeca0fa4bae47df092706f6fbaeed))
* drop+recreate contentless FTS5 index instead of DELETE ([8e9a616](https://github.com/onion-4-dinner/yellowjacket/commit/8e9a61603779eacbee7013b9bc760b315baf782a))
* **frontend:** reposition search indicator into toolbar and fix album cover art lookup ([a29137b](https://github.com/onion-4-dinner/yellowjacket/commit/a29137b2ba4c6b33ce9a5f868cbd6013e0e3b116))
* include full track metadata in GetAudioFilesByReleaseGroup query ([97f256d](https://github.com/onion-4-dinner/yellowjacket/commit/97f256d67f463d752f7adc5b400c4bf34eae1df1))
* **quick-10:** add migration 5 and fix entity cache for composite album key ([d43ba7b](https://github.com/onion-4-dinner/yellowjacket/commit/d43ba7bd0c7ace2a9ed71990a19498f8e9f90751))
* **quick-10:** update release_groups schema and queries for composite uniqueness ([999ab96](https://github.com/onion-4-dinner/yellowjacket/commit/999ab967beb9107a3f30ba287acbffad22f0b0de))
* **quick-13:** resolve lint issues in main source files ([e1a95e6](https://github.com/onion-4-dinner/yellowjacket/commit/e1a95e65a9f0f436b2e2d92befa9c881b6e8e430))
* **quick-14:** add roll-back-on-failure to queue index advancement ([2820de2](https://github.com/onion-4-dinner/yellowjacket/commit/2820de2510560fcd6d1015c18542d5ac30468247))
* **quick-9:** set fixed height on queue track items for stable virtualizer scroll ([ebde5e5](https://github.com/onion-4-dinner/yellowjacket/commit/ebde5e5a8bc4da8f40bef8f171c7ed86c213a336))
### Performance
* **07-01:** add incremental persistence helpers for queue mutations ([cdd17db](https://github.com/onion-4-dinner/yellowjacket/commit/cdd17db27509908514c21517631306655a2b3bd7))
* **07-01:** eliminate redundant lookups in SetQueue Phase 2 ([ced58fe](https://github.com/onion-4-dinner/yellowjacket/commit/ced58fe6a93d6f220137562b8ff09ffc33c69266))
* **07-02:** defer eagerFetch to after DOM ready for instant app shell ([cd98ad6](https://github.com/onion-4-dinner/yellowjacket/commit/cd98ad6dc8c2e4e6e0f01a48099b0c0511bf5a98))
* **08-01:** add queueMicrotask coalescing to library store and debounce search input ([3bf66ed](https://github.com/onion-4-dinner/yellowjacket/commit/3bf66ed125ed55bfbde95b0bc973710c2f2243b8))
* **08-02:** migrate cover-grid, artists-view, and genres-view virtualizers to repeat() directive ([1c3514d](https://github.com/onion-4-dinner/yellowjacket/commit/1c3514da1d0491b9758d7a6f9f72d59ef78fc8ed))
* **08-02:** migrate track-list and queue-panel virtualizers to repeat() directive ([d2d7d8c](https://github.com/onion-4-dinner/yellowjacket/commit/d2d7d8c6ce22923772cae4858b02804d15f74bb7))
* **08-03:** optimize column rendering and apply classMap to queue-panel renderTrackItem ([62f41c2](https://github.com/onion-4-dinner/yellowjacket/commit/62f41c24910632b270f9f5765e20e48db4b95ec9))
* **08-03:** replace class string construction with classMap directive in renderTrackRow ([ad21027](https://github.com/onion-4-dinner/yellowjacket/commit/ad210278fc20729dc76390e6bba9bff050549046))
### Refactoring
* **06-01:** consolidate search queries to use track_metadata VIEW ([9159b40](https://github.com/onion-4-dinner/yellowjacket/commit/9159b409dcd2afaa7dcc97bf5b0694edf85f06a4))
* **quick-14:** make playOrLoadCurrentTrack and playCurrentTrack return bool ([6eeddda](https://github.com/onion-4-dinner/yellowjacket/commit/6eeddda97669258cc5b7ba175a3c98d598a2871f))
## [1.1.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.2...v1.1.3) (2026-02-21)
### Bug Fixes
* add typescript as explicit devDependency and auto-install frontend deps in setup ([#70](https://github.com/onion-4-dinner/yellowjacket/issues/70)) ([7316587](https://github.com/onion-4-dinner/yellowjacket/commit/73165877fa79656ab9bc6f60bd8e9e52d6be206c))
* use local tsc binary in pre-commit hook to avoid PATH issues ([#71](https://github.com/onion-4-dinner/yellowjacket/issues/71)) ([6079e55](https://github.com/onion-4-dinner/yellowjacket/commit/6079e558ff913d38c7f1c4aeb52cc09474c4ed20))
## [1.1.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.1...v1.1.2) (2026-02-15)
### Bug Fixes
* r2 upload ([#69](https://github.com/onion-4-dinner/yellowjacket/issues/69)) ([0252466](https://github.com/onion-4-dinner/yellowjacket/commit/0252466f615b4e2fd9694790c6d311a9eac1ccf2))
## [1.1.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.0...v1.1.1) (2026-02-15)
### Bug Fixes
* **ci:** remove build-check job from CI workflow ([#66](https://github.com/onion-4-dinner/yellowjacket/issues/66)) ([42d3f45](https://github.com/onion-4-dinner/yellowjacket/commit/42d3f45d85afa694e9545997af3ff4ac814ad021))
## [1.1.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.3...v1.1.0) (2026-02-15)
### Features
* **ci:** upload release artifacts to Cloudflare R2 ([#65](https://github.com/onion-4-dinner/yellowjacket/issues/65)) ([8985084](https://github.com/onion-4-dinner/yellowjacket/commit/89850848cbf7783e5c85348ff18f7cd11d60231a))
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-15)
### ⚠ BREAKING CHANGES
* **deps:** update module github.com/evilmartians/lefthook to v2 (#61)
* **deps:** update actions/checkout action to v6 (#45)
* **deps:** update dependency vite to v7 (#53)
### Bug Fixes
* resolve all lint errors and make linting a required CI check ([#62](https://github.com/onion-4-dinner/yellowjacket/issues/62)) ([30b2480](https://github.com/onion-4-dinner/yellowjacket/commit/30b2480df49f57878b0e8c923da6ad8d6fe99416))
* virtual list and cover grid ([#63](https://github.com/onion-4-dinner/yellowjacket/issues/63)) ([7579a76](https://github.com/onion-4-dinner/yellowjacket/commit/7579a768be84225ed46db4e7a90781f3e30e2953))
### Miscellaneous
* **deps:** update actions/checkout action to v6 ([#45](https://github.com/onion-4-dinner/yellowjacket/issues/45)) ([2d6e221](https://github.com/onion-4-dinner/yellowjacket/commit/2d6e22105d2daed1dc5b586c0442e2941949a165))
* **deps:** update dependency vite to v7 ([#53](https://github.com/onion-4-dinner/yellowjacket/issues/53)) ([f0006c4](https://github.com/onion-4-dinner/yellowjacket/commit/f0006c4c4335b60b58cccdd29de4792965e39694))
* **deps:** update module github.com/evilmartians/lefthook to v2 ([#61](https://github.com/onion-4-dinner/yellowjacket/issues/61)) ([e32b217](https://github.com/onion-4-dinner/yellowjacket/commit/e32b2179129ae7f26037697a125710ff7587566d))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### ⚠ BREAKING CHANGES
* **deps:** update actions/setup-node action to v6 (#48)
* **deps:** update dependency stylelint-config-standard to v40 (#52)
* **deps:** update dependency node to v24 (#51)
* **deps:** update dependency vite-plugin-static-copy to v3 (#54)
* **deps:** update golangci/golangci-lint-action action to v9 (#55)
* **deps:** update amannn/action-semantic-pull-request action to v6 (#50)
* **deps:** update actions/upload-artifact action to v6 (#49)
* **deps:** update actions/setup-go action to v6 (#47)
* **deps:** update actions/download-artifact action to v7 (#46)
### Bug Fixes
* **ci:** use allowedPostUpgradeCommands for Renovate post-upgrade tasks ([#60](https://github.com/onion-4-dinner/yellowjacket/issues/60)) ([0aef483](https://github.com/onion-4-dinner/yellowjacket/commit/0aef483b3cccd0616fd5be2d06d0856b46851d09))
### Miscellaneous
* **deps:** update actions/download-artifact action to v7 ([#46](https://github.com/onion-4-dinner/yellowjacket/issues/46)) ([1910f99](https://github.com/onion-4-dinner/yellowjacket/commit/1910f99cf64e9bdc5ce91e89cab254ecca15d030))
* **deps:** update actions/setup-go action to v6 ([#47](https://github.com/onion-4-dinner/yellowjacket/issues/47)) ([8911fb2](https://github.com/onion-4-dinner/yellowjacket/commit/8911fb2400047cf2f3dfa719edc1d1bf474cdaa5))
* **deps:** update actions/setup-node action to v6 ([#48](https://github.com/onion-4-dinner/yellowjacket/issues/48)) ([d7382fd](https://github.com/onion-4-dinner/yellowjacket/commit/d7382fd8444b6618dbfe991f5f97231528a07f13))
* **deps:** update actions/upload-artifact action to v6 ([#49](https://github.com/onion-4-dinner/yellowjacket/issues/49)) ([a2c644b](https://github.com/onion-4-dinner/yellowjacket/commit/a2c644b00eed83acc0ed38a2eb8c73868b7b79af))
* **deps:** update amannn/action-semantic-pull-request action to v6 ([#50](https://github.com/onion-4-dinner/yellowjacket/issues/50)) ([643ba27](https://github.com/onion-4-dinner/yellowjacket/commit/643ba27f066164aeb47e8d9aaf20fe98b9b69d30))
* **deps:** update dependency node to v24 ([#51](https://github.com/onion-4-dinner/yellowjacket/issues/51)) ([e7d3971](https://github.com/onion-4-dinner/yellowjacket/commit/e7d39711078ce86b0c029f0d03ff81162c5dc28a))
* **deps:** update dependency stylelint-config-standard to v40 ([#52](https://github.com/onion-4-dinner/yellowjacket/issues/52)) ([422aabc](https://github.com/onion-4-dinner/yellowjacket/commit/422aabcc07e9700ff189302b363e13d87c69163a))
* **deps:** update dependency vite-plugin-static-copy to v3 ([#54](https://github.com/onion-4-dinner/yellowjacket/issues/54)) ([77fa643](https://github.com/onion-4-dinner/yellowjacket/commit/77fa6435a5298f58ef83607d99c59b876132c66c))
* **deps:** update golangci/golangci-lint-action action to v9 ([#55](https://github.com/onion-4-dinner/yellowjacket/issues/55)) ([aedb7d1](https://github.com/onion-4-dinner/yellowjacket/commit/aedb7d1e6d204c56c468dd26b340752fd6bfeaeb))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* resolve Renovate repo detection and pre-push hook hang ([#36](https://github.com/onion-4-dinner/yellowjacket/issues/36)) ([b205889](https://github.com/onion-4-dinner/yellowjacket/commit/b205889128f01e9eb75b607cf7c4034887cda3f4))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
* rename downloaded artifacts to platform-specific names for release ([e3bda0e](https://github.com/onion-4-dinner/yellowjacket/commit/e3bda0e2fc7700fad382cabe00aeb46f91fbb0a0))
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-14)
### Bug Fixes
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### Bug Fixes
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### Bug Fixes
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
+723 -98
View File
@@ -144,7 +144,7 @@ meaningless against the seed's one empty playlist, so it builds ten
It wraps every bound Go method, so "did that refetch the library" is a It wraps every bound Go method, so "did that refetch the library" is a
fact rather than an inference. It is not a spec and does not run in CI. fact rather than an inference. It is not a spec and does not run in CI.
**The cheapest tier needs none of that.** `make ui-test` runs 757 **The cheapest tier needs none of that.** `make ui-test` runs 776
Vitest tests in a real Chromium with no Wails, no backend, no seeded Vitest tests in a real Chromium with no Wails, no backend, no seeded
library and no virtual display, because **v3 routes every runtime call library and no virtual display, because **v3 routes every runtime call
— bindings, event emits, window, dialogs, clipboard — through one IPC — bindings, event emits, window, dialogs, clipboard — through one IPC
@@ -223,45 +223,158 @@ rather than renaming them.
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below. - `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below.
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand. - `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
**Schema changes need two things, not one.** `sql/schemas/*.sql` is **The schema is one description, and there is no migration chain.**
`CREATE ... IF NOT EXISTS` and is what sqlc reads — it's the single `sql/schemas/*.sql` is `CREATE ... IF NOT EXISTS`, declares the
source of truth for "what the schema looks like right now", and it's current shape of every table, and is what sqlc reads and what an
what a fresh install gets verbatim. But it's a no-op against a install gets verbatim. That is the whole mechanism: `sql/migrations/`,
database that already has the table, so an existing install needs a `applyMigrations` and `schema_migrations` were squashed away with the
matching file in `sql/schemas/../migrations/` (e.g. file-shaped rewrite (plan 013). A schema change is one edit to one
`NNNN_description.sql`, `ALTER TABLE ... ADD COLUMN ...` / file plus `make generate`. Reintroducing a chain means reintroducing
`CREATE INDEX ...`) to actually reach that shape. Both run on every the drift it caused before — `sql/schemas/` and the migrations
open, migrations after schema files, tracked in `schema_migrations` disagreed, and sqlc generated against the stale one.
so each applies once; a migration's `ALTER TABLE ADD COLUMN` failing
with "duplicate column name" on an already-current database is
expected and tolerated, not an error.
A few things that bite if forgotten: **What that costs an existing database is repaired once, at open.**
- **Column order must match between the two paths.** `ALTER TABLE `CREATE ... IF NOT EXISTS` reaches an existing table only if its shape
ADD COLUMN` always appends at the end, so a migrated column must already matches and otherwise silently no-ops, so a *changed* table
also be declared *last* in the `CREATE TABLE` in `sql/schemas/` never migrates. Plan 014 added `total_tracks` to `explore_index` and
otherwise a fresh install and an upgraded install disagree on to `indexRowFields` — the projection every explore read uses — and no
column order, and a `SELECT *` query (sqlc binds those database that already existed grew the column: **every** Explore
positionally) silently reads the wrong field on one of them. See search, browse, artist and album page on such an install failed with
`backend/database/migrations_test.go`'s `no such column: total_tracks`, while a fresh install was perfectly
`TestMigrations_ColumnOrderMatchesFreshInstall`, which is the healthy, which is exactly why no test saw it. Plan 013 was worse on
regression test for exactly this. the same install: `applySchema` could not be applied at all over a
- **Don't put an index on a migrated column in `sql/schemas/`.** pre-013 `audio_files`, so the app did not open.
Schema files run before migrations, against a database that may
not have that column yet — the index's predicate would fail `backend/database/staleshape.go` runs before `applySchema` and
(this is precisely the bug an earlier session shipped and a user retires what is stale, so the create is a create. Five things about
hit at `make sandbox`). Declare it in the migration file instead, it are load-bearing:
after the `ALTER TABLE` that adds the column. - **It parses `sql/schemas/` for the expectation** rather than
- This project **had** a 48-step migration chain before and tore it writing the column list down a second time, because a second list
out (see `.planning/NOTES.md`, "No migration chain") because is a second thing to forget — the fault it exists to repair.
`sql/schemas/` had drifted from what the migrations actually - **It notices a changed *type*, not just a missing column.** 013
produced and sqlc silently generated against the stale version. moved `mbid` from TEXT to BLOB, and SQLite does not coerce between
The design here avoids that by keeping `sql/schemas/` as the them: a comparison against 16 raw bytes returns no rows rather than
literal target shape (not a hand-maintained description of it) an error. `ALTER TABLE ADD COLUMN` would have handled
and letting migrations replay tolerantly against it — but the `total_tracks` alone and cannot express this at all, which is why
same drift is possible again if a schema change ships without the repair drops rather than migrates.
updating both files. Don't reintroduce a *second* description of - **`Authored` is never retired**, and that boundary is a test
the schema anywhere else. (`TestAuthoredTablesAreNeverRetired`), not a comment. Everything
else is rebuildable: `Cache` by definition, `Owned` by a rescan —
plan 013's stated "delete and rescan" — and `Derived` from Owned.
A table the schema no longer describes at all goes too; 013 left
seven behind plus `schema_migrations`.
- **Whether a stale `Cache` table may be rebuilt is a build tag**, and
it is the most expensive thing in this file to get wrong. In the app
the catalog is *downloaded*, so a wrong shape costs a minute of
re-fetching the artifact and keeping it costs every Explore read. In
`cmd/indexbuild` the catalog is *derived*, and the only way back is
the ~205 GB dump stream the `/cache` volume exists to avoid — so
`retireStaleCache` is false there (`staleshape_policy_indexbuild.go`)
and `TestTheCatalogSurvivesAStaleShape` fails the moment it is not.
`TestNoCacheTableIsRetiredHere` is the same assertion made of *every*
`datamap` Cache table rather than one, because the risk is not that
shape recurring — it is the next destructive repair added to
`database.NewDB`, the chokepoint every binary here shares, without
asking which binary it is in.
This is written down because it already happened: the repair shipped
without the distinction and dropped the real CI catalog on its first
run, with `reason="column entity_type is TEXT, schema declares
INTEGER"`. The mismatch was genuine — that database is deliberately
kept in the older encoding, which `fix(indexexport): read an index
older than the binary` exists to tolerate — so it would have been
dropped on *every* run. The consequence is that a future
`explore_index` column fails the index job loudly on `applySchema`
rather than silently costing it a rebuild, which is the trade a
human should get to make.
- **The drops are one transaction with `defer_foreign_keys`.** Those
legacy tables reference each other, so dropping them in any order
fails on whichever goes first, and turning foreign keys *off*
instead would silently take `playlist_tracks.audio_file_id`'s
ON DELETE SET NULL with it — leaving playlist 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 above passed its own regression
test on two runs in three until the order was fixed.
Retiring `explore_index` takes its FTS and its meta with it, because
the `dump_import_done` marker is what would otherwise stop the
artifact ever being fetched again.
**What that costs an existing database is that it does not open**, and
"delete and rescan" is the answer (plan 013, open question 1) — free
for everyone except one machine. The index job's `/cache` volume is a
real `YJ_HOME` that survives between runs, and half of it is the
catalog: deleting it means re-downloading ~205 GB. So `cmd/indexbuild`
repairs it instead (`staleschema.go`), dropping every table `datamap`
does not classify as `Cache` **before** the schema is applied. Nothing
scans, plays or authors in that database, so its non-catalog half is
empty by construction and a shape left over from an older schema is
pure liability. 013's reshaped `audio_files` failed every run of that
job on `CREATE INDEX ... album_id` against the old table until this;
`TestRetireLibraryTables` reproduces exactly that, symptom first.
**The local library is shaped like files, not like MusicBrainz.**
`audio_files` carries its own tags — title, artist credit, track and
disc numbers, year, composer, the recording MBID — and points at two
shared rows: `albums` (many files to one) and `artists` (many albums
to one). `file_genres` is the one genuine many-to-many. That is the
entire local model.
It used to be MusicBrainz's: `recordings`, `release_group_recordings`,
`artist_credit` and `artist_credit_artist` sat between a file and its
own tags. Measured on a real 25,966-file library, **every**
many-to-many that model expressed was 1:1 in the data — no recording
had two files, none belonged to two release groups, and 3 credits of
2,823 listed more than one artist. What it cost was a six-way join in
every read, a `MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine to undo fan-outs that never
happened, and a class of bugs where a metadata row **outlived the file
that created it**: retagging a file created a new recording and
abandoned the old one, so that library carried 812 orphaned
recordings, 216 release groups and 260 artists — and 129 catalog rows
that confidently claimed to be owned by files that no longer existed.
A few things that follow, and bite if forgotten:
- **Ownership is a file.** "Do I own this" is asked of `audio_files`
and nothing else — `GetFilePathsByRecordingMBIDs`,
`LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
`pruneStaleLocalCrossReferences` all join it. A metadata row is not
ownership; that was the bug, and it is now structurally impossible
for a row to exist without its file.
- **The projection is defined once, in the `track_metadata` view.**
Every query that returns a track selects from it, which is why
there is one row type (`sqlcgen.TrackMetadatum`) and one mapper
(`trackFromRow`). It existed before and only the raw-SQL search
paths used it, so nine hand-rolled copies had already drifted: the
view preferred the album's original year and one copy used the
track's, and the same library reported different years on different
screens. The FTS searches cannot be sqlc queries (MATCH is not in
its grammar) and spell the column list out in `search.go` — that is
the one exception and it is one constant.
- **`library_id = 0` means every library.** Each list query used to
exist twice, scoped and unscoped, with a branch at every call site
and a separate binding for each. One query answers both, and the
scoped form costs nothing measurable (23 ms against 21 ms over 26k
rows).
- **A slice and a named parameter do not compose in sqlc.**
`sqlc.slice` expands to N placeholders but a named argument is
numbered independently, so `GetFilePathsByAlbums([1,2], 0)` read
album id 2 as the library id. Where a query needs both, return
`library_id` and filter in Go (`inLibrary`).
- **A cache without a ceiling is a leak with a schedule.** Every
store that grows with use declares a budget beside its retention
(`browsedArtBudget`, `httpCacheBudget`), because an age bound does
not bound anything a user can outrun in an afternoon.
- **A query file must be ASCII.** sqlc's parameter rewriter works on
byte offsets, so one non-ASCII character in a *query* comment
corrupts the generated Go into garbage like `SELECid`. Schema files
are not rewritten and may contain anything.
- **A view is dropped and recreated, not migrated.**
`CREATE VIEW IF NOT EXISTS` no-ops against a database holding the
old definition, so `track_metadata.sql` opens with
`DROP VIEW IF EXISTS`; a view holds no data, so rebuilding it on
every open costs nothing.
- **A write wearing a query's shape still needs the writer.** - **A write wearing a query's shape still needs the writer.**
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a `DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
*query-only* read pool (a second `sql.DB` over the same file), so *query-only* read pool (a second `sql.DB` over the same file), so
@@ -271,32 +384,24 @@ rather than renaming them.
created at all. Use `ExecContext`, or `QueryRowWriter` when the created at all. Use `ExecContext`, or `QueryRowWriter` when the
statement really does return a row. Nothing caught this because statement really does return a row. Nothing caught this because
`NewTestDB` shares one in-memory connection and leaves `readDB` `NewTestDB` shares one in-memory connection and leaves `readDB`
nil, so `reader()` returns the *writer* under test and the unit nil, so `reader()` returns the *writer* under test.
tests exercised a handle the app does not have.
`TestNoWritesOnTheReadPool` walks the tree for it, in the same `TestNoWritesOnTheReadPool` walks the tree for it, in the same
spirit as `TestNoDirectRuntimeEmits` and for the same reason — a spirit as `TestNoDirectRuntimeEmits` and for the same reason — a
lint pass only sees one build configuration. lint pass only sees one build configuration.
- **A new table needs one file, not two.** The two-file rule is
about a column added to a table that already exists.
`applySchema` runs every file in `sql/schemas/` on *every* open,
so a `CREATE TABLE IF NOT EXISTS` reaches an existing install
verbatim and a migration for it would be a second description of
the same table — which is exactly what the third rule forbids.
`excluded_paths` is the worked example, index included, since the
column and its index arrive together.
- **A new table has to say what kind of data it holds.** - **A new table has to say what kind of data it holds.**
`backend/datamap` is a catalogue of every table's Kind and `backend/datamap` is a catalogue of every table's Kind and
Lifetime, and `TestCatalogCoversSchema` fails on a table missing Lifetime, and `TestCatalogCoversSchema` fails on a table missing
from it. `TestAuthoredCascadesAreDeliberate` then makes an from it. `TestAuthoredCascadesAreDeliberate` then makes an
*authored* table that cascades an explicit, argued exemption — *authored* table that cascades an explicit, argued exemption —
authored data is what a user cannot get back. authored data is what a user cannot get back. Two entries say
- **Squashing is fine pre-1.0.** While this hasn't shipped to real **MIXED KIND** and mean it: `audio_files` is an owned projection
users, periodically folding `sql/migrations/` into `sql/schemas/` except for `play_count`, `last_played` and `tag_status`, which are
and deleting the migration files (then wiping your own dev/sandbox authored; `lyrics` carries a `source` column because a lyric read
DB) is a legitimate way to keep the migrations directory from from a tag is free to rebuild and one fetched from LRCLIB is not.
accumulating dev-only churn — same effect as the old "just nuke - **Test data has one seeder.** `database.InsertTestTrack` inserts a
it" workflow, opt-in instead of mandatory. Stop doing that once file with its artist, album and genres. Twenty test files used to
real user databases exist in the wild. carry their own, each assembling the old FK chain in a slightly
different order.
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC). - `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
- `jobs` — The registry every long-running operation reports through: - `jobs` — The registry every long-running operation reports through:
progress, pause/cancel, a global indicator and (for scans) a pause progress, pause/cancel, a global indicator and (for scans) a pause
@@ -306,7 +411,26 @@ rather than renaming them.
came about. came about.
- `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments. - `config` — TOML-based settings. Settings page uses HTMX + templ for server-rendered HTML fragments.
- `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists. - `playlist` / `smartplaylist` — Playlist CRUD and rule-based smart playlists.
- `mediacontrols` — MPRIS integration on Linux via D-Bus. - `mediacontrols`OS media controls behind one `Handler`: MPRIS over
D-Bus on desktop Linux, a MediaSession on Android, a no-op stub
elsewhere. The split is by build tag and `android` implies `linux`,
so the three files read `linux && !android`, `android` and `!linux`.
Its Android half needs no JNI beyond what Wails exports — a JSON
payload out through `application.Android.StartForegroundService`, a
command event back through `WailsBridge.emitEvent` — and the Java it
talks to is `build/android/.../WailsForegroundService.java`. That
contract (payload keys, state words, command names) is in
`androidpayload.go` **without** the build tag, because a tagged file
is compiled by nothing `make lint` or `make test` runs and is
untestable off a phone.
`OnDuck` is the one callback MPRIS does not use: Android asks for
attenuation rather than a pause when something short needs the
output. `Player.SetDuck` keeps it as an offset on top of the user's
level rather than writing through to the volume, so it cannot
accumulate and nothing persists or emits a level the user did not
choose — and it only ever fires below API 26, where the framework
does not already duck the app itself.
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows). - `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
- `explore` — Catalog search and browse over `explore_index`. See below. - `explore` — Catalog search and browse over `explore_index`. See below.
Its **shelves** (`shelves.go`) are the page Explore shows before Its **shelves** (`shelves.go`) are the page Explore shows before
@@ -391,6 +515,142 @@ work happens **once, centrally**, and users download the result:
(`dumpincremental.go`), and resolves artists outside the artifact's (`dumpincremental.go`), and resolves artists outside the artifact's
coverage lazily on first view. coverage lazily on first view.
**The catalog stores ids as bytes, and that is a size decision.**
`explore_index` is 2,052,200 rows, and its MBIDs and entity types were
half of it: three 36-character text columns and one storing the words
"artist", "release_group", "recording" two million times. They are 16
raw bytes and a small integer now. Measured on a real catalog, the
table and its six indexes went **780 MB to 405 MB** — the largest
single saving available in this app, and the reason a fresh install is
~0.6 GB rather than ~1.0 GB.
`backend/explore/mbid.go` is the only place that encoding is known.
Everything above it speaks dashed strings and entity names —
`SearchIndexResult`, the bindings, the frontend — and `dbMBID` /
`dbEntityType` convert at the SQL boundary. That confinement is the
point: the alternative is blobs reaching code that has no use for them.
Four things about it are load-bearing, and they exist because of *how*
this fails when it fails: **SQLite does not coerce between TEXT and
BLOB**, so a query comparing the column against a 36-character string
returns no rows rather than an error, and a scan into a plain string
yields sixteen bytes of mojibake. Neither is visible except as a result
that is quietly empty.
- **The column checks itself.** `CHECK(length(mbid) = 16)` means a
stringly *write* fails at the insert that made it. It also caught
every fixture that had been using `"rh"` as an MBID; `testMBID()`
hashes a label into a real one so they stay readable.
- **The projection is one constant and one scanner.**
`indexRowColumns` / `scanIndexRow` replaced four copies of a 22-column
list and four matching `Scan` calls — four chances to decode wrongly.
`indexRowColumnsFor("i")` is the same list qualified, for the FTS join
where both sides have a `title`.
- **A query that names an entity type inline writes the code with the
name beside it** — `entity_type = 1 /* artist */`. Splicing a Go
constant in would keep them in step automatically but makes every such
query a concatenation; `TestEntityCodesAreStable` pins the mapping
instead, because it is a storage format and changing one is not a
refactor.
- **`TestStoredEncodingRoundTrips` sweeps every read path** — lookup,
top-N, exact match, FTS search, popularity batch, the CAA map — and
asserts each returns something with a dashed id. A missed conversion
site shows up there and essentially nowhere else.
**The artifact is read in either encoding.** A published artifact
carries whichever form the exporter that built it used, and there is one
already out there in the old text form. `artifactStoresText` asks the
artifact (`typeof(mbid)`) rather than trusting a version number, and
`artifactSelectColumns` converts on the way in — one `unhex` per row on
a once-a-month import, against requiring a rebuilt artifact before a new
build can read anything. That probe **must** run on the writer:
`core` is attached to that one connection, so `QueryContext` asks a
pool where the artifact does not exist, and the error would silently
select the conversion path for an artifact that needs none.
**Its shape is the pattern for every column added after the fact.**
`artifactHasTotals` is the same question about `total_tracks`, on the
same handle: an artifact built before a column existed is still a
perfectly good catalog, so it is *asked* and the missing column is
selected as a literal `0`. Adding the column to the importer's SELECT
list without that is how a published artifact — which nobody can re-cut
retroactively — starts failing with `no such column`.
**A credit is ordered parts, and the string is derived from them.** A
track credited to several artists had exactly one navigable artist and
the rest were punctuation: `primaryArtist()` string-parses the credit,
strips a " feat. " clause and discards the guest, and deliberately does
not split on `&`, `with` or `,` because those live inside real artist
names ("Simon & Garfunkel"). Measured on a real 26,069-file library,
**13%** of recordings are multi-artist upstream while only **0.86%** of
files carry a structured multi-artist tag — mp3 carries *zero* files
with multiple `MUSICBRAINZ_ARTISTID` across 19,840 — so this cannot be
a tag-parsing feature. (The "3 credits of 2,823" figure that justified
plan 013's removal of the credit tables measured our own *writer*:
`cachedLinkArtist` ran once per credit, so a collaboration could never
have been recorded. Dropping the join table was still right on cost.)
`artist_credit_part` / `artist_credit_ref` carry the decomposition for
multi-artist credits only — a single-artist credit is already
`explore_index`'s own `artist_name`, and storing those would triple the
table to say nothing. Five things about it are load-bearing:
- **Join phrases are assembly instructions, not disassembly ones.**
`creditLink` concatenates parts, so link boundaries are known by
construction. Locating a `credited_name` *inside* the stored credit
string would reintroduce the fault this exists to fix: that string may
come from the file's tags while the parts come from the catalog, and
the two disagree for ~1 in 3 multi-artist credits (`'Skrillex feat.
Swae Lee'` tagged against `'Skrillex & Swae Lee'` upstream).
- **`credited_name` is stored per row**, never joined from `artists`:
MusicBrainz credits "Snoop Dogg" on a track by the artist called
"Snoop Doggy Dogg". Display follows the credit, navigation the MBID.
- **The lookup is keyed on the recording MBID**, which the catalog and a
local file both carry (`library.Track.RecordingMBID`), so one binding
serves Explore and the library's own lists — which is why this needed
no local table. `file_artists` remains the offline-resilience step and
is deliberately *not* declared until something writes it.
- **Absence is cached as an answer.** `credit-store.ts` stores `[]` for
a single-artist credit — *asked*, not *answered* — or the ~87% that
have nothing to decompose are re-requested on every render forever.
`request()` is per-row and coalesces into one call per frame, because
a virtualized list cannot hand over "the whole list": 50,000 rows is
100 queries for the ~30 on screen.
- **The dump is a third source, and it had to be.** 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. The
pass runs on **every** mode, because a complete import means
`refresh`, which never enters the importer at all, and it reports
whether it populated anything so `changed` republishes the artifact.
**A 0.6 GB download asks about the connection first.** `explore`'s
catalog artifact had no network awareness at all, which on a phone is a
month's data allowance spent without being asked (plan 016 B4).
`netpolicy.go` is the gate, and its shape is dictated by one constraint:
`explore` is imported by `cmd/indexbuild`, which is built with
`CGO_ENABLED=0` and must not link Wails — so the *policy* and the
*parsing* live here and are tested on every platform, while the platform
call is a closure injected from `app.go`. It is
`application.Mobile.NetworkJSON()`, not `application.Android`'s: the
latter exists only under the `android` build tag, and `Mobile`'s desktop
implementation is a stub returning `""`.
Three rules in it are load-bearing. **An unknown answer is not a metered
one** — only mobile answers at all, so treating silence as metered would
refuse the download on every desktop. **Cellular is the only signal
available**: the runtime reports `wifi|cellular|ethernet|none` and no
metered flag, so a metered *Wi-Fi* (a hotspot, a hotel) cannot be
detected and is not refused, which is a documented gap rather than an
oversight. And **the gate runs before anything is staged**, so declining
is a no-op rather than a job in the indicator and a status the user has
to dismiss. The permission (`AllowMeteredCatalogDownload`, default
false, so an existing config is careful without a migration) is read at
the moment a download would start, so turning it on takes effect on the
next attempt rather than the next launch.
**Background work yields, and says so in the context.** The post-scan **Background work yields, and says so in the context.** The post-scan
backfills share MusicBrainz's rate limiters with every page the user backfills share MusicBrainz's rate limiters with every page the user
can open, and both were FIFO — so a thousand-artist enrichment put an can open, and both were FIFO — so a thousand-artist enrichment put an
@@ -453,7 +713,20 @@ is what every other failure here already does. SQLite's writer pool is
`MaxOpenConns(1)`, so the workers queue at the Go level rather than `MaxOpenConns(1)`, so the workers queue at the Go level rather than
racing for the file. racing for the file.
Four things about the marks are load-bearing. The marks are **a table, not Five things about the marks are load-bearing. **A mark records that the
upstream was asked, not that it answered with something.** ListenBrainz
returns 200 and `[]` for an artist it has no popularity data for — which
is most of a long-tail library, and the same is true of an artist whose
every row falls under `indexMinPopularity` — and keying
`discog_fetched` on "did rows come back" made those artists permanent
candidates: "Filling in artist details" re-ran for up to
`discogBackfillMaxPerRun` of them on **every launch**, forever, doing
the same two fetches to the same empty answer. So `indexOneArtist` sets
the mark when both fetches *succeeded* (`fetchTopReleaseGroups` and
`fetchTopRecordings` return an error for that reason), and only a real
failure — transport, non-2xx, unreadable body — leaves the artist for
the next run. `browseFullDiscography` already had this right: an artist
with genuinely no release groups is still marked browsed. The marks are **a table, not
more `explore_index` columns**, because `artifactimport.go` merges the more `explore_index` columns**, because `artifactimport.go` merges the
downloaded catalog by column list — a flag added there is a second downloaded catalog by column list — a flag added there is a second
place to remember, and forgetting it silently wipes every mark on the place to remember, and forgetting it silently wipes every mark on the
@@ -505,6 +778,33 @@ that invents its own flat layout agrees with the bug.
keeping only `explore.ArtistImageKeepNames()` and refusing an empty keeping only `explore.ArtistImageKeepNames()` and refusing an empty
keep set for the reason the covers sweep refuses an empty live set. keep set for the reason the covers sweep refuses an empty live set.
**An age is not a ceiling, and a cache needs one.** Art for an artist
the user owns is kept indefinitely; everything else aged out after 90
days and nothing counted it, so the same install held portraits for
**5,770 artists in a 1,301-artist library** — every artist page opened
in Explore fetches one, and a browsing afternoon is entirely inside the
retention window. `browsedArtBudget` (256 MB) is the second pass:
oldest browsed artist first, until what is left fits, with owned
artists outside the budget entirely. `httpCacheBudget` is the same rule
one cache over, and it is what makes the year-long entity TTL below
safe — once answers stop expiring, expiry stops being a bound.
**"Owned" is a file here too.** The sweep's live set used to be "there
is an `artists` row", which the file-shaped schema made meaningless;
it joins `audio_files` now, like every other ownership question. Its
test had seeded an artists row with no file and called it owned — the
exact phantom, in the fixture of the test that guards it.
**Only the tiers of a cover are stored.** `saveCoverArt` writes
`_sm`/`_md`/`_lg` and records the largest as `cover_art.file_path`;
`coverart.ResolveURLs` reports that one as `Original` too, because it
is the largest kept. The full-resolution image used to be written
beside them and was **1,134 MB of a 1.4 GB covers directory** against
110 MB for all three tiers — with nothing rendering it, since the grid
caps at 350 px and the largest tier is 400. The bytes are still in the
audio file, which is where they came from, so the repair pass that
regenerated tiers *from the stored original* went with it.
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated as TypeScript in `frontend/bindings/`, nested by Go import path — don't edit by hand. The `@go` alias absorbs the constant prefix, so a call site imports `@go/library/library.js`. **Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated as TypeScript in `frontend/bindings/`, nested by Go import path — don't edit by hand. The `@go` alias absorbs the constant prefix, so a call site imports `@go/library/library.js`.
**One seam states what the generated types get wrong, rather than 78 patches.** v3's generator is honest where v2's lied: a Go `nil` slice marshals to JSON `null` and always has (v2 typed it `T[]`), and a Go named string type is a closed set (v2 typed it `string`). There is no flag to turn either off, correctly. So `utils/binding.ts` states the app's actual contract at the only place it is true — `list` yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil map and drop null-valued keys (which loses nothing: `noUncheckedIndexedAccess` already makes every read `V | undefined`), and `compact` is the same for a map arriving as a *field*. Where a nullable slice is a model field there is no boundary to put it at, and those are `?? []` at the point of use. **One seam states what the generated types get wrong, rather than 78 patches.** v3's generator is honest where v2's lied: a Go `nil` slice marshals to JSON `null` and always has (v2 typed it `T[]`), and a Go named string type is a closed set (v2 typed it `string`). There is no flag to turn either off, correctly. So `utils/binding.ts` states the app's actual contract at the only place it is true — `list` yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil map and drop null-valued keys (which loses nothing: `noUncheckedIndexedAccess` already makes every read `V | undefined`), and `compact` is the same for a map arriving as a *field*. Where a nullable slice is a model field there is no boundary to put it at, and those are `?? []` at the point of use.
@@ -526,6 +826,27 @@ moment it is most needed is the likeliest moment loading one fails.
`first-run-wizard` and the startup chrome are eager for the ordinary `first-run-wizard` and the startup chrome are eager for the ordinary
reason — they are the first paint. reason — they are the first paint.
**A navigation is a history entry, and that is the whole back stack.**
`index.ts` records each navigation with `pushState` (same URL — the app
has no routes, and a path a reload cannot resolve is worse than none)
and replays `popstate` with `_isBack`. It exists for Android, whose back
button is not a key the page can bind: the scaffold's
`MainActivity.onBackPressed` asks `webView.canGoBack()` and finishes the
activity otherwise, so an app that never touched `history` quit from any
depth — which is what a device reported. Hooking the platform's own
mechanism rather than adding a JNI callback is also what makes it
testable in a browser (`page.goBack()`), and the Java half needed no
change at all.
Two rules hold it up. 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 (`navigate-back`, fired
by the detail views and `now-playing-view`) go through `history.back()`
rather than a stack of their own: the old `navStack` is **deleted**, not
kept beside it, because two stacks is precisely how a view's own back
button and the phone's gesture come to disagree about what one press
means.
**A primary view is cached, not unmounted.** `index.ts` keeps every **A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so is what preserves `scrollTop` across navigation — so
@@ -681,6 +1002,20 @@ against the real components:
moving focus without setting it leaves the highlight on whichever moving focus without setting it leaves the highlight on whichever
item the mouse last touched. item the mouse last touched.
**And a menu opens from a finger, through the event it already has.**
`utils/long-press.ts` is one document-capture listener installed once
from `index.ts`: a touch that holds still for 500 ms dispatches a
synthetic `contextmenu` at the touch point, so all six components that
bind one — delegated on a virtualizer, per row, per card — gained the
gesture without changing. The target is `composedPath()[0]` rather than
`elementFromPoint`, which stops at the outermost shadow host and so
reaches a delegated listener and no per-row one; a browser that fires
its own long-press `contextmenu` (Chromium does, WebKit and the WebView
vary) wins, ours being told from theirs by **identity** rather than
`isTrusted`, since no test can dispatch a trusted event; and the click
that ends the gesture is swallowed, keyed on the gesture rather than on
a time window so the first tap on the menu it opened is not eaten too.
Three lists had no focused row to open a menu *from* — the queue panel Three lists had no focused row to open a menu *from* — the queue panel
and both playlist detail views — and gained a roving tab stop through and both playlist detail views — and gained a roving tab stop through
`utils/roving-rows.ts`. **`track-list` deliberately does not use it**: `utils/roving-rows.ts`. **`track-list` deliberately does not use it**:
@@ -851,6 +1186,65 @@ this app promises, no scrollbar appears. Note that `overflow: hidden`
still permits *programmatic* scrolling, so a probe that sets still permits *programmatic* scrolling, so a probe that sets
`scrollLeft` passes on the broken build; the spec uses a wheel gesture. `scrollLeft` passes on the broken build; the spec uses a wheel gesture.
**Below 600px it reflows instead, and that is the phone.** The sideways
scroll above was the concession available while the shell had one
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
sidebar column, `<bottom-nav>` takes over as the primary navigation,
the header's controls shrink or stand down, and the shell measures
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
all along. 600 rather than the sidebar's 900 because 900 is a laptop:
the answer there is a narrower sidebar, which is still a sidebar.
Three rules in it are load-bearing, and the second cost 30 specs.
**A grid item's implicit minimum is its content**, so one child that
insists on 580px makes the *body* 580px wide inside a 360px viewport
and `overflow-x: hidden` then hides a third of the app rather than
fitting it. Every box between the viewport and the content that must
shrink carries `min-width: 0`, and the things that cannot shrink say so
in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s
label, `audio-player`'s seek bar and volume. A media query inside a
shadow root is answered by the viewport, so a component states what it
drops at phone width itself rather than the shell reaching in.
**A duplicated component duplicates its handles.** `bottom-nav`'s
"More" opens the *same* `<app-sidebar>` in a `wa-drawer` rather than
listing the destinations again — but rendering it unconditionally put a
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
specs failed with "strict mode violation: resolved to 2 elements" on a
desktop viewport where the element is not even visible. It renders only
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
before that.
**The tab bar is four destinations and a way to the rest.** Three to
five is where touch targets stop being thumb-sized; eleven over 360px
is 32px each. Which four is plan 016's committed subset, and everything
else — Settings included, because a phone still needs it — is behind
"More".
**The phone section of `index.css` is last on purpose.** A media query
adds no specificity, so a `@media (max-width: 599px)` block placed
above the plain rules it overrides loses to them — which is how phase 1
shipped a header that kept its 2em gutters and 24px title on a 390px
phone with every declaration dead and nothing failing. The shell fitted
anyway, because the fitting is done by `min-width: 0` and by each
component's own media query, which live in their own stylesheets and
have no later rule to lose to. Cosmetic declarations are exactly what
no assertion sees; a screenshot found it.
**`<now-playing-view>` is where the seek bar and volume went.** It is a
*detail* view (`DETAIL_LOADERS`, so the nav stack carries the way out —
a tab you cannot leave by pressing again is not a tab), reached from a
phone-only button over the mini player's art, and it **composes the
real `<seek-bar>`, `<player-controls>` and `<volume-control>`** rather
than reimplementing them. While it is up, `index.css` hides the bottom
bar through `body:has(#main-content[data-active-view="now-playing"])`
the active view is already published as an attribute, and a class
toggled from `index.ts` would be a second expression of the same fact.
The view therefore carries its own queue button, because that button
lives in the bar it hides.
**The playing row is a shape, not a hue.** `track-list` and **The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left `queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint padding, plus `aria-current` — before, both rows were a background tint
@@ -958,31 +1352,47 @@ when the whole release is owned, **Play 7 of 12** when some of it is,
and **no play button at all** when none is, because a Play button that and **no play button at all** when none is, because a Play button that
plays nothing (or seven tracks of forty) is worse than none. plays nothing (or seven tracks of forty) is worse than none.
`albumLibraryStatus()` is deliberately *not* what decides that. It is **The page asks one question, once, and it is "is there a file".**
four claims of decreasing confidence OR'd into one tick — a local album Ownership used to be several claims of decreasing confidence OR'd
id, the backend's cross-reference, a cached MBID match, and finally together — a local album id, the backend's cross-reference, a cached
*any single track* marked `inLibrary` — which is a fine answer to "is MBID match, and finally *any single track* flagged `inLibrary`none
any of this mine" and a useless basis for a button. `ownership()` of which is "there is a file to play", which is why the tick could be
counts the displayed tracklist instead, whose `inLibrary` flags the green on an album whose every action did nothing, and why the
backend sets per recording MBID. tracklist's context menu asked the backend on **hover** whether the row
it was drawing was owned. `filePaths` is the one answer: a map from a
displayed track to its path, filled once from `updated()` by a single
batched `GetFilePathsByRecordingMBIDs`, and read by the badge, the Play
button's count, the dimmed rows and every menu item. `askedFor` is a
separate set from `filePaths` because the guard has to be *asked*, not
*answered* — an unowned MBID never lands in the map, so guarding on the
map re-requests it on every render, forever.
The other half is that the *displayed* tracklist is also one thing.
`buildVersionEntries` synthesises the "Your Library" entry from
`localTracks`, so an album the catalog cannot answer for is exactly the
case that needs the version list rebuilt — `loadLocalTracks` guarded
that rebuild on `releases.length > 0` and so rendered "No release data
available" over a tracklist it was holding in memory.
**And the key it plays by is not the key it looks owned by.** The local **And the key it plays by is not the key it looks owned by.** The local
album id is used wherever there is one, because a library-only album album id is used wherever there is one, because a library-only album
has *no* recording MBIDs — its tracks are synthesised from has *no* recording MBIDs — its tracks are synthesised from
`GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed `GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed
lookup on an untagged library resolves to nothing and Play queues lookup on an untagged library resolves to nothing and Play queues
nothing while looking entirely correct. nothing while looking entirely correct. Those synthesised tracks carry
`GetFilePathsByRecordingMBIDs` is the catalog-only fallback and the their `FilePath` from the same rows, so they populate `filePaths`
third member of the `GetFilePathsBy…` family: one query, paths only, directly and cost no lookup at all; the batched
grouped so the caller keeps the tracklist's order. It exists rather `GetFilePathsByRecordingMBIDs` is what answers for a *catalog*
than a lookup by track id because **`MBTrack.LocalID` is declared and tracklist. It is the third member of the `GetFilePathsBy…` family: one
nothing in the backend ever writes it**. query, paths only, grouped so the caller keeps the tracklist's order.
It exists rather than a lookup by track id because **`MBTrack.LocalID`
is declared and nothing in the backend ever writes it**.
**How much of an album is here is a question the files can answer.** **How much of an album is here is a question the files can answer.**
`ownership()` above counts the *displayed* tracklist, which for a `ownership()` above counts the *displayed* tracklist, which for a
library copy is a tautology — every local track is `inLibrary: true`, library copy is a tautology — every local track has a file, so owned
so owned always equals total and "do I have all of this" had no local always equals total and "do I have all of this" had no local answer.
answer. The album page therefore asked MusicBrainz, and The album page therefore asked MusicBrainz, and
`BrowseReleases` is the most expensive call the app makes: releases `BrowseReleases` is the most expensive call the app makes: releases
plus every version's full tracklist, on a 1 req/s limiter shared with plus every version's full tracklist, on a 1 req/s limiter shared with
`PrefetchReleases`, which fires up to eight when an artist page `PrefetchReleases`, which fires up to eight when an artist page
@@ -990,8 +1400,7 @@ renders.
The denominator was already on disk. `metadata` has read the "5/12" The denominator was already on disk. `metadata` has read the "5/12"
totals off every file since forever (`m.Track()`, `m.Disc()`) and totals off every file since forever (`m.Track()`, `m.Disc()`) and
discarded them; they persist to discarded them; they persist to `audio_files.total_tracks` now, and
`release_group_recordings.total_tracks` now, and
`GetAlbumCompleteness` sums them. **A complete, MBID-matched album `GetAlbumCompleteness` sums them. **A complete, MBID-matched album
makes no catalog call at all** — identity from the MBID, tracklist from makes no catalog call at all** — identity from the MBID, tracklist from
the tags, which between them are what the browse was being spent on. the tags, which between them are what the browse was being spent on.
@@ -1010,9 +1419,30 @@ Owned counts *distinct track numbers* for the same reason in reverse:
this app detects duplicates, and counting two files of track 3 twice this app detects duplicates, and counting two files of track 3 twice
would report a short album as complete. would report a short album as complete.
What tags cannot give is *which* tracks are missing, only how many — so **Where the tags have no total, the catalog does.** `explore_index`
an incomplete album still browses, and that is now the exception rather carries a per-release-group `total_tracks` — ~2 bytes across 400,677
than every album load. Two smaller consequences: existing databases rows, about 800 kB of artifact — and `completenessAnswer()` merges the
two: the numerator stays local (distinct track numbers on disk) and
only the denominator is borrowed, because a catalog total describes the
canonical release while the files' own total, where they declare one,
describes the release the user actually has. Zero still means "the
catalog does not say", so an album neither side can total keeps the
plain tick.
Two rules hold up the column itself. **It is counted before the
popularity filter**: `cmd/indexbuild` counts the canonical dump's rows
per kept release, and counting only the *kept recordings* would say "9"
about a twelve-track album whose other three nobody has played — the
same confident lie that kept whole tracklists out of the artifact.
And **adding a column to the importer's SELECT is how you break every
artifact already published**, so `artifactHasTotals()` asks the
attached artifact whether the column is there (on the writer, where
`core` is attached) and selects a literal `0` when it is not — the same
shape as the encoding probe beside it.
What neither side can give is *which* tracks are missing, only how many
— so an incomplete album still browses, and that is now the exception
rather than every album load. Two smaller consequences: existing databases
read "unknown" until a rescan repopulates the column (which degrades to read "unknown" until a rescan repopulates the column (which degrades to
exactly the old behaviour, so nothing breaks), and our own `tagwriter` exactly the old behaviour, so nothing breaks), and our own `tagwriter`
writes track and disc *numbers* but not totals, so autotagging a folder writes track and disc *numbers* but not totals, so autotagging a folder
@@ -1095,6 +1525,27 @@ missing half; `catalogFailed` is the only route to `unavailable` now,
and the timer is a 60 s backstop for a genuine hang rather than the and the timer is a 60 s backstop for a genuine hang rather than the
verdict. verdict.
**Activating a row plays the list the row is in, from that row.** A
double-click — and Play on a single row's context menu — queues the
list as *displayed* with `startIndex` on that row, not a queue of one
track that stops when the song ends. The two playlist views and
`cover-grid`'s album dropdown always did this; the album page and
`track-list` did not, so playing anything from the two largest
tracklists in the app discarded the album around it. Three rules come
with it. **A menu asks how much is selected**: one row is a position
and means "from here", several rows are an explicit choice of *those*
tracks and become the queue on their own (which is also the only case
where `shuffleStart` still applies, since no one row was named as the
place to start). **The index is into the paths, not into the rows**
`explore-album-details` queues `ownedFilePaths()` and its dimmed rows
are not in it, so an index taken from the tracklist starts an album
somewhere else entirely, or past its end. And **the index is looked up
when it is used, not remembered**: selection keys are file paths
because those survive a re-sort, a re-filter and a refetch, and an
index survives none of the three — `displayIndexOf` is that lookup,
against `cachedSortedTracks`, which is the only order the user can see
and therefore the only one they can mean.
**Ask for what the caller uses, once.** "Play this artist" resolved **Ask for what the caller uses, once.** "Play this artist" resolved
file paths with one `GetAlbumTracks` per album, sequentially, and every file paths with one `GetAlbumTracks` per album, sequentially, and every
one of the four sites doing that asked for whole track rows to read one of the four sites doing that asked for whole track rows to read
@@ -1228,6 +1679,28 @@ by the three places that need them (the default widths, the
normaliser, and the resize handles' positions), because they were normaliser, and the resize handles' positions), because they were
written out separately and that is how they came to disagree. written out separately and that is how they came to disagree.
**A phone draws one column of two lines, and that is a column set
rather than a second row template.** Measured on the device: at 424 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
— "Duration" did not fit its own header. The columns were never too
wide; there were too many of them. `PHONE_COLUMN_IDS` is `titleArtist`
(title over artist, sharing the row's whole width) plus the duration, so
the row, the delegated events, the selection semantics, the playing
marker and the virtualizer are all untouched: from their side only the
number of columns changed. Three rules come with it. **The row height
lives in two places and they must agree** — `PHONE_ROW_HEIGHT` and the
CSS rule — because the virtualizer positions rows from that number, so a
taller row overlaps its neighbour. **What is drawn and what can be
sorted are different questions**: the page header's sort list is built
from `configuredColumns`, or a phone (which has no column headers
either) could sort by nothing but title and duration. And **a phone's
widths are neither loaded nor saved**: `loadColumnWidths` is keyed by
column *id* and fills a gap with the minimum, so the stacked column —
which nothing can ever have saved a width for — came out at 148 px
beside a duration column of 236, and saving would have replaced the
width the user dragged on a desktop for the same id.
**The default columns are declared twice and must agree.** **The default columns are declared twice and must agree.**
`tracklist.DefaultColumns` is what a fresh install persists; `tracklist.DefaultColumns` is what a fresh install persists;
`DEFAULT_COLUMN_IDS` in `track-list/columns.ts` is what the list draws `DEFAULT_COLUMN_IDS` in `track-list/columns.ts` is what the list draws
@@ -1519,6 +1992,21 @@ frontend would receive (`backend/queue/emit_test.go` is the model).
`events.Deliver` is the same call returning an error instead of `events.Deliver` is the same call returning an error instead of
dropping, and has one legitimate caller — `/__test/emit`. dropping, and has one legitimate caller — `/__test/emit`.
**Naming the Wails application costs cgo, so exactly two files may.**
v3's `application` package is GTK/WebKit bindings on Linux, and
`cmd/indexbuild` / `cmd/indexexport` are built in a plain `golang`
container with `CGO_ENABLED=0` — the index workflow says so and it is
the one job that must not fail, since it owns the ~205 GB checkpoint.
So the single `app.Event.Emit` lives in `backend/events/runtime_wails.go`
under `//go:build !indexbuild` (with `runtime_indexbuild.go` returning
`ErrNoRuntime`, which is what the app itself returns before Run), and
`explore`'s `ServiceStartup` — the only other thing in that dependency
tree naming `application` — sits in `backend/explore/servicestartup.go`
under the same tag. `TestIndexToolsDoNotImportWails` walks the dependency
graph with `go list -deps -tags indexbuild` and is what keeps it that
way; a `ServiceStartup` hook added to a package the index tools import
is the way this comes back.
## Code Generation ## Code Generation
Two generators run via `go generate ./...` (or `make generate`): Two generators run via `go generate ./...` (or `make generate`):
@@ -1539,13 +2027,31 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
two in step or semantic-release will decline to release something the two in step or semantic-release will decline to release something the
check accepted. check accepted.
`.releaserc.yml` is a complete semantic-release config that **nothing `.releaserc.yml` **is** what runs now, from `release.yml`, and it is why
currently runs** — no workflow invokes it, and `CHANGELOG.md` is not the commit grammar is load-bearing rather than decorative: a merge to
being written by it. That is deliberate for now (wiring it means pushing `main` whose commits are all `chore`/`ci`/`docs` releases nothing, and a
tags, committing a changelog back, and interacting with the three mistyped `feat` ships a minor version. `make release-dry` answers "what
publish workflows); it is recorded here rather than implied, because would this merge release" without pushing.
this file claimed for five phases that commitlint gated CI and that
semantic release ran, and neither was true. **`@semantic-release/github` is not in that config and must not be.**
Gitea's API is `/api/v1` and is not GitHub's surface, so
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
`POST`, which is the whole of the Gitea-shaped work. The community
plugin (`@saithodev/semantic-release-gitea`) was considered and
rejected: last published 2022, on `got@10`, declaring no peer
dependency on semantic-release at all.
Two things in it fail *silently* and are therefore pinned with their
reasons. **The notes come from `CHANGELOG.md`, not from an argument**:
release notes are rendered commit messages — arbitrary text carrying
backticks, quotes and `$` — so templating `${nextRelease.notes}` into
`publishCmd` would be a shell injection whose input is the commit log.
And **`conventional-changelog-conventionalcommits` is held at 9**,
because at 10 it is quietly incompatible with the writer
`release-notes-generator@14` pulls in: every release note renders as a
bare `## 0.0.1 (date)` heading with no sections and no commits beneath
it, no step fails, and the release ships with an empty body. Check the
rendered notes, never the exit code.
## Testing ## Testing
@@ -1554,14 +2060,97 @@ Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
## Git Workflow ## Git Workflow
Feature branches and PRs are the norm, but direct pushes to `main` are allowed. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite. Feature branches and PRs are the only way in: **`main` is a protected
branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
+ `CI / e2e*` as required status checks), so a direct push is rejected by
the pre-receive hook. This file said otherwise for a long time. Tags are
*not* protected, which is what lets `release.yml` push one.
Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
## CI ## CI
Four workflows in `.gitea/workflows/`. Three of them package and Seven workflows in `.gitea/workflows/`. Five of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`); only publish (`arch-package`, `homebrew-formula`, `index-artifact`,
`ci.yml` gates, and it is the one to look at when deciding whether a `android-apk`, `desktop-assets`); `release.yml` decides *whether* four of
push was healthy. those run at all; only `ci.yml` gates, and it is the one to look at when
deciding whether a push was healthy.
**`release.yml` is the entry point for all of it.** On every push to
`main` it reads the Conventional Commits since the last tag and, if any
is releasable, writes the changelog, pushes the tag and creates the Gitea
release whose body is that changelog section. `arch-package`,
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
`v*`, so **the tag push is what starts them** — nothing is released by
hand any more.
Four things about it are load-bearing:
- **The tag is pushed with a user PAT, not the Actions token.** Gitea,
like GitHub, does not start a workflow from a ref pushed by a
workflow's own token (go-gitea#33123). The token is what decides this,
so `PACKAGE_TOKEN` is handed to semantic-release as the
`repositoryUrl` credential and the push is attributed to a person.
- **That same limitation is used deliberately, once.** semantic-release
calls the first release of a tagless repo `1.0.0` and offers no way to
say otherwise, so a `v0.0.0` floor tag is what makes the first release
`0.0.1` — and it is pushed with the *Actions* token precisely so it
triggers nothing. All four publishers additionally skip `v0.0.0`
explicitly, cleanly rather than by failing, because a floor is not a
shipment.
- **The release page is the changelog, and that follows from the branch
protection.** `@semantic-release/git` would push a `chore(release):`
commit back to `main`, which the pre-receive hook rejects — *after* the
tag had been pushed, leaving a tagged release the run then reports as
failed. Whitelisting the CI user was the alternative and was declined:
it weakens a protection someone set on purpose and lets a bot push to
`main` without the checks every human PR passes. So the plugin is
absent, `@semantic-release/changelog` writes to a gitignored
`.release-notes.md` purely to carry the notes into
`scripts/gitea-release.sh`, and `CHANGELOG.md` is a signpost to the
releases page rather than a file that would silently stop updating.
The workflow keeps its `chore(release):` guard anyway, for the day
someone adds the plugin back.
- **An asset upload waits for the release to exist.** 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 a
release id to attach to. `scripts/release-asset.sh` polls for it. The
capacity-1 runner serialises things enough that this would usually work
by accident, which is the worst kind of bug.
**Releases restarted at `0.0.1`, which is a downgrade on every channel.**
pacman and Homebrew both silently offer no upgrade from the old `1.x`,
and Android refuses the install outright — its remedy is an uninstall
that takes the user's library. This was chosen over pacman's `epoch` and
over offsetting `versionCode`, on the grounds that both are permanent and
a reinstall is once. `packaging/homebrew/README.md` and
`docs/android-release.md` say so where a user would look.
**`desktop-assets.yml` publishes Linux and nothing else, and macOS is not
an oversight.** `GOOS=darwin CGO_ENABLED=0` fails at
`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin
backend is Objective-C behind cgo, so a `.app` needs a macOS host and the
runner is a Linux container. That is exactly why the Homebrew formula
builds from source on the user's own Mac. Windows *does* cross-compile
cleanly (`GOOS=windows CGO_ENABLED=0`, a couple of seconds — oto uses
WinMM through `x/sys`, sqlite is modernc's pure-Go driver, WebView2 is
COM syscalls, MPRIS is `linux && !android`-tagged) and is deliberately
not published: no Windows build of this app has ever been *run*, and no
tier here can exercise one.
**`android-apk.yml` is the one that can lose something irrecoverable.** It builds the signed
`arm64-v8a` APK (the only ABI Android can run this app on — see
`app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is
readable without credentials — the reason Obtainium can poll a plain
URL. Android refuses to update an app whose signing certificate
changed, and the only remedy is an uninstall that takes the user's
library with it, so the job **refuses to build** without the keystore
secret rather than falling through to Gradle's debug-key default, and
**refuses to publish** an artifact whose certificate says `CN=Android
Debug`. It is deliberately not a job in `ci.yml`: that workflow runs on
every branch push, this one takes tens of minutes on a cold cache, and
the runner has capacity 1. `docs/android-release.md` is the operating
document.
Two jobs, both in an `ubuntu:24.04` container: Two jobs, both in an `ubuntu:24.04` container:
@@ -1643,11 +2232,47 @@ four bit the packaging recipes:
**`build/`'s platform metadata is generated from `build/config.yml`.** **`build/`'s platform metadata is generated from `build/config.yml`.**
`wails3 task common:update:build-assets` rewrites `Info.plist`, the `wails3 task common:update:build-assets` rewrites `Info.plist`, the
`.desktop` template, `nfpm.yaml` and the Windows manifest from that `.desktop` template, `nfpm.yaml` and the Windows manifest from that
one file — so a hand edit to any of them is lost on the next refresh, one file — so a hand edit to any of them is lost on the next refresh.
and the two fields it does *not* own (nfpm's `homepage` and `license`) nfpm's `homepage` and `license` say in place that the refresh does not
say so in place. That refresh also regenerates `build/ios/` and own them, and **that comment is wrong**: a refresh reset them to
`build/android/`, which this repo does not carry: they are gitignored `https://wails.io` and `MIT`. Re-check those two after any refresh.
rather than deleted-and-rediscovered, and their `includes:` entries
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the **That refresh does not touch the mobile trees**, contrary to what this
file said for five phases. `update build-assets` extracts only
`updatable_build_assets` (darwin/ios/linux/windows); `build/android/`
and `build/ios/` come from `generate build-assets`, which rewrites the
whole of `build/`. So `build/android/` is **committed and hand-edited
like source** — it was generated once into a scratch directory and
copied across (plan 015), it carries one deliberate edit to its
`Taskfile.yml`, and only its output is gitignored. `build/ios/` is
still not carried and its `includes:` entry is still dropped.
**Its `MainActivity` owns the safe area, because `targetSdk 35` does
not leave that to the theme.** Android 15 lays every app out
edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the
scaffold's theme sets, and the WebView is `match_parent`, so the page's
bottom band — the transport and, on a phone, the tab bar — would be
drawn under the gesture bar. `applyWindowInsets()` pads the container by
`systemBars | displayCutout | ime` and returns the insets rather than
consuming them; the window background is black to match the app's own
ramp, since that padding is what shows through. It is **pre-emptive**:
the phone this was checked against is Android 14, where the system still
insets the window, and the enforcement applies to an app *running on*
15. No browser tier can see this class of fault either way — a viewport
has no system bars.
**And a device is an engine, not just a screen.** The phone this app was
first run on renders in **Chrome 113** — two years behind every browser
any other tier uses — at a 424x439 CSS px viewport. It has `:has()`,
`color-mix()` and `dialog.showModal()`; it does **not** have relaxed CSS
nesting (Chrome 120, so a nested rule beginning with a bare element
selector is silently dropped), the Popover API (114, which Web Awesome's
popups set `popover="manual"` for), `light-dark()` or relative colour
syntax. So "it renders at that size in Chromium" is not evidence about
the phone, and resizing a spec cannot recover the missing signal. `make
android-inspect` forwards the WebView's devtools socket and `make
android-eval` asks the real page — raw CDP, because `connectOverCDP`
calls `Browser.setDownloadBehavior` and a WebView refuses it.
`build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is *metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version. stamped at link time from the packaging recipe's git-derived version.
+83 -5
View File
@@ -38,6 +38,64 @@ dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
dev-logs: ## Tail the headless app log dev-logs: ## Tail the headless app log
@tail -f .dev/app.log @tail -f .dev/app.log
# ---------------------------------------------------------------- #
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
# android-tier.md for which of these to reach for and why a failure #
# here looks like nothing at all. #
# ---------------------------------------------------------------- #
# The NDK is pinned: r26d is what the pipeline is built and checked
# against, and newer NDKs have broken Wails' Android build before.
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
# does not — hence the separate default.
ANDROID_SDK ?= $(HOME)/Android/Sdk
ANDROID_NDK ?= /opt/android-ndk
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
# `package`, not `package:fat`: x86_64 Android cannot run this app at
# all (modernc's raw lstat vs Android's seccomp -- see
# android-tier.md), so the second ABI was ~31 MB that could not run
# anywhere. app/build.gradle's abiFilters says the same thing to
# Gradle; both have to agree or the .so is built and then dropped.
android: build-frontend ## Build the arm64 APK into bin/
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
android-emulator: ## Boot the emulator headless in the background and wait for it
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
android-install: ## Install bin/yellowjacket.apk onto the running emulator
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
android-launch: ## Force-stop, clear logcat, and start the app
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
android-logs: ## Tail logcat, filtered to the app's own tags
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
# The only tier that can see the platform is the one you can look at.
android-screenshot: ## Grab the device screen (OUT=<path>)
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
# The page's own answer, from the engine that is really rendering it.
# Needs the debug build installed (it is a sibling id, so it does not
# disturb the release app): see scripts/android-eval.mjs.
android-inspect: ## Forward the device WebView's devtools socket
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
# "Did it start" is the wrong question — a crash-looping app starts
# several times a second. This asserts the *same pid* is still there.
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
# Seeds are produced by *running the app* — driving the real AddLibrary # Seeds are produced by *running the app* — driving the real AddLibrary
# binding and waiting for the real scan — never by hand-writing a # binding and waiting for the real scan — never by hand-writing a
# config.toml and DB rows. A hand-built seed is a second description # config.toml and DB rows. A hand-built seed is a second description
@@ -121,10 +179,12 @@ bindings-check: ## Fail if the generated bindings are stale
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs @cd frontend && node scripts/check-css-literals.mjs
# .pi/ documents commands, and a skill that documents a command wrongly # .pi/ and CLAUDE.md document commands, and a doc that documents a
# is worse than no skill: an agent runs it confidently. Every command # command wrongly is worse than no doc: an agent runs it confidently.
# in there is a make target on purpose, so this is checkable. # Every command in them is a make target on purpose, so this is
skill-check: ## Fail if .pi/ documents a make target that does not exist # checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
# two harnesses cannot drift onto two descriptions of one project.
skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md is not a symlink
@./scripts/skill-check.sh @./scripts/skill-check.sh
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long # Conventional Commits, which CLAUDE.md claimed CI enforced for a long
@@ -132,6 +192,24 @@ skill-check: ## Fail if .pi/ documents a make target that does not exist
commit-check: ## Fail if a commit subject is not a Conventional Commit commit-check: ## Fail if a commit subject is not a Conventional Commit
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE)) @./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
# What a merge to main would release, without releasing it. Reads the
# same .releaserc.yml CI does, so "why did that not cut a version" is
# answerable locally instead of by pushing and watching. Needs no
# credentials: --dry-run neither tags nor publishes.
#
# The pins must stay identical to release.yml's, which is where the note
# on holding the conventionalcommits preset at 9 lives -- at 10 the
# release notes come out empty with everything green.
release-dry: ## Print the version a merge to main would release
@npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
-p @semantic-release/release-notes-generator@14 \
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release --dry-run --no-ci
# v3 generates TypeScript into frontend/bindings/, nested by Go import # v3 generates TypeScript into frontend/bindings/, nested by Go import
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the # path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
# constant prefix, so a call site imports '@go/library/library.js'. # constant prefix, so a call site imports '@go/library/library.js'.
@@ -147,7 +225,7 @@ bindings: ## Regenerate frontend/bindings from the bound Go services
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \ sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
perf perf-compare \ perf perf-compare \
ui-test ui-watch ui-visual ui-visual-update ui-setup \ ui-test ui-watch ui-visual ui-visual-update ui-setup \
bindings bindings-check skill-check commit-check bindings bindings-check skill-check commit-check release-dry
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR: # Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so # on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
+1
View File
@@ -15,6 +15,7 @@ includes:
windows: ./build/windows/Taskfile.yml windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml linux: ./build/linux/Taskfile.yml
android: ./build/android/Taskfile.yml
tasks: tasks:
build: build:
+28 -5
View File
@@ -191,6 +191,24 @@ func NewYellowJacketApp(
yjApp.library.SetJobRegistry(yjApp.jobs) yjApp.library.SetJobRegistry(yjApp.jobs)
yjApp.explore.SetJobRegistry(yjApp.jobs) yjApp.explore.SetJobRegistry(yjApp.jobs)
// Whether this connection is one to spend ~0.6 GB of catalog on
// (plan 016 B4). The probe is injected from here because `explore` is
// imported by `cmd/indexbuild`, which must not link Wails: naming
// `application` there is what `TestIndexToolsDoNotImportWails`
// forbids.
//
// `application.Mobile`, not `application.Android`: the latter exists
// only under the `android` build tag, while `Mobile` is the portable
// name whose desktop implementation is a stub returning "" — which
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
// one; this is the same call by the name every build has.
yjApp.explore.SetNetworkPolicy(
func() explore.Network {
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
},
yjApp.appConfig.GetAllowMeteredCatalogDownload,
)
// Let the release prefetch skip albums the user already owns in // Let the release prefetch skip albums the user already owns in
// full — those open with no catalog call at all, so warming their // full — those open with no catalog call at all, so warming their
// tracklists spends the most expensive request in the app on // tracklists spends the most expensive request in the app on
@@ -484,20 +502,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// Register playback finished handler to drive queue auto-advance. // Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere). // Initialize OS media controls (MPRIS on desktop Linux, a
// MediaSession on Android, no-op elsewhere). The callbacks are the
// same on every platform; only what delivers them differs.
yj.mediaControls = mediacontrols.NewHandler(yj.logger) yj.mediaControls = mediacontrols.NewHandler(yj.logger)
if err := yj.mediaControls.Init(mediacontrols.Callbacks{ if err := yj.mediaControls.Init(mediacontrols.Callbacks{
OnPlay: yj.queue.Play, OnPlay: yj.queue.Play,
OnPause: func() { OnPause: func() {
if err := yj.player.Pause(); err != nil { if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Pause failed", "err", err) yj.logger.Warn("Media controls Pause failed", "err", err)
} }
}, },
OnPlayPause: func() { OnPlayPause: func() {
if yj.player.IsPlaying() { if yj.player.IsPlaying() {
if err := yj.player.Pause(); err != nil { if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err) yj.logger.Warn(
"Media controls PlayPause(pause) failed", "err", err,
)
} }
} else { } else {
yj.queue.Play() yj.queue.Play()
@@ -505,14 +527,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
}, },
OnStop: func() { OnStop: func() {
if err := yj.player.Pause(); err != nil { if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Stop failed", "err", err) yj.logger.Warn("Media controls Stop failed", "err", err)
} }
}, },
OnNext: yj.queue.Next, OnNext: yj.queue.Next,
OnPrevious: yj.queue.Previous, OnPrevious: yj.queue.Previous,
OnSeek: func(positionSec int) { OnSeek: func(positionSec int) {
if err := yj.player.Seek(positionSec); err != nil { if err := yj.player.Seek(positionSec); err != nil {
yj.logger.Warn("MPRIS Seek failed", "err", err) yj.logger.Warn("Media controls Seek failed", "err", err)
} }
}, },
OnVolume: func(vol float64) { OnVolume: func(vol float64) {
@@ -522,6 +544,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
), ),
) )
}, },
OnDuck: yj.player.SetDuck,
}); err != nil { }); err != nil {
yj.logger.Error( yj.logger.Error(
"Failed to initialize media controls", "Failed to initialize media controls",
+29 -30
View File
@@ -328,43 +328,42 @@ func (a *Applier) Apply(
func (a *Applier) syncDBMBIDs( func (a *Applier) syncDBMBIDs(
ctx context.Context, tr TrackApply, cand Candidate, ctx context.Context, tr TrackApply, cand Candidate,
) error { ) error {
// Look up recording row via audio_file.
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err != nil {
return fmt.Errorf("get audio_file: %w", err)
}
if tr.CandidateTrack.MBID != "" { if tr.CandidateTrack.MBID != "" {
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{ if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true}, RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
ID: af.RecordingID, ID: tr.Local.AudioFileID,
}); err != nil { }); err != nil {
return fmt.Errorf("set recording mbid: %w", err) return fmt.Errorf("set recording mbid: %w", err)
} }
} }
if cand.ReleaseGroupMBID != "" { if cand.ReleaseGroupMBID == "" {
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID) return nil
if err == nil && rgID > 0 { }
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ID: rgID,
}); err != nil {
return fmt.Errorf("set release group mbid: %w", err)
}
// Stamp the release-group's original-release year too — // The album is reached through the file rather than through two
// this is what the tracklist / smart-playlist year rule // join tables; SetFileAlbumMBID takes the file id and does the
// surfaces by default once the user accepts a candidate. // lookup in one statement.
if year := parseYear(cand.OriginalDate); year > 0 { if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
if err := a.q.SetReleaseGroupOriginalYear( Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{ ID: tr.Local.AudioFileID,
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true}, }); err != nil {
ID: rgID, return fmt.Errorf("set album mbid: %w", err)
}, }
); err != nil {
return fmt.Errorf("set release group original year: %w", err) // Stamp the album's original-release year too - this is what the
} // tracklist and the smart-playlist year rule surface by default
// once the user accepts a candidate.
if year := parseYear(cand.OriginalDate); year > 0 {
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err == nil && af.AlbumID.Valid {
if err := a.q.SetAlbumOriginalYear(
ctx, sqlcgen.SetAlbumOriginalYearParams{
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
ID: af.AlbumID.Int64,
},
); err != nil {
return fmt.Errorf("set album original year: %w", err)
} }
} }
} }
+10 -40
View File
@@ -2,7 +2,6 @@ package autotag_test
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"sync" "sync"
"testing" "testing"
@@ -87,51 +86,22 @@ func seedAudioFiles(
q := db.Queries q := db.Queries
ctx := db.Ctx ctx := db.Ctx
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: "Test Album",
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
out := make([]sqlcgen.AudioFile, 0, len(paths)) out := make([]sqlcgen.AudioFile, 0, len(paths))
for i, p := range paths { for i, p := range paths {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ id := database.InsertTestTrack(t, db, database.TestTrack{
Name: p, FilePath: p,
ArtistCreditID: ac.ID, Title: p,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true}, Artist: "Test Artist",
Album: "Test Album",
TrackNumber: int64(i + 1),
LengthMs: 100000,
GroupKey: groupKey,
}) })
if err != nil {
t.Fatalf("create recording: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{ af, err := q.GetAudioFile(ctx, id)
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: p,
LengthMilliseconds: 100000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: p,
LibraryID: 0,
GroupKey: groupKey,
TagStatus: "untagged",
})
if err != nil { if err != nil {
t.Fatalf("create audio file: %v", err) t.Fatalf("read seeded audio file: %v", err)
} }
out = append(out, af) out = append(out, af)
+9 -9
View File
@@ -56,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup(
} }
// ResolveLocal returns candidate releases sourced from the local // ResolveLocal returns candidate releases sourced from the local
// DB's release_groups rows (filtered to those carrying an MBID) // DB's albums (filtered to those carrying an MBID)
// whose normalized name matches the tagging item's album name. // whose normalized name matches the tagging item's album name.
// No network calls. Candidates carry all tracks flat; caller runs // No network calls. Candidates carry all tracks flat; caller runs
// AlignTracks on each to produce per-track alignments. // AlignTracks on each to produce per-track alignments.
@@ -67,7 +67,7 @@ func (r *LocalResolver) ResolveLocal(
return nil, nil return nil, nil
} }
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName) rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName)
if err != nil { if err != nil {
return nil, fmt.Errorf("list local candidates: %w", err) return nil, fmt.Errorf("list local candidates: %w", err)
} }
@@ -84,12 +84,12 @@ func (r *LocalResolver) ResolveLocal(
continue continue
} }
if _, ok := byID[row.ReleaseGroupID]; !ok { if _, ok := byID[row.AlbumID]; !ok {
byID[row.ReleaseGroupID] = localCandidate(row) byID[row.AlbumID] = localCandidate(row)
} }
tracksByID[row.ReleaseGroupID] = append( tracksByID[row.AlbumID] = append(
tracksByID[row.ReleaseGroupID], tracksByID[row.AlbumID],
CandidateTrack{ CandidateTrack{
Position: int(row.TrackNumber), Position: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber), DiscNumber: int(row.DiscNumber),
@@ -113,15 +113,15 @@ func (r *LocalResolver) ResolveLocal(
// localCandidate converts one sqlc row (minus track-level fields) // localCandidate converts one sqlc row (minus track-level fields)
// into a Candidate shell. Track fields and alignments are filled // into a Candidate shell. Track fields and alignments are filled
// in by the caller. // in by the caller.
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate { func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate {
date := "" date := ""
if row.Year > 0 { if row.Year > 0 {
date = fmt.Sprintf("%04d", row.Year) date = fmt.Sprintf("%04d", row.Year)
} }
mbid := "" mbid := ""
if row.ReleaseGroupMbid.Valid { if row.AlbumMbid.Valid {
mbid = row.ReleaseGroupMbid.String mbid = row.AlbumMbid.String
} }
return &Candidate{ return &Candidate{
+11 -64
View File
@@ -2,13 +2,11 @@ package autotag_test
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"testing" "testing"
"yellowjacket/backend/autotag" "yellowjacket/backend/autotag"
"yellowjacket/backend/database" "yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
) )
// seedAlbum drops a minimal release_group + recordings + audio_files // seedAlbum drops a minimal release_group + recordings + audio_files
@@ -33,70 +31,19 @@ type seededTrack struct {
func seed(t *testing.T, db *database.DB, album seededAlbum) { func seed(t *testing.T, db *database.DB, album seededAlbum) {
t.Helper() t.Helper()
ctx := db.Ctx
q := db.Queries
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert ac: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: album.albumName,
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
if album.releaseMBID != "" {
if _, err := db.ExecContext(
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
album.releaseMBID, rg.ID,
); err != nil {
t.Fatalf("set rg mbid: %v", err)
}
}
for _, tr := range album.tracks { for _, tr := range album.tracks {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ database.InsertTestTrack(t, db, database.TestTrack{
Name: tr.title, FilePath: tr.filePath,
ArtistCreditID: ac.ID, Title: tr.title,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true}, Artist: "Test Artist",
Album: album.albumName,
AlbumMBID: album.releaseMBID,
RecordingMBID: tr.recordingMBID,
TrackNumber: int64(tr.trackNumber),
LengthMs: tr.lengthMillis,
LibraryID: album.libraryID,
GroupKey: album.groupKey,
}) })
if err != nil {
t.Fatalf("create recording: %v", err)
}
if tr.recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
tr.recordingMBID, rec.ID,
); err != nil {
t.Fatalf("set recording mbid: %v", err)
}
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: tr.filePath,
LengthMilliseconds: tr.lengthMillis,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: tr.filePath,
LibraryID: album.libraryID,
GroupKey: album.groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
} }
if _, err := db.ExecContext(` if _, err := db.ExecContext(`
+2
View File
@@ -19,6 +19,8 @@ const applyJobPrefix = "autotag:"
// registry gets progress, cancel and the global indicator for free; the // registry gets progress, cancel and the global indicator for free; the
// three subsystems that lacked them were the three that were not // three subsystems that lacked them were the three that were not
// registered. // registered.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (s *Service) SetJobRegistry(reg *jobs.Registry) { func (s *Service) SetJobRegistry(reg *jobs.Registry) {
s.mu.Lock() s.mu.Lock()
s.jobsReg = reg s.jobsReg = reg
+32 -149
View File
@@ -3,12 +3,12 @@ package autotagservice
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"testing" "testing"
"yellowjacket/backend/autotag" "yellowjacket/backend/autotag"
"yellowjacket/backend/database" "yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
) )
// newTestService builds a Service with just enough wired up for // newTestService builds a Service with just enough wired up for
@@ -36,62 +36,18 @@ func newTestService(t *testing.T, db *database.DB) *Service {
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) { func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
t.Helper() t.Helper()
ctx := db.Ctx
q := db.Queries
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) { addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
ac, err := q.UpsertArtistCredit(ctx, artist) database.InsertTestTrack(t, db, database.TestTrack{
if err != nil { FilePath: filePath,
t.Fatalf("upsert artist credit: %v", err) Title: title,
} Artist: artist,
Album: album,
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ AlbumArtist: albumArtist,
Name: title, TrackNumber: int64(trackNum),
ArtistCreditID: ac.ID, LengthMs: 200000,
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true}, LibraryID: libraryID,
GroupKey: groupKey,
}) })
if err != nil {
t.Fatalf("create recording: %v", err)
}
if album != "" {
albumArtistAC, err := q.UpsertArtistCredit(ctx, albumArtist)
if err != nil {
t.Fatalf("upsert album artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: album,
AlbumArtistCreditID: sql.NullInt64{Int64: albumArtistAC.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert release group: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(
ctx,
sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
},
); err != nil {
t.Fatalf("link release group recording: %v", err)
}
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: filePath,
LengthMilliseconds: 200000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: filePath,
LibraryID: libraryID,
GroupKey: groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
} }
addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1) addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1)
@@ -113,58 +69,22 @@ func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryI
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) { func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
t.Helper() t.Helper()
ctx := db.Ctx for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} {
q := db.Queries database.InsertTestTrack(t, db, database.TestTrack{
FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1),
ac, err := q.UpsertArtistCredit(ctx, "The Beatles") Title: title,
if err != nil { Artist: "The Beatles",
t.Fatalf("upsert artist credit: %v", err) Album: "Abbey Road",
} TrackNumber: int64(i + 1),
LengthMs: 200000,
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ LibraryID: libraryID,
Name: "Abbey Road", GroupKey: groupKey,
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert release group: %v", err)
}
titles := []string{"Come Together", "Something", "Maxwell's Silver Hammer", "Oh! Darling"}
for i, title := range titles {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: title,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}) })
if err != nil {
t.Fatalf("create recording: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}); err != nil {
t.Fatalf("link release group recording: %v", err)
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: groupKey + "/" + title + ".mp3",
LengthMilliseconds: 200000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: title + ".mp3",
LibraryID: libraryID,
GroupKey: groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
} }
if _, err := db.ExecContext(` if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status) INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
VALUES (?, ?, 4, 'Abbey Road', 'The Beatles', 0, 'pending') VALUES (?, ?, 3, 'Abbey Road', 'The Beatles', 0, 'pending')
`, groupKey, libraryID); err != nil { `, groupKey, libraryID); err != nil {
t.Fatalf("insert tagging item: %v", err) t.Fatalf("insert tagging item: %v", err)
} }
@@ -293,20 +213,11 @@ func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) {
db := database.NewTestDB(t) db := database.NewTestDB(t)
if _, err := db.Queries.CreateAudioFileWithGroupKey( database.InsertTestTrack(t, db, database.TestTrack{
db.Ctx, FilePath: "/coherent/01.mp3",
sqlcgen.CreateAudioFileWithGroupKeyParams{ Title: "Track",
FilePath: "/coherent/01.mp3", GroupKey: "g-coherent",
FileTypeID: 0, })
RecordingID: mustCreateRecording(t, db, "Track"),
Basename: "01.mp3",
LibraryID: 0,
GroupKey: "g-coherent",
TagStatus: "untagged",
},
); err != nil {
t.Fatalf("create audio file: %v", err)
}
if _, err := db.ExecContext(` if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status) INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
@@ -328,20 +239,11 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
db := database.NewTestDB(t) db := database.NewTestDB(t)
// A real, live folder — must survive. // A real, live folder — must survive.
if _, err := db.Queries.CreateAudioFileWithGroupKey( database.InsertTestTrack(t, db, database.TestTrack{
db.Ctx, FilePath: "/live/01.mp3",
sqlcgen.CreateAudioFileWithGroupKeyParams{ Title: "Track",
FilePath: "/live/01.mp3", GroupKey: "g-live",
FileTypeID: 0, })
RecordingID: mustCreateRecording(t, db, "Track"),
Basename: "01.mp3",
LibraryID: 0,
GroupKey: "g-live",
TagStatus: "untagged",
},
); err != nil {
t.Fatalf("create audio file: %v", err)
}
if _, err := db.ExecContext(` if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status) INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
@@ -385,22 +287,3 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err) t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err)
} }
} }
func mustCreateRecording(t *testing.T, db *database.DB, title string) int64 {
t.Helper()
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rec, err := db.Queries.CreateRecordingFull(db.Ctx, sqlcgen.CreateRecordingFullParams{
Name: title,
ArtistCreditID: ac.ID,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
return rec.ID
}
+48 -2
View File
@@ -411,9 +411,10 @@ func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error
formats = append(formats, string(f)) formats = append(formats, string(f))
} }
c.Downloads.MinFileSizeMB = prefs.MinSizeMB c.Downloads.MinKbps = prefs.MinKbps
c.Downloads.MaxKbps = prefs.MaxKbps
c.Downloads.PreferredKbps = prefs.PreferredKbps
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
c.Downloads.AllowedFormats = formats c.Downloads.AllowedFormats = formats
if err := c.Save(); err != nil { if err := c.Save(); err != nil {
@@ -620,6 +621,51 @@ func (c *Config) SetQueueFallback(mode string) error {
return nil return nil
} }
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
// catalog may be fetched on a metered connection.
func (c *Config) GetAllowMeteredCatalogDownload() bool {
if c.General == nil {
return false
}
return c.General.AllowMeteredCatalogDownload
}
// SetAllowMeteredCatalogDownload saves the metered-download permission.
//
// There is nothing to validate and nothing to restart: the policy is
// read at the moment a download would start, so turning it on takes
// effect on the next attempt rather than needing this launch to be over.
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.AllowMeteredCatalogDownload = allow
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"AllowMeteredCatalogDownload": allow,
},
)
c.logger.Info(
"metered catalog download permission updated",
"allow", allow,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns. // GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column { func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil { if c.TrackList == nil {
+6
View File
@@ -48,6 +48,12 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
type GeneralConfig struct { type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"` DefaultPage DefaultPage `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"` QueueFallback QueueFallback `toml:"QueueFallback"`
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
// be fetched on a connection the platform calls cellular. It defaults
// to false, which is the whole point: the zero value is the safe one,
// so an existing config with no such key refuses by default rather
// than needing a migration to become careful.
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
} }
// ApplyDefaults fills zero-value fields with sensible defaults. // ApplyDefaults fills zero-value fields with sensible defaults.
+34 -10
View File
@@ -12,7 +12,15 @@ import (
// PathPrefix is the URL path prefix for cover art served by the asset handler. // PathPrefix is the URL path prefix for cover art served by the asset handler.
const PathPrefix = "/covers/" const PathPrefix = "/covers/"
// URLs holds the resolved URL paths for all cover art size variants. // URLs holds the resolved URL paths for a cover's size variants.
//
// Original is the largest variant kept, which is the Large one: the
// full-resolution image is no longer stored. It was 1,134 MB of a
// 1.4 GB covers directory on a real 2,057-album library against 110 MB
// for all three rendered tiers, and nothing rendered it - the grid caps
// at 350 px and the largest tier is 400. The field keeps its name
// because it is what a caller means by "the cover", and the bytes it
// came from are still in the audio file if a bigger one is ever wanted.
type URLs struct { type URLs struct {
Original string Original string
Small string Small string
@@ -36,25 +44,41 @@ func CoversDir() (string, error) {
return filepath.Join(dataDir, dirName), nil return filepath.Join(dataDir, dirName), nil
} }
// SizedFilename derives a sized-variant filename from an original cover art // Suffixes are the size variants a cover is stored as, largest last.
// filename and a size suffix. var Suffixes = []string{"_sm", "_md", "_lg"}
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
func SizedFilename(originalFilename, suffix string) string {
ext := filepath.Ext(originalFilename)
name := strings.TrimSuffix(originalFilename, ext)
return name + suffix + ".jpg" // SizedFilename derives a sized-variant filename from a cover art
// filename and a size suffix. The input may itself be a variant, so
// its suffix is stripped first: SizedFilename("a1b2_lg.jpg", "_sm")
// and SizedFilename("a1b2.jpg", "_sm") both return "a1b2_sm.jpg".
func SizedFilename(filename, suffix string) string {
return BaseName(filename) + suffix + ".jpg"
}
// BaseName strips the extension and any size suffix from a cover art
// filename, leaving the content hash that identifies the cover.
func BaseName(filename string) string {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
for _, suffix := range Suffixes {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
} }
// ResolveURLs converts a cover art filesystem path into URL paths // ResolveURLs converts a cover art filesystem path into URL paths
// for the original and all size variants (small, medium, large). // for the original and all size variants (small, medium, large).
func ResolveURLs(filesystemPath string) URLs { func ResolveURLs(filesystemPath string) URLs {
base := filepath.Base(filesystemPath) base := filepath.Base(filesystemPath)
large := PathPrefix + SizedFilename(base, "_lg")
return URLs{ return URLs{
Original: PathPrefix + base, Original: large,
Small: PathPrefix + SizedFilename(base, "_sm"), Small: PathPrefix + SizedFilename(base, "_sm"),
Medium: PathPrefix + SizedFilename(base, "_md"), Medium: PathPrefix + SizedFilename(base, "_md"),
Large: PathPrefix + SizedFilename(base, "_lg"), Large: large,
} }
} }
+6 -4
View File
@@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
path string path string
// Original is the largest kept variant: the full-resolution
// image is not stored (see URLs).
wantOrig string wantOrig string
wantSm string wantSm string
wantMd string wantMd string
@@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) {
{ {
name: "absolute path", name: "absolute path",
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg", path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
wantOrig: "/covers/a1b2c3d4.jpg", wantOrig: "/covers/a1b2c3d4_lg.jpg",
wantSm: "/covers/a1b2c3d4_sm.jpg", wantSm: "/covers/a1b2c3d4_sm.jpg",
wantMd: "/covers/a1b2c3d4_md.jpg", wantMd: "/covers/a1b2c3d4_md.jpg",
wantLg: "/covers/a1b2c3d4_lg.jpg", wantLg: "/covers/a1b2c3d4_lg.jpg",
@@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) {
{ {
name: "bare filename", name: "bare filename",
path: "abcdef01.png", path: "abcdef01.png",
wantOrig: "/covers/abcdef01.png", wantOrig: "/covers/abcdef01_lg.jpg",
wantSm: "/covers/abcdef01_sm.jpg", wantSm: "/covers/abcdef01_sm.jpg",
wantMd: "/covers/abcdef01_md.jpg", wantMd: "/covers/abcdef01_md.jpg",
wantLg: "/covers/abcdef01_lg.jpg", wantLg: "/covers/abcdef01_lg.jpg",
+20 -181
View File
@@ -5,13 +5,10 @@ import (
"context" "context"
"database/sql" "database/sql"
"embed" "embed"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"path" "path"
"sort"
"strconv"
"strings" "strings"
_ "modernc.org/sqlite" // Register sqlite driver. _ "modernc.org/sqlite" // Register sqlite driver.
@@ -26,9 +23,6 @@ import (
//go:embed sql/schemas/*.sql //go:embed sql/schemas/*.sql
var schemas embed.FS var schemas embed.FS
//go:embed sql/migrations/*.sql
var migrations embed.FS
// DB wraps the SQLite database connection and queries. // DB wraps the SQLite database connection and queries.
// //
// Two handles back a single database file. db is the single-writer // Two handles back a single database file. db is the single-writer
@@ -95,6 +89,14 @@ func NewDB(logger *slog.Logger) (*DB, error) {
return nil, fmt.Errorf("could not apply PRAGMAs: %w", err) return nil, fmt.Errorf("could not apply PRAGMAs: %w", err)
} }
// Before the schema is applied, not after: applySchema is
// CREATE ... IF NOT EXISTS, which no-ops against a table that
// already exists in an older shape. Retiring the stale one first is
// what turns that no-op into a create.
if err := retireStaleTables(dbCtx, db, logger); err != nil {
return nil, err
}
if err := applySchema(dbCtx, db); err != nil { if err := applySchema(dbCtx, db); err != nil {
return nil, err return nil, err
} }
@@ -301,20 +303,19 @@ func (d *DB) ResumeExploreIndexFTS() error {
return nil return nil
} }
// applySchema creates the full schema on a fresh database and brings // applySchema creates the full schema.
// an existing one up to date via sql/migrations.
// //
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS, // The schema files under sql/schemas are CREATE ... IF NOT EXISTS and
// so on a genuinely new database they create every table already at // declare the current, latest shape of every table — so running them
// its current, latest shape — that's the fast path new installs // against a fresh database produces exactly that shape, and running
// take. A database that already has an older shape (e.g. a // them against a database already at that shape does nothing. That is
// tagging_items missing a column a later build added) needs the gap // the whole mechanism; there is no migration chain and no
// closed, which IF NOT EXISTS can't do: it silently no-ops on a // schema_migrations table.
// table that already exists, columns and all. sql/migrations holds //
// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.) // There was one, and it was squashed (see .planning/plans/013): a chain
// for exactly that gap, tracked in schema_migrations so each applies // only earns its keep once real user databases exist in the wild, and
// at most once — see applyMigrations for how a fresh database's // until then it is a second description of the schema that can drift
// already-current tables tolerate replaying them anyway. // from the first — which this project has already been bitten by once.
func applySchema(ctx context.Context, db *sql.DB) error { func applySchema(ctx context.Context, db *sql.DB) error {
dirEntries, err := schemas.ReadDir("sql/schemas") dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil { if err != nil {
@@ -344,171 +345,9 @@ func applySchema(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("could not create explore FTS triggers: %w", err) return fmt.Errorf("could not create explore FTS triggers: %w", err)
} }
if err := applyMigrations(ctx, db); err != nil {
return fmt.Errorf("could not apply migrations: %w", err)
}
// The download subsystem's Want/Request rename reuses table names
// (download_requests names a different table before and after), so
// it cannot be a plain sql/migrations file the way an ADD COLUMN
// migration can; see download_rename_migration.go for why.
if err := migrateDownloadRename(ctx, db); err != nil {
return fmt.Errorf("could not migrate download rename: %w", err)
}
return nil return nil
} }
// schemaMigrationsTable tracks which sql/migrations files have run,
// by their leading numeric prefix.
const schemaMigrationsTable = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`
// applyMigrations runs every sql/migrations file not yet recorded in
// schema_migrations, in filename order (numeric prefix), one
// statement at a time.
//
// Every migration runs on EVERY database, fresh or old — there is no
// "skip on fresh install" branch. A fresh database's tables already
// carry a migration's effect (sql/schemas declares the target shape
// directly), so its statements are expected to sometimes be no-ops
// there: "duplicate column name" from an ALTER TABLE ADD COLUMN is
// tolerated and treated as "already applied", the same way
// createExploreIndexFTSTriggers tolerates "already exists". Any
// other error is fatal. This is deliberately simpler than detecting
// "is this database fresh" — every migration converges both a fresh
// and an upgraded database to the identical final schema (including
// column order — ALTER TABLE ADD COLUMN always appends at the end,
// so sql/schemas must declare a migrated column last too; see the
// comment on tagging_items.sql and the regression test in
// migrations_test.go).
func applyMigrations(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, schemaMigrationsTable); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
dirEntries, err := migrations.ReadDir("sql/migrations")
if err != nil {
return fmt.Errorf("could not read migrations directory: %w", err)
}
sort.Slice(dirEntries, func(i, j int) bool {
return dirEntries[i].Name() < dirEntries[j].Name()
})
for _, dirEntry := range dirEntries {
if dirEntry.IsDir() {
continue
}
version, err := migrationVersion(dirEntry.Name())
if err != nil {
return err
}
applied, err := migrationApplied(ctx, db, version)
if err != nil {
return err
}
if applied {
continue
}
filePath := path.Join("sql/migrations", dirEntry.Name())
sqlContent, err := fs.ReadFile(migrations, filePath)
if err != nil {
return fmt.Errorf("could not read file %s: %w", filePath, err)
}
if err := execMigrationStatements(ctx, db, string(sqlContent)); err != nil {
return fmt.Errorf("error executing migration %s: %w", dirEntry.Name(), err)
}
if _, err := db.ExecContext(
ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, version,
); err != nil {
return fmt.Errorf("record migration %d applied: %w", version, err)
}
}
return nil
}
// execMigrationStatements runs a migration file one statement at a
// time — NOT as one multi-statement Exec — so that one statement
// being a tolerable no-op (ALTER TABLE ADD COLUMN on a fresh
// database) doesn't abort the statements after it in the same file
// (e.g. a trailing CREATE INDEX that a fresh database still needs,
// since sql/schemas deliberately doesn't declare an index on a
// migrated column — see the comment on tagging_items.sql).
//
// Splitting on ";" is safe for the simple ALTER/CREATE TABLE/CREATE
// INDEX statements migrations are expected to contain; it is NOT
// safe for statements embedding a literal semicolon (e.g. a CREATE
// TRIGGER body) — write those with executeContext calls in Go
// instead of a sql/migrations file, the same way the explore FTS
// triggers already are.
func execMigrationStatements(ctx context.Context, db *sql.DB, script string) error {
for stmt := range strings.SplitSeq(script, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.ExecContext(ctx, stmt); err != nil {
if strings.Contains(err.Error(), "duplicate column name") {
continue
}
return fmt.Errorf("statement %q: %w", stmt, err)
}
}
return nil
}
// migrationVersion extracts the leading integer prefix from a
// migration filename, e.g. "0001_tagging_items_synthetic.sql" -> 1.
func migrationVersion(filename string) (int, error) {
prefix, _, ok := strings.Cut(filename, "_")
if !ok {
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
}
version, err := strconv.Atoi(prefix)
if err != nil {
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
}
return version, nil
}
var errMigrationFilename = errors.New(
"migration filename must start with a numeric prefix followed by '_' (e.g. 0001_description.sql)",
)
func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) {
var v int
err := db.QueryRowContext(
ctx, `SELECT version FROM schema_migrations WHERE version = ?`, version,
).Scan(&v)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("check migration %d: %w", version, err)
default:
return true, nil
}
}
// applyPRAGMAs configures SQLite connection settings. Called by both // applyPRAGMAs configures SQLite connection settings. Called by both
// NewDB and NewTestDB to ensure identical behavior. // NewDB and NewTestDB to ensure identical behavior.
func applyPRAGMAs(ctx context.Context, db *sql.DB) error { func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
+34 -107
View File
@@ -312,31 +312,13 @@ func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
// Create prerequisite data: artist_credit, recording, // Create prerequisite data: artist_credit, recording,
// audio_file. // audio_file.
_, err := db.ExecContext( InsertTestTrack(t, db, TestTrack{
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')", FilePath: "/test/music/song.mp3",
) Title: "Test Song",
if err != nil { Artist: "Test Artist",
t.Fatalf("insert artist_credit: %v", err) LengthMs: 180000,
} LibraryID: libID,
})
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) " +
"VALUES (1, 'Test Song', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files "+
"(id, file_path, length_milliseconds, file_type_id, "+
"recording_id, library_id) "+
"VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)",
libID,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
// Create playlist. // Create playlist.
playlist, err := db.Queries.CreatePlaylist( playlist, err := db.Queries.CreatePlaylist(
@@ -442,39 +424,18 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib") db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
// Insert prerequisite recording. // Insert prerequisite recording.
InsertTestTrack(t, db, TestTrack{
FilePath: "/test/track.mp3",
Title: "Track",
Artist: "Test",
LibraryID: libID,
})
// Insert audio file with invalid library_id - should fail FK.
_, err := db.ExecContext( _, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) " +
"VALUES (1, 'Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
// Insert audio file with valid library_id — should succeed.
_, err = db.ExecContext(
"INSERT INTO audio_files "+
"(id, file_path, length_milliseconds, file_type_id, "+
"recording_id, library_id) "+
"VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)",
libID,
)
if err != nil {
t.Fatalf("insert audio_file with valid library: %v", err)
}
// Insert audio file with invalid library_id — should fail FK.
_, err = db.ExecContext(
"INSERT INTO audio_files " + "INSERT INTO audio_files " +
"(id, file_path, length_milliseconds, file_type_id, " + "(id, file_path, length_milliseconds, file_type_id, library_id) " +
"recording_id, library_id) " + "VALUES (2, '/test/song2.mp3', 200000, 0, 999)",
"VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)",
) )
if err == nil { if err == nil {
t.Error( t.Error(
@@ -483,16 +444,16 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
} }
// Count files by library. // Count files by library.
count, err := db.Queries.CountAudioFilesByLibrary( count, err := db.Queries.CountAudioFiles(
db.Ctx, libID, db.Ctx, libID,
) )
if err != nil { if err != nil {
t.Fatalf("CountAudioFilesByLibrary: %v", err) t.Fatalf("CountAudioFiles: %v", err)
} }
if count != 1 { if count != 1 {
t.Errorf( t.Errorf(
"CountAudioFilesByLibrary = %d, want 1", count, "CountAudioFiles = %d, want 1", count,
) )
} }
} }
@@ -503,31 +464,13 @@ func TestTrackMetadataViewHasLibraryID(t *testing.T) {
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib") db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
// Insert prerequisites. // Insert prerequisites.
_, err := db.ExecContext( InsertTestTrack(t, db, TestTrack{
"INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')", FilePath: "/test/view.mp3",
) Title: "View Track",
if err != nil { Artist: "View Artist",
t.Fatalf("insert artist_credit: %v", err) LengthMs: 200000,
} LibraryID: libID,
})
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) " +
"VALUES (1, 'View Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files "+
"(id, file_path, length_milliseconds, file_type_id, "+
"recording_id, library_id) "+
"VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)",
libID,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
// Query track_metadata VIEW and verify library_id is present // Query track_metadata VIEW and verify library_id is present
// with the correct value. // with the correct value.
@@ -842,29 +785,13 @@ func TestPlayHistoryTable(t *testing.T) {
// Round-trip: insert a play_history row and verify play_count update. // Round-trip: insert a play_history row and verify play_count update.
// First, set up test data. The test DB already has library id=0. // First, set up test data. The test DB already has library id=0.
_, err = db.ExecContext( InsertTestTrack(t, db, TestTrack{
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", FilePath: "/test/play_history.mp3",
) Title: "Test Track",
if err != nil { Artist: "Test Artist",
t.Fatalf("insert artist_credit: %v", err) TrackNumber: 1,
} DiscNumber: 1,
})
_, err = db.ExecContext(
`INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number)
VALUES (1, 'Test Track', 1, 1, 1)`,
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
`INSERT INTO audio_files
(id, file_path, length_milliseconds, file_type_id, recording_id, library_id)
VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
// Verify default play_count is 0. // Verify default play_count is 0.
var playCount int64 var playCount int64
@@ -1,141 +0,0 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
)
// migrateDownloadRename performs the download subsystem's table rename
// for existing databases that still carry the old table names: the
// durable "I asked for this" record moved from download_wants to
// download_requests, and the one-shot search-and-grab attempt moved
// from download_requests to download_downloads (see CLAUDE.md and
// .planning/NOTES.md for the full Want->Request / Request->Download
// rename).
//
// This cannot be a plain sql/migrations file the way an ADD COLUMN
// migration is. That pattern's tolerance for "duplicate column name"
// works because a fresh database's sql/schemas pass already produces
// the identical target shape under the identical table name, so
// replaying the ALTER TABLE against it is a safe no-op. Here the name
// "download_requests" is reused for a different table before and after
// the rename, so a fresh database's schema pass creates a real, empty,
// correctly-shaped download_downloads AND a real, empty,
// correctly-shaped (new) download_requests before this ever runs.
// Blindly replaying "ALTER TABLE download_requests RENAME TO
// download_downloads" against that fresh database would rename the new,
// empty Request table into Download's place, destroying the fresh
// install rather than no-opping. Gating on whether the OLD
// download_wants table still exists — a name nothing creates or
// references once this has run — is what tells an old database and a
// fresh (or already migrated) one apart without executing anything
// destructive on the fresh path.
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
var name string
err := db.QueryRowContext(
ctx,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&name)
switch {
case errors.Is(err, sql.ErrNoRows):
// Nothing to migrate: either a fresh install (sql/schemas
// already produced the target shape) or a database this has
// already run against.
case err != nil:
return fmt.Errorf("check for download_wants table: %w", err)
default:
if err := runDownloadRename(ctx, db); err != nil {
return err
}
}
return ensureDownloadIndexes(ctx, db)
}
// runDownloadRename performs the actual rename dance against a
// database confirmed to still have the old download_wants table.
func runDownloadRename(ctx context.Context, db *sql.DB) error {
stmts := []string{
// The schema pass already created an empty, correctly-shaped
// download_downloads placeholder under this name (it never
// existed under the old naming), which would otherwise collide
// with the rename below.
`DROP TABLE IF EXISTS download_downloads`,
// 1. Free the "download_requests" name: the old one-shot
// attempt table becomes download_downloads.
`ALTER TABLE download_requests RENAME TO download_downloads`,
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
// 2. Claim the now-free "download_requests" name for the
// durable-intent table.
`ALTER TABLE download_wants RENAME TO download_requests`,
// 3. The transfer table's FK now points at download_downloads.
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
// Named indexes survive a table/column rename attached to their
// old name, so drop them here; ensureDownloadIndexes recreates
// them under the names sql/schemas' comments describe.
`DROP INDEX IF EXISTS idx_download_requests_created`,
`DROP INDEX IF EXISTS idx_download_requests_state`,
`DROP INDEX IF EXISTS idx_download_wants_due`,
`DROP INDEX IF EXISTS idx_download_wants_entity`,
`DROP INDEX IF EXISTS idx_download_wants_parent`,
`DROP INDEX IF EXISTS idx_download_items_request`,
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin download rename migration: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("download rename migration %q: %w", stmt, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit download rename migration: %w", err)
}
return nil
}
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
// omits inline for the renamed table/columns (see
// migrateDownloadRename), under their final names. Safe to call
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
// the time this runs every column/table involved is guaranteed to be
// in its final shape on both a fresh and a migrated database.
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
stmts := []string{
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
ON download_downloads(created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
ON download_downloads(state)`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
ON download_requests(next_try_at) WHERE state = 'wanted'`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
ON download_requests(entity, state)`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
ON download_requests(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_download_items_download
ON download_items(download_id)`,
}
for _, stmt := range stmts {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ensure download index: %w", err)
}
}
return nil
}
@@ -1,365 +0,0 @@
package database
import (
"database/sql"
"errors"
"testing"
)
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
// are frozen snapshots of the download subsystem's tables exactly as
// they read before the Want/Request rename (see
// download_rename_migration.go) — i.e. what a real user's existing
// database looks like today, before upgrading to a build that includes
// this migration.
const oldDownloadRequestsDDL = `
CREATE TABLE IF NOT EXISTS download_requests (
id TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
release_mbid TEXT,
release_group_mbid TEXT,
recording_mbid TEXT,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'searching'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_requests_created
ON download_requests(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_requests_state
ON download_requests(state);
`
const oldDownloadWantsDDL = `
CREATE TABLE IF NOT EXISTS download_wants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mbid TEXT NOT NULL,
entity TEXT NOT NULL
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
library_id INTEGER NOT NULL,
artist TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'future'
CHECK(scope IN ('future', 'all')),
secondary INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK(state IN ('wanted', 'satisfied', 'paused')),
parent_id INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_tried_at DATETIME,
next_try_at DATETIME,
external_ids TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mbid, library_id),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_wants_due
ON download_wants(next_try_at)
WHERE state = 'wanted';
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
ON download_wants(entity, state);
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
ON download_wants(parent_id);
`
const oldDownloadItemsDDL = `
CREATE TABLE IF NOT EXISTS download_items (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
provider_id INTEGER NOT NULL,
transport_id INTEGER,
external_id TEXT NOT NULL DEFAULT '',
candidate TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'queued'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
staging_dir TEXT NOT NULL DEFAULT '',
bytes_done INTEGER NOT NULL DEFAULT 0,
bytes_total INTEGER NOT NULL DEFAULT 0,
imported_paths TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_items_live
ON download_items(state)
WHERE state NOT IN ('complete', 'cancelled', 'failed');
CREATE INDEX IF NOT EXISTS idx_download_items_request
ON download_items(request_id);
CREATE INDEX IF NOT EXISTS idx_download_items_state
ON download_items(state);
`
// seedOldDownloadSchema builds the pre-rename download tables and
// inserts one row of real data into each, standing in for a real
// user's database at the moment it upgrades.
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
t.Helper()
for _, ddl := range []string{
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
} {
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
t.Fatalf("create old download schema: %v", err)
}
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
); err != nil {
t.Fatalf("seed library: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_wants
(id, mbid, entity, library_id, artist, title, state)
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
); err != nil {
t.Fatalf("seed download_wants: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_requests
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
); err != nil {
t.Fatalf("seed download_requests: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_items
(id, request_id, provider_id, state)
VALUES ('item-1', 'dl-1', 1, 'complete')`,
); err != nil {
t.Fatalf("seed download_items: %v", err)
}
}
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
// brand-new database produces the target shape directly and that
// migrateDownloadRename's gate (checking for the old download_wants
// table) is a no-op there — the destructive path this test guards
// against is exactly the one described in download_rename_migration.go:
// blindly replaying the rename against a fresh database's already-
// correct, empty download_requests/download_downloads tables.
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
t.Parallel()
db := openMemDB(t)
if err := applySchema(t.Context(), db); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
var name string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
table,
).Scan(&name)
if err != nil {
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
}
}
var stray string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&stray)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
}
// Both auto-download guardrail indexes sql/schemas deliberately
// omits (see ensureDownloadIndexes) must still exist.
for _, idx := range []string{
"idx_download_requests_due",
"idx_download_requests_entity",
"idx_download_requests_parent",
"idx_download_items_download",
} {
var name string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
idx,
).Scan(&name)
if err != nil {
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
}
}
}
// TestDownloadRename_UpgradesExistingDatabase is the regression test
// for the rename itself: an old-shaped database (download_wants +
// old-style download_requests, both with real rows) must end up with
// the same table names, column names, and data a fresh install would
// have — nothing dropped, nothing silently emptied.
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
upgraded := openMemDB(t)
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
if err != nil {
t.Fatalf("read libraries schema: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
t.Fatalf("create libraries table: %v", err)
}
seedOldDownloadSchema(t, upgraded)
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema (upgrade path): %v", err)
}
// Column order must match a fresh install's, for the same reason
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
// sqlc's `SELECT *` binds positionally.
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
freshCols := tableColumns(t, fresh, table)
upgradedCols := tableColumns(t, upgraded, table)
if len(freshCols) != len(upgradedCols) {
t.Fatalf(
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
)
}
for i := range freshCols {
if freshCols[i] != upgradedCols[i] {
t.Errorf(
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
table,
i,
freshCols[i],
upgradedCols[i],
freshCols,
upgradedCols,
)
}
}
}
// The seeded rows survived the rename under their new names.
var (
requestMBID string
requestEntity string
)
err = upgraded.QueryRowContext(
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
).Scan(&requestMBID, &requestEntity)
if err != nil {
t.Fatalf("seeded request row missing after rename: %v", err)
}
if requestMBID != "artist-mbid" || requestEntity != "artist" {
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
}
var (
downloadRequestID sql.NullInt64
downloadAlbum string
)
err = upgraded.QueryRowContext(
t.Context(),
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
).Scan(&downloadRequestID, &downloadAlbum)
if err != nil {
t.Fatalf("seeded download row missing after rename: %v", err)
}
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
}
if downloadAlbum != "OK Computer" {
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
}
var itemDownloadID string
err = upgraded.QueryRowContext(
t.Context(),
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
).Scan(&itemDownloadID)
if err != nil {
t.Fatalf("seeded item row missing after rename: %v", err)
}
if itemDownloadID != "dl-1" {
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
}
// The old table is gone, not just emptied.
var stray string
err = upgraded.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&stray)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
}
// Running the whole thing again (as a second app startup would) is
// a no-op: the gate sees no download_wants table and does nothing
// further, so this must not error or duplicate anything.
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema a second time: %v", err)
}
var count int
if err := upgraded.QueryRowContext(
t.Context(), `SELECT COUNT(*) FROM download_requests`,
).Scan(&count); err != nil {
t.Fatalf("count download_requests: %v", err)
}
if count != 1 {
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
}
}
+18 -7
View File
@@ -1,18 +1,26 @@
package database package database
import ( import (
"crypto/sha256"
"strings" "strings"
"testing" "testing"
) )
// seedExploreRow inserts one explore_index row. // seedExploreRow inserts one explore_index row.
//
// The catalog stores an MBID as 16 raw bytes and an entity type as a
// code (see backend/explore/mbid.go), and the column says so, so the
// label these tests use as an id is hashed into something the table
// will accept. What they actually assert on is the FTS text.
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) { func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
t.Helper() t.Helper()
sum := sha256.Sum256([]byte(mbid))
if _, err := db.ExecContext(` if _, err := db.ExecContext(`
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid) INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
VALUES ('recording', ?, ?, ?, '') VALUES (3 /* recording */, ?, ?, ?, x'')
`, mbid, title, artist); err != nil { `, sum[:16], title, artist); err != nil {
t.Fatalf("seed %s: %v", mbid, err) t.Fatalf("seed %s: %v", mbid, err)
} }
} }
@@ -202,7 +210,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
// A popularity refresh: an FTS column is not named at all. // A popularity refresh: an FTS column is not named at all.
if _, err := db.ExecContext( if _, err := db.ExecContext(
"UPDATE explore_index SET popularity = 42 WHERE mbid = 'mbid-1'", "UPDATE explore_index SET popularity = 42 WHERE title = ?",
"Unchanged Title",
); err != nil { ); err != nil {
t.Fatalf("popularity update: %v", err) t.Fatalf("popularity update: %v", err)
} }
@@ -212,8 +221,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
if _, err := db.ExecContext(` if _, err := db.ExecContext(`
UPDATE explore_index UPDATE explore_index
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43 SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
WHERE mbid = 'mbid-1' WHERE title = ?
`); err != nil { `, "Unchanged Title"); err != nil {
t.Fatalf("no-op text update: %v", err) t.Fatalf("no-op text update: %v", err)
} }
@@ -237,7 +246,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist") seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
if _, err := db.ExecContext( if _, err := db.ExecContext(
"UPDATE explore_index SET title = 'Corrected Title' WHERE mbid = 'mbid-2'", "UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?",
"Original Title",
); err != nil { ); err != nil {
t.Fatalf("rename: %v", err) t.Fatalf("rename: %v", err)
} }
@@ -252,7 +262,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
// The same for the other two indexed columns. // The same for the other two indexed columns.
if _, err := db.ExecContext( if _, err := db.ExecContext(
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE mbid = 'mbid-2'", "UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?",
"Corrected Title",
); err != nil { ); err != nil {
t.Fatalf("artist rename: %v", err) t.Fatalf("artist rename: %v", err)
} }
+108 -111
View File
@@ -1,15 +1,26 @@
package database package database
import ( import (
"database/sql"
"errors"
"fmt" "fmt"
"strings" "strings"
"unicode" "unicode"
) )
// toNullString treats an empty string as NULL.
func toNullString(v string) sql.NullString {
if v == "" {
return sql.NullString{}
}
return sql.NullString{String: v, Valid: true}
}
// LyricsHit is a single result from a lyric-fragment search: the // LyricsHit is a single result from a lyric-fragment search: the
// matched recording plus enough metadata to render and play it. // matched file plus enough metadata to render and play it.
type LyricsHit struct { type LyricsHit struct {
RecordingID int64 AudioFileID int64
FilePath string FilePath string
LengthMilliseconds int64 LengthMilliseconds int64
Title string Title string
@@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
return nil, nil return nil, nil
} }
// Map the matched recording (lyrics_index.rowid == recordings.id) // lyrics_index.rowid is the audio file's id, so the hit is already
// to a representative playable file via the lowest audio_files id, // a playable file - it used to be a recording id, which then had to
// then to the track_metadata VIEW for display fields. // be mapped back to "some file of that recording" by a grouped
// subquery.
// //
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, ` rows, err := d.reader().QueryContext(d.Ctx, `
SELECT SELECT
r.id, tm.id,
tm.file_path, tm.file_path,
tm.length_milliseconds, tm.length_milliseconds,
tm.title, tm.title,
tm.artist_name, tm.artist_name,
tm.album tm.album
FROM lyrics_index li FROM lyrics_index li
JOIN recordings r ON r.id = li.rowid JOIN track_metadata tm ON tm.id = li.rowid
JOIN (
SELECT recording_id, MIN(id) AS af_id
FROM audio_files
GROUP BY recording_id
) af ON af.recording_id = r.id
JOIN track_metadata tm ON tm.id = af.af_id
WHERE lyrics_index MATCH ? WHERE lyrics_index MATCH ?
ORDER BY rank ORDER BY rank
LIMIT ? LIMIT ?
@@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
for rows.Next() { for rows.Next() {
var h LyricsHit var h LyricsHit
if err := rows.Scan( if err := rows.Scan(
&h.RecordingID, &h.AudioFileID,
&h.FilePath, &h.FilePath,
&h.LengthMilliseconds, &h.LengthMilliseconds,
&h.Title, &h.Title,
@@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
return results, nil return results, nil
} }
// GetRecordingLyrics returns the stored lyrics for a recording, or // GetLyrics returns the stored lyrics for a file, or "" if none.
// an empty string if none are stored. func (d *DB) GetLyrics(audioFileID int64) (string, error) {
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
var lyrics string var lyrics string
err := d.db.QueryRowContext(d.Ctx, err := d.reader().QueryRowContext(d.Ctx,
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?", "SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID,
recordingID,
).Scan(&lyrics) ).Scan(&lyrics)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil { if err != nil {
return "", fmt.Errorf("could not read recording lyrics: %w", err) return "", fmt.Errorf("could not read lyrics: %w", err)
} }
return lyrics, nil return lyrics, nil
} }
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS // SetLyrics writes lyrics for a file and keeps the FTS index in sync.
// lyrics_index in sync (delete + reinsert the single row). Used by //
// the LRCLIB backfill to persist fetched lyrics. Passing an empty // `source` says where they came from, which is the question the old
// string clears both the column and the index entry. // column could not answer: lyrics read from a USLT frame are rebuilt
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error { // free by any rescan, and lyrics fetched from LRCLIB are network
if _, err := d.db.ExecContext(d.Ctx, // traffic nobody wants to repeat. Passing an empty string clears both
"UPDATE recordings SET lyrics = ? WHERE id = ?", // the row and the index entry.
lyrics, recordingID, func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error {
); err != nil { if strings.TrimSpace(lyrics) == "" {
return fmt.Errorf("could not update recording lyrics: %w", err) if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics WHERE audio_file_id = ?", audioFileID,
); err != nil {
return fmt.Errorf("could not delete lyrics: %w", err)
}
return d.upsertLyricsIndex(audioFileID, "")
} }
return d.upsertLyricsIndex(recordingID, lyrics) if _, err := d.db.ExecContext(d.Ctx, `
INSERT INTO lyrics (audio_file_id, text, source, recording_mbid)
VALUES (?, ?, ?, ?)
ON CONFLICT(audio_file_id) DO UPDATE SET
text = excluded.text,
source = excluded.source,
recording_mbid = COALESCE(excluded.recording_mbid, lyrics.recording_mbid),
fetched_at = CURRENT_TIMESTAMP
`, audioFileID, lyrics, source, toNullString(recordingMBID)); err != nil {
return fmt.Errorf("could not write lyrics: %w", err)
}
return d.upsertLyricsIndex(audioFileID, lyrics)
} }
// upsertLyricsIndex refreshes a single recording's entry in the // upsertLyricsIndex refreshes a single file's entry in the contentless
// contentless lyrics_index. contentless_delete=1 makes the DELETE // lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
// valid; an empty lyrics string leaves the row deleted. // lyrics string leaves the row deleted.
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error { func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
if _, err := d.db.ExecContext(d.Ctx, if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID, "DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
); err != nil { ); err != nil {
return fmt.Errorf("could not delete lyrics_index row: %w", err) return fmt.Errorf("could not delete lyrics_index row: %w", err)
} }
@@ -141,7 +168,7 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized. // SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
if _, err := d.db.ExecContext(d.Ctx, if _, err := d.db.ExecContext(d.Ctx,
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)", "INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
recordingID, lyrics, audioFileID, lyrics,
); err != nil { ); err != nil {
return fmt.Errorf("could not insert lyrics_index row: %w", err) return fmt.Errorf("could not insert lyrics_index row: %w", err)
} }
@@ -149,22 +176,16 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
return nil return nil
} }
// RebuildLyricsIndex repopulates lyrics_index from scratch using the // RebuildLyricsIndex repopulates lyrics_index from the lyrics table.
// current recordings table. Cheap for a personal library and safe to
// run after every scan.
func (d *DB) RebuildLyricsIndex() error { func (d *DB) RebuildLyricsIndex() error {
if _, err := d.db.ExecContext(d.Ctx, if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil {
"DELETE FROM lyrics_index",
); err != nil {
return fmt.Errorf("could not clear lyrics_index: %w", err) return fmt.Errorf("could not clear lyrics_index: %w", err)
} }
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input. // SAFETY: FTS5 virtual table INSERT. Values sourced from lyrics; no user input.
if _, err := d.db.ExecContext(d.Ctx, ` if _, err := d.db.ExecContext(d.Ctx, `
INSERT INTO lyrics_index(rowid, lyrics) INSERT INTO lyrics_index(rowid, lyrics)
SELECT id, lyrics SELECT audio_file_id, text FROM lyrics WHERE text != ''
FROM recordings
WHERE lyrics IS NOT NULL AND lyrics != ''
`); err != nil { `); err != nil {
return fmt.Errorf("could not rebuild lyrics_index: %w", err) return fmt.Errorf("could not rebuild lyrics_index: %w", err)
} }
@@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error {
return nil return nil
} }
// RecordingsMissingLyrics returns recordings that have no stored // LyricsCandidate identifies a file that needs its lyrics fetched and
// lyrics but do carry the artist/title/duration needed to look them // carries the fields an external provider matches on.
// up from an external provider. Used by the LRCLIB backfill. The type LyricsCandidate struct {
// limit bounds each batch so the backfill can be run incrementally. AudioFileID int64
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) { Title string
Artist string
Album string
RecordingMBID string
LengthMilliseconds int64
}
// FilesMissingLyrics returns files with no stored lyrics that carry
// the artist/title/duration needed to look them up. Used by the
// LRCLIB backfill; the limit bounds each batch.
func (d *DB) FilesMissingLyrics(limit int) ([]LyricsCandidate, error) {
if limit <= 0 { if limit <= 0 {
limit = 200 limit = 200
} }
rows, err := d.db.QueryContext(d.Ctx, ` rows, err := d.reader().QueryContext(d.Ctx, `
SELECT SELECT tm.id, tm.title, tm.artist_name, tm.album,
r.id, tm.recording_mbid, tm.length_milliseconds
COALESCE(r.name, ''), FROM track_metadata tm
COALESCE(ac.text, ''), WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id)
COALESCE(rg.name, ''), AND tm.title != '' AND tm.artist_name != ''
MIN(af.length_milliseconds)
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE (r.lyrics IS NULL OR r.lyrics = '')
AND r.name IS NOT NULL AND r.name != ''
AND ac.text IS NOT NULL AND ac.text != ''
GROUP BY r.id
LIMIT ? LIMIT ?
`, limit) `, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err) return nil, fmt.Errorf("could not query files missing lyrics: %w", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
@@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
for rows.Next() { for rows.Next() {
var c LyricsCandidate var c LyricsCandidate
if err := rows.Scan( if err := rows.Scan(
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds, &c.AudioFileID, &c.Title, &c.Artist, &c.Album,
&c.RecordingMBID, &c.LengthMilliseconds,
); err != nil { ); err != nil {
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err) return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
} }
@@ -229,44 +247,23 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
return out, nil return out, nil
} }
// LyricsCandidate identifies a recording that needs its lyrics fetched // FileLyricLookup returns the provider-match fields for one file, so
// and carries the fields an external provider matches on. // lyrics can be fetched on demand. Returns nil if the file has no
type LyricsCandidate struct { // artist/title to match on.
RecordingID int64 func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) {
Title string
Artist string
Album string
LengthMilliseconds int64
}
// RecordingLyricLookup returns the provider-match fields (artist,
// title, album, duration) for a single recording, so lyrics can be
// fetched on demand. Returns nil if the recording has no audio file
// or no artist/title to match on.
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
var c LyricsCandidate var c LyricsCandidate
err := d.db.QueryRowContext(d.Ctx, ` err := d.reader().QueryRowContext(d.Ctx, `
SELECT SELECT tm.id, tm.title, tm.artist_name, tm.album,
r.id, tm.recording_mbid, tm.length_milliseconds
COALESCE(r.name, ''), FROM track_metadata tm
COALESCE(ac.text, ''), WHERE tm.id = ?
COALESCE(rg.name, ''), `, audioFileID).Scan(
COALESCE(MIN(af.length_milliseconds), 0) &c.AudioFileID, &c.Title, &c.Artist, &c.Album,
FROM recordings r &c.RecordingMBID, &c.LengthMilliseconds,
JOIN audio_files af ON af.recording_id = r.id )
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE r.id = ?
GROUP BY r.id
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
if err != nil { if err != nil {
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err) return nil, fmt.Errorf("could not look up file for lyrics: %w", err)
} }
if c.Title == "" || c.Artist == "" { if c.Title == "" || c.Artist == "" {
+25 -51
View File
@@ -4,59 +4,33 @@ import (
"testing" "testing"
) )
// seedLyricsTrack inserts the minimal FK chain (artist_credit → // seedLyricsTrack inserts one file with the given lyrics, so lyric
// recording → audio_file → release_group link) for one track with the // searches have something realistic to join against. It used to
// given lyrics, so lyric-search tests have realistic joins. // insert a four-row FK chain by hand.
func seedLyricsTrack( func seedLyricsTrack(
t *testing.T, t *testing.T,
db *DB, db *DB,
id int64, id int64,
title, artist, album, lyrics string, title, artist, album, lyrics string,
lenMs int64, lenMs int64,
) { ) int64 {
t.Helper() t.Helper()
if _, err := db.ExecContext( fileID := InsertTestTrack(t, db, TestTrack{
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist, FilePath: "/music/track" + itoa(id) + ".mp3",
); err != nil { Title: title,
t.Fatalf("insert artist_credit: %v", err) Artist: artist,
Album: album,
LengthMs: lenMs,
})
if lyrics != "" {
if err := db.SetLyrics(fileID, lyrics, "tag", ""); err != nil {
t.Fatalf("seed lyrics: %v", err)
}
} }
if _, err := db.ExecContext( return fileID
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
); err != nil {
t.Fatalf("insert release_group: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
id, title, id, nullableLyrics(lyrics),
); err != nil {
t.Fatalf("insert recording: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
"VALUES (?, ?, ?, ?, ?)",
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
); err != nil {
t.Fatalf("insert audio_file: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
id, id,
); err != nil {
t.Fatalf("insert release_group_recordings: %v", err)
}
}
func nullableLyrics(l string) any {
if l == "" {
return nil
}
return l
} }
func itoa(v int64) string { func itoa(v int64) string {
@@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) {
} }
h := hits[0] h := hits[0]
if h.RecordingID != 1 { if h.AudioFileID != 1 {
t.Errorf("RecordingID = %d, want 1", h.RecordingID) t.Errorf("RecordingID = %d, want 1", h.AudioFileID)
} }
if h.Title != "The Sound of Silence" { if h.Title != "The Sound of Silence" {
@@ -191,11 +165,11 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
// Backfill lyrics — should update both the column and the FTS index. // Backfill lyrics — should update both the column and the FTS index.
const lyrics = "Yesterday all my troubles seemed so far away" const lyrics = "Yesterday all my troubles seemed so far away"
if err := db.SetRecordingLyrics(1, lyrics); err != nil { if err := db.SetLyrics(1, lyrics, "lrclib", ""); err != nil {
t.Fatalf("SetRecordingLyrics: %v", err) t.Fatalf("SetRecordingLyrics: %v", err)
} }
stored, err := db.GetRecordingLyrics(1) stored, err := db.GetLyrics(1)
if err != nil { if err != nil {
t.Fatalf("GetRecordingLyrics: %v", err) t.Fatalf("GetRecordingLyrics: %v", err)
} }
@@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
t.Fatalf("SearchLyrics: %v", err) t.Fatalf("SearchLyrics: %v", err)
} }
if len(hits) != 1 || hits[0].RecordingID != 1 { if len(hits) != 1 || hits[0].AudioFileID != 1 {
t.Fatalf("expected recording 1 after backfill, got %+v", hits) t.Fatalf("expected recording 1 after backfill, got %+v", hits)
} }
} }
@@ -222,7 +196,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000) seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000) seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
missing, err := db.RecordingsMissingLyrics(50) missing, err := db.FilesMissingLyrics(50)
if err != nil { if err != nil {
t.Fatalf("RecordingsMissingLyrics: %v", err) t.Fatalf("RecordingsMissingLyrics: %v", err)
} }
@@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
} }
c := missing[0] c := missing[0]
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" { if c.AudioFileID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
t.Errorf("unexpected candidate: %+v", c) t.Errorf("unexpected candidate: %+v", c)
} }
@@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
} }
// Single-recording lookup mirrors the batch fields. // Single-recording lookup mirrors the batch fields.
one, err := db.RecordingLyricLookup(2) one, err := db.FileLyricLookup(2)
if err != nil { if err != nil {
t.Fatalf("RecordingLyricLookup: %v", err) t.Fatalf("RecordingLyricLookup: %v", err)
} }
-196
View File
@@ -1,196 +0,0 @@
package database
import (
"database/sql"
"testing"
)
// oldTaggingItemsDDL is a frozen snapshot of tagging_items exactly as
// it read before sql/migrations/0001_tagging_items_synthetic.sql —
// i.e. what a real user's existing database looks like today, before
// upgrading to a build that includes that migration.
const oldTaggingItemsDDL = `
CREATE TABLE IF NOT EXISTS tagging_items (
group_key TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
track_count INTEGER NOT NULL DEFAULT 0,
album_name TEXT NOT NULL DEFAULT '',
album_artist TEXT NOT NULL DEFAULT '',
disc_number INTEGER NOT NULL DEFAULT 0,
best_match_release_mbid TEXT,
score REAL,
last_checked_at DATETIME,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
cleared_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
ON tagging_items(library_id, status);
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
ON tagging_items(library_id) WHERE status = 'pending';
`
// tableColumns returns the column names of a table in on-disk
// (positional) order, via PRAGMA table_info — the order sqlc's
// generated `SELECT *` scans bind to positionally.
func tableColumns(t *testing.T, db *sql.DB, table string) []string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "PRAGMA table_info("+table+")")
if err != nil {
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
}
defer func() { _ = rows.Close() }()
var cols []string
for rows.Next() {
var (
cid int
name string
ctype string
notnull int
dfltValue sql.NullString
primaryKey int
)
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &primaryKey); err != nil {
t.Fatalf("scan table_info row: %v", err)
}
cols = append(cols, name)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate table_info: %v", err)
}
return cols
}
func openMemDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:?_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
t.Fatalf("open in-memory db: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := applyPRAGMAs(t.Context(), db); err != nil {
t.Fatalf("apply pragmas: %v", err)
}
return db
}
// TestMigrations_ColumnOrderMatchesFreshInstall is the regression
// test for the exact failure mode that got the old 48-step migration
// chain torn out (see .planning/NOTES.md, "No migration chain"):
// sql/schemas drifting from what migrations actually produce, so
// sqlc-generated code silently reads the wrong thing.
//
// A fresh install takes tagging_items straight from sql/schemas
// (CREATE TABLE, columns in file order). An existing database takes
// it from sql/schemas (the base shape, unchanged since the table
// already existed) plus sql/migrations/0001 (`ALTER TABLE ADD
// COLUMN`, which SQLite always appends at the END of the column
// list, regardless of where the column sits in the CREATE TABLE
// statement). If sql/schemas ever declares a migrated column
// somewhere other than last, the two paths produce tables with the
// SAME columns in a DIFFERENT order — invisible until a `SELECT *`
// (e.g. GetTaggingItem) silently binds a value to the wrong field.
func TestMigrations_ColumnOrderMatchesFreshInstall(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
upgraded := openMemDB(t)
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
if err != nil {
t.Fatalf("read libraries schema: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
t.Fatalf("create libraries table: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), oldTaggingItemsDDL); err != nil {
t.Fatalf("create pre-migration tagging_items: %v", err)
}
// sql/schemas no-ops on the pre-existing tagging_items (IF NOT
// EXISTS), then sql/migrations/0001's ALTER TABLE statements
// actually add the missing columns for real this time.
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema (upgrade path): %v", err)
}
freshCols := tableColumns(t, fresh, "tagging_items")
upgradedCols := tableColumns(t, upgraded, "tagging_items")
if len(freshCols) != len(upgradedCols) {
t.Fatalf(
"column count mismatch: fresh install has %d (%v), upgraded has %d (%v)",
len(freshCols), freshCols, len(upgradedCols), upgradedCols,
)
}
for i := range freshCols {
if freshCols[i] != upgradedCols[i] {
t.Errorf(
"column order mismatch at position %d: fresh install has %q, upgraded has %q\nfresh: %v\nupgraded: %v",
i,
freshCols[i],
upgradedCols[i],
freshCols,
upgradedCols,
)
}
}
}
// TestMigrations_FreshDatabaseStillRecordsAndGetsIndex confirms a
// brand-new database runs migration 0001 (tolerating "duplicate
// column name" from its ALTER TABLE statements, since sql/schemas
// already declared those columns), records it applied, AND still
// gets the trailing CREATE INDEX statement sql/schemas deliberately
// omits for migrated columns.
func TestMigrations_FreshDatabaseStillRecordsAndGetsIndex(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema: %v", err)
}
var version int
err := fresh.QueryRowContext(
t.Context(), "SELECT version FROM schema_migrations WHERE version = 1",
).Scan(&version)
if err != nil {
t.Fatalf("expected migration 1 to be recorded as applied on a fresh db: %v", err)
}
var indexName string
err = fresh.QueryRowContext(
t.Context(),
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_tagging_items_parent_group_key'",
).Scan(&indexName)
if err != nil {
t.Fatalf("expected idx_tagging_items_parent_group_key to exist on a fresh db: %v", err)
}
}
+86
View File
@@ -0,0 +1,86 @@
package database
import (
"testing"
)
// TestOneRowPerTrackForAMultiArtistCredit pins what is left of the
// multi-artist problem, which is now much smaller than it was.
//
// It used to be possible for one file to produce several rows: an
// artist credit was a row in its own table linking *many* artists, so
// any query that joined artist_credit_artist to read the artist MBID
// returned the same track once per credited artist. The playlist, the
// queue, the library list and the phantom resolver all did, and all
// showed collaborations twice. Nine queries carried a
// first-credited-artist subquery to work around it.
//
// The join is gone: a file carries its credit as text and points at one
// primary artist, so the fan-out has nothing to fan out from. What is
// still worth pinning is that the credit text survives intact - a
// collaboration must still *read* as one - and that the file resolves
// to exactly one row wherever it is asked for.
func TestOneRowPerTrackForAMultiArtistCredit(t *testing.T) {
t.Parallel()
db := NewTestDB(t)
id := InsertTestTrack(t, db, TestTrack{
FilePath: "/lib/collab.mp3",
Title: "Collab Song",
Artist: "A feat. B",
ArtistMBID: "mbid-a",
Album: "An Album",
LengthMs: 200000,
})
t.Run("one row in the view", func(t *testing.T) {
var n int
if err := db.QueryRowWriter(
`SELECT COUNT(*) FROM track_metadata WHERE id = ?`, id,
).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Errorf("track_metadata rows = %d, want 1", n)
}
})
t.Run("the credit is preserved and the artist resolved", func(t *testing.T) {
rows, err := db.Queries.GetTracks(db.Ctx, 0)
if err != nil {
t.Fatalf("get tracks: %v", err)
}
if len(rows) != 1 {
t.Fatalf("tracks = %d, want 1", len(rows))
}
if rows[0].ArtistName != "A feat. B" {
t.Errorf("artist credit = %q, want %q", rows[0].ArtistName, "A feat. B")
}
if rows[0].ArtistMbid != "mbid-a" {
t.Errorf("artist mbid = %q, want %q", rows[0].ArtistMbid, "mbid-a")
}
})
t.Run("one row per album track", func(t *testing.T) {
var albumID int64
if err := db.QueryRowWriter(
`SELECT album_id FROM audio_files WHERE id = ?`, id,
).Scan(&albumID); err != nil {
t.Fatalf("album id: %v", err)
}
rows, err := db.Queries.GetTracks(db.Ctx, 0)
if err != nil {
t.Fatalf("album tracks: %v", err)
}
if len(rows) != 1 {
t.Errorf("album tracks = %d, want 1", len(rows))
}
})
}
+55 -176
View File
@@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
"yellowjacket/backend/database/sql/sqlcgen"
) )
// SearchRow holds a single result from an FTS5 or basename search. // SearchRow holds a single result from an FTS5 or basename search.
@@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error {
return nil return nil
} }
// SearchTrackRow holds a full track result from an FTS5 search, // trackMetadataColumns is the column list of the track_metadata view,
// matching all 16 columns returned by GetAllTracksWithFullMetadata. // in the order sqlc generates TrackMetadatum's fields. The FTS
type SearchTrackRow struct { // searches below cannot be sqlc queries (MATCH is not in its grammar),
FilePath string // so this is the one place the view's shape is written out by hand.
LengthMilliseconds int64 const trackMetadataColumns = `
Title string tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name,
ArtistName string tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year,
TrackNumber sql.NullInt64 tm.release_year, tm.composer, tm.file_type, tm.sample_rate,
DiscNumber sql.NullInt64 tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id,
Album string tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid,
Genre string tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id`
Year int64
Composer string // scanTrackMetadata reads track_metadata rows into the generated row
FileType string // type, so an FTS hit and an ordinary query produce the same Track.
SampleRate int64 func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) {
BitDepth int64 var out []sqlcgen.TrackMetadatum
Channels int64
Bitrate int64 for rows.Next() {
FileSize int64 var r sqlcgen.TrackMetadatum
if err := rows.Scan(
&r.ID, &r.FilePath, &r.LengthMilliseconds, &r.Title, &r.ArtistName,
&r.TrackNumber, &r.DiscNumber, &r.Album, &r.Genre, &r.Year,
&r.ReleaseYear, &r.Composer, &r.FileType, &r.SampleRate,
&r.BitDepth, &r.Channels, &r.Bitrate, &r.FileSize, &r.LibraryID,
&r.PlayCount, &r.LastPlayed, &r.CoverArtPath, &r.ArtistMbid,
&r.ReleaseGroupMbid, &r.RecordingMbid, &r.AlbumID, &r.ArtistID,
); err != nil {
return nil, fmt.Errorf("scan track metadata: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate track metadata: %w", err)
}
return out, nil
} }
// SearchFTSTracks performs a full-text search and returns full track // SearchFTSTracks performs a full-text search and returns whole tracks.
// metadata for each match. Unlike SearchFTS (which returns only 5 //
// columns), this includes all 16 fields needed for library.Track. // A library id of 0 means every library. There were two of these, one
// per case, each with its own copy of a sixteen-column projection that
// silently dropped the MBIDs and the play count - which is why the
// caller used to pass zeros for them.
func (d *DB) SearchFTSTracks( func (d *DB) SearchFTSTracks(
query string, limit int, query string, libraryID int64, limit int,
) ([]SearchTrackRow, error) { ) ([]sqlcgen.TrackMetadatum, error) {
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
if query == "" { if query == "" {
return nil, nil return nil, nil
} }
ftsQuery := buildFTSQuery(query)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, ` rows, err := d.reader().QueryContext(d.Ctx, `
SELECT SELECT`+trackMetadataColumns+`
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.track_number,
tm.disc_number,
tm.album,
tm.genre,
tm.year,
tm.composer,
tm.file_type,
tm.sample_rate,
tm.bit_depth,
tm.channels,
tm.bitrate,
tm.file_size
FROM search_index si FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ? WHERE search_index MATCH ?
AND (? = 0 OR tm.library_id = ?)
ORDER BY rank ORDER BY rank
LIMIT ? LIMIT ?
`, ftsQuery, limit) `, buildFTSQuery(query), libraryID, libraryID, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("FTS track search failed: %w", err)
"FTS track search failed: %w", err,
)
} }
defer func() { _ = rows.Close() }() defer func() { _ = rows.Close() }()
var results []SearchTrackRow return scanTrackMetadata(rows)
for rows.Next() {
var r SearchTrackRow
if err := rows.Scan(
&r.FilePath,
&r.LengthMilliseconds,
&r.Title,
&r.ArtistName,
&r.TrackNumber,
&r.DiscNumber,
&r.Album,
&r.Genre,
&r.Year,
&r.Composer,
&r.FileType,
&r.SampleRate,
&r.BitDepth,
&r.Channels,
&r.Bitrate,
&r.FileSize,
); err != nil {
return nil, fmt.Errorf(
"could not scan search track row: %w",
err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"search track row iteration error: %w",
err,
)
}
return results, nil
} }
// SearchFTSTracksByLibrary performs a full-text search scoped to a
// specific library and returns full track metadata for each match.
func (d *DB) SearchFTSTracksByLibrary(
query string, limit int, libraryID int64,
) ([]SearchTrackRow, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
ftsQuery := buildFTSQuery(query)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.track_number,
tm.disc_number,
tm.album,
tm.genre,
tm.year,
tm.composer,
tm.file_type,
tm.sample_rate,
tm.bit_depth,
tm.channels,
tm.bitrate,
tm.file_size
FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ? AND tm.library_id = ?
ORDER BY rank
LIMIT ?
`, ftsQuery, libraryID, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS library track search failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
var results []SearchTrackRow
for rows.Next() {
var r SearchTrackRow
if err := rows.Scan(
&r.FilePath,
&r.LengthMilliseconds,
&r.Title,
&r.ArtistName,
&r.TrackNumber,
&r.DiscNumber,
&r.Album,
&r.Genre,
&r.Year,
&r.Composer,
&r.FileType,
&r.SampleRate,
&r.BitDepth,
&r.Channels,
&r.Bitrate,
&r.FileSize,
); err != nil {
return nil, fmt.Errorf(
"could not scan library search track row: %w",
err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"library search track row iteration error: %w",
err,
)
}
return results, nil
}
// scanSearchRows reads all rows from a query result into a slice.
func scanSearchRows( func scanSearchRows(
rows interface { rows interface {
Next() bool Next() bool
+76 -232
View File
@@ -3,6 +3,8 @@ package database
import ( import (
"fmt" "fmt"
"testing" "testing"
"yellowjacket/backend/database/sql/sqlcgen"
) )
// seedSearchData inserts ~7 tracks with the full FK chain required for // seedSearchData inserts ~7 tracks with the full FK chain required for
@@ -84,128 +86,44 @@ func seedSearchData(t *testing.T, db *DB) {
}, },
} }
// Build unique sets.
artistMap := map[string]int64{}
albumMap := map[string]int64{}
var artistID, albumID int64
for _, tr := range tracks { for _, tr := range tracks {
if _, ok := artistMap[tr.artist]; !ok { var genres []string
artistID++
artistMap[tr.artist] = artistID
}
if _, ok := albumMap[tr.album]; !ok {
albumID++
albumMap[tr.album] = albumID
}
}
// Insert artist_credit rows.
for text, id := range artistMap {
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (?, ?)",
id, text,
)
if err != nil {
t.Fatalf("insert artist_credit %q: %v", text, err)
}
}
// Insert release_groups.
for name, id := range albumMap {
_, err := db.ExecContext(
"INSERT INTO release_groups (id, name) VALUES (?, ?)",
id, name,
)
if err != nil {
t.Fatalf("insert release_group %q: %v", name, err)
}
}
// Insert genres + recording_genres.
genreMap := map[string]int64{}
var genreID int64
for _, tr := range tracks {
if tr.genre == "" {
continue
}
if _, ok := genreMap[tr.genre]; !ok {
genreID++
genreMap[tr.genre] = genreID
_, err := db.ExecContext(
"INSERT INTO genres (id, name) VALUES (?, ?)",
genreID, tr.genre,
)
if err != nil {
t.Fatalf("insert genre %q: %v", tr.genre, err)
}
}
}
for _, tr := range tracks {
acID := artistMap[tr.artist]
rgID := albumMap[tr.album]
// Insert recording.
_, err := db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id, "+
"track_number, disc_number, year, genre, composer) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
tr.id, tr.title, acID, tr.trackNum, tr.discNum,
tr.year, tr.genre, tr.composer,
)
if err != nil {
t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err)
}
// Insert audio_files.
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, "+
"length_milliseconds, file_type_id, recording_id, "+
"sample_rate, bit_depth, channels, bitrate, file_size) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id,
tr.sr, tr.bd, tr.ch, tr.br, tr.fsize,
)
if err != nil {
t.Fatalf("insert audio_file %d: %v", tr.id, err)
}
// Link recording to release_group.
_, err = db.ExecContext(
"INSERT INTO release_group_recordings "+
"(release_group_id, recording_id, track_number, disc_number) "+
"VALUES (?, ?, ?, ?)",
rgID, tr.id, tr.trackNum, tr.discNum,
)
if err != nil {
t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err)
}
// Insert search_index entry (rowid must match audio_files.id).
if err := db.InsertSearchIndex(
tr.id, tr.filePath, tr.title, tr.artist, tr.album,
); err != nil {
t.Fatalf("insert search_index for %d: %v", tr.id, err)
}
// Insert recording_genres link.
if tr.genre != "" { if tr.genre != "" {
gID := genreMap[tr.genre] genres = []string{tr.genre}
}
_, err = db.ExecContext( var trackNum, discNum int64
"INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)", if tr.trackNum != nil {
tr.id, gID, trackNum = *tr.trackNum
) }
if err != nil {
t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err) if tr.discNum != nil {
} discNum = *tr.discNum
}
id := InsertTestTrack(t, db, TestTrack{
FilePath: tr.filePath,
Title: tr.title,
Artist: tr.artist,
Album: tr.album,
Genres: genres,
TrackNumber: trackNum,
DiscNumber: discNum,
Year: tr.year,
LengthMs: tr.lenMs,
})
// The fixtures assert on audio properties and the composer,
// which InsertTestTrack does not carry - they are not part of
// what a seeder should have to know about a track.
if _, err := db.ExecContext(
`UPDATE audio_files
SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
channels = ?, bitrate = ?, file_size = ?, composer = ?
WHERE id = ?`,
tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
); err != nil {
t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
} }
} }
} }
@@ -553,7 +471,7 @@ func TestSearchFTSTracks(t *testing.T) {
db := NewTestDB(t) db := NewTestDB(t)
seedSearchData(t, db) seedSearchData(t, db)
results, err := db.SearchFTSTracks("queen", 10) results, err := db.SearchFTSTracks("queen", 0, 10)
if err != nil { if err != nil {
t.Fatalf("SearchFTSTracks: %v", err) t.Fatalf("SearchFTSTracks: %v", err)
} }
@@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) {
} }
// Find the Bohemian Rhapsody result and verify all 16 fields. // Find the Bohemian Rhapsody result and verify all 16 fields.
var br *SearchTrackRow var br *sqlcgen.TrackMetadatum
for i, r := range results { for i, r := range results {
if r.Title == "Bohemian Rhapsody" { if r.Title == "Bohemian Rhapsody" {
@@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) {
db := NewTestDB(t) db := NewTestDB(t)
// Set up minimal FK chain for a single track. // Set up minimal FK chain for a single track.
_, err := db.ExecContext( InsertTestTrack(t, db, TestTrack{
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')", FilePath: "/test/track.mp3",
) Title: "Test Track",
if err != nil { Artist: "Test Artist",
t.Fatalf("insert artist_credit: %v", err) LengthMs: 180000,
} })
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)",
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
// Insert into search index. // Insert into search index.
if err := db.InsertSearchIndex( if err := db.InsertSearchIndex(
@@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) {
db := NewTestDB(t) db := NewTestDB(t)
// Seed the full entity graph WITHOUT inserting into search_index. // Seed the file WITHOUT putting it in search_index.
_, err := db.ExecContext( InsertTestTrack(t, db, TestTrack{
"INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')", FilePath: "/rebuild/track.mp3",
) Title: "Rebuild Track",
if err != nil { Artist: "Rebuild Artist",
t.Fatalf("insert artist_credit: %v", err) Album: "Rebuild Album",
} LengthMs: 200000,
SkipSearchIndex: true,
_, err = db.ExecContext( })
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)",
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')",
)
if err != nil {
t.Fatalf("insert release_group: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)",
)
if err != nil {
t.Fatalf("insert release_group_recordings: %v", err)
}
// Search should return nothing before rebuild. // Search should return nothing before rebuild.
results, err := db.SearchFTS("Rebuild", 10) results, err := db.SearchFTS("Rebuild", 10)
@@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
db := NewTestDB(t) db := NewTestDB(t)
// Set up minimal FK chain for a single track at rowid 100. // Set up minimal FK chain for a single track at rowid 100.
_, err := db.ExecContext( id := InsertTestTrack(t, db, TestTrack{
"INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')", FilePath: "/test/update_cycle.mp3",
) Title: "Old Title",
if err != nil { Artist: "Old Artist",
t.Fatalf("insert artist_credit: %v", err) LengthMs: 200000,
} SkipSearchIndex: true,
})
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (100, 'Old Title', 100)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) " +
"VALUES (100, '/test/update_cycle.mp3', 200000, 0, 100)",
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
// 1. Insert with old metadata. // 1. Insert with old metadata.
if err := db.InsertSearchIndex( if err := db.InsertSearchIndex(
100, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album", id, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
); err != nil { ); err != nil {
t.Fatalf("InsertSearchIndex (old): %v", err) t.Fatalf("InsertSearchIndex (old): %v", err)
} }
// Verify search for "Old Title" returns rowid 100. // Verify search for "Old Title" finds it.
results, err := db.SearchFTS("Old Title", 10) results, err := db.SearchFTS("Old Title", 10)
if err != nil { if err != nil {
t.Fatalf("SearchFTS(Old Title): %v", err) t.Fatalf("SearchFTS(Old Title): %v", err)
@@ -926,9 +790,9 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
t.Fatal("SearchFTS(Old Title): got 0 results after insert") t.Fatal("SearchFTS(Old Title): got 0 results after insert")
} }
// 2. Delete rowid 100. // 2. Delete the row.
if err := db.DeleteSearchIndex(100); err != nil { if err := db.DeleteSearchIndex(id); err != nil {
t.Fatalf("DeleteSearchIndex(100): %v", err) t.Fatalf("DeleteSearchIndex(%d): %v", id, err)
} }
// Verify "Old Title" no longer found. // Verify "Old Title" no longer found.
@@ -944,25 +808,17 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
) )
} }
// 3. Update the recording name in the DB to simulate tag edit. // 3. Update the file's title in the DB to simulate a tag edit.
_, err = db.ExecContext( _, err = db.ExecContext(
"UPDATE recordings SET name = 'New Title' WHERE id = 100", "UPDATE audio_files SET title = 'New Title' WHERE file_path = '/test/update_cycle.mp3'",
) )
if err != nil { if err != nil {
t.Fatalf("update recording: %v", err) t.Fatalf("update title: %v", err)
} }
// Also add a new artist_credit for the new artist. // 4. Re-insert the row with new metadata.
_, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (101, 'New Artist')",
)
if err != nil {
t.Fatalf("insert new artist_credit: %v", err)
}
// 4. Re-insert rowid 100 with new metadata.
if err := db.InsertSearchIndex( if err := db.InsertSearchIndex(
100, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album", id, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
); err != nil { ); err != nil {
t.Fatalf("InsertSearchIndex (new): %v", err) t.Fatalf("InsertSearchIndex (new): %v", err)
} }
@@ -1079,25 +935,13 @@ func TestSearchIndexSchema(t *testing.T) {
t.Fatalf("insert artist: %v", err) t.Fatalf("insert artist: %v", err)
} }
// The credit tables this used to assert a UNIQUE constraint on are
// gone; a file names its artist directly, and artists are unique by
// name, which is asserted below.
_, err = db.ExecContext( _, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')", "INSERT INTO artists (id, name) VALUES (2, 'Test')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
)
if err != nil {
t.Fatalf("first insert artist_credit_artist: %v", err)
}
// Duplicate insert should fail with UNIQUE constraint.
_, err = db.ExecContext(
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
) )
if err == nil { if err == nil {
t.Error("duplicate artist_credit_artist insert should fail, got nil error") t.Error("duplicate artist name should fail, got nil error")
} }
} }
@@ -1,10 +0,0 @@
-- Adds SplitMixedFolder's synthetic-group bookkeeping to an
-- existing tagging_items table. A fresh database never runs this
-- file: sql/schemas/tagging_items.sql already declares these
-- columns, so applySchema's isFreshDatabase check stamps this
-- version as applied without executing it.
ALTER TABLE tagging_items ADD COLUMN synthetic INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tagging_items ADD COLUMN parent_group_key TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_tagging_items_parent_group_key
ON tagging_items(parent_group_key) WHERE parent_group_key != '';
@@ -1,24 +0,0 @@
-- Repairs tagging_items rows left behind by a library-scan bug: the
-- rescan's orphan-cleanup phase deleted audio_files rows for files
-- removed from disk without decrementing/clearing their tagging
-- group, so a folder whose contents were fully replaced kept a
-- phantom entry (stale track_count, no matching audio_files) in the
-- autotag queue forever. The library scan code no longer has this
-- gap, but a database written before the fix still carries the
-- damage — this is a one-time repair, not ongoing bookkeeping.
--
-- Drop groups with no audio_files left at all.
DELETE FROM tagging_items
WHERE group_key NOT IN (
SELECT DISTINCT group_key FROM audio_files WHERE group_key != ''
);
-- Reconcile track_count for groups that are still alive but drifted
-- (some, not all, of their tracks were removed without decrementing).
UPDATE tagging_items
SET track_count = (
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
)
WHERE track_count != (
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
);
@@ -1 +0,0 @@
ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0;
@@ -1,3 +0,0 @@
ALTER TABLE queue ADD COLUMN source_type TEXT NOT NULL DEFAULT '';
ALTER TABLE queue ADD COLUMN source_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE queue ADD COLUMN source_label TEXT NOT NULL DEFAULT '';
@@ -1 +0,0 @@
ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT;
@@ -1 +0,0 @@
ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER;
+137
View File
@@ -0,0 +1,137 @@
-- Queries over albums (formerly release_groups).
--
-- The two-copy pattern is gone here too: one query answers both the
-- whole-library and the single-library case. The `fallback_ac`
-- subquery every album read used to carry -- "if the album has no album
-- artist credit, borrow one from any of its recordings" -- is gone with
-- it, because the album carries its own credit text now.
-- name: UpsertAlbum :one
INSERT INTO albums (name, artist_credit, artist_id, year, cover_art_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name, artist_credit) DO UPDATE SET
artist_id = COALESCE(excluded.artist_id, albums.artist_id),
year = COALESCE(excluded.year, albums.year),
cover_art_id = COALESCE(excluded.cover_art_id, albums.cover_art_id)
RETURNING *;
-- name: GetAlbum :one
SELECT * FROM albums WHERE id = ? LIMIT 1;
-- name: SetAlbumMBID :exec
UPDATE albums SET mbid = ? WHERE id = ?;
-- name: SetAlbumOriginalYear :exec
UPDATE albums SET original_year = ? WHERE id = ?;
-- name: SetAlbumCoverArt :exec
UPDATE albums SET cover_art_id = ? WHERE id = ?;
-- name: SetAlbumPendingReleaseMBID :exec
UPDATE albums SET pending_release_mbid = ? WHERE id = ?;
-- name: ResolveAlbumPendingReleaseMBID :exec
-- Clears the pending marker once the release-group MBID it stood in for
-- has been resolved. Guarded so a real MBID is never overwritten.
UPDATE albums
SET mbid = ?, pending_release_mbid = NULL
WHERE id = ? AND (mbid IS NULL OR mbid = '');
-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '');
-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?;
-- name: DeleteAllAlbums :exec
DELETE FROM albums;
-- name: GetEmptyAlbumIDs :many
-- Albums with no file left behind them. Under the old schema this was
-- one of three orphan sweeps that had to run by hand and did not;
-- audio_files is the only thing that can leave an album empty now, so
-- this is the whole of it.
SELECT id FROM albums al
WHERE NOT EXISTS (
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
);
-- name: GetAlbums :many
SELECT
al.id,
al.name,
COALESCE(al.original_year, al.year) AS year,
COALESCE(al.year, 0) AS release_year,
al.mbid,
al.artist_credit AS artist_name,
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
COALESCE(ca.file_path, '') AS cover_art_path
FROM albums al
LEFT JOIN artists ar ON ar.id = al.artist_id
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
WHERE EXISTS (
SELECT 1 FROM audio_files af
WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
)
ORDER BY al.name;
-- name: GetAlbumsByArtistName :many
SELECT
al.id,
al.name,
COALESCE(al.original_year, al.year) AS year,
COALESCE(al.year, 0) AS release_year,
al.mbid,
al.artist_credit AS artist_name,
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
COALESCE(ca.file_path, '') AS cover_art_path
FROM albums al
LEFT JOIN artists ar ON ar.id = al.artist_id
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
WHERE (al.artist_credit = sqlc.arg(artist) OR ar.name = sqlc.arg(artist))
AND EXISTS (
SELECT 1 FROM audio_files af
WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
)
ORDER BY year, al.name;
-- name: GetAlbumCompleteness :one
-- "Do I have all of this album", answered from the tags on disk.
--
-- The expectation is a **sum over discs**, not one number: totals are
-- declared per disc ("5/12" on disc 2 means 12 tracks on disc 2), so a
-- multi-disc album's expectation is the sum of each disc's declared
-- total. A disc whose files declared nothing leaves the whole album
-- unknowable rather than being covered by the discs that did -- which is
-- what `known` reports.
--
-- Owned counts DISTINCT track numbers: this app detects duplicates, and
-- counting two files of track 3 twice would report a short album as
-- complete.
SELECT
-- Distinct (disc, track) pairs: this app detects duplicates, and
-- counting two files of track 3 twice would report a short album as
-- complete. A file with no track number falls back to its own id,
-- because three untagged files are three tracks, not one.
CAST(COUNT(DISTINCT CAST(COALESCE(a.disc_number, 1) AS TEXT) || ':' ||
COALESCE(CAST(a.track_number AS TEXT), 'f' || a.id)
) AS INTEGER) AS owned,
CAST(COALESCE((
SELECT SUM(per_disc.total)
FROM (
SELECT MAX(b.total_tracks) AS total
FROM audio_files b
WHERE b.album_id = sqlc.arg(album_id) AND b.total_tracks IS NOT NULL
GROUP BY COALESCE(b.disc_number, 1)
) per_disc
), 0) AS INTEGER) AS expected,
CAST((
SELECT COUNT(*) = 0 FROM audio_files c
WHERE c.album_id = sqlc.arg(album_id) AND c.total_tracks IS NULL
) AS INTEGER) AS known
FROM audio_files a
WHERE a.album_id = sqlc.arg(album_id);
@@ -1,42 +0,0 @@
-- name: CreateArtistCredit :one
INSERT INTO artist_credit (text) VALUES (?)
RETURNING *;
-- name: GetArtistCredit :one
SELECT * FROM artist_credit
WHERE id = ? LIMIT 1;
-- name: GetArtistCreditByText :one
SELECT * FROM artist_credit
WHERE text = ? LIMIT 1;
-- name: UpsertArtistCredit :one
INSERT INTO artist_credit (text) VALUES (?)
ON CONFLICT(text) DO UPDATE SET text = excluded.text
RETURNING *;
-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
WHERE id = ?;
-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id = ?;
-- name: DeleteAllArtistCredits :exec
DELETE FROM artist_credit;
-- name: CountArtistCreditReferences :one
SELECT
(SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) +
(SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1)
AS total;
-- name: GetOrphanedArtistCreditIDs :many
-- Artist credits no longer used by any recording or release group - run
-- after orphaned recordings/release groups are deleted, so a credit
-- that only existed for now-removed tracks is cleaned up too.
SELECT ac.id FROM artist_credit ac
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id);
@@ -1,24 +0,0 @@
-- name: CreateArtistCreditArtist :one
INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)
RETURNING *;
-- name: GetArtistCreditArtist :one
SELECT * FROM artist_credit_artist
WHERE id = ? LIMIT 1;
-- name: UpdateArtistCreditArtist :exec
UPDATE artist_credit_artist
SET artist_id = ?, credit_id = ?
WHERE id =?;
-- name: DeleteArtistCreditArtist :exec
DELETE FROM artist_credit_artist
WHERE id =?;
-- name: DeleteAllArtistCreditArtists :exec
DELETE FROM artist_credit_artist;
-- name: DeleteArtistCreditArtistByCredit :exec
DELETE FROM artist_credit_artist
WHERE credit_id = ?;
+36 -48
View File
@@ -1,67 +1,55 @@
-- name: CreateArtist :one -- Queries over artists.
INSERT INTO artists (name) VALUES (?) --
-- An artist row is reachable two ways: as a file's primary artist
-- (audio_files.artist_id) and as an album's artist (albums.artist_id).
-- Both used to route through artist_credit + artist_credit_artist,
-- which is how "which album artists are in library 2" came to be a
-- five-join subquery inside a three-join query.
-- name: UpsertArtist :one
INSERT INTO artists (name, mbid) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
mbid = COALESCE(excluded.mbid, artists.mbid)
RETURNING *; RETURNING *;
-- name: GetArtist :one -- name: GetArtist :one
SELECT * FROM artists SELECT * FROM artists WHERE id = ? LIMIT 1;
WHERE id = ? LIMIT 1;
-- name: GetArtistByName :one -- name: GetArtistByName :one
SELECT * FROM artists SELECT * FROM artists WHERE name = ? LIMIT 1;
WHERE name = ? LIMIT 1;
-- name: UpsertArtist :one -- name: SetArtistMBID :exec
INSERT INTO artists (name) VALUES (?) UPDATE artists SET mbid = ? WHERE id = ?;
ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING *;
-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
WHERE id = ?;
-- name: DeleteArtist :exec -- name: DeleteArtist :exec
DELETE FROM artists DELETE FROM artists WHERE id = ?;
WHERE id = ?;
-- name: DeleteAllArtists :exec -- name: DeleteAllArtists :exec
DELETE FROM artists; DELETE FROM artists;
-- name: GetUnreferencedArtistIDs :many
-- Artists no file and no album points at any more.
SELECT id FROM artists a
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id);
-- name: GetAllArtists :many -- name: GetAllArtists :many
SELECT * FROM artists SELECT * FROM artists ORDER BY name;
ORDER BY name;
-- name: GetAlbumArtists :many -- name: GetAlbumArtists :many
SELECT DISTINCT a.id, a.name, a.mbid SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id JOIN albums al ON al.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id WHERE EXISTS (
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id SELECT 1 FROM audio_files af
ORDER BY a.name; WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
-- name: GetOrphanedArtistIDs :many
-- Artists no longer credited on any recording or release group - left
-- behind when a scan's orphan cleanup removes the audio_files that used
-- to justify them, since deleting an audio_files row doesn't cascade.
SELECT a.id FROM artists a
WHERE NOT EXISTS (
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
);
-- name: GetAlbumArtistsByLibrary :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
WHERE a.id IN (
SELECT DISTINCT aca2.artist_id
FROM artist_credit_artist aca2
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
) )
ORDER BY a.name; ORDER BY a.name;
-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
LEFT JOIN artists a ON a.id = af.artist_id
WHERE af.file_path = ?
LIMIT 1;
+170 -310
View File
@@ -1,39 +1,60 @@
-- Queries over audio_files and the track_metadata view above it.
--
-- Every query that returns "a track" selects from `track_metadata`,
-- which is the one place the projection is defined. The scoped and
-- unscoped variants that used to be written twice are one query now:
-- library_id 0 means "every library", and `(:id = 0 OR library_id = :id)`
-- costs nothing measurable (23 ms vs 21 ms over 26k rows) because these
-- queries scan either way.
-- ---------------------------------------------------------------------
-- Writes
-- ---------------------------------------------------------------------
-- name: CreateAudioFile :one -- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files ( INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id, file_path, library_id, file_type_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size,
library_id, group_key, tag_status, modified_at title, artist_credit, artist_id, album_id,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) track_number, disc_number, total_tracks, year, composer, comment,
recording_mbid, basename, group_key, modified_at, tag_status
) VALUES (
?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)
RETURNING *; RETURNING *;
-- name: GetAudioFileGroupKey :one -- name: UpdateAudioFileTags :exec
SELECT group_key FROM audio_files -- A rescan of a file whose mtime moved: the tags are re-read and
WHERE id = ? LIMIT 1; -- written over the same row. Under the old schema this created a
-- *new* recording and repointed the file at it, abandoning the old one
-- -- which is where 812 orphaned rows and every phantom "you own this"
-- came from. There is nothing to orphan now.
UPDATE audio_files
SET title = ?, artist_credit = ?, artist_id = ?, album_id = ?,
track_number = ?, disc_number = ?, total_tracks = ?, year = ?,
composer = ?, comment = ?, recording_mbid = ?,
sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?,
file_size = ?, length_milliseconds = ?, modified_at = ?
WHERE id = ?;
-- name: SetAudioFileGroupKey :exec -- name: SetAudioFileGroupKey :exec
UPDATE audio_files SET group_key = ? WHERE id = ?; UPDATE audio_files SET group_key = ? WHERE id = ?;
-- name: GetAudioFile :one -- name: PromoteAudioFileTagStatusIfUntagged :exec
SELECT * FROM audio_files -- A rescan re-reads the tags of a file whose mtime moved, so a file
WHERE id = ? LIMIT 1; -- another tagger stamped with MBIDs since import arrives here still
-- carrying the 'untagged' status it was created with (only the insert
-- name: GetAudioFileByPath :one -- path sets it). Promote it the same way saveAudioFile does.
SELECT * FROM audio_files -- Guarded on 'untagged' so it cannot overwrite a deliberate
WHERE file_path = ? LIMIT 1; -- 'user_skipped_permanent', and so a file losing its MBIDs is left
-- alone -- demotion is the scan's judgement, not this statement's.
-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
WHERE id = ?;
-- name: UpdateAudioFileRecording :exec
UPDATE audio_files UPDATE audio_files
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ? SET tag_status = 'user_confirmed'
WHERE id = ?; WHERE id = ? AND tag_status = 'untagged';
-- name: UpdateAudioFileStat :exec -- name: UpdateAudioFileStat :exec
-- Records the on-disk mtime/size without re-reading tags. Used to -- Records the on-disk mtime/size without re-reading tags. Used to
@@ -43,307 +64,146 @@ UPDATE audio_files
SET modified_at = ?, file_size = ? SET modified_at = ?, file_size = ?
WHERE id = ?; WHERE id = ?;
-- name: SetAudioFileRecordingMBID :exec
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
-- name: DeleteAudioFile :exec
DELETE FROM audio_files WHERE id = ?;
-- name: DeleteAllAudioFiles :exec
DELETE FROM audio_files;
-- ---------------------------------------------------------------------
-- Reads: the file row itself
-- ---------------------------------------------------------------------
-- name: GetAudioFile :one
SELECT * FROM audio_files WHERE id = ? LIMIT 1;
-- name: GetAudioFileByPath :one
SELECT * FROM audio_files WHERE file_path = ? LIMIT 1;
-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files WHERE id = ? LIMIT 1;
-- name: GetAllAudioFilePaths :many
SELECT id, file_path FROM audio_files;
-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (sqlc.slice('paths'));
-- name: GetRandomAudioFilePath :one
SELECT file_path FROM audio_files ORDER BY RANDOM() LIMIT 1;
-- name: CountAudioFiles :one
SELECT COUNT(*) AS count FROM audio_files
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
-- name: GetLibraryMaxModifiedAt :one -- name: GetLibraryMaxModifiedAt :one
-- Newest recorded mtime in a library, for the startup soft scan. 0 when -- Newest recorded mtime in a library, for the startup soft scan. 0 when
-- the library is empty or no row has a baseline yet. -- the library is empty or no row has a baseline yet.
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
WHERE library_id = ?; WHERE library_id = ?;
-- name: DeleteAudioFile :exec -- ---------------------------------------------------------------------
DELETE FROM audio_files -- Reads: tracks
WHERE id = ?; -- ---------------------------------------------------------------------
-- name: CountAudioFiles :one -- name: GetTracks :many
SELECT count(*) FROM audio_files; SELECT * FROM track_metadata
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
-- name: GetRandomAudioFilePath :one -- name: GetTrackByPath :one
SELECT file_path FROM audio_files SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
ORDER BY RANDOM()
LIMIT 1;
-- name: GetAllAudioFiles :many -- name: GetTracksByAlbum :many
SELECT * FROM audio_files; SELECT * FROM track_metadata
WHERE album_id = sqlc.arg(album_id)
AND library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id)
ORDER BY disc_number, track_number;
-- name: GetAllAudioFilePaths :many -- name: GetTracksByGenre :many
SELECT id, file_path FROM audio_files; SELECT tm.* FROM track_metadata tm
JOIN file_genres fg ON fg.audio_file_id = tm.id
-- name: GetAudioFilesNeedingMetadata :many JOIN genres g ON g.id = fg.genre_id
SELECT * FROM audio_files WHERE g.name = sqlc.arg(genre)
WHERE recording_id = 0; AND tm.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), tm.library_id);
-- name: GetAllAudioFilesWithArtist :many
SELECT
af.id,
af.file_path,
af.length_milliseconds,
af.file_type_id,
af.recording_id,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.name, '') AS title
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id;
-- name: GetTrackMetadataByPath :one
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1;
-- name: GetAllTracksWithFullMetadata :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
-- name: SearchAudioFilesByBasename :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
WHERE af.basename = ?
LIMIT ?;
-- name: LookupTrackMetaByPaths :many -- name: LookupTrackMetaByPaths :many
SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid SELECT id, file_path, title, artist_name, album, cover_art_path,
artist_mbid, release_group_mbid, recording_mbid
FROM track_metadata FROM track_metadata
WHERE file_path IN (sqlc.slice('paths')); WHERE file_path IN (sqlc.slice('paths'));
-- name: GetAudioFilesByLibrary :many -- name: SearchTracksByBasename :many
SELECT * FROM audio_files WHERE library_id = ?; SELECT id, file_path, length_milliseconds, title, artist_name, album
FROM track_metadata
WHERE file_path IN (
SELECT file_path FROM audio_files WHERE basename = sqlc.arg(basename)
)
LIMIT sqlc.arg(lim);
-- name: CountAudioFilesByLibrary :one -- ---------------------------------------------------------------------
SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?; -- Reads: file paths, grouped by whatever the caller asked about
-- ---------------------------------------------------------------------
-- These answer "what can I play" and they all ask audio_files, because
-- that is the only table whose rows are files. Grouped rather than
-- flattened because the caller owns the order.
-- name: DeleteAllAudioFiles :exec -- name: GetFilePathsByAlbums :many
DELETE FROM audio_files; -- The library filter is applied in Go rather than here: sqlc numbers a
-- named parameter (?2) but expands a slice into N placeholders, so the
-- name: GetAllTracksWithFullMetadataByLibrary :many -- two together bind the wrong values - GetFilePathsByAlbums([1,2], 0)
SELECT -- read album id 2 as the library id. Returning library_id and
af.file_path, -- filtering the (small) result is the version that cannot be wrong.
af.length_milliseconds, SELECT album_id, library_id, file_path FROM audio_files
COALESCE(r.name, '') AS title, WHERE album_id IN (sqlc.slice('album_ids'))
COALESCE(ac.text, '') AS artist_name, ORDER BY disc_number, track_number;
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE af.library_id = ?;
-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
rgr.track_number,
rgr.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE rgr.release_group_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
-- name: GetAudioFilesByReleaseGroupByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
rgr.track_number,
rgr.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE rgr.release_group_id = ? AND af.library_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
-- "Play this artist" and "play these albums" wanted file paths and asked
-- for whole track rows to get them, one round trip per album (perf.m2).
-- These answer the same question in one query and carry only what the
-- caller uses; the release group id comes back so the caller can keep
-- its own album ordering.
-- name: GetFilePathsByReleaseGroups :many
SELECT rgr.release_group_id, af.file_path
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
ORDER BY rgr.disc_number, rgr.track_number;
-- name: GetFilePathsByReleaseGroupsByLibrary :many
SELECT rgr.release_group_id, af.file_path
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
AND af.library_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
-- Same shape again, keyed on recording MBID, for the catalog side.
-- An Explore album page knows which of its tracks the user owns only
-- as a set of recording MBIDs -- that is exactly how the backend
-- decides `inLibrary` (markReleasesInLibrary -> CheckMBIDs) -- and
-- MBTrack.LocalID is declared but never written by anything, so there
-- is no id to ask by. Grouped by MBID because a recording can have
-- more than one file (the duplicate fixtures are precisely that) and
-- because the caller owns the order: the tracklist's, not the
-- database's.
-- name: GetFilePathsByRecordingMBIDs :many -- name: GetFilePathsByRecordingMBIDs :many
SELECT r.mbid AS recording_mbid, af.file_path -- The ownership question in its only honest form: which of these
FROM recordings r -- catalog recordings has a *file* behind it. Asked of audio_files, so
JOIN audio_files af ON af.recording_id = r.id -- a metadata row with no file cannot answer yes.
WHERE r.mbid IN (sqlc.slice('mbids')) SELECT recording_mbid, library_id, file_path FROM audio_files
ORDER BY af.file_path; WHERE recording_mbid IN (sqlc.slice('mbids'))
ORDER BY file_path;
-- name: GetFilePathsByRecordingMBIDsByLibrary :many -- name: GetFilePathsByGenres :many
SELECT r.mbid AS recording_mbid, af.file_path SELECT g.name AS genre, af.library_id, af.file_path
FROM recordings r FROM audio_files af
JOIN audio_files af ON af.recording_id = r.id JOIN file_genres fg ON fg.audio_file_id = af.id
WHERE r.mbid IN (sqlc.slice('mbids')) JOIN genres g ON g.id = fg.genre_id
AND af.library_id = ? WHERE g.name IN (sqlc.slice('genres'))
ORDER BY af.file_path; ORDER BY af.disc_number, af.track_number;
-- name: GetAudioFilesByPaths :many -- name: GetFilePathsByArtistMBID :many
SELECT id, library_id, file_path, group_key FROM audio_files SELECT DISTINCT af.file_path
WHERE file_path IN (sqlc.slice('paths')); FROM audio_files af
JOIN artists a ON a.id = af.artist_id
WHERE a.mbid = ?;
-- ---------------------------------------------------------------------
-- Ownership, asked in bulk
-- ---------------------------------------------------------------------
-- name: OwnedRecordingMBIDs :many
-- Which of these recording MBIDs are actually in the library. This is
-- what marks a catalog tracklist owned; it used to be
-- `SELECT mbid FROM recordings`, which answered yes for 129 tracks in a
-- real library that had no file at all.
SELECT DISTINCT recording_mbid FROM audio_files
WHERE recording_mbid IN (sqlc.slice('mbids'));
-- name: OwnedAlbumMBIDs :many
SELECT DISTINCT al.mbid FROM albums al
JOIN audio_files af ON af.album_id = al.id
WHERE al.mbid IN (sqlc.slice('mbids'));
-- name: OwnedArtistMBIDs :many
SELECT DISTINCT a.mbid FROM artists a
JOIN audio_files af ON af.artist_id = a.id
WHERE a.mbid IN (sqlc.slice('mbids'));
-- name: GetAudioFilesInLibrary :many
SELECT * FROM audio_files WHERE library_id = ?;
+35 -135
View File
@@ -1,150 +1,50 @@
-- Queries over genres and file_genres.
--
-- The track-returning ones live in audio_files.sql with the rest of the
-- track_metadata reads; what is left here is the genre list itself and
-- the link table's writes.
-- name: UpsertGenre :one -- name: UpsertGenre :one
INSERT INTO genres (name) VALUES (?) INSERT INTO genres (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = name ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING *; RETURNING *;
-- name: CreateRecordingGenre :exec -- name: LinkFileGenre :exec
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?);
VALUES (?, ?);
-- name: DeleteRecordingGenres :exec -- name: DeleteFileGenres :exec
DELETE FROM recording_genres DELETE FROM file_genres WHERE audio_file_id = ?;
WHERE recording_id = ?;
-- name: GetGenresByRecordingID :many -- name: GetGenreNamesByFile :many
SELECT g.* SELECT g.name FROM genres g
FROM genres g JOIN file_genres fg ON fg.genre_id = g.id
JOIN recording_genres rg ON g.id = rg.genre_id WHERE fg.audio_file_id = ?;
WHERE rg.recording_id = ?;
-- name: DeleteAllRecordingGenres :exec -- name: GetGenreNamesByFilePaths :many
DELETE FROM recording_genres; -- Genres for many files at once. The mix builder asked this one file
-- at a time, inside two nested loops -- twelve thousand single-row
-- name: DeleteAllGenres :exec -- queries to assemble one mix.
DELETE FROM genres; SELECT af.file_path, g.name
FROM audio_files af
-- name: GetTracksByGenre :many JOIN file_genres fg ON fg.audio_file_id = af.id
SELECT JOIN genres g ON g.id = fg.genre_id
af.file_path, WHERE af.file_path IN (sqlc.slice('paths'));
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ?
ORDER BY r.name;
-- name: GetTracksByGenreByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ? AND af.library_id = ?
ORDER BY r.name;
-- name: CountGenreReferences :one
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?;
-- name: DeleteGenre :exec -- name: DeleteGenre :exec
DELETE FROM genres WHERE id = ?; DELETE FROM genres WHERE id = ?;
-- name: DeleteAllGenres :exec
DELETE FROM genres;
-- name: GetUnusedGenreIDs :many
SELECT id FROM genres g
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id);
-- name: GetAllGenresWithCounts :many -- name: GetAllGenresWithCounts :many
SELECT g.name, COUNT(rg.recording_id) AS track_count SELECT g.name, COUNT(fg.audio_file_id) AS track_count
FROM genres g FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id JOIN file_genres fg ON fg.genre_id = g.id
JOIN audio_files af ON af.id = fg.audio_file_id
WHERE af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
GROUP BY g.id, g.name GROUP BY g.id, g.name
ORDER BY g.name; ORDER BY g.name;
-- name: GetAllGenresWithCountsByLibrary :many
SELECT g.name, COUNT(rg.recording_id) AS track_count
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.library_id = ?
GROUP BY g.id, g.name
ORDER BY g.name;
-- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
-- one query instead of one per genre, and file paths instead of whole
-- track rows, which was 6 MB over the IPC for five genres.
-- name: GetFilePathsByGenres :many
SELECT g.name AS genre_name, af.file_path
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (sqlc.slice('genre_names'))
ORDER BY r.name;
-- name: GetFilePathsByGenresByLibrary :many
SELECT g.name AS genre_name, af.file_path
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (sqlc.slice('genre_names'))
AND af.library_id = ?
ORDER BY r.name;
+25 -33
View File
@@ -2,16 +2,15 @@
-- --
-- Every one of these returns album ids and nothing else. The display -- Every one of these returns album ids and nothing else. The display
-- columns (cover art, artist credit, year) already have exactly one -- columns (cover art, artist credit, year) already have exactly one
-- correct expression of them, in GetAllAlbumsWithDetails, and a second -- correct expression of them, in GetAlbums, and a second
-- copy per shelf would be six more places for that to drift. The home -- copy per shelf would be six more places for that to drift. The home
-- service joins the ids back to that one album list in Go. -- service joins the ids back to that one album list in Go.
-- name: HomeRecentlyPlayedAlbums :many -- name: HomeRecentlyPlayedAlbums :many
-- Albums with the most recent play, newest first. -- Albums with the most recent play, newest first.
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL WHERE af.last_played IS NOT NULL
GROUP BY rg.id GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC ORDER BY MAX(af.last_played) DESC
@@ -22,9 +21,8 @@ LIMIT ?;
-- stands in for one: it is monotonic and assigned at import, which is -- stands in for one: it is monotonic and assigned at import, which is
-- the same ordering an added_at column would give. -- the same ordering an added_at column would give.
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
ORDER BY MAX(af.id) DESC ORDER BY MAX(af.id) DESC
LIMIT ?; LIMIT ?;
@@ -32,9 +30,8 @@ LIMIT ?;
-- name: HomeMostPlayedAlbums :many -- name: HomeMostPlayedAlbums :many
-- Albums by total plays across their tracks. -- Albums by total plays across their tracks.
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
HAVING SUM(af.play_count) > 0 HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC ORDER BY SUM(af.play_count) DESC
@@ -45,9 +42,8 @@ LIMIT ?;
-- shelf is a different suggestion each time rather than the same -- shelf is a different suggestion each time rather than the same
-- alphabetical head of the list forever. -- alphabetical head of the list forever.
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
HAVING SUM(af.play_count) = 0 HAVING SUM(af.play_count) = 0
ORDER BY RANDOM() ORDER BY RANDOM()
@@ -56,9 +52,8 @@ LIMIT ?;
-- name: HomeStaleAlbums :many -- name: HomeStaleAlbums :many
-- Played before, but not for a long while. -- Played before, but not for a long while.
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL WHERE af.last_played IS NOT NULL
GROUP BY rg.id GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?) HAVING MAX(af.last_played) < datetime('now', ?)
@@ -67,9 +62,8 @@ LIMIT ?;
-- name: HomeRandomAlbums :many -- name: HomeRandomAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ?; LIMIT ?;
@@ -78,10 +72,10 @@ LIMIT ?;
-- A random sample of albums carrying a genre, so the same genre shelf -- A random sample of albums carrying a genre, so the same genre shelf
-- is not the same ten albums every time the page opens. -- is not the same ten albums every time the page opens.
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = rgen.genre_id JOIN genres g ON g.id = fg.genre_id
WHERE g.name = ? WHERE g.name = ?
GROUP BY rg.id GROUP BY rg.id
ORDER BY RANDOM() ORDER BY RANDOM()
@@ -93,10 +87,10 @@ LIMIT ?;
-- album carries is a shelf about that one album. -- album carries is a shelf about that one album.
SELECT SELECT
g.name AS genre, g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count COUNT(DISTINCT af.album_id) AS album_count
FROM genres g FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id JOIN file_genres fg ON fg.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id JOIN audio_files af ON af.id = fg.audio_file_id
GROUP BY g.id GROUP BY g.id
HAVING album_count >= 3 HAVING album_count >= 3
ORDER BY album_count DESC ORDER BY album_count DESC
@@ -106,14 +100,12 @@ LIMIT ?;
-- Artists by total plays, as the album-artist credit text the album -- Artists by total plays, as the album-artist credit text the album
-- list already displays. -- list already displays.
SELECT SELECT
COALESCE(ac.text, '') AS artist_name, rg.artist_credit AS artist_name,
SUM(af.play_count) AS plays SUM(af.play_count) AS plays
FROM release_groups rg FROM albums rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id JOIN audio_files af ON af.album_id = rg.id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id WHERE rg.artist_credit <> ''
JOIN audio_files af ON af.recording_id = rgr.recording_id GROUP BY rg.artist_credit
WHERE ac.text <> ''
GROUP BY ac.text
HAVING plays > 0 HAVING plays > 0
ORDER BY plays DESC ORDER BY plays DESC
LIMIT ?; LIMIT ?;
-30
View File
@@ -1,30 +0,0 @@
-- Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
-- expanding a seed selection into a candidate pool by artist similarity
-- and genre overlap, restricted to what is actually in the library.
-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?;
-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?;
-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1;
+25 -66
View File
@@ -47,29 +47,18 @@ SELECT
pt.playlist_id, pt.playlist_id,
pt.audio_file_id, pt.audio_file_id,
pt.position, pt.position,
COALESCE(af.file_path, '') AS file_path, COALESCE(tm.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds, COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title, COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid, CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid, COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE pt.playlist_id = ? WHERE pt.playlist_id = ?
ORDER BY pt.position; ORDER BY pt.position;
@@ -79,29 +68,18 @@ SELECT
pt.playlist_id, pt.playlist_id,
pt.audio_file_id, pt.audio_file_id,
pt.position, pt.position,
COALESCE(af.file_path, '') AS file_path, COALESCE(tm.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds, COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title, COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid, CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid, COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY pt.playlist_id, pt.position; ORDER BY pt.playlist_id, pt.position;
-- name: DeleteAllPlaylistTracks :exec -- name: DeleteAllPlaylistTracks :exec
@@ -132,27 +110,8 @@ WHERE playlist_id = ? AND audio_file_id = (
); );
-- name: GetTrackPhantomMetadata :one -- name: GetTrackPhantomMetadata :one
SELECT -- The display fields a playlist row keeps after its file goes away.
COALESCE(r.name, '') AS title, SELECT title, artist_name AS artist, album,
COALESCE(ac.text, '') AS artist, length_milliseconds AS duration_ms, genre, cover_art_path
COALESCE(rg.name, '') AS album, FROM track_metadata
af.length_milliseconds AS duration_ms, WHERE id = ?;
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.id = ?;
+5 -20
View File
@@ -13,27 +13,12 @@ SET current_position = ?
WHERE id = 1; WHERE id = 1;
-- name: GetQueueTracks :many -- name: GetQueueTracks :many
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path, -- The queue's rows, joined to the one track projection.
COALESCE(r.name, '') AS title, SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
COALESCE(ac.text, '') AS artist, tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
COALESCE(rg.name, '') AS album, tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM queue_tracks qt FROM queue_tracks qt
JOIN audio_files af ON qt.audio_file_id = af.id JOIN track_metadata tm ON tm.id = qt.audio_file_id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY qt.position; ORDER BY qt.position;
-- name: GetQueueTrackCount :one -- name: GetQueueTrackCount :one
@@ -1,47 +0,0 @@
-- name: CreateRecording :one
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
RETURNING *;
-- name: CreateRecordingFull :one
INSERT INTO recordings (
name, artist_credit_id, track_number, disc_number,
year, genre, composer, lyrics, comment
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetRecording :one
SELECT * FROM recordings
WHERE id = ? LIMIT 1;
-- name: UpdateRecording :exec
UPDATE recordings
SET name = ?, artist_credit_id = ?
WHERE id = ?;
-- name: UpdateRecordingFull :exec
UPDATE recordings
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
WHERE id = ?;
-- name: DeleteRecording :exec
DELETE FROM recordings
WHERE id = ?;
-- name: DeleteAllRecordings :exec
DELETE FROM recordings;
-- name: GetAllRecordings :many
SELECT * FROM recordings
ORDER BY name;
-- name: CountRecordingsByArtistCredit :one
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?;
-- name: GetOrphanedRecordingIDs :many
-- Recordings no longer backed by any audio_files row - left behind
-- when a scan's orphan cleanup deletes the file that used to own them,
-- since deleting audio_files doesn't cascade to recordings.
SELECT r.id FROM recordings r
LEFT JOIN audio_files af ON af.recording_id = r.id
WHERE af.id IS NULL;
@@ -1,50 +0,0 @@
-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (
release_group_id, recording_id, track_number, disc_number, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs;
-- name: GetReleaseGroupRecording :one
SELECT * FROM release_group_recordings
WHERE id = ? LIMIT 1;
-- name: GetReleaseGroupRecordings :many
SELECT * FROM release_group_recordings
WHERE release_group_id = ?
ORDER BY disc_number, track_number;
-- name: GetRecordingReleaseGroups :many
SELECT * FROM release_group_recordings
WHERE recording_id = ?;
-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
WHERE id = ?;
-- name: DeleteReleaseGroupRecordingByFK :exec
DELETE FROM release_group_recordings
WHERE release_group_id = ? AND recording_id = ?;
-- name: DeleteAllReleaseGroupRecordings :exec
DELETE FROM release_group_recordings;
-- name: DeleteReleaseGroupRecordingsByRecording :exec
DELETE FROM release_group_recordings
WHERE recording_id = ?;
@@ -1,216 +0,0 @@
-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING *;
-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetReleaseGroup :one
SELECT * FROM release_groups
WHERE id = ? LIMIT 1;
-- name: GetReleaseGroupByNameAndArtist :one
SELECT * FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1;
-- name: UpsertReleaseGroup :one
INSERT INTO release_groups (name, album_artist_credit_id, year)
VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING *;
-- name: SetReleaseGroupOriginalYear :exec
-- Set the release group's original-release-year (release-group's
-- first-release-date from MusicBrainz). Called from autotag apply
-- when the user confirms a candidate; the file-tag year stays in
-- the year column.
UPDATE release_groups SET original_year = ? WHERE id = ?;
-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
WHERE id = ?;
-- name: UpdateReleaseGroupCoverArt :exec
UPDATE release_groups
SET cover_art_id = ?
WHERE id = ?;
-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
WHERE id = ?;
-- name: DeleteAllReleaseGroups :exec
DELETE FROM release_groups;
-- name: GetAllReleaseGroups :many
SELECT * FROM release_groups
ORDER BY name;
-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
rg.name,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
ORDER BY rg.name;
-- name: GetAllAlbumsWithDetailsByLibrary :many
SELECT
rg.id,
rg.name,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
WHERE rg.id IN (
SELECT DISTINCT rgr2.release_group_id
FROM release_group_recordings rgr2
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
)
ORDER BY rg.name;
-- name: GetAlbumsByArtist :many
SELECT
rg.id,
rg.name,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
WHERE aca.artist_id = ?
ORDER BY rg.name;
-- name: CountReleaseGroupRecordings :one
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
-- name: GetOrphanedReleaseGroupIDs :many
-- Release groups with no recordings left in them - run after orphaned
-- recordings (and their release_group_recordings rows) are deleted, so
-- a release group whose last owned track was removed is cleaned up too.
SELECT rg.id FROM release_groups rg
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
WHERE rgr.id IS NULL;
-- name: GetAlbumsByArtistByLibrary :many
SELECT
rg.id,
rg.name,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
WHERE aca.artist_id = ?
AND rg.id IN (
SELECT DISTINCT rgr2.release_group_id
FROM release_group_recordings rgr2
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
)
ORDER BY rg.name;
+87 -62
View File
@@ -87,10 +87,7 @@ LIMIT 1;
SELECT ti.group_key SELECT ti.group_key
FROM tagging_items ti FROM tagging_items ti
JOIN audio_files af ON af.group_key = ti.group_key JOIN audio_files af ON af.group_key = ti.group_key
LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN albums rg ON rg.id = af.album_id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE ti.synthetic = 0 WHERE ti.synthetic = 0
AND ti.track_count >= 4 AND ti.track_count >= 4
AND ( AND (
@@ -98,13 +95,23 @@ WHERE ti.synthetic = 0
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown') OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
) )
GROUP BY ti.group_key GROUP BY ti.group_key
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1 HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1; AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
-- name: CountPendingTaggingItems :one -- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items -- "Needs tagging" is a question about the files, not about the row:
WHERE status = 'pending' -- every scanned folder gets a tagging_items row (see
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id); -- UpsertTaggingItemOnTrackAdd), including one whose files all arrived
-- carrying a recording MBID. Without the EXISTS a fully MB-tagged
-- library reports its entire album count as pending work. See the
-- same predicate on the three list queries below.
SELECT COUNT(*) FROM tagging_items ti
WHERE ti.status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
);
-- name: ListPendingTaggingItemsAlphabetical :many -- name: ListPendingTaggingItemsAlphabetical :many
SELECT SELECT
@@ -125,6 +132,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter) AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL AND ti.cleared_at IS NULL
-- Actionable rows must have something to act on: see
-- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are
-- exempt because they are history, not work -- an applied folder is
-- fully tagged by definition and would otherwise vanish from the
-- sidebar's Completed section the instant it succeeded.
AND (
ti.status IN ('confirmed', 'skipped')
OR EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
)
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT @row_limit OFFSET @row_offset; LIMIT @row_limit OFFSET @row_offset;
@@ -150,6 +169,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter) AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL AND ti.cleared_at IS NULL
-- See ListPendingTaggingItemsAlphabetical.
AND (
ti.status IN ('confirmed', 'skipped')
OR EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
)
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name) ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT @row_limit OFFSET @row_offset; LIMIT @row_limit OFFSET @row_offset;
@@ -204,61 +231,53 @@ ORDER BY ti.created_at DESC, ti.group_key
LIMIT @row_limit OFFSET @row_offset; LIMIT @row_limit OFFSET @row_offset;
-- name: ListAudioFilesInTaggingGroup :many -- name: ListAudioFilesInTaggingGroup :many
-- album_name/album_artist are the PER-TRACK tags (via each track's -- album_name/album_artist are the PER-TRACK tags (each file's own
-- own release_group link), not the folder-level tagging_items -- album link), not the folder-level tagging_items values.
-- values. SplitMixedFolder clusters on these to find sub-albums -- SplitMixedFolder clusters on these to find sub-albums hiding inside
-- hiding inside a folder full of unrelated tracks. -- a folder full of unrelated tracks.
SELECT SELECT
af.id, af.id,
af.file_path, af.file_path,
af.basename, af.basename,
af.length_milliseconds, af.length_milliseconds,
af.tag_status, af.tag_status,
COALESCE(r.track_number, 0) AS track_number, COALESCE(af.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number, COALESCE(af.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title, af.title,
COALESCE(ac.text, '') AS artist_name, af.artist_credit AS artist_name,
COALESCE(r.mbid, '') AS recording_mbid, COALESCE(af.recording_mbid, '') AS recording_mbid,
COALESCE(rg.name, '') AS album_name, COALESCE(al.name, '') AS album_name,
COALESCE(rgac.text, '') AS album_artist COALESCE(al.artist_credit, '') AS album_artist
FROM audio_files af FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN albums al ON al.id = af.album_id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
WHERE af.group_key = ? WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0), ORDER BY COALESCE(af.disc_number, 0),
COALESCE(r.track_number, 0), COALESCE(af.track_number, 0),
af.file_path; af.file_path;
-- name: ListLocalReleaseGroupCandidates :many -- name: ListLocalAlbumCandidates :many
-- Returns one row per (release_group, track) combination for any -- One row per (album, track) for any local album carrying an MBID.
-- local release_group that has an MBID. Callers group these in Go -- Callers group these in Go and filter by normalized album-name match;
-- and filter by normalized album-name match. Joined case-insensitive -- the join is case-insensitive on name to pre-filter cheaply.
-- on name to pre-filter cheaply; Go does the real normalization.
SELECT SELECT
rg.id AS release_group_id, al.id AS album_id,
rg.mbid AS release_group_mbid, al.mbid AS album_mbid,
rg.name AS album_name, al.name AS album_name,
COALESCE(rg.year, 0) AS year, COALESCE(al.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit, al.artist_credit,
COALESCE(rgr.track_number, 0) AS track_number, COALESCE(af.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number, COALESCE(af.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title, af.title AS track_title,
COALESCE(r.mbid, '') AS recording_mbid, COALESCE(af.recording_mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds af.length_milliseconds
FROM release_groups rg FROM albums al
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = al.id
JOIN recordings r ON r.id = rgr.recording_id WHERE al.mbid IS NOT NULL
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id AND al.mbid != ''
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id AND af.recording_mbid IS NOT NULL
WHERE rg.mbid IS NOT NULL AND af.recording_mbid != ''
AND rg.mbid != '' AND al.name = ? COLLATE NOCASE
AND r.mbid IS NOT NULL ORDER BY al.id, af.disc_number, af.track_number;
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
-- name: SetTaggingItemBestMatch :exec -- name: SetTaggingItemBestMatch :exec
UPDATE tagging_items UPDATE tagging_items
@@ -284,17 +303,16 @@ WHERE group_key = ?;
-- name: SetAudioFileTagStatus :exec -- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?; UPDATE audio_files SET tag_status = ? WHERE id = ?;
-- name: SetRecordingMBID :exec -- name: SetFileRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?; UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
-- name: SetReleaseGroupMBID :exec -- name: SetFileAlbumMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?; -- The album MBID for the album a file belongs to. Keyed by file
-- because that is what the autotag apply path holds; under the old
-- name: GetRecordingReleaseGroupID :one -- schema it had to look the release group up through two join tables
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id -- first (GetRecordingReleaseGroupID), which is gone.
FROM release_group_recordings rgr UPDATE albums SET mbid = ?
WHERE rgr.recording_id = ? WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?);
LIMIT 1;
-- name: GetNextPendingTaggingItem :one -- name: GetNextPendingTaggingItem :one
SELECT SELECT
@@ -315,5 +333,12 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending' WHERE ti.status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND ti.group_key > @after_group_key AND ti.group_key > @after_group_key
-- See CountPendingTaggingItems: the cursor must not stop on a
-- folder the list query no longer shows, or "next" walks folders
-- that are not in the sidebar.
AND EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
ORDER BY ti.group_key ORDER BY ti.group_key
LIMIT 1; LIMIT 1;
+43
View File
@@ -0,0 +1,43 @@
-- One row per album in the library.
--
-- This is `release_groups` renamed, and the rename is the point: a
-- release group is a *MusicBrainz* concept and the catalog still has
-- them (`explore_index.entity_type = 'release_group'`). What this
-- table holds is the local thing — the album some files on disk belong
-- to — which may or may not have a catalog counterpart. Calling both
-- of them "release group" is most of why "is this album mine" was a
-- question three different subsystems answered three different ways.
--
-- `artist_credit` is the album artist as tagged ("Various Artists",
-- "A & B"); `artist_id` is the primary artist it resolves to. Album
-- identity is (name, artist_credit), which is what the old
-- UNIQUE(name, album_artist_credit_id) meant with a join in the way.
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
artist_credit TEXT NOT NULL DEFAULT '',
artist_id INTEGER,
mbid TEXT,
-- year is the tagged year of the copy on disk; original_year is
-- MusicBrainz's first-release date when known. For a 2010 remaster
-- of a 1973 album: original_year 1973, year 2010.
year INTEGER,
original_year INTEGER,
cover_art_id INTEGER,
-- Set when the files carried a release MBID but no release-group
-- MBID; a background pass resolves it and clears this.
pending_release_mbid TEXT,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(artist_id) REFERENCES artists(id),
UNIQUE(name, artist_credit)
);
CREATE INDEX IF NOT EXISTS idx_albums_artist_id
ON albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_albums_cover_art_id
ON albums(cover_art_id);
CREATE INDEX IF NOT EXISTS idx_albums_mbid
ON albums(mbid) WHERE mbid IS NOT NULL;
@@ -1,4 +0,0 @@
CREATE TABLE IF NOT EXISTS artist_credit (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL UNIQUE
);
@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS artist_credit_artist (
id integer PRIMARY KEY,
artist_id int NOT NULL,
credit_id int NOT NULL,
FOREIGN KEY(artist_id) REFERENCES artists(id),
FOREIGN KEY(credit_id) REFERENCES artist_credit(id)
);
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id
ON artist_credit_artist(artist_id);
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id
ON artist_credit_artist(credit_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_credit_artist_unique
ON artist_credit_artist(artist_id, credit_id);
@@ -0,0 +1,56 @@
-- The decomposition of a multi-artist credit, from the MusicBrainz
-- dump. One row per credited artist, in credit order.
--
-- A credit is ordered parts, and the credit *string* is derived from
-- them -- MusicBrainz's own `artist_credit.name` is a cached render and
-- nothing more. Rendering is a concatenation:
--
-- for each part in position order:
-- emit link(credited_name -> artist_mbid)
-- emit text(join_phrase)
--
-- so the link boundaries are known by construction. That is the whole
-- reason this table exists, and it is why nothing may reconstruct a
-- credit by *searching* for a name inside a credit string: the stored
-- string may have come from a file's tags while the parts come from the
-- catalog, and measured on a real library those disagree for about one
-- in three multi-artist credits ("Skrillex feat. Swae Lee" tagged
-- against "Skrillex & Swae Lee" upstream). A search would miss, or
-- match the wrong span.
--
-- `credited_name` is the name *as credited*, which is not the artist's
-- canonical name: MusicBrainz credits "Snoop Dogg" on a track by the
-- artist whose name is "Snoop Doggy Dogg". It is stored per row rather
-- than joined from an artist table for exactly that reason.
--
-- Only *multi-artist* credits are stored. A single-artist credit is
-- (name, "") and is already fully described by explore_index's
-- artist_name and artist_mbid; storing those would roughly triple the
-- table to say nothing new.
--
-- Credits are shared: an album's twelve tracks by one artist reference
-- one credit_id. That is the opposite of the local library's verdict
-- in plan 013, and correctly so -- credit sharing is 1:1 in one
-- person's files and genuinely many-to-one across a 2M-row catalog.
--
-- MBIDs are the same 16 raw bytes explore_index stores, for the same
-- size reason and with the same CHECK, so a stringly write fails at the
-- insert that made it rather than reading back as no rows at all. See
-- backend/explore/mbid.go.
CREATE TABLE IF NOT EXISTS artist_credit_part (
credit_id INTEGER NOT NULL,
position INTEGER NOT NULL,
artist_mbid BLOB NOT NULL CHECK(length(artist_mbid) = 16),
-- The name as credited on this release, which may differ from the
-- artist's canonical name. Display uses this; navigation uses the
-- MBID above.
credited_name TEXT NOT NULL,
-- The literal connector that follows this part -- " feat. ", " & ",
-- ", ", or "" on the last part. Rendered as plain text between two
-- links.
join_phrase TEXT NOT NULL DEFAULT '',
PRIMARY KEY (credit_id, position)
) WITHOUT ROWID;
@@ -0,0 +1,30 @@
-- Which credit a catalog entity is credited to. One row per recording
-- or release group whose credit names more than one artist.
--
-- This is a table rather than an `explore_index.artist_credit_id`
-- column, and that is a deliberate consequence of how this app applies
-- its schema. `applySchema` is CREATE ... IF NOT EXISTS and there is
-- no migration chain (plan 013), so a *column* added to an existing
-- table never reaches a database that already has it -- while a new
-- *table* is created on every install, old or new, for free.
-- explore_index is the one table nobody can afford to drop and rebuild
-- on a schema change: it is the artifact users download rather than
-- derive.
--
-- Only multi-artist credits are referenced here, matching
-- artist_credit_part. An entity with no row is credited to exactly one
-- artist, which explore_index's own artist_name and artist_mbid already
-- describe -- so absence is the common case and means "nothing to
-- decompose", not "unknown".
--
-- `credit_id` is opaque and is only meaningful against the
-- artist_credit_part rows built or imported alongside it. The two are
-- always written together; nothing persists a credit_id anywhere else.
-- The local library stores resolved parts, never this id.
CREATE TABLE IF NOT EXISTS artist_credit_ref (
mbid BLOB NOT NULL PRIMARY KEY CHECK(length(mbid) = 16),
credit_id INTEGER NOT NULL
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_artist_credit_ref_credit
ON artist_credit_ref(credit_id);
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid -- No index on artist_mbid alone: the UNIQUE index below has it as its
ON artist_images(artist_mbid); -- leftmost column.
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
ON artist_images(artist_mbid, source, source_url); ON artist_images(artist_mbid, source, source_url);
@@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
PRIMARY KEY (mbid, source) PRIMARY KEY (mbid, source)
); );
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid); -- No index on mbid alone: PRIMARY KEY (mbid, source) already has it as
-- its leftmost column, so a second one costs a write per row and serves
-- no read.
+92 -27
View File
@@ -1,34 +1,92 @@
-- One row per audio file, and the file's tags live on it.
--
-- This table used to be a stub — path, format, a foreign key — with
-- every tag-derived field one join away in `recordings`, which was in
-- turn linked to an album through `release_group_recordings` and to an
-- artist through `artist_credit` + `artist_credit_artist`. That is
-- MusicBrainz's data model, and it is the right model for MusicBrainz:
-- a recording really can appear on many releases and a credit really
-- can list many artists.
--
-- It was the wrong model here, and the library said so. Measured on a
-- real 25,966-file library: **no** recording had more than one file,
-- **no** recording belonged to more than one release group, and 3 of
-- 2,823 credits listed more than one artist. Every many-to-many the
-- schema modelled was 1:1 in the data, and the cost of modelling it
-- anyway was a six-way join in every read, a `MIN(release_group_id)`
-- subquery in eleven queries to collapse a fan-out that never happened,
-- a first-credited-artist subquery in nine more to collapse the other
-- one, and — the reason this changed — a whole class of bugs where a
-- `recordings` row **outlived the file that created it**. Retagging a
-- file created a new recording and abandoned the old one, so the same
-- library carried 812 recordings, 216 release groups and 260 artists
-- with no file behind them, and everything that asked "do I own this"
-- by looking for a metadata row got 129 confident yeses for tracks
-- that could not be played.
--
-- With the tags on the file, ownership is not a rule anyone can forget:
-- the row *is* the file.
CREATE TABLE IF NOT EXISTS audio_files ( CREATE TABLE IF NOT EXISTS audio_files (
id integer PRIMARY KEY, id INTEGER PRIMARY KEY,
file_path text NOT NULL UNIQUE, file_path TEXT NOT NULL UNIQUE,
length_milliseconds int NOT NULL, library_id INTEGER NOT NULL DEFAULT 0,
file_type_id int NOT NULL, file_type_id INTEGER NOT NULL,
recording_id int NOT NULL,
sample_rate int NOT NULL DEFAULT 0, -- Audio properties, read from the file itself.
bit_depth int NOT NULL DEFAULT 0, length_milliseconds INTEGER NOT NULL,
channels int NOT NULL DEFAULT 0, sample_rate INTEGER NOT NULL DEFAULT 0,
bitrate int NOT NULL DEFAULT 0, bit_depth INTEGER NOT NULL DEFAULT 0,
file_size int NOT NULL DEFAULT 0, channels INTEGER NOT NULL DEFAULT 0,
basename text NOT NULL DEFAULT '', bitrate INTEGER NOT NULL DEFAULT 0,
library_id int NOT NULL DEFAULT 0, file_size INTEGER NOT NULL DEFAULT 0,
play_count int NOT NULL DEFAULT 0,
last_played datetime, -- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is
tag_status TEXT NOT NULL DEFAULT 'untagged' -- for display; `artist_id` is the primary artist it resolves to, and
-- is what grouping, browsing and the artist page use. Keeping both
-- is what makes the credit table unnecessary: the string is the only
-- thing that was ever read off it.
title TEXT NOT NULL DEFAULT '',
artist_credit TEXT NOT NULL DEFAULT '',
artist_id INTEGER,
album_id INTEGER,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the tag declared: the 12 in "5/12", per disc. It
-- is what lets "do I have all of this album" be answered from disk
-- instead of from MusicBrainz. NULL means the tag did not say, which
-- is a third state and not the same as zero.
total_tracks INTEGER,
year INTEGER,
composer TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
recording_mbid TEXT,
-- Library bookkeeping.
basename TEXT NOT NULL DEFAULT '',
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import and
-- compared against the on-disk mtime during a scan to detect files
-- another application retagged in place.
modified_at INTEGER NOT NULL DEFAULT 0,
play_count INTEGER NOT NULL DEFAULT 0,
last_played DATETIME,
tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN ( CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent' 'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
)), )),
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import.
-- Compared against the on-disk mtime during a scan to detect files
-- another application retagged in place. 0 means "never recorded"
-- (rows predating migration 47) and is treated as not-stale so an
-- upgrade does not re-import the whole library.
modified_at int NOT NULL DEFAULT 0,
FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id), FOREIGN KEY(library_id) REFERENCES libraries(id),
FOREIGN KEY(library_id) REFERENCES libraries(id) FOREIGN KEY(artist_id) REFERENCES artists(id),
FOREIGN KEY(album_id) REFERENCES albums(id)
); );
CREATE INDEX IF NOT EXISTS idx_audio_files_album_id
ON audio_files(album_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_artist_id
ON audio_files(artist_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_basename CREATE INDEX IF NOT EXISTS idx_audio_files_basename
ON audio_files(basename); ON audio_files(basename);
@@ -36,10 +94,17 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
ON audio_files(group_key) WHERE group_key != ''; ON audio_files(group_key) WHERE group_key != '';
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
ON audio_files(library_id); ON audio_files(library_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id -- The ownership question, asked by MBID: "is there a *file* with this
ON audio_files(recording_id); -- recording MBID". Nothing may answer it from a metadata table again.
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_mbid
ON audio_files(recording_mbid) WHERE recording_mbid IS NOT NULL;
-- Answers "does this tagging group still contain untagged files" in one
-- seek per group. The autotag queue asks it once per row.
CREATE INDEX IF NOT EXISTS idx_audio_files_untagged_group_key
ON audio_files(group_key) WHERE tag_status = 'untagged';
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
ON audio_files(library_id) WHERE tag_status = 'untagged'; ON audio_files(library_id) WHERE tag_status = 'untagged';
+39 -7
View File
@@ -1,10 +1,25 @@
-- The downloaded MusicBrainz/ListenBrainz catalog.
--
-- MusicBrainz ids are stored as their 16 raw bytes and entity types as
-- small integers, which is a size decision: on a real 2,052,200-row
-- catalog those four columns were 220 MB of a 383 MB table and were
-- carried again in every index keyed on them, and the conversion took
-- the table and its four indexes from 677 MB to 389 MB. See
-- backend/explore/mbid.go, which is the only place that encoding is
-- known -- everything above it speaks dashed strings and entity names.
--
-- The CHECK constraints are what make a mistake loud. SQLite does not
-- coerce between TEXT and BLOB, so a query comparing this column
-- against a 36-character string returns no rows rather than an error;
-- a *write* of one fails here instead, at the insert that made it.
CREATE TABLE IF NOT EXISTS explore_index ( CREATE TABLE IF NOT EXISTS explore_index (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL, entity_type INTEGER NOT NULL,
mbid TEXT NOT NULL, mbid BLOB NOT NULL CHECK(length(mbid) = 16),
title TEXT NOT NULL, title TEXT NOT NULL,
artist_name TEXT NOT NULL, artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL, artist_mbid BLOB NOT NULL
CHECK(length(artist_mbid) IN (0, 16)),
aliases TEXT NOT NULL DEFAULT '', aliases TEXT NOT NULL DEFAULT '',
-- Popularity signals, derived from the ListenBrainz listens dump. -- Popularity signals, derived from the ListenBrainz listens dump.
@@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index (
-- Recording-specific fields. -- Recording-specific fields.
duration INTEGER NOT NULL DEFAULT 0, duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid TEXT NOT NULL DEFAULT '', caa_release_mbid BLOB NOT NULL DEFAULT x''
CHECK(length(caa_release_mbid) IN (0, 16)),
release_name TEXT NOT NULL DEFAULT '', release_name TEXT NOT NULL DEFAULT '',
-- Release-group-specific fields. -- Release-group-specific fields.
@@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index (
secondary_types TEXT NOT NULL DEFAULT '', secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '', release_date TEXT NOT NULL DEFAULT '',
-- How many tracks the release group's canonical release has, so
-- "do I have all of this" is answerable offline for an album the
-- library holds no tags for. Zero means the catalog does not say,
-- which is the same third state the local answer has -- and is what
-- every row carries until a central dump build fills it.
total_tracks INTEGER NOT NULL DEFAULT 0,
-- Artist-specific fields. -- Artist-specific fields.
artist_type TEXT NOT NULL DEFAULT '', artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '',
@@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index (
UNIQUE(mbid) UNIQUE(mbid)
); );
-- The exact-match tier's two indexes.
--
-- Their predicate is the champion set - the popular rows plus whatever
-- the user owns - and matching it to `ExactMatches`' own WHERE clause is
-- what makes them small. They used to say `popularity > 0`, which on a
-- real 2,052,200-row catalog covered 2,046,645 of them: a full index
-- wearing a partial index's clothes, 101 MB for the pair. Narrowed to
-- the set the tier can actually return, they are 3 MB and the query
-- plan is unchanged (measured, on that catalog).
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
ON explore_index(LOWER(artist_name)) ON explore_index(LOWER(artist_name))
WHERE popularity > 0; WHERE popularity >= 10000 OR in_library = 1;
CREATE INDEX IF NOT EXISTS idx_explore_caa_release CREATE INDEX IF NOT EXISTS idx_explore_caa_release
ON explore_index(caa_release_mbid) ON explore_index(caa_release_mbid)
WHERE entity_type = 'release_group' AND caa_release_mbid != ''; WHERE entity_type = 2 AND caa_release_mbid != x'';
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
ON explore_index(artist_mbid, entity_type, popularity DESC); ON explore_index(artist_mbid, entity_type, popularity DESC);
@@ -62,4 +94,4 @@ CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop
CREATE INDEX IF NOT EXISTS idx_explore_title_lower CREATE INDEX IF NOT EXISTS idx_explore_title_lower
ON explore_index(LOWER(title)) ON explore_index(LOWER(title))
WHERE popularity > 0; WHERE popularity >= 10000 OR in_library = 1;
@@ -0,0 +1,18 @@
-- Genres per file. This is `recording_genres` with the recording taken
-- out of the middle: it is the one many-to-many in the local library
-- that is actually many-to-many (a real library runs about four genre
-- rows per file), which is why it stays a join table when the others
-- did not.
CREATE TABLE IF NOT EXISTS file_genres (
audio_file_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
PRIMARY KEY (audio_file_id, genre_id),
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE,
FOREIGN KEY(genre_id) REFERENCES genres(id)
) WITHOUT ROWID;
-- The reverse direction ("which files are in this genre"). The
-- forward direction is served by the primary key, so — unlike the
-- table this replaces — there is no third index restating it.
CREATE INDEX IF NOT EXISTS idx_file_genres_genre_id
ON file_genres(genre_id);
+6 -10
View File
@@ -1,15 +1,11 @@
-- One row per "go find me this", from the moment the user asks until -- One row per folder the user has added as a music library.
-- the files are in the library or the attempt is abandoned.
-- --
-- release_mbid / release_group_mbid are the anchor: a request that -- Everything else keyed by library_id means "which of these folders did
-- carries one can be matched against a known tracklist at import time, -- this come from"; a library_id of 0 in a query means "all of them".
-- which is what makes unattended completion safe. Free-text requests
-- (both NULL) are always presented to the user for confirmation.
-- --
-- `expected` caches the anchor's tracklist as JSON so ranking and -- autotag_warning_acked records that the user has been told what
-- import do not have to re-resolve it, and so a request survives the -- autotagging will do to the files in this folder, which is a decision
-- explore index being rebuilt underneath it. -- they made and not something a rescan can rediscover.
CREATE TABLE IF NOT EXISTS libraries ( CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
+25
View File
@@ -0,0 +1,25 @@
-- Lyrics for a file, and where they came from.
--
-- These used to be a column on `recordings`, in a table classified
-- `Owned` — data a rescan can rebuild from the files. That was true of
-- lyrics read out of a USLT frame and false of lyrics fetched from
-- LRCLIB, and nothing recorded which was which, so a library with
-- 24,294 of them could not answer how many were free to rebuild and how
-- many were network traffic waiting to happen. `source` answers it.
--
-- `recording_mbid` is carried alongside the file id so a future
-- re-import can re-adopt fetched lyrics without asking LRCLIB again;
-- the file id is the key because untagged files have no MBID and are
-- exactly the ones whose lyrics had to be fetched.
CREATE TABLE IF NOT EXISTS lyrics (
audio_file_id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'tag'
CHECK(source IN ('tag', 'lrclib')),
recording_mbid TEXT,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lyrics_recording_mbid
ON lyrics(recording_mbid) WHERE recording_mbid IS NOT NULL;
@@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS recording_genres (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(genre_id) REFERENCES genres(id),
UNIQUE(recording_id, genre_id)
);
CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id
ON recording_genres(genre_id);
CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id
ON recording_genres(recording_id);
@@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
artist_credit_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
composer TEXT,
lyrics TEXT,
comment TEXT,
mbid TEXT,
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
);
CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id
ON recordings(artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL;
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS release_group_recordings (
id INTEGER PRIMARY KEY,
release_group_id INTEGER NOT NULL,
recording_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the file's own tag declared: the 12 in "5/12", per
-- disc. Read off every file at scan and, until now, discarded — so
-- "do I have all of this album" had no local answer and the album
-- page asked MusicBrainz. NULL means the tag did not say, which is
-- a third state and not the same as zero.
total_tracks INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id
ON release_group_recordings(recording_id);
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id
ON release_group_recordings(release_group_id);
@@ -1,20 +0,0 @@
CREATE TABLE IF NOT EXISTS "release_groups" (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER, pending_release_mbid TEXT,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
UNIQUE(name, album_artist_credit_id)
);
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
ON release_groups(album_artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id
ON release_groups(cover_art_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL;
@@ -1,4 +1,11 @@
-- Release MBID -> release-group MBID, captured during a dump import.
--
-- It is empty on an ordinary install and looks droppable for that
-- reason: only a local dump build (`indexbuild`) fills it. The daily
-- incremental refresh reads it to roll per-release listen counts up to
-- the release group they belong to, so an install that has built its
-- own index does need it.
CREATE TABLE IF NOT EXISTS release_to_rg ( CREATE TABLE IF NOT EXISTS release_to_rg (
release_mbid TEXT PRIMARY KEY, release_mbid TEXT PRIMARY KEY,
rg_mbid TEXT NOT NULL rg_mbid TEXT NOT NULL
) WITHOUT ROWID; ) WITHOUT ROWID;
@@ -6,5 +6,5 @@ CREATE TABLE IF NOT EXISTS similar_artist_map (
PRIMARY KEY (source_artist_mbid, similar_artist_mbid) PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
); );
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source -- No index on source_artist_mbid alone: the PRIMARY KEY has it as its
ON similar_artist_map(source_artist_mbid); -- leftmost column.
+40 -28
View File
@@ -1,23 +1,42 @@
CREATE VIEW IF NOT EXISTS track_metadata AS -- The one definition of "a track, with everything a list needs".
--
-- A view is a definition, not data, so it is dropped and recreated on
-- every open rather than carrying a migration alongside it: CREATE VIEW
-- IF NOT EXISTS silently keeps an older database on the old definition,
-- and a migration file restating it would be the second description of
-- the schema the migration rules exist to prevent.
--
-- This projection used to exist **nine times** — four copies in
-- audio_files.sql, two in playlists.sql, two in genres.sql, one in
-- queue.sql — plus this view, which only the raw-SQL search paths used.
-- They had already drifted: this view preferred the album's
-- original_year for `year` and GetAllTracksWithFullMetadata used the
-- track's own, so the same library reported different years on
-- different screens. Every query that wants a track row now selects
-- from here, which is also why there is one row type and one mapper on
-- the Go side instead of nine and a twenty-two-argument function.
DROP VIEW IF EXISTS track_metadata;
CREATE VIEW track_metadata AS
SELECT SELECT
af.id, af.id,
af.file_path, af.file_path,
af.length_milliseconds, af.length_milliseconds,
COALESCE(r.name, '') AS title, af.title,
COALESCE(ac.text, '') AS artist_name, af.artist_credit AS artist_name,
r.track_number, af.track_number,
r.disc_number, af.disc_number,
COALESCE(rg.name, '') AS album, COALESCE(al.name, '') AS album,
CAST(COALESCE( CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||') (SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub FROM file_genres fg
JOIN genres g ON rg_sub.genre_id = g.id JOIN genres g ON g.id = fg.genre_id
WHERE rg_sub.recording_id = r.id), WHERE fg.audio_file_id = af.id),
'' ''
) AS TEXT) AS genre, ) AS TEXT) AS genre,
COALESCE(rg.original_year, rg.year, r.year, 0) AS year, COALESCE(al.original_year, al.year, af.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year, COALESCE(al.year, af.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer, af.composer,
COALESCE(ft.extension, '') AS file_type, COALESCE(ft.extension, '') AS file_type,
af.sample_rate, af.sample_rate,
af.bit_depth, af.bit_depth,
@@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS
af.play_count, af.play_count,
af.last_played, af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path, COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid, COALESCE(ar.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid, COALESCE(al.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid COALESCE(af.recording_mbid, '') AS recording_mbid,
af.album_id,
af.artist_id
FROM audio_files af FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN albums al ON al.id = af.album_id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN artists ar ON ar.id = af.artist_id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
LEFT JOIN artists a ON a.id = aca.artist_id LEFT JOIN file_types ft ON ft.id = af.file_type_id;
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
+426
View File
@@ -0,0 +1,426 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: albums.sql
package sqlcgen
import (
"context"
"database/sql"
)
const deleteAlbum = `-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?
`
func (q *Queries) DeleteAlbum(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteAlbum, id)
return err
}
const deleteAllAlbums = `-- name: DeleteAllAlbums :exec
DELETE FROM albums
`
func (q *Queries) DeleteAllAlbums(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteAllAlbums)
return err
}
const getAlbum = `-- name: GetAlbum :one
SELECT id, name, artist_credit, artist_id, mbid, year, original_year, cover_art_id, pending_release_mbid FROM albums WHERE id = ? LIMIT 1
`
func (q *Queries) GetAlbum(ctx context.Context, id int64) (Album, error) {
row := q.db.QueryRowContext(ctx, getAlbum, id)
var i Album
err := row.Scan(
&i.ID,
&i.Name,
&i.ArtistCredit,
&i.ArtistID,
&i.Mbid,
&i.Year,
&i.OriginalYear,
&i.CoverArtID,
&i.PendingReleaseMbid,
)
return i, err
}
const getAlbumCompleteness = `-- name: GetAlbumCompleteness :one
SELECT
-- Distinct (disc, track) pairs: this app detects duplicates, and
-- counting two files of track 3 twice would report a short album as
-- complete. A file with no track number falls back to its own id,
-- because three untagged files are three tracks, not one.
CAST(COUNT(DISTINCT CAST(COALESCE(a.disc_number, 1) AS TEXT) || ':' ||
COALESCE(CAST(a.track_number AS TEXT), 'f' || a.id)
) AS INTEGER) AS owned,
CAST(COALESCE((
SELECT SUM(per_disc.total)
FROM (
SELECT MAX(b.total_tracks) AS total
FROM audio_files b
WHERE b.album_id = ?1 AND b.total_tracks IS NOT NULL
GROUP BY COALESCE(b.disc_number, 1)
) per_disc
), 0) AS INTEGER) AS expected,
CAST((
SELECT COUNT(*) = 0 FROM audio_files c
WHERE c.album_id = ?1 AND c.total_tracks IS NULL
) AS INTEGER) AS known
FROM audio_files a
WHERE a.album_id = ?1
`
type GetAlbumCompletenessRow struct {
Owned int64
Expected int64
Known int64
}
// "Do I have all of this album", answered from the tags on disk.
//
// The expectation is a **sum over discs**, not one number: totals are
// declared per disc ("5/12" on disc 2 means 12 tracks on disc 2), so a
// multi-disc album's expectation is the sum of each disc's declared
// total. A disc whose files declared nothing leaves the whole album
// unknowable rather than being covered by the discs that did -- which is
// what `known` reports.
//
// Owned counts DISTINCT track numbers: this app detects duplicates, and
// counting two files of track 3 twice would report a short album as
// complete.
func (q *Queries) GetAlbumCompleteness(ctx context.Context, albumID sql.NullInt64) (GetAlbumCompletenessRow, error) {
row := q.db.QueryRowContext(ctx, getAlbumCompleteness, albumID)
var i GetAlbumCompletenessRow
err := row.Scan(&i.Owned, &i.Expected, &i.Known)
return i, err
}
const getAlbums = `-- name: GetAlbums :many
SELECT
al.id,
al.name,
COALESCE(al.original_year, al.year) AS year,
COALESCE(al.year, 0) AS release_year,
al.mbid,
al.artist_credit AS artist_name,
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
COALESCE(ca.file_path, '') AS cover_art_path
FROM albums al
LEFT JOIN artists ar ON ar.id = al.artist_id
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
WHERE EXISTS (
SELECT 1 FROM audio_files af
WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
)
ORDER BY al.name
`
type GetAlbumsRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
Mbid sql.NullString
ArtistName string
ArtistMbid string
CoverArtPath string
}
func (q *Queries) GetAlbums(ctx context.Context, libraryID int64) ([]GetAlbumsRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbums, libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAlbumsRow
for rows.Next() {
var i GetAlbumsRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.ArtistMbid,
&i.CoverArtPath,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAlbumsByArtistName = `-- name: GetAlbumsByArtistName :many
SELECT
al.id,
al.name,
COALESCE(al.original_year, al.year) AS year,
COALESCE(al.year, 0) AS release_year,
al.mbid,
al.artist_credit AS artist_name,
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
COALESCE(ca.file_path, '') AS cover_art_path
FROM albums al
LEFT JOIN artists ar ON ar.id = al.artist_id
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
WHERE (al.artist_credit = ?1 OR ar.name = ?1)
AND EXISTS (
SELECT 1 FROM audio_files af
WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(?2 AS INTEGER), 0), af.library_id)
)
ORDER BY year, al.name
`
type GetAlbumsByArtistNameParams struct {
Artist string
LibraryID int64
}
type GetAlbumsByArtistNameRow struct {
ID int64
Name string
Year sql.NullInt64
ReleaseYear int64
Mbid sql.NullString
ArtistName string
ArtistMbid string
CoverArtPath string
}
func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArtistNameParams) ([]GetAlbumsByArtistNameRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsByArtistName, arg.Artist, arg.LibraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAlbumsByArtistNameRow
for rows.Next() {
var i GetAlbumsByArtistNameRow
if err := rows.Scan(
&i.ID,
&i.Name,
&i.Year,
&i.ReleaseYear,
&i.Mbid,
&i.ArtistName,
&i.ArtistMbid,
&i.CoverArtPath,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
SELECT id, pending_release_mbid FROM albums
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
AND (mbid IS NULL OR mbid = '')
`
type GetAlbumsWithPendingReleaseMBIDRow struct {
ID int64
PendingReleaseMbid sql.NullString
}
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAlbumsWithPendingReleaseMBIDRow
for rows.Next() {
var i GetAlbumsWithPendingReleaseMBIDRow
if err := rows.Scan(&i.ID, &i.PendingReleaseMbid); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getEmptyAlbumIDs = `-- name: GetEmptyAlbumIDs :many
SELECT id FROM albums al
WHERE NOT EXISTS (
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
)
`
// Albums with no file left behind them. Under the old schema this was
// one of three orphan sweeps that had to run by hand and did not;
// audio_files is the only thing that can leave an album empty now, so
// this is the whole of it.
func (q *Queries) GetEmptyAlbumIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getEmptyAlbumIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const resolveAlbumPendingReleaseMBID = `-- name: ResolveAlbumPendingReleaseMBID :exec
UPDATE albums
SET mbid = ?, pending_release_mbid = NULL
WHERE id = ? AND (mbid IS NULL OR mbid = '')
`
type ResolveAlbumPendingReleaseMBIDParams struct {
Mbid sql.NullString
ID int64
}
// Clears the pending marker once the release-group MBID it stood in for
// has been resolved. Guarded so a real MBID is never overwritten.
func (q *Queries) ResolveAlbumPendingReleaseMBID(ctx context.Context, arg ResolveAlbumPendingReleaseMBIDParams) error {
_, err := q.db.ExecContext(ctx, resolveAlbumPendingReleaseMBID, arg.Mbid, arg.ID)
return err
}
const setAlbumCoverArt = `-- name: SetAlbumCoverArt :exec
UPDATE albums SET cover_art_id = ? WHERE id = ?
`
type SetAlbumCoverArtParams struct {
CoverArtID sql.NullInt64
ID int64
}
func (q *Queries) SetAlbumCoverArt(ctx context.Context, arg SetAlbumCoverArtParams) error {
_, err := q.db.ExecContext(ctx, setAlbumCoverArt, arg.CoverArtID, arg.ID)
return err
}
const setAlbumMBID = `-- name: SetAlbumMBID :exec
UPDATE albums SET mbid = ? WHERE id = ?
`
type SetAlbumMBIDParams struct {
Mbid sql.NullString
ID int64
}
func (q *Queries) SetAlbumMBID(ctx context.Context, arg SetAlbumMBIDParams) error {
_, err := q.db.ExecContext(ctx, setAlbumMBID, arg.Mbid, arg.ID)
return err
}
const setAlbumOriginalYear = `-- name: SetAlbumOriginalYear :exec
UPDATE albums SET original_year = ? WHERE id = ?
`
type SetAlbumOriginalYearParams struct {
OriginalYear sql.NullInt64
ID int64
}
func (q *Queries) SetAlbumOriginalYear(ctx context.Context, arg SetAlbumOriginalYearParams) error {
_, err := q.db.ExecContext(ctx, setAlbumOriginalYear, arg.OriginalYear, arg.ID)
return err
}
const setAlbumPendingReleaseMBID = `-- name: SetAlbumPendingReleaseMBID :exec
UPDATE albums SET pending_release_mbid = ? WHERE id = ?
`
type SetAlbumPendingReleaseMBIDParams struct {
PendingReleaseMbid sql.NullString
ID int64
}
func (q *Queries) SetAlbumPendingReleaseMBID(ctx context.Context, arg SetAlbumPendingReleaseMBIDParams) error {
_, err := q.db.ExecContext(ctx, setAlbumPendingReleaseMBID, arg.PendingReleaseMbid, arg.ID)
return err
}
const upsertAlbum = `-- name: UpsertAlbum :one
INSERT INTO albums (name, artist_credit, artist_id, year, cover_art_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(name, artist_credit) DO UPDATE SET
artist_id = COALESCE(excluded.artist_id, albums.artist_id),
year = COALESCE(excluded.year, albums.year),
cover_art_id = COALESCE(excluded.cover_art_id, albums.cover_art_id)
RETURNING id, name, artist_credit, artist_id, mbid, year, original_year, cover_art_id, pending_release_mbid
`
type UpsertAlbumParams struct {
Name string
ArtistCredit string
ArtistID sql.NullInt64
Year sql.NullInt64
CoverArtID sql.NullInt64
}
// Queries over albums (formerly release_groups).
//
// The two-copy pattern is gone here too: one query answers both the
// whole-library and the single-library case. The `fallback_ac`
// subquery every album read used to carry -- "if the album has no album
// artist credit, borrow one from any of its recordings" -- is gone with
// it, because the album carries its own credit text now.
func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album, error) {
row := q.db.QueryRowContext(ctx, upsertAlbum,
arg.Name,
arg.ArtistCredit,
arg.ArtistID,
arg.Year,
arg.CoverArtID,
)
var i Album
err := row.Scan(
&i.ID,
&i.Name,
&i.ArtistCredit,
&i.ArtistID,
&i.Mbid,
&i.Year,
&i.OriginalYear,
&i.CoverArtID,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -1,140 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: artist_credit.sql
package sqlcgen
import (
"context"
)
const countArtistCreditReferences = `-- name: CountArtistCreditReferences :one
SELECT
(SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) +
(SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1)
AS total
`
func (q *Queries) CountArtistCreditReferences(ctx context.Context, artistCreditID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countArtistCreditReferences, artistCreditID)
var total int64
err := row.Scan(&total)
return total, err
}
const createArtistCredit = `-- name: CreateArtistCredit :one
INSERT INTO artist_credit (text) VALUES (?)
RETURNING id, text
`
func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, createArtistCredit, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
return i, err
}
const deleteAllArtistCredits = `-- name: DeleteAllArtistCredits :exec
DELETE FROM artist_credit
`
func (q *Queries) DeleteAllArtistCredits(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteAllArtistCredits)
return err
}
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id = ?
`
func (q *Queries) DeleteArtistCredit(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteArtistCredit, id)
return err
}
const getArtistCredit = `-- name: GetArtistCredit :one
SELECT id, text FROM artist_credit
WHERE id = ? LIMIT 1
`
func (q *Queries) GetArtistCredit(ctx context.Context, id int64) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, getArtistCredit, id)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
return i, err
}
const getArtistCreditByText = `-- name: GetArtistCreditByText :one
SELECT id, text FROM artist_credit
WHERE text = ? LIMIT 1
`
func (q *Queries) GetArtistCreditByText(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, getArtistCreditByText, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
return i, err
}
const getOrphanedArtistCreditIDs = `-- name: GetOrphanedArtistCreditIDs :many
SELECT ac.id FROM artist_credit ac
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id)
`
// Artist credits no longer used by any recording or release group - run
// after orphaned recordings/release groups are deleted, so a credit
// that only existed for now-removed tracks is cleaned up too.
func (q *Queries) GetOrphanedArtistCreditIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedArtistCreditIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
UPDATE artist_credit
SET text = ?
WHERE id = ?
`
type UpdateArtistCreditParams struct {
Text string
ID int64
}
func (q *Queries) UpdateArtistCredit(ctx context.Context, arg UpdateArtistCreditParams) error {
_, err := q.db.ExecContext(ctx, updateArtistCredit, arg.Text, arg.ID)
return err
}
const upsertArtistCredit = `-- name: UpsertArtistCredit :one
INSERT INTO artist_credit (text) VALUES (?)
ON CONFLICT(text) DO UPDATE SET text = excluded.text
RETURNING id, text
`
func (q *Queries) UpsertArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
row := q.db.QueryRowContext(ctx, upsertArtistCredit, text)
var i ArtistCredit
err := row.Scan(&i.ID, &i.Text)
return i, err
}
@@ -1,85 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: artist_credit_artists.sql
package sqlcgen
import (
"context"
)
const createArtistCreditArtist = `-- name: CreateArtistCreditArtist :one
INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)
RETURNING id, artist_id, credit_id
`
type CreateArtistCreditArtistParams struct {
ArtistID int64
CreditID int64
}
func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtistCreditArtistParams) (ArtistCreditArtist, error) {
row := q.db.QueryRowContext(ctx, createArtistCreditArtist, arg.ArtistID, arg.CreditID)
var i ArtistCreditArtist
err := row.Scan(&i.ID, &i.ArtistID, &i.CreditID)
return i, err
}
const deleteAllArtistCreditArtists = `-- name: DeleteAllArtistCreditArtists :exec
DELETE FROM artist_credit_artist
`
func (q *Queries) DeleteAllArtistCreditArtists(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteAllArtistCreditArtists)
return err
}
const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec
DELETE FROM artist_credit_artist
WHERE id =?
`
func (q *Queries) DeleteArtistCreditArtist(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtist, id)
return err
}
const deleteArtistCreditArtistByCredit = `-- name: DeleteArtistCreditArtistByCredit :exec
DELETE FROM artist_credit_artist
WHERE credit_id = ?
`
func (q *Queries) DeleteArtistCreditArtistByCredit(ctx context.Context, creditID int64) error {
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtistByCredit, creditID)
return err
}
const getArtistCreditArtist = `-- name: GetArtistCreditArtist :one
SELECT id, artist_id, credit_id FROM artist_credit_artist
WHERE id = ? LIMIT 1
`
func (q *Queries) GetArtistCreditArtist(ctx context.Context, id int64) (ArtistCreditArtist, error) {
row := q.db.QueryRowContext(ctx, getArtistCreditArtist, id)
var i ArtistCreditArtist
err := row.Scan(&i.ID, &i.ArtistID, &i.CreditID)
return i, err
}
const updateArtistCreditArtist = `-- name: UpdateArtistCreditArtist :exec
UPDATE artist_credit_artist
SET artist_id = ?, credit_id = ?
WHERE id =?
`
type UpdateArtistCreditArtistParams struct {
ArtistID int64
CreditID int64
ID int64
}
func (q *Queries) UpdateArtistCreditArtist(ctx context.Context, arg UpdateArtistCreditArtistParams) error {
_, err := q.db.ExecContext(ctx, updateArtistCreditArtist, arg.ArtistID, arg.CreditID, arg.ID)
return err
}
+63 -88
View File
@@ -7,20 +7,9 @@ package sqlcgen
import ( import (
"context" "context"
"database/sql"
) )
const createArtist = `-- name: CreateArtist :one
INSERT INTO artists (name) VALUES (?)
RETURNING id, name, mbid
`
func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) {
row := q.db.QueryRowContext(ctx, createArtist, name)
var i Artist
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
return i, err
}
const deleteAllArtists = `-- name: DeleteAllArtists :exec const deleteAllArtists = `-- name: DeleteAllArtists :exec
DELETE FROM artists DELETE FROM artists
` `
@@ -31,8 +20,7 @@ func (q *Queries) DeleteAllArtists(ctx context.Context) error {
} }
const deleteArtist = `-- name: DeleteArtist :exec const deleteArtist = `-- name: DeleteArtist :exec
DELETE FROM artists DELETE FROM artists WHERE id = ?
WHERE id = ?
` `
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error { func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
@@ -43,56 +31,17 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
const getAlbumArtists = `-- name: GetAlbumArtists :many const getAlbumArtists = `-- name: GetAlbumArtists :many
SELECT DISTINCT a.id, a.name, a.mbid SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id JOIN albums al ON al.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id WHERE EXISTS (
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id SELECT 1 FROM audio_files af
ORDER BY a.name WHERE af.album_id = al.id
` AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) {
rows, err := q.db.QueryContext(ctx, getAlbumArtists)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Artist
for rows.Next() {
var i Artist
if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAlbumArtistsByLibrary = `-- name: GetAlbumArtistsByLibrary :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
WHERE a.id IN (
SELECT DISTINCT aca2.artist_id
FROM artist_credit_artist aca2
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
) )
ORDER BY a.name ORDER BY a.name
` `
func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) ([]Artist, error) { func (q *Queries) GetAlbumArtists(ctx context.Context, libraryID int64) ([]Artist, error) {
rows, err := q.db.QueryContext(ctx, getAlbumArtistsByLibrary, libraryID) rows, err := q.db.QueryContext(ctx, getAlbumArtists, libraryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -115,8 +64,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64)
} }
const getAllArtists = `-- name: GetAllArtists :many const getAllArtists = `-- name: GetAllArtists :many
SELECT id, name, mbid FROM artists SELECT id, name, mbid FROM artists ORDER BY name
ORDER BY name
` `
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) { func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
@@ -143,8 +91,7 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
} }
const getArtist = `-- name: GetArtist :one const getArtist = `-- name: GetArtist :one
SELECT id, name, mbid FROM artists SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1
WHERE id = ? LIMIT 1
` `
func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) { func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
@@ -154,9 +101,28 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
return i, err return i, err
} }
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
LEFT JOIN artists a ON a.id = af.artist_id
WHERE af.file_path = ?
LIMIT 1
`
type GetArtistByFilePathRow struct {
ArtistName string
ArtistMbid string
}
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
var i GetArtistByFilePathRow
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
return i, err
}
const getArtistByName = `-- name: GetArtistByName :one const getArtistByName = `-- name: GetArtistByName :one
SELECT id, name, mbid FROM artists SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1
WHERE name = ? LIMIT 1
` `
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) { func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
@@ -166,18 +132,15 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err
return i, err return i, err
} }
const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many const getUnreferencedArtistIDs = `-- name: GetUnreferencedArtistIDs :many
SELECT a.id FROM artists a SELECT id FROM artists a
WHERE NOT EXISTS ( WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id)
)
` `
// Artists no longer credited on any recording or release group - left // Artists no file and no album points at any more.
// behind when a scan's orphan cleanup removes the audio_files that used func (q *Queries) GetUnreferencedArtistIDs(ctx context.Context) ([]int64, error) {
// to justify them, since deleting an audio_files row doesn't cascade. rows, err := q.db.QueryContext(ctx, getUnreferencedArtistIDs)
func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -199,30 +162,42 @@ func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
return items, nil return items, nil
} }
const updateArtist = `-- name: UpdateArtist :exec const setArtistMBID = `-- name: SetArtistMBID :exec
UPDATE artists UPDATE artists SET mbid = ? WHERE id = ?
SET name = ?
WHERE id = ?
` `
type UpdateArtistParams struct { type SetArtistMBIDParams struct {
Name string Mbid sql.NullString
ID int64 ID int64
} }
func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) error { func (q *Queries) SetArtistMBID(ctx context.Context, arg SetArtistMBIDParams) error {
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID) _, err := q.db.ExecContext(ctx, setArtistMBID, arg.Mbid, arg.ID)
return err return err
} }
const upsertArtist = `-- name: UpsertArtist :one const upsertArtist = `-- name: UpsertArtist :one
INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = excluded.name INSERT INTO artists (name, mbid) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
mbid = COALESCE(excluded.mbid, artists.mbid)
RETURNING id, name, mbid RETURNING id, name, mbid
` `
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) { type UpsertArtistParams struct {
row := q.db.QueryRowContext(ctx, upsertArtist, name) Name string
Mbid sql.NullString
}
// Queries over artists.
//
// An artist row is reachable two ways: as a file's primary artist
// (audio_files.artist_id) and as an album's artist (albums.artist_id).
// Both used to route through artist_credit + artist_credit_artist,
// which is how "which album artists are in library 2" came to be a
// five-join subquery inside a three-join query.
func (q *Queries) UpsertArtist(ctx context.Context, arg UpsertArtistParams) (Artist, error) {
row := q.db.QueryRowContext(ctx, upsertArtist, arg.Name, arg.Mbid)
var i Artist var i Artist
err := row.Scan(&i.ID, &i.Name, &i.Mbid) err := row.Scan(&i.ID, &i.Name, &i.Mbid)
return i, err return i, err
File diff suppressed because it is too large Load Diff
+65 -355
View File
@@ -7,36 +7,9 @@ package sqlcgen
import ( import (
"context" "context"
"database/sql"
"strings" "strings"
) )
const countGenreReferences = `-- name: CountGenreReferences :one
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?
`
func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countGenreReferences, genreID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
VALUES (?, ?)
`
type CreateRecordingGenreParams struct {
RecordingID int64
GenreID int64
}
func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error {
_, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID)
return err
}
const deleteAllGenres = `-- name: DeleteAllGenres :exec const deleteAllGenres = `-- name: DeleteAllGenres :exec
DELETE FROM genres DELETE FROM genres
` `
@@ -46,12 +19,12 @@ func (q *Queries) DeleteAllGenres(ctx context.Context) error {
return err return err
} }
const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec const deleteFileGenres = `-- name: DeleteFileGenres :exec
DELETE FROM recording_genres DELETE FROM file_genres WHERE audio_file_id = ?
` `
func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error { func (q *Queries) DeleteFileGenres(ctx context.Context, audioFileID int64) error {
_, err := q.db.ExecContext(ctx, deleteAllRecordingGenres) _, err := q.db.ExecContext(ctx, deleteFileGenres, audioFileID)
return err return err
} }
@@ -64,20 +37,12 @@ func (q *Queries) DeleteGenre(ctx context.Context, id int64) error {
return err return err
} }
const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec
DELETE FROM recording_genres
WHERE recording_id = ?
`
func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error {
_, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID)
return err
}
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
SELECT g.name, COUNT(rg.recording_id) AS track_count SELECT g.name, COUNT(fg.audio_file_id) AS track_count
FROM genres g FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id JOIN file_genres fg ON fg.genre_id = g.id
JOIN audio_files af ON af.id = fg.audio_file_id
WHERE af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
GROUP BY g.id, g.name GROUP BY g.id, g.name
ORDER BY g.name ORDER BY g.name
` `
@@ -87,8 +52,8 @@ type GetAllGenresWithCountsRow struct {
TrackCount int64 TrackCount int64
} }
func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) { func (q *Queries) GetAllGenresWithCounts(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts) rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts, libraryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -110,35 +75,25 @@ func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWit
return items, nil return items, nil
} }
const getAllGenresWithCountsByLibrary = `-- name: GetAllGenresWithCountsByLibrary :many const getGenreNamesByFile = `-- name: GetGenreNamesByFile :many
SELECT g.name, COUNT(rg.recording_id) AS track_count SELECT g.name FROM genres g
FROM genres g JOIN file_genres fg ON fg.genre_id = g.id
JOIN recording_genres rg ON g.id = rg.genre_id WHERE fg.audio_file_id = ?
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.library_id = ?
GROUP BY g.id, g.name
ORDER BY g.name
` `
type GetAllGenresWithCountsByLibraryRow struct { func (q *Queries) GetGenreNamesByFile(ctx context.Context, audioFileID int64) ([]string, error) {
Name string rows, err := q.db.QueryContext(ctx, getGenreNamesByFile, audioFileID)
TrackCount int64
}
func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsByLibraryRow, error) {
rows, err := q.db.QueryContext(ctx, getAllGenresWithCountsByLibrary, libraryID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetAllGenresWithCountsByLibraryRow var items []string
for rows.Next() { for rows.Next() {
var i GetAllGenresWithCountsByLibraryRow var name string
if err := rows.Scan(&i.Name, &i.TrackCount); err != nil { if err := rows.Scan(&name); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, name)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -149,45 +104,42 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
return items, nil return items, nil
} }
const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many const getGenreNamesByFilePaths = `-- name: GetGenreNamesByFilePaths :many
SELECT af.file_path, g.name
SELECT g.name AS genre_name, af.file_path FROM audio_files af
FROM genres g JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN recording_genres rg ON g.id = rg.genre_id JOIN genres g ON g.id = fg.genre_id
JOIN recordings r ON rg.recording_id = r.id WHERE af.file_path IN (/*SLICE:paths*/?)
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (/*SLICE:genre_names*/?)
ORDER BY r.name
` `
type GetFilePathsByGenresRow struct { type GetGenreNamesByFilePathsRow struct {
GenreName string FilePath string
FilePath string Name string
} }
// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2): // Genres for many files at once. The mix builder asked this one file
// one query instead of one per genre, and file paths instead of whole // at a time, inside two nested loops -- twelve thousand single-row
// track rows, which was 6 MB over the IPC for five genres. // queries to assemble one mix.
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) { func (q *Queries) GetGenreNamesByFilePaths(ctx context.Context, paths []string) ([]GetGenreNamesByFilePathsRow, error) {
query := getFilePathsByGenres query := getGenreNamesByFilePaths
var queryParams []interface{} var queryParams []interface{}
if len(genreNames) > 0 { if len(paths) > 0 {
for _, v := range genreNames { for _, v := range paths {
queryParams = append(queryParams, v) queryParams = append(queryParams, v)
} }
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1) query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
} else { } else {
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1) query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
} }
rows, err := q.db.QueryContext(ctx, query, queryParams...) rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetFilePathsByGenresRow var items []GetGenreNamesByFilePathsRow
for rows.Next() { for rows.Next() {
var i GetFilePathsByGenresRow var i GetGenreNamesByFilePathsRow
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil { if err := rows.Scan(&i.FilePath, &i.Name); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@@ -201,51 +153,24 @@ func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string)
return items, nil return items, nil
} }
const getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many const getUnusedGenreIDs = `-- name: GetUnusedGenreIDs :many
SELECT g.name AS genre_name, af.file_path SELECT id FROM genres g
FROM genres g WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id)
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (/*SLICE:genre_names*/?)
AND af.library_id = ?
ORDER BY r.name
` `
type GetFilePathsByGenresByLibraryParams struct { func (q *Queries) GetUnusedGenreIDs(ctx context.Context) ([]int64, error) {
GenreNames []string rows, err := q.db.QueryContext(ctx, getUnusedGenreIDs)
LibraryID int64
}
type GetFilePathsByGenresByLibraryRow struct {
GenreName string
FilePath string
}
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
query := getFilePathsByGenresByLibrary
var queryParams []interface{}
if len(arg.GenreNames) > 0 {
for _, v := range arg.GenreNames {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
}
queryParams = append(queryParams, arg.LibraryID)
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []GetFilePathsByGenresByLibraryRow var items []int64
for rows.Next() { for rows.Next() {
var i GetFilePathsByGenresByLibraryRow var id int64
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil { if err := rows.Scan(&id); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, id)
} }
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return nil, err return nil, err
@@ -256,247 +181,32 @@ func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFile
return items, nil return items, nil
} }
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many const linkFileGenre = `-- name: LinkFileGenre :exec
SELECT g.id, g.name INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?)
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
WHERE rg.recording_id = ?
` `
func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) { type LinkFileGenreParams struct {
rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID) AudioFileID int64
if err != nil { GenreID int64
return nil, err
}
defer rows.Close()
var items []Genre
for rows.Next() {
var i Genre
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
} }
const getTracksByGenre = `-- name: GetTracksByGenre :many func (q *Queries) LinkFileGenre(ctx context.Context, arg LinkFileGenreParams) error {
SELECT _, err := q.db.ExecContext(ctx, linkFileGenre, arg.AudioFileID, arg.GenreID)
af.file_path, return err
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ?
ORDER BY r.name
`
type GetTracksByGenreRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) {
rows, err := q.db.QueryContext(ctx, getTracksByGenre, name)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTracksByGenreRow
for rows.Next() {
var i GetTracksByGenreRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.Composer,
&i.FileType,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTracksByGenreByLibrary = `-- name: GetTracksByGenreByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ? AND af.library_id = ?
ORDER BY r.name
`
type GetTracksByGenreByLibraryParams struct {
Name string
LibraryID int64
}
type GetTracksByGenreByLibraryRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
func (q *Queries) GetTracksByGenreByLibrary(ctx context.Context, arg GetTracksByGenreByLibraryParams) ([]GetTracksByGenreByLibraryRow, error) {
rows, err := q.db.QueryContext(ctx, getTracksByGenreByLibrary, arg.Name, arg.LibraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTracksByGenreByLibraryRow
for rows.Next() {
var i GetTracksByGenreByLibraryRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.Composer,
&i.FileType,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
} }
const upsertGenre = `-- name: UpsertGenre :one const upsertGenre = `-- name: UpsertGenre :one
INSERT INTO genres (name) VALUES (?) INSERT INTO genres (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = name ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING id, name RETURNING id, name
` `
// Queries over genres and file_genres.
//
// The track-returning ones live in audio_files.sql with the rest of the
// track_metadata reads; what is left here is the genre list itself and
// the link table's writes.
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) { func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
row := q.db.QueryRowContext(ctx, upsertGenre, name) row := q.db.QueryRowContext(ctx, upsertGenre, name)
var i Genre var i Genre
+25 -33
View File
@@ -12,10 +12,10 @@ import (
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = rgen.genre_id JOIN genres g ON g.id = fg.genre_id
WHERE g.name = ? WHERE g.name = ?
GROUP BY rg.id GROUP BY rg.id
ORDER BY RANDOM() ORDER BY RANDOM()
@@ -54,9 +54,8 @@ func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenrePa
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
HAVING SUM(af.play_count) > 0 HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC ORDER BY SUM(af.play_count) DESC
@@ -89,9 +88,8 @@ func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int6
const homeRandomAlbums = `-- name: HomeRandomAlbums :many const homeRandomAlbums = `-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
@@ -122,9 +120,8 @@ func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, e
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
ORDER BY MAX(af.id) DESC ORDER BY MAX(af.id) DESC
LIMIT ? LIMIT ?
@@ -159,9 +156,8 @@ func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]i
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL WHERE af.last_played IS NOT NULL
GROUP BY rg.id GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC ORDER BY MAX(af.last_played) DESC
@@ -172,7 +168,7 @@ LIMIT ?
// //
// Every one of these returns album ids and nothing else. The display // Every one of these returns album ids and nothing else. The display
// columns (cover art, artist credit, year) already have exactly one // columns (cover art, artist credit, year) already have exactly one
// correct expression of them, in GetAllAlbumsWithDetails, and a second // correct expression of them, in GetAlbums, and a second
// copy per shelf would be six more places for that to drift. The home // copy per shelf would be six more places for that to drift. The home
// service joins the ids back to that one album list in Go. // service joins the ids back to that one album list in Go.
// Albums with the most recent play, newest first. // Albums with the most recent play, newest first.
@@ -201,9 +197,8 @@ func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]
const homeStaleAlbums = `-- name: HomeStaleAlbums :many const homeStaleAlbums = `-- name: HomeStaleAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL WHERE af.last_played IS NOT NULL
GROUP BY rg.id GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?) HAVING MAX(af.last_played) < datetime('now', ?)
@@ -242,14 +237,12 @@ func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams
const homeTopArtists = `-- name: HomeTopArtists :many const homeTopArtists = `-- name: HomeTopArtists :many
SELECT SELECT
COALESCE(ac.text, '') AS artist_name, rg.artist_credit AS artist_name,
SUM(af.play_count) AS plays SUM(af.play_count) AS plays
FROM release_groups rg FROM albums rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id JOIN audio_files af ON af.album_id = rg.id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id WHERE rg.artist_credit <> ''
JOIN audio_files af ON af.recording_id = rgr.recording_id GROUP BY rg.artist_credit
WHERE ac.text <> ''
GROUP BY ac.text
HAVING plays > 0 HAVING plays > 0
ORDER BY plays DESC ORDER BY plays DESC
LIMIT ? LIMIT ?
@@ -288,10 +281,10 @@ func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArt
const homeTopGenres = `-- name: HomeTopGenres :many const homeTopGenres = `-- name: HomeTopGenres :many
SELECT SELECT
g.name AS genre, g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count COUNT(DISTINCT af.album_id) AS album_count
FROM genres g FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id JOIN file_genres fg ON fg.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id JOIN audio_files af ON af.id = fg.audio_file_id
GROUP BY g.id GROUP BY g.id
HAVING album_count >= 3 HAVING album_count >= 3
ORDER BY album_count DESC ORDER BY album_count DESC
@@ -331,9 +324,8 @@ func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenr
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
SELECT rg.id AS album_id SELECT rg.id AS album_id
FROM release_groups rg FROM albums rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id JOIN audio_files af ON af.album_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id GROUP BY rg.id
HAVING SUM(af.play_count) = 0 HAVING SUM(af.play_count) = 0
ORDER BY RANDOM() ORDER BY RANDOM()
-103
View File
@@ -1,103 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: mix.sql
package sqlcgen
import (
"context"
"database/sql"
)
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1
`
type GetArtistByFilePathRow struct {
ArtistName string
ArtistMbid string
}
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
var i GetArtistByFilePathRow
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
return i, err
}
const getFilePathsByArtistMBID = `-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?
`
// Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
// expanding a seed selection into a candidate pool by artist similarity
// and genre overlap, restricted to what is actually in the library.
func (q *Queries) GetFilePathsByArtistMBID(ctx context.Context, mbid sql.NullString) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getFilePathsByArtistMBID, mbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var file_path string
if err := rows.Scan(&file_path); err != nil {
return nil, err
}
items = append(items, file_path)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGenreNamesByFilePath = `-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?
`
func (q *Queries) GetGenreNamesByFilePath(ctx context.Context, filePath string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getGenreNamesByFilePath, filePath)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+55 -57
View File
@@ -9,20 +9,34 @@ import (
"time" "time"
) )
type Album struct {
ID int64
Name string
ArtistCredit string
ArtistID sql.NullInt64
Mbid sql.NullString
Year sql.NullInt64
OriginalYear sql.NullInt64
CoverArtID sql.NullInt64
PendingReleaseMbid sql.NullString
}
type Artist struct { type Artist struct {
ID int64 ID int64
Name string Name string
Mbid sql.NullString Mbid sql.NullString
} }
type ArtistCredit struct { type ArtistCreditPart struct {
ID int64 CreditID int64
Text string Position int64
ArtistMbid []byte
CreditedName string
JoinPhrase string
} }
type ArtistCreditArtist struct { type ArtistCreditRef struct {
ID int64 Mbid []byte
ArtistID int64
CreditID int64 CreditID int64
} }
@@ -56,21 +70,31 @@ type ArtistMetadatum struct {
type AudioFile struct { type AudioFile struct {
ID int64 ID int64
FilePath string FilePath string
LengthMilliseconds int64 LibraryID int64
FileTypeID int64 FileTypeID int64
RecordingID int64 LengthMilliseconds int64
SampleRate int64 SampleRate int64
BitDepth int64 BitDepth int64
Channels int64 Channels int64
Bitrate int64 Bitrate int64
FileSize int64 FileSize int64
Title string
ArtistCredit string
ArtistID sql.NullInt64
AlbumID sql.NullInt64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
Year sql.NullInt64
Composer string
Comment string
RecordingMbid sql.NullString
Basename string Basename string
LibraryID int64 GroupKey string
ModifiedAt int64
PlayCount int64 PlayCount int64
LastPlayed sql.NullTime LastPlayed sql.NullTime
TagStatus string TagStatus string
GroupKey string
ModifiedAt int64
} }
type CoverArt struct { type CoverArt struct {
@@ -160,20 +184,21 @@ type ExploreChampionFt struct {
type ExploreIndex struct { type ExploreIndex struct {
ID int64 ID int64
EntityType string EntityType int64
Mbid string Mbid []byte
Title string Title string
ArtistName string ArtistName string
ArtistMbid string ArtistMbid []byte
Aliases string Aliases string
Popularity int64 Popularity int64
ListenerCount int64 ListenerCount int64
Duration int64 Duration int64
CaaReleaseMbid string CaaReleaseMbid []byte
ReleaseName string ReleaseName string
PrimaryType string PrimaryType string
SecondaryTypes string SecondaryTypes string
ReleaseDate string ReleaseDate string
TotalTracks int64
ArtistType string ArtistType string
Country string Country string
Disambiguation string Disambiguation string
@@ -197,6 +222,11 @@ type ExploreIndexMetum struct {
Value string Value string
} }
type FileGenre struct {
AudioFileID int64
GenreID int64
}
type FileType struct { type FileType struct {
ID int64 ID int64
Extension string Extension string
@@ -231,6 +261,14 @@ type Library struct {
AutotagWarningAcked int64 AutotagWarningAcked int64
} }
type Lyric struct {
AudioFileID int64
Text string
Source string
RecordingMbid sql.NullString
FetchedAt time.Time
}
type LyricsIndex struct { type LyricsIndex struct {
Lyrics string Lyrics string
} }
@@ -291,48 +329,6 @@ type QueueTrack struct {
Position int64 Position int64
} }
type Recording struct {
ID int64
Name string
ArtistCreditID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Year sql.NullInt64
Genre sql.NullString
Composer sql.NullString
Lyrics sql.NullString
Comment sql.NullString
Mbid sql.NullString
}
type RecordingGenre struct {
ID int64
RecordingID int64
GenreID int64
}
type ReleaseGroup struct {
ID int64
Name string
CoverArtID sql.NullInt64
AlbumArtistCreditID sql.NullInt64
Year sql.NullInt64
TotalTracks sql.NullInt64
TotalDiscs sql.NullInt64
Mbid sql.NullString
OriginalYear sql.NullInt64
PendingReleaseMbid sql.NullString
}
type ReleaseGroupRecording struct {
ID int64
ReleaseGroupID int64
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
}
type ReleaseToRg struct { type ReleaseToRg struct {
ReleaseMbid string ReleaseMbid string
RgMbid string RgMbid string
@@ -410,4 +406,6 @@ type TrackMetadatum struct {
ArtistMbid string ArtistMbid string
ReleaseGroupMbid string ReleaseGroupMbid string
RecordingMbid string RecordingMbid string
AlbumID sql.NullInt64
ArtistID sql.NullInt64
} }
+25 -66
View File
@@ -124,29 +124,18 @@ SELECT
pt.playlist_id, pt.playlist_id,
pt.audio_file_id, pt.audio_file_id,
pt.position, pt.position,
COALESCE(af.file_path, '') AS file_path, COALESCE(tm.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds, COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title, COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid, CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid, COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY pt.playlist_id, pt.position ORDER BY pt.playlist_id, pt.position
` `
@@ -368,29 +357,18 @@ SELECT
pt.playlist_id, pt.playlist_id,
pt.audio_file_id, pt.audio_file_id,
pt.position, pt.position,
COALESCE(af.file_path, '') AS file_path, COALESCE(tm.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds, COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title, COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist, COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album, COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid, CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid, COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE pt.playlist_id = ? WHERE pt.playlist_id = ?
ORDER BY pt.position ORDER BY pt.position
` `
@@ -451,30 +429,10 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID
} }
const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one
SELECT SELECT title, artist_name AS artist, album,
COALESCE(r.name, '') AS title, length_milliseconds AS duration_ms, genre, cover_art_path
COALESCE(ac.text, '') AS artist, FROM track_metadata
COALESCE(rg.name, '') AS album, WHERE id = ?
af.length_milliseconds AS duration_ms,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.id = ?
` `
type GetTrackPhantomMetadataRow struct { type GetTrackPhantomMetadataRow struct {
@@ -486,6 +444,7 @@ type GetTrackPhantomMetadataRow struct {
CoverArtPath string CoverArtPath string
} }
// The display fields a playlist row keeps after its file goes away.
func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) { func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) {
row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id) row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id)
var i GetTrackPhantomMetadataRow var i GetTrackPhantomMetadataRow
+5 -20
View File
@@ -61,27 +61,11 @@ func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) {
} }
const getQueueTracks = `-- name: GetQueueTracks :many const getQueueTracks = `-- name: GetQueueTracks :many
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path, SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
COALESCE(r.name, '') AS title, tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
COALESCE(ac.text, '') AS artist, tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM queue_tracks qt FROM queue_tracks qt
JOIN audio_files af ON qt.audio_file_id = af.id JOIN track_metadata tm ON tm.id = qt.audio_file_id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY qt.position ORDER BY qt.position
` `
@@ -99,6 +83,7 @@ type GetQueueTracksRow struct {
RecordingMbid string RecordingMbid string
} }
// The queue's rows, joined to the one track projection.
func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) { func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
rows, err := q.db.QueryContext(ctx, getQueueTracks) rows, err := q.db.QueryContext(ctx, getQueueTracks)
if err != nil { if err != nil {

Some files were not shown because too many files have changed in this diff Show More