Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Each is a clean skip rather than a failure, matching the v0.0.0 guard
beside it: a red run against a tag that was never meant to ship is noise.
2026-08-18 22:25:11 -04:00
logan 065a879190 Merge pull request 'Give the icons one vocabulary and sweep the call sites' (#114) from feat/34-icon-language into main
Release / release (push) Successful in 32s
Build & publish Arch package / arch-package (push) Successful in 2m35s
Attach the desktop build to the release / linux (push) Successful in 56s
Sync Homebrew formula / sync-formula (push) Successful in 6s
CI / e2e (push) Successful in 6m17s
CI / check (push) Successful in 3m8s
Build & publish the Android APK / apk (push) Successful in 1m29s
Closes #34
2026-08-19 01:37:34 +00:00
30 changed files with 1691 additions and 191 deletions
+17
View File
@@ -139,6 +139,23 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. Two reasons it is worst here. The APK goes
# to the *generic* registry, which is readable without
# credentials so Obtainium can poll a plain URL — a beta would
# be offered to every device on it. And the versionCode maths
# below splits on dots and would read "1" out of "0-beta",
# producing a code that is wrong rather than a build that
# fails: Android orders releases by that integer and refuses
# anything not greater than what is installed.
case "$v" in
*-*)
echo "v$v is a prerelease; not publishing an APK for it"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
# Android orders releases by an integer and refuses anything
+16
View File
@@ -72,6 +72,22 @@ jobs:
exit 0
fi
# A prerelease is not a shipment either, and this trigger is
# `v*` — which matches `v0.4.0-beta.1`. Nothing produces one
# today; the guard is here because the thing that would is
# semantic-release's `prerelease: true` channel, a one-line
# change in .releaserc.yml whose blast radius is four public
# package channels. Same argument as release.yml's
# `chore(release):` guard: cheap, against something a future
# edit turns on somewhere else entirely.
case "$v" in
*-*)
echo "$v is a prerelease; not packaging it for pacman"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "building $v"
+13
View File
@@ -95,6 +95,19 @@ jobs:
exit 0
fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. The mildest of the four — assets attach to
# the prerelease's own Gitea release and no package manager
# reads them — but four workflows sharing one trigger should
# share one answer about what a shipment is.
case "$v" in
*-*)
echo "$v is a prerelease; not attaching desktop assets"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
+12
View File
@@ -56,6 +56,18 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. It matters most here of the four: the tap
# is public, and `brew upgrade` would offer a beta to everyone
# on it.
case "$VERSION" in
*-*)
echo "$TAG is a prerelease; not syncing it to a public tap"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
+54 -8
View File
@@ -1,11 +1,36 @@
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.
# run at all. 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.
#
# **It is triggered by hand, and there is deliberately no `push`
# trigger.** There was one, on `main`, which made the trigger "a PR was
# merged" and nothing else: eight releases in twenty-two hours
# (v0.0.1 -> v0.3.1) for one session's work, each fanning out to four
# publishers on a runner with capacity 1, so ~40 packaging jobs shipped
# three issues and ordinary PR CI queued behind them. A version per
# merged PR is a version per unit of *work*, not per *shipment*, and
# pacman, Homebrew and Obtainium see every one.
#
# Nothing else had to change to batch them: semantic-release already
# reads every commit since the last tag, so five fixes and two feats
# become one minor release with all seven in the notes. Release
# frequency was only ever how often this file fired.
#
# This is the rule `index-artifact.yml` states and is the other instance
# of: **a job that mutates state which cannot be rebuilt in ten minutes
# is triggered deliberately, not by a push.** A release here is a tag,
# a Gitea release, an Arch package, a Homebrew formula, a signed APK and
# desktop assets — and an Android version going backwards costs the user
# their library (docs/android-release.md).
#
# A schedule was considered and rejected: a cron batches without anyone
# having to remember, but it puts the decision back on a timer, which is
# the thing being removed.
#
# **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
@@ -19,9 +44,12 @@ name: Release
# instead.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
dry_run:
description: "Report what would be released and stop"
required: false
default: "false"
# 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.
@@ -163,14 +191,32 @@ jobs:
# 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.
# The point of a manual trigger is deliberateness, and deliberate
# means being able to look before pulling the lever. `--dry-run`
# reports the version and the notes and writes nothing: no tag, no
# release, no publishers. `make release-dry` is the same answer
# locally; this is it from the runner, against the same commit and
# the same tag history, which is what actually decides.
- name: Run semantic-release
if: steps.guard.outputs.skip == 'false'
working-directory: /src
env:
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -eu
git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app"
# Anything but a literal "true" releases for real. A typo in a
# dispatch box must not silently turn a shipment into a no-op
# that reports success — the failure worth avoiding is the one
# where nothing happens and the run is green.
dry=""
if [ "${DRY_RUN:-false}" = "true" ]; then
echo "DRY RUN — no tag will be pushed and no release created"
dry="--dry-run"
fi
npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
@@ -178,5 +224,5 @@ jobs:
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release \
semantic-release $dry \
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
+46
View File
@@ -3534,3 +3534,49 @@ silent.** `artifactHasTotals` and `artifactHasCredits` are both correct
and both mean a feature can ship, pass every test, and produce nothing
for anybody without a single failure anywhere. Checking the *published
file* is one query and is not implied by any tick in CI.
## "Do I own this" has two answers in the schema, and one of them is a flag (2026-08-19)
Decided while doing #38, and it outlives it because every future
catalog surface has to pick one.
`explore_index` carries both `in_library` and `local_artist_id` /
`local_release_group_id` / `local_recording_id`. They are written by
the same pass (`collectLibraryEntities`), so on a healthy database they
agree, and the code read them as an OR — `inLibrary || localId > 0` —
at eight call sites.
They are not the same kind of thing:
- **`local_*_id` is a fact with an owner.** Every query that sets one
joins `audio_files`, and `pruneStaleLocalCrossReferences` clears it
with an existence test that is a file test in all three cases. It is
the same rule `explore-album-details`'s `filePaths` implements, one
layer down and computed once per scan.
- **`in_library` is a ratchet.** `upsertBatch` raises it with
`MAX(in_library, excluded.in_library)` and the prune is the only
thing that lowers it — gated on the local id being non-null, so a row
holding the flag *without* an id is a fixed point nothing can clear.
Filed as #118; it still drives search scoring, the popularity-floor
bypass and two Explore shelves, so routing the UI around it was not a
fix.
What made the choice concrete rather than theoretical: on
`explore-artist-details` the *same card* used both. The context menu
gated Play on `localId > 0`; the badge used `inLibrary`. An album with
the flag and no local row drew a green tick saying it was in your
library, offered no Play, and — the request item being gated on *not*
owned — offered no way to ask for it either.
The rejected alternative is worth keeping: batching a real file lookup
per screenful, the way `credit-store` coalesces. It would have answered
for **recordings** (`GetFilePathsByRecordingMBIDs`) and most of the
cards on these surfaces are release groups, so it would have made track
rows strong, left album cards exactly where they were, and cost a new
store. The batch that *was* worth adding is a different question —
`GetAlbumsCompleteness`, "how much of this album is here", which no
per-card flag can answer at all.
The general point: **two columns that agree today are not one column.**
Which of them a new surface reads should be decided by which one has
something that can un-set it.
+14 -3
View File
@@ -1,8 +1,19 @@
# semantic-release configuration.
#
# 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.
# Run by hand from .gitea/workflows/release.yml, which has no push
# trigger: determine the version from the Conventional Commits since the
# last tag, write the changelog, push the tag, and create the Gitea
# release. A release is a shipment rather than a merge, and the commits
# accumulate until someone says so -- this file needs to know nothing
# about that, because reading everything since the last tag is what it
# already did.
#
# `branches` is main and only main. A `prerelease: true` channel is the
# obvious next edit here and is the one to think twice about: all four
# publishing workflows trigger on `v*`, which matches `v0.4.0-beta.1`.
# They carry a prerelease guard now, so the failure is a clean skip
# rather than a beta in a public tap -- but they are four separate files
# and this is the line that would turn them on.
#
# **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
+10 -5
View File
@@ -5,15 +5,20 @@ The changelog is the releases page:
<https://git.ljones.me/yonlu/yellowjacket/releases>
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.
contains, by `.gitea/workflows/release.yml`. Each one carries its notes
as its body, grouped by change type, with a link to the commit behind
every line.
That workflow is **run by hand**, so a release holds everything merged
since the last one rather than one PR's worth. It used to fire on every
push to `main`, which made a version per merged PR (issue #115).
**This file is not generated and is not a copy of that.** `main` is a
protected branch, so nothing pushes a changelog commit back to it — and a
file that claimed to be a changelog while silently never updating would
be worse than no file at all. `make release-dry` prints what the next
merge would release.
be worse than no file at all. `make release-dry` prints what a release
run would cut right now, and the workflow's own `dry_run` input answers
the same question from CI.
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
+97 -4
View File
@@ -1658,6 +1658,59 @@ not about plumbing — it says rows may be missing from the page
altogether, which nothing on screen can show. (`explore-artist-details`
still uses `loading`; it has no equivalent per-row signal.)
**And that treatment is the app's, not the page's.**
`utils/ownership.ts` is the rule written once, because it was written
at eight call sites and so none of them had the whole of it: Explore's
cards, `top-results-row` and the artist page's three card shapes all
mixed owned and unowned with a small badge as the only difference, and
the badge on the *owned* ones was a green tick — the mark on the common
case this tracklist removed. Owned is plain and draws no badge at all;
unowned is dimmed, says so in its accessible name, and keeps its
request affordance; a partly-held album says how partly.
Four things about it are load-bearing.
**Ownership is `localId`, and `inLibrary` is deliberately not
consulted.** The album page answers with `filePaths`, a real file per
displayed track, and a card grid cannot afford that — but it does not
need to, because `explore_index.local_*_id` is built by
`collectLibraryEntities` from queries that every one join `audio_files`
and cleared by `pruneStaleLocalCrossReferences`, whose existence test
is a file test in all three cases. That is the same "ownership is a
file" rule computed once per scan instead of once per screenful.
`in_library` is written by the same pass, so the two agree in a healthy
database, but it is a one-way ratchet
(`MAX(in_library, excluded.in_library)`) whose only clearing pass is
gated on a non-null local id: it cannot be un-set on its own (#118).
One is a fact with an owner; the other is a flag that happens to agree.
Both `explore-view` and `explore-artist-details` additionally kept a
`libraryMBIDs` set that accumulated every MBID ever seen with the flag
and cleared it never, in views that never unmount; both are gone.
**The two answers used to sit on one card.**
`renderReleaseMenuItems` gates Play on `release.localId > 0` while the
badge used `inLibrary`, so an album with the flag and no local row drew
a tick saying it was in your library, offered no Play, and — the
request item being gated on *not* owned — offered no way to ask for it
either. Any new surface that asks the question twice will reproduce it.
**`aria-disabled` goes on rows and not on cards.** An unowned *row*
cannot be activated; an unowned *card* still navigates to the catalog
page for it, which is a perfectly good thing to do with something you
do not own. The accessible name carries the state either way, which is
why it is one helper and not a class.
**The count is batched, not looked up.** `store/completeness-store.ts`
is `credit-store` one question over: `request()` is per-card and
coalesces a screenful into one `GetAlbumsCompleteness`, absence is
cached as an answer (or the albums with no totals re-ask forever), and
the whole cache is dropped on a scan, a retag or a removal rather than
aged. `library-status.ts`'s `albumBadgeFor` is where that meets
`Known`: a total that was never declared is a plain `in-library`, never
a ring at 0%. One consequence in the badge itself — a `partial` badge
is *actionable*, and a control named after its action alone dropped the
count from the one state the ring exists for, so its name is both.
**A partly-owned album draws the release, not the part.** Once the tags
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
with three dimmed, rather than the nine on disk — the missing tracks
@@ -2309,13 +2362,53 @@ those run at all; `unclaim.yml` is housekeeping on the tracker and
touches no code; 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
**`release.yml` is the entry point for all of it, and it is triggered by
hand.** 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.
`v*`, so **the tag push is what starts them**the version, the notes
and the packaging are still nobody's manual work; *when* is the only
decision left to a person.
**It used to fire on every push to `main`, which made the trigger "a PR
was merged".** That is a version per unit of *work* rather than per
*shipment*: eight releases in twenty-two hours (`v0.0.1``v0.3.1`) for
one session, each fanning out to four publishers on a runner with
capacity 1 — ~40 packaging jobs to ship three issues, with ordinary PR CI
queued behind them. Nothing else had to change to batch them, because
**semantic-release already reads every commit since the last tag**: five
`fix`es and two `feat`s become one minor release with all seven in the
notes. Release frequency was only ever how often the workflow fired.
This is the same rule `index-artifact.yml` states — *a job that mutates
state which cannot be rebuilt in ten minutes is triggered deliberately,
not by a push* — and the two are now the only workflows with no push
trigger. A schedule was considered and rejected: a cron batches without
anyone having to remember, but it puts the decision back on a timer,
which is the thing being removed. A `beta` integration branch was
considered and rejected too (#115): it relocates the trigger rather than
removing one, needs a second protected branch carrying the same required
checks, and *adds* a full `check` + `e2e` run per batch on the very
runner whose queue is the complaint.
**`dry_run` is why the manual trigger is usable.** The point of pulling
a lever by hand is being able to look first, so the dispatch takes a
flag that runs `semantic-release --dry-run`: the version and the notes,
no tag, no release, no publishers. Anything but the literal string
`true` releases for real — a typo in a dispatch box must not silently
turn a shipment into a green no-op.
**A prerelease tag is not a shipment, and all four publishers now say
so.** Their trigger is `v*`, which matches `v0.4.0-beta.1`; they guarded
`v0.0.0` and nothing else. Nothing produces a prerelease today — the
guard is there because the thing that would is `prerelease: true` in
`.releaserc.yml`, one line whose blast radius is a public Homebrew tap
and a credential-free APK registry that Obtainium polls. `android-apk`
is the worst of the four twice over, since its `versionCode` maths
splits on dots and would read `1` out of `0-beta` — a wrong number
rather than a failed build, and Android refuses anything not greater
than what is installed.
Four things about it are load-bearing:
+11 -5
View File
@@ -192,15 +192,21 @@ skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md
commit-check: ## Fail if a commit subject is not a Conventional Commit
@./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.
# What running the release workflow now would ship, without shipping 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.
#
# release.yml is dispatch-only, so this answers the question that
# actually gets asked now -- what has accumulated since the last tag --
# rather than what one merge would have done. The workflow's own
# `dry_run` input is the same answer from the runner, against whatever
# main points at rather than the working tree.
#
# 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
release-dry: ## Print the version a release run would cut right now
@npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
+46
View File
@@ -135,3 +135,49 @@ SELECT
) AS INTEGER) AS known
FROM audio_files a
WHERE a.album_id = sqlc.arg(album_id);
-- name: GetAlbumsCompleteness :many
-- The same question as GetAlbumCompleteness, asked of a screenful of
-- albums at once.
--
-- A card grid cannot afford one query per card, and the answer it wants
-- is the one thing a badge cannot guess: an album held 9 tracks of 12
-- must show the count, never a bare tick. So this is one query for the
-- whole grid, asked only of the cards that have a local album id.
--
-- It is two grouping levels rather than the single-album form's
-- correlated subqueries, because a correlated subquery in the FROM
-- clause is not something SQLite will reliably do -- and because the
-- slice may only be spelled once, or sqlc expands it twice with
-- independently numbered placeholders.
--
-- The per-disc level is where the meaning is, and it is the same
-- meaning as the single-album query. `owned` counts DISTINCT track
-- numbers within a disc (this app detects duplicates, and counting two
-- files of track 3 twice would report a short album as complete), with
-- a file that declares no track number falling back to its own id
-- because three untagged files are three tracks and not one.
-- `expected` takes each disc's declared total and sums over discs,
-- since a total is declared per disc and a release total written on
-- every file of a two-disc album would double its expectation. A disc
-- whose files declared nothing contributes a NULL that SUM ignores,
-- and `known` is what says the album is therefore unanswerable.
WITH per_disc AS (
SELECT
album_id AS album_id,
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
AS owned_on_disc,
MAX(total_tracks) AS disc_total,
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
AS discs_without_a_total
FROM audio_files
WHERE album_id IN (sqlc.slice('album_ids'))
GROUP BY album_id, COALESCE(disc_number, 1)
)
SELECT
CAST(album_id AS INTEGER) AS album_id,
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
FROM per_disc
GROUP BY album_id;
@@ -8,6 +8,7 @@ package sqlcgen
import (
"context"
"database/sql"
"strings"
)
const deleteAlbum = `-- name: DeleteAlbum :exec
@@ -234,6 +235,98 @@ func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArti
return items, nil
}
const getAlbumsCompleteness = `-- name: GetAlbumsCompleteness :many
WITH per_disc AS (
SELECT
album_id AS album_id,
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
AS owned_on_disc,
MAX(total_tracks) AS disc_total,
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
AS discs_without_a_total
FROM audio_files
WHERE album_id IN (/*SLICE:album_ids*/?)
GROUP BY album_id, COALESCE(disc_number, 1)
)
SELECT
CAST(album_id AS INTEGER) AS album_id,
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
FROM per_disc
GROUP BY album_id
`
type GetAlbumsCompletenessRow struct {
AlbumID int64
Owned int64
Expected int64
Known int64
}
// The same question as GetAlbumCompleteness, asked of a screenful of
// albums at once.
//
// A card grid cannot afford one query per card, and the answer it wants
// is the one thing a badge cannot guess: an album held 9 tracks of 12
// must show the count, never a bare tick. So this is one query for the
// whole grid, asked only of the cards that have a local album id.
//
// It is two grouping levels rather than the single-album form's
// correlated subqueries, because a correlated subquery in the FROM
// clause is not something SQLite will reliably do -- and because the
// slice may only be spelled once, or sqlc expands it twice with
// independently numbered placeholders.
//
// The per-disc level is where the meaning is, and it is the same
// meaning as the single-album query. `owned` counts DISTINCT track
// numbers within a disc (this app detects duplicates, and counting two
// files of track 3 twice would report a short album as complete), with
// a file that declares no track number falling back to its own id
// because three untagged files are three tracks and not one.
// `expected` takes each disc's declared total and sums over discs,
// since a total is declared per disc and a release total written on
// every file of a two-disc album would double its expectation. A disc
// whose files declared nothing contributes a NULL that SUM ignores,
// and `known` is what says the album is therefore unanswerable.
func (q *Queries) GetAlbumsCompleteness(ctx context.Context, albumIds []sql.NullInt64) ([]GetAlbumsCompletenessRow, error) {
query := getAlbumsCompleteness
var queryParams []interface{}
if len(albumIds) > 0 {
for _, v := range albumIds {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:album_ids*/?", strings.Repeat(",?", len(albumIds))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:album_ids*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAlbumsCompletenessRow
for rows.Next() {
var i GetAlbumsCompletenessRow
if err := rows.Scan(
&i.AlbumID,
&i.Owned,
&i.Expected,
&i.Known,
); 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 != ''
+9
View File
@@ -2212,6 +2212,7 @@ func (e *Service) gatherTopCandidates(
ArtistType: a.Type,
Country: a.Country,
InLibrary: a.InLibrary,
LocalID: a.LocalID,
},
category: "artist",
qualityScore: quality,
@@ -2243,6 +2244,7 @@ func (e *Service) gatherTopCandidates(
ArtistType: a.Type,
Country: a.Country,
InLibrary: a.InLibrary,
LocalID: a.LocalID,
},
category: "artist",
qualityScore: quality,
@@ -2275,6 +2277,7 @@ func (e *Service) gatherTopCandidates(
PrimaryType: rg.PrimaryType,
Year: year,
InLibrary: rg.InLibrary,
LocalID: rg.LocalID,
},
category: "release_group",
qualityScore: quality,
@@ -2320,6 +2323,7 @@ func (e *Service) gatherTopCandidates(
PrimaryType: rg.PrimaryType,
Year: year,
InLibrary: rg.InLibrary,
LocalID: rg.LocalID,
},
category: "release_group",
qualityScore: quality,
@@ -2347,6 +2351,7 @@ func (e *Service) gatherTopCandidates(
CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName,
InLibrary: r.InLibrary,
LocalID: r.LocalID,
},
category: "recording",
qualityScore: quality,
@@ -2403,6 +2408,7 @@ func (e *Service) gatherTopCandidates(
CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName,
InLibrary: r.InLibrary,
LocalID: r.LocalID,
},
category: "recording",
qualityScore: quality,
@@ -2425,6 +2431,7 @@ func (e *Service) gatherTopCandidates(
ArtistType: m.ArtistType,
Country: m.Country,
InLibrary: m.InLibrary || m.LocalArtistID > 0,
LocalID: m.LocalArtistID,
},
category: "artist",
qualityScore: quality,
@@ -2445,6 +2452,7 @@ func (e *Service) gatherTopCandidates(
PrimaryType: m.PrimaryType,
Year: year,
InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0,
LocalID: m.LocalReleaseGroupID,
},
category: "release_group",
qualityScore: quality,
@@ -2459,6 +2467,7 @@ func (e *Service) gatherTopCandidates(
ArtistMBID: m.ArtistMBID,
Length: m.Duration,
InLibrary: m.InLibrary || m.LocalRecordingID > 0,
LocalID: m.LocalRecordingID,
},
category: "recording",
qualityScore: quality,
+10 -1
View File
@@ -41,7 +41,16 @@ type TopResult struct {
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
ReleaseName string `json:"releaseName,omitempty"`
// Library status — populated from index cross-reference columns.
InLibrary bool `json:"inLibrary"`
//
// LocalID is the one the cards read. It is the local row behind
// this entity — an album, a file, an artist — and it is set and
// cleared by a test against `audio_files`, so it means "there is
// something of mine here". InLibrary is written by the same pass
// but is a one-way ratchet the prune can only clear alongside a
// local id, so it is the weaker of the two and stays for scoring
// (`fwInLibrary`), which is where an approximate answer is fine.
InLibrary bool `json:"inLibrary"`
LocalID int64 `json:"localId,omitempty"`
}
// MBArtist is a Wails-friendly projection of a MusicBrainz artist.
+109
View File
@@ -232,3 +232,112 @@ func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
t.Errorf("empty album reported %+v, want zero and unknown", got)
}
}
// The batch and the single-album query are two spellings of one
// question, and the thing worth pinning is that they never disagree.
//
// They are genuinely different SQL — the single-album form is
// correlated subqueries over one album, the batch is two grouping
// levels over a slice — so the risk is not a typo but a drift in
// meaning: a disc's total counted once per file, a duplicate counted
// twice, a disc with no total silently covered by one that had one.
// Every shape the table above cares about is staged here at once,
// because a batch that is only ever asked about one album is not being
// asked the question that can go wrong.
func TestGetAlbumsCompletenessAgreesWithTheSingleAlbumQuery(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
shapes := map[int][]track{
1: disc(1, 100, 12, 12),
2: disc(1, 200, 9, 12),
3: disc(1, 300, 13, 12),
4: {{recordingID: 400, disc: 1, number: 1}},
5: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
6: append(
disc(1, 700, 10, 10),
track{recordingID: 750, disc: 2, number: 1},
),
7: append(
disc(1, 800, 5, 6),
track{recordingID: 899, disc: 1, number: 3, total: 6},
),
}
ids := make([]int64, 0, len(shapes))
for albumID, tracks := range shapes {
stageAlbum(t, lib, albumID, tracks)
ids = append(ids, albumIDFor(t, lib, albumID))
}
batch, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if len(batch) != len(ids) {
t.Fatalf("batch answered for %d albums, want %d", len(batch), len(ids))
}
for _, id := range ids {
one, err := lib.GetAlbumCompleteness(id)
if err != nil {
t.Fatalf("GetAlbumCompleteness(%d): %v", id, err)
}
if got := batch[id]; got != one {
t.Errorf("album %d: batch says %+v, single says %+v", id, got, one)
}
}
}
// An album with no files is absent from the batch, not zeroed.
//
// "I have none of this" and "I have no idea" are the third state Known
// exists to keep apart, and a caller reading a missing key gets nothing
// rather than a confident zero it would have to know to distrust.
func TestGetAlbumsCompletenessOmitsAnAlbumWithNoFiles(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
stageAlbum(t, lib, 1, disc(1, 100, 3, 3))
held := albumIDFor(t, lib, 1)
got, err := lib.GetAlbumsCompleteness([]int64{held, 4242})
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if _, ok := got[4242]; ok {
t.Errorf("an album with no files answered %+v, want absent", got[4242])
}
if !got[held].Complete {
t.Errorf("held album reported %+v, want complete", got[held])
}
}
// A caller with nothing to ask about must not issue a query at all —
// sqlc's empty-slice branch rewrites the placeholder to NULL, which is
// a perfectly valid query returning nothing, so this is about the round
// trip rather than the answer.
func TestGetAlbumsCompletenessAsksNothingForAnEmptyList(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
for _, ids := range [][]int64{nil, {}, {0}, {-1, 0}} {
got, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness(%v): %v", ids, err)
}
if len(got) != 0 {
t.Errorf("GetAlbumsCompleteness(%v) = %+v, want empty", ids, got)
}
}
}
+59
View File
@@ -244,6 +244,65 @@ func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error)
}, nil
}
// GetAlbumsCompleteness answers the same question for a screenful of
// albums in one query, keyed by album id.
//
// A card grid asks this about every card that has a local album behind
// it, and one query per card is how a grid of fifty albums becomes
// fifty round trips. The answer matters there for the reason it
// matters on the album page: an album held 9 tracks of 12 has to show
// the count, and a bare tick saying "in your library" is the complaint
// this whole rule came from.
//
// An album with no row in the result is one with no files, and it is
// absent rather than zeroed — "I have none of this" and "I have no
// idea" are the same third state `Known` exists to keep apart, and a
// caller reading a missing key gets nothing rather than a confident 0.
func (l *Library) GetAlbumsCompleteness(
albumIDs []int64,
) (map[int64]AlbumCompleteness, error) {
out := make(map[int64]AlbumCompleteness, len(albumIDs))
if len(albumIDs) == 0 {
return out, nil
}
keys := make([]sql.NullInt64, 0, len(albumIDs))
for _, id := range albumIDs {
if id <= 0 {
continue
}
keys = append(keys, sql.NullInt64{Int64: id, Valid: true})
}
if len(keys) == 0 {
return out, nil
}
rows, err := l.db.ReadQueries.GetAlbumsCompleteness(l.ctx, keys)
if err != nil {
l.logger.Error("could not get album completeness in batch",
"albums", len(keys), "error", err)
return nil, fmt.Errorf("could not get album completeness: %w", err)
}
for _, row := range rows {
known := row.Known != 0 && row.Expected > 0
out[row.AlbumID] = AlbumCompleteness{
Owned: int(row.Owned),
Expected: int(row.Expected),
Known: known,
Complete: known && row.Owned >= row.Expected,
}
}
return out, nil
}
// GetAlbumTracks returns one album's tracks in disc/track order.
func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) {
rows, err := l.db.ReadQueries.GetTracksByAlbum(
+16 -6
View File
@@ -7,12 +7,22 @@ which is what lets Obtainium poll a plain URL with no token. It also
attaches the same file to the Gitea release, which is what a person
looking at the release page downloads.
**Tags are not pushed by hand any more.** `.gitea/workflows/release.yml`
reads the Conventional Commits on every merge to `main`, decides the
version, and pushes the tag this workflow is keyed on — so releasing the
APK means merging a `fix:` or `feat:` commit, not running `git tag`. The
`workflow_dispatch` path below remains, for rebuilding a tag that already
exists.
**Tags are not pushed by hand any more, but releasing is a decision.**
`.gitea/workflows/release.yml` reads the Conventional Commits since the
last tag, decides the version, and pushes the tag this workflow is keyed
on — so releasing the APK means **running that workflow**, not running
`git tag`. It has no push trigger: merging a `fix:` or `feat:` used to
be enough and produced a version per merged PR (issue #115). Run it with
`dry_run` first to see what the accumulated commits would ship. The
`workflow_dispatch` path below is a different thing and remains, for
rebuilding a tag that already exists.
**A prerelease tag is skipped here**, cleanly. This workflow triggers on
`v*`, which matches `v0.4.0-beta.1`, and it is the one where that would
hurt most: the APK goes to the credential-free generic registry that
Obtainium polls, and the `versionCode` maths below splits on dots — it
would read `1` out of `0-beta` and produce a wrong number rather than a
failed build.
## The 1.x installs cannot be upgraded to 0.0.x
@@ -431,8 +431,17 @@ export interface TopResult {
/**
* Library status — populated from index cross-reference columns.
*
* LocalID is the one the cards read. It is the local row behind
* this entity — an album, a file, an artist — and it is set and
* cleared by a test against `audio_files`, so it means "there is
* something of mine here". InLibrary is written by the same pass
* but is a one-way ratchet the prune can only clear alongside a
* local id, so it is the weaker of the two and stays for scoring
* (`fwInLibrary`), which is where an approximate answer is fine.
*/
"inLibrary": boolean;
"localId"?: number;
}
/**
@@ -98,6 +98,26 @@ export function GetAlbumsByArtist(artist: string, libraryID: number): $Cancellab
return $Call.ByID(1456840721, artist, libraryID);
}
/**
* GetAlbumsCompleteness answers the same question for a screenful of
* albums in one query, keyed by album id.
*
* A card grid asks this about every card that has a local album behind
* it, and one query per card is how a grid of fifty albums becomes
* fifty round trips. The answer matters there for the reason it
* matters on the album page: an album held 9 tracks of 12 has to show
* the count, and a bare tick saying "in your library" is the complaint
* this whole rule came from.
*
* An album with no row in the result is one with no files, and it is
* absent rather than zeroed — "I have none of this" and "I have no
* idea" are the same third state `Known` exists to keep apart, and a
* caller reading a missing key gets nothing rather than a confident 0.
*/
export function GetAlbumsCompleteness(albumIDs: number[] | null): $CancellablePromise<{ [_ in `${number}`]?: $models.AlbumCompleteness } | null> {
return $Call.ByID(531636827, albumIDs);
}
/**
* GetAllLibrariesWithTrackCounts lists the libraries and their sizes.
*/
@@ -3,6 +3,7 @@ import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { unownedLabel, unownedStyles } from '@utils/ownership';
import {
LookupReleaseGroup,
BrowseReleases,
@@ -316,6 +317,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
exploreLinkStyles,
contextMenuStyles,
srOnly,
unownedStyles,
css`
:host {
display: flex;
@@ -687,20 +689,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
white-space: nowrap;
}
/* A track the library does not have, on the pattern a
* streaming service uses for something it cannot play: the
* row stays, dimmed, so the album reads as the album rather
* than as the subset that happens to be here.
*
* The dimming is a colour, so it cannot be the only signal
* — the row also carries aria-disabled, which is what
* reaches anyone not seeing it. Secondary rather than
* tertiary because the row's hover background is
* bgOverlay, which tertiary does not clear. */
.track-row.unowned .track-title {
color: var(--yj-text-secondary, #b3b3b3);
font-weight: 400;
}
/* The dimming itself is unownedStyles, from
* utils/ownership.ts, imported above. It was written here
* first — this tracklist is where the treatment came from —
* and moved out when seven other surfaces had to draw the
* same thing, because two of them would otherwise have
* ended up drawing it slightly differently. (No backticks or
* apostrophes-as-quotes here: this is inside a tagged
* template literal.) */
/* The request control is offered on every row that has
* something to request, and is not revealed on hover.
@@ -3273,7 +3269,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${owned
? `Play “${track.title}`
: `${track.title} — not in your library`}
: unownedLabel(track.title, 'track')}
@dblclick=${() => this.onTrackRowDblClick(track)}
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, track)}
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, track)}
@@ -39,7 +39,17 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor, toggleRequest } from '@utils/library-status';
import {
albumBadgeFor,
libraryStatusFor,
toggleRequest,
} from '@utils/library-status';
import {
isOwned,
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import { queueStore } from '../../store/queue-store';
@@ -178,7 +188,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@state() private discoRowSize = 5;
private discoObserver?: ResizeObserver;
@state() private similarExpanded = false;
private libraryMBIDs = new Set<string>();
/* ── Release prefetch ── */
@@ -257,6 +266,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
designTokens,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
css`
:host {
display: flex;
@@ -995,6 +1005,9 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
/** Unsubscribe handle for the requests list. */
private unsubRequests: (() => void) | null = null;
/** Unsubscribes the "how much of this album is here" repaint. */
private unsubCompleteness: (() => void) | null = null;
override connectedCallback() {
super.connectedCallback();
if (this.artistMBID || this.localArtistId) {
@@ -1007,6 +1020,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.unsubRequests = downloadStore.subscribe(() => this.requestUpdate());
void downloadStore.init().then(() => this.requestUpdate());
// The count behind a partly-held album lands a frame after the
// cards do, since the store batches a screenful into one query.
this.unsubCompleteness = completenessStore.subscribe(() =>
this.requestUpdate(),
);
// A background discography fetch (top tracks / top releases for an
// artist that wasn't indexed yet) finished — re-fetch those two
// sections, once per artist, so they fill in without the initial
@@ -1045,6 +1064,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
super.disconnectedCallback();
this.unsubRequests?.();
this.unsubRequests = null;
this.unsubCompleteness?.();
this.unsubCompleteness = null;
this.unsubDiscogReady?.();
this.unsubSimilarReady?.();
if (this.discogFallbackTimer) clearTimeout(this.discogFallbackTimer);
@@ -1573,10 +1594,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
this.catalogPending = false;
}
// Populate libraryMBIDs from the inLibrary flag (already
// set by the backend via local_release_group_id cross-ref).
this.checkLibrary();
// Batch-resolve cover art for discography (lower priority — loaded after top sections).
void this.batchResolveThumbnails(
rgs?.map((r) => ({ mbid: r.mbid, albumName: r.title, artistName: r.artistCredit }))
@@ -1903,23 +1920,6 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
}
}
private checkLibrary() {
// Backend now populates `inLibrary` directly on each MBReleaseGroup
// via the local_release_group_id cross-reference column. Just read it.
let updated = false;
for (const rg of this.releaseGroups) {
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
this.libraryMBIDs.add(rg.mbid);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
}
/* ── Playback ── */
/**
@@ -2015,11 +2015,14 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
/**
* File path for one top track, resolved by recording MBID — the
* same key `inLibrary`/`localId` were set from. Works whether or
* not the containing release itself matched a local album.
* same key `localId` was set from. Works whether or not the
* containing release itself matched a local album.
*
* Gated on the same answer the row is drawn from, or a row drawn
* dimmed and `aria-disabled` would still try to play and fail.
*/
private async trackFilePath(track: LBTopRecording): Promise<string | null> {
if (!(track.inLibrary || track.localId) || !track.recordingMbid) return null;
if (!isOwned(track) || !track.recordingMbid) return null;
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await dictByName(
@@ -2063,7 +2066,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
}
private isTrackOwned(track: LBTopRecording): boolean {
return Boolean(track.inLibrary || track.localId);
return isOwned(track);
}
private onTrackRowDblClick(track: LBTopRecording): void {
@@ -2106,7 +2109,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
mbid: rg.releaseGroupMbid || '',
localId: rg.localId ?? 0,
title: rg.title,
owned: Boolean(rg.inLibrary || rg.localId),
owned: isOwned(rg),
};
}
@@ -2126,10 +2129,12 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
mbid: isLocal ? '' : rg.mbid || '',
localId: Number.isFinite(localId) ? localId : 0,
title: rg.title,
owned:
this.libraryMBIDs.has(rg.mbid) ||
Boolean(rg.inLibrary) ||
localId > 0,
// The same answer the menu gates Play on, which is the
// point: this used to be `inLibrary` too, so a card could
// report itself owned, be offered no Play (that item is
// gated on the local id) and be offered no request either
// (that one is gated on *not* owned).
owned: localId > 0,
};
}
@@ -2944,15 +2949,20 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="top-section-col top-section-col-tracks">
<h3 class="section-header">Top Tracks</h3>
<div class="track-list">
${tracks.map(
(t, i) => html`
${tracks.map((t, i) => {
const owned = this.isTrackOwned(t);
return html`
<div
class=${classMap({ 'track-item': true, owned: this.isTrackOwned(t) })}
class=${classMap({
'track-item': true,
owned,
unowned: !owned,
})}
tabindex="0"
role="button"
aria-label=${this.isTrackOwned(t)
? `Play “${t.trackName}`
: `${t.trackName} — not in your library`}
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${ownershipLabel(owned, 'Play', t.trackName, 'track')}
@dblclick=${() => this.onTrackRowDblClick(t)}
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)}
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)}
@@ -2977,16 +2987,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<span class="track-listens">
${formatListenCount(t.totalListenCount)} plays
</span>
<library-status-indicator
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>
${owned
? nothing
: html`<library-status-indicator
status=${libraryStatusFor(false, t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>`}
</div>
`,
)}
`;
})}
</div>
${canExpandTracks
? html`
@@ -3057,10 +3069,16 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
const artURL = this.thumbnailURLs.get(rg.releaseGroupMbid) || '';
const target = this.topReleaseTarget(rg);
const owned = target.owned;
const badge = albumBadgeFor(
{ localId: target.localId },
rg.releaseGroupMbid,
);
return html`
<div
class="top-release-card"
class=${classMap({ 'top-release-card': true, unowned: !owned })}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToTopRelease(rg)}
role="button"
tabindex="0"
@@ -3092,14 +3110,18 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="top-release-meta-text">
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div>
<library-status-indicator
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>`}
</div>
</div>
</div>
@@ -3189,14 +3211,15 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
private renderAlbumCard(rg: MBReleaseGroup) {
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
const status = libraryStatusFor(inLibrary, rg.mbid);
const target = this.albumTarget(rg);
const owned = target.owned;
const badge = albumBadgeFor({ localId: target.localId }, target.mbid);
return html`
<div
class="album-card"
class=${classMap({ 'album-card': true, unowned: !owned })}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToAlbum(rg)}
role="button"
tabindex="0"
@@ -3225,13 +3248,17 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<div class="album-meta-text">
${year ? html`<span>${year}</span>` : nothing}
</div>
<library-status-indicator
status=${status}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>`}
</div>
</div>
`;
@@ -1,5 +1,11 @@
import { avatarBackground } from '@utils/avatar-color';
import { libraryStatusFor } from '@utils/library-status';
import { albumBadgeFor, libraryStatusFor } from '@utils/library-status';
import {
isOwned,
ownershipLabel,
unownedStyles,
} from '@utils/ownership';
import { completenessStore } from '@store/completeness-store';
import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
@@ -171,7 +177,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
private searchDebounceTimer?: ReturnType<typeof setTimeout>;
private thumbnailCache = new LRUMap<string, string>(THUMBNAIL_CACHE_LIMIT);
private artistImageCache = new LRUMap<string, string>(ARTIST_IMAGE_CACHE_LIMIT);
private libraryMBIDs = new Set<string>();
constructor() {
super();
@@ -226,6 +231,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
srOnly,
exploreLinkStyles,
contextMenuStyles,
unownedStyles,
css`
:host {
display: block;
@@ -812,6 +818,13 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
// Explore should not pay for it.
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
void downloadStore.init().then(() => this.requestUpdate());
// How much of an owned album is here arrives a frame after the
// cards do — the store coalesces a screenful into one query —
// so a card that turns out to be 9 of 12 repaints when the
// answer lands rather than showing a plain tick until something
// else happens to re-render the grid.
this.whileActive(completenessStore.subscribe(() => this.requestUpdate()));
}
/** A debounced search that lands after the user has left the page is
@@ -1083,7 +1096,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
this.results?.artists ?? [],
this.results?.releaseGroups ?? [],
);
this.checkLibrary();
} catch (err) {
if (version !== this.searchVersion) return;
@@ -1229,8 +1241,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
void this.playAlbum(rg, false);
}
private onRecordingRowDblClick(r: { mbid: string; inLibrary: boolean; localId?: number }): void {
if (!r.inLibrary && !r.localId) return;
// The same answer the row is drawn from. It used to accept
// `inLibrary` as well, so a row drawn dimmed and `aria-disabled`
// would still try to play and fail with a notification.
private onRecordingRowDblClick(r: { mbid: string; localId?: number }): void {
if (!isOwned(r)) return;
void this.playRecording(r.mbid);
}
@@ -1665,42 +1680,6 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
}
}
/**
* Check which result MBIDs exist in the local library.
*/
private checkLibrary() {
if (!this.results) return;
// Backend now populates `inLibrary` directly on each MB result
// via the local_*_id cross-reference columns. Just read those.
let updated = false;
for (const a of this.results.artists ?? []) {
if (a.mbid && a.inLibrary && !this.libraryMBIDs.has(a.mbid)) {
this.libraryMBIDs.add(a.mbid);
updated = true;
}
}
for (const rg of this.results.releaseGroups ?? []) {
if (rg.mbid && rg.inLibrary && !this.libraryMBIDs.has(rg.mbid)) {
this.libraryMBIDs.add(rg.mbid);
updated = true;
}
}
for (const r of this.results.recordings ?? []) {
if (r.mbid && r.inLibrary && !this.libraryMBIDs.has(r.mbid)) {
this.libraryMBIDs.add(r.mbid);
updated = true;
}
}
if (updated) {
this.requestUpdate();
}
}
/* ── Navigation ── */
private navigateToArtist(artist: MBArtist) {
@@ -2107,12 +2086,16 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
: nothing}
<div class="horizontal-row">
${artists.map((a) => {
const owned = isOwned(a);
const name = a.englishName || a.name;
return html`
<div
class="artist-card"
class=${classMap({ 'artist-card': true, unowned: !owned })}
@click=${() => this.navigateToArtist(a)}
role="button"
tabindex="0"
aria-label=${ownershipLabel(owned, 'Artist', name, 'artist')}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -2171,11 +2154,17 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
const artURL = this.thumbnailCache.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
const owned = Boolean(rg.localId);
const owned = isOwned(rg);
const badge = albumBadgeFor(rg, rg.mbid);
return html`
<div
class=${classMap({ 'album-card': true, owned })}
class=${classMap({
'album-card': true,
owned,
unowned: !owned,
})}
aria-label=${ownershipLabel(owned, 'Album', rg.title, 'album')}
@click=${() => this.navigateToAlbum(rg)}
@dblclick=${() => this.onAlbumCardDblClick(rg)}
@contextmenu=${(e: MouseEvent) =>
@@ -2228,13 +2217,17 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
: nothing}
${year ? html`<span>${year}</span>` : nothing}
</div>
<library-status-indicator
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>
${badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>`}
</div>
</div>
`;
@@ -2249,12 +2242,20 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<section>
<h3 class="section-header">Tracks</h3>
<div class="track-list">
${recordings.map(
(r) => html`
${recordings.map((r) => {
const owned = isOwned(r);
return html`
<div
class=${classMap({ 'track-item': true, owned: Boolean(r.inLibrary || r.localId) })}
class=${classMap({
'track-item': true,
owned,
unowned: !owned,
})}
role="button"
tabindex="0"
aria-disabled=${owned ? 'false' : 'true'}
aria-label=${ownershipLabel(owned, 'Play', r.title, 'track')}
@dblclick=${() => this.onRecordingRowDblClick(r)}
@contextmenu=${(e: MouseEvent) =>
this.onExploreContextMenu(e, {
@@ -2291,16 +2292,18 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
? html`<span class="track-duration">${formatDuration(r.length)}</span>`
: nothing}
</div>
<library-status-indicator
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
entity-type="track"
label=${r.title}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>
${owned
? nothing
: html`<library-status-indicator
status=${libraryStatusFor(false, r.mbid)}
entity-type="track"
label=${r.title}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>`}
</div>
`,
)}
`;
})}
</div>
</section>
`;
@@ -297,9 +297,23 @@ export class LibraryStatusIndicator extends LitElement {
// row to one and nothing to the other, and "Add … to library"
// was the old button's promise written into the copy.
if (this.actionable) {
return this.status === 'queued'
? `Cancel the request for ${kind}${name}`
: `Request ${kind}${name}`;
if (this.status === 'queued') {
return `Cancel the request for ${kind}${name}`;
}
// A partly-held album is actionable *and* has a count, and
// the count does not survive being named after the action
// alone. The `partial` case below says why it matters — a
// ring says "some" to a sighted user and nothing to anyone
// else — and that argument does not stop applying because
// the badge became clickable. This branch used to say only
// "Request album X", so the one state the ring exists for
// was the one state whose name did not mention it.
if (this.status === 'partial') {
return `Request the rest of ${kind}${name}${this.owned} of ${this.expected} tracks are in your library`;
}
return `Request ${kind}${name}`;
}
switch (this.status) {
@@ -11,8 +11,16 @@ import '../library-status-indicator/library-status-indicator.js';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import { creditLink, exploreLinkStyles } from '../../utils/explore-link';
import { creditStore } from '@store/credit-store';
import { libraryStatusFor } from '../../utils/library-status';
import { albumBadgeFor, libraryStatusFor } from '../../utils/library-status';
import {
isOwned,
ownershipLabel,
unownedStyles,
type OwnableKind,
} from '../../utils/ownership';
import { completenessStore } from '../../store/completeness-store';
import { downloadStore } from '../../store/download-store';
import { classMap } from 'lit/directives/class-map.js';
/** Format milliseconds as m:ss. */
function formatDuration(ms: number | undefined): string {
@@ -65,6 +73,9 @@ export class TopResultsRow extends LitElement {
/** Unsubscribes the credit-arrival repaint. */
private creditsUnsub?: () => void;
/** Unsubscribes the "how much of this album is here" repaint. */
private unsubCompleteness?: () => void;
override connectedCallback(): void {
super.connectedCallback();
@@ -74,6 +85,9 @@ export class TopResultsRow extends LitElement {
this.unsubRequests = downloadStore.subscribe(() =>
this.requestUpdate(),
);
this.unsubCompleteness = completenessStore.subscribe(() =>
this.requestUpdate(),
);
}
override disconnectedCallback(): void {
@@ -81,12 +95,15 @@ export class TopResultsRow extends LitElement {
this.creditsUnsub = undefined;
this.unsubRequests?.();
this.unsubRequests = undefined;
this.unsubCompleteness?.();
this.unsubCompleteness = undefined;
super.disconnectedCallback();
}
static override styles = [
designTokens,
exploreLinkStyles,
unownedStyles,
css`
:host {
display: block;
@@ -289,23 +306,41 @@ export class TopResultsRow extends LitElement {
? r.year || ''
: formatDuration(r.length) || '';
const status: LibraryStatus = libraryStatusFor(
Boolean(r.inLibrary),
r.mbid,
);
const entityType: 'artist' | 'album' | 'track' =
const entityType: OwnableKind =
r.entityType === 'artist'
? 'artist'
: r.entityType === 'release_group'
? 'album'
: 'track';
// Ownership is the local row, not the catalog's flag — see
// `utils/ownership.ts`. An album additionally says *how much*
// of it is here, which is the one thing a tick cannot.
const owned = isOwned(r);
const badge =
entityType === 'album'
? albumBadgeFor(r, r.mbid)
: {
status: libraryStatusFor(owned, r.mbid) as LibraryStatus,
owned: 0,
expected: 0,
};
// A card navigates whether or not the entity is owned, so it is
// not `aria-disabled` the way an unplayable track row is — the
// name is what carries the state to anyone not seeing the
// dimming.
return html`
<div
class="card"
class=${classMap({ card: true, unowned: !owned })}
role="button"
tabindex="0"
aria-label=${`${badgeLabel(r.entityType)}: ${r.name}`}
aria-label=${ownershipLabel(
owned,
`${badgeLabel(r.entityType)}:`,
r.name,
entityType,
)}
@click=${() => this.handleClick(r)}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
@@ -345,10 +380,12 @@ export class TopResultsRow extends LitElement {
: nothing}
</div>
</div>
${isArtist
${isArtist || badge.status === 'in-library'
? nothing
: html`<library-status-indicator
status=${status}
status=${badge.status}
owned=${badge.owned}
expected=${badge.expected}
entity-type=${entityType}
label=${r.name}
request-mbid=${r.mbid}
+208
View File
@@ -0,0 +1,208 @@
/**
* How much of an album is here, keyed by local album id.
*
* The album page has always been able to say "9 of 12"; a card could
* not, so an album held two tracks of ten wore a plain green tick on
* every grid in the app — which is the complaint the badge-accuracy
* issue was filed about, one surface over. A ring with a count needs a
* numerator and a denominator per album, and one `GetAlbumCompleteness`
* per card is fifty round trips for a grid of fifty.
*
* So this is `credit-store` one question over, and for the same three
* reasons.
*
* **The lookup is batched and coalesced across callers.** `request()`
* is per-card, cheap and safe to call from a render: it collects into a
* pending set and flushes once on the next frame, which turns a
* screenful into exactly one `GetAlbumsCompleteness`. A frame rather
* than a microtask because the point is to collect every card an update
* pass renders, and those do not land inside one microtask checkpoint.
*
* **Absence is cached as an answer.** An album with no files at all is
* omitted by the backend deliberately — "I have none of this" and "I
* have no idea" are the third state `known` exists to keep apart — so a
* miss stored as a hit would re-ask about it forever. What is cached is
* *asked*, and the unknown answer is a first-class value.
*
* **It is invalidated rather than aged.** Completeness is a fact about
* files on disk, and the two events that change it — a scan finishing
* and tags being rewritten — are exactly the ones `library-store`
* already discards everything on. `TracksRemovedFromLibrary` is the
* third: it changes a numerator without a rescan.
*/
import { GetAlbumsCompleteness } from '@go/library/library.js';
import type * as library from '@go/library/models.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../events';
import { LRUMap } from '../utils/lru-map';
import { compact } from '../utils/binding';
import { registerCacheProbe } from '../utils/cache-stats';
/**
* Entries retained. Four small numbers each, so the bound is about
* unbounded growth rather than bytes — and it is set well above any one
* grid, since a cap below the visible count evicts cards that are still
* on screen and the re-render fetches them straight back.
*/
export const COMPLETENESS_CACHE_LIMIT = 20_000;
/** What the app asks of a cached entry. */
export type Completeness = library.AlbumCompleteness;
/**
* The answer for an album nobody has a file of.
*
* Not a zero-of-zero: `known` false is what every consumer already
* reads as "say nothing", and it is the same value the album page's
* `completenessAnswer()` treats as unknowable.
*/
const NOTHING_HERE: Completeness = {
owned: 0,
expected: 0,
known: false,
complete: false,
} as Completeness;
class CompletenessStore {
private cache = new LRUMap<number, Completeness>(COMPLETENESS_CACHE_LIMIT);
/** Ids with a request in flight, so a re-render does not refetch. */
private inFlight = new Set<number>();
private listeners = new Set<() => void>();
/** Collected by request(), flushed as one batch on the next frame. */
private pending = new Set<number>();
private flushHandle: number | null = null;
constructor() {
registerCacheProbe('albumCompleteness', () => ({
entries: this.cache.size,
chars: this.cache.size * 4,
limit: COMPLETENESS_CACHE_LIMIT,
}));
EventsOn(Events.LibraryScanComplete, () => this.invalidate());
EventsOn(Events.TrackMetadataChanged, () => this.invalidate());
EventsOn(Events.TracksRemovedFromLibrary, () => this.invalidate());
}
/**
* Subscribe to "some answers arrived".
*
* Deliberately not per-album, for `credit-store`'s reason: a grid
* fetches its cards in one call and re-renders once, so a
* fine-grained signal would buy nothing and cost a listener a card.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
/** The answer for one album, or undefined until it has been asked. */
get(albumID: number | undefined | null): Completeness | undefined {
if (!albumID || albumID <= 0) return undefined;
return this.cache.get(albumID);
}
/**
* Ask about one album, joining whatever batch is forming.
*
* Safe from inside a render: a set insert and a scheduled flush,
* with anything cached or in flight dropped. It does not loop —
* after a flush every id asked for is cached, so the re-render's
* requests are all dropped and nothing notifies again.
*/
request(albumID: number | undefined | null): void {
if (!albumID || albumID <= 0) return;
if (this.cache.has(albumID)) return;
if (this.inFlight.has(albumID)) return;
if (this.pending.has(albumID)) return;
this.pending.add(albumID);
if (this.flushHandle !== null) return;
this.flushHandle = requestAnimationFrame(() => {
this.flushHandle = null;
const batch = [...this.pending];
this.pending.clear();
void this.ensure(batch);
});
}
/**
* Ask and read in one call, for use inside a template.
*
* A getter with a side effect, deliberately — `credit-store` makes
* the same trade and for the same reason: the alternative is every
* call site writing `request(x)` beside `get(x)` and one of them
* eventually forgetting, which renders a permanently unknown
* completeness that looks exactly like an album with no totals.
*/
completeness(albumID: number | undefined | null): Completeness | undefined {
this.request(albumID);
return this.get(albumID);
}
/** Fetch for a list, skipping anything known or already in flight. */
async ensure(albumIDs: readonly number[]): Promise<void> {
const wanted = new Set<number>();
for (const id of albumIDs) {
if (!id || id <= 0) continue;
// `has` rather than `get`: probing must not mark an entry
// recently-used, or scrolling past a card would keep it
// alive ahead of one actually being rendered.
if (this.cache.has(id)) continue;
if (this.inFlight.has(id)) continue;
wanted.add(id);
}
if (wanted.size === 0) return;
const batch = [...wanted];
for (const id of batch) this.inFlight.add(id);
try {
const found = compact(await GetAlbumsCompleteness(batch));
for (const id of batch) {
this.cache.set(id, found[String(id)] ?? NOTHING_HERE);
}
this.notify();
} catch (err) {
// A count is an enrichment: without it a card shows the
// plain "you have this", which is what it showed before and
// is a weaker answer rather than a broken one.
console.error('Failed to load album completeness', err);
} finally {
for (const id of batch) this.inFlight.delete(id);
}
}
/** Drop everything: the files on disk changed. */
invalidate(): void {
this.cache = new LRUMap<number, Completeness>(
COMPLETENESS_CACHE_LIMIT,
);
this.notify();
}
private notify(): void {
for (const fn of this.listeners) fn();
}
}
export const completenessStore = new CompletenessStore();
+57
View File
@@ -1,7 +1,9 @@
import { completenessStore } from '@store/completeness-store';
import { downloadStore } from '@store/download-store';
import { libraryStore } from '@store/library-store';
import type * as download from '@go/download/models.js';
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
import { isOwned, type Ownable } from './ownership';
/**
* What the tick/hourglass/plus badge should say about one entity.
@@ -46,6 +48,61 @@ export function libraryStatusFor(
return 'not-in-library';
}
/**
* Everything a badge needs about one entity, decided in one place.
*
* `status` is the state; `owned`/`expected` are the counts behind
* `partial` and are zero for every other state, which is what the badge
* requires — it documents that a caller with no total must not pass a
* ring at 0%.
*/
export interface BadgeState {
status: LibraryStatus;
owned: number;
expected: number;
}
/**
* What the badge on an album card should say.
*
* Three rules, and the middle one is the whole point of this issue.
*
* **Ownership is the local album id**, per `utils/ownership.ts` — a
* file, not the catalog's `inLibrary` ratchet.
*
* **A partly-held album says how partly.** The count comes from
* `completenessStore`, which batches a screenful into one query;
* reading it is what asks for it. Before this, an album held 2 tracks
* of 10 wore the same green tick as one held whole on every grid in
* the app.
*
* **A total that was never declared is not a total of zero.** Where
* `known` is false — most of an untagged library, and every album until
* a rescan repopulates `audio_files.total_tracks` — this is a plain
* `in-library` and says nothing, which is the rule the badge's own
* documentation states and the reason `Known` exists at all.
*/
export function albumBadgeFor(
album: Ownable | null | undefined,
mbid?: string | null,
): BadgeState {
if (!isOwned(album)) {
return { status: libraryStatusFor(false, mbid), owned: 0, expected: 0 };
}
const held = completenessStore.completeness(album?.localId);
if (held?.known && !held.complete) {
return {
status: 'partial',
owned: held.owned,
expected: held.expected,
};
}
return { status: 'in-library', owned: 0, expected: 0 };
}
/** What a badge can ask for. Artists are deliberately absent: a
* discography subscription is `explore-artist-details`'s Follow
* button, which can say what it is committing to. */
+134
View File
@@ -0,0 +1,134 @@
/**
* What "I do not own this" looks like, and how the app decides it.
*
* The rule the user asked for, in their words: *owned content is the
* default, normal, unadorned presentation; unowned content is what gets
* marked*. `explore-album-details` implemented it for one tracklist —
* dimmed in place, `aria-disabled` because dimming is a colour and
* cannot be the only signal, and nothing at all drawn on the owned rows
* — and every other catalog surface still mixed the two with a small
* badge as the only difference. This is that rule, written once, so
* eight surfaces cannot each keep their own version of it.
*
* ## Ownership is a file, and `localId` is the flag that says so
*
* The album page answers "do I own this row" with `filePaths`, a map
* from a displayed track to a real file. A card grid cannot afford a
* lookup per card — and does not need one, because the answer is
* already on every model.
*
* `explore_index.local_artist_id` / `local_release_group_id` /
* `local_recording_id` are built by `collectLibraryEntities` from
* queries that every one join `audio_files`, and cleared by
* `pruneStaleLocalCrossReferences` whose existence test is a file test
* in all three cases. That is the same "ownership is a file" rule,
* computed once per scan instead of once per screenful.
*
* **`inLibrary` is the weaker one and is deliberately not consulted.**
* It is written by the same pass, so today the two agree — but it is a
* one-way ratchet (`in_library = MAX(in_library, excluded.in_library)`)
* whose only clearing pass is gated on a non-null `local_*_id`, so it
* cannot be un-set on its own. One of the two is a fact with an owner;
* the other is a flag that happens to agree with it.
*
* The divergence was observable before this: both `explore-view` and
* `explore-artist-details` kept a `libraryMBIDs` set that accumulated
* every MBID ever seen with `inLibrary` and cleared it never, in a view
* that never unmounts. And on one artist-detail card the two answers
* were used side by side — the context menu gated Play on
* `localId > 0` while the badge said "in your library" from
* `inLibrary`, so a card could claim to be owned, offer no Play, and
* (the request item being gated on *not* owned) offer no way to ask for
* it either.
*/
import { css } from 'lit';
/** Anything a card or row can be drawn from, as far as this is concerned. */
export interface Ownable {
/** The local row id behind this entity: an album, a file, an artist. */
localId?: number | null;
}
/**
* Whether there is something of the user's behind this entity.
*
* Deliberately narrow: a local id and nothing else. Passing the model
* straight in is the point — a call site that has to remember which of
* two fields to read is a call site that will eventually read the other
* one, which is exactly how the two answers came to sit on one card.
*/
export function isOwned(entity: Ownable | null | undefined): boolean {
return (entity?.localId ?? 0) > 0;
}
/** The kinds of thing a catalog surface can draw. */
export type OwnableKind = 'album' | 'track' | 'artist';
/**
* The sentence an unowned thing says, once.
*
* It reaches whoever is not seeing the dimming, so it has to name the
* thing as well as the state — "not in your library" alone, repeated
* down a grid, identifies nothing. The em dash matches the album
* tracklist's existing phrasing, which is where this came from.
*/
export function unownedLabel(name: string, kind: OwnableKind): string {
return `${name} — not in your library, ${
kind === 'artist' ? 'browsing the catalog' : 'available to request'
}`;
}
/**
* The accessible name for a card or row, owned or not.
*
* `activates` is what the thing does when it is yours: "Play", "Album",
* whatever the surface's own verb is. An unowned one does not get that
* verb, because it cannot do it.
*/
export function ownershipLabel(
owned: boolean,
activates: string,
name: string,
kind: OwnableKind,
): string {
return owned ? `${activates} ${name}` : unownedLabel(name, kind);
}
/**
* The dimming, shared so it cannot drift across surfaces.
*
* Two things about it are load-bearing.
*
* **The text dims to a token, not with `opacity`.** `theme-store`'s
* ramps are checked by `theme-contrast.test.ts` against every surface
* text can sit on; an opacity multiplier is outside that check and
* would quietly drop a dimmed title under 4.5:1 on the light ramps.
* Secondary rather than tertiary for the reason the album tracklist
* gives: these rows and cards have a `bgOverlay` hover background,
* which tertiary does not clear.
*
* **Only the artwork takes an `opacity`.** A cover is not text, so it
* is outside the contrast rule entirely, and it is the part of a card
* that carries the most weight — dimming it is what makes a grid read
* as catalog at a glance rather than needing the badge to be found.
*/
export const unownedStyles = css`
.unowned .album-title,
.unowned .track-title,
.unowned .card-name,
.unowned .top-release-title,
.unowned .artist-name {
color: var(--yj-text-secondary, #b3b3b3);
font-weight: 400;
}
.unowned .album-art-container,
.unowned .top-release-art,
.unowned .track-art,
.unowned .card-image,
.unowned .card-image-placeholder,
.unowned .artist-avatar {
opacity: 0.55;
}
`;
@@ -77,3 +77,30 @@ describe('the library status badge', () => {
expect(name).toContain('Glass Harbour');
});
});
/**
* A partly-held album is the one state that is *actionable and
* counted*: there are tracks left to ask for, so the badge is a button
* — and a control is named after what activating it does, which is how
* the count came to be dropped from exactly the state the ring exists
* for. Both, or the ring says "some" to an eye and nothing to anyone
* else.
*/
describe('a partial badge that can act', () => {
it('names the action and keeps the count', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 9,
expected: 12,
entityType: 'album',
label: 'Glass Harbour',
requestMbid: 'rg-1',
});
const name = shadow(el, '.badge')?.getAttribute('aria-label') ?? '';
expect(shadow(el, 'button.badge')).not.toBeNull();
expect(name).toContain('Request the rest of');
expect(name).toContain('9 of 12');
});
});
@@ -0,0 +1,363 @@
/**
* Owned is plain; unowned is what gets marked.
*
* `explore-album-details` had this right for one tracklist and nothing
* else did: Explore's cards, the top-results row and the artist page's
* three card shapes all mixed owned and unowned with a small badge as
* the only difference — and drew a green tick on the *common* case,
* which is the treatment the album page's own green ticks were removed
* for.
*
* What is pinned here is the rule rather than any one surface, because
* the fault this replaced was eight call sites each holding their own
* version of it:
*
* - an owned thing draws **no badge at all**;
* - an unowned one is dimmed *and* says so in its accessible name,
* because dimming is a colour and cannot be the only signal;
* - ownership is a **file** (`localId`), never the catalog's
* `inLibrary` ratchet, which is a flag that happens to agree;
* - and a partly-held album says *how* partly, which is the one thing
* a tick cannot.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { page } from 'vitest/browser';
import '@components/explore-view/explore-view';
import '@components/top-results-row/top-results-row';
import { flush, stub, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { completenessStore } from '@store/completeness-store';
const SEARCH = 'explore.Service.SearchLocal';
const SHELVES = 'explore.Service.GetExploreShelves';
const COMPLETENESS = 'library.Library.GetAlbumsCompleteness';
/** A release group as the backend projects one. */
function album(
title: string,
{ localId = 0, inLibrary = false }: { localId?: number; inLibrary?: boolean },
) {
return {
mbid: `rg-${title}`,
title,
artistCredit: 'An Artist',
artistMbid: 'ar-1',
primaryType: 'Album',
firstReleaseDate: '1994-05-01',
popularity: 100,
listenerCount: 10,
secondaryTypes: [],
inLibrary,
localId,
};
}
/** A recording as the backend projects one. */
function recording(
title: string,
{ localId = 0, inLibrary = false }: { localId?: number; inLibrary?: boolean },
) {
return {
mbid: `rec-${title}`,
title,
artistCredit: 'An Artist',
artistMbid: 'ar-1',
length: 200000,
popularity: 0,
listenerCount: 0,
inLibrary,
localId,
};
}
/** Mount Explore showing one page of results. */
async function exploreShowing(results: {
releaseGroups?: unknown[];
recordings?: unknown[];
artists?: unknown[];
}) {
stub(SHELVES, { shelves: [], state: 'ready' });
stub(SEARCH, {
artists: [],
releaseGroups: [],
recordings: [],
...results,
});
const el = await fixture('explore-view');
// A cached primary view only fetches on arrival, and the search is
// what these cards come from.
(el as unknown as { onViewActivate: () => void }).onViewActivate?.();
await update(el, { results: { artists: [], releaseGroups: [], recordings: [], ...results } });
await flush();
await el.updateComplete;
return el;
}
beforeEach(() => {
resetHarness();
stub(COMPLETENESS, {});
// The store is a singleton and caches an *answer*, including the
// absent one — which is the point, or 87% of a grid re-asks forever.
// Two tests in one file are two sessions as far as it is concerned,
// so a stale entry from the test above would otherwise decide the
// one below. Found by writing the assertion the wrong way round.
completenessStore.invalidate();
});
describe('an owned thing is plain', () => {
it('draws no badge on an album card it has files for', async () => {
const el = await exploreShowing({
releaseGroups: [album('Held', { localId: 7 })],
});
expect(shadowAll(el, '.album-card')).toHaveLength(1);
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
});
it('draws no badge on a track row it has a file for', async () => {
const el = await exploreShowing({
recordings: [recording('Held', { localId: 9 })],
});
expect(shadowAll(el, '.track-item')).toHaveLength(1);
expect(shadow(el, '.track-item library-status-indicator')).toBeNull();
});
it('does not dim it', async () => {
const el = await exploreShowing({
releaseGroups: [album('Held', { localId: 7 })],
});
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(
false,
);
});
});
describe('an unowned thing is marked', () => {
it('dims the card and keeps its request badge', async () => {
const el = await exploreShowing({
releaseGroups: [album('Absent', {})],
});
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
});
/**
* The name is the half of this that reaches anyone not seeing the
* dimming, so it has to be the browser's own answer — a shadow-root
* query cannot compute a name, and this repo has shipped a nameless
* control three times.
*/
it('says so in the name the browser computes', async () => {
await exploreShowing({ releaseGroups: [album('Absent', {})] });
await expect
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
.toBeInTheDocument();
});
/**
* A track row is `aria-disabled` and a card is not, and the
* difference is not cosmetic: activating an unowned row does nothing
* (`onRecordingRowDblClick` returns early), while a card navigates to
* the catalog page for it, which is a perfectly good thing to do with
* something you do not own.
*/
it('marks a row that cannot be played as disabled', async () => {
const el = await exploreShowing({
recordings: [recording('Absent', {})],
});
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
'true',
);
});
it('leaves a card that still navigates enabled', async () => {
const el = await exploreShowing({
releaseGroups: [album('Absent', {})],
});
expect(shadow(el, '.album-card')?.getAttribute('aria-disabled')).toBeNull();
});
});
/**
* The decision this issue turned on.
*
* `inLibrary` is written by the same pass that writes the local ids, so
* the two agree in a healthy database — but it is a one-way ratchet
* (`MAX(in_library, excluded.in_library)`) whose only clearing pass is
* gated on a non-null `local_*_id`, so it cannot be un-set on its own.
* A row carrying it with no local id behind it is a claim of ownership
* with no file, which is exactly what the album page refuses to trust.
*/
describe('ownership is a file, not a flag', () => {
it('treats a card flagged inLibrary with no local row as unowned', async () => {
const el = await exploreShowing({
releaseGroups: [album('Phantom', { inLibrary: true, localId: 0 })],
});
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
});
it('does the same for a track row', async () => {
const el = await exploreShowing({
recordings: [recording('Phantom', { inLibrary: true, localId: 0 })],
});
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
'true',
);
});
});
/**
* The count, which is what `#16`'s deferred third step asked for: an
* album held 2 tracks of 10 wore the same green tick as one held whole,
* on every grid in the app.
*/
describe('a partly-held album says how partly', () => {
it('draws the ring and puts the count in the badge name', async () => {
stub(COMPLETENESS, {
'7': { owned: 9, expected: 12, known: true, complete: false },
});
const el = await exploreShowing({
releaseGroups: [album('Partly', { localId: 7 })],
});
// The store batches into the next frame, so the answer lands one
// repaint after the cards do — which is the thing the subscription
// exists for.
await new Promise((r) => requestAnimationFrame(() => r(null)));
await flush();
await el.updateComplete;
const badge = shadow(el, '.album-card library-status-indicator');
expect(badge?.getAttribute('status')).toBe('partial');
// A partly-held album is *actionable* — it has three tracks left to
// ask for — so the badge is a button, and the name has to carry the
// action and the count. Naming it after the action alone left the
// one state the ring exists for as the one state whose name did not
// mention it.
await expect
.element(
page.getByRole('button', {
name: /Request the rest of album .*Partly.* — 9 of 12 tracks/,
}),
)
.toBeInTheDocument();
});
/**
* Where the tags never declared a total, `known` is false and the
* card must say nothing — most of an untagged library is in that
* state, and a ring drawn from its absence would mark all of it
* incomplete on no evidence. That is the rule `Known` exists for.
*/
it('says nothing when the total was never declared', async () => {
stub(COMPLETENESS, {
'7': { owned: 3, expected: 0, known: false, complete: false },
});
const el = await exploreShowing({
releaseGroups: [album('Untotalled', { localId: 7 })],
});
await new Promise((r) => requestAnimationFrame(() => r(null)));
await flush();
await el.updateComplete;
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
});
it('asks about the owned albums only, in one call', async () => {
const seen: unknown[][] = [];
stub(COMPLETENESS, (...args: unknown[]) => {
seen.push(args);
return {};
});
await exploreShowing({
releaseGroups: [
album('Held', { localId: 7 }),
album('Also held', { localId: 8 }),
album('Absent', {}),
album('Phantom', { inLibrary: true, localId: 0 }),
],
});
await new Promise((r) => requestAnimationFrame(() => r(null)));
await flush();
expect(seen).toHaveLength(1);
expect(seen[0]?.[0]).toEqual([7, 8]);
});
});
describe('the top-results row follows the same rule', () => {
const result = (
name: string,
entityType: string,
extra: Record<string, unknown> = {},
) => ({
entityType,
mbid: `top-${name}`,
name,
artistCredit: 'An Artist',
intentScore: 1,
inLibrary: false,
...extra,
});
it('draws no badge on something it owns', async () => {
const el = await fixture('top-results-row', {
results: [result('Held', 'release_group', { localId: 7 })],
query: 'held',
});
expect(shadow(el, '.card library-status-indicator')).toBeNull();
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(false);
});
it('dims and names something it does not', async () => {
const el = await fixture('top-results-row', {
results: [result('Absent', 'release_group')],
query: 'absent',
});
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(true);
await expect
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
.toBeInTheDocument();
});
/**
* An artist card has never had a badge — a discography subscription
* is the artist page's Follow button, which can say what it commits
* to — so the dimming and the name are the whole signal there.
*/
it('marks an unowned artist without offering a request', async () => {
const el = await fixture('top-results-row', {
results: [result('An Artist', 'artist')],
query: 'an artist',
});
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(true);
expect(shadow(el, '.card library-status-indicator')).toBeNull();
});
});
+7 -2
View File
@@ -1,10 +1,15 @@
# YellowJacket Arch package
This directory holds the `PKGBUILD` and desktop entry used to build the
Arch Linux package. CI builds it on every push to `main` (see
Arch Linux package. CI builds it on every **release tag** (see
`.gitea/workflows/arch-package.yml`) and publishes it to the Gitea Arch
package registry, from which pacman can install it directly.
Tags come from `.gitea/workflows/release.yml`, which is run by hand — it
used to fire on every push to `main`, which meant a new package per
merged PR (issue #115). A prerelease tag (`v0.4.0-beta.1`) is skipped:
the workflow's trigger is `v*` and matches one.
## Installing from the registry
The registry is public — no login required.
@@ -58,7 +63,7 @@ this is not recommended.
- Only the runtime package is published; the `-debug` package makepkg
produces (detached symbols) is skipped by the workflow.
- Package versions come from `pkgver()` in the PKGBUILD, derived from git
(e.g. `1.3.0.r173.g4ae5ffc-1`), so every push to `main` yields a new
(e.g. `1.3.0.r173.g4ae5ffc-1`), so every release tag yields a new
version.
- Repo priority: if another configured repo ever provides a package named
`yellowjacket`, the repo listed **first** in `pacman.conf` wins. Force a