Compare commits

..
Author SHA1 Message Date
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
logan 89882b4863 refactor(ui): give the icons one vocabulary and sweep the call sites
CI / check (pull_request) Successful in 2m27s
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / e2e (pull_request) Successful in 6m42s
`plus` meant "add to the queue", "add to a playlist", "make a new
playlist" and "you do not own this" -- the first two adjacent in the
same context menu, so two neighbouring items were the same glyph doing
different things. `list` meant the queue (the button that opens it), the
Playlists destination, and adding to the queue in `queue-panel` alone.
Two icons carrying seven meanings is not a vocabulary, and nothing
catches it: a wrong-but-real icon renders perfectly.

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

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

Two things this found rather than changed:

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

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

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

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

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

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

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

Three things about it are load-bearing:

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

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

Closes #7
2026-08-18 20:49:08 -04:00
logan a4777f26b6 Merge pull request 'Declare the track and disc totals when tagging' (#105) from fix/16-tagwriter-totals into main
Release / release (push) Successful in 30s
CI / e2e (push) Successful in 6m8s
CI / check (push) Successful in 2m23s
Build & publish the Android APK / apk (push) Successful in 1m25s
Build & publish Arch package / arch-package (push) Successful in 2m31s
Attach the desktop build to the release / linux (push) Successful in 54s
Sync Homebrew formula / sync-formula (push) Successful in 6s
Closes #16
2026-08-19 00:45:07 +00:00
logan 92faa9741b Merge branch 'main' into fix/16-tagwriter-totals
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m22s
CI / e2e (pull_request) Successful in 6m8s
2026-08-18 20:32:08 -04:00
logan 4b9114fd8d fix(tagwriter): declare the track and disc totals when tagging
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 6m18s
An album the user holds 2 of 10 tracks of showed a green tick reading
"is in your library", and the mechanism was our own writer. tagwriter
wrote track and disc *numbers* and dropped the totals, so autotagging a
folder made the release MBID-matched -- which is what earns the tick --
while erasing the one field GetAlbumCompleteness reads. The evidence
for "2 of 10" was destroyed by the act that produced the tick.

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

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

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

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

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

Closes #16
2026-08-18 18:19:54 -04:00
50 changed files with 1879 additions and 121 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"
+51
View File
@@ -3483,3 +3483,54 @@ public tap.
A guard added today does not protect a tag that points at yesterday. When
re-pointing a tag, check what the workflows looked like *there*.
## A tag reader looks at exactly one spelling of "total" (measured 2026-08-18)
Writing #16's totals means matching the reader, which is
`dhowden/tag`, and it is narrower than the specs are:
- **Vorbis (FLAC, OGG): `TRACKTOTAL` and `DISCTOTAL` only.**
`vorbis.go`'s `Track()` reads `tracknumber` and `tracktotal` and
nothing else, so `TOTALTRACKS` — which several taggers write and
which xiph lists — and a `1/12` packed into `TRACKNUMBER` both read
back as *no total*. They write successfully. Nothing errors.
- **ID3v2 (MP3): `TRCK`/`TPOS` as `n/N`**, via `parseXofN`. That is one
frame carrying two facts, which is why `applyPositionFrame` reads the
existing frame before writing either half.
- **WAV: nothing at all.** There is no RIFF reader in the module, so a
WAV's `id3 ` chunk is invisible to `metadata.ExtractTags` — every
field, not just the totals. Filed as #104.
The general shape, and the reason this is written down: a tag written
under a name the reader does not look at is indistinguishable from one
never written. So the tests assert the round trip through
`metadata.ExtractTags` — the reader the *scan* uses — rather than
through the bytes the writer produced.
## The published catalog artifact predates `total_tracks` (measured 2026-08-18)
```
$ curl -sSI .../generic/yellowjacket-core-index/latest/core-index.db.zst
last-modified: Mon, 10 Aug 2026 04:38:16 GMT
content-length: 75417037
$ sqlite3 core-index.db \
"SELECT COUNT(*) FROM pragma_table_info('explore_index') WHERE name='total_tracks';"
0
$ sqlite3 core-index.db "SELECT COUNT(*) FROM explore_index;"
1079667
```
The column landed in the schema on 2026-08-16; the artifact is from
08-10, and `index-artifact.yml` is a weekly cron, not a push trigger.
So `completenessAnswer()`'s catalog fallback answers 0 for **every**
user today — the machinery is correct and `artifactHasTotals()` is
doing precisely its job, there is just no data behind it. Same position
the credit tables are in; both ride on the next publish (#88).
The general point, which is why this is written down rather than just
fixed: **a probe that makes a column optional also makes its absence
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.
+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
+165 -26
View File
@@ -1448,6 +1448,52 @@ is therefore **reported at runtime** to `window.__yjIconMisses` and
drawn as a fallback — an e2e sweep asserts there are none — since a
missing icon used to be impossible, the CDN having had everything.
**What each icon *means* is a second table, and it is
`utils/icon-language.ts`.** Bundling answers "does this name resolve";
nothing answered "does this name mean what the one next to it means",
and a wrong-but-real icon renders perfectly. So `plus` came to mean add
to the queue, add to a playlist, make a new playlist **and** you do not
own this — the first two *adjacent in the same context menu* — while
`list` meant the queue, the Playlists destination and adding to the
queue.
The rule the table is built on: **an icon names the noun it acts on,
not the verb.** "Add to queue" and "add to playlist" are one verb on
two nouns, so the noun is what has to differ — which is why adding to a
playlist wears the Playlists destination's own icon, and why the queue
got `bars-staggered` and stopped wearing Playlists'. `plus` keeps the
one meaning it is unambiguous about, making something that is not there
yet.
Four things about it are load-bearing:
- **The request toggle is one glyph in two weights**
(`regular/bookmark``solid/bookmark`), because two states of a
toggle have to read as each other's opposite and a plus against a
bookmark does not. The pair was *already in the app and already
right* on `explore-album-details`'s "Request this" button while the
badge forty pixels away showed a plus — `utils/library-status.ts`'s
fault one layer down, having made the two agree on what wanting means
and left them disagreeing on what it looks like.
- **Downloads keeps the solid bookmark, deliberately.** That is the
same word twice, not two words: the badge says "this is on your
list" and the nav item is that list.
- **`icon-language.test.ts` sweeps the source**, because the rule is
about every call site and checking one checks nothing — the same
shape as `TestNoDirectRuntimeEmits`. It reads every `src/**/*.ts` as
raw text and fails on a literal `name="plus"` or `icon: 'list'`
outside the table, and its **first assertion is that it read
anything at all**, since a sweep over an empty glob passes.
- **It also asserts every `ICON_*` is bundled**, which closes the loop
the runtime cannot: `bookmark-check` is Font Awesome **Pro** and sat
on `explore-artist-details`'s Follow button, drawn for every followed
artist as a circled question mark. `offline-icons.spec.ts` sweeps
`__yjIconMisses` and could not see it, because no spec had ever
followed an artist — the same fault `requested-badge.spec.ts` was
written for, one component over, still live. A name computed from
state was only checkable from the state; now it is checkable from the
table.
**An album page says how much of the album is yours.**
`explore-album-details` is a *catalog* page and there is no
library-side album detail page at all, so the album on it may be
@@ -1547,11 +1593,55 @@ shape as the encoding probe beside it.
What neither side can give is *which* tracks are missing, only how many
— so an incomplete album still browses, and that is now the exception
rather than every album load. Two smaller consequences: existing databases
read "unknown" until a rescan repopulates the column (which degrades to
exactly the old behaviour, so nothing breaks), and our own `tagwriter`
writes track and disc *numbers* but not totals, so autotagging a folder
currently degrades the field this rests on.
rather than every album load. One smaller consequence: existing databases
read "unknown" until a rescan repopulates the column, which degrades to
exactly the old behaviour, so nothing breaks.
**And our own writers declare the total, because for a long time they
did not.** `tagwriter` wrote track and disc *numbers* and dropped the
totals, so autotagging an album actively **erased** the evidence this
rests on: the release became MBID-matched — a green tick — while the
field `GetAlbumCompleteness` reads stayed absent, which is exactly the
"2 of 10 tracks, reported as in your library" the report described.
`FieldTotalTracks` / `FieldTotalDiscs` are written by the autotag apply
pass and by the download importer, and `dbsync` persists the track
total to the row so the album page agrees with the file without waiting
for a rescan.
Five things about it are load-bearing, and four of them fail silently:
- **The total is per *disc*, not per release**, because that is what
the tag form declares and what `GetAlbumCompleteness` **sums** per
disc — a release total written on every file multiplies a two-disc
album's expectation by two, and no library can then satisfy it.
`backend/tagtotals` is that derivation, once, because the two callers
must not import each other or the writer.
- **The Vorbis names are `TRACKTOTAL` and `DISCTOTAL` and no other
spelling.** `dhowden/tag`'s Vorbis reader looks at exactly those two
keys, so a perfectly reasonable `TOTALTRACKS`, or a `1/12` inside
`TRACKNUMBER`, is written successfully and reads back as no total at
all. The tests assert the round trip through the reader the *scan*
uses rather than through the bytes, for that reason.
- **ID3's number and total share one frame**, so writing either alone
has to read the other off the existing tag or it silently discards
it. A total with no number is not written: `/12` is what a reader
parses as track 0.
- **The totals are written unconditionally, not on a diff.** The case
this exists for is a file that declares *no* total, which compares
equal to nothing and is exactly what a "only if it changed" guard
skips.
- **A single-track download must not be totalled.** A `RecordingMBID`
anchor resolves `Expected` to that one track, so the same code would
tag a track off a twelve-track album "1 of 1" — and a declared total
outranks the catalog total that would otherwise have answered
correctly. Confidently wrong is worse than absent here, which is the
same rule `Known` exists for.
One gap this did not close, and it is older: **`dhowden/tag` has no
RIFF reader**, so nothing the tag writer puts in a WAV's `id3 ` chunk
is visible to `metadata.ExtractTags` — not the totals and not the title
either. `wav_test.go` reads that chunk itself, which is why no test
ever noticed.
**The absence is what gets marked, not the presence.** The tracklist
put a green tick against every owned track and a legend underneath
@@ -1579,6 +1669,32 @@ side-effect worth knowing: this is what finally makes `ownership()`
say something true here, since counting the displayed tracklist of a
library-only entry could only ever produce "9 of 9".
**And it can be asked, because the rule alone reaches too few albums.**
That guard depends on two inputs the user does not control: the files
declaring a per-disc total, and the catalog's own `total_tracks`. Where
neither says — which is a great deal of any library, and *every* library
until an artifact carrying the column is published — a partly-owned
album showed only the tracks on disk with nothing to say the rest
existed. `renderTracklistScope()` is the explicit route: a
"Show the whole album" switch that flips the synthetic "Your Library"
entry between the local files and the release, which is the rendering
the page could already do and could only be *triggered* automatically.
Three things about it are load-bearing. **`showFullTracklist` is a
tri-state**, `null` meaning "follow the automatic rule": the rule is
right when it fires and the switch has to be able to agree with the page
it sits on rather than starting out contradicting it, which a plain
boolean would need recomputed every time the completeness answer moved
underneath it. **`fullReleaseCluster()` falls back to the
highest-scoring cluster**, because `findLibraryCluster` is a guess over
the `inLibrary` flags and returns *nothing* when none are set — which is
exactly the untagged library the switch exists for, so without the
fallback the control would be absent precisely where it is needed. And
**it is shown only where it can change what is on screen**: against the
library entry, with a release to switch to, and only when the two
tracklists differ — the same test the version dropdown answers, one
control over.
**A dropdown is only a choice if the choices differ.** The version
selector tested `versionEntries.length`, but a release group routinely
has several releases — reissues, regional pressings, a remaster — whose
@@ -2138,23 +2254,6 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
mistyped `feat` ships a minor version. `make release-dry` answers "what
would this merge release" without pushing.
**The analyzer reads the type and ignores the scope, so a CI-only change
is `ci:` and never `fix(ci):`.** The scope is decoration; `fix` is a
patch whatever is in the brackets. Two commits touching nothing but
`.gitea/workflows/unclaim.yml` were written `fix(ci):` and cut `v0.2.1`
and `v0.2.2` — real releases, published to Arch, Homebrew and the APK
registry, containing no user-facing change. They were left in place
rather than deleted, because a version that vanishes is worse for
whoever pulled it than one that turns out to be empty.
**The blast radius is bigger than the version number**, which is what
makes this worth a paragraph. A merge to `main` starts two workflows;
if `release.yml` then pushes a tag, that tag push starts **four more**
(`arch-package`, `homebrew-formula`, `android-apk`, `desktop-assets`) —
on a runner with capacity 1, where the APK build alone is tens of
minutes. `make release-dry` before merging is how you find out, and it
is cheaper than every one of those.
**`@semantic-release/github` is not in that config and must not be.**
Gitea's API is `/api/v1` and is not GitHub's surface, so
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
@@ -2210,13 +2309,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 \
+30
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/tagtotals"
)
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
@@ -28,6 +29,8 @@ const (
FieldYear = "year"
FieldTrackNumber = "track_number"
FieldDiscNumber = "disc_number"
FieldTotalTracks = "total_tracks"
FieldTotalDiscs = "total_discs"
FieldCoverArt = "cover_art"
)
@@ -418,5 +421,32 @@ func buildChanges(
changes[FieldDiscNumber] = track.DiscNumber
}
// The totals are what says "2 of 10" rather than a bare tick, and
// dropping them here is what made autotagging an album *erase* the
// evidence: the release becomes MBID-matched while the field
// GetAlbumCompleteness reads stays absent.
//
// They are written unconditionally where the candidate has a
// tracklist, not only when they differ from the local value, because
// the common case is a file that declares no total at all -- which
// compares equal to nothing and would be skipped by a diff guard.
if tracks, discs := tagtotals.For(
candidatePositions(cand), track.DiscNumber,
); tracks > 0 {
changes[FieldTotalTracks] = tracks
changes[FieldTotalDiscs] = discs
}
return changes
}
// candidatePositions is the candidate's tracklist as bare positions.
func candidatePositions(cand Candidate) []tagtotals.Position {
out := make([]tagtotals.Position, 0, len(cand.Tracks))
for _, t := range cand.Tracks {
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
}
return out
}
+90
View File
@@ -0,0 +1,90 @@
package autotag
import "testing"
// Autotagging an album used to *erase* the evidence that says "2 of 10":
// the release became MBID-matched while the totals the files declared
// went unwritten, so the album page showed a plain tick. These pin the
// two halves of the fix that are easy to get wrong silently.
func TestBuildChanges_Totals(t *testing.T) {
t.Parallel()
twoDiscs := Candidate{
Tracks: []CandidateTrack{
{DiscNumber: 1, Position: 1},
{DiscNumber: 1, Position: 2},
{DiscNumber: 2, Position: 1},
{DiscNumber: 2, Position: 2},
{DiscNumber: 2, Position: 3},
},
}
tests := []struct {
name string
cand Candidate
local LocalTrack
track CandidateTrack
wantTracks any
wantDiscs any
}{
{
// The common case, and the one a diff guard would skip: the
// file declares no total at all, so the total "has not
// changed" and would never be written.
name: "a file with no total gets one",
cand: Candidate{Tracks: []CandidateTrack{
{Position: 1}, {Position: 2}, {Position: 3},
}},
local: LocalTrack{TrackNumber: 1},
track: CandidateTrack{Position: 1},
wantTracks: 3,
wantDiscs: 1,
},
{
// 5 here would be the release's track count. Summed once
// per disc by GetAlbumCompleteness that claims a ten-track
// expectation for a five-track album, which no library can
// ever satisfy.
name: "a multi-disc release totals the track's own disc",
cand: twoDiscs,
local: LocalTrack{},
track: CandidateTrack{DiscNumber: 2, Position: 1},
wantTracks: 3,
wantDiscs: 2,
},
{
name: "the other disc gets its own total",
cand: twoDiscs,
local: LocalTrack{},
track: CandidateTrack{DiscNumber: 1, Position: 1},
wantTracks: 2,
wantDiscs: 2,
},
{
// A candidate with no tracklist knows nothing, and writing
// a zero would claim it did.
name: "a candidate with no tracklist writes no total",
cand: Candidate{},
local: LocalTrack{},
track: CandidateTrack{Position: 1},
wantTracks: nil,
wantDiscs: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
changes := buildChanges(tc.local, tc.cand, tc.track)
if got := changes[FieldTotalTracks]; got != tc.wantTracks {
t.Errorf("%s: got %v, want %v", FieldTotalTracks, got, tc.wantTracks)
}
if got := changes[FieldTotalDiscs]; got != tc.wantDiscs {
t.Errorf("%s: got %v, want %v", FieldTotalDiscs, got, tc.wantDiscs)
}
})
}
}
+38
View File
@@ -0,0 +1,38 @@
package autotagservice
import (
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/tagwriter"
)
// twAdapter passes the diff map through unchanged, so autotag's field
// constants and tagwriter's are the same keys written down twice --
// deliberately, to keep autotag out of the write pipeline's import
// graph. A key that drifts does not fail to compile and does not fail
// to write: the writer simply finds no entry under the name it looks
// for, and the field is silently dropped. That is what this pins, and
// this package is the one place that imports both.
func TestAutotagAndTagwriterAgreeOnFieldNames(t *testing.T) {
t.Parallel()
pairs := map[string][2]string{
"title": {autotag.FieldTitle, tagwriter.FieldTitle},
"artist": {autotag.FieldArtist, tagwriter.FieldArtist},
"album": {autotag.FieldAlbum, tagwriter.FieldAlbum},
"album artist": {autotag.FieldAlbumArtist, tagwriter.FieldAlbumArtist},
"year": {autotag.FieldYear, tagwriter.FieldYear},
"track number": {autotag.FieldTrackNumber, tagwriter.FieldTrackNumber},
"disc number": {autotag.FieldDiscNumber, tagwriter.FieldDiscNumber},
"total tracks": {autotag.FieldTotalTracks, tagwriter.FieldTotalTracks},
"total discs": {autotag.FieldTotalDiscs, tagwriter.FieldTotalDiscs},
"cover art": {autotag.FieldCoverArt, tagwriter.FieldCoverArt},
}
for name, pair := range pairs {
if pair[0] != pair[1] {
t.Errorf("%s: autotag says %q, tagwriter says %q", name, pair[0], pair[1])
}
}
}
+32
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"yellowjacket/backend/tagtotals"
"yellowjacket/backend/tagwriter"
)
@@ -275,6 +276,25 @@ func (i *Importer) tagFile(p plannedFile, dl Download) error {
changes[tagwriter.FieldDiscNumber] = p.Track.DiscNumber
}
// An imported file should arrive knowing how much of the album it
// is one of, or the album reads as "in your library" from its first
// imported track onward.
//
// A *track* download is the case this must not touch: a
// RecordingMBID anchor resolves Expected to exactly that one track,
// so totalling it would write "1 of 1" onto a track off a
// twelve-track album -- a confident lie, and one that outranks the
// catalog's own total, which is the fallback that would otherwise
// have answered correctly.
if dl.RecordingMBID == "" {
if tracks, discs := tagtotals.For(
expectedPositions(dl.Expected), p.Track.DiscNumber,
); tracks > 0 {
changes[tagwriter.FieldTotalTracks] = tracks
changes[tagwriter.FieldTotalDiscs] = discs
}
}
if err := i.tags.WriteUntrackedFileTags(p.Source, changes); err != nil {
return fmt.Errorf("write tags: %w", err)
}
@@ -282,6 +302,18 @@ func (i *Importer) tagFile(p plannedFile, dl Download) error {
return nil
}
// expectedPositions is the download's resolved tracklist as bare
// positions.
func expectedPositions(expected []ExpectedTrack) []tagtotals.Position {
out := make([]tagtotals.Position, 0, len(expected))
for _, t := range expected {
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
}
return out
}
// destinationFor computes a file's library path from the template.
func (i *Importer) destinationFor(
p plannedFile,
+74
View File
@@ -446,3 +446,77 @@ func keysOf(m map[string]tagwriter.TagChanges) []string {
return out
}
// An imported album should arrive knowing its own size, or the album
// page reads "in your library" from its first imported track onward --
// which is the badge complaint this exists to answer.
func TestImportWritesTheAlbumTotals(t *testing.T) {
t.Parallel()
f := newImportFixture(t,
"01 - Airbag.flac",
"02 - Paranoid Android.flac",
"03 - Subterranean Homesick Alien.flac",
"04 - Exit Music (For a Film).flac",
)
if _, err := f.importer.Import(
context.Background(),
fourTrackDownload(),
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
); err != nil {
t.Fatalf("Import: %v", err)
}
changes := f.tags.writes["01 - Airbag.flac"]
if changes == nil {
t.Fatal("no tag write recorded for the first track")
}
if got := changes[tagwriter.FieldTotalTracks]; got != 4 {
t.Errorf("%s: got %v, want 4", tagwriter.FieldTotalTracks, got)
}
if got := changes[tagwriter.FieldTotalDiscs]; got != 1 {
t.Errorf("%s: got %v, want 1", tagwriter.FieldTotalDiscs, got)
}
}
// A RecordingMBID anchor resolves Expected to exactly the one track it
// asked for, so totalling it would tag a track off a twelve-track album
// as "1 of 1" -- worse than saying nothing, because a declared total
// outranks the catalog total that would have answered correctly.
func TestImportWritesNoTotalsForATrackDownload(t *testing.T) {
t.Parallel()
f := newImportFixture(t, "01 - Airbag.flac")
dl := Download{
ID: "dl-track",
LibraryID: 1,
RecordingMBID: "mbid-recording",
Artist: "Radiohead",
Album: "OK Computer",
Expected: []ExpectedTrack{{Position: 1, Title: "Airbag"}},
}
if _, err := f.importer.Import(
context.Background(),
dl,
Result{Dir: f.dir, Files: f.files},
ImportOptions{LibraryRoot: f.root, WriteTags: true},
); err != nil {
t.Fatalf("Import: %v", err)
}
changes := f.tags.writes["01 - Airbag.flac"]
if changes == nil {
t.Fatal("no tag write recorded")
}
if _, ok := changes[tagwriter.FieldTotalTracks]; ok {
t.Errorf("%s written for a single-track download: %v",
tagwriter.FieldTotalTracks, changes[tagwriter.FieldTotalTracks])
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package tagtotals derives the totals a tag's "5/12" form declares.
//
// It exists because the two writers that know a release's full
// tracklist -- the autotag apply pass and the download importer --
// must not import each other or the tag writer, and because getting
// the denominator wrong is invisible: a total that is too large marks
// a complete album incomplete forever, and nothing fails.
package tagtotals
// Position is one track's place in a release. A zero Disc means the
// release did not say, which is disc 1.
type Position struct {
Disc int
Track int
}
// For returns the totals to write on a file sitting on disc `disc`:
// how many tracks that disc has, and how many discs the release has.
//
// The track total is **per disc** and not the release's track count,
// because that is what the tag form means and what
// GetAlbumCompleteness sums -- summing a release total once per disc
// would multiply a two-disc album's expectation by two.
//
// Tracks are counted by distinct position rather than by row: a
// tracklist that lists a position twice is a defect in the source, and
// counting it twice would put an album permanently out of reach of its
// own total.
func For(all []Position, disc int) (tracks, discs int) {
disc = normaliseDisc(disc)
seenTracks := make(map[int]struct{}, len(all))
seenDiscs := make(map[int]struct{}, 1)
for _, p := range all {
d := normaliseDisc(p.Disc)
seenDiscs[d] = struct{}{}
if d != disc || p.Track <= 0 {
continue
}
seenTracks[p.Track] = struct{}{}
}
return len(seenTracks), len(seenDiscs)
}
// normaliseDisc treats an undeclared disc as disc 1.
func normaliseDisc(d int) int {
if d <= 0 {
return 1
}
return d
}
+92
View File
@@ -0,0 +1,92 @@
package tagtotals_test
import (
"testing"
"yellowjacket/backend/tagtotals"
)
func TestFor(t *testing.T) {
t.Parallel()
singleDisc := []tagtotals.Position{
{Disc: 0, Track: 1}, {Disc: 0, Track: 2}, {Disc: 0, Track: 3},
}
twoDiscs := []tagtotals.Position{
{Disc: 1, Track: 1},
{Disc: 1, Track: 2},
{Disc: 2, Track: 1},
{Disc: 2, Track: 2},
{Disc: 2, Track: 3},
}
tests := []struct {
name string
all []tagtotals.Position
disc int
wantTracks int
wantDiscs int
}{
{
name: "a single-disc release totals its own tracks",
all: singleDisc, disc: 0, wantTracks: 3, wantDiscs: 1,
},
{
// An undeclared disc is disc 1, on both sides of the
// question -- a file tagged "disc 1" and a tracklist that
// declares no disc describe the same disc.
name: "an undeclared disc is disc 1",
all: singleDisc, disc: 1, wantTracks: 3, wantDiscs: 1,
},
{
// The whole point: 5 here would be the release's track
// count, which summed once per disc claims a ten-track
// expectation for a five-track album.
name: "a multi-disc release totals the file's own disc",
all: twoDiscs, disc: 2, wantTracks: 3, wantDiscs: 2,
},
{
name: "the other disc gets its own total",
all: twoDiscs, disc: 1, wantTracks: 2, wantDiscs: 2,
},
{
// A disc the tracklist does not mention cannot be totalled,
// and 0 is how the caller is told to write nothing.
name: "a disc with no tracks totals nothing",
all: twoDiscs, disc: 3, wantTracks: 0, wantDiscs: 2,
},
{
name: "an empty tracklist totals nothing",
all: nil, disc: 1, wantTracks: 0, wantDiscs: 0,
},
{
// A source that lists a position twice would otherwise put
// the album permanently one track short of its own total.
name: "a repeated position counts once",
all: []tagtotals.Position{
{Disc: 1, Track: 1}, {Disc: 1, Track: 1}, {Disc: 1, Track: 2},
},
disc: 1, wantTracks: 2, wantDiscs: 1,
},
{
name: "a track with no position is not counted",
all: []tagtotals.Position{
{Disc: 1, Track: 0}, {Disc: 1, Track: 1},
},
disc: 1, wantTracks: 1, wantDiscs: 1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tracks, discs := tagtotals.For(tc.all, tc.disc)
if tracks != tc.wantTracks || discs != tc.wantDiscs {
t.Errorf("For(%v, %d) = (%d, %d), want (%d, %d)",
tc.all, tc.disc, tracks, discs, tc.wantTracks, tc.wantDiscs)
}
})
}
}
+10 -1
View File
@@ -183,6 +183,15 @@ func syncDatabase(
discNum = toNullInt64(v)
}
// The completeness evidence. Without this the row keeps whatever
// the last scan read while the file on disk now declares a total,
// so the album stays "unknown" until a full rescan -- which is the
// state the report describes.
totalTracks := old.TotalTracks
if v, ok := asInt(params.changes[FieldTotalTracks]); ok {
totalTracks = toNullInt64(v)
}
composer := old.Composer
if v, ok := params.changes[FieldComposer].(string); ok {
composer = v
@@ -207,7 +216,7 @@ func syncDatabase(
AlbumID: albumID,
TrackNumber: trackNum,
DiscNumber: discNum,
TotalTracks: old.TotalTracks,
TotalTracks: totalTracks,
Year: year,
Composer: composer,
Comment: old.Comment,
+5
View File
@@ -101,6 +101,11 @@ func applyFlacTextChanges(cmt *flacvorbis.MetaDataBlockVorbisComment, changes Ta
{FieldYear, flacvorbis.FIELD_DATE, true},
{FieldTrackNumber, flacvorbis.FIELD_TRACKNUMBER, true},
{FieldDiscNumber, "DISCNUMBER", true},
// TRACKTOTAL/DISCTOTAL and no other spelling: dhowden/tag's
// Vorbis reader looks at exactly these two keys, so TOTALTRACKS
// or a "1/12" inside TRACKNUMBER reads back as no total at all.
{FieldTotalTracks, "TRACKTOTAL", true},
{FieldTotalDiscs, "DISCTOTAL", true},
{FieldComposer, "COMPOSER", false},
}
+63 -11
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
@@ -66,17 +67,10 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
tag.SetYear(strconv.Itoa(v))
}
if v, ok := asInt(changes[FieldTrackNumber]); ok {
trckID := tag.CommonID("Track number/Position in set")
tag.DeleteFrames(trckID)
tag.AddTextFrame(trckID, id3v2.EncodingUTF8, strconv.Itoa(v))
}
if v, ok := asInt(changes[FieldDiscNumber]); ok {
tposID := tag.CommonID("Part of a set")
tag.DeleteFrames(tposID)
tag.AddTextFrame(tposID, id3v2.EncodingUTF8, strconv.Itoa(v))
}
applyPositionFrame(tag, "Track number/Position in set", changes,
FieldTrackNumber, FieldTotalTracks)
applyPositionFrame(tag, "Part of a set", changes,
FieldDiscNumber, FieldTotalDiscs)
if v, ok := changes[FieldComposer].(string); ok {
tag.DeleteFrames("TCOM")
@@ -90,6 +84,64 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
}
}
// applyPositionFrame writes an ID3v2 position frame (TRCK or TPOS) in
// the "n/N" form the readers parse.
//
// The number and the total are separate diff entries and either may be
// absent, so the frame's *existing* value is the base: writing a total
// alone must not discard the number that is already there, and writing
// a number alone must not discard a total the file already declared.
// A total with no number at all is not written, since "/12" says
// nothing a reader can use.
func applyPositionFrame(
tag *id3v2.Tag, description string, changes TagChanges, numKey, totalKey string,
) {
_, hasNum := changes[numKey]
_, hasTotal := changes[totalKey]
if !hasNum && !hasTotal {
return
}
frameID := tag.CommonID(description)
num, total := parseXofN(
strings.TrimRight(tag.GetTextFrame(frameID).Text, "\x00 \t\n\r"),
)
if v, ok := asInt(changes[numKey]); ok {
num = v
}
if v, ok := asInt(changes[totalKey]); ok {
total = v
}
if num <= 0 {
return
}
value := strconv.Itoa(num)
if total > 0 {
value += "/" + strconv.Itoa(total)
}
tag.DeleteFrames(frameID)
tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value)
}
// parseXofN splits an ID3v2 "n/N" position value. A bare "n" yields a
// zero total, and anything unparseable yields zeros — the same reading
// dhowden/tag gives the frame.
func parseXofN(s string) (int, int) {
numText, totalText, _ := strings.Cut(s, "/")
num, _ := strconv.Atoi(strings.TrimSpace(numText))
total, _ := strconv.Atoi(strings.TrimSpace(totalText))
return num, total
}
// applyCoverArtChanges handles the FieldCoverArt entry in the diff map.
//
// - []byte with len > 0: embed the given image as front cover.
+2
View File
@@ -166,6 +166,8 @@ var oggFieldMappings = []struct { //nolint:gochecknoglobals // field mapping tab
{FieldYear, "DATE", true},
{FieldTrackNumber, "TRACKNUMBER", true},
{FieldDiscNumber, "DISCNUMBER", true},
{FieldTotalTracks, "TRACKTOTAL", true},
{FieldTotalDiscs, "DISCTOTAL", true},
{FieldComposer, "COMPOSER", false},
}
+28
View File
@@ -333,3 +333,31 @@ func TestWriteTrackTags_DBSync(t *testing.T) {
t.Error("expected FTS5 result for 'New Title'")
}
}
// The row is what the album page reads, and it is only refreshed by a
// scan. Leaving total_tracks at whatever the last scan saw means an
// album autotagged just now stays "unknown" -- a plain tick on an album
// the user holds two tracks of -- until a full rescan happens to run.
func TestWriteTrackTags_PersistsTheTotal(t *testing.T) {
db := database.NewTestDB(t)
dir := t.TempDir()
trackID := seedTestTrack(t, db, createPipelineTestMP3(t, dir))
tw := NewTagWriter(testLogger(), db, &mockPlayer{}, &mockPipelineLocker{})
if err := tw.WriteTrackTags(trackID, TagChanges{
FieldTrackNumber: 2,
FieldTotalTracks: 10,
}); err != nil {
t.Fatalf("WriteTrackTags: %v", err)
}
af, err := db.Queries.GetAudioFile(context.Background(), trackID)
if err != nil {
t.Fatalf("get audio file: %v", err)
}
if !af.TotalTracks.Valid || af.TotalTracks.Int64 != 10 {
t.Errorf("total_tracks: got %v, want 10", af.TotalTracks)
}
}
+9
View File
@@ -26,6 +26,15 @@ const (
FieldDiscNumber = "disc_number"
FieldComposer = "composer"
FieldCoverArt = "cover_art" // []byte for set, nil for clear
// FieldTotalTracks is how many tracks are on *this file's disc*, not
// in the whole release. That is what the "5/12" form declares and
// what GetAlbumCompleteness sums per disc; a release total written
// here would multiply the expectation by the number of discs.
FieldTotalTracks = "total_tracks"
// FieldTotalDiscs is how many discs the release has.
FieldTotalDiscs = "total_discs"
)
// AudioFormat represents a supported audio file format.
+199
View File
@@ -0,0 +1,199 @@
package tagwriter
import (
"path/filepath"
"testing"
"yellowjacket/backend/metadata"
)
// The totals are the evidence GetAlbumCompleteness reads, and every way
// of getting them wrong is silent: a tag written under a name the
// reader does not look at reads back as no total at all, which is
// indistinguishable from never having written one. So these assert the
// round trip through the *reader the scan uses*, not the bytes.
//
// WAV is the exception and it is not this change's: dhowden/tag has no
// RIFF reader at all, so metadata.ExtractTags cannot see a WAV's ID3
// chunk -- which is why every other test here reads that chunk itself.
func TestWriteTotals_RoundTripsInEveryFormat(t *testing.T) {
t.Parallel()
changes := TagChanges{
FieldTitle: "Some Song",
FieldTrackNumber: 2,
FieldTotalTracks: 10,
FieldDiscNumber: 1,
FieldTotalDiscs: 2,
}
viaScanner := func(t *testing.T, path string) *metadata.TrackMetadata {
t.Helper()
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
return meta
}
tests := []struct {
name string
write func(t *testing.T, dir string) string
read func(t *testing.T, path string) *metadata.TrackMetadata
}{
{
name: "mp3",
read: viaScanner,
write: func(t *testing.T, dir string) string {
t.Helper()
path := createTestMP3(t, dir, "totals.mp3", nil)
if err := writeMp3Tags(testLogger(), path, changes); err != nil {
t.Fatalf("writeMp3Tags: %v", err)
}
return path
},
},
{
name: "flac",
read: viaScanner,
write: func(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "totals.flac")
makeMinimalFLAC(t, path)
if err := writeFlacTags(testLogger(), path, changes); err != nil {
t.Fatalf("writeFlacTags: %v", err)
}
return path
},
},
{
name: "ogg",
read: viaScanner,
write: func(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "totals.ogg")
createTestOGG(t, path)
if err := writeOggTags(testLogger(), path, changes); err != nil {
t.Fatalf("writeOggTags: %v", err)
}
return path
},
},
{
name: "wav",
read: readWavID3Tags,
write: func(t *testing.T, dir string) string {
t.Helper()
path := createTestWAV(t, dir, "totals.wav", nil)
if err := writeWavTags(testLogger(), path, changes); err != nil {
t.Fatalf("writeWavTags: %v", err)
}
return path
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
meta := tc.read(t, tc.write(t, t.TempDir()))
assertIntField(t, "TrackNumber", meta.TrackNumber, 2)
assertIntField(t, "TotalTracks", meta.TotalTracks, 10)
assertIntField(t, "DiscNumber", meta.DiscNumber, 1)
assertIntField(t, "TotalDiscs", meta.TotalDiscs, 2)
})
}
}
// A number and a total are separate diff entries, so writing one must
// not discard the other. For ID3v2 they share a single "n/N" frame,
// which is the only place this can go wrong -- and it goes wrong by
// silently zeroing a total the file already declared.
func TestWriteMp3Totals_PartialUpdateKeepsTheOtherHalf(t *testing.T) {
t.Parallel()
t.Run("writing the number keeps the total", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := createTestMP3(t, dir, "seeded.mp3", TagChanges{
FieldTrackNumber: 2,
FieldTotalTracks: 10,
})
if err := writeMp3Tags(testLogger(), path, TagChanges{
FieldTrackNumber: 4,
}); err != nil {
t.Fatalf("writeMp3Tags: %v", err)
}
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
assertIntField(t, "TrackNumber", meta.TrackNumber, 4)
assertIntField(t, "TotalTracks", meta.TotalTracks, 10)
})
t.Run("writing the total keeps the number", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := createTestMP3(t, dir, "seeded.mp3", TagChanges{
FieldTrackNumber: 7,
})
if err := writeMp3Tags(testLogger(), path, TagChanges{
FieldTotalTracks: 12,
}); err != nil {
t.Fatalf("writeMp3Tags: %v", err)
}
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
assertIntField(t, "TrackNumber", meta.TrackNumber, 7)
assertIntField(t, "TotalTracks", meta.TotalTracks, 12)
})
// "/12" says nothing a reader can use, and dhowden/tag reads it as
// track 0 -- which the scan would store as a real track number.
t.Run("a total with no number writes nothing", func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := createTestMP3(t, dir, "bare.mp3", nil)
if err := writeMp3Tags(testLogger(), path, TagChanges{
FieldTotalTracks: 12,
}); err != nil {
t.Fatalf("writeMp3Tags: %v", err)
}
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
assertIntField(t, "TrackNumber", meta.TrackNumber, 0)
assertIntField(t, "TotalTracks", meta.TotalTracks, 0)
})
}
+5 -4
View File
@@ -522,19 +522,20 @@ func readWavID3Tags(
}
}
// Track number (TRCK).
// Track number and total (TRCK), disc number and total (TPOS).
// Both carry the "n/N" form, so they are read the way a reader
// reads them rather than with Atoi -- which sees "2/10" as 0.
trckID := parsed.CommonID("Track number/Position in set")
if frames := parsed.GetFrames(trckID); len(frames) > 0 {
if tf, ok := frames[0].(id3v2.TextFrame); ok {
meta.TrackNumber = atoiSafe(tf.Text)
meta.TrackNumber, meta.TotalTracks = parseXofN(tf.Text)
}
}
// Disc number (TPOS).
tposID := parsed.CommonID("Part of a set")
if frames := parsed.GetFrames(tposID); len(frames) > 0 {
if tf, ok := frames[0].(id3v2.TextFrame); ok {
meta.DiscNumber = atoiSafe(tf.Text)
meta.DiscNumber, meta.TotalDiscs = parseXofN(tf.Text)
}
}
+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
+4 -1
View File
@@ -39,7 +39,10 @@
<audio-player></audio-player>
<button aria-label="Toggle queue" aria-controls="queue-panel" aria-expanded="false"
id="queue-button">
<wa-icon name="list"></wa-icon>
<!-- ICON_QUEUE in src/utils/icon-language.ts, written out
because this file has no module scope. It was `list`,
which is the Playlists destination's icon. -->
<wa-icon name="bars-staggered"></wa-icon>
</button>
</footer>
<!-- The phone's primary navigation, hidden above 600px by
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM64 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L96 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 609 B

@@ -37,6 +37,10 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dict, list } from '@utils/binding';
import {
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;
@@ -1371,7 +1375,7 @@ export class ArtistsView
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_QUEUE}
></wa-icon>
Add to Queue
</wa-dropdown-item>
@@ -1407,7 +1411,7 @@ export class ArtistsView
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
@@ -6,6 +6,7 @@ import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.
import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
import { ICON_PLAYLIST } from '@utils/icon-language';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
@@ -138,7 +139,7 @@ export class BottomNav extends LitElement {
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
];
override connectedCallback() {
@@ -76,6 +76,10 @@ import type {
SortDirection,
} from './cover-grid-types.js';
import { list } from '@utils/binding';
import {
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
@customElement('cover-grid')
export class CoverGrid
@@ -2123,7 +2127,7 @@ export class CoverGrid
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_QUEUE}
></wa-icon>
Add to Queue
</wa-dropdown-item>
@@ -2156,7 +2160,7 @@ export class CoverGrid
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
@@ -52,6 +52,12 @@ import { dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
import '@components/playlist-picker/playlist-picker.js';
import {
ICON_CAN_REQUEST,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REQUESTED,
} from '@utils/icon-language';
/**
* The region the album header's own failures are rendered in.
@@ -193,6 +199,25 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private selectedVersionKey: string = '';
@state() private coverArtURL = '';
/**
* Whether to draw the whole release rather than only the files on
* disk `null` while nobody has said, which is the automatic rule
* (`buildLibraryEntry`: show the release once the tags say the album
* is incomplete).
*
* It is a *tri-state* on purpose. The automatic rule is right when
* it fires and the switch has to be able to agree with it, or the
* control would start out contradicting the page it is sitting on;
* a plain boolean would need its default recomputed every time the
* completeness answer changed underneath it.
*
* The rule alone was not enough, which is the report: it depends on
* the files declaring a per-disc total, so a library whose tags
* never said sat permanently on "only my tracks" with no way to ask
* for the rest and no way to tell that there was a rest.
*/
@state() private showFullTracklist: boolean | null = null;
/**
* The local album's own tracks the authoritative answer to "what
* is actually on disk," independent of `this.releases`, which
@@ -559,6 +584,19 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
}
/* ── Tracklist ── */
.tracklist-scope {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
margin-bottom: 8px;
}
.tracklist-scope-hint {
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #888);
}
.tracklist {
display: flex;
flex-direction: column;
@@ -914,6 +952,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.releases = [];
this.versionEntries = [];
this.selectedVersionKey = '';
this.showFullTracklist = null;
this.localTracks = [];
this.filePaths = new Map();
this.askedFor = new Set();
@@ -1817,17 +1856,23 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// Guarded on `known` rather than on "fewer tracks than the
// cluster", which would swap in a catalog tracklist for
// every album whose tags simply never declared a total.
//
// And guarded on the *user's* answer first, because the
// automatic rule can only fire where the tags declared a
// total: an album that says nothing is not an album that is
// complete, and it used to be shown as one.
const answer = this.completenessAnswer();
const incomplete = answer?.known && !answer.complete;
if (incomplete) {
const fullRelease = this.findLibraryCluster(clusters);
if (this.showFullTracklist ?? (answer?.known && !answer.complete)) {
const fullRelease = this.fullReleaseCluster(clusters);
if (fullRelease) {
return {
key: 'synthetic:library',
label: 'Your Library',
sublabel: `${answer?.owned ?? 0} of ${answer?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`,
sublabel: answer?.known
? `${answer.owned} of ${answer.expected} tracks · ${this.clusterLabel(fullRelease)}`
: `${this.clusterLabel(fullRelease)} · full tracklist`,
group: 'aggregate',
syntheticKind: 'library',
tracks: fullRelease.representative.tracks ?? [],
@@ -1861,6 +1906,25 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
};
}
/**
* The release to draw when the whole album is wanted rather than
* the files on disk.
*
* `findLibraryCluster` is the right answer where it has one the
* release the user's tracks overlap most but it is a guess over
* the `inLibrary` flags and returns nothing at all when none of
* them are set, which is every untagged library. Falling back to
* the highest-scoring cluster is what makes the switch work there;
* that is the same release the page would call "Standard", and the
* sublabel names it either way rather than leaving the user to
* wonder whose tracklist they are reading.
*/
private fullReleaseCluster(
clusters: ReleaseCluster[],
): ReleaseCluster | undefined {
return this.findLibraryCluster(clusters) ?? clusters[0];
}
/**
* Fallback only: used when there's no local album to anchor on
* (see `buildLibraryEntry`). Finds the cluster with the highest
@@ -2238,6 +2302,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderVersionSelector()}
${this.renderTracklistScope()}
${this.renderTracklist()}
</div>
<track-details></track-details>
@@ -2378,7 +2443,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
data-testid="album-queue"
@click=${() => this.queueOwned()}
>
<wa-icon slot="start" name="list"></wa-icon>
<wa-icon slot="start" name=${ICON_QUEUE}></wa-icon>
Add to queue
</wa-button>
${partial
@@ -2681,7 +2746,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
of the same Free glyph carry the toggle instead. -->
<wa-icon
slot="start"
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
name=${this.isRequested ? ICON_REQUESTED : ICON_CAN_REQUEST}
></wa-icon>
${this.isRequested ? 'Requested' : 'Request this'}
</wa-button>
@@ -3036,6 +3101,86 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Tracklist ── */
/**
* "Show the whole album" the switch between the files on disk and
* the release they are part of.
*
* The page could already draw the full release with the missing
* rows dimmed, and did so automatically once the tags said the
* album was incomplete. What it could not do was be *asked*: where
* the files declare no per-disc total and the catalog has none
* either, the rule never fires, so a partly-owned album showed only
* the tracks the user had and nothing said the rest existed.
*
* Three things about when it appears, all of them the same rule
* a control that cannot change what is on screen is worse than no
* control, which is what the version dropdown's own guard is for:
*
* - Only against the synthetic "Your Library" entry. Every other
* entry *is* a catalog tracklist already.
* - Only when a catalog release exists to switch to.
* - Only when the two differ. A complete album's release has the
* same rows as its files, so the switch would redraw the same
* list and read as broken.
*/
private renderTracklistScope() {
const current = this.currentVersion();
if (current?.syntheticKind !== 'library') return nothing;
if (this.localTracks.length === 0) return nothing;
const full = this.fullReleaseCluster(this.clustersOf(this.versionEntries));
const fullCount = full?.representative.tracks?.length ?? 0;
if (fullCount === 0 || fullCount <= this.localTracks.length) {
return nothing;
}
const showing = current.tracks.length > this.localTracks.length;
return html`
<div class="tracklist-scope">
<wa-switch
size="small"
?checked=${showing}
@change=${this.handleTracklistScopeChange}
>
Show the whole album
</wa-switch>
<span class="tracklist-scope-hint">
${showing
? `${this.localTracks.length} of ${fullCount} tracks are in your library`
: `${fullCount - this.localTracks.length} more tracks are on this release`}
</span>
</div>
`;
}
/**
* The clusters behind the current entries.
*
* `buildClusters` computes them and keeps only the entries, so this
* recovers them rather than storing the array twice two copies of
* a list rebuilt on four different events is how they come to
* disagree.
*/
private clustersOf(entries: VersionEntry[]): ReleaseCluster[] {
return entries
.filter((e) => e.group === 'cluster')
.map((e) => e.cluster)
.filter((c): c is ReleaseCluster => !!c);
}
private handleTracklistScopeChange = (e: Event) => {
this.showFullTracklist = (e.target as HTMLInputElement).checked;
// The entries are derived, so the switch rebuilds them rather
// than patching the one it changed. `buildClusters` re-defaults
// the selection, which lands back on "Your Library" — the only
// entry this control is ever shown against.
this.buildClusters();
};
/**
* The heading is there and is not drawn.
*
@@ -3199,7 +3344,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@click=${() => this.onContextMenuAction('add-to-queue')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@@ -3218,7 +3363,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.openPlaylistSubmenu();
}}
>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
Add to Playlist
<span class="submenu-arrow">&#9654;</span>
</wa-dropdown-item>
@@ -59,6 +59,12 @@ import { dict, dictByName } from '@utils/binding';
import type { TrackDetails } from '@components/track-details/track-details.js';
import { showTrackDetailsForPath } from '@utils/track-details-opener.js';
import '@components/playlist-picker/playlist-picker.js';
import {
ICON_CAN_REQUEST,
ICON_PLAYLIST,
ICON_QUEUE,
ICON_REQUESTED,
} from '@utils/icon-language';
/* ── Constants ── */
@@ -2674,7 +2680,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
@click=${() => this.onContextMenuAction('add-to-queue')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@@ -2693,7 +2699,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
void this.openPlaylistSubmenu(true);
}}
>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
Add to Playlist
<span class="submenu-arrow">&#9654;</span>
</wa-dropdown-item>
@@ -2737,7 +2743,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => void this.onReleaseAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => void this.onReleaseAction('play-next')}>
@@ -2751,7 +2757,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
<wa-dropdown-item @click=${() => void this.onReleaseRequestToggle()}>
<wa-icon
slot="icon"
name=${requested ? 'xmark' : 'bookmark'}
name=${requested ? ICON_REQUESTED : ICON_CAN_REQUEST}
></wa-icon>
${requested ? 'Cancel Request' : 'Request This'}
</wa-dropdown-item>
@@ -2789,9 +2795,15 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
appearance=${request ? 'filled' : 'outlined'}
@click=${() => void this.toggleFollow(request?.id)}
>
<!-- This was bookmark-check, which is not in
names.txt and so has rendered the missing-icon
fallback a circled question mark on every
followed artist since it was written. A
backtick around that name would end this
template literal, which is why there is none. -->
<wa-icon
slot="start"
name=${request ? 'bookmark-check' : 'bookmark'}
name=${request ? ICON_REQUESTED : ICON_CAN_REQUEST}
></wa-icon>
${request ? 'Following' : 'Follow for new releases'}
</wa-button>
@@ -36,6 +36,7 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { dict, dictByName } from '@utils/binding';
import { ICON_QUEUE } from '@utils/icon-language';
/** The region explore's own action failures (play/queue) are rendered in. */
export const ExploreRegion = 'explore';
@@ -1342,7 +1343,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
@@ -35,6 +35,10 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@components/playlist-picker/playlist-picker.js';
import { dictByName } from '@utils/binding';
import {
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;
@@ -1211,7 +1215,7 @@ export class GenresView
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_QUEUE}
></wa-icon>
Add to Queue
</wa-dropdown-item>
@@ -1257,7 +1261,7 @@ export class GenresView
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
@@ -4,6 +4,11 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { toggleRequest } from '@utils/library-status';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import {
ICON_CAN_REQUEST,
ICON_IN_LIBRARY,
ICON_REQUESTED,
} from '@utils/icon-language';
/**
* Library status for an entity (artist, album, or track).
@@ -248,18 +253,25 @@ export class LibraryStatusIndicator extends LitElement {
* hourglass says "wait, this is under way", which overstates what a
* request is: nothing may be downloading, nothing may ever be found,
* and the user can leave one sitting on the list indefinitely. A
* bookmark says the honest thing it is on your list and reads as
* the opposite of the plus that put it there, which is what a
* toggle's two states have to do.
* bookmark says the honest thing it is on your list.
*
* The *other* state is the outline of that same bookmark, not a
* plus. Two states of one toggle have to read as each other's
* opposite, and a plus and a bookmark do not this badge showed a
* plus on the same page as a "Request this" button already using
* the outline/solid pair, forty pixels away. That is the fault
* `utils/library-status.ts` was written for, one layer down: it
* made the two agree on what wanting *means* and left them
* disagreeing on what it looks like.
*/
private iconName(): string {
switch (this.status) {
case 'in-library':
return 'check';
return ICON_IN_LIBRARY;
case 'queued':
return 'bookmark';
return ICON_REQUESTED;
default:
return 'plus';
return ICON_CAN_REQUEST;
}
}
@@ -14,6 +14,7 @@ import { creditStore } from '@store/credit-store';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { ICON_QUEUE } from '@utils/icon-language';
/**
* What is playing, at the size a phone has room for (plan 016 B2,
@@ -339,7 +340,7 @@ export class NowPlayingView extends LitElement {
aria-label="Show the queue"
@click=${this.openQueue}
>
<wa-icon name="list"></wa-icon>
<wa-icon name=${ICON_QUEUE}></wa-icon>
</button>
</header>
`;
@@ -70,6 +70,10 @@ import {
} from '@utils/explore-link';
import { designTokens } from '../../styles/tokens.css';
import { list } from '@utils/binding';
import {
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
/** One playlist row: the track and its position in the *playlist*,
* which is not its position in the filtered view. */
@@ -1358,7 +1362,7 @@ export class PlaylistDetails
</button>
<div class="playlist-avatar">
<wa-icon
name="list"
name=${ICON_PLAYLIST}
></wa-icon>
</div>
<div class="playlist-info">
@@ -1665,7 +1669,7 @@ export class PlaylistDetails
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_QUEUE}
></wa-icon>
Add to Queue
</wa-dropdown-item>
@@ -1720,7 +1724,7 @@ export class PlaylistDetails
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
@@ -18,6 +18,7 @@ import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import { list } from '@utils/binding';
import { ICON_NEW } from '@utils/icon-language';
/**
* A reusable playlist picker that displays existing playlists
@@ -307,7 +308,7 @@ export class PlaylistPicker extends LitElement {
`
: nothing}
<wa-dropdown-item @click=${this.handleShowCreate}>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_NEW}></wa-icon>
New Playlist
</wa-dropdown-item>
</div>
@@ -37,6 +37,10 @@ import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { FavoritesController } from '@store/controllers/favorites-controller';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import {
ICON_NEW,
ICON_PLAYLIST,
} from '@utils/icon-language';
const SCROLL_DEBOUNCE_MS = 100;
@@ -1496,7 +1500,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
@dragleave=${this.onNewButtonDragLeave}
@drop=${this.onNewButtonDrop}
>
<wa-icon name="plus"></wa-icon>
<wa-icon name=${ICON_NEW}></wa-icon>
New Playlist
</button>
<button
@@ -1641,10 +1645,10 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
>
<div class="drop-zone-icon">
<wa-icon
name="plus"
name=${ICON_NEW}
></wa-icon>
</div>
<wa-icon name="list"></wa-icon>
<wa-icon name=${ICON_PLAYLIST}></wa-icon>
<p>No playlists yet</p>
<p style="font-size: 12px;">
Create a playlist or drop
@@ -1666,7 +1670,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
>
<div class="drop-zone-icon">
<wa-icon
name="plus"
name=${ICON_NEW}
></wa-icon>
</div>
<p>
@@ -1701,7 +1705,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
>
<div class="drop-zone-icon">
<wa-icon
name="plus"
name=${ICON_NEW}
></wa-icon>
</div>
</li>
@@ -60,6 +60,11 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import {
ICON_NEW,
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
/** Above this many tracks, clearing the queue asks first. */
const CLEAR_CONFIRM_THRESHOLD = 20;
@@ -1755,7 +1760,7 @@ export class QueuePanel
title="Add queue to playlist"
>
<wa-icon
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
</button>
</div>
@@ -1794,11 +1799,11 @@ export class QueuePanel
? html`<div class="empty-state">
<div class="drop-zone-icon">
<wa-icon
name="plus"
name=${ICON_NEW}
></wa-icon>
</div>
<wa-icon
name="list"
name=${ICON_QUEUE}
></wa-icon>
<p>Queue is empty</p>
<p style="font-size: 12px;">
@@ -1875,7 +1880,7 @@ export class QueuePanel
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
@@ -4,6 +4,10 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import {
ICON_PLAYLIST,
ICON_REQUESTED,
} from '@utils/icon-language';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'downloads' | 'autotag' | 'jobs' | 'settings';
@@ -197,13 +201,13 @@ export class AppSidebar extends LitElement {
private navItems: NavItem[] = [
{ id: 'home', label: 'Home', icon: 'house' },
{ id: 'playlists', label: 'Playlists', icon: 'list' },
{ id: 'playlists', label: 'Playlists', icon: ICON_PLAYLIST },
{ id: 'artists', label: 'Artists', icon: 'user-group' },
{ id: 'genres', label: 'Genres', icon: 'masks-theater' },
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'downloads', label: 'Downloads', icon: 'bookmark' },
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
@@ -61,6 +61,10 @@ import {
import '@components/smart-playlist-editor/smart-playlist-editor.js';
import { designTokens } from '../../styles/tokens.css';
import { list } from '@utils/binding';
import {
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
/**
@@ -1481,7 +1485,7 @@ export class SmartPlaylistDetails
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_QUEUE}
></wa-icon>
Add to Queue
</wa-dropdown-item>
@@ -1521,7 +1525,7 @@ export class SmartPlaylistDetails
>
<wa-icon
slot="icon"
name="plus"
name=${ICON_PLAYLIST}
></wa-icon>
Add to Playlist
<span
@@ -74,6 +74,10 @@ import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import '@components/playlist-picker/playlist-picker.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
ICON_PLAYLIST,
ICON_QUEUE,
} from '@utils/icon-language';
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
const SORT_FIELD_KEY = 'track-list-sort-field';
@@ -2342,7 +2346,7 @@ export class TrackList
@click=${() => this.onContextMenuAction('add-to-queue')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_QUEUE}></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@@ -2364,7 +2368,7 @@ export class TrackList
void this.ctxMenu.showPlaylistSubmenu(this.selection.getSelectedKeysOrdered());
}}
>
<wa-icon slot="icon" name="plus"></wa-icon>
<wa-icon slot="icon" name=${ICON_PLAYLIST}></wa-icon>
Add to Playlist
<span class="submenu-arrow">&#9654;</span>
</wa-dropdown-item>
+1
View File
@@ -24,6 +24,7 @@ solid/arrows-rotate
solid/arrow-up-short-wide
solid/backward-step
solid/bars
solid/bars-staggered
regular/bookmark
solid/bookmark
solid/box-open
+100
View File
@@ -0,0 +1,100 @@
/**
* What each icon in this app means, once.
*
* The set was a mix: `plus` meant "add to the queue", "add to a
* playlist", "make a new playlist" and "you do not own this" the
* first two *adjacent in the same context menu* while `list` meant
* the queue, the Playlists destination, and (in `queue-panel` alone)
* adding to the queue. Two icons carrying seven meanings between them
* is not a vocabulary, and a user cannot learn one that says four
* things.
*
* The rule these are chosen by: **an icon names the noun it acts on,
* not the verb.** "Add to queue" and "add to playlist" are the same
* verb on different nouns, so the noun is what has to differ which is
* also why adding to a playlist wears the Playlists destination's own
* icon rather than a generic plus. `plus` survives for exactly the one
* thing it is unambiguous about, making something that did not exist.
*
* Import these rather than writing a name inline. A literal string is
* how the last set drifted, and nothing catches it: a wrong-but-real
* icon renders perfectly.
*/
/** Start playing this now. */
export const ICON_PLAY = 'play';
/** Start playing this now, in a shuffled order. */
export const ICON_SHUFFLE = 'shuffle';
/**
* The queue, and putting something into it.
*
* One glyph for the noun and the action, so the button that opens the
* queue and the menu item that adds to it are visibly the same subject.
* The queue used to wear `list`, which is the Playlists destination.
*/
export const ICON_QUEUE = 'bars-staggered';
/** Put this next in the queue rather than at the end. */
export const ICON_PLAY_NEXT = 'forward-step';
/**
* A playlist, and adding something to one.
*
* The same icon as the Playlists destination in the sidebar, which is
* the point: the menu item says where the thing is going.
*/
export const ICON_PLAYLIST = 'list';
/**
* Make a new thing that did not exist a playlist, a rule, a library.
*
* This is the only meaning `plus` keeps. It used to carry four.
*/
export const ICON_NEW = 'plus';
/**
* The request ("want") toggle, as an outline/solid pair.
*
* Two states of one control have to read as each other's opposite,
* which a plus and a bookmark do not. The pair was already in the app
* and already correct `explore-album-details`'s "Want this" button
* has used it since it was written, and `favorites-controller` uses the
* same shape for `regular/heart` `heart` while the badge forty
* pixels away showed a plus for the same state.
*
* That is `utils/library-status.ts`'s fault one layer down: it made the
* two surfaces agree on *what wanting means* and left them disagreeing
* on what it looks like.
*/
export const ICON_CAN_REQUEST = 'regular/bookmark';
export const ICON_REQUESTED = 'solid/bookmark';
/**
* You have this.
*
* Deliberately not drawn on the common case see the tracklist, where
* absence is what gets marked. This is for the places that answer the
* question directly, like the badge on a catalog card.
*/
export const ICON_IN_LIBRARY = 'check';
/**
* Something is being fetched right now.
*
* Distinct from `ICON_REQUESTED`: a request may sit on the list
* forever without anything happening, which is exactly why the badge's
* "queued" state stopped being an hourglass.
*/
export const ICON_DOWNLOADING = 'download';
/**
* Take this away.
*
* One icon for removing from a playlist, from the queue and from the
* library, because the difference that matters is stated in the words
* beside it and in the confirmation "Remove from Library" says in its
* impact line that the files are not deleted.
*/
export const ICON_REMOVE = 'trash';
@@ -0,0 +1,238 @@
/**
* Asking to see the whole album.
*
* The page could already draw the full release with the missing rows
* dimmed, and did so automatically once the tags said the album was
* incomplete. What it could not do was be *asked*: the rule depends on
* the files declaring a per-disc total, so where they declare none
* which is a great deal of any library a partly-owned album showed
* only the tracks on disk and nothing said the rest existed.
*
* The switch is the explicit route. Its rules are all one rule: a
* control that cannot change what is on screen is worse than no
* control, which is the same test the version dropdown answers.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { page } from 'vitest/browser';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MBID = 'rg-0001';
function track(n: number, owned = false) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: owned,
};
}
function release(mbid: string, date: string, trackCount: number, owned = 0) {
return {
mbid,
title: 'Glass Harbour',
date,
status: 'Official',
tracks: Array.from({ length: trackCount }, (_, i) =>
track(i + 1, i < owned),
),
};
}
/** Local files with no recording MBIDs an untagged rip, which is the
* case the automatic rule cannot see. */
function localTracks(count: number) {
return Array.from({ length: count }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: '',
}));
}
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
async function albumWith(
releases: unknown[],
completeness: Record<string, unknown>,
local: unknown[] = [],
): Promise<LitElement> {
stub('explore.Service.BrowseReleases', releases);
stub('library.Library.GetAlbumCompleteness', completeness);
stub('library.Library.GetAlbumTracks', local);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
const scopeSwitch = (el: LitElement) => shadow(el, '.tracklist-scope wa-switch');
async function toggle(el: LitElement) {
const sw = scopeSwitch(el) as HTMLInputElement | null;
if (!sw) throw new Error('no tracklist scope switch on the page');
sw.checked = !sw.checked;
sw.dispatchEvent(new Event('change'));
await flush();
await el.updateComplete;
}
describe('the "show the whole album" switch', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
/**
* The report, exactly: two tracks on disk, twelve on the release,
* and nothing to say so because the tags declared no total.
*/
it('reveals the rest of the release when the total is unknown', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 2)],
UNKNOWN,
localTracks(2),
);
expect(shadowAll(el, '.track-row')).toHaveLength(2);
await toggle(el);
const rows = shadowAll(el, '.track-row');
expect(rows).toHaveLength(12);
// Nothing resolves to a file, so every row is marked unowned —
// the dimming is the signal, and it is not this switch's job to
// invent ownership it cannot prove.
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(12);
});
/** And back again — a switch that only goes one way is a button. */
it('goes back to the files on disk', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 2)],
UNKNOWN,
localTracks(2),
);
await toggle(el);
expect(shadowAll(el, '.track-row')).toHaveLength(12);
await toggle(el);
expect(shadowAll(el, '.track-row')).toHaveLength(2);
});
/**
* The automatic rule still fires, and the control has to agree with
* the page it is sitting on rather than starting out contradicting
* it. This is what the tri-state is for.
*/
it('starts checked when the tags already said the album is short', async () => {
stub(
'library.Library.GetFilePathsByRecordingMBIDs',
Object.fromEntries(
Array.from({ length: 9 }, (_, i) => [
`rec-${i + 1}`,
[`/music/0${i + 1}.mp3`],
]),
),
);
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 9)],
{ owned: 9, expected: 12, known: true, complete: false },
localTracks(9),
);
expect(shadowAll(el, '.track-row')).toHaveLength(12);
expect((scopeSwitch(el) as HTMLInputElement).checked).toBe(true);
});
/** And the user outranks it: turning it off asks for the files. */
it('lets the automatic answer be overridden', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 9)],
{ owned: 9, expected: 12, known: true, complete: false },
localTracks(9),
);
await toggle(el);
expect(shadowAll(el, '.track-row')).toHaveLength(9);
});
/**
* A control the accessibility tree cannot name is not a control, and
* this app has shipped that fault twice `wa-slider` pointed
* `aria-labelledby` at an empty internal label, and `config-field`
* rendered a `<label>` as a sibling with no `for`.
*
* `wa-switch` gets it right for a *different* reason than either:
* its `<input role="switch">` sits inside a native `<label>` that also
* holds the `<slot>`, so the name is computed across the flattened
* tree from light-DOM text. That is worth an assertion rather than an
* assumption and it has to be the browser's own answer, since
* querying shadow roots cannot compute a name.
*/
it('is named for anyone not looking at it', async () => {
await albumWith(
[release('rel-1', '2019-04-01', 12, 2)],
UNKNOWN,
localTracks(2),
);
await expect
.element(page.getByRole('switch', { name: 'Show the whole album' }))
.toBeInTheDocument();
});
describe('is absent where it could not change anything', () => {
it('when the album is entirely owned', async () => {
// Ten files, a ten-track release: the switch would redraw the
// same list, which reads as broken.
const el = await albumWith(
[release('rel-1', '2019-04-01', 10, 10)],
{ owned: 10, expected: 10, known: true, complete: true },
localTracks(10),
);
expect(scopeSwitch(el)).toBeNull();
});
it('when there is no catalog release to switch to', async () => {
const el = await albumWith([], UNKNOWN, localTracks(4));
expect(scopeSwitch(el)).toBeNull();
});
it('when the album is not in the library at all', async () => {
// Every entry here is already a catalog tracklist; there is no
// "only my tracks" to go back to.
const el = await albumWith([release('rel-1', '2019-04-01', 12)], UNKNOWN);
expect(scopeSwitch(el)).toBeNull();
});
});
});
+19 -2
View File
@@ -19,6 +19,11 @@ import {
update,
visual,
} from '@test/support/render';
import {
ICON_CAN_REQUEST,
ICON_IN_LIBRARY,
ICON_REQUESTED,
} from '@utils/icon-language';
describe('<app-sidebar>', () => {
it('renders a testid per destination, which is how e2e navigates', async () => {
@@ -154,9 +159,20 @@ describe('<library-status-indicator>', () => {
it('defaults to "not in library"', async () => {
const el = await fixture('library-status-indicator');
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('plus');
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe(ICON_CAN_REQUEST);
});
/**
* Named from the vocabulary rather than written out, or this test
* pins the glyphs *against* the table it is supposed to follow
* which is what it did: it asserted `plus` for the un-owned state,
* the same glyph two adjacent menu items were using for two other
* meanings, and passing was the reason nobody looked.
*
* What is still worth asserting is that the three differ, which is
* the property the states need and the one the table cannot state
* about itself here.
*/
it('uses a distinct glyph per state', async () => {
const glyphs: (string | null | undefined)[] = [];
@@ -166,7 +182,8 @@ describe('<library-status-indicator>', () => {
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
}
expect(glyphs).toEqual(['check', 'bookmark', 'plus']);
expect(glyphs).toEqual([ICON_IN_LIBRARY, ICON_REQUESTED, ICON_CAN_REQUEST]);
expect(new Set(glyphs).size).toBe(3);
});
it('phrases its label around the entity it describes', async () => {
@@ -0,0 +1,140 @@
/**
* The icon vocabulary is one table, and nothing writes around it.
*
* A wrong-but-real icon name renders perfectly: no error, no fallback,
* no failing assertion anywhere. That is how `plus` came to mean "add
* to the queue", "add to a playlist", "make a new playlist" and "you do
* not own this" the first two adjacent in the same context menu
* while `list` meant the queue, the Playlists destination *and* adding
* to the queue.
*
* `src/icons/index.ts` catches a name that is not *bundled*. Nothing
* catches a name that is bundled and means something else, so this
* sweeps the source for the governed ones. It is the same shape as
* `TestNoDirectRuntimeEmits` and `TestNoWritesOnTheReadPool` in the
* backend, and exists for the same reason: the rule is about every call
* site, so checking one is checking nothing.
*/
import { describe, expect, it } from 'vitest';
import { bundledIconNames } from '../../src/icons';
import * as icons from '@utils/icon-language';
/** Every component source, as text. */
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
eager: true,
query: '?raw',
import: 'default',
});
/**
* The names that carry a meaning the table owns.
*
* Deliberately not every bundled name. `check` is `ICON_IN_LIBRARY`
* here and also the "Copied" confirmation in `job-log-view`, which is
* a different, perfectly good meaning governing it would force a
* false rename. What belongs on this list is a name that was actually
* overloaded.
*/
const GOVERNED = [
'plus',
'list',
'bookmark',
'solid/bookmark',
'regular/bookmark',
'bars-staggered',
];
/** The one file allowed to say them, plus its own test. */
const DEFINITION = /icon-language\.(ts|test\.ts)$/;
describe('the icon vocabulary', () => {
/**
* A sweep over nothing passes. This is the assertion that makes the
* rest of the file mean something, and it is the first thing that
* breaks if the glob pattern stops matching after a move.
*/
it('actually reads the source', () => {
const paths = Object.keys(SOURCES);
expect(paths.length).toBeGreaterThan(100);
expect(paths.some((p) => p.endsWith('/track-list.ts'))).toBe(true);
expect(SOURCES[paths[0]!]).toContain('import');
});
it.each(GOVERNED)('is not written around for %s', (name) => {
const offenders: string[] = [];
for (const [path, source] of Object.entries(SOURCES)) {
if (DEFINITION.test(path)) continue;
// Both spellings: an icon in a template, and an icon name in a
// data table (which is how the sidebar and bottom-nav carry
// theirs).
const literal = new RegExp(
`(name="${name}"|icon: '${name}'|name=\\$\\{[^}]*'${name}')`,
);
if (literal.test(source)) offenders.push(path);
}
expect(offenders).toEqual([]);
});
/**
* A meaning with no icon behind it is the state the badge's `queued`
* spent a year in declared, styled, and produced by nothing.
*/
it('gives every meaning a name', () => {
const values = Object.entries(icons).filter(([k]) => k.startsWith('ICON_'));
expect(values.length).toBeGreaterThan(0);
for (const [key, value] of values) {
expect(`${key}=${value}`).toMatch(/^ICON_[A-Z_]+=[a-z]+[a-z/-]*$/);
}
});
/**
* Every name in the table is a name the app actually ships.
*
* This is the loop the vocabulary closes. A name that is not bundled
* renders a circled question mark and reports itself to
* `__yjIconMisses` at *runtime*, from a state something has to
* reach first. `bookmark-check` is Font Awesome **Pro**, and it was
* on `explore-artist-details`'s Follow button, drawn for every
* followed artist, invisible to `offline-icons.spec.ts` because no
* spec had ever followed one. Reaching the state is no longer how
* this is found.
*/
it('names only icons that are bundled', () => {
const bundled = new Set(bundledIconNames());
const missing = Object.entries(icons)
.filter(([k]) => k.startsWith('ICON_'))
.filter(([, v]) => !bundled.has(v as string))
.map(([k, v]) => `${k} (${v})`);
expect(missing).toEqual([]);
});
/**
* The two states of the request toggle have to be the same glyph in
* two weights, or they do not read as each other's opposite which
* is what a plus against a bookmark was.
*/
it('makes the request toggle an outline/solid pair', () => {
expect(icons.ICON_CAN_REQUEST).toBe(`regular/${icons.ICON_REQUESTED.replace('solid/', '')}`);
});
/**
* The queue and the Playlists destination wore the same icon, and
* "add to queue" and "add to playlist" sat next to each other wearing
* a third same one. Whatever the table says, these three have to
* differ from each other.
*/
it('keeps the queue, playlists and creating something apart', () => {
const three = [icons.ICON_QUEUE, icons.ICON_PLAYLIST, icons.ICON_NEW];
expect(new Set(three).size).toBe(3);
});
});
+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