Compare commits
219
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
772c71c49f | ||
|
|
ee1d8b3179 | ||
|
|
29feb4b94b | ||
|
|
4e667759c4 | ||
|
|
ff3875b55d | ||
|
|
76e1c444cc | ||
|
|
4f32d4e13c | ||
|
|
4f628b1f52 | ||
|
|
2100f0022f | ||
|
|
52038dc5ae | ||
|
|
0d331666d6 | ||
|
|
6a5a3c33dc | ||
|
|
1668b9e0d2 | ||
|
|
ec64dbded0 | ||
|
|
dad852a8a0 | ||
|
|
30c6b665f1 | ||
|
|
d034d6e571 | ||
|
|
25ea1f3511 | ||
|
|
842fe47e9e | ||
|
|
168e588387 | ||
|
|
7eb55bd378 | ||
|
|
f31331c83b | ||
|
|
6a22601af7 | ||
|
|
ce9951b93a | ||
|
|
99a45401c7 | ||
|
|
dd76bd2fa7 | ||
|
|
dee176c0f7 | ||
|
|
75a24f98b6 | ||
|
|
327785e5ec | ||
|
|
7ba5d321f6 | ||
|
|
f126dd7397 | ||
|
|
510d3470f9 | ||
|
|
d78830aa52 | ||
|
|
a72d1f68ed | ||
|
|
60f1c5a6b2 | ||
|
|
11ba7b3180 | ||
|
|
7f8e185d7c | ||
|
|
42483c4b61 | ||
|
|
deea6ad06d | ||
|
|
fba608fdbd | ||
|
|
f59490b113 | ||
|
|
6cca57f229 | ||
|
|
ea3edde697 | ||
|
|
14e3ab574c | ||
|
|
4b2eec5703 | ||
|
|
3871d37fdb | ||
|
|
09b005557c | ||
|
|
ef5574d18b | ||
|
|
31dafb0ce0 | ||
|
|
9e7e7ce5a1 | ||
|
|
9aaa8beb99 | ||
|
|
2e29e67664 | ||
|
|
f26b44db08 | ||
|
|
b43172a60c | ||
|
|
2be6fb3066 | ||
|
|
867ced8c81 | ||
|
|
fd71ef53c5 | ||
|
|
e3b64f9255 | ||
|
|
c7e5a4f086 | ||
|
|
f65822c4b2 | ||
|
|
32d4dc2c82 | ||
|
|
218e4f5e99 | ||
|
|
56a5ff99fe | ||
|
|
af4b28b0d7 | ||
|
|
4ee5b4b473 | ||
|
|
a70a7ed9eb | ||
|
|
de2cb2693a | ||
|
|
880adff12c | ||
|
|
d6f7412e9d | ||
|
|
1ab767a317 | ||
|
|
ac8f86eb00 | ||
|
|
47bd9ef211 | ||
|
|
b801fa533a | ||
|
|
8879192097 | ||
|
|
f76ee96ac4 | ||
|
|
23f5a0c53a | ||
|
|
502b814a65 | ||
|
|
c19a806298 | ||
|
|
67eeb75e7b | ||
|
|
fe1fbefee7 | ||
|
|
de04339494 | ||
|
|
998ce75fb6 | ||
|
|
4b392cb4c4 | ||
|
|
8d2109b87e | ||
|
|
b741b01cdf | ||
|
|
8a757c9bb4 | ||
|
|
d714bd7090 | ||
|
|
d64b069053 | ||
|
|
5490b2423e | ||
|
|
ffc9490a32 | ||
|
|
dc6625d33a | ||
|
|
dc8db159f9 | ||
|
|
86e7444603 | ||
|
|
2365806d18 | ||
|
|
7d348f243a | ||
|
|
ddd04623f7 | ||
|
|
9ad1477b1e | ||
|
|
70ab3ddf94 | ||
|
|
4f7529c315 | ||
|
|
bb21072386 | ||
|
|
ead1354e4d | ||
|
|
ae85df0dad | ||
|
|
6e7e349e63 | ||
|
|
f9ba9a87d7 | ||
|
|
e4efec6f0c | ||
|
|
99f355b2fc | ||
|
|
c79d4d47a3 | ||
|
|
8efed2dd2b | ||
|
|
12af6ec1f7 | ||
|
|
f3d1ae1c8c | ||
|
|
5af545e38d | ||
|
|
9da3967dd9 | ||
|
|
c8d94a8203 | ||
|
|
43d78a731a | ||
|
|
a3926704cc | ||
|
|
ea53d4f15b | ||
|
|
ea16e07c46 | ||
|
|
4f47c85208 | ||
|
|
603728a3fb | ||
|
|
018d857746 | ||
|
|
a7ac2b4a3e | ||
|
|
d347809e6e | ||
|
|
a5ffcc22e3 | ||
|
|
f18691560d | ||
|
|
c84a9069ef | ||
|
|
f967916550 | ||
|
|
3fa7c7734b | ||
|
|
cceeb40b16 | ||
|
|
2926ecd4b4 | ||
|
|
ff3c4003cb | ||
|
|
def596a99e | ||
|
|
14f78c0b57 | ||
|
|
7cea238e71 | ||
|
|
4f2f1827ab | ||
|
|
e454e4074b | ||
|
|
c518ac8c73 | ||
|
|
977f624123 | ||
|
|
23f3d4b3b0 | ||
|
|
8d46c4abb7 | ||
|
|
f714fe513d | ||
|
|
087c69ac8d | ||
|
|
bb7dde1963 | ||
|
|
446380e3a9 | ||
|
|
e07f248cc8 | ||
|
|
90ac6e0825 | ||
|
|
4e3c953acf | ||
|
|
ede183d026 | ||
|
|
481c9dca65 | ||
|
|
4025106234 | ||
|
|
a3134f997f | ||
|
|
3607fe445e | ||
|
|
61d549a9d5 | ||
|
|
2b84bc53e9 | ||
|
|
282dab43eb | ||
|
|
cc9df4004c | ||
|
|
b5d70ac1cd | ||
|
|
9118c16fe3 | ||
|
|
fe67849e57 | ||
|
|
21b303ba7c | ||
|
|
9375f25629 | ||
|
|
905654cc84 | ||
|
|
219fa3c615 | ||
|
|
c4e055ce51 | ||
|
|
10eca353ab | ||
|
|
88fc50afb8 | ||
|
|
19c68d73a7 | ||
|
|
41c41a860e | ||
|
|
4bf59b45b7 | ||
|
|
fc99d9e0d7 | ||
|
|
90f1239fba | ||
|
|
b2fe1cb1e0 | ||
|
|
065a879190 | ||
|
|
89882b4863 | ||
|
|
18a08daa91 | ||
|
|
aa59773d22 | ||
|
|
a4777f26b6 | ||
|
|
92faa9741b | ||
|
|
bf0a53e64c | ||
|
|
7be4a02e31 | ||
|
|
ad9c25a5a2 | ||
|
|
a83a127e31 | ||
|
|
4b9114fd8d | ||
|
|
e049a71458 | ||
|
|
0c944f2382 | ||
|
|
75525b67e4 | ||
|
|
85768dc489 | ||
|
|
1a221a40d3 | ||
|
|
0821deb877 | ||
|
|
31ada14111 | ||
|
|
20139394f3 | ||
|
|
eb139cf872 | ||
|
|
ae82fd2233 | ||
|
|
3c3197df4b | ||
|
|
e16bd245bd | ||
|
|
887a9324b4 | ||
|
|
fcb484ead5 | ||
|
|
48de41cd69 | ||
|
|
66a6ee63ab | ||
|
|
10660c8168 | ||
|
|
441b67daaa | ||
|
|
026f26bdf6 | ||
|
|
73dc80bdc9 | ||
|
|
760021ea5a | ||
|
|
63ec068add | ||
|
|
a2ff0aed4c | ||
|
|
12e75ee24c | ||
|
|
792e87298b | ||
|
|
266e7032dd | ||
|
|
d6b48fb3ac | ||
|
|
3bf27e3fd5 | ||
|
|
185eb1b125 | ||
|
|
b3556d825c | ||
|
|
bf4f352117 | ||
|
|
1062b7c0bc | ||
|
|
48abecb830 | ||
|
|
e1c07438e9 | ||
|
|
6e563f3846 | ||
|
|
186f6a5839 | ||
|
|
786d9c6110 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -128,12 +156,26 @@ jobs:
|
||||
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
|
||||
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
|
||||
|
||||
# **On the parent, not on HEAD.** The floor marks what has
|
||||
# already been released, so tagging the commit being pushed
|
||||
# leaves nothing between the floor and HEAD — semantic-release
|
||||
# then correctly reports there is nothing to release, which is
|
||||
# exactly what the first run of this workflow did. HEAD^ is the
|
||||
# first parent, so on the merge commit this fires for it is main
|
||||
# as it was before the merge, and everything the merge brought
|
||||
# in is releasable.
|
||||
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
|
||||
if [ -z "$floor" ]; then
|
||||
echo "HEAD has no parent, so no commit can precede the floor" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
|
||||
git tag v0.0.0 "${{ github.sha }}"
|
||||
git tag v0.0.0 "$floor"
|
||||
git push --quiet \
|
||||
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
|
||||
refs/tags/v0.0.0
|
||||
echo "seeded v0.0.0 at ${{ github.sha }}"
|
||||
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
|
||||
|
||||
# Pinned rather than installed into the repo: this is a Go project
|
||||
# and a package.json at its root invites the npm plugin and every
|
||||
@@ -149,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 \
|
||||
@@ -164,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"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Unclaim
|
||||
|
||||
# A `Closes #N` footer in a commit body closes the issue on merge — and
|
||||
# leaves `Status/In Progress` on it, because Gitea's auto-close touches
|
||||
# state and nothing else. So #100 was closed and simultaneously marked
|
||||
# as being actively worked on, and `scripts/issue.sh close` (which does
|
||||
# drop the label) is exactly the thing the footer exists to avoid
|
||||
# calling.
|
||||
#
|
||||
# **This hooks the close, not the merge.** Stripping the label in the
|
||||
# PR would work and would be a per-PR habit; habits are what the footer
|
||||
# removed. `issues: [closed]` covers every path an issue can close by —
|
||||
# the footer on merge, `issue.sh close`, someone clicking Close in the
|
||||
# web UI — and asks nothing of anyone at any of them.
|
||||
#
|
||||
# **Reopening deliberately does not restore it.** Reopening says the
|
||||
# work was not finished, not that somebody is at a keyboard doing it
|
||||
# now; the claim gets re-made by whoever picks it up.
|
||||
#
|
||||
# **This is not instant, and should not be described as it.** The
|
||||
# runner has capacity 1 and is shared with an index build that can hold
|
||||
# it for three hours, so a label tweak can queue behind one. Stale for
|
||||
# an afternoon beats stale forever, which is what it was.
|
||||
#
|
||||
# The audit that answers "is this still firing" stays in CLAUDE.md and
|
||||
# is one command:
|
||||
#
|
||||
# ./scripts/issue.sh list --state closed --label "Status/In Progress"
|
||||
#
|
||||
# A workflow that silently stops working is the failure mode this whole
|
||||
# area has already produced once.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
unclaim:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
|
||||
steps:
|
||||
- name: Drop the claim label
|
||||
# **Inside a container the act runner selects `sh`, not bash**, so
|
||||
# `set -o pipefail` fails the job on its second line with "Illegal
|
||||
# option" and the step never reaches the API. `homebrew-formula.yml`
|
||||
# carries the same `set -euo pipefail` without trouble because it
|
||||
# runs with **no container**, on the host image where bash is the
|
||||
# default — so "another workflow does it" is not evidence here.
|
||||
shell: bash
|
||||
env:
|
||||
# The automatic Actions token, as release.yml uses for the
|
||||
# floor tag. It needs no more than write access to this repo.
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# `ca-certificates` is named because `--no-install-recommends`
|
||||
# skips it, and `ubuntu:24.04` ships no CA bundle of its own —
|
||||
# so curl comes up unable to verify TLS against our own Gitea
|
||||
# and fails with "error setting certificate file" (exit 77).
|
||||
# Every other containerised workflow here spells it out for the
|
||||
# same reason; this one did not, and cost a release cycle.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl jq >/dev/null
|
||||
|
||||
label_id=$(
|
||||
curl -sSf -H "Authorization: token $TOKEN" "$API/labels?limit=100" |
|
||||
jq -r '.[] | select(.name == "Status/In Progress") | .id'
|
||||
)
|
||||
|
||||
# The label not existing is a repo somebody reorganised, not a
|
||||
# failure of this run — say so and stop, rather than failing a
|
||||
# job on every close from then on.
|
||||
if [ -z "$label_id" ]; then
|
||||
echo "unclaim: no 'Status/In Progress' label in this repo; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# DELETE is idempotent here: an issue that never carried the
|
||||
# label answers the same as one that did, which is what makes
|
||||
# this safe to run on *every* close rather than only the ones
|
||||
# that were claimed.
|
||||
# The body is captured, not discarded, so a refusal is
|
||||
# diagnosable from this log alone. Whether the automatic
|
||||
# token carries issue-write scope is still unproven, and
|
||||
# "DELETE returned 403" without Gitea's own sentence costs
|
||||
# another merge to find out which of the two it is.
|
||||
body=$(mktemp)
|
||||
code=$(
|
||||
curl -sS -o "$body" -w '%{http_code}' -X DELETE \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$API/issues/$ISSUE/labels/$label_id"
|
||||
)
|
||||
|
||||
case "$code" in
|
||||
204) echo "unclaim: #$ISSUE is closed and unclaimed" ;;
|
||||
*)
|
||||
echo "unclaim: DELETE returned $code for #$ISSUE" >&2
|
||||
cat "$body" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -88,3 +88,8 @@ build/android/overlay.json
|
||||
# Written by @semantic-release/changelog purely to carry the release notes
|
||||
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||
.release-notes.md
|
||||
|
||||
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
|
||||
# and .planning/). Written by the scheduled backlog runs.
|
||||
.pi/journal.md
|
||||
.pi/schedule-prompts.json
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
# Work log
|
||||
|
||||
Temporal memory: what happened and what's next. Structure lives in
|
||||
`CLAUDE.md`, operational instructions in `.pi/skills/yellowjacket-dev/`,
|
||||
measured discoveries in `.planning/NOTES.md`. Don't duplicate those here.
|
||||
|
||||
## Current state
|
||||
|
||||
Plan 005 (agent development harness) is **complete — all seven
|
||||
phases**. Everything from phase 1 onward is still **uncommitted**: one
|
||||
large but coherent working-tree diff, nothing pushed.
|
||||
|
||||
All four tiers verified green from a cold, cleaned state:
|
||||
`make ui-test` 313 passed, `make lint` 0 issues × 3 configurations,
|
||||
`make test` green × 3 passes, `make e2e` 19 passed. Both CI jobs
|
||||
verified green in a bare `ubuntu:24.04` container, including 19/19 on
|
||||
WebKit.
|
||||
|
||||
**Committed and pushed** as `5ca6cad` (the harness) + `ccacd67` (a CI
|
||||
fix), and **green on the real runner**: job `check` ~4 min, job `e2e`
|
||||
~3 min with 19/19 chromium *and* 19/19 webkit. One commit rather than
|
||||
seven because the working tree was the end state, not per-phase
|
||||
snapshots — `Makefile`, `CLAUDE.md` and `lefthook.yml` are touched by
|
||||
nearly every phase, so a split would have been fabricated history.
|
||||
|
||||
Still unverified, because no run has failed yet: the
|
||||
`actions/upload-artifact` step (`continue-on-error`, so it cannot mask
|
||||
a real failure) and whether pnpm honours `npm_config_store_dir` for
|
||||
store caching. Worth checking the next time a spec legitimately fails.
|
||||
|
||||
- [ ] `gitea_ci`'s `job_logs` returns 404 on Gitea 1.27.1 — the endpoint
|
||||
is not exposed. Logs come from the VPS instead: `zstdcat` the file
|
||||
under `gitea/actions_log/<owner>/<repo>/<xx>/<task_id>.log.zst`,
|
||||
and note `zstdcat` is not in the gitea container, so
|
||||
`docker cp` it out first. Job status is `action_run_job.status`
|
||||
(1 success, 2 failure, 4 skipped, 5 waiting, 6 running).
|
||||
Probably belongs in the `gitea` skill, not here.
|
||||
|
||||
Open items deliberately not fixed: WAV tags are write-only
|
||||
(`TestWAVTagsAreNotReadableYet`), `themeStore.loadFromBackend`'s failure
|
||||
handler cannot recover, `backend/playlist` has no CRUD suite.
|
||||
|
||||
## Log
|
||||
|
||||
### 2026-08-11 — cold skill run, then phase 7 (CI)
|
||||
|
||||
- **Followed the skill cold first**, as the last session asked. It
|
||||
works: app up from a wiped `.dev/`, an undocumented flow driven
|
||||
(queue panel + shuffle, asserted on `QueueModeChanged`), stopped —
|
||||
~1 minute, no dead ends. One real config bug: `outputDir` in
|
||||
`.playwright/cli.config.json` resolves against **cwd**, not the
|
||||
config file's directory (only `initScript` does that), so snapshots
|
||||
were landing above the repo and a *stale* one from the previous
|
||||
session answered `ls -t` instead. That cost a DOM walk to disprove a
|
||||
regression that did not exist. Four smaller doc gaps fixed
|
||||
(`sandbox-seed` already runs `testdata`; `ui-setup`/`e2e-setup` were
|
||||
undocumented prerequisites; `snapshot` prints a path; `dev-stop`
|
||||
leaves the browser open), plus `dev-headless.sh`'s own banner, which
|
||||
was suggesting the bare `window.go` call its next paragraph warns
|
||||
against.
|
||||
- **Built both CI jobs as container scripts before writing any YAML**,
|
||||
then transcribed the YAML back out and re-ran it to prove the
|
||||
transcription. Push-and-see is a bad loop on a self-hosted runner.
|
||||
- **It found a real bug immediately**: `make lint` omitted
|
||||
`webkit2_41` on all three passes, so it was linting configurations
|
||||
nothing builds. Invisible on Arch (which still ships
|
||||
`webkit2gtk-4.0.pc`), fatal on Ubuntu 24.04. Tag sets now match
|
||||
`make test`.
|
||||
- **Both open decisions settled by measurement**: ALSA `null` PCM for
|
||||
audio (no daemon; the elapsed clock really advances), dead-address
|
||||
stub for the explore artifact (and setting it for the *app* run, not
|
||||
just seeding, is worth 8x on suite wall clock). **WebKit is a
|
||||
required step** — it had never been run anywhere, so one throwaway
|
||||
container run replaced a coin flip with 19/19 at +11 s.
|
||||
|
||||
### 2026-08-10 — phase 6, pi affordances
|
||||
|
||||
- Added `.pi/skills/yellowjacket-dev/` as a directory rather than a flat
|
||||
file: only the description is always in context, so `SKILL.md` stays
|
||||
short enough that reading it whole is never a decision, and the deeper
|
||||
material sits in `references/{harness,fixtures,ui-tier,schema-change}.md`.
|
||||
- Settled the CLAUDE.md-vs-skill split **grammatically, not topically**,
|
||||
because a topical split is what rots — every new fact gets two
|
||||
plausible homes. Three docs, three tenses: NOTES.md is past
|
||||
(measured, dated, append-only), CLAUDE.md is present (what the system
|
||||
is), the skill is imperative (what to run). A new paragraph's tense
|
||||
decides where it goes.
|
||||
- The five gotchas (binding timeouts, first-run wizard, `pkill -f`,
|
||||
seeds-by-running, WebKit-is-CI-only) went **inline in SKILL.md**, not
|
||||
into a reference: you need them before the failure, not after.
|
||||
- Trimmed CLAUDE.md's "Fixtures and the headless harness" section by
|
||||
about half — the command sequences and gotchas it was carrying are now
|
||||
the skill's, and leaving both would have created exactly the duplicate
|
||||
description this repo has a standing rule against.
|
||||
- Added `make skill-check` / `scripts/skill-check.sh` + a pre-commit
|
||||
hook: every command in `.pi/**/*.md` must be a real `make` target, so
|
||||
the Makefile stays the source of truth for invocation and a renamed
|
||||
target fails a commit instead of misleading an agent later. Verified
|
||||
it fails (it caught its own not-yet-created target) and passes.
|
||||
- Added the `/e2e` prompt template: promoting a hand-driven
|
||||
`playwright-cli` session into a spec is a transcription with four
|
||||
fixed substitutions (refs → testids, sleeps → `waitForEvent`, raw
|
||||
`window.go` → `callBinding`, short fixture → `LONG_TRACK`), plus three
|
||||
runs — pass, pass again, pass after a DB restore — because the usual
|
||||
failure is a spec depending on state the hand-driving left behind.
|
||||
- One shell trap: under `set -euo pipefail`, `x="$(make -pqRr | …)"`
|
||||
fails the whole assignment, because `make -q` exits non-zero when a
|
||||
target is out of date and `pipefail` propagates it.
|
||||
|
||||
### Earlier
|
||||
|
||||
Phases 1–5 of plan 005: fixture generator and manifest, headless launch
|
||||
and seeds, the event bridge + `data-testid` pass + `backend/testctl` +
|
||||
`e2e/`, the Vitest component tier + `make bindings-check`, and the
|
||||
`events.Emit` wrapper with its in-process service-event tests. Recaps
|
||||
and the five "verified end to end" blocks are in
|
||||
`.planning/plans/active/005-agent-development-harness.md`; the lessons
|
||||
are in `.planning/NOTES.md`.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
description: Take on the next actionable backlog issue end to end, and stop
|
||||
---
|
||||
Take on exactly one issue from the YellowJacket backlog, end to end, and stop.
|
||||
|
||||
Repo: yonlu/yellowjacket at https://git.ljones.me — API base
|
||||
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket, auth with
|
||||
`-H "Authorization: token $GITEA_TOKEN"`. Default branch is `main`.
|
||||
|
||||
## 1. Orient before you pick
|
||||
|
||||
Read, in this order: `CLAUDE.md` (the architecture and the reasons behind
|
||||
it), `.pi/journal.md` (what happened last), `.planning/NOTES.md` (what was
|
||||
already considered and rejected), and `.planning/plans/active/`. Do not skip
|
||||
this because the issue looks small — most of this codebase's traps are
|
||||
written down in exactly one of those four places, and the ones that bite are
|
||||
the ones you didn't read.
|
||||
|
||||
## 2. Pick the issue
|
||||
|
||||
List open issues. Choose the single highest-value one that is *actionable
|
||||
right now*:
|
||||
|
||||
- Order by `Priority/Critical` → `High` → `Medium` → `Low`. Within a tier,
|
||||
prefer `Reviewed/Confirmed`, then `Kind/Bug` over `Kind/Enhancement` over
|
||||
`Kind/Feature`.
|
||||
- Consult issue #73 (the roadmap) — if it sequences the candidates, that
|
||||
ordering wins over the label ordering.
|
||||
- **Skip** anything labelled `Status/Blocked`, `Status/In Progress`,
|
||||
`Status/Abandoned`, `Reviewed/Won't Fix`, `Reviewed/Duplicate`,
|
||||
`Reviewed/Invalid`, or already carrying an open PR.
|
||||
- **Skip anything someone else is already on.** The label is not the only
|
||||
claim, because a concurrent session may not have applied it — several pi
|
||||
sessions run against this repo from separate worktrees under
|
||||
`~/.paseo/worktrees/`. Run `git ls-remote --heads origin` and skip any
|
||||
issue whose number or slug matches an existing branch (`60-…`,
|
||||
`fix/<slug>`). A duplicated fix costs more than a skipped issue.
|
||||
- **Skip** anything that cannot be verified without hardware you do not
|
||||
have: physical-device Android behaviour (audio output, on-device file
|
||||
writes, real gesture input). A browser at 424px is not a phone — see the
|
||||
Chrome 113 section of `CLAUDE.md`.
|
||||
- **Skip** intermittent-failure issues unless you can reproduce the failure
|
||||
on demand within a few minutes. Chasing a 1-in-3 flake is an unbounded
|
||||
task and does not belong in a scheduled run.
|
||||
- If nothing qualifies, say so, do nothing, and stop. An empty run is a
|
||||
correct outcome.
|
||||
|
||||
## 3. Claim it
|
||||
|
||||
Add `Status/In Progress` to the issue and comment that you are picking it
|
||||
up. Then branch:
|
||||
|
||||
```
|
||||
git fetch origin && git checkout -b <type>/<short-slug> origin/main
|
||||
```
|
||||
|
||||
`<type>` matches the issue's `Kind` (`fix/`, `feat/`, `refactor/`, `test/`,
|
||||
`docs/`, `ci/`). Branch from `origin/main`, never by checking out `main`
|
||||
itself — this repo is worked from several git worktrees at once and `main`
|
||||
is checked out in one of them, so `git checkout main` fails outright.
|
||||
|
||||
## 4. Do the work
|
||||
|
||||
Fix the issue that was reported and nothing else. Match the surrounding
|
||||
code's style. Follow the constraints in `CLAUDE.md` rather than reasoning
|
||||
from first principles — where it explains why something is shaped the way it
|
||||
is, that shape is load-bearing and there is usually a test pinning it.
|
||||
|
||||
**Anything else you discover becomes a new issue, not a bigger diff.** File
|
||||
it with the right `Area/`, `Kind/`, `Priority/` labels, describe the
|
||||
symptom before the theory, and link it from your PR. Scope creep is the
|
||||
failure mode this instruction exists to prevent.
|
||||
|
||||
If the work turns out to be materially larger than the issue implied, stop:
|
||||
comment on the issue with what you found and what it would actually take,
|
||||
remove `Status/In Progress`, push nothing, and end the run.
|
||||
|
||||
## 5. Verify — the right tier, not the cheapest one
|
||||
|
||||
Run `make generate` if you touched `.sql` or `.templ`, and `make bindings`
|
||||
if you changed a bound Go signature. Then run what the change actually
|
||||
demands:
|
||||
|
||||
- Go change → `make lint` and `make test` (both cover all three build
|
||||
configurations).
|
||||
- Frontend component or store → `make ui-test`.
|
||||
- User-visible flow → `make e2e` against `make dev-headless`. **Check the
|
||||
port first**: `ss -ltn | grep 34115`. If it is occupied, another worktree
|
||||
is already running the app — do not start a second one and do not run
|
||||
`make e2e`. Attaching to someone else's build produces a green result
|
||||
about code that is not yours, which is worse than no result. Either
|
||||
choose an issue that does not need this tier, or stop and say why.
|
||||
- Anything cosmetic or layout-related → look at a screenshot. Several bugs
|
||||
in this repo's history were invisible to every assertion and obvious in an
|
||||
image.
|
||||
|
||||
A tier you skipped is a claim you did not check. If a tier fails for reasons
|
||||
unrelated to your change, say so explicitly rather than quietly moving on.
|
||||
|
||||
## 6. Keep the documentation true
|
||||
|
||||
If you changed structure, behaviour, or a constraint, update `CLAUDE.md` in
|
||||
the same commit. That file is this project's memory; a change that leaves it
|
||||
describing the old shape is worse than no change. Append a short entry to
|
||||
`.pi/journal.md` covering what you did, what you verified, and what you left
|
||||
open.
|
||||
|
||||
## 7. Commit and open the PR
|
||||
|
||||
Conventional Commits, imperative subject, ≤72 chars, scope optional. The
|
||||
body explains *why*. Push the branch — never push to `main`, never
|
||||
force-push.
|
||||
|
||||
Open the PR:
|
||||
|
||||
```
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket/pulls \
|
||||
-d '{"head":"<branch>","base":"main","title":"<subject>","body":"<body>"}'
|
||||
```
|
||||
|
||||
The body states: what the issue was, what you changed and why, **which
|
||||
verification tiers you ran and their results**, anything you deliberately
|
||||
did not do, and `Closes #<n>`.
|
||||
|
||||
Then wait for CI (`ci.yml`, jobs `check` and `e2e`) and report the result on
|
||||
the PR. If it fails, read the log — `gitea_ci`'s `job_logs` 404s on this
|
||||
Gitea build, so use
|
||||
`GET /api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` for per-step
|
||||
status and `GET /api/v1/repos/yonlu/yellowjacket/actions/jobs/<id>/logs` for
|
||||
the log — and fix it. Two consecutive failed CI runs on the same cause: stop,
|
||||
comment what you know on the PR, and leave it for a human.
|
||||
|
||||
**Do not merge.** Comment on the issue linking the PR, leave
|
||||
`Status/In Progress` on, and end the run.
|
||||
|
||||
## Finally
|
||||
|
||||
Report in three lines: which issue you took, what state it is in
|
||||
(PR open / CI green / stopped and why), and any issues you filed.
|
||||
@@ -130,7 +130,13 @@ reference, because you need them *before* the failure, not after.
|
||||
what you otherwise get is `Property 'scroll' does not exist on type
|
||||
'CSSResult'` pointing at a line of prose, or every test in the suite
|
||||
failing to import. It went in after the trap cost a fourth session in
|
||||
which its own warning had been read twice.
|
||||
which its own warning had been read twice. **The same command carries
|
||||
a second CSS check**: a nested rule whose selector starts with an
|
||||
element name (`audio-player { … }` rather than `& audio-player { … }`)
|
||||
is silently dropped by the device's Chrome 113 and by nothing else, so
|
||||
every tier you can run renders it correctly. Run it after touching
|
||||
`index.css` or any `css` literal; a rule directly inside a top-level
|
||||
`@media` is not nested and is not flagged.
|
||||
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
|
||||
says it is not.** That endpoint 404s on this Gitea build. The REST
|
||||
API answers, with the `GITEA_TOKEN` already in the environment:
|
||||
@@ -195,6 +201,12 @@ Two rules about climbing:
|
||||
- **Do not write an e2e spec first.** Drive the flow by hand, then
|
||||
promote it with `/e2e`. Specs written blind assert on selectors that
|
||||
do not exist.
|
||||
- **Not every view has a nav item.** Since #25 the destinations are
|
||||
configurable, Autotag is hidden by default and Downloads is absent
|
||||
until a download client exists — so `getByTestId('nav-<view>')` waits
|
||||
30 s for a locator that will never resolve. `navigateTo(page, view)`
|
||||
(`e2e/support/fixtures.ts`) dispatches the app's own `navigate` event.
|
||||
Click the nav item when the *nav* is what the spec is about.
|
||||
|
||||
Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
|
||||
`make bindings-check`, `make css-check` and — from `frontend/` —
|
||||
|
||||
@@ -8,13 +8,29 @@ This tier answers "does the phone build run", nothing else. It is not a
|
||||
spec tier, it does not run in CI, and the app is not a usable Android
|
||||
player yet (plan 015 says why, at length).
|
||||
|
||||
## Three facts that make failure invisible
|
||||
## Two facts that make failure invisible
|
||||
|
||||
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
|
||||
`/dev/null`. Every `slog` line the app writes is discarded — including
|
||||
the one naming the error it is about to exit on. `setprop
|
||||
log.redirect-stdio true` does not help: it redirects the *Java*
|
||||
runtime's `System.out`, and the Go code is a c-shared native library.
|
||||
There were three. The first was that **Go's stdout does not reach
|
||||
logcat** — an Android app's fd 1 and 2 go to `/dev/null`, so every
|
||||
`slog` line the app wrote was discarded, including the one naming the
|
||||
error it was about to exit on. That is fixed (#160):
|
||||
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
|
||||
selected in `main()` by build tag, and the app's whole diagnostic
|
||||
stream now arrives under the `yellowjacket` tag, which `make
|
||||
android-logs` filters for.
|
||||
|
||||
What remains true about it is the part that misleads: **`setprop
|
||||
log.redirect-stdio true` still does not help**, because it redirects
|
||||
the *Java* runtime's `System.out` and the Go code is a c-shared native
|
||||
library. Nothing that reaches logcat here does so through stdout, so
|
||||
anything printed with `fmt.Println` is still lost. Log with `slog`.
|
||||
|
||||
The tag is a fixed string rather than the application id, and that is
|
||||
load-bearing rather than tidy: the debug build carries
|
||||
`applicationIdSuffix ".dev"` so it can be installed beside the release
|
||||
app, and it is the only build whose WebView can be inspected — so a tag
|
||||
derived from the id would be filtered out on the one build anybody
|
||||
debugging this app is running.
|
||||
|
||||
**`os.Exit` is a silent death.** `main()` ends several failure paths in
|
||||
`os.Exit(1)`. From Android's side that is a process that vanished:
|
||||
@@ -34,7 +50,10 @@ the wrong question. `make android-smoke` asks the right one — is it the
|
||||
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
|
||||
followed immediately by a new pid doing the same thing. That means the
|
||||
native library loaded, the JNI bridge came up, Go's `main()` ran, and
|
||||
`main()` left. Work backwards through its `os.Exit(1)` paths.
|
||||
`main()` left. Work backwards through its `os.Exit(1)` paths — and
|
||||
since #160, **read the `E/yellowjacket` line above it first**, because
|
||||
every one of those paths logs the error before it exits. That line is
|
||||
what #52 spent months without.
|
||||
|
||||
## What to run
|
||||
|
||||
@@ -194,9 +213,13 @@ like the app's fault and none is:
|
||||
|---|---|---|
|
||||
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||
| arm64, real device | — | unverified, still |
|
||||
| arm64, real device | **runs** (2026-08-20) | — |
|
||||
|
||||
**A physical arm64 device remains the only verification path.**
|
||||
**A physical arm64 device remains the only verification path**, and it
|
||||
has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34,
|
||||
arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs,
|
||||
launches and stays up; `make android-smoke SECONDS=60` passes on it.
|
||||
What that run *found* is the lifecycle fault below.
|
||||
|
||||
### What was fixed to get here
|
||||
|
||||
@@ -210,6 +233,11 @@ no-op. `backend/system` gained no import of the Wails application
|
||||
package, which matters for the same reason `backend/events` is split by
|
||||
the `indexbuild` tag.
|
||||
|
||||
**And `main()` is now latched to one run per process** (#52). That is
|
||||
the second `os.Exit(1)` in this file's history and it had the same
|
||||
signature as the first, which is the argument for #160: both were named
|
||||
exactly by an `slog` line that went to `/dev/null`.
|
||||
|
||||
### What is still not done
|
||||
|
||||
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||
@@ -249,10 +277,25 @@ one.
|
||||
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||
they are the right thing to reach for when you want something one-off:
|
||||
|
||||
> **These four were unsafe until #159 and are now the way in.** All of
|
||||
> them began with `adb uninstall {{.APP_ID}}`, where `APP_ID` defaulted
|
||||
> to `app.yellowjacket` — the **release** id — while `run` and
|
||||
> `run:device` build the **debug** variant, whose id is
|
||||
> `app.yellowjacket.dev`. So they uninstalled the user's app, taking
|
||||
> the library with it, installed a different package, and then failed
|
||||
> to launch the one they had removed.
|
||||
>
|
||||
> They share `scripts/android-deploy.sh` now, which **never**
|
||||
> uninstalls (`install -r`, and a changed signing certificate is
|
||||
> reported with the command rather than acted on), reads the package id
|
||||
> back out of the built APK, and refuses a target that is not the kind
|
||||
> the task names. There is nothing left to avoid; the manual sequence
|
||||
> below is kept because it is still the smallest thing that works.
|
||||
|
||||
```
|
||||
wails3 task android:run # debug build + emulator install + launch
|
||||
wails3 task android:run:device # same, first connected physical device
|
||||
wails3 task android:deploy-device # production APK to a device
|
||||
wails3 task android:run:device # debug build + install + launch on a phone
|
||||
wails3 task android:deploy-device # release build, same
|
||||
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||
wails3 task android:studio # open build/android/ in Android Studio
|
||||
wails3 task android:device:list
|
||||
@@ -260,6 +303,16 @@ wails3 task android:logs:all
|
||||
wails3 task android:clean
|
||||
```
|
||||
|
||||
**`run` and `deploy-emulator` mean the emulator, and now say so to
|
||||
adb.** They used a bare `adb install`, which with exactly one device
|
||||
attached picks that device whatever it is — so with a phone plugged in
|
||||
and no emulator running, the task whose summary reads "in the Android
|
||||
Emulator" installed on the phone. They pass `--target emulator` and
|
||||
refuse with `make android-emulator` as the remedy.
|
||||
|
||||
**`DEVICE_ID=<serial>` still names a device, and several attached
|
||||
devices is now an error rather than a silent pick of the first.**
|
||||
|
||||
Two are deliberately **not** wrapped. `android:logs` greps logcat for
|
||||
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
|
||||
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
|
||||
@@ -269,16 +322,51 @@ instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
|
||||
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||
sequenced.
|
||||
|
||||
## The identity is declared twice
|
||||
## The identity is read back from the APK
|
||||
|
||||
It used to be **declared twice**, and that is what #159 was.
|
||||
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
||||
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
|
||||
adb-driven task uninstalls, launches and filters. **Nothing enforces
|
||||
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
|
||||
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
|
||||
that file (verified with `--dry`), and even when set it feeds only the
|
||||
adb commands, never Gradle. Change both or the official `run`/`deploy`
|
||||
tasks address a package that is not installed.
|
||||
installs; `APP_ID` in `build/android/Taskfile.yml` was what every
|
||||
adb-driven task uninstalled, launched and filtered, and nothing
|
||||
enforced that they agree. They did not: the debug buildType carries
|
||||
`applicationIdSuffix ".dev"`, so every task that assembles a debug APK
|
||||
addressed the release id. This file flagged the hazard for five phases
|
||||
and it cashed out twice — once as a wrong `am start`, once as an
|
||||
uninstall of the user's library.
|
||||
|
||||
**`scripts/android-pkgid.sh` is the one answer now.** It prints the
|
||||
package id an APK declares (`aapt2 dump packagename`, falling back to
|
||||
`aapt dump badging`), and the deploy path installs and launches *that*.
|
||||
The APK is the authority because the task that installs it has just
|
||||
built it: whatever Gradle resolved the applicationId to, suffixes and
|
||||
flavours included, is in the file, and no default can disagree with it.
|
||||
An APK it cannot read is a hard failure, never a fallback to a written
|
||||
down default — guessing is the bug.
|
||||
|
||||
**`APP_ID` survives as an assertion, not a setting**, and has no
|
||||
default. `wails3 task android:run APP_ID=app.yellowjacket` says "this
|
||||
build had better declare that id" and is refused, naming both, *before*
|
||||
anything is installed or a device is even chosen. It could never have
|
||||
been a setting: `ANDROID.md`'s advice to put it in `build/config.yml`
|
||||
does not work in beta.8 — `wails3 task` never reads that file (verified
|
||||
with `--dry`) — and even when set it fed only the adb commands, never
|
||||
Gradle.
|
||||
|
||||
`scripts/android-emulator.sh` derives `PKG` the same way, from
|
||||
`bin/yellowjacket.apk` when one is built, so `make android-install`,
|
||||
`android-launch`, `android-logs` and `android-smoke` follow whichever
|
||||
variant is actually in `bin/`. `YJ_ANDROID_PKG` still overrides, and
|
||||
the old literal survives only for a tree with no APK built yet.
|
||||
|
||||
**The uninstall is gone and is not coming back.** It existed to make
|
||||
the bare `install` on the next line work at all — without `-r` Android
|
||||
refuses an install over an existing package — so `install -r` removes
|
||||
the *reason* for it rather than merely removing it. What is left is the
|
||||
one case an uninstall really is the remedy, a changed signing
|
||||
certificate, and that is exactly the case where performing it silently
|
||||
costs the user their library. So it is named and not done, which is the
|
||||
answer `scripts/android-emulator.sh` had already reached for
|
||||
`make android-install`.
|
||||
|
||||
Related, and it will bite once: the launcher activity is
|
||||
`com.wails.app.MainActivity` and the applicationId is
|
||||
@@ -287,6 +375,27 @@ resolves the leading dot against the *applicationId* and fails with a
|
||||
class-not-found that reads like a broken build. Always the
|
||||
fully-qualified form.
|
||||
|
||||
**`wails3 task android:run:device` is the way to put a debug build on a
|
||||
real device**, since #159. What #52 used, before it was safe, was the
|
||||
longer form, and it is still the smallest thing that works if you want
|
||||
no script between you and adb:
|
||||
|
||||
```bash
|
||||
wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
|
||||
adb install -r bin/yellowjacket.apk # -r, never uninstall
|
||||
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity
|
||||
```
|
||||
|
||||
The id in that last line is the one thing to keep an eye on by hand —
|
||||
`./scripts/android-pkgid.sh bin/yellowjacket.apk` is what the tasks ask,
|
||||
and it is a good habit before any `am start` written out in full.
|
||||
|
||||
`YJ_ANDROID_PKG=app.yellowjacket.dev` still overrides what
|
||||
`scripts/android-emulator.sh` — and therefore `make android-smoke`,
|
||||
`android-logs`, `android-launch` — addresses, but it is rarely needed
|
||||
now: that default is read from `bin/yellowjacket.apk`, so it already
|
||||
follows whichever variant was built last.
|
||||
|
||||
## What only a device can answer
|
||||
|
||||
The emulator cannot run this app (three separate reasons, none of them
|
||||
@@ -311,6 +420,91 @@ system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
**The third such fault was the activity lifecycle** (#52), and it is
|
||||
the one to re-check after touching `main()`, `WailsBridge` or
|
||||
`MainActivity`. Android destroys and recreates an activity **without
|
||||
restarting the process**, and Wails' `nativeInit` — which
|
||||
`MainActivity.onCreate` calls — runs `go mainFunc()` every time. So
|
||||
Go's `main()` ran again on a live app, `app.Run()` refused (`a.starting`
|
||||
is still true behind Android's `select{}`), and the `os.Exit(1)` under
|
||||
it took the healthy first app down with it.
|
||||
|
||||
### The lifecycle check, and how to trigger it on demand
|
||||
|
||||
This is the regression guard for #52 on this tier, because no other
|
||||
tier runs `main()` on Android at all. The Go-side guard
|
||||
(`TestMainClaimsBeforeItDoesAnything`) catches work creeping above the
|
||||
latch; only the device catches the latch not working.
|
||||
|
||||
**Trigger a relaunch with a configuration change the manifest does not
|
||||
declare.** `AndroidManifest.xml` lists
|
||||
`orientation|screenSize|keyboardHidden|uiMode`, so those are handled
|
||||
in-place and are *not* triggers. `fontScale` is not listed, and it is a
|
||||
one-liner:
|
||||
|
||||
```bash
|
||||
adb shell settings put system font_scale 1.15 # restore the old value after
|
||||
```
|
||||
|
||||
That is the same in-process destroy/recreate that "Don't keep
|
||||
activities", a locale change and a memory trim produce, but on demand.
|
||||
|
||||
**"Don't keep activities" is the report's own lever and did not work on
|
||||
this device**: `settings put global always_finish_activities 1` reads
|
||||
back as `1`, `am set-always-finish-activities` does not exist on this
|
||||
build, and the activity was never finished on backgrounding. Do not
|
||||
spend an afternoon on it; use the config change.
|
||||
|
||||
**The assertion is the pid, and the tell is two bridge inits in one.**
|
||||
|
||||
```bash
|
||||
adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"
|
||||
```
|
||||
|
||||
Healthy is one pid appearing twice — the process surviving the
|
||||
recreation:
|
||||
|
||||
```
|
||||
I/WailsBridge(28420): Wails bridge initialized
|
||||
I/WailsBridge(28420): Wails bridge initialized <- same pid, recreated
|
||||
```
|
||||
|
||||
Broken is that pair followed within a second by:
|
||||
|
||||
```
|
||||
I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
|
||||
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
|
||||
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state
|
||||
```
|
||||
|
||||
Two things about reading that. **`has died: fg TOP` is not a memory
|
||||
kill** — the system does not reclaim the foreground process, so this is
|
||||
the app leaving of its own accord. And there is **no crash record
|
||||
anywhere**: `logcat -b crash` is empty, no `AndroidRuntime`, no
|
||||
`libc: Fatal signal`, no tombstone. That is the `os.Exit` signature,
|
||||
and it is why "the system killed it" is the wrong first hypothesis.
|
||||
|
||||
**Surviving is only half of it — check the recreated WebView is still
|
||||
wired to the running app.** A plausible-looking fix (making
|
||||
`WailsBridge.initialized` static, so the second `nativeInit` is skipped)
|
||||
keeps the process alive and silently breaks this, because `nativeInit`
|
||||
is also what re-points the JNI reference at the new bridge. Go would go
|
||||
on executing JavaScript against the destroyed activity's WebView: the
|
||||
app opens, renders, and never receives another backend event.
|
||||
|
||||
Ask the page, after a relaunch and a resume:
|
||||
|
||||
```bash
|
||||
make android-inspect
|
||||
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
|
||||
# background and foreground the app, then:
|
||||
make android-eval EXPR='JSON.stringify(window.__probe)'
|
||||
```
|
||||
|
||||
A healthy build answers with events from the live services —
|
||||
`["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]`.
|
||||
`[]` means the bridge reference is stale.
|
||||
|
||||
## Asking the device, not just looking at it
|
||||
|
||||
A real phone can be inspected, and that turns this tier from "reported
|
||||
@@ -340,6 +534,130 @@ Four things about it, each of which costs an hour if met cold:
|
||||
script. Plug in over USB for anything longer than a couple of probes.
|
||||
- **The socket name carries the pid**, which changes on every launch, so
|
||||
it is resolved rather than remembered.
|
||||
- **A reinstall resets the runtime permissions**, and the grant dialog
|
||||
is a separate activity that takes focus — so the app is up, `am start`
|
||||
reports "delivered to currently running top-most instance", and
|
||||
`pidof` is empty because it never got to the foreground.
|
||||
`dumpsys window | grep mCurrentFocus` naming
|
||||
`GrantPermissionsActivity` is the tell. `adb shell pm grant
|
||||
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
|
||||
`POST_NOTIFICATIONS`) ahead of the launch skips it.
|
||||
|
||||
### Getting the app into a state worth measuring
|
||||
|
||||
A fresh install is **not** a neutral starting point, and three things
|
||||
about it will each cost you a measurement.
|
||||
|
||||
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
|
||||
`dev-headless.sh` and in CI and is *real* here, so the app spends its
|
||||
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
|
||||
screen** while it does. Every vertical number taken in that state is
|
||||
wrong -- one #51 measurement had the album art at 0px and it was
|
||||
entirely this.
|
||||
|
||||
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
|
||||
cleanly. **It then starts again within seconds.** So stop it
|
||||
*immediately before* the measurement rather than once at the beginning,
|
||||
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
|
||||
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
|
||||
that otherwise keep the band open.
|
||||
|
||||
**A library added over the bridge does not dismiss the first-run
|
||||
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
|
||||
checks for an existing library once, on mount, and its "Get Started"
|
||||
button gates on a directory chosen *in the wizard* -- so it stays up
|
||||
with a correctly disabled button over everything you are trying to
|
||||
measure. Nothing is broken; reload the page and it is gone. This reads
|
||||
exactly like a tap being swallowed, which is the expensive part.
|
||||
|
||||
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
|
||||
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
|
||||
`AddLibrary` takes the plain path; a push into
|
||||
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
|
||||
there. Some builds additionally want
|
||||
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
|
||||
the app opens the *system* "All files access" screen on launch -- so
|
||||
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
|
||||
that, not a crash.
|
||||
|
||||
### A note on quoting `make android-eval`
|
||||
|
||||
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
|
||||
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
|
||||
snippet containing a string literal -- breaks in a way that reads as a
|
||||
JavaScript error. Put the expression in a file and pass it positionally:
|
||||
|
||||
```bash
|
||||
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
|
||||
```
|
||||
|
||||
That is the same script `make android-eval` wraps, so nothing is lost.
|
||||
Two things worth knowing about it: it does **not** await a promise, so
|
||||
an async call has to park its result (`window.__r = ...`) and be read
|
||||
back in a second eval; and the shim from the section below is lost on
|
||||
every reload and every app restart, along with the devtools socket,
|
||||
whose name carries the pid.
|
||||
|
||||
### Calling a binding on the device
|
||||
|
||||
**The runtime call does not go over HTTP on Android**, and this is worth
|
||||
knowing before an hour is spent on it. The WebView cannot deliver a
|
||||
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
|
||||
calls through the `addJavascriptInterface` bridge instead: the
|
||||
@wailsio/runtime installs a `customTransport` that calls
|
||||
`window.wails.invokeAsync(id, payload)` and receives the answer on
|
||||
`window._wailsAndroidCallback`. Two consequences:
|
||||
|
||||
- **`.playwright/init-events.js` does not transfer to the device.** Its
|
||||
outbound half hooks `fetch`, which sees nothing here, and its
|
||||
`call()` posts to `/wails/runtime`, which answers
|
||||
`Invalid runtime call: missing object value` — the interceptor got the
|
||||
URL with no body. Its *inbound* half is still right, because
|
||||
`dispatchWailsEvent` is the entry point in every mode.
|
||||
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
|
||||
the bundle captured its reference at module scope, so a wrapper
|
||||
installed afterwards records nothing. That is why the harness is an
|
||||
`initScript` and not a step in a spec.
|
||||
|
||||
What works is to borrow the bridge, chaining the runtime's own callback
|
||||
so its pending calls still resolve:
|
||||
|
||||
```js
|
||||
const pending = new Map();
|
||||
const prev = window._wailsAndroidCallback;
|
||||
window._wailsAndroidCallback = (id, response, error) => {
|
||||
if (!pending.has(id)) return prev && prev(id, response, error);
|
||||
const p = pending.get(id); pending.delete(id);
|
||||
const env = JSON.parse(response || "{}");
|
||||
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
|
||||
};
|
||||
window.__yj = { call(name, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = "yj" + Math.random().toString(36).slice(2);
|
||||
pending.set(id, { resolve, reject });
|
||||
window.wails.invokeAsync(id, JSON.stringify({
|
||||
object: 0, method: 0, windowName: "",
|
||||
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
|
||||
clientId: window._wails.clientId,
|
||||
}));
|
||||
});
|
||||
} };
|
||||
```
|
||||
|
||||
That turns the device into a tier that can be *driven* rather than only
|
||||
looked at — `__yj.call("player.Player.LoadFile", [path])` and
|
||||
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
|
||||
#53 was measured. Names are the Go ones (`GetTracks`, not
|
||||
`GetAllTracks`); an unknown one comes back as a plain
|
||||
`unknown bound method name`, so a wrong guess is loud.
|
||||
|
||||
**Getting audio onto the phone**: `adb push` into
|
||||
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
|
||||
files are not there — scoped storage. `/sdcard/Music/...` plus
|
||||
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
|
||||
plain path. The generated fixtures are **~2 seconds** each, which is
|
||||
fine for a scan and useless for watching a seek bar, so synthesise a
|
||||
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
|
||||
|
||||
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||
|
||||
+1467
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ reviews. Nothing was changed.
|
||||
|
||||
Findings below are numbered `H-n` (hands-on) and cross-reference the
|
||||
static reports where they overlap. The reconciliation plan built from
|
||||
all four files is `.planning/plans/pending/007-ui-reconciliation.md`.
|
||||
all four files is `.planning/plans/completed/007-ui-reconciliation.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
# 017 — Releases that happen by themselves
|
||||
|
||||
> **Status: built, not yet run.** Phases 0–4 have landed on this branch;
|
||||
> phase 5 is the merge itself and cannot be done until then. The old
|
||||
> `v1.x` tags are already deleted from `origin`. Verified locally against
|
||||
> a scratch remote: semantic-release computes **0.0.1** from these
|
||||
> commits and renders correct sectioned notes.
|
||||
>
|
||||
> **One thing found by testing that no amount of reading would have
|
||||
> caught.** `conventional-changelog-conventionalcommits@10` — the current
|
||||
> release, and my first pin — is silently incompatible with the writer
|
||||
> `release-notes-generator@14` depends on: the version is right, the tag
|
||||
> is right, every step reports success, and the release body is a bare
|
||||
> `## 0.0.1 (date)` heading with **nothing under it**. It is pinned to 9
|
||||
> in both `release.yml` and `make release-dry`, with the reason written
|
||||
> beside it. Four of my seven original pins were wrong majors besides;
|
||||
> they were guesses, and `npm view` was the fix.
|
||||
|
||||
The goal in one sentence: **a merge to `main` computes the next version
|
||||
from the commits it contains, cuts a tag and a Gitea release whose body
|
||||
is the changelog, and every publishing channel builds that tag.** The
|
||||
first release under this scheme is `v0.0.1`, and the five existing `v1.x`
|
||||
tags go.
|
||||
|
||||
## What is there now
|
||||
|
||||
Measured, not remembered:
|
||||
|
||||
- **Five tags and zero releases.** `v1.3.0`, `v1.4.0`, `v1.4.1`,
|
||||
`v1.5.0`, `v1.6.0` exist on `origin`;
|
||||
`GET /api/v1/repos/yonlu/yellowjacket/releases` returns `[]`. So there
|
||||
is no release page to preserve and nothing but the tags to remove.
|
||||
- **`CHANGELOG.md` is stale and belongs to another repo.** Its newest
|
||||
entry is `1.3.0` and every link in it points at
|
||||
`github.com/onion-4-dinner/yellowjacket` — it was written by a
|
||||
semantic-release run against a GitHub remote this project no longer
|
||||
has.
|
||||
- **`.releaserc.yml` is a complete semantic-release config that nothing
|
||||
invokes**, which CLAUDE.md already says in as many words.
|
||||
- **Root `package.json` is literally `{}`** — the stub left behind by
|
||||
whatever was going to run it.
|
||||
- The triggers today are: `arch-package` on **push to `main`**,
|
||||
`homebrew-formula` on **`v*`**, `android-apk` on **`v*`**, `ci` on
|
||||
every branch, `index-artifact` on cron/dispatch. So Arch publishes a
|
||||
`git describe` version on every merge and the other two publish only
|
||||
when a human remembers to push a tag.
|
||||
|
||||
## Decision 1 — semantic-release, with `exec` in place of the `github` plugin
|
||||
|
||||
**Revised: the first draft of this plan proposed a shell script and the
|
||||
argument for it does not hold.** Recorded here rather than deleted,
|
||||
because the reasoning is what the decision rests on.
|
||||
|
||||
What I said, and what checking it showed:
|
||||
|
||||
- *"The two plugins that would carry the work do not fit."* Half true.
|
||||
`@semantic-release/github` genuinely does not speak Gitea's `/api/v1`
|
||||
— but the replacement is **`@semantic-release/exec`**, which is
|
||||
first-party, published 2026-06, and peer-deps `semantic-release >=24.1`.
|
||||
Its `publishCmd` is one `curl` at the Gitea release endpoint with
|
||||
`${nextRelease.notes}` as the body. The Gitea-shaped part of this is
|
||||
five lines, and the part I proposed to hand-roll — parsing conventional
|
||||
commits, ordering semver, rendering grouped notes — is the part with
|
||||
the edge cases and none of it is Gitea-shaped at all.
|
||||
- *"`@semantic-release/git` commits the changelog back to `main`, which
|
||||
re-triggers everything."* True, and it is the one real risk — but it
|
||||
is a two-line guard (skip the job when `HEAD`'s subject is
|
||||
`chore(release):`), not a reason to write a version calculator. That
|
||||
guard is needed under **either** design, since either one writes a
|
||||
changelog commit.
|
||||
- *"A Node dependency tree at the root of a Go repo."* The commitlint
|
||||
precedent does not transfer. commitlint was a dependency to regex one
|
||||
line; this is a dependency to do something with real complexity, it is
|
||||
`npx`-only so nothing lands in the repo, and Node is already installed
|
||||
in CI for the frontend.
|
||||
- *"It cannot be told to produce `0.0.1`."* Wrong — that is a property
|
||||
of which commits are in the range, not of the tool. Identical under
|
||||
both designs. See below.
|
||||
|
||||
Note also that **`@saithodev/semantic-release-gitea` is a dead end** and
|
||||
should not be reached for: last published 2022, depends on `got@10` and
|
||||
`fs-extra@8`, and declares no peer dependency on semantic-release at all
|
||||
— i.e. it is untested against anything since v19, against a core now at
|
||||
v25. `exec` + `curl` is both simpler and maintained.
|
||||
|
||||
So `.releaserc.yml` stays, and its plugin list becomes five **first-party**
|
||||
plugins, all published within the last six months:
|
||||
|
||||
| plugin | job |
|
||||
| --- | --- |
|
||||
| `commit-analyzer` | the version |
|
||||
| `release-notes-generator` | the notes |
|
||||
| `changelog` | writes `CHANGELOG.md` |
|
||||
| `git` | commits it back |
|
||||
| `exec` | `curl`s the Gitea release |
|
||||
|
||||
The `releaseRules` and `presetConfig` blocks already in the file are
|
||||
kept verbatim — they are the same bump table `commit-check.sh` already
|
||||
enforces the grammar for, and nothing about the project's commit
|
||||
convention changes.
|
||||
|
||||
Two mechanical details that decide whether this works at all:
|
||||
|
||||
- **semantic-release pushes the tag itself**, as core behaviour, using
|
||||
`repositoryUrl`. The remote here is `ssh://git@git.ljones.me:2222/…`,
|
||||
which would need an SSH key in CI — so the run passes
|
||||
`--repository-url "https://x-access-token:$PACKAGE_TOKEN@git.ljones.me/yonlu/yellowjacket.git"`
|
||||
on the command line rather than committing a token to the config.
|
||||
**That is also what satisfies Decision 2**: the tag push is attributed
|
||||
to a real user, not to the Actions token.
|
||||
- **The empty root `package.json` (`{}`) goes.** semantic-release does
|
||||
not need one when `--repository-url` is explicit, and leaving a
|
||||
package manifest at the root of a Go repo invites the npm plugin and
|
||||
every tool that looks for one.
|
||||
|
||||
Invocation is pinned in the workflow, not installed into the repo:
|
||||
|
||||
```
|
||||
npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@14 \
|
||||
-p @semantic-release/release-notes-generator@15 \
|
||||
-p @semantic-release/changelog@6 \
|
||||
-p @semantic-release/git@10 \
|
||||
-p @semantic-release/exec@7 \
|
||||
-p conventional-changelog-conventionalcommits@9 \
|
||||
semantic-release --repository-url "…"
|
||||
```
|
||||
|
||||
(Exact majors get pinned from `npm view` at implementation time;
|
||||
`conventional-changelog-conventionalcommits` is in the list because both
|
||||
the analyzer and the notes generator name that preset and neither
|
||||
depends on it.)
|
||||
|
||||
## Decision 2 — how the publish workflows learn about the tag
|
||||
|
||||
**Gitea, like GitHub, does not start a workflow from a tag pushed by a
|
||||
workflow's own token** (go-gitea#33123, and the forum thread it points
|
||||
at). This is the one load-bearing unknown in the plan.
|
||||
|
||||
The remedy is to push the tag with a *user* PAT — `secrets.PACKAGE_TOKEN`
|
||||
is already in this repo and already used by `arch-package` and
|
||||
`android-apk` to clone and to publish — so the push is attributed to a
|
||||
person and the `v*` triggers fire normally. That keeps the three publish
|
||||
workflows completely unchanged in shape.
|
||||
|
||||
**It is verified in phase 5, not assumed.** The fallback, if it does not
|
||||
fire, is an explicit `POST
|
||||
/api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches` per
|
||||
channel from the release job. That needs `workflow_dispatch` (with a
|
||||
`version` input) added to `homebrew-formula.yml` and `arch-package.yml`;
|
||||
`android-apk.yml` already has both. **Add those inputs in phase 3
|
||||
regardless** — a hand-triggered rebuild of one channel is worth having
|
||||
whether or not the fallback is needed.
|
||||
|
||||
The alternative — one `release.yml` with the three publishes as
|
||||
`needs:` jobs — is rejected: it means either copying ~400 lines of
|
||||
Android and Arch setup into it or relying on `workflow_call`, and it
|
||||
puts every merge to `main` behind an up-to-60-minute Android build on a
|
||||
runner with capacity 1.
|
||||
|
||||
## Decision 3 — 1.6.0 → 0.0.1 is a downgrade, and the answer is reinstall
|
||||
|
||||
**Decided: no version-code offset, no epoch. The version number stays
|
||||
honest and existing installs are replaced by hand.** Every channel is a
|
||||
downgrade and each declines differently, so what to expect:
|
||||
|
||||
- **Arch: no upgrade is offered, silently.** `pkgver()` derives from
|
||||
`git describe`, so after the wipe it reads `0.0.1.rN.gHASH`, which
|
||||
pacman orders *below* the `1.3.0.rN.*` in the registry. `pacman -R
|
||||
yellowjacket && pacman -S yellowjacket` is the remedy. (`epoch=1` in
|
||||
the PKGBUILD would have avoided it for one line — but an epoch can
|
||||
never be removed, and it puts a permanent `1:` in front of every
|
||||
version string this project will ever have.)
|
||||
- **Homebrew: no upgrade is offered, silently.** Brew has no epoch at
|
||||
all. `brew uninstall yellowjacket && brew install …`.
|
||||
- **Android: a hard refusal.** `versionCode` is
|
||||
`maj*10000 + min*100 + pat`, so `0.0.1` is **1** against the **10300**
|
||||
an installed 1.3.0 carries, and the install fails with
|
||||
`INSTALL_FAILED_VERSION_DOWNGRADE`. Uninstall first — **and that takes
|
||||
the app's library and config with it**, which is the same data loss
|
||||
`android-apk.yml`'s keystore guard exists to prevent, arrived at from
|
||||
the other direction. The workflow's own `code -le 0` guard still passes
|
||||
at 1, so nothing in CI stops or warns about this.
|
||||
|
||||
All three go in the release notes for `v0.0.1` and in
|
||||
`packaging/homebrew/README.md` / `docs/android-release.md`, because a
|
||||
channel that silently offers no upgrade is indistinguishable from a
|
||||
broken pipeline six months from now.
|
||||
|
||||
## Landing exactly `v0.0.1`
|
||||
|
||||
Determinism comes from two things:
|
||||
|
||||
1. **Seed `v0.0.0` on `6fb7b5e`** (current `origin/main`) after wiping
|
||||
the old tags. That is the floor, and the analyser's range starts
|
||||
there.
|
||||
2. **This branch carries no `feat:` commit.** Everything in it is
|
||||
`ci:`/`docs:`/`chore:`/`build:`, plus at least one `fix:` — which is
|
||||
honest, since wiring up release machinery that was configured and
|
||||
never run *is* a fix. One patch-level commit in `v0.0.0..HEAD`
|
||||
computes `0.0.1` and nothing else can.
|
||||
|
||||
This is a property of the commit range, not of the tool — it would have
|
||||
been the same constraint under the shell script.
|
||||
|
||||
This is a real constraint on the branch, not an accounting trick: a
|
||||
single `feat:` commit here makes the first release `v0.1.0`.
|
||||
|
||||
`v0.0.0` itself gets no release object — it is a floor, not a shipment.
|
||||
|
||||
## Decision 4 — what the release page carries
|
||||
|
||||
Four artifacts, and the fourth is the interesting one. Measured on this
|
||||
machine rather than assumed:
|
||||
|
||||
| asset | built by | state |
|
||||
| --- | --- | --- |
|
||||
| `yellowjacket-<v>-android-arm64.apk` | `android-apk.yml` | already built, verified, signed |
|
||||
| `yellowjacket-<v>-linux-amd64.tar.gz` | new job | binary + `.desktop` + icon |
|
||||
| `yellowjacket-<v>-x86_64.pkg.tar.zst` | `arch-package.yml` | already built; free to attach |
|
||||
| `yellowjacket-<v>-windows-amd64.zip` | new job | **compiles; has never been run** |
|
||||
|
||||
**macOS cannot be one of them.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||
`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin
|
||||
backend is Objective-C behind cgo, so a `.app` needs a macOS host and
|
||||
the runner is a Linux container. That is precisely why the Homebrew
|
||||
channel builds from source on the user's own Mac, and it stays the
|
||||
answer for macOS.
|
||||
|
||||
**Windows is newly possible and should be labelled honestly.**
|
||||
`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -tags production`
|
||||
succeeds in 2.5 s and produces a 40 MB `.exe` — nothing in the audio,
|
||||
database or webview path needs cgo on Windows (oto uses WinMM through
|
||||
`x/sys`, sqlite is modernc's pure-Go driver, WebView2 is COM syscalls,
|
||||
and MPRIS is `linux && !android`-tagged). But **compiling is not
|
||||
running**: no Windows build of this app has ever been started, no CI tier
|
||||
can exercise one, and `backend/system`'s `%LOCALAPPDATA%` path has never
|
||||
resolved on a real machine. It ships marked as untested in the release
|
||||
notes, or it does not ship — an unlabelled Windows download is a promise
|
||||
nothing here can keep.
|
||||
|
||||
### The race the ordering creates
|
||||
|
||||
semantic-release runs **prepare** (changelog commit, tag push) before
|
||||
**publish** (the `exec` curl that creates the release object). The tag
|
||||
push is what starts the publishing workflows — so a fast one can reach
|
||||
its upload step *before the release exists*, and
|
||||
`POST /releases/{id}/assets` needs an id.
|
||||
|
||||
The capacity-1 runner serialises things enough that this would usually
|
||||
work, which is the worst kind of bug. So each upload step **polls
|
||||
`GET /api/v1/repos/…/releases/tags/{tag}` with a bounded retry** before
|
||||
uploading, and fails loudly on timeout rather than skipping the asset.
|
||||
That is ~8 lines of shell, shared by all three publishers.
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase 0 — clear the ground.**
|
||||
Delete `v1.3.0`–`v1.6.0` locally and on `origin`; push `v0.0.0` at
|
||||
`6fb7b5e` — this is the floor semantic-release reads, and without it the
|
||||
first release is `1.0.0` by its own rule. Delete the empty root
|
||||
`package.json`. Truncate `CHANGELOG.md` to a header plus a line saying
|
||||
history before `0.0.1` is in `git log` — the existing content is another
|
||||
repo's links and cannot be repaired, only replaced, and the `changelog`
|
||||
plugin prepends to whatever it finds.
|
||||
|
||||
**Phase 1 — `.releaserc.yml`.**
|
||||
Swap `@semantic-release/github` for `@semantic-release/exec`, whose
|
||||
`publishCmd` POSTs to
|
||||
`/api/v1/repos/yonlu/yellowjacket/releases` with `tag_name`, `name` and
|
||||
`body` taken from `${nextRelease.*}`. Keep `commit-analyzer`,
|
||||
`release-notes-generator`, `changelog` and `git` exactly as written; fix
|
||||
the `git` plugin's commit message so it passes `commit-check`
|
||||
(`chore(release): ${nextRelease.version}` — the existing one already
|
||||
does, but the trailing `${nextRelease.notes}` in the body is worth
|
||||
keeping deliberate rather than incidental). `make release-dry` wraps
|
||||
`semantic-release --dry-run` so the next version is answerable without
|
||||
pushing anything.
|
||||
|
||||
`scripts/commit-check.sh`'s header already points at `.releaserc.yml`
|
||||
for the type list and stays correct — that coupling survives this plan
|
||||
rather than being broken by it.
|
||||
|
||||
**Phase 2 — `.gitea/workflows/release.yml`.**
|
||||
On `push: branches: [main]`. Node 22, the pinned `npx` line from
|
||||
Decision 1, `--repository-url` carrying `PACKAGE_TOKEN`. Concurrency
|
||||
group `release-main` with `cancel-in-progress: false` — cutting a tag is
|
||||
not a thing to cancel halfway.
|
||||
|
||||
The one guard that matters: **the job exits early when `HEAD`'s subject
|
||||
starts `chore(release):`**, so the changelog commit the `git` plugin
|
||||
pushes cannot re-enter this workflow. That is checked in shell rather
|
||||
than left to `[skip ci]`, whose handling in Gitea is one more thing that
|
||||
would have to be verified.
|
||||
|
||||
**Phase 3 — rewire the publish workflows.**
|
||||
`arch-package.yml` moves from `push: branches: [main]` to
|
||||
`push: tags: ['v*']` plus `workflow_dispatch`, so a merge no longer
|
||||
publishes an untagged package. `homebrew-formula.yml` gains
|
||||
`workflow_dispatch` with a `version` input and takes its version from
|
||||
the input when there is no tag. `android-apk.yml` needs neither.
|
||||
|
||||
**Phase 3b — the assets.**
|
||||
`scripts/release-asset.sh` is the shared uploader: wait for the release
|
||||
by tag, then `POST /releases/{id}/assets?name=…`. `android-apk.yml` and
|
||||
`arch-package.yml` each call it with the artifact they already built.
|
||||
A new `desktop-assets` job — `push: tags: ['v*']`, in the same
|
||||
`ubuntu:24.04` container `ci.yml` uses — builds the Linux binary via
|
||||
`make build-prod` and the Windows one via the `CGO_ENABLED=0`
|
||||
cross-compile, and uploads both. It is a separate job from the Arch one
|
||||
because that runs in an `archlinux` container as an unprivileged
|
||||
`makepkg` user, and grafting two unrelated builds onto it would make one
|
||||
failure look like the other.
|
||||
|
||||
**Phase 4 — say that the upgrade is a reinstall, and that Windows is untried.**
|
||||
No code change: a note in `packaging/homebrew/README.md`, one in
|
||||
`docs/android-release.md`, the three-channel downgrade warning written
|
||||
into the `v0.0.1` release notes, and a standing line in the notes
|
||||
template marking the Windows asset unverified until someone runs it.
|
||||
|
||||
**Phase 5 — cut it and watch.** *(the only phase left)*
|
||||
Merge, then verify with `gitea_ci` that (a) `release.yml` ran, seeded
|
||||
`v0.0.0` and produced `v0.0.1`, (b) the release exists **with a non-empty
|
||||
body** — check the body, not the exit code — and (c) **all four publish
|
||||
workflows started from the tag**. If (c) is empty, that is Decision 2's
|
||||
fallback and the `workflow_dispatch` inputs added in phase 3 are already
|
||||
there to drive it.
|
||||
|
||||
The expected sequence on the merge is: `release.yml` seeds `v0.0.0`
|
||||
(triggering nothing), releases `0.0.1`, and pushes both the changelog
|
||||
commit and the tag — at which point `release.yml` fires a second time on
|
||||
the changelog commit and exits at the `chore(release):` guard, while the
|
||||
four `v*` workflows start. On a capacity-1 runner they will queue behind
|
||||
each other, Android last and longest.
|
||||
|
||||
**Phase 6 — the documentation that will otherwise be wrong.**
|
||||
CLAUDE.md's *Commits* section currently explains `.releaserc.yml` and
|
||||
says nothing runs it; the CI section says there are five workflows and
|
||||
that only `ci.yml` gates. Both change. `docs/android-release.md`
|
||||
describes tags as hand-pushed. `make skill-check` fails on a `.pi/`
|
||||
reference to a make target that does not exist, so `make release-dry`
|
||||
gets documented or nothing does.
|
||||
|
||||
## Open questions for you
|
||||
|
||||
1. **Ship the Windows `.exe` or not?** It builds, and it has never run.
|
||||
Marked-as-untested is the assumption; say if you would rather hold it
|
||||
back until someone boots it.
|
||||
|
||||
Resolved: semantic-release stays, with `exec` in place of the `github`
|
||||
plugin (Decision 1). Reinstalls are accepted, so no epoch and no
|
||||
versionCode offset (Decision 3). The release carries the APK, a Linux
|
||||
tarball, the Arch package and — pending (1) — a Windows zip; macOS is
|
||||
not buildable here and stays a Homebrew-from-source channel (Decision 4).
|
||||
`v0.0.0` has to be a real tag under this design — semantic-release reads
|
||||
git tags for its floor and has no "treat absence as 0.0.0" knob that
|
||||
also stops it calling the first release `1.0.0`.
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
# 012 — What we ask the network for, and what we already had
|
||||
|
||||
> **Completed.** Findings 1, 2 and 4 shipped. Finding 3 — the bound-but-uncalled methods — is now **#86**.
|
||||
|
||||
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
|
||||
configs), `tsc` and 752 Vitest tests pass; **not driven against the
|
||||
real app**, so the numbers below are read off the code, not measured.
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
# 015 — Android release pipeline
|
||||
|
||||
> **Completed.** The pipeline ships a signed APK from CI on every `v*` tag; `docs/android-release.md` is its operating document.
|
||||
|
||||
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||
generic package registry so Obtainium can poll a plain URL.
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
# 015 — Multi-artist credits, navigable
|
||||
|
||||
> **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it.
|
||||
|
||||
## The problem
|
||||
|
||||
A track credited to more than one artist has exactly one navigable
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **Completed.** Sections A, B1, B2 and B4 shipped. B3, writing tags on the device, is now **#87**; the device-found UI faults are #51–#72, sequenced by #73.
|
||||
|
||||
> **Status: all of section A is done.** A1–A3 landed with "let the app
|
||||
> reach the user's music"; A4 (MediaSession, transport notification,
|
||||
> audio focus) landed with "survive the screen locking". The direction
|
||||
@@ -0,0 +1,87 @@
|
||||
# 017 — Releases that happen by themselves
|
||||
|
||||
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
|
||||
Commits since the last tag, cuts the tag and the Gitea release whose body
|
||||
is the generated changelog, and the four publishing workflows build that
|
||||
tag and attach their artifacts. Nothing is released by hand.
|
||||
|
||||
## What it looks like now
|
||||
|
||||
`release.yml` on push to `main` → semantic-release → tag → four `v*`
|
||||
workflows in parallel (serialised in practice by the capacity-1 runner):
|
||||
|
||||
| workflow | publishes | attaches |
|
||||
| --- | --- | --- |
|
||||
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
|
||||
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
|
||||
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
|
||||
| `homebrew-formula` | the public tap | — (builds from source) |
|
||||
|
||||
Verified on the real thing: all five green, three assets on the release,
|
||||
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
|
||||
|
||||
## The five decisions, and what they cost
|
||||
|
||||
1. **semantic-release, not a shell script.** The first draft of this plan
|
||||
proposed hand-rolling it and the argument did not survive checking:
|
||||
`@semantic-release/exec` is first-party and current, and the
|
||||
Gitea-shaped part is one `curl`. What I would have hand-rolled —
|
||||
commit parsing, semver ordering, note rendering — is the part with the
|
||||
edge cases and none of it is Gitea-shaped.
|
||||
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
|
||||
before it was checked: last published 2022, `got@10`, and no peer
|
||||
dependency on semantic-release at all.
|
||||
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
|
||||
commit-back is rejected by the pre-receive hook — and would be
|
||||
rejected *after* the tag was pushed, leaving a tagged release the run
|
||||
reports as failed. The release page is the changelog;
|
||||
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
|
||||
signpost.
|
||||
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
|
||||
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
|
||||
once. Documented in `packaging/homebrew/README.md` and
|
||||
`docs/android-release.md`.
|
||||
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
|
||||
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
|
||||
because no build of it has ever been *run*.
|
||||
|
||||
## Four things that only showed up by running it
|
||||
|
||||
- **`conventional-changelog-conventionalcommits@10` renders empty
|
||||
notes.** Silently: right version, right tag, every step green, and a
|
||||
release body that is a bare `## 0.0.1 (date)` heading with nothing
|
||||
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
|
||||
the reason beside both. **Check the rendered notes, never the exit
|
||||
code.**
|
||||
- **semantic-release core dry-run-pushes to the release branch** as a
|
||||
permission check, independently of any plugin. `PACKAGE_TOKEN` had
|
||||
package-write and repo-*read* — enough to clone, not enough for this —
|
||||
and it failed with a flat `403 Forbidden` that reads exactly like
|
||||
branch protection. It is not: a `--dry-run` push never reaches the
|
||||
pre-receive hook, which a one-line experiment settled. The token needed
|
||||
`write:repository`.
|
||||
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
|
||||
commit itself it leaves nothing between the floor and HEAD, and
|
||||
semantic-release correctly reports there is nothing to release. The
|
||||
first run did exactly that and cut nothing.
|
||||
- **A tag-triggered workflow runs from the tagged commit's tree.**
|
||||
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
|
||||
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
|
||||
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
|
||||
general: a guard added today does not protect a tag pointing at
|
||||
yesterday.
|
||||
|
||||
## Two mechanisms confirmed, having been assumptions
|
||||
|
||||
- **A tag pushed with a user PAT does start the `v*` workflows**; one
|
||||
pushed with the Actions token does not (go-gitea#33123). Both halves
|
||||
are load-bearing and both were observed: the floor seed triggered
|
||||
nothing, and the release tag triggered all four.
|
||||
- **Tags are not protected** on this repo, only `main` — which is what
|
||||
lets semantic-release tag at all.
|
||||
|
||||
## Left behind deliberately
|
||||
|
||||
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
|
||||
four publishers skip it by name.
|
||||
@@ -0,0 +1,327 @@
|
||||
# 018 — Supported sizes, and what the queue panel is
|
||||
|
||||
**Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`)
|
||||
**Unblocks:** #55 (queue as a screen) — a real Gitea dependency
|
||||
**Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
|
||||
**Status:** complete — #24 shipped as PR #132, and the matrix's last
|
||||
unkept promise closed with #69.
|
||||
|
||||
#73 puts this first in Phase 2 and hangs the rest of the phase off it,
|
||||
so the decision has to be written down and arguable before any CSS
|
||||
moves. This document is the decision. Everything below the matrix is
|
||||
either a measurement or an argument for one of the four choices #24
|
||||
asks for.
|
||||
|
||||
---
|
||||
|
||||
## What is actually wrong, measured
|
||||
|
||||
Against the running app (`make dev-headless SEED=default`, Chromium),
|
||||
Playlists, sweeping the viewport with the queue open and closed. The
|
||||
number that matters is how much of the page header survives.
|
||||
|
||||
| viewport | sidebar | queue | main panel | header needs | actions clipped |
|
||||
|---|---|---|---|---|---|
|
||||
| 1280×800 | 200 | open 321 | 759 | 759 | — |
|
||||
| 1000×700 | 200 | open 321 | 479 | 747 | New Playlist, New Smart Playlist |
|
||||
| **900×600** | 200 | open 321 | **379** | 747 | **all three** |
|
||||
| 800×600 | 56 | open 321 | 423 | 747 | all three |
|
||||
| 700×600 | 56 | open 321 | 323 | 747 | all three |
|
||||
| 390×780 | — | open 321 | **69** | 747 | all three |
|
||||
| 320×600 | — | open 321 | **0** | 747 | all three |
|
||||
| 900×600 | 200 | closed | 700 | 747 | New Smart Playlist |
|
||||
| **800×600** | 56 | closed | 744 | 747 | **New Smart Playlist (158/162px)** |
|
||||
| 320×600 | — | closed | 320 | 747 | all three |
|
||||
|
||||
Five things in that table are not in the issue.
|
||||
|
||||
**The header clips at the supported minimum with the queue closed.**
|
||||
At 800×600 — the size `backend/config/window.go` enforces and the only
|
||||
size this app *promises* — "New Smart Playlist" loses 4px of its 162.
|
||||
#24 reads as a queue-panel bug; the queue makes it dramatic, but the
|
||||
header overflows on its own at the minimum window.
|
||||
|
||||
**900×600 is worse than 800×600, because the sidebar expands at 900.**
|
||||
`AUTO_COLLAPSE_VIEWPORT` collapses the sidebar to icons *below* 900, so
|
||||
at 899px the main panel is 843px and at 900px it is 700px. The worst
|
||||
desktop case is therefore not the minimum window; it is the pixel
|
||||
immediately above the collapse. Anything that tests "the minimum" and
|
||||
stops has not tested the worst case, which is what
|
||||
`layout-overflow.spec.ts` does today.
|
||||
|
||||
**At phone widths the queue is not a drawer, it is an amputation.**
|
||||
`queue-panel`'s host is `flex-shrink: 0; width: 0`, going to
|
||||
`width: var(--queue-width, 320px)` under `[open]` — it is *in the flow*
|
||||
of `.content-area`, so it takes its width from the main panel rather
|
||||
than covering it. At 390px that leaves 69px of the page; at 320px it
|
||||
leaves **0px**, and the app is not degraded but gone. This is the
|
||||
measurement #55 needs and did not have.
|
||||
|
||||
**Only Playlists overflows.** Sweeping all ten primary views at 900×600
|
||||
and at 390×780, every other header reports `scrollWidth ==
|
||||
clientWidth`, and Albums at 390px renders title, count and sort
|
||||
legibly (checked on a screenshot, not just the number). #69 is
|
||||
therefore one view's action set — three text buttons totalling 390px —
|
||||
and not a systemic header failure, though the *rule* still belongs in
|
||||
`page-header`.
|
||||
|
||||
**Both reasons in `MinWidth`'s comment are stale.** It says the floor is
|
||||
800×600 because "below ~780 the header's subtitle wraps" and "below
|
||||
~600 tall the eleven sidebar items no longer fit". The subtitle is
|
||||
`display: none` below 900 (index.css), and the sidebar host is
|
||||
`overflow-y: auto` — at 600×460 its `scrollHeight` is 434 against a
|
||||
332px client, and Settings is reachable after scrolling. Neither
|
||||
mechanism can happen any more. That does not mean the floor should
|
||||
move; it means its stated reason no longer supports it, which is worse
|
||||
than either answer.
|
||||
|
||||
*(Care needed: my first probe for the sidebar scroller searched
|
||||
`shadowRoot.querySelectorAll('*')` and reported "items are
|
||||
unreachable", because the scroller is the **host** and a host is not in
|
||||
its own shadow root. The claim in CLAUDE.md is correct.)*
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — the supported size matrix
|
||||
|
||||
Three bands. Two of them already exist and are already argued; what is
|
||||
new is that they are written down as a *promise*, and that the queue is
|
||||
part of it.
|
||||
|
||||
| band | width | navigation | queue | promise |
|
||||
|---|---|---|---|---|
|
||||
| **Phone** | < 600 | `bottom-nav` + drawer | overlay, full width | reflows; nothing needs sideways scrolling; fits 320px |
|
||||
| **Compact** | 600 – 899 | icon sidebar | overlay + scrim | nothing is clipped or unreachable at any width in the band |
|
||||
| **Desktop** | ≥ 900 | labelled sidebar | inline where it fits (see decision 2), else overlay | as Compact |
|
||||
|
||||
And one promise across all three: **no action is ever unreachable.**
|
||||
That is the sentence #69 asks for and it is the one the matrix exists
|
||||
to make checkable.
|
||||
|
||||
**400% zoom** keeps the meaning it already has: WCAG 1.4.10 names 320px
|
||||
as the reflow target, the phone band covers it, and
|
||||
`layout-overflow.spec.ts` already asserts a 320px viewport needs no
|
||||
sideways scrolling. What changes is that the *queue* must be part of
|
||||
that assertion — it is not today, and with the queue open at 320px the
|
||||
main panel is 0px wide, which no current test can see.
|
||||
|
||||
**The window minimum stays 800×600**, and its comment gets the real
|
||||
reason. The old mechanisms are gone, but the floor is still where the
|
||||
Compact band's chrome stops being comfortable, and lowering it would
|
||||
mean promising the desktop layout at sizes where only the phone layout
|
||||
works. The interesting consequence is decision 4.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — the queue is an overlay when it cannot afford to be a column
|
||||
|
||||
**The rule.** The queue panel renders inline — in the flow, as today —
|
||||
only while
|
||||
|
||||
```
|
||||
viewport − sidebar − queueWidth ≥ 480
|
||||
```
|
||||
|
||||
and as an overlay with a scrim otherwise.
|
||||
|
||||
**Why it cannot be a media query**, which is the load-bearing half:
|
||||
the queue's width is *user state*. It is drag-resizable between 200 and
|
||||
500px and persisted (`--queue-width`, `MIN_WIDTH`/`MAX_WIDTH` in
|
||||
`queue-panel.ts`). A breakpoint at a fixed viewport width silently
|
||||
assumes the default 320, and is wrong by 180px for a user who has
|
||||
dragged the panel wide — in the direction that hurts, since a wider
|
||||
queue is exactly when the content can least afford it. So the mode is
|
||||
computed from the measured widths and published as an attribute, the
|
||||
way `data-active-view` already is, and the CSS keys off that.
|
||||
|
||||
**Why 480, honestly.** There is no cliff to derive it from. The track
|
||||
list rescales its columns continuously — at main widths from 900 down
|
||||
to 544 its `--grid-cols` shrink from 213px to 124px with
|
||||
`rowOverflow=0` throughout — and the album grid steps 3 columns to 2
|
||||
somewhere between 564 and 644 without breaking. So this is a judgement,
|
||||
anchored on two things: it keeps the *default* window (1100 wide, main
|
||||
= 580) inline, because the inline queue is a desktop affordance people
|
||||
choose and turning it into an overlay for the common case would be a
|
||||
regression in feel; and it puts every case measured as broken —
|
||||
900×600 at main=379, and every phone width — on the overlay side.
|
||||
1024×768 lands at main=504 and stays inline.
|
||||
|
||||
**The scrim is the other half of the issue's complaint** ("make the
|
||||
queue obviously an overlay *over* the content so it reads as something
|
||||
to close"). An overlay queue gets a scrim, closes on scrim click and on
|
||||
Escape, and returns focus to `#queue-button`.
|
||||
|
||||
**What must not change**: #55's Direction is explicit — one component,
|
||||
two mount points, do not fork it. The overlay is a *presentation* of
|
||||
the same `queue-panel`, so the roving tab stop, Alt+Arrow reorder, drag
|
||||
reorder, selection semantics and the `virtualizer.requestUpdate()` on
|
||||
selection and current-track change all come along untouched. This
|
||||
decision deliberately stops short of #55's detail-view mount, but it is
|
||||
the shape that makes it possible, and it unblocks it.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — #69 is its own PR, and here is the finding that decides it
|
||||
|
||||
`page-header` **cannot collapse its own actions**, and that is not an
|
||||
effort estimate but a fact about the API. Actions arrive through
|
||||
`<slot name="actions">` as arbitrary light-DOM markup — Playlists slots
|
||||
a `<div class="header-actions">` of three `<button>`s with click
|
||||
handlers, drag handlers and a conditional class. A component cannot
|
||||
move another component's light-DOM children into a dropdown and keep
|
||||
their behaviour; there is nothing generic to render as a menu item.
|
||||
|
||||
So the overflow rule needs an *actions API* — hosts declaring
|
||||
`{icon, label, handler, priority}` data that `page-header` can render
|
||||
either as buttons or as menu items — which is a change to all three
|
||||
hosts that slot actions, not a rule added in one place. That is a
|
||||
different piece of work from this one, it is independently verifiable,
|
||||
and the desktop half of #69's symptom is removed by decision 2 anyway
|
||||
(the queue stops eating the header's width).
|
||||
|
||||
It therefore stays #69, gets the finding above recorded on it, and
|
||||
follows immediately after this. What *this* plan owes it is the
|
||||
promise in the matrix — no action unreachable at any supported size —
|
||||
and the measurement that the only offender today is Playlists.
|
||||
|
||||
**And the promise is not kept yet, which is the honest version of a
|
||||
claim this document made in its first draft.** "Decision 2 removes the
|
||||
desktop half of #69's symptom" was too strong. Measured after phase 2,
|
||||
at 900×600 on Playlists:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| queue open | main 379px, **all three** actions clipped | main 700px, **one** clipped |
|
||||
| queue closed | main 700px, one clipped | unchanged |
|
||||
|
||||
So the queue's *contribution* is gone — open and closed are now
|
||||
identical, which is the whole of what this decision owed — and the
|
||||
residual "New Smart Playlist: 114/162px" is the header overflowing on
|
||||
its own, at a size the queue never touched. #69 is still a live defect
|
||||
at a supported size, and the matrix's promise is what will close it.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4 — a very small window becomes the phone layout, not the mini-player
|
||||
|
||||
#24 asks whether a very small window should switch to the mini-player
|
||||
(#12) "or simply refuse to go there". Both options in the question are
|
||||
worse than the one the codebase already has.
|
||||
|
||||
**#12 is a second window, not a mode.** Its findings say so: v3
|
||||
supports multiple windows, `AlwaysOnTop` is a window *option*, and the
|
||||
frontend would need an entry branch mounting only the mini-player root
|
||||
for a second window loading the same bundle. Turning the main window
|
||||
into a mini-player at some width conflates the two: it would throw away
|
||||
the user's navigation state on a resize, and it puts the MPRIS question
|
||||
(#12's own open question — media controls are process-level and must
|
||||
not be per-window) on a code path that a drag can trigger by accident.
|
||||
|
||||
**And "refuses" is unnecessary, because the reflow already exists.**
|
||||
The phone band is real, tested, and reached by width alone — a desktop
|
||||
window narrowed below 600px already gets `bottom-nav` and the phone
|
||||
shell. That is a better answer than refusing: it is strictly more
|
||||
usable than a hard minimum, it costs nothing new, and it is the same
|
||||
code Android runs, so it stays exercised.
|
||||
|
||||
So: the main window reflows and never becomes a mini-player; #12 stays
|
||||
a separate always-on-top window and is not blocked by, or coupled to,
|
||||
this decision. The window minimum stays 800×600 for the reason in
|
||||
decision 1 — but the phone band is what happens below it, not a
|
||||
refusal, which is why the minimum is a comfort floor rather than a
|
||||
correctness one.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
1. **This document**, linked from #24, with the matrix reported on the
|
||||
issue and #55 told whether it is unblocked. *(no code)* — **done**
|
||||
2. **The queue's overlay mode** — computed mode attribute, scrim,
|
||||
Escape and scrim-click close, focus return. The inline path is
|
||||
unchanged above the threshold. — **done**
|
||||
3. **The window minimum's comment** — replace both stale reasons with
|
||||
the measured ones. No value change. — **done**
|
||||
4. **Verification**, below. Including the specs that must change
|
||||
because they assert the old behaviour. — **done**
|
||||
|
||||
#69 follows as its own branch; #55 became unblocked at phase 2.
|
||||
|
||||
## What landed, measured
|
||||
|
||||
Main panel width with the queue open, before and after:
|
||||
|
||||
| viewport | before | after | mode |
|
||||
|---|---|---|---|
|
||||
| 1280×800 | 759 | 759 | inline |
|
||||
| 1100×720 (default window) | 579 | 579 | inline |
|
||||
| 1024×768 | 503 | 503 | inline |
|
||||
| 900×600 | **379** | **700** | overlay |
|
||||
| 800×600 | 423 | 744 | overlay |
|
||||
| 390×780 | **69** | **390** | overlay |
|
||||
| 320×600 | **0** | **320** | overlay |
|
||||
|
||||
The scrim is perceptible but subtle on a dark ramp, which is worth
|
||||
knowing before someone "fixes" it: sampled from the screenshots at
|
||||
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
|
||||
row's text 242 → 133. It covers the **content area only** — not the
|
||||
sidebar or the transport — on purpose: the queue is not modal, and
|
||||
leaving the navigation live means the scrim reads as "this is over the
|
||||
content" (which is what #24 asked for) without pretending the rest of
|
||||
the app is unavailable.
|
||||
|
||||
## What #69 did with the promise, and one thing this plan got wrong
|
||||
|
||||
#69 landed on its own branch as decision 3 said it would, and the
|
||||
matrix's *no action is ever unreachable at any supported size* is now
|
||||
kept rather than promised. Measured on Playlists, actions clipped:
|
||||
|
||||
| viewport | before #24 | after #24 | after #69 |
|
||||
|---|---|---|---|
|
||||
| 900×600, queue open | all three | one (114/162px) | none |
|
||||
| 900×600, queue closed | one | one | none |
|
||||
| 800×600, queue closed | one (158/162px) | one | none |
|
||||
| 390×780 | all three | all three | none |
|
||||
| 320×600 | all three | all three | none |
|
||||
|
||||
The shape was the one decision 3 predicted — an actions API first, an
|
||||
overflow rule second — and all three hosts that slot actions migrated.
|
||||
|
||||
**What this document got wrong is smaller and worth keeping.** Decision
|
||||
1 says the header's minimum is a *comfort* floor and that only the
|
||||
queue and the actions compete for the header's width. They are not the
|
||||
only two: every child of that flex row was `flex-shrink: 0`, so
|
||||
whatever came last lost, and the actions come last. At 320px the sort
|
||||
control alone is 172px of the header — so with every action already
|
||||
collapsed into the menu, the *menu button* was 76px off the right edge.
|
||||
The promise was still broken with nothing left to collapse.
|
||||
|
||||
That is why #69 also had to decide what gives way: the title (which the
|
||||
navigation also states) and, below 600px, the word "Sort:" (which the
|
||||
direction arrow implies). Neither is an action, which is the rule the
|
||||
matrix actually encodes — **an action is a capability and everything
|
||||
else on that row is a label.**
|
||||
|
||||
## Verification, and what each tier cannot see
|
||||
|
||||
- `make ui-test` — the queue panel's mode logic is component-tier
|
||||
work and belongs there. It **cannot** see the shell: the threshold is
|
||||
computed from the sidebar and viewport, which do not exist in that
|
||||
tier.
|
||||
- `make e2e` — `layout-overflow.spec.ts` gains the queue-open case at
|
||||
every band (it has none today, which is why main=0px at 320px has
|
||||
never failed anything) and **gains 900×600**, since the minimum is
|
||||
not the worst case. `queue-toggle-state.spec.ts` and
|
||||
`phone-shell.spec.ts` both touch the panel and must be re-read before
|
||||
editing.
|
||||
- **Screenshots at every band, read by a human.** This is not optional
|
||||
here: `layout-overflow.spec.ts` asserts the *shell* needs no sideways
|
||||
scrolling and passes on a build whose album header clips its own
|
||||
buttons (measured this session at 390px; filed on #66). Clipping
|
||||
*inside* a component is invisible to it, and clipping is this issue.
|
||||
- `make ui-visual` **cannot help at all** — the component tier renders
|
||||
the token fallbacks, because the theme only reaches `:root` in the
|
||||
real app.
|
||||
- Accessible names via `page.getByRole(...)`, never a shadow-root
|
||||
query. A drawer with a scrim is exactly the shape that grows a
|
||||
nameless control, and this repo has shipped one three times.
|
||||
@@ -0,0 +1,408 @@
|
||||
# 019 — The Android touch model
|
||||
|
||||
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
|
||||
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
|
||||
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
|
||||
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
|
||||
being diverged from)
|
||||
**Status:** shipped (phases 1-4). Its one deliberate remainder is #200.
|
||||
|
||||
#73 puts #60 first in Phase 4 because it is "the presentation every
|
||||
other item needs", and this is the next one. The Direction on #63 asks
|
||||
for the interaction model to be designed as one piece before any of it
|
||||
is built, because it *reassigns an existing gesture* rather than adding
|
||||
one — `utils/long-press.ts` currently owns the 500ms hold, and every
|
||||
context menu in the app is downstream of it.
|
||||
|
||||
This document is that design. Everything below is a measurement, or an
|
||||
argument for one of the choices #63 leaves open.
|
||||
|
||||
---
|
||||
|
||||
## The mapping
|
||||
|
||||
| gesture | pointer is a finger | pointer is a mouse |
|
||||
|---|---|---|
|
||||
| single tap / click | **play the row** | select the row |
|
||||
| double | — | play the row |
|
||||
| long press (500ms) | **enter selection mode** | — |
|
||||
| right-click | — | context menu |
|
||||
| swipe right | **add to queue** | — |
|
||||
| drag | reorder / drag to playlist | reorder / drag to playlist |
|
||||
|
||||
Three of those are #63's report unchanged. Two are decisions it left
|
||||
open, and one is a deliberate divergence.
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — the predicate is the pointer, not the platform
|
||||
|
||||
#63 says "the row component needs a platform-aware interaction layer
|
||||
rather than shared handlers". It needs an interaction layer; it should
|
||||
not be platform-aware.
|
||||
|
||||
**The question a row has to answer is not "am I on Android" or "is the
|
||||
viewport under 600px" but "what made this event".** `pointerType ===
|
||||
'touch'`, read off the event that is being handled, which is already
|
||||
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
|
||||
and is the only such test in the frontend today.
|
||||
|
||||
This is #64's rule — the predicate is named after the capability, not
|
||||
the platform — and it carries #64's warning with it. Keyed on a width:
|
||||
|
||||
- an Android **tablet** at 600px or more gets click-selects /
|
||||
double-click-plays on a touchscreen, which is the exact inversion
|
||||
this issue exists to fix, on the platform it exists for;
|
||||
- a **touchscreen laptop** cannot be described at all, because both
|
||||
pointers are live in the same session on the same row;
|
||||
- and a narrow desktop window gets phone semantics with a mouse.
|
||||
|
||||
Per event, all three are right for free, and there is no second
|
||||
declaration of what a phone does — the thing CLAUDE.md declines to add
|
||||
every time it comes up.
|
||||
|
||||
**Measured, so this is not an assumption about the WebView.** On the
|
||||
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
|
||||
driving a real tap with `adb shell input tap`:
|
||||
|
||||
```
|
||||
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
|
||||
```
|
||||
|
||||
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
|
||||
5, and `(pointer: coarse)` / `(hover: none)` both match.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — there is no double-tap, and the number is why
|
||||
|
||||
#63 asks for *single tap → play* **and** *double tap → context menu*.
|
||||
Those two cannot both be honoured. The first tap of a double tap is
|
||||
indistinguishable from a single tap until the interval expires, so
|
||||
"tap plays" necessarily becomes "tap waits to find out whether you
|
||||
meant something else, then plays". The app already owns that constant:
|
||||
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
|
||||
= 250` for precisely this reason.
|
||||
|
||||
**What it would be added to, measured on the device.** Six runs, from
|
||||
the play command to the backend's `TrackChanged`:
|
||||
|
||||
```
|
||||
155, 123, 85, 56, 91 ms median ~100
|
||||
```
|
||||
|
||||
So the app's primary interaction is ~100ms, and a double-tap
|
||||
discriminator makes it ~350 — **3.5x, of which 250ms is spent
|
||||
deliberately doing nothing** — paid on every track anyone ever plays,
|
||||
in order to reach a menu.
|
||||
|
||||
It is also against the platform's convention, which counts for more
|
||||
than usual here because this is the phone build and nothing else:
|
||||
long-press is *how you select* on Android (Gmail, Files, Photos),
|
||||
double-tap is zoom or nothing, and a list's menu is either the
|
||||
long-press sheet or a per-row overflow.
|
||||
|
||||
**So the menu and the selection action bar become the same surface**,
|
||||
which is the convention and removes a concept rather than adding one.
|
||||
Long-press selects the row it was made on and raises the action bar;
|
||||
the bar's actions *are* the context menu's actions, contextualised to
|
||||
whatever is selected — one row or forty. #60's bottom sheet stays
|
||||
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
|
||||
`menu-surface` are reused rather than reimplemented.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — tap-to-play and selection mode ship together
|
||||
|
||||
The obvious phase order is "tap plays first, it is the smallest
|
||||
change". It is wrong, and the reason is a capability that exists today
|
||||
and is easy to miss.
|
||||
|
||||
**A touch user can already multi-select**: tap selects (the desktop
|
||||
semantics, which a finger currently gets), and the long-press menu then
|
||||
acts on the selection. Move tap to play without shipping selection mode
|
||||
in the same change and there is a window — a release, if it lands — in
|
||||
which selecting forty tracks to add to a playlist is impossible on a
|
||||
phone. That is a regression dressed as an increment.
|
||||
|
||||
So phase 1 is both, or neither.
|
||||
|
||||
---
|
||||
|
||||
## What the code looks like now
|
||||
|
||||
| surface | how it binds | selection |
|
||||
|---|---|---|
|
||||
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
|
||||
| `queue-panel` | delegated, same shape | `SelectionController` |
|
||||
| `playlist-details` | per row | `SelectionController` |
|
||||
| `smart-playlist-details` | per row | `SelectionController` |
|
||||
|
||||
All four already share `SelectionController`, and all four resolve a
|
||||
row from an event by `data-index` / `data-file-path` on the row. So the
|
||||
gesture layer has one shape to talk to, and "selection mode" is a flag
|
||||
on the controller they already have rather than a fifth concept.
|
||||
|
||||
`utils/long-press.ts` is one document-capture listener that synthesises
|
||||
a `contextmenu` — the seam that needed no component to opt in. **This
|
||||
plan keeps that shape and changes what the gesture means**, which is
|
||||
why it is a rewrite of that file rather than a second listener set: two
|
||||
document listeners both claiming the 500ms hold is the fault the file's
|
||||
own header warns about.
|
||||
|
||||
---
|
||||
|
||||
## Two measurements that decide the implementation
|
||||
|
||||
**`touch-action` is `auto` on both the virtualizer and the rows.** With
|
||||
`auto` the browser owns panning on both axes, so a horizontal drag can
|
||||
be claimed as a scroll and our gesture ends in `pointercancel`
|
||||
mid-swipe. A row that wants a horizontal swipe has to declare
|
||||
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
|
||||
virtualizer's scroll, and must stay native or the list stutters) and
|
||||
hands us the horizontal axis. This is the single most likely way for
|
||||
swipe-to-queue to "work in Chromium and not on the phone".
|
||||
|
||||
**The row is 424x52 on the device**, so a swipe threshold in px is a
|
||||
fraction of a row height, not of a screen.
|
||||
|
||||
**And the third one was found by building phase 1 and then running it**
|
||||
— it is not something any browser tier can report. Chrome 113's Android
|
||||
WebView **fires its own `contextmenu` on a long press**. `long-press.ts`
|
||||
stood down when a trusted one arrived, which was right while both paths
|
||||
ended in the same place; once a hold can mean selection mode they end
|
||||
in different places, and standing down means the gesture silently does
|
||||
the *old* thing. Measured, before the fix:
|
||||
|
||||
```
|
||||
{"log":["contextmenu isTrusted=true"],
|
||||
"state":{"bar":null,"menuActive":true,"selected":1}}
|
||||
```
|
||||
|
||||
`yj-long-press` was never announced at all, the context menu opened,
|
||||
and all 26 tests in the component tier passed — dispatched pointer
|
||||
events do not make a browser synthesise a `contextmenu`.
|
||||
|
||||
So the browser's event is a **trigger, not a competitor**: the gesture
|
||||
is announced from it, and only a component that claims it suppresses
|
||||
the native menu. Unclaimed, it propagates untouched. That is the same
|
||||
"browser wins" outcome, reached by asking instead of assuming — and
|
||||
verified both ways on the device, a track row entering selection mode
|
||||
and an album card still opening its menu.
|
||||
|
||||
The tier could not *find* it and can *hold* it: a test cannot dispatch
|
||||
a trusted event, but this module has always told its own apart by
|
||||
identity rather than `isTrusted`, so an untrusted one from a test takes
|
||||
exactly the browser's path.
|
||||
|
||||
---
|
||||
|
||||
## A tier note: this one can be driven, not only measured
|
||||
|
||||
`adb shell input tap|swipe` reaches the WebView as real pointer events,
|
||||
which the log above is evidence of. So for the first time the Android
|
||||
tier can *perform* the thing under test rather than describe the page
|
||||
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
|
||||
is `input swipe X Y X+N Y 120`.
|
||||
|
||||
Device CSS pixels from device pixels, on this phone:
|
||||
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
|
||||
horizontally (measured from the tap above: 200,300 arrived as 78,94).
|
||||
|
||||
This does not make the device a spec tier — it does not run in CI and
|
||||
`make ui-test` still has to carry the assertions. It makes "does the
|
||||
gesture actually fire on Chrome 113" answerable in seconds.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
|
||||
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
|
||||
recognition of tap / long-press / horizontal swipe, dispatched as
|
||||
composed custom events so a delegated listener in any shadow root
|
||||
still works. `SelectionController` gains a mode. `track-list` acts on
|
||||
tap and enters the mode on long press. The action bar.
|
||||
|
||||
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
|
||||
finding above and a reveal-and-snap affordance. **Shipped**; what the
|
||||
device said about it is the section below.
|
||||
|
||||
**Phase 3 — the other three surfaces**, which is mostly wiring, since
|
||||
they already share the controller. **Shipped**, and it was not entirely
|
||||
wiring — see below.
|
||||
|
||||
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
|
||||
row are a single-click target inside a row whose single tap now plays;
|
||||
that conflict is #67's, and this plan should not pre-empt its answer
|
||||
beyond making tap-to-play win on touch. **Shipped.**
|
||||
|
||||
## Phase 3 was not symmetric, in two places
|
||||
|
||||
**A tap on a queue row plays that position**, not the list. Copying
|
||||
`track-list`'s tap — which sets the queue to the list the row is in —
|
||||
would rebuild the queue *from* the queue, discarding its source, its
|
||||
shuffle order and everything a user had inserted by hand. It reads as a
|
||||
no-op and is not one.
|
||||
|
||||
**The queue panel has no swipe, deliberately.** A right swipe means
|
||||
*add to the queue* everywhere else it exists, and a queue row is
|
||||
already in the queue; the only thing it could mean there is *remove*,
|
||||
which is the same gesture with the opposite effect one screen away —
|
||||
the fault `utils/icon-language.ts` exists to have fixed for glyphs.
|
||||
Removing a queue row is on the row itself (the ×), on its bottom sheet
|
||||
since #60, and on the selection bar this phase gave it. The assertion
|
||||
is that its rows do **not** carry `data-swipe`, so a swipe there cannot
|
||||
silently become a second meaning for the app's one horizontal gesture.
|
||||
|
||||
And the affordance became `utils/swipe-to-queue.ts` rather than being
|
||||
copied twice. Three lists want it; three copies of "how far is far
|
||||
enough" is three chances for them to disagree, which is what
|
||||
`utils/library-status.ts` and `utils/ownership.ts` each exist to have
|
||||
stopped happening. The shared stylesheet is keyed on `[data-swipe]`
|
||||
rather than on a class name, because the three lists call their rows
|
||||
two different things and the `touch-action` half of the device fix has
|
||||
to reach all of them.
|
||||
|
||||
## Phase 4 was already true, which is why it is asserted
|
||||
|
||||
A claimed tap has its click swallowed at document capture, so an
|
||||
`explore-link` inside the row never sees one and tap-to-play wins with
|
||||
no rule of its own. Nothing in the suite would have failed if that
|
||||
stopped covering the link, and the symptom — tapping a track's *title*
|
||||
navigating to its album instead of playing it — is one a phone user
|
||||
meets constantly and a mouse user never does.
|
||||
|
||||
**Its test was vacuous when written**, in the way this file keeps
|
||||
finding: the tap helper dispatched `pointerdown` and `pointerup` and no
|
||||
`click`, so there was nothing to swallow and the assertion held on any
|
||||
build. It sends the trailing click now, which also strengthened phase
|
||||
1's "a tap plays and does not also select". The fixture needed an MBID
|
||||
for the same reason — without one the link asks the backend for a local
|
||||
album first and gives up when nothing answers, so "it did not navigate"
|
||||
was true of a working build and a broken one alike.
|
||||
|
||||
---
|
||||
|
||||
## What phase 2 measured, which was not what phase 2 predicted
|
||||
|
||||
The `touch-action` finding above is **half** of the answer, and
|
||||
shipping only that half would have been the exact failure it warns
|
||||
about. Driving a real finger with `adb shell input swipe` across a
|
||||
track row, three values, all three on the device:
|
||||
|
||||
```
|
||||
touch-action: auto pointerdown, 1 move, pointercancel
|
||||
touch-action: pan-y pointerdown, 2 moves, pointercancel
|
||||
touch-action: none pointerdown, 2 moves, pointercancel
|
||||
```
|
||||
|
||||
`touchmove` kept firing in all three. So **Chrome 113's WebView
|
||||
cancels the pointer stream ~16px into any drag whatever `touch-action`
|
||||
says**, and a swipe recognised from `pointermove` — which is what the
|
||||
rest of this module is built on — is a swipe that dies 16px in.
|
||||
|
||||
The other half is a **non-passive `touchmove` calling
|
||||
`preventDefault()`**: with it, the same swipe ran to 12 moves and a
|
||||
`pointerup` at full travel. And both halves are required, which was
|
||||
measured rather than assumed — with the `preventDefault` in place and
|
||||
`touch-action` back at `auto`, the gesture died after **one** move.
|
||||
The reading is that `auto` lets the browser commit to a horizontal pan
|
||||
on the first move past slop, before any threshold of ours can have
|
||||
been crossed, while `pan-y` leaves it undecided long enough for the
|
||||
second move to claim it.
|
||||
|
||||
`none` is the one value to avoid: the list stopped scrolling at all.
|
||||
With the shipped pair, a vertical drag still scrolls the virtualizer
|
||||
81px on the same run that a horizontal one survives.
|
||||
|
||||
**`draggable="true"` is not a competitor**, which is the other thing
|
||||
the device was asked. No `dragstart` fires from a touch drag on this
|
||||
WebView at all, so the drag-to-playlist attribute on every row needs no
|
||||
pointer-type gate.
|
||||
|
||||
### And it found a phase 1 defect that no tier can see
|
||||
|
||||
The native `contextmenu` arrives in **either** order, and phase 1 only
|
||||
handled one of them. `nativeSeen` covers the browser's menu arriving
|
||||
*during* the hold. The reverse — our 500ms timer firing first, a
|
||||
component claiming it, and Chrome delivering its own `contextmenu`
|
||||
50–70ms *later* — was suppressed by nothing, so the context menu
|
||||
opened on top of the selection bar. Measured over four holds:
|
||||
|
||||
```
|
||||
hold 1 yj-long-press, then contextmenu isTrusted=true menu open
|
||||
hold 2 yj-long-press clean
|
||||
hold 3 yj-long-press, then contextmenu isTrusted=true menu open
|
||||
hold 4 yj-long-press clean
|
||||
```
|
||||
|
||||
Two in four, on the one surface #63 exists to have changed, and
|
||||
invisible to both browser tiers because neither synthesises a
|
||||
`contextmenu` from a dispatched press. A press that has produced its
|
||||
outcome now suppresses a late one whichever branch it took; six holds
|
||||
on the fixed build, six clean.
|
||||
|
||||
### The rules phase 2 settled
|
||||
|
||||
- **A swipe is not a selection.** It queues the row it was made on,
|
||||
unless that row is one of several *explicitly* selected — the same
|
||||
rule the context menu answers with, because a bar reading "40
|
||||
selected" beside a gesture that quietly queues one of them is two
|
||||
answers to one question. It never changes the selection, which is
|
||||
where it differs from a right-click.
|
||||
- **Rightward only.** Nothing is bound to a leftward swipe and
|
||||
claiming one would take a gesture away to do nothing with it.
|
||||
- **The commit threshold is a fraction of the row** (0.3, floor 72px),
|
||||
because the row is 424x52 on this device and a bare pixel count is a
|
||||
fraction of a row height on one screen and a third of the width on
|
||||
the next.
|
||||
- **The affordance is not only a colour** (WCAG 1.4.1, the rule the
|
||||
playing-row marker exists for): the pane carries the queue icon and
|
||||
words, the words change at the threshold ("Add to queue" → "Release
|
||||
to add" → "Added"), and the outcome is announced in a live region.
|
||||
- **The row does not move; its cells do.** `.track-row` is
|
||||
`contain: strict` with `overflow: hidden`, so a pane held at the
|
||||
row's original position while the row translates is a pane at a
|
||||
negative offset inside a clipping box and is simply not painted.
|
||||
Sliding the cells needs no wrapper element in a row that is already
|
||||
a grid.
|
||||
- **The travel is written to the row's own style, not rendered.** One
|
||||
render at the start, one at the threshold, one at the end; a
|
||||
virtualizer re-rendering every visible row per frame of one finger's
|
||||
travel is the thing `perf.m1` is about.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does selection mode have an escape other than the bar's own
|
||||
close?** *Settled: Escape, here; back, not here.*
|
||||
|
||||
Escape leaves the mode, from `selection-bar` rather than from each
|
||||
of the four hosts — that element exists only while the mode does, so
|
||||
it is the one place a dismissal can be attached and detached with
|
||||
the thing it dismisses. It is the same documented exception the
|
||||
overlaid queue's Escape is: **a dismissal, not a shortcut**, so it
|
||||
is not a panel-scoped binding.
|
||||
|
||||
The back gesture is the half that is *not* done, and deliberately.
|
||||
The obvious version — `selection-bar` pushing a history entry — is
|
||||
precisely the fault `navStack` was deleted for: the shell owns the
|
||||
stack (#6/#55) and is the only thing that calls `pushState`, so that
|
||||
two stacks cannot disagree about what one press means. Four lists
|
||||
each reaching for `history` is four stacks. It is also wrong on its
|
||||
own terms, since a mode is per-component and a user who enters one,
|
||||
navigates away and returns has an entry for a mode that no longer
|
||||
exists. #55 settled the shape for a *place*; a mode is not one,
|
||||
which is why it could not simply inherit that answer.
|
||||
|
||||
What it wants is one shell-owned register of dismissible surfaces,
|
||||
which would retro-fit the queue overlay, the dialogs and this alike
|
||||
rather than adding a fourth private answer. **#200.**
|
||||
|
||||
2. **Does a tap on a row's favourite icon still toggle it in normal
|
||||
mode?** *Settled in phase 1: yes.* A control inside the row keeps
|
||||
its own tap — the gesture is simply not claimed there, so the click
|
||||
behind it falls through untouched. It is the same rule the shortcut
|
||||
service has for a focused control that owns a key, and it is what
|
||||
keeps the 44px favourite target (#56) from becoming a 44px play
|
||||
target. The queue row's × is the second instance of it.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Autotag (v1.3) — MusicBrainz Autotagger
|
||||
|
||||
> **Historical record.** Phases 008–010 shipped, and the scoring engine was subsequently overhauled (`recommend.go`, `rank.go`, `mixedbag.go`), which makes the 011/012 sections below stale in their details. What is actually left is **#90** (auto-accept and entry points) and **#91** (settings, and a way back from the dismissed file-write warning).
|
||||
|
||||
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008–012), each depending on the prior one.
|
||||
|
||||
| Phase | Title | Status |
|
||||
@@ -1,195 +0,0 @@
|
||||
# 010 — Owned albums, offline
|
||||
|
||||
**Status:** not started — and **much smaller than when it was written**
|
||||
**Branch:** none yet
|
||||
**Created:** 2026-08-13
|
||||
**Depends on:** nothing
|
||||
**Related:** the `AlbumReleasesFailed` fix that prompted it, and the
|
||||
tag-derived completeness that landed after it (same session)
|
||||
|
||||
---
|
||||
|
||||
## What already shipped, and what it leaves
|
||||
|
||||
The common case is solved without this plan. `GetAlbumCompleteness`
|
||||
reads the "5/12" denominator off the files' own tags — persisted to
|
||||
`release_group_recordings.total_tracks`, having been extracted at every
|
||||
scan since forever and discarded — and an album that is **MBID-matched
|
||||
and complete** now opens with **no catalog call at all**. Identity from
|
||||
the MBID, tracklist from the tags; those were the two things the browse
|
||||
was being spent on.
|
||||
|
||||
So the set this plan still has to serve is not "albums you own a track
|
||||
of". It is:
|
||||
|
||||
- albums that are genuinely **incomplete** (the catalog is the only way
|
||||
to say *which* tracks are missing — tags give the count, not the
|
||||
names), and
|
||||
- albums whose tags **never declared a total**, where completeness is
|
||||
unknowable locally and the catalog is the only source.
|
||||
|
||||
On a well-tagged library that is a small minority, which changes the
|
||||
economics below considerably: the run is shorter, and the rate limiter
|
||||
contention that dominates this design is proportionally less severe.
|
||||
Re-measure before building — the answer may now be "the prefetch is
|
||||
enough".
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Opening an album detail page for an album **you already own** hits
|
||||
MusicBrainz. Every time it is not in the response cache, which for most
|
||||
of a library is every time, because nothing warms that cache except a
|
||||
capped prefetch on the artist page.
|
||||
|
||||
The user's framing: *this is a classic example of an album we should
|
||||
have had locally.*
|
||||
|
||||
## Why we do not have it, despite the discography backfill
|
||||
|
||||
`BackfillLibraryDiscographies` / `EnsureArtistDiscography`
|
||||
(`backend/explore/searchindex.go:301`, `:397`) do less than the name
|
||||
suggests. Per artist, `indexOneArtist` fetches:
|
||||
|
||||
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
|
||||
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
|
||||
|
||||
and writes them as **flat `explore_index` rows**. There is no release
|
||||
group → tracklist relation anywhere in the index, and no release-level
|
||||
rows at all. `explore_index` recordings carry `caa_release_mbid` and
|
||||
`release_name`, which name the release used for cover art — not a
|
||||
tracklist.
|
||||
|
||||
So "we have full discographies for library artists" means *we know
|
||||
which albums the artist made, offline*. It has never meant we know
|
||||
what is on any of them.
|
||||
|
||||
The only store of release-level catalog data in the app is `http_cache`
|
||||
under `mb:browse:releases:<rg>` (90-day TTL, `musicbrainz.go:27`),
|
||||
populated **only** by a live `BrowseReleases` with
|
||||
`Includes: ["recordings", "media"]` at `MaxLimit` — the most expensive
|
||||
call the app makes to MusicBrainz. It is warmed by exactly one thing:
|
||||
`PrefetchReleases` (`explore.go:746`), capped at 8, called only when an
|
||||
artist page renders.
|
||||
|
||||
An album opened from the library grid therefore always browses live.
|
||||
|
||||
## What to build
|
||||
|
||||
**A post-scan backfill that warms the release cache for release groups
|
||||
that are owned but not known-complete** — bounded, resumable, and
|
||||
shaped exactly like `BackfillLibraryDiscographies`, which is the proven
|
||||
pattern for this in the codebase.
|
||||
|
||||
The scoping rule is the user's and it is the right one: not "every
|
||||
album by every artist in the library" (50 release groups per artist,
|
||||
mostly never opened) but albums with owned tracks — narrowed further,
|
||||
now, to the ones a local answer cannot already cover. The query gains
|
||||
one clause: skip release groups whose `GetAlbumCompleteness` reports
|
||||
`complete`.
|
||||
|
||||
Sketch:
|
||||
|
||||
1. A query for release groups with ≥1 owned track and no warm release
|
||||
cache entry. `release_groups.mbid` is the key; the owned-track join
|
||||
is `audio_files → recordings → release_group_recordings`, the same
|
||||
shape `unenrichedLibraryArtistMBIDs` already uses one table over.
|
||||
2. Order by owned-track count descending, so the albums the user has
|
||||
most of are warmed first — same reasoning as the discography
|
||||
backfill's ordering, same benefit if a run is cut short.
|
||||
3. Run through `releasesSF`, so it never double-fetches a release group
|
||||
an interactive open is already handling.
|
||||
4. Bound a run (`discogBackfillMaxPerRun` has a value to copy) and make
|
||||
it resumable: the resume marker is the response cache itself —
|
||||
`BrowseReleasesCached` already answers "is this one done", so unlike
|
||||
the discography path this needs **no new flag column**.
|
||||
5. Trigger it where `BackfillLibraryDiscographies` is triggered, and
|
||||
register it with `jobs` so it has progress, pause and cancel like
|
||||
every other long-running operation.
|
||||
|
||||
### The rate limiter is the whole design constraint
|
||||
|
||||
> **Update (2026-08-13): the priority half is built, and the sentence
|
||||
> below is wrong on a detail.** `e.mb` runs on `mbSearchLimiter`
|
||||
> (`NewRateLimiterBurst(3, 1)`); the 1 req/s `NewRateLimiter()` cited
|
||||
> here is the *artist image* limiter. Both are shared and both were
|
||||
> FIFO. `RateLimiter.WithBackgroundLane` + `WithBackgroundPriority(ctx)`
|
||||
> now make a marked caller yield to interactive work and pace at 1/s,
|
||||
> and `jobs.KindCatalogEnrich` + `startBackfillJob` give the existing
|
||||
> backfills progress and cancel. **"Do not start until the priority
|
||||
> question has an answer" is satisfied** — mark this backfill's context
|
||||
> and register it the way `BackfillLibraryDiscographies` now is.
|
||||
> `PrefetchReleases`' cap of 8 is still unrevisited.
|
||||
|
||||
One shared `NewRateLimiter()` at 1 req/s (`explore.go:84`) serves this,
|
||||
`PrefetchReleases`, and every interactive browse. A backfill over a
|
||||
few thousand owned albums is *hours* of wall clock at that rate — which
|
||||
is fine for a background job, and not fine if it starves the album page
|
||||
the user is looking at right now.
|
||||
|
||||
That is the real work in this plan, and it is not the query:
|
||||
|
||||
- Interactive browses need to **jump the queue**. Today they cannot;
|
||||
there is one limiter and it is FIFO.
|
||||
- `PrefetchReleases`' cap of 8 was sized when nothing else competed for
|
||||
the limiter. Revisit it in the same change.
|
||||
- The 60 s fallback the `AlbumReleasesFailed` fix installed is sized
|
||||
for today's contention. If a backfill can queue behind it, that
|
||||
number is wrong again — which is an argument for priority, not for a
|
||||
bigger number.
|
||||
|
||||
Do not start the query until the priority question has an answer.
|
||||
|
||||
## The alternative that was considered and rejected
|
||||
|
||||
**Project release-group tracklists in the dump build and ship them in
|
||||
the artifact.** The data is there: `canonical_musicbrainz_data.csv`
|
||||
carries `release_mbid` *and* `recording_mbid`
|
||||
(`dumpcatalog.go:520`), and `release_to_rg` already maps release →
|
||||
release group. It is derivable from bytes the index build already
|
||||
streams, with no new API surface at all, and it would work offline on
|
||||
first launch with no per-user backfill.
|
||||
|
||||
It is rejected **for this plan** because the artifact is built
|
||||
centrally and is byte-identical for every user, so "albums the user
|
||||
owns a track of" cannot be a filter on it. Shipping tracklists for the
|
||||
whole catalog means per-recording rows against a ~900 MB artifact
|
||||
budget (~426 B/row measured), and gating on a popularity floor means it
|
||||
is absent for exactly the obscure albums a local backfill would have
|
||||
covered.
|
||||
|
||||
Worse than absent, in fact — and this is the argument that actually
|
||||
kills it. The floor is not one number over artists; it is a **per
|
||||
artist track budget** (`dumpcatalog.go:58-89`): 50 tracks for a tier-A
|
||||
artist, 25 for tier B, 12 for tier C. A projected tracklist would
|
||||
therefore be *whichever* of an album's tracks survived that budget,
|
||||
with nothing marking the rest as absent — so the album page would count
|
||||
owned against a truncated denominator and render "Play 7 of 9" for a
|
||||
twelve-track album. That is a confident lie, where the honest states
|
||||
this plan's alternative produces (complete / incomplete / unknown) are
|
||||
at worst silent.
|
||||
|
||||
Note that `markLibraryArtists` (`dumpcatalog.go:246`) already grants
|
||||
every library artist full coverage — 500 tracks, 100 release groups —
|
||||
by reading the local library, so the per-user tailoring this option
|
||||
supposedly cannot have does exist in code. It is a no-op in the CI
|
||||
build (empty library), and reaching it means a **local** dump build:
|
||||
the ~205 GB, half-a-day download the entire artifact design exists to
|
||||
avoid. Whoever finds that function next should read this paragraph
|
||||
before getting excited about it.
|
||||
|
||||
Worth revisiting if the artifact ever gains per-user tailoring, or if a
|
||||
measurement shows the row count is smaller than feared. Note it also
|
||||
yields the *canonical* tracklist rather than MusicBrainz's full version
|
||||
list, so the versions dropdown would still browse live when opened.
|
||||
|
||||
## Done when
|
||||
|
||||
- Opening an owned album that has never been opened before renders its
|
||||
catalog tracklist with no network call, after one backfill run.
|
||||
- An interactive browse issued while the backfill is running is not
|
||||
delayed by it.
|
||||
- The backfill appears in the jobs indicator, and can be paused and
|
||||
cancelled there.
|
||||
- A second run after a completed one does approximately nothing.
|
||||
+14
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -172,12 +172,17 @@ ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
||||
bindings-check: ## Fail if the generated bindings are stale
|
||||
@./scripts/bindings-check.sh
|
||||
|
||||
# Two CSS traps that report a long way from their cause, or not at all.
|
||||
# A backtick inside a comment in a css`` literal ends the literal, and
|
||||
# what you get back is a type error about CSSResult, or every test in
|
||||
# the suite failing to import. Four sessions, three plans. Instant.
|
||||
# the suite failing to import. Four sessions, three plans. And a nested
|
||||
# rule starting with an element name is dropped by the device's
|
||||
# Chrome 113 in silence -- no tier here runs an engine that can see it.
|
||||
# Instant.
|
||||
.PHONY: css-check
|
||||
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
||||
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
|
||||
@cd frontend && node scripts/check-css-literals.mjs
|
||||
@cd frontend && node scripts/check-css-nesting.mjs
|
||||
|
||||
# .pi/ and CLAUDE.md document commands, and a doc that documents a
|
||||
# command wrongly is worse than no doc: an agent runs it confidently.
|
||||
@@ -192,15 +197,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 \
|
||||
|
||||
@@ -106,5 +106,8 @@ make dev # run with hot-reload
|
||||
make build-prod # produce a release binary
|
||||
```
|
||||
|
||||
More detail for contributors lives in
|
||||
[`docs/dev/overview.md`](./docs/dev/overview.md) and [`CLAUDE.md`](./CLAUDE.md).
|
||||
More detail for contributors lives in [`CLAUDE.md`](./CLAUDE.md) — the
|
||||
architecture, the conventions and the reasons behind them. What is
|
||||
being worked on is [the issue
|
||||
tracker](https://git.ljones.me/yonlu/yellowjacket/issues); #73 is the
|
||||
roadmap.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build android
|
||||
|
||||
// The write itself, and nothing else. Everything decidable off a phone
|
||||
// is in androidlog.go; see the package comment for why.
|
||||
|
||||
package androidlog
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -llog
|
||||
#include <stdlib.h>
|
||||
#include <android/log.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// The priorities in androidlog.go are android/log.h's own values, and
|
||||
// these are what says so. A constant expression that would be negative
|
||||
// does not compile as a uint, so a renumbered header fails the build
|
||||
// here rather than logging everything at the wrong severity -- which is
|
||||
// the failure that would otherwise be invisible, since logcat would
|
||||
// happily print whatever number it was handed.
|
||||
const (
|
||||
_ = uint(C.ANDROID_LOG_VERBOSE - PrioVerbose)
|
||||
_ = uint(PrioVerbose - C.ANDROID_LOG_VERBOSE)
|
||||
_ = uint(C.ANDROID_LOG_DEBUG - PrioDebug)
|
||||
_ = uint(PrioDebug - C.ANDROID_LOG_DEBUG)
|
||||
_ = uint(C.ANDROID_LOG_INFO - PrioInfo)
|
||||
_ = uint(PrioInfo - C.ANDROID_LOG_INFO)
|
||||
_ = uint(C.ANDROID_LOG_WARN - PrioWarn)
|
||||
_ = uint(PrioWarn - C.ANDROID_LOG_WARN)
|
||||
_ = uint(C.ANDROID_LOG_ERROR - PrioError)
|
||||
_ = uint(PrioError - C.ANDROID_LOG_ERROR)
|
||||
_ = uint(C.ANDROID_LOG_FATAL - PrioFatal)
|
||||
_ = uint(PrioFatal - C.ANDROID_LOG_FATAL)
|
||||
)
|
||||
|
||||
// New returns the handler main() installs on Android.
|
||||
func New(opts *slog.HandlerOptions) slog.Handler {
|
||||
return NewHandler(opts, write)
|
||||
}
|
||||
|
||||
// write hands one line to liblog.
|
||||
func write(prio int, tag, msg string) {
|
||||
cTag := C.CString(tag)
|
||||
defer C.free(unsafe.Pointer(cTag))
|
||||
|
||||
cMsg := C.CString(msg)
|
||||
defer C.free(unsafe.Pointer(cMsg))
|
||||
|
||||
C.__android_log_write(C.int(prio), cTag, cMsg)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Package androidlog routes slog to logcat.
|
||||
//
|
||||
// **An Android app's fd 1 and 2 go to /dev/null**, so every line this
|
||||
// app writes with slog is discarded on that platform -- including the
|
||||
// one naming the error it is about to os.Exit on. #52 is what that
|
||||
// cost: a process that vanished with no tombstone, no AndroidRuntime
|
||||
// stack and nothing in `logcat -b crash`, at Priority/Critical for
|
||||
// months, whose entire diagnosis was one slog.Error main.go was
|
||||
// already writing.
|
||||
//
|
||||
// The platform's own sink is __android_log_write, which is a handful
|
||||
// of cgo -- and cgo compiled by nothing `make lint` or `make test`
|
||||
// runs, since the only toolchain that builds the android tag is a
|
||||
// cross-compiler and the only thing that runs it is a phone. So the
|
||||
// split here is the one backend/mediacontrols/androidpayload.go makes,
|
||||
// pushed as far as it will go: **everything except the write itself is
|
||||
// in this file, untagged**. The priority mapping, the formatting, the
|
||||
// chunking and the handler's own attr and group bookkeeping are
|
||||
// ordinary Go that `go test` exercises on any platform; android.go is
|
||||
// fifteen lines that hand a string to liblog.
|
||||
package androidlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Tag is what logcat labels these lines with.
|
||||
//
|
||||
// It is a constant of ours rather than the application id, because the
|
||||
// debug build carries `applicationIdSuffix ".dev"` so that it can be
|
||||
// installed beside the release app -- so a tag derived from the package
|
||||
// name is a *different* tag on the one build that can be inspected, and
|
||||
// the filter that is supposed to show these lines would hide them on
|
||||
// exactly the build used to look for them.
|
||||
const Tag = "yellowjacket"
|
||||
|
||||
// Android's priorities, from android/log.h. These are the values
|
||||
// __android_log_write takes; android.go asserts at compile time that
|
||||
// they still match the header, so a renumbered platform is a build
|
||||
// failure here rather than a warning silently logged as an error.
|
||||
const (
|
||||
PrioVerbose = 2
|
||||
PrioDebug = 3
|
||||
PrioInfo = 4
|
||||
PrioWarn = 5
|
||||
PrioError = 6
|
||||
PrioFatal = 7
|
||||
)
|
||||
|
||||
// maxPayload is how much of one line liblog will carry.
|
||||
//
|
||||
// The kernel logger's entry is 4068 bytes for the tag, the message and
|
||||
// their two NULs together, and what does not fit is **dropped without
|
||||
// comment** -- so a long line would be truncated in the middle of the
|
||||
// thing worth reading. 3500 leaves room for the tag and for the "(N/M)"
|
||||
// a continuation carries.
|
||||
const maxPayload = 3500
|
||||
|
||||
// WriteFunc is the platform sink: one already-formatted line, at one
|
||||
// priority, under one tag.
|
||||
//
|
||||
// It is a parameter rather than a package-level function so that the
|
||||
// handler can be driven by a test on a machine with no liblog at all.
|
||||
type WriteFunc func(prio int, tag, msg string)
|
||||
|
||||
// Priority maps a slog level onto an Android one.
|
||||
//
|
||||
// slog's levels are open -- a caller may define its own at any int --
|
||||
// so this is a banding rather than a lookup: anything below Info is
|
||||
// debug, anything at or above Error is error. A custom level between
|
||||
// two of the standard ones lands in the band beneath it, which is what
|
||||
// slog's own level naming does.
|
||||
func Priority(level slog.Level) int {
|
||||
switch {
|
||||
case level < slog.LevelDebug:
|
||||
return PrioVerbose
|
||||
case level < slog.LevelInfo:
|
||||
return PrioDebug
|
||||
case level < slog.LevelWarn:
|
||||
return PrioInfo
|
||||
case level < slog.LevelError:
|
||||
return PrioWarn
|
||||
default:
|
||||
return PrioError
|
||||
}
|
||||
}
|
||||
|
||||
// Handler formats records with slog's own TextHandler and hands each
|
||||
// line to a WriteFunc.
|
||||
//
|
||||
// It delegates the formatting rather than doing it, because WithAttrs
|
||||
// and WithGroup are the half of slog.Handler that is easy to get subtly
|
||||
// wrong -- and a logger whose groups are wrong is a logger nobody reads.
|
||||
// What it does own is what logcat needs and TextHandler does not know
|
||||
// about: the priority, and the fact that a line has a maximum length.
|
||||
type Handler struct {
|
||||
write WriteFunc
|
||||
|
||||
// mu guards buf, which the delegate writes into. slog.Handler is
|
||||
// documented as safe for concurrent use.
|
||||
mu *sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
delegate slog.Handler
|
||||
}
|
||||
|
||||
// NewHandler builds a handler over an arbitrary sink.
|
||||
//
|
||||
// The time and the level are dropped from the formatted line: logcat
|
||||
// stamps every entry with both, and repeating them costs a quarter of
|
||||
// the width of a phone-sized terminal to say the same thing twice.
|
||||
func NewHandler(opts *slog.HandlerOptions, write WriteFunc) *Handler {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
inner := &slog.HandlerOptions{}
|
||||
if opts != nil {
|
||||
*inner = *opts
|
||||
}
|
||||
|
||||
user := inner.ReplaceAttr
|
||||
inner.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && isBuiltin(a) {
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
return user(groups, a)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
write: write,
|
||||
mu: &sync.Mutex{},
|
||||
buf: buf,
|
||||
delegate: slog.NewTextHandler(buf, inner),
|
||||
}
|
||||
}
|
||||
|
||||
// isBuiltin reports whether an attr is slog's own time or level,
|
||||
// rather than a caller's attribute that happens to share the name.
|
||||
//
|
||||
// ReplaceAttr cannot tell those apart by key. It is called with an
|
||||
// empty group path for the built-ins *and* for every top-level
|
||||
// attribute, so a key comparison alone silently eats a caller's own
|
||||
// "level" or "time" -- which is not hypothetical: the probe that
|
||||
// verified this package on the device logged one, and the attribute
|
||||
// vanished. The kinds are what separate them, because slog builds the
|
||||
// built-ins as slog.Any(LevelKey, r.Level) and slog.Time(TimeKey, ...)
|
||||
// and an attribute value of type slog.Level is not something a caller
|
||||
// passes by accident.
|
||||
func isBuiltin(a slog.Attr) bool {
|
||||
switch a.Key {
|
||||
case slog.TimeKey:
|
||||
return a.Value.Kind() == slog.KindTime
|
||||
case slog.LevelKey:
|
||||
_, ok := a.Value.Any().(slog.Level)
|
||||
|
||||
return ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the level is worth formatting.
|
||||
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.delegate.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle formats one record and writes it out, in as many entries as
|
||||
// its length demands.
|
||||
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.buf.Reset()
|
||||
|
||||
if err := h.delegate.Handle(ctx, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prio := Priority(rec.Level)
|
||||
for _, line := range Chunk(strings.TrimRight(h.buf.String(), "\n")) {
|
||||
h.write(prio, Tag, line)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAttrs returns a handler carrying the given attributes.
|
||||
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return h.derive(h.delegate.WithAttrs(attrs))
|
||||
}
|
||||
|
||||
// WithGroup returns a handler that qualifies subsequent attributes.
|
||||
func (h *Handler) WithGroup(name string) slog.Handler {
|
||||
return h.derive(h.delegate.WithGroup(name))
|
||||
}
|
||||
|
||||
// derive shares the buffer and its mutex with the parent.
|
||||
//
|
||||
// They must be shared rather than copied: the delegate returned by
|
||||
// WithAttrs writes into the *same* buffer this one does, so a second
|
||||
// mutex would guard nothing and two loggers derived from one would
|
||||
// interleave their bytes into a single line.
|
||||
func (h *Handler) derive(delegate slog.Handler) *Handler {
|
||||
return &Handler{
|
||||
write: h.write,
|
||||
mu: h.mu,
|
||||
buf: h.buf,
|
||||
delegate: delegate,
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk splits a formatted record into entries liblog will carry
|
||||
// whole.
|
||||
//
|
||||
// A record short enough to fit is returned as it is, which is nearly
|
||||
// every record; the numbering only appears where something was going
|
||||
// to be silently truncated anyway. It splits on bytes rather than runes
|
||||
// because the limit is a byte count -- a multi-byte rune straddling the
|
||||
// boundary is a mojibake character in a log line, against a lost one.
|
||||
func Chunk(msg string) []string {
|
||||
if len(msg) <= maxPayload {
|
||||
return []string{msg}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for rest := msg; rest != ""; {
|
||||
n := min(maxPayload, len(rest))
|
||||
parts = append(parts, rest[:n])
|
||||
rest = rest[n:]
|
||||
}
|
||||
|
||||
numbered := make([]string, 0, len(parts))
|
||||
for i, p := range parts {
|
||||
numbered = append(
|
||||
numbered,
|
||||
"("+strconv.Itoa(i+1)+"/"+strconv.Itoa(len(parts))+") "+p,
|
||||
)
|
||||
}
|
||||
|
||||
return numbered
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package androidlog_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/androidlog"
|
||||
)
|
||||
|
||||
// entry is one call to the sink.
|
||||
type entry struct {
|
||||
prio int
|
||||
tag string
|
||||
msg string
|
||||
}
|
||||
|
||||
// recorder is the platform write, on a machine with no platform.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
entries []entry
|
||||
}
|
||||
|
||||
func (r *recorder) write(prio int, tag, msg string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.entries = append(r.entries, entry{prio: prio, tag: tag, msg: msg})
|
||||
}
|
||||
|
||||
func (r *recorder) only(t *testing.T) entry {
|
||||
t.Helper()
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if len(r.entries) != 1 {
|
||||
t.Fatalf("want exactly one entry, got %d: %v", len(r.entries), r.entries)
|
||||
}
|
||||
|
||||
return r.entries[0]
|
||||
}
|
||||
|
||||
func newLogger(r *recorder, level slog.Level) *slog.Logger {
|
||||
return slog.New(androidlog.NewHandler(
|
||||
&slog.HandlerOptions{Level: level},
|
||||
r.write,
|
||||
))
|
||||
}
|
||||
|
||||
// TestPriorityMapsEveryLevel pins the level banding.
|
||||
//
|
||||
// This is the one thing in #160 that a wrong answer hides rather than
|
||||
// breaks: logcat prints whatever priority it is handed, so an Error
|
||||
// filed as Info is a line that is present, correct and invisible to
|
||||
// every filter anyone would use to look for it.
|
||||
func TestPriorityMapsEveryLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
level slog.Level
|
||||
want int
|
||||
}{
|
||||
{"below debug is verbose", slog.LevelDebug - 1, androidlog.PrioVerbose},
|
||||
{"debug", slog.LevelDebug, androidlog.PrioDebug},
|
||||
{"info", slog.LevelInfo, androidlog.PrioInfo},
|
||||
{"warn", slog.LevelWarn, androidlog.PrioWarn},
|
||||
{"error", slog.LevelError, androidlog.PrioError},
|
||||
|
||||
// slog's levels are open, so a caller may sit between two of
|
||||
// the named ones. Each lands in the band beneath it, which is
|
||||
// what slog's own level naming does ("INFO+2").
|
||||
{"between info and warn", slog.LevelInfo + 2, androidlog.PrioInfo},
|
||||
{"between warn and error", slog.LevelWarn + 1, androidlog.PrioWarn},
|
||||
{"above error", slog.LevelError + 4, androidlog.PrioError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := androidlog.Priority(tt.level); got != tt.want {
|
||||
t.Errorf("Priority(%v) = %d, want %d", tt.level, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrioritiesAreTheHeadersValues pins the constants themselves.
|
||||
//
|
||||
// android.go asserts these against android/log.h at compile time, but
|
||||
// only a cross-compiler ever builds that file. This is the assertion
|
||||
// that runs in CI, and the numbers are written out longhand on purpose
|
||||
// -- comparing a constant to itself would pass on any renumbering.
|
||||
func TestPrioritiesAreTheHeadersValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"verbose", androidlog.PrioVerbose, 2},
|
||||
{"debug", androidlog.PrioDebug, 3},
|
||||
{"info", androidlog.PrioInfo, 4},
|
||||
{"warn", androidlog.PrioWarn, 5},
|
||||
{"error", androidlog.PrioError, 6},
|
||||
{"fatal", androidlog.PrioFatal, 7},
|
||||
} {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s priority = %d, want %d", tt.name, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordReachesTheSink is the whole point of the package: a line
|
||||
// written with slog arrives, under the app's tag, at the right
|
||||
// priority.
|
||||
func TestRecordReachesTheSink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
newLogger(rec, slog.LevelInfo).Error("application error", "err", "boom")
|
||||
|
||||
got := rec.only(t)
|
||||
|
||||
if got.prio != androidlog.PrioError {
|
||||
t.Errorf("priority = %d, want %d", got.prio, androidlog.PrioError)
|
||||
}
|
||||
|
||||
if got.tag != androidlog.Tag {
|
||||
t.Errorf("tag = %q, want %q", got.tag, androidlog.Tag)
|
||||
}
|
||||
|
||||
if !strings.Contains(got.msg, "application error") {
|
||||
t.Errorf("message %q does not carry the message", got.msg)
|
||||
}
|
||||
|
||||
if !strings.Contains(got.msg, `err=boom`) {
|
||||
t.Errorf("message %q does not carry the attribute", got.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheTagIsNotTheApplicationID guards the trap the tag exists to
|
||||
// avoid.
|
||||
//
|
||||
// The debug build carries `applicationIdSuffix ".dev"`, so it is
|
||||
// installed as app.yellowjacket.dev -- and it is the *only* build whose
|
||||
// WebView can be inspected, so it is the build anyone debugging this
|
||||
// app is running. A tag derived from the application id therefore
|
||||
// differs between the build being looked at and the build the filter
|
||||
// was written for, which is the failure this whole issue is about
|
||||
// wearing a different hat.
|
||||
func TestTheTagIsNotTheApplicationID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if strings.Contains(androidlog.Tag, ".") {
|
||||
t.Errorf(
|
||||
"tag %q looks like an application id; it must be stable "+
|
||||
"across the debug suffix",
|
||||
androidlog.Tag,
|
||||
)
|
||||
}
|
||||
|
||||
// Logcat's tag field is 23 bytes. A longer one is truncated, and a
|
||||
// truncated tag matches no filter.
|
||||
if len(androidlog.Tag) > 23 {
|
||||
t.Errorf("tag %q is %d bytes, over logcat's 23", androidlog.Tag, len(androidlog.Tag))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeAndLevelAreDropped checks the formatting decision.
|
||||
//
|
||||
// logcat stamps every entry with a timestamp and a priority letter, so
|
||||
// carrying slog's own is the same information twice on a 424px screen.
|
||||
func TestTimeAndLevelAreDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
newLogger(rec, slog.LevelInfo).Warn("scan finished", "files", 1577)
|
||||
|
||||
got := rec.only(t).msg
|
||||
|
||||
if strings.Contains(got, "time=") {
|
||||
t.Errorf("message %q still carries a timestamp", got)
|
||||
}
|
||||
|
||||
if strings.Contains(got, "level=") {
|
||||
t.Errorf("message %q still carries a level", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "files=1577") {
|
||||
t.Errorf("message %q lost its attributes with them", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACallersOwnLevelAttrSurvives is a regression, and it was found on
|
||||
// the phone rather than here.
|
||||
//
|
||||
// Dropping slog's built-in time and level by key alone also drops a
|
||||
// caller's attribute of the same name, because ReplaceAttr sees an
|
||||
// empty group path for both. The probe that verified this package on
|
||||
// the device wrote slog.Info("...", "level", "info") and logcat showed
|
||||
// the message with no attributes at all.
|
||||
func TestACallersOwnLevelAttrSurvives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
newLogger(rec, slog.LevelInfo).Info("probe", "level", "info", "time", "soon")
|
||||
|
||||
got := rec.only(t).msg
|
||||
|
||||
for _, want := range []string{"level=info", "time=soon"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("message %q lost the caller's %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// And slog's own are still gone: the built-in level renders as a
|
||||
// bare word like INFO, never as the caller's value.
|
||||
if strings.Contains(got, "level=INFO") {
|
||||
t.Errorf("message %q carries slog's own level", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLevelIsHonoured checks that Enabled reaches the delegate.
|
||||
func TestLevelIsHonoured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
log := newLogger(rec, slog.LevelWarn)
|
||||
|
||||
log.Info("not this one")
|
||||
log.Warn("this one")
|
||||
|
||||
if got := rec.only(t).msg; !strings.Contains(got, "this one") {
|
||||
t.Errorf("wrong record survived: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupsAndAttrsSurvive covers the half of slog.Handler this
|
||||
// delegates rather than implements -- the reason it delegates at all.
|
||||
func TestGroupsAndAttrsSurvive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
log := newLogger(rec, slog.LevelInfo).
|
||||
With("component", "player").
|
||||
WithGroup("track")
|
||||
|
||||
log.Info("loaded", "path", "/sdcard/Music/a.flac")
|
||||
|
||||
got := rec.only(t).msg
|
||||
|
||||
for _, want := range []string{
|
||||
"component=player",
|
||||
"track.path=/sdcard/Music/a.flac",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("message %q is missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedHandlersDoNotInterleave is why derive shares the buffer's
|
||||
// mutex rather than taking a new one.
|
||||
//
|
||||
// Two loggers derived from one write into the same buffer, so a second
|
||||
// mutex would guard nothing and a concurrent pair would splice each
|
||||
// other's bytes into a single line -- which reads as corrupted logs
|
||||
// under load and as nothing at all in a test that logs once.
|
||||
func TestDerivedHandlersDoNotInterleave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
base := newLogger(rec, slog.LevelInfo)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
log := base.With("worker", i).WithGroup("g")
|
||||
for range 50 {
|
||||
log.Info("tick", "n", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
|
||||
if len(rec.entries) != 8*50 {
|
||||
t.Fatalf("got %d entries, want %d", len(rec.entries), 8*50)
|
||||
}
|
||||
|
||||
for _, e := range rec.entries {
|
||||
if strings.Count(e.msg, "msg=tick") != 1 {
|
||||
t.Fatalf("interleaved line: %q", e.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkLeavesShortLinesAlone is the common case: no numbering
|
||||
// appears on a record that was never going to be truncated.
|
||||
func TestChunkLeavesShortLinesAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := androidlog.Chunk("msg=short")
|
||||
|
||||
if len(got) != 1 || got[0] != "msg=short" {
|
||||
t.Errorf("Chunk(short) = %q, want the input unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkSplitsWhatWouldBeTruncated covers the case liblog drops
|
||||
// silently.
|
||||
func TestChunkSplitsWhatWouldBeTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const n = 9000
|
||||
|
||||
long := strings.Repeat("x", n)
|
||||
parts := androidlog.Chunk(long)
|
||||
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("a %d-byte line was not split", n)
|
||||
}
|
||||
|
||||
var payload strings.Builder
|
||||
|
||||
for i, p := range parts {
|
||||
if len(p) > 4000 {
|
||||
t.Errorf("part %d is %d bytes, over liblog's entry", i, len(p))
|
||||
}
|
||||
|
||||
_, rest, found := strings.Cut(p, ") ")
|
||||
if !found {
|
||||
t.Fatalf("part %d carries no (n/m) marker: %q", i, p)
|
||||
}
|
||||
|
||||
payload.WriteString(rest)
|
||||
}
|
||||
|
||||
if payload.String() != long {
|
||||
t.Errorf("the parts do not reassemble into the input")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,34 @@ const (
|
||||
RecommendationStrong Recommendation = "strong"
|
||||
)
|
||||
|
||||
// ConfidentTier is the tier at which this package considers a match
|
||||
// good enough to act on without being asked to look.
|
||||
//
|
||||
// It exists as a name rather than as `== RecommendationStrong` at
|
||||
// each call site because two features read it and they must not
|
||||
// disagree about what "high confidence" means: the album page tells
|
||||
// the user unprompted that the autotagger has a match (#28), and
|
||||
// strict auto-accept will rewrite the files without asking (#90).
|
||||
// A page that says "we are sure" about something the auto-accept
|
||||
// pass would decline is the app contradicting itself.
|
||||
//
|
||||
// What the two do *not* share is everything else. Surfacing a match
|
||||
// is a suggestion with a confirm dialog behind it; auto-accept is an
|
||||
// irreversible on-disk rewrite, and #90 gates it on further
|
||||
// conditions this tier cannot express — exact track count, every
|
||||
// title matching, lengths within a couple of seconds, no cover
|
||||
// replacement, no MBID conflict. So this is the floor both stand on,
|
||||
// not the whole of either test.
|
||||
const ConfidentTier = RecommendationStrong
|
||||
|
||||
// Confident reports whether a tier clears ConfidentTier.
|
||||
//
|
||||
// A comparison rather than an equality, so adding a tier above
|
||||
// "strong" later does not silently stop qualifying.
|
||||
func Confident(r Recommendation) bool {
|
||||
return recommendationRank(r) >= recommendationRank(ConfidentTier)
|
||||
}
|
||||
|
||||
const (
|
||||
// Absolute score tiers.
|
||||
strongScoreThresh = 0.90
|
||||
|
||||
@@ -168,3 +168,34 @@ func TestRecommend_LocalCandidatesWithoutRGMBIDCompareByTitle(t *testing.T) {
|
||||
t.Errorf("different-title rival: Recommend = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The tier both features stand on is one name, checked here rather
|
||||
// than assumed at two call sites.
|
||||
//
|
||||
// #28 renders "we have a match for this album" on the album page and
|
||||
// #90 will rewrite files without asking; a page that claims confidence
|
||||
// the auto-accept pass would decline is the app contradicting itself.
|
||||
// What they do not share is everything else — auto-accept adds gates
|
||||
// this tier cannot express — so this pins the floor, not the whole of
|
||||
// either test.
|
||||
func TestConfidentIsTheOneSharedFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if ConfidentTier != RecommendationStrong {
|
||||
t.Errorf("ConfidentTier = %q, want strong", ConfidentTier)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
rec Recommendation
|
||||
want bool
|
||||
}{
|
||||
{RecommendationNone, false},
|
||||
{RecommendationLow, false},
|
||||
{RecommendationMedium, false},
|
||||
{RecommendationStrong, true},
|
||||
} {
|
||||
if got := Confident(tc.rec); got != tc.want {
|
||||
t.Errorf("Confident(%q) = %v, want %v", tc.rec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
)
|
||||
|
||||
// AlbumMatchView is "the autotagger already has a confident match for
|
||||
// the album you are looking at".
|
||||
//
|
||||
// It is deliberately not a score. The album page renders a suggestion,
|
||||
// and a suggestion has to be actionable: which release, what it is
|
||||
// called, and whether acting on it here would do the whole album or
|
||||
// only part of it.
|
||||
type AlbumMatchView struct {
|
||||
// GroupKey is the tagging group the actions operate on.
|
||||
GroupKey string `json:"groupKey"`
|
||||
|
||||
// Recommendation is the tier, as a string, for a caller that
|
||||
// wants to render the strength rather than trust the filter.
|
||||
Recommendation string `json:"recommendation"`
|
||||
|
||||
// Score is the top candidate's raw score, 0..1.
|
||||
Score float64 `json:"score"`
|
||||
|
||||
// ReleaseMBID is the release Apply would write.
|
||||
ReleaseMBID string `json:"releaseMbid"`
|
||||
|
||||
// Title and ArtistCredit name that release, so the banner can say
|
||||
// what it is offering rather than "a match".
|
||||
Title string `json:"title"`
|
||||
ArtistCredit string `json:"artistCredit"`
|
||||
|
||||
// TrackCount is the group's local track count.
|
||||
TrackCount int64 `json:"trackCount"`
|
||||
|
||||
// GroupCount is how many tagging groups this album spans.
|
||||
//
|
||||
// More than one means a multi-disc album (one group per disc), and
|
||||
// it is the reason this is a field rather than an implementation
|
||||
// detail: applying "the album" from a single button would retag
|
||||
// one disc of three and leave the folder holding a mix of old and
|
||||
// new tags. The caller offers review instead.
|
||||
GroupCount int `json:"groupCount"`
|
||||
}
|
||||
|
||||
// MatchForAlbum answers "does the autotagger have something confident
|
||||
// to say about this album", for the album detail page.
|
||||
//
|
||||
// Three things about it are load-bearing.
|
||||
//
|
||||
// **It costs no MusicBrainz request.** Everything it needs is already
|
||||
// on disk: `tagging_items` carries the top score and release from the
|
||||
// background prefetch, and `tagging_candidates` durably holds the
|
||||
// scored list. The rate limiters here are shared with every page the
|
||||
// user can open, so a lookup that fires on page load must not join
|
||||
// that queue — which also means this returns nothing for a folder
|
||||
// nobody has scored yet, rather than scoring it now. That is the
|
||||
// right trade: the prefetch will get to it, and a page that silently
|
||||
// spends a minute of somebody's MusicBrainz budget to draw a banner
|
||||
// is worse than a page that says nothing.
|
||||
//
|
||||
// **The tier is computed, not read.** `tagging_items.score` is the raw
|
||||
// number and `Recommend` is what turns it into a claim — capping it
|
||||
// for an ambiguous runner-up, an incomplete alignment or a folder too
|
||||
// small to corroborate itself. Filtering on the raw score would
|
||||
// promise confidence the scorer had explicitly withheld.
|
||||
//
|
||||
// **Nothing is said about an album the user has already answered
|
||||
// for.** Only a `pending` group qualifies: `confirmed` covers both a
|
||||
// finished apply and an explicit "leave as is", and `skipped` is the
|
||||
// user saying not now. Re-offering either is nagging, and "leave as
|
||||
// is" would be actively wrong to argue with.
|
||||
func (s *Service) MatchForAlbum(albumID int64) (*AlbumMatchView, error) {
|
||||
if albumID <= 0 {
|
||||
return nil, nil //nolint:nilnil // "no album" is not an error.
|
||||
}
|
||||
|
||||
rows, err := s.db.Queries.GetTaggingItemsForAlbum(
|
||||
s.ctx, sql.NullInt64{Int64: albumID, Valid: true},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tagging items for album: %w", err)
|
||||
}
|
||||
|
||||
pending := rows[:0:0]
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Status == "pending" {
|
||||
pending = append(pending, row)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return nil, nil //nolint:nilnil // nothing to say is not an error.
|
||||
}
|
||||
|
||||
// Rows arrive best-score-first, so the first pending one is the
|
||||
// group worth describing. On a multi-disc album that is one disc
|
||||
// of several and GroupCount says so.
|
||||
best := pending[0]
|
||||
|
||||
cands := s.lookupCachedCandidates(best.GroupKey)
|
||||
if len(cands) == 0 {
|
||||
return nil, nil //nolint:nilnil // not scored yet; see the doc comment.
|
||||
}
|
||||
|
||||
locals, err := s.scorer.LocalTracksForGroup(s.ctx, best.GroupKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local tracks for group: %w", err)
|
||||
}
|
||||
|
||||
group := autotag.Group{
|
||||
AlbumName: best.AlbumName,
|
||||
AlbumArtist: best.AlbumArtist,
|
||||
Tracks: locals,
|
||||
Synthetic: best.Synthetic != 0,
|
||||
}
|
||||
|
||||
rec := autotag.Recommend(group, cands)
|
||||
if !autotag.Confident(rec) {
|
||||
return nil, nil //nolint:nilnil // not confident enough to interrupt.
|
||||
}
|
||||
|
||||
top := cands[0]
|
||||
|
||||
// The release the banner names must be the release Apply would
|
||||
// write. Apply with an empty MBID takes the top cached candidate,
|
||||
// which is what this reads — but it is passed explicitly anyway,
|
||||
// so a rescore between the page rendering and the user clicking
|
||||
// cannot swap the album out from under a button they have already
|
||||
// read.
|
||||
return &AlbumMatchView{
|
||||
GroupKey: best.GroupKey,
|
||||
Recommendation: string(rec),
|
||||
Score: top.Score,
|
||||
ReleaseMBID: top.ReleaseMBID,
|
||||
Title: top.Title,
|
||||
ArtistCredit: top.ArtistCredit,
|
||||
TrackCount: best.TrackCount,
|
||||
GroupCount: len(pending),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// seedAlbumGroup writes one album's files, its tagging item and the
|
||||
// durable candidate blob the prefetch would have left behind.
|
||||
//
|
||||
// The candidate list is what a real one looks like in the two ways
|
||||
// that decide the tier: a per-track alignment for every local track,
|
||||
// and a runner-up far enough away not to count as ambiguity.
|
||||
func seedAlbumGroup(
|
||||
t *testing.T,
|
||||
db *database.DB,
|
||||
groupKey string,
|
||||
tracks int,
|
||||
status string,
|
||||
score float64,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
for i := 1; i <= tracks; i++ {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePathFor(groupKey, i),
|
||||
Title: titleFor(i),
|
||||
Artist: "Tideline",
|
||||
Album: "Glass Harbour",
|
||||
AlbumArtist: "Tideline",
|
||||
TrackNumber: int64(i),
|
||||
LengthMs: 200000,
|
||||
LibraryID: 0,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items
|
||||
(group_key, library_id, track_count, album_name, album_artist,
|
||||
disc_number, status, score, best_match_release_mbid)
|
||||
VALUES (?, 0, ?, 'Glass Harbour', 'Tideline', 0, ?, ?, 'rel-1')
|
||||
`, groupKey, tracks, status, score); err != nil {
|
||||
t.Fatalf("insert tagging item: %v", err)
|
||||
}
|
||||
|
||||
var albumID int64
|
||||
if err := db.QueryRowWriter(
|
||||
`SELECT album_id FROM audio_files WHERE group_key = ? LIMIT 1`, groupKey,
|
||||
).Scan(&albumID); err != nil {
|
||||
t.Fatalf("read album id: %v", err)
|
||||
}
|
||||
|
||||
return albumID
|
||||
}
|
||||
|
||||
func filePathFor(groupKey string, n int) string {
|
||||
return "/music/" + groupKey + "/0" + string(rune('0'+n)) + ".mp3"
|
||||
}
|
||||
|
||||
func titleFor(n int) string {
|
||||
return "Track " + string(rune('0'+n))
|
||||
}
|
||||
|
||||
// storeCandidates writes the durable blob GetCandidates would have
|
||||
// cached, with `top` as the winning score.
|
||||
func storeCandidates(
|
||||
t *testing.T, db *database.DB, groupKey string, tracks int, top float64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
aligns := make([]autotag.TrackAlignment, 0, tracks)
|
||||
for i := range tracks {
|
||||
aligns = append(aligns, autotag.TrackAlignment{
|
||||
Status: autotag.AlignmentMatched,
|
||||
LocalIndex: i,
|
||||
})
|
||||
}
|
||||
|
||||
cands := []autotag.Candidate{
|
||||
{
|
||||
ReleaseMBID: "rel-1",
|
||||
ReleaseGroupMBID: "rg-1",
|
||||
Title: "Glass Harbour",
|
||||
ArtistCredit: "Tideline",
|
||||
TrackCount: tracks,
|
||||
Alignments: aligns,
|
||||
Score: top,
|
||||
},
|
||||
{
|
||||
ReleaseMBID: "rel-2",
|
||||
ReleaseGroupMBID: "rg-2",
|
||||
Title: "Something Else",
|
||||
ArtistCredit: "Another Band",
|
||||
TrackCount: tracks,
|
||||
Score: 0.40,
|
||||
},
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(cands)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal candidates: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
`INSERT INTO tagging_candidates (group_key, candidates) VALUES (?, ?)`,
|
||||
groupKey, string(blob),
|
||||
); err != nil {
|
||||
t.Fatalf("insert candidates: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A confident match is what the album page exists to surface.
|
||||
func TestMatchForAlbumSurfacesAConfidentMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-1", 8, "pending", 0.95)
|
||||
storeCandidates(t, db, "grp-1", 8, 0.95)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("no match returned for a strong candidate")
|
||||
}
|
||||
|
||||
if got.Recommendation != string(autotag.RecommendationStrong) {
|
||||
t.Errorf("recommendation = %q, want strong", got.Recommendation)
|
||||
}
|
||||
|
||||
// The release named is the release Apply would write — the page
|
||||
// must not offer one album and tag another.
|
||||
if got.ReleaseMBID != "rel-1" || got.Title != "Glass Harbour" {
|
||||
t.Errorf("named %q/%q, want rel-1/Glass Harbour", got.ReleaseMBID, got.Title)
|
||||
}
|
||||
|
||||
if got.GroupCount != 1 {
|
||||
t.Errorf("groupCount = %d, want 1", got.GroupCount)
|
||||
}
|
||||
}
|
||||
|
||||
// The tier is computed from the candidates, not read off the raw
|
||||
// score — a high number the scorer would have capped must not reach
|
||||
// the page as confidence it withheld.
|
||||
func TestMatchForAlbumDoesNotTrustTheStoredScore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
// Two tracks: below the evidence floor, so `Recommend` caps this
|
||||
// at medium however well it scores.
|
||||
albumID := seedAlbumGroup(t, db, "grp-2", 2, "pending", 0.99)
|
||||
storeCandidates(t, db, "grp-2", 2, 0.99)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v for a two-track folder, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A weak match is not worth interrupting for.
|
||||
func TestMatchForAlbumStaysQuietBelowTheTier(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-3", 8, "pending", 0.60)
|
||||
storeCandidates(t, db, "grp-3", 8, 0.60)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v for a 0.60 match, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An album the user has already answered for is not re-offered.
|
||||
//
|
||||
// `confirmed` covers both a finished apply and an explicit "leave as
|
||||
// is", and arguing with the second would be actively wrong.
|
||||
func TestMatchForAlbumRespectsAnAnswerAlreadyGiven(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, status := range []string{"confirmed", "skipped", "matched"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-"+status, 8, status, 0.95)
|
||||
storeCandidates(t, db, "grp-"+status, 8, 0.95)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v for a %s group, want nothing", got, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A folder nobody has scored yet says nothing, rather than scoring it
|
||||
// now: the MusicBrainz limiter is shared with every page the user can
|
||||
// open, and this runs on page load.
|
||||
func TestMatchForAlbumMakesNoNetworkCallForAnUnscoredFolder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
// No storeCandidates: the prefetch has not reached this folder.
|
||||
albumID := seedAlbumGroup(t, db, "grp-4", 8, "pending", 0.95)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v with no cached candidates, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-disc album is several groups, and the count is what stops
|
||||
// the page offering one button that would retag one disc of two.
|
||||
func TestMatchForAlbumCountsEveryGroupOfTheAlbum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-d1", 8, "pending", 0.95)
|
||||
storeCandidates(t, db, "grp-d1", 8, 0.95)
|
||||
|
||||
// Disc two: same album row, its own folder and tagging group.
|
||||
for i := 1; i <= 6; i++ {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePathFor("grp-d2", i),
|
||||
Title: titleFor(i),
|
||||
Artist: "Tideline",
|
||||
Album: "Glass Harbour",
|
||||
AlbumArtist: "Tideline",
|
||||
TrackNumber: int64(i),
|
||||
DiscNumber: 2,
|
||||
LengthMs: 200000,
|
||||
LibraryID: 0,
|
||||
GroupKey: "grp-d2",
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items
|
||||
(group_key, library_id, track_count, album_name, album_artist,
|
||||
disc_number, status, score)
|
||||
VALUES ('grp-d2', 0, 6, 'Glass Harbour', 'Tideline', 2, 'pending', 0.93)
|
||||
`); err != nil {
|
||||
t.Fatalf("insert disc two: %v", err)
|
||||
}
|
||||
|
||||
storeCandidates(t, db, "grp-d2", 6, 0.93)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("no match returned")
|
||||
}
|
||||
|
||||
if got.GroupCount != 2 {
|
||||
t.Errorf("groupCount = %d, want 2", got.GroupCount)
|
||||
}
|
||||
|
||||
// Best-first: the 0.95 disc is the one described.
|
||||
if got.GroupKey != "grp-d1" {
|
||||
t.Errorf("described %q, want the higher-scoring grp-d1", got.GroupKey)
|
||||
}
|
||||
}
|
||||
|
||||
// An album with no local files at all — a pure catalog page — is not
|
||||
// a question this can answer.
|
||||
func TestMatchForAlbumSaysNothingWithoutAnAlbum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
for _, id := range []int64{0, -1, 4242} {
|
||||
got, err := svc.MatchForAlbum(id)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum(%d): %v", id, err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("MatchForAlbum(%d) = %+v, want nil", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-1
@@ -544,7 +544,7 @@ func (c *Config) SetDefaultPage(page string) error {
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.DefaultPage = DefaultPage(page)
|
||||
c.General.DefaultPage = View(page)
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf(
|
||||
@@ -666,6 +666,124 @@ func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPopupVolume reports whether the bottom bar's volume control is a
|
||||
// click-to-open popup rather than an inline slider (#42).
|
||||
func (c *Config) GetPopupVolume() bool {
|
||||
if c.General == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.General.PopupVolume
|
||||
}
|
||||
|
||||
// SetPopupVolume saves the volume control's presentation.
|
||||
//
|
||||
// Nothing to validate: both values are legal at every width, and the
|
||||
// frontend additionally stands the inline slider down below the phone
|
||||
// breakpoint whatever this says, because that is about room rather than
|
||||
// about preference.
|
||||
func (c *Config) SetPopupVolume(popup bool) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.PopupVolume = popup
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"PopupVolume": popup,
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info("volume control presentation updated", "popup", popup)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetViewVisibility reports which primary views the sidebar should
|
||||
// show, answered for every known view rather than only the ones the
|
||||
// config mentions -- so the frontend filters on a value and never has
|
||||
// to hold a second copy of the defaults.
|
||||
func (c *Config) GetViewVisibility() map[string]bool {
|
||||
if c.General == nil {
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
return general.ResolvedViewVisibility()
|
||||
}
|
||||
|
||||
return c.General.ResolvedViewVisibility()
|
||||
}
|
||||
|
||||
// SetViewVisible shows or hides one primary view.
|
||||
//
|
||||
// Two refusals, both about a state the user cannot get out of from the
|
||||
// UI they would be left with: Settings is never hideable, and the
|
||||
// launch page is never hideable while it is the launch page (change it
|
||||
// first). Hiding a view does not make it unreachable -- `navigate`
|
||||
// still resolves it, which detail views depend on -- it only takes the
|
||||
// nav item away.
|
||||
func (c *Config) SetViewVisible(view string, visible bool) error {
|
||||
spec, known := LookupView(view)
|
||||
if !known {
|
||||
return fmt.Errorf("%w: %q", errUnknownView, view)
|
||||
}
|
||||
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
if !visible {
|
||||
if !spec.Hideable {
|
||||
return fmt.Errorf("%w: %q", errViewNotHideable, view)
|
||||
}
|
||||
|
||||
if spec.ID == c.General.DefaultPage {
|
||||
return fmt.Errorf("%w: %q", errViewIsLaunchPage, view)
|
||||
}
|
||||
}
|
||||
|
||||
if c.General.ViewVisibility == nil {
|
||||
c.General.ViewVisibility = make(map[string]bool, len(Views))
|
||||
}
|
||||
|
||||
c.General.ViewVisibility[view] = visible
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid view visibility: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf("could not save config: %w", err)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"ViewVisibility": c.General.ResolvedViewVisibility(),
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"view visibility updated",
|
||||
"view", view,
|
||||
"visible", visible,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTrackListColumns returns the configured track-list columns.
|
||||
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
||||
if c.TrackList == nil {
|
||||
|
||||
@@ -188,3 +188,43 @@ func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmit_PopupVolumeRoundTripsAndDefaultsToInline pins both halves of
|
||||
// #42's storage decision.
|
||||
//
|
||||
// The **default** is the load-bearing one: inline is what a fresh
|
||||
// install and an existing `config.toml` with no such key must both
|
||||
// produce, which is why the field names the popup rather than the
|
||||
// inline slider. A flag spelled the other way round would default to
|
||||
// false, hand every existing install the popup this issue exists to
|
||||
// stop being the only option, and need a migration to say otherwise.
|
||||
func TestEmit_PopupVolumeRoundTripsAndDefaultsToInline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if conf.GetPopupVolume() {
|
||||
t.Error("a config with no PopupVolume key wants the popup, want inline")
|
||||
}
|
||||
|
||||
if err := conf.SetPopupVolume(true); err != nil {
|
||||
t.Fatalf("SetPopupVolume: %v", err)
|
||||
}
|
||||
|
||||
if !conf.GetPopupVolume() {
|
||||
t.Error("GetPopupVolume = false after setting it true")
|
||||
}
|
||||
|
||||
data := payloadMap(t, rec, events.GeneralConfigChanged)
|
||||
if data["PopupVolume"] != true {
|
||||
t.Errorf("PopupVolume = %v, want true", data["PopupVolume"])
|
||||
}
|
||||
|
||||
if err := conf.SetPopupVolume(false); err != nil {
|
||||
t.Fatalf("SetPopupVolume(false): %v", err)
|
||||
}
|
||||
|
||||
if conf.GetPopupVolume() {
|
||||
t.Error("GetPopupVolume = true after setting it false")
|
||||
}
|
||||
}
|
||||
|
||||
+96
-26
@@ -5,27 +5,13 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DefaultPage identifies which view the app opens to on launch.
|
||||
type DefaultPage string
|
||||
|
||||
// Valid DefaultPage values, matching the frontend's top-level route ids.
|
||||
const (
|
||||
DefaultPageHome DefaultPage = "home"
|
||||
DefaultPageTracks DefaultPage = "tracks"
|
||||
DefaultPageAlbums DefaultPage = "albums"
|
||||
DefaultPageArtists DefaultPage = "artists"
|
||||
DefaultPageGenres DefaultPage = "genres"
|
||||
DefaultPagePlaylists DefaultPage = "playlists"
|
||||
DefaultPageExplore DefaultPage = "explore"
|
||||
DefaultPageDownloads DefaultPage = "downloads"
|
||||
DefaultPageAutotag DefaultPage = "autotag"
|
||||
DefaultPageJobs DefaultPage = "jobs"
|
||||
)
|
||||
|
||||
// DefaultDefaultPage is the launch page for a fresh install.
|
||||
const DefaultDefaultPage = DefaultPageHome
|
||||
const DefaultDefaultPage = ViewHome
|
||||
|
||||
var errUnknownDefaultPage = errors.New("unknown default page")
|
||||
var (
|
||||
errUnknownDefaultPage = errors.New("unknown default page")
|
||||
errViewCannotLaunch = errors.New("view cannot be the launch page")
|
||||
)
|
||||
|
||||
// QueueFallback identifies what plays, if anything, once the queue
|
||||
// runs out with nothing left to auto-advance to.
|
||||
@@ -46,18 +32,52 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
||||
// GeneralConfig holds general application preferences that don't
|
||||
// belong to a more specific subsystem.
|
||||
type GeneralConfig struct {
|
||||
DefaultPage DefaultPage `toml:"DefaultPage"`
|
||||
DefaultPage View `toml:"DefaultPage"`
|
||||
QueueFallback QueueFallback `toml:"QueueFallback"`
|
||||
// ViewVisibility says which sidebar destinations are shown, keyed by
|
||||
// view id.
|
||||
//
|
||||
// **An absent key means that view's own default** (`Views`), and that
|
||||
// is the whole reason this is a map rather than a `HiddenViews
|
||||
// []string` or a struct of booleans. A list's zero value is "hide
|
||||
// nothing", which cannot express Autotag being off by default without
|
||||
// a migration; a struct field for a view that later stops existing is
|
||||
// stored garbage somebody has to deprecate. Here a view added later
|
||||
// gets its own default rather than being invisible or forcibly
|
||||
// visible, an unknown key is dropped on load, and no install needs
|
||||
// migrating in either direction. Same polarity rule as
|
||||
// AllowMeteredCatalogDownload: the zero value is the intended answer.
|
||||
ViewVisibility map[string]bool `toml:"ViewVisibility"`
|
||||
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
|
||||
// be fetched on a connection the platform calls cellular. It defaults
|
||||
// to false, which is the whole point: the zero value is the safe one,
|
||||
// so an existing config with no such key refuses by default rather
|
||||
// than needing a migration to become careful.
|
||||
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
|
||||
// PopupVolume draws the bottom bar's volume as a click-to-open popup
|
||||
// instead of a slider that is always there (#42).
|
||||
//
|
||||
// The polarity is the rule this file already states twice: **the
|
||||
// zero value is the intended answer**. Inline is the new default, so
|
||||
// the flag has to name the *other* choice — an `InlineVolume bool`
|
||||
// would default to false and give every existing install the popup
|
||||
// this issue exists to stop being the only option, and would need a
|
||||
// migration to say otherwise.
|
||||
PopupVolume bool `toml:"PopupVolume"`
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||
//
|
||||
// A launch page naming a *retired* view is treated as a zero value
|
||||
// rather than as an error, because the alternative is an app that will
|
||||
// not start for anyone who had that page selected when it was removed.
|
||||
// An unknown-but-not-retired name still fails Validate: that is a typo,
|
||||
// and telling someone about it is the useful answer.
|
||||
func (c *GeneralConfig) ApplyDefaults() {
|
||||
if _, retired := RetiredViews[c.DefaultPage]; retired {
|
||||
c.DefaultPage = ""
|
||||
}
|
||||
|
||||
if c.DefaultPage == "" {
|
||||
c.DefaultPage = DefaultDefaultPage
|
||||
}
|
||||
@@ -71,15 +91,17 @@ func (c *GeneralConfig) ApplyDefaults() {
|
||||
func (c *GeneralConfig) Validate() error {
|
||||
c.ApplyDefaults()
|
||||
|
||||
switch c.DefaultPage {
|
||||
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
|
||||
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
|
||||
DefaultPageAutotag, DefaultPageJobs:
|
||||
// Valid.
|
||||
default:
|
||||
spec, known := LookupView(string(c.DefaultPage))
|
||||
if !known {
|
||||
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
|
||||
}
|
||||
|
||||
if !spec.CanLaunch {
|
||||
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
|
||||
}
|
||||
|
||||
c.normalizeViewVisibility()
|
||||
|
||||
switch c.QueueFallback {
|
||||
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
|
||||
// Valid.
|
||||
@@ -89,3 +111,51 @@ func (c *GeneralConfig) Validate() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeViewVisibility drops what the stored map may not say, and
|
||||
// repairs the one invariant the shell depends on.
|
||||
//
|
||||
// Three things are dropped or forced, and all three are reachable only
|
||||
// from a hand-edited config or from a version that knew different
|
||||
// views: an unknown id (a view removed since, e.g. when #27 folds Jobs
|
||||
// into Settings) says nothing to anybody; a view that is not Hideable
|
||||
// cannot be false; and **the launch page is always visible**, because
|
||||
// otherwise an install lands on a page with no nav item pointing at it.
|
||||
//
|
||||
// That last one is a *repair* here and an *error* at the setter
|
||||
// (SetViewVisible), deliberately. On load there is nobody to tell and
|
||||
// the honest reading of "my launch page is Autotag" is that this user
|
||||
// wants Autotag, so it is un-hidden rather than the launch page being
|
||||
// silently reset to something they did not choose. At the setter the
|
||||
// user is right there and can act, so it refuses and says why.
|
||||
func (c *GeneralConfig) normalizeViewVisibility() {
|
||||
for id := range c.ViewVisibility {
|
||||
spec, known := LookupView(id)
|
||||
if !known || !spec.Hideable {
|
||||
delete(c.ViewVisibility, id)
|
||||
}
|
||||
}
|
||||
|
||||
if visible, ok := c.ViewVisibility[string(c.DefaultPage)]; ok && !visible {
|
||||
c.ViewVisibility[string(c.DefaultPage)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvedViewVisibility answers for every known view, so no caller has
|
||||
// to know the defaults -- the frontend included, which is why the
|
||||
// binding returns this rather than the stored map.
|
||||
func (c *GeneralConfig) ResolvedViewVisibility() map[string]bool {
|
||||
resolved := make(map[string]bool, len(Views))
|
||||
|
||||
for _, v := range Views {
|
||||
visible := v.VisibleByDefault
|
||||
|
||||
if stored, ok := c.ViewVisibility[string(v.ID)]; ok && v.Hideable {
|
||||
visible = stored
|
||||
}
|
||||
|
||||
resolved[string(v.ID)] = visible
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package config
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errUnknownView = errors.New("unknown view")
|
||||
errViewNotHideable = errors.New("view cannot be hidden")
|
||||
errViewIsLaunchPage = errors.New("view is the launch page")
|
||||
)
|
||||
|
||||
// View identifies one of the shell's primary destinations -- the
|
||||
// things the sidebar lists and `index.ts` knows as `VIEW_TAGS`.
|
||||
type View string
|
||||
|
||||
// The primary views, in no particular order: the sidebar owns the order
|
||||
// it draws them in, because that is presentation.
|
||||
const (
|
||||
ViewHome View = "home"
|
||||
ViewPlaylists View = "playlists"
|
||||
ViewArtists View = "artists"
|
||||
ViewGenres View = "genres"
|
||||
ViewAlbums View = "albums"
|
||||
ViewTracks View = "tracks"
|
||||
ViewExplore View = "explore"
|
||||
ViewDownloads View = "downloads"
|
||||
ViewAutotag View = "autotag"
|
||||
ViewSettings View = "settings"
|
||||
)
|
||||
|
||||
// RetiredViews are destinations that used to exist and no longer do.
|
||||
//
|
||||
// A *visibility* entry for a removed view needs no such list: it is a
|
||||
// key in a map, and an unknown key is dropped on load. A `DefaultPage`
|
||||
// is a **value**, and an unknown one fails validation -- which on the
|
||||
// load path means the app refuses to start rather than a setting being
|
||||
// ignored. So the one shape that cannot be retired for free is named
|
||||
// here and reset to the default instead.
|
||||
//
|
||||
// `jobs` was folded into Settings by #27: library scans under
|
||||
// Libraries, index work under Search Index, downloads under the
|
||||
// download clients, and the autotag apply into the Autotag view.
|
||||
var RetiredViews = map[View]struct{}{
|
||||
"jobs": {},
|
||||
}
|
||||
|
||||
// ViewSpec is what the backend knows about a destination. The label and
|
||||
// the icon are deliberately absent: those are presentation, they live
|
||||
// beside the rest of the app's icon vocabulary in
|
||||
// `frontend/src/utils/icon-language.ts`, and a Go copy of them would be
|
||||
// a second thing to keep in step for nothing.
|
||||
type ViewSpec struct {
|
||||
// ID is the view name the frontend navigates by.
|
||||
ID View
|
||||
// VisibleByDefault is what an install gets when the config says
|
||||
// nothing about this view -- which is every install until somebody
|
||||
// changes it, and every view added after this one shipped.
|
||||
VisibleByDefault bool
|
||||
// Hideable is false for Settings alone. It is a property of the
|
||||
// view rather than a check in the setter because `config.toml` is
|
||||
// hand-editable, and an app that can be locked out of its own
|
||||
// Settings by a typo is a support problem nobody can debug
|
||||
// remotely.
|
||||
Hideable bool
|
||||
// CanLaunch reports whether the view may be the launch page.
|
||||
// Settings is the only one that may not, which is the shape the
|
||||
// DefaultPage enum already had.
|
||||
CanLaunch bool
|
||||
}
|
||||
|
||||
// Views is the one list of primary destinations, in the order Settings
|
||||
// offers them.
|
||||
//
|
||||
// It is the single source for three things that used to be written down
|
||||
// separately: which views exist, which of them may be the launch page
|
||||
// (`DefaultPage`'s validation reads it), and what an unconfigured
|
||||
// install shows.
|
||||
//
|
||||
// Autotag is the one view hidden by default: it rewrites tags on disk,
|
||||
// which is not what most libraries want on day one, and #25 asks for it
|
||||
// to be turned on deliberately.
|
||||
var Views = []ViewSpec{
|
||||
{ID: ViewHome, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewPlaylists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewArtists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewGenres, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewAlbums, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewTracks, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
|
||||
}
|
||||
|
||||
// LookupView returns the spec for a view id.
|
||||
func LookupView(id string) (ViewSpec, bool) {
|
||||
for _, v := range Views {
|
||||
if string(v.ID) == id {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
|
||||
return ViewSpec{}, false
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newViewTestConfig builds a Config backed by a temp file, which is all
|
||||
// SetViewVisible needs: it saves and emits, and the emit is a no-op
|
||||
// without a running app.
|
||||
func newViewTestConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
|
||||
c := &Config{
|
||||
logger: slog.Default(),
|
||||
filePath: filepath.Join(t.TempDir(), "config.toml"),
|
||||
}
|
||||
|
||||
// Load a file that is not there: that is what marks the config
|
||||
// loaded, without which Save refuses on the *second* write.
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// A view the config says nothing about takes its own default, which is
|
||||
// what makes this need no migration in either direction: an existing
|
||||
// install gets Autotag hidden without a key, and a view added later
|
||||
// gets its own answer rather than the list's.
|
||||
func TestViewVisibilityDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
resolved := general.ResolvedViewVisibility()
|
||||
|
||||
if len(resolved) != len(Views) {
|
||||
t.Fatalf("resolved %d views, want %d", len(resolved), len(Views))
|
||||
}
|
||||
|
||||
if resolved[string(ViewAutotag)] {
|
||||
t.Error("autotag should be hidden by default")
|
||||
}
|
||||
|
||||
for _, v := range Views {
|
||||
if v.ID == ViewAutotag {
|
||||
continue
|
||||
}
|
||||
|
||||
if !resolved[string(v.ID)] {
|
||||
t.Errorf("%s should be visible by default", v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stored answer wins over the default, in both directions -- turning
|
||||
// Autotag on is the whole user-facing point.
|
||||
func TestViewVisibilityStoredWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{
|
||||
ViewVisibility: map[string]bool{
|
||||
string(ViewAutotag): true,
|
||||
string(ViewExplore): false,
|
||||
},
|
||||
}
|
||||
general.ApplyDefaults()
|
||||
|
||||
resolved := general.ResolvedViewVisibility()
|
||||
|
||||
if !resolved[string(ViewAutotag)] {
|
||||
t.Error("autotag was switched on and should be visible")
|
||||
}
|
||||
|
||||
if resolved[string(ViewExplore)] {
|
||||
t.Error("explore was switched off and should be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// A key for a view that no longer exists is discarded rather than
|
||||
// migrated. This is the property the #25-before-#27 ordering rests on:
|
||||
// when Jobs folds into Settings, `jobs = true` in somebody's config is
|
||||
// a key nothing asks about, not a cleanup task.
|
||||
func TestValidateDropsUnknownAndUnhideableViews(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{
|
||||
ViewVisibility: map[string]bool{
|
||||
"a-view-that-was-removed": true,
|
||||
string(ViewSettings): false,
|
||||
string(ViewAutotag): true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := general.ViewVisibility["a-view-that-was-removed"]; ok {
|
||||
t.Error("an unknown view id should be dropped on load")
|
||||
}
|
||||
|
||||
if _, ok := general.ViewVisibility[string(ViewSettings)]; ok {
|
||||
t.Error("settings is not hideable and should not be stored")
|
||||
}
|
||||
|
||||
if !general.ResolvedViewVisibility()[string(ViewSettings)] {
|
||||
t.Error("settings must resolve visible whatever the file said")
|
||||
}
|
||||
}
|
||||
|
||||
// On load there is nobody to tell, so a launch page hidden by a
|
||||
// hand-edited file is un-hidden rather than the launch page being
|
||||
// reset to something the user did not choose.
|
||||
func TestValidateRevealsAHiddenLaunchPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{
|
||||
DefaultPage: ViewAutotag,
|
||||
ViewVisibility: map[string]bool{
|
||||
string(ViewAutotag): false,
|
||||
},
|
||||
}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if !general.ResolvedViewVisibility()[string(ViewAutotag)] {
|
||||
t.Error("the launch page must be visible")
|
||||
}
|
||||
}
|
||||
|
||||
// A launch page naming a view that no longer exists resets to the
|
||||
// default instead of failing validation, which on the load path would
|
||||
// mean the app refusing to start for whoever had it selected.
|
||||
//
|
||||
// This is the one shape #25's storage decision does *not* make free: a
|
||||
// visibility entry is a key and an unknown key is dropped, but a launch
|
||||
// page is a value.
|
||||
func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: "jobs"}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if general.DefaultPage != DefaultDefaultPage {
|
||||
t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that is merely wrong is still an error: that is a typo, and
|
||||
// saying so is more useful than ignoring it.
|
||||
func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: "nonsense"}
|
||||
|
||||
if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) {
|
||||
t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A retired view is not a view, so nothing offers it and nothing
|
||||
// resolves it -- the visibility map included.
|
||||
func TestARetiredViewIsGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for id := range RetiredViews {
|
||||
if _, ok := LookupView(string(id)); ok {
|
||||
t.Errorf("%s is retired but still in Views", id)
|
||||
}
|
||||
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
if _, ok := general.ResolvedViewVisibility()[string(id)]; ok {
|
||||
t.Errorf("%s is retired but still resolves a visibility", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings may not be the launch page, which is the shape the old
|
||||
// DefaultPage enum had and is now read off the same table.
|
||||
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: ViewSettings}
|
||||
|
||||
err := general.Validate()
|
||||
if !errors.Is(err, errViewCannotLaunch) {
|
||||
t.Fatalf("Validate() error = %v, want errViewCannotLaunch", err)
|
||||
}
|
||||
}
|
||||
|
||||
// At the setter the user is present and can act, so the two states
|
||||
// they could not get out of are refused rather than repaired.
|
||||
func TestSetViewVisibleRefusals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
view string
|
||||
visible bool
|
||||
want error
|
||||
}{
|
||||
{"settings is never hideable", string(ViewSettings), false, errViewNotHideable},
|
||||
{"the launch page is not hideable", string(ViewHome), false, errViewIsLaunchPage},
|
||||
{"an unknown view is not a setting", "nonsense", false, errUnknownView},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newViewTestConfig(t)
|
||||
|
||||
err := c.SetViewVisible(tt.view, tt.visible)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("SetViewVisible() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Showing a view is never refused, including Settings and the launch
|
||||
// page -- there is no state to be stuck in.
|
||||
func TestSetViewVisibleShowsAnything(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newViewTestConfig(t)
|
||||
|
||||
for _, v := range Views {
|
||||
if err := c.SetViewVisible(string(v.ID), true); err != nil {
|
||||
t.Fatalf("SetViewVisible(%q, true) error: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.GetViewVisibility()[string(ViewAutotag)] {
|
||||
t.Error("autotag was switched on and should be visible")
|
||||
}
|
||||
}
|
||||
|
||||
// The stored map survives a save/load round trip, which is what a
|
||||
// map-valued TOML key is worth checking for.
|
||||
func TestViewVisibilityRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
|
||||
original := &Config{logger: slog.Default(), filePath: path}
|
||||
if err := original.Load(); err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
if err := original.SetViewVisible(string(ViewAutotag), true); err != nil {
|
||||
t.Fatalf("SetViewVisible() error: %v", err)
|
||||
}
|
||||
|
||||
if err := original.SetViewVisible(string(ViewExplore), false); err != nil {
|
||||
t.Fatalf("SetViewVisible() error: %v", err)
|
||||
}
|
||||
|
||||
loaded := &Config{logger: slog.Default(), filePath: path}
|
||||
if err := loaded.Load(); err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
resolved := loaded.GetViewVisibility()
|
||||
|
||||
if !resolved[string(ViewAutotag)] {
|
||||
t.Error("autotag should have loaded as visible")
|
||||
}
|
||||
|
||||
if resolved[string(ViewExplore)] {
|
||||
t.Error("explore should have loaded as hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// Every view the shell can launch into is a view the sidebar can show,
|
||||
// or an install could land on a page with no nav item and no setting
|
||||
// pointing at it.
|
||||
func TestEveryLaunchableViewIsAView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, v := range Views {
|
||||
if !v.CanLaunch {
|
||||
continue
|
||||
}
|
||||
|
||||
if !v.Hideable {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := LookupView(string(v.ID)); !ok {
|
||||
t.Errorf("%s is launchable but not a known view", v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,31 @@ const (
|
||||
// enforces this at runtime; it is also the floor below which a
|
||||
// reported size is treated as bogus and not persisted.
|
||||
//
|
||||
// 800x600 is where the shell was measured to still work, rather
|
||||
// than a round number: below ~780 the header's subtitle wraps and
|
||||
// pushes the title out of the 4em top bar, and below ~600 tall the
|
||||
// eleven sidebar items no longer fit at once. The previous
|
||||
// 512x384 was aspirational — at 700x480 the sidebar overflowed
|
||||
// behind the player bar with no scroll and Settings and Jobs could
|
||||
// not be reached at all.
|
||||
// **Both reasons this comment used to give have expired**, and the
|
||||
// value is right for a third one. It said the floor was 800x600
|
||||
// because "below ~780 the header's subtitle wraps and pushes the
|
||||
// title out of the 4em top bar" and "below ~600 tall the eleven
|
||||
// sidebar items no longer fit at once". Neither mechanism can
|
||||
// happen now: the subtitle is display:none from 899px down
|
||||
// (index.css), and the sidebar host is overflow-y:auto — measured
|
||||
// at 600x460, its scrollHeight is 434 against a 332px client and
|
||||
// Settings is reachable after scrolling. A floor defended by two
|
||||
// mechanisms that no longer exist is a number nobody can argue
|
||||
// with, which is worse than either answer.
|
||||
//
|
||||
// It stays 800x600 because that is where the *desktop* chrome
|
||||
// stops being comfortable — the Compact band of plan 018's size
|
||||
// matrix (#24) — and not because the app breaks below it. It does
|
||||
// not: under 600px wide the phone layout takes over (bottom-nav,
|
||||
// no sidebar) and the shell fits 320px exactly, which is what
|
||||
// makes this a comfort floor rather than a correctness one, and
|
||||
// why a very small window reflows instead of becoming a
|
||||
// mini-player (#12 is a second always-on-top window, not a mode of
|
||||
// this one).
|
||||
//
|
||||
// The previous 512x384 was aspirational — at 700x480 the sidebar
|
||||
// overflowed behind the player bar with no scroll and Settings and
|
||||
// Jobs could not be reached at all.
|
||||
MinWidth = 800
|
||||
// MinHeight is the smallest allowed window height in pixels.
|
||||
MinHeight = 600
|
||||
|
||||
@@ -40,7 +40,8 @@ WHERE id = ? AND (mbid IS NULL OR mbid = '');
|
||||
-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||
SELECT id, pending_release_mbid FROM albums
|
||||
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||
AND (mbid IS NULL OR mbid = '');
|
||||
AND (mbid IS NULL OR mbid = '')
|
||||
LIMIT ?;
|
||||
|
||||
-- name: DeleteAlbum :exec
|
||||
DELETE FROM albums WHERE id = ?;
|
||||
@@ -135,3 +136,49 @@ SELECT
|
||||
) AS INTEGER) AS known
|
||||
FROM audio_files a
|
||||
WHERE a.album_id = sqlc.arg(album_id);
|
||||
|
||||
-- name: GetAlbumsCompleteness :many
|
||||
-- The same question as GetAlbumCompleteness, asked of a screenful of
|
||||
-- albums at once.
|
||||
--
|
||||
-- A card grid cannot afford one query per card, and the answer it wants
|
||||
-- is the one thing a badge cannot guess: an album held 9 tracks of 12
|
||||
-- must show the count, never a bare tick. So this is one query for the
|
||||
-- whole grid, asked only of the cards that have a local album id.
|
||||
--
|
||||
-- It is two grouping levels rather than the single-album form's
|
||||
-- correlated subqueries, because a correlated subquery in the FROM
|
||||
-- clause is not something SQLite will reliably do -- and because the
|
||||
-- slice may only be spelled once, or sqlc expands it twice with
|
||||
-- independently numbered placeholders.
|
||||
--
|
||||
-- The per-disc level is where the meaning is, and it is the same
|
||||
-- meaning as the single-album query. `owned` counts DISTINCT track
|
||||
-- numbers within a disc (this app detects duplicates, and counting two
|
||||
-- files of track 3 twice would report a short album as complete), with
|
||||
-- a file that declares no track number falling back to its own id
|
||||
-- because three untagged files are three tracks and not one.
|
||||
-- `expected` takes each disc's declared total and sums over discs,
|
||||
-- since a total is declared per disc and a release total written on
|
||||
-- every file of a two-disc album would double its expectation. A disc
|
||||
-- whose files declared nothing contributes a NULL that SUM ignores,
|
||||
-- and `known` is what says the album is therefore unanswerable.
|
||||
WITH per_disc AS (
|
||||
SELECT
|
||||
album_id AS album_id,
|
||||
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
|
||||
AS owned_on_disc,
|
||||
MAX(total_tracks) AS disc_total,
|
||||
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
|
||||
AS discs_without_a_total
|
||||
FROM audio_files
|
||||
WHERE album_id IN (sqlc.slice('album_ids'))
|
||||
GROUP BY album_id, COALESCE(disc_number, 1)
|
||||
)
|
||||
SELECT
|
||||
CAST(album_id AS INTEGER) AS album_id,
|
||||
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
|
||||
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
|
||||
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
|
||||
FROM per_disc
|
||||
GROUP BY album_id;
|
||||
|
||||
@@ -342,3 +342,35 @@ WHERE ti.status = 'pending'
|
||||
)
|
||||
ORDER BY ti.group_key
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetTaggingItemsForAlbum :many
|
||||
-- Every tagging group holding a file of this album.
|
||||
--
|
||||
-- The join is `audio_files.group_key`, not a key derived from the
|
||||
-- album's folder path: a group carved out of a mixed-bag folder by
|
||||
-- SplitMixedFolder is keyed on its tags rather than on a directory,
|
||||
-- so a path-derived key finds nothing for exactly the messiest
|
||||
-- libraries this is meant to help.
|
||||
--
|
||||
-- Usually one row. A multi-disc album is one group per disc, which
|
||||
-- the caller has to know about rather than average over -- applying
|
||||
-- to "the album" would silently retag one disc of three.
|
||||
SELECT
|
||||
ti.group_key,
|
||||
ti.status,
|
||||
ti.score,
|
||||
ti.best_match_release_mbid,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
ti.synthetic
|
||||
FROM tagging_items ti
|
||||
WHERE ti.group_key IN (
|
||||
SELECT DISTINCT af.group_key
|
||||
FROM audio_files af
|
||||
WHERE af.album_id = sqlc.arg(album_id) AND af.group_key != ''
|
||||
)
|
||||
AND ti.cleared_at IS NULL
|
||||
-- Best first, with an unscored group last rather than first: NULL
|
||||
-- sorts low in SQLite and DESC would put it at the top.
|
||||
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key;
|
||||
|
||||
@@ -8,6 +8,7 @@ package sqlcgen
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deleteAlbum = `-- name: DeleteAlbum :exec
|
||||
@@ -234,10 +235,103 @@ func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArti
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAlbumsCompleteness = `-- name: GetAlbumsCompleteness :many
|
||||
WITH per_disc AS (
|
||||
SELECT
|
||||
album_id AS album_id,
|
||||
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
|
||||
AS owned_on_disc,
|
||||
MAX(total_tracks) AS disc_total,
|
||||
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
|
||||
AS discs_without_a_total
|
||||
FROM audio_files
|
||||
WHERE album_id IN (/*SLICE:album_ids*/?)
|
||||
GROUP BY album_id, COALESCE(disc_number, 1)
|
||||
)
|
||||
SELECT
|
||||
CAST(album_id AS INTEGER) AS album_id,
|
||||
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
|
||||
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
|
||||
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
|
||||
FROM per_disc
|
||||
GROUP BY album_id
|
||||
`
|
||||
|
||||
type GetAlbumsCompletenessRow struct {
|
||||
AlbumID int64
|
||||
Owned int64
|
||||
Expected int64
|
||||
Known int64
|
||||
}
|
||||
|
||||
// The same question as GetAlbumCompleteness, asked of a screenful of
|
||||
// albums at once.
|
||||
//
|
||||
// A card grid cannot afford one query per card, and the answer it wants
|
||||
// is the one thing a badge cannot guess: an album held 9 tracks of 12
|
||||
// must show the count, never a bare tick. So this is one query for the
|
||||
// whole grid, asked only of the cards that have a local album id.
|
||||
//
|
||||
// It is two grouping levels rather than the single-album form's
|
||||
// correlated subqueries, because a correlated subquery in the FROM
|
||||
// clause is not something SQLite will reliably do -- and because the
|
||||
// slice may only be spelled once, or sqlc expands it twice with
|
||||
// independently numbered placeholders.
|
||||
//
|
||||
// The per-disc level is where the meaning is, and it is the same
|
||||
// meaning as the single-album query. `owned` counts DISTINCT track
|
||||
// numbers within a disc (this app detects duplicates, and counting two
|
||||
// files of track 3 twice would report a short album as complete), with
|
||||
// a file that declares no track number falling back to its own id
|
||||
// because three untagged files are three tracks and not one.
|
||||
// `expected` takes each disc's declared total and sums over discs,
|
||||
// since a total is declared per disc and a release total written on
|
||||
// every file of a two-disc album would double its expectation. A disc
|
||||
// whose files declared nothing contributes a NULL that SUM ignores,
|
||||
// and `known` is what says the album is therefore unanswerable.
|
||||
func (q *Queries) GetAlbumsCompleteness(ctx context.Context, albumIds []sql.NullInt64) ([]GetAlbumsCompletenessRow, error) {
|
||||
query := getAlbumsCompleteness
|
||||
var queryParams []interface{}
|
||||
if len(albumIds) > 0 {
|
||||
for _, v := range albumIds {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:album_ids*/?", strings.Repeat(",?", len(albumIds))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:album_ids*/?", "NULL", 1)
|
||||
}
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAlbumsCompletenessRow
|
||||
for rows.Next() {
|
||||
var i GetAlbumsCompletenessRow
|
||||
if err := rows.Scan(
|
||||
&i.AlbumID,
|
||||
&i.Owned,
|
||||
&i.Expected,
|
||||
&i.Known,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||
SELECT id, pending_release_mbid FROM albums
|
||||
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||
AND (mbid IS NULL OR mbid = '')
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
type GetAlbumsWithPendingReleaseMBIDRow struct {
|
||||
@@ -245,8 +339,8 @@ type GetAlbumsWithPendingReleaseMBIDRow struct {
|
||||
PendingReleaseMbid sql.NullString
|
||||
}
|
||||
|
||||
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
|
||||
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context, limit int64) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -231,6 +231,82 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTaggingItemsForAlbum = `-- name: GetTaggingItemsForAlbum :many
|
||||
SELECT
|
||||
ti.group_key,
|
||||
ti.status,
|
||||
ti.score,
|
||||
ti.best_match_release_mbid,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
ti.synthetic
|
||||
FROM tagging_items ti
|
||||
WHERE ti.group_key IN (
|
||||
SELECT DISTINCT af.group_key
|
||||
FROM audio_files af
|
||||
WHERE af.album_id = ?1 AND af.group_key != ''
|
||||
)
|
||||
AND ti.cleared_at IS NULL
|
||||
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key
|
||||
`
|
||||
|
||||
type GetTaggingItemsForAlbumRow struct {
|
||||
GroupKey string
|
||||
Status string
|
||||
Score sql.NullFloat64
|
||||
BestMatchReleaseMbid sql.NullString
|
||||
TrackCount int64
|
||||
AlbumName string
|
||||
AlbumArtist string
|
||||
Synthetic int64
|
||||
}
|
||||
|
||||
// Every tagging group holding a file of this album.
|
||||
//
|
||||
// The join is `audio_files.group_key`, not a key derived from the
|
||||
// album's folder path: a group carved out of a mixed-bag folder by
|
||||
// SplitMixedFolder is keyed on its tags rather than on a directory,
|
||||
// so a path-derived key finds nothing for exactly the messiest
|
||||
// libraries this is meant to help.
|
||||
//
|
||||
// Usually one row. A multi-disc album is one group per disc, which
|
||||
// the caller has to know about rather than average over -- applying
|
||||
// to "the album" would silently retag one disc of three.
|
||||
// Best first, with an unscored group last rather than first: NULL
|
||||
// sorts low in SQLite and DESC would put it at the top.
|
||||
func (q *Queries) GetTaggingItemsForAlbum(ctx context.Context, albumID sql.NullInt64) ([]GetTaggingItemsForAlbumRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTaggingItemsForAlbum, albumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTaggingItemsForAlbumRow
|
||||
for rows.Next() {
|
||||
var i GetTaggingItemsForAlbumRow
|
||||
if err := rows.Scan(
|
||||
&i.GroupKey,
|
||||
&i.Status,
|
||||
&i.Score,
|
||||
&i.BestMatchReleaseMbid,
|
||||
&i.TrackCount,
|
||||
&i.AlbumName,
|
||||
&i.AlbumArtist,
|
||||
&i.Synthetic,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listAudioFilesInTaggingGroup = `-- name: ListAudioFilesInTaggingGroup :many
|
||||
SELECT
|
||||
af.id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,15 +310,35 @@ func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
|
||||
|
||||
summary.Synced = r.syncExternalLists(ctx)
|
||||
|
||||
attempted, started, err := r.attemptDue(ctx, force)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
// Nothing is searched for when there is nothing to search with, and
|
||||
// the point is what that *does not* do to the list.
|
||||
//
|
||||
// Attempting anyway is not merely wasted work: every request comes
|
||||
// back "no download clients are enabled", which RecordAttempt writes
|
||||
// down as an attempt and schedules a retry for -- so a user who has
|
||||
// deliberately built a wanted list with no client watched their
|
||||
// requests accrue failures and announce "next check in 6 hours"
|
||||
// about a check that cannot happen. Wanting something without a way
|
||||
// to fetch it is a supported thing to do; being told it is being
|
||||
// looked for is a lie.
|
||||
//
|
||||
// Everything above this line still runs: an artist subscription
|
||||
// still expands, and a request the user satisfied by some other
|
||||
// route -- ripped, bought, copied in -- is still retired, because
|
||||
// neither needs a provider.
|
||||
summary.NoProviders = len(r.manager.enabledProviders()) == 0
|
||||
|
||||
if !summary.NoProviders {
|
||||
attempted, started, err := r.attemptDue(ctx, force)
|
||||
if err != nil {
|
||||
return summary, err
|
||||
}
|
||||
|
||||
summary.Attempted = attempted
|
||||
summary.Started = started
|
||||
}
|
||||
|
||||
summary.Attempted = attempted
|
||||
summary.Started = started
|
||||
summary.Waiting = r.countWaiting(ctx)
|
||||
summary.NoProviders = len(r.manager.enabledProviders()) == 0
|
||||
|
||||
r.logger.Info(
|
||||
"reconciled request list",
|
||||
|
||||
@@ -453,6 +453,15 @@ func TestReconcileRespectsBatchSize(t *testing.T) {
|
||||
f := newReconcileFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A client that searches and finds nothing. The batch size is about
|
||||
// how many requests one pass *searches for*, which only means
|
||||
// anything when there is something to search with -- a pass with no
|
||||
// provider now attempts nothing at all, deliberately.
|
||||
f.manager.installProvider(
|
||||
Config{ID: 1, Priority: 50},
|
||||
NewFakeProvider(1, "finds-nothing", Caps{CanSearch: true}),
|
||||
)
|
||||
|
||||
f.reconciler.SetBatch(2)
|
||||
|
||||
for _, mbid := range []string{"rg-1", "rg-2", "rg-3", "rg-4"} {
|
||||
@@ -593,3 +602,72 @@ func TestSummaryReportsNoProviders(t *testing.T) {
|
||||
t.Error("summary did not report that no download client is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// ...and it does not search, which is the part the user sees.
|
||||
//
|
||||
// Attempting with no provider fails every request with "no download
|
||||
// clients are enabled", and RecordAttempt writes that down as an
|
||||
// attempt and schedules a retry -- so a wanted list built deliberately
|
||||
// without a client accrued failures and announced "next check in 6
|
||||
// hours" about a check that cannot happen. Wanting something with no
|
||||
// way to fetch it is supported; being told it is being looked for is
|
||||
// a lie.
|
||||
func TestNoProvidersMeansNoAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f := newReconcileFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := f.store.AddRequest(ctx, Request{
|
||||
MBID: "rg-1",
|
||||
Entity: EntityReleaseGroup,
|
||||
LibraryID: 1,
|
||||
Title: "OK Computer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddRequest: %v", err)
|
||||
}
|
||||
|
||||
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
|
||||
|
||||
summary, err := f.reconciler.RunNow(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunNow: %v", err)
|
||||
}
|
||||
|
||||
if summary.Attempted != 0 {
|
||||
t.Errorf("attempted %d requests with no client to search with, want 0",
|
||||
summary.Attempted)
|
||||
}
|
||||
|
||||
// The list still knows what is on it: "nothing happened" has to be
|
||||
// reportable as "nothing was searched for, of the one thing you
|
||||
// want" rather than as silence.
|
||||
if summary.Waiting != 1 {
|
||||
t.Errorf("summary reported %d waiting, want 1", summary.Waiting)
|
||||
}
|
||||
|
||||
req, err := f.store.GetRequest(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequest: %v", err)
|
||||
}
|
||||
|
||||
if req.Attempts != 0 {
|
||||
t.Errorf("attempts = %d, want 0: a pass that could not search did not",
|
||||
req.Attempts)
|
||||
}
|
||||
|
||||
if req.LastError != "" {
|
||||
t.Errorf("lastError = %q, want empty: the request did not fail, it "+
|
||||
"was never tried", req.LastError)
|
||||
}
|
||||
|
||||
// A new request is due immediately (next_try_at is set to now on
|
||||
// insert), so the fault is not the presence of a time -- it is a
|
||||
// time pushed into the future by a failed attempt, which is what the
|
||||
// UI renders as "next check in 6 hours".
|
||||
if req.NextTryAt.After(time.Now().Add(time.Minute)) {
|
||||
t.Errorf("next try scheduled for %v: a check that cannot happen was "+
|
||||
"put on the clock", req.NextTryAt)
|
||||
}
|
||||
}
|
||||
|
||||
+44
-31
@@ -2,6 +2,7 @@ package explore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/jobs"
|
||||
)
|
||||
@@ -279,37 +281,32 @@ func (e *Service) BackfillReleaseGroupMBIDs() {
|
||||
go e.backfillReleaseGroupMBIDs(e.ctx)
|
||||
}
|
||||
|
||||
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
|
||||
rows, err := e.db.QueryContext(
|
||||
"SELECT id, pending_release_mbid FROM release_groups "+
|
||||
"WHERE (mbid IS NULL OR mbid = '') "+
|
||||
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
|
||||
"LIMIT ?",
|
||||
releaseGroupMBIDBackfillMaxPerRun,
|
||||
// pendingReleaseMBIDs is the albums this pass has work to do on.
|
||||
//
|
||||
// It is separate from the pass, and returns its error rather than
|
||||
// logging it, so that a test can assert the statement runs against the
|
||||
// real schema. That is not a general preference -- it is this
|
||||
// statement's history: it named `release_groups`, a table plan 013
|
||||
// renamed to `albums`, so it failed on every launch since e7748f1 and
|
||||
// the pass returned quietly having done nothing. A test of the pass
|
||||
// as a whole cannot see that, because a query error and an empty
|
||||
// library are the same early return.
|
||||
func (e *Service) pendingReleaseMBIDs(
|
||||
ctx context.Context,
|
||||
) ([]sqlcgen.GetAlbumsWithPendingReleaseMBIDRow, error) {
|
||||
return e.db.ReadQueries.GetAlbumsWithPendingReleaseMBID(
|
||||
ctx, releaseGroupMBIDBackfillMaxPerRun,
|
||||
)
|
||||
}
|
||||
|
||||
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
|
||||
pending, err := e.pendingReleaseMBIDs(ctx)
|
||||
if err != nil {
|
||||
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type pendingRow struct {
|
||||
id int64
|
||||
releaseMBID string
|
||||
}
|
||||
|
||||
var pending []pendingRow
|
||||
|
||||
for rows.Next() {
|
||||
var p pendingRow
|
||||
|
||||
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
|
||||
pending = append(pending, p)
|
||||
}
|
||||
}
|
||||
|
||||
_ = rows.Close()
|
||||
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -335,7 +332,7 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
|
||||
|
||||
job.progress(i, len(pending))
|
||||
|
||||
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
|
||||
release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
|
||||
if err != nil || release.ReleaseGroupMBID == "" {
|
||||
// Left alone rather than cleared: LookupRelease caches its
|
||||
// answer (success or a release with no group) for 7 days,
|
||||
@@ -344,12 +341,19 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = e.db.ExecContext(
|
||||
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
|
||||
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
|
||||
release.ReleaseGroupMBID, p.id,
|
||||
)
|
||||
if err != nil {
|
||||
// The writer, not ReadQueries: an UPDATE issued on the
|
||||
// query-only pool fails at runtime with "attempt to write a
|
||||
// readonly database".
|
||||
if err := e.db.Queries.ResolveAlbumPendingReleaseMBID(
|
||||
ctx,
|
||||
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
|
||||
Mbid: sql.NullString{
|
||||
String: release.ReleaseGroupMBID,
|
||||
Valid: true,
|
||||
},
|
||||
ID: p.ID,
|
||||
},
|
||||
); err != nil {
|
||||
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -2212,6 +2216,7 @@ func (e *Service) gatherTopCandidates(
|
||||
ArtistType: a.Type,
|
||||
Country: a.Country,
|
||||
InLibrary: a.InLibrary,
|
||||
LocalID: a.LocalID,
|
||||
},
|
||||
category: "artist",
|
||||
qualityScore: quality,
|
||||
@@ -2243,6 +2248,7 @@ func (e *Service) gatherTopCandidates(
|
||||
ArtistType: a.Type,
|
||||
Country: a.Country,
|
||||
InLibrary: a.InLibrary,
|
||||
LocalID: a.LocalID,
|
||||
},
|
||||
category: "artist",
|
||||
qualityScore: quality,
|
||||
@@ -2275,6 +2281,7 @@ func (e *Service) gatherTopCandidates(
|
||||
PrimaryType: rg.PrimaryType,
|
||||
Year: year,
|
||||
InLibrary: rg.InLibrary,
|
||||
LocalID: rg.LocalID,
|
||||
},
|
||||
category: "release_group",
|
||||
qualityScore: quality,
|
||||
@@ -2320,6 +2327,7 @@ func (e *Service) gatherTopCandidates(
|
||||
PrimaryType: rg.PrimaryType,
|
||||
Year: year,
|
||||
InLibrary: rg.InLibrary,
|
||||
LocalID: rg.LocalID,
|
||||
},
|
||||
category: "release_group",
|
||||
qualityScore: quality,
|
||||
@@ -2347,6 +2355,7 @@ func (e *Service) gatherTopCandidates(
|
||||
CAAReleaseMBID: r.CAAReleaseMBID,
|
||||
ReleaseName: r.ReleaseName,
|
||||
InLibrary: r.InLibrary,
|
||||
LocalID: r.LocalID,
|
||||
},
|
||||
category: "recording",
|
||||
qualityScore: quality,
|
||||
@@ -2403,6 +2412,7 @@ func (e *Service) gatherTopCandidates(
|
||||
CAAReleaseMBID: r.CAAReleaseMBID,
|
||||
ReleaseName: r.ReleaseName,
|
||||
InLibrary: r.InLibrary,
|
||||
LocalID: r.LocalID,
|
||||
},
|
||||
category: "recording",
|
||||
qualityScore: quality,
|
||||
@@ -2425,6 +2435,7 @@ func (e *Service) gatherTopCandidates(
|
||||
ArtistType: m.ArtistType,
|
||||
Country: m.Country,
|
||||
InLibrary: m.InLibrary || m.LocalArtistID > 0,
|
||||
LocalID: m.LocalArtistID,
|
||||
},
|
||||
category: "artist",
|
||||
qualityScore: quality,
|
||||
@@ -2445,6 +2456,7 @@ func (e *Service) gatherTopCandidates(
|
||||
PrimaryType: m.PrimaryType,
|
||||
Year: year,
|
||||
InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0,
|
||||
LocalID: m.LocalReleaseGroupID,
|
||||
},
|
||||
category: "release_group",
|
||||
qualityScore: quality,
|
||||
@@ -2459,6 +2471,7 @@ func (e *Service) gatherTopCandidates(
|
||||
ArtistMBID: m.ArtistMBID,
|
||||
Length: m.Duration,
|
||||
InLibrary: m.InLibrary || m.LocalRecordingID > 0,
|
||||
LocalID: m.LocalRecordingID,
|
||||
},
|
||||
category: "recording",
|
||||
qualityScore: quality,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package explore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// The release-group MBID backfill queried `release_groups`, a table
|
||||
// plan 013 renamed to `albums`, so it failed on its first statement on
|
||||
// every launch from e7748f1 until #189 -- and the pass swallowed that,
|
||||
// because a query error and an empty library are the same early
|
||||
// return. Nothing noticed for two reasons worth keeping in mind:
|
||||
//
|
||||
// - the statement was **raw SQL**, so sqlc never read it. Every other
|
||||
// statement in the repo was renamed by the same change because sqlc
|
||||
// reads sql/schemas/ and cannot generate against a table that is not
|
||||
// declared. The two sqlc queries this now calls were written by 013
|
||||
// and left uncalled.
|
||||
// - it needs no network and no fixture library to reproduce. The
|
||||
// failure is at prepare time.
|
||||
|
||||
// seedPendingAlbum inserts an album whose files carried a release MBID
|
||||
// but no release-group MBID, which is what `library.updateMBIDs`
|
||||
// leaves behind for this pass to resolve.
|
||||
func seedPendingAlbum(
|
||||
t *testing.T,
|
||||
db *database.DB,
|
||||
name, pendingMBID string,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
res, err := db.ExecContext(
|
||||
"INSERT INTO albums (name, artist_credit, pending_release_mbid) "+
|
||||
"VALUES (?, ?, ?)",
|
||||
name, "Test Artist", pendingMBID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert albums row: %v", err)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
func newPendingTestService(db *database.DB) *Service {
|
||||
return &Service{db: db, logger: slog.Default()}
|
||||
}
|
||||
|
||||
// TestPendingReleaseMBIDsRunsAgainstTheRealSchema is the regression.
|
||||
//
|
||||
// It asserts the statement *runs*, which is the whole of what was
|
||||
// broken: against the old raw SQL this returns
|
||||
// "no such table: release_groups" rather than a row.
|
||||
func TestPendingReleaseMBIDsRunsAgainstTheRealSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
e := newPendingTestService(db)
|
||||
|
||||
want := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
|
||||
|
||||
pending, err := e.pendingReleaseMBIDs(db.Ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("the backfill's query failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pending) != 1 {
|
||||
t.Fatalf("got %d pending albums, want 1", len(pending))
|
||||
}
|
||||
|
||||
if pending[0].ID != want {
|
||||
t.Errorf("got album id %d, want %d", pending[0].ID, want)
|
||||
}
|
||||
|
||||
if got := pending[0].PendingReleaseMbid.String; got != "release-mbid-1" {
|
||||
t.Errorf("got pending mbid %q, want %q", got, "release-mbid-1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyUnresolvedAlbumsAreReturned pins the two conditions that make
|
||||
// the pass idempotent, since between them they are what stops it doing
|
||||
// the same MusicBrainz lookups on every launch forever.
|
||||
func TestOnlyUnresolvedAlbumsAreReturned(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
e := newPendingTestService(db)
|
||||
|
||||
pendingID := seedPendingAlbum(t, db, "Still Pending", "release-mbid-1")
|
||||
|
||||
// Already resolved: it has a real MBID, so there is nothing to
|
||||
// look up even though a marker is still sitting on it.
|
||||
resolved := seedPendingAlbum(t, db, "Already Resolved", "release-mbid-2")
|
||||
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
|
||||
Mbid: sql.NullString{String: "rg-mbid", Valid: true},
|
||||
ID: resolved,
|
||||
}); err != nil {
|
||||
t.Fatalf("set album mbid: %v", err)
|
||||
}
|
||||
|
||||
// Never had a release MBID to resolve in the first place, which is
|
||||
// most of a library.
|
||||
seedPendingAlbum(t, db, "Nothing Pending", "")
|
||||
|
||||
pending, err := e.pendingReleaseMBIDs(db.Ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("the backfill's query failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pending) != 1 || pending[0].ID != pendingID {
|
||||
t.Fatalf(
|
||||
"got %d albums %v, want only the unresolved one (%d)",
|
||||
len(pending), pending, pendingID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvingClearsTheMarker is the other half: once the lookup has
|
||||
// answered, the album must stop being a candidate, or the pass repeats
|
||||
// the same live MusicBrainz call on every launch.
|
||||
func TestResolvingClearsTheMarker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
e := newPendingTestService(db)
|
||||
|
||||
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
|
||||
|
||||
// The writer, deliberately: this is an UPDATE, and the read pool
|
||||
// would refuse it at runtime.
|
||||
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
|
||||
db.Ctx,
|
||||
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
|
||||
Mbid: sql.NullString{String: "resolved-rg-mbid", Valid: true},
|
||||
ID: id,
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("resolve pending release mbid: %v", err)
|
||||
}
|
||||
|
||||
pending, err := e.pendingReleaseMBIDs(db.Ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("the backfill's query failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("a resolved album is still a candidate: %v", pending)
|
||||
}
|
||||
|
||||
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get album: %v", err)
|
||||
}
|
||||
|
||||
if album.Mbid.String != "resolved-rg-mbid" {
|
||||
t.Errorf("album mbid = %q, want the resolved one", album.Mbid.String)
|
||||
}
|
||||
|
||||
if album.PendingReleaseMbid.Valid &&
|
||||
album.PendingReleaseMbid.String != "" {
|
||||
t.Errorf(
|
||||
"the pending marker survived as %q",
|
||||
album.PendingReleaseMbid.String,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAResolvedMBIDIsNeverOverwritten covers the guard in the UPDATE.
|
||||
//
|
||||
// The pass runs against rows it read earlier, and a rescan can resolve
|
||||
// an album from its tags in between -- a real MBID from the file must
|
||||
// win over one this pass inferred from a release.
|
||||
func TestAResolvedMBIDIsNeverOverwritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
id := seedPendingAlbum(t, db, "Pending Album", "release-mbid-1")
|
||||
|
||||
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
|
||||
Mbid: sql.NullString{String: "from-the-tags", Valid: true},
|
||||
ID: id,
|
||||
}); err != nil {
|
||||
t.Fatalf("set album mbid: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Queries.ResolveAlbumPendingReleaseMBID(
|
||||
db.Ctx,
|
||||
sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
|
||||
Mbid: sql.NullString{String: "from-the-backfill", Valid: true},
|
||||
ID: id,
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("resolve pending release mbid: %v", err)
|
||||
}
|
||||
|
||||
album, err := db.ReadQueries.GetAlbum(db.Ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get album: %v", err)
|
||||
}
|
||||
|
||||
if album.Mbid.String != "from-the-tags" {
|
||||
t.Errorf(
|
||||
"album mbid = %q, want the tagged one to have won",
|
||||
album.Mbid.String,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestThePassIsBounded checks the LIMIT.
|
||||
//
|
||||
// Each row costs a live MusicBrainz lookup on a 1 req/s limiter shared
|
||||
// with every page the user can open, so an unbounded read is a run that
|
||||
// lasts as long as the library is untagged. The sqlc query 013 wrote
|
||||
// had no LIMIT; the raw statement it was replacing did.
|
||||
func TestThePassIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
e := newPendingTestService(db)
|
||||
|
||||
for i := range releaseGroupMBIDBackfillMaxPerRun + 10 {
|
||||
seedPendingAlbum(
|
||||
t, db,
|
||||
"Album "+string(rune('A'+i%26))+strconv.Itoa(i),
|
||||
"release-mbid-"+strconv.Itoa(i),
|
||||
)
|
||||
}
|
||||
|
||||
pending, err := e.pendingReleaseMBIDs(db.Ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("the backfill's query failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pending) != releaseGroupMBIDBackfillMaxPerRun {
|
||||
t.Errorf(
|
||||
"got %d albums, want the run bounded at %d",
|
||||
len(pending), releaseGroupMBIDBackfillMaxPerRun,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,100 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneClearsInLibraryWithNoLocalID covers the fixed point: a row
|
||||
// carrying in_library with a NULL local_*_id. The upsert's conflict
|
||||
// clause is `in_library = MAX(in_library, excluded.in_library)`, so it
|
||||
// can only ever raise the flag, and this pass used to be gated on the id
|
||||
// being present — which meant nothing in the app could clear such a row,
|
||||
// ever. It is asserted for all three entity types because the gate was
|
||||
// written once and used three times, so a fix applied to one is a fix
|
||||
// that looks complete.
|
||||
//
|
||||
// The rows are seeded with raw SQL rather than through seedIndexResult
|
||||
// deliberately: upsertBatch writes a zero LocalArtistID as literal 0,
|
||||
// not NULL, and 0 satisfies `IS NOT NULL` — so the old gate already
|
||||
// caught that shape and a fixture built through the upsert cannot
|
||||
// reproduce this at all. NULL is what the artifact importer and any
|
||||
// older writer leave behind, the column being nullable with no default.
|
||||
func TestPruneClearsInLibraryWithNoLocalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
si := NewSearchIndex(db, nil, nil, slog.Default())
|
||||
|
||||
// A genuinely owned artist, to prove the wider gate does not simply
|
||||
// clear everything it now looks at.
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: "/music/owned.mp3",
|
||||
Artist: "Owned",
|
||||
})
|
||||
|
||||
artist, err := db.Queries.GetArtistByName(t.Context(), "Owned")
|
||||
if err != nil {
|
||||
t.Fatalf("read seeded artist: %v", err)
|
||||
}
|
||||
|
||||
seedIndexResult(t, db, SearchIndexResult{
|
||||
EntityType: EntityArtist,
|
||||
MBID: testMBID("owned"),
|
||||
Title: "Owned",
|
||||
ArtistName: "Owned",
|
||||
ArtistMBID: testMBID("owned"),
|
||||
InLibrary: true,
|
||||
LocalArtistID: artist.ID,
|
||||
})
|
||||
|
||||
orphans := []struct {
|
||||
name string
|
||||
entityType string
|
||||
mbid string
|
||||
}{
|
||||
{"artist", EntityArtist, "orphan-artist"},
|
||||
{"release group", EntityReleaseGroup, "orphan-release-group"},
|
||||
{"recording", EntityRecording, "orphan-recording"},
|
||||
}
|
||||
|
||||
for _, o := range orphans {
|
||||
if _, err := db.ExecContext(
|
||||
`INSERT INTO explore_index
|
||||
(entity_type, mbid, title, artist_name, artist_mbid,
|
||||
in_library,
|
||||
local_artist_id, local_release_group_id, local_recording_id)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
|
||||
dbEntityType(o.entityType), dbMBID(testMBID(o.mbid)), o.name, o.name,
|
||||
dbMBID(testMBID(o.mbid)),
|
||||
nil, nil, nil,
|
||||
); err != nil {
|
||||
t.Fatalf("seed %s orphan: %v", o.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
si.pruneStaleLocalCrossReferences()
|
||||
|
||||
inLibrary := func(t *testing.T, mbid string) int {
|
||||
t.Helper()
|
||||
|
||||
var flag int
|
||||
if err := db.QueryRowWriter(
|
||||
"SELECT in_library FROM explore_index WHERE mbid = ?", dbMBID(mbid),
|
||||
).Scan(&flag); err != nil {
|
||||
t.Fatalf("read in_library for %q: %v", mbid, err)
|
||||
}
|
||||
|
||||
return flag
|
||||
}
|
||||
|
||||
for _, o := range orphans {
|
||||
if got := inLibrary(t, testMBID(o.mbid)); got != 0 {
|
||||
t.Errorf("%s with a NULL local id: in_library = %d, want 0", o.name, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got := inLibrary(t, testMBID("owned")); got != 1 {
|
||||
t.Errorf("owned artist: in_library = %d, want 1 (it still has a file)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount verifies the
|
||||
// backfill queue prioritizes artists by how many tracks the user actually
|
||||
// owns, not by how many duplicate-mbid artist rows happen to exist (the
|
||||
|
||||
@@ -2562,6 +2562,19 @@ func (si *SearchIndex) PopulateLocalCrossReferences() {
|
||||
// The row itself is left in place (it may still be part of the shipped
|
||||
// catalog, just no longer owned) — only the "this is mine" bookkeeping
|
||||
// is cleared.
|
||||
//
|
||||
// It is gated on the flag *or* the id, not on the id alone. Gated on
|
||||
// the id, `in_library = 1 AND local_*_id IS NULL` is a fixed point: the
|
||||
// upsert can only ever raise the flag and this pass skipped such a row
|
||||
// by construction, so nothing in the app could clear it — a row claiming
|
||||
// to be owned, permanently, with no local row to check the claim
|
||||
// against. Nothing in the tree writes that shape today
|
||||
// (collectLibraryEntities sets both together), which is exactly why it
|
||||
// is worth closing now: the exposure is a database written by an older
|
||||
// version, and the next writer that sets the flag without an id, which
|
||||
// nothing structurally prevents. A NULL id fails the existence test on
|
||||
// its own, so the wider gate needs no second clause to say what "not
|
||||
// owned" means.
|
||||
func (si *SearchIndex) pruneStaleLocalCrossReferences() {
|
||||
type prune struct {
|
||||
entityType string
|
||||
@@ -2594,7 +2607,8 @@ func (si *SearchIndex) pruneStaleLocalCrossReferences() {
|
||||
result, err := si.db.ExecContext(
|
||||
`UPDATE explore_index
|
||||
SET in_library = 0, `+p.column+` = NULL
|
||||
WHERE entity_type = ? AND `+p.column+` IS NOT NULL
|
||||
WHERE entity_type = ?
|
||||
AND (`+p.column+` IS NOT NULL OR in_library = 1)
|
||||
AND NOT EXISTS (`+p.exists+`)`,
|
||||
dbEntityType(p.entityType),
|
||||
)
|
||||
|
||||
@@ -41,7 +41,16 @@ type TopResult struct {
|
||||
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
|
||||
ReleaseName string `json:"releaseName,omitempty"`
|
||||
// Library status — populated from index cross-reference columns.
|
||||
InLibrary bool `json:"inLibrary"`
|
||||
//
|
||||
// LocalID is the one the cards read. It is the local row behind
|
||||
// this entity — an album, a file, an artist — and it is set and
|
||||
// cleared by a test against `audio_files`, so it means "there is
|
||||
// something of mine here". InLibrary is written by the same pass
|
||||
// but is a one-way ratchet the prune can only clear alongside a
|
||||
// local id, so it is the weaker of the two and stays for scoring
|
||||
// (`fwInLibrary`), which is where an approximate answer is fine.
|
||||
InLibrary bool `json:"inLibrary"`
|
||||
LocalID int64 `json:"localId,omitempty"`
|
||||
}
|
||||
|
||||
// MBArtist is a Wails-friendly projection of a MusicBrainz artist.
|
||||
|
||||
@@ -232,3 +232,112 @@ func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
|
||||
t.Errorf("empty album reported %+v, want zero and unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The batch and the single-album query are two spellings of one
|
||||
// question, and the thing worth pinning is that they never disagree.
|
||||
//
|
||||
// They are genuinely different SQL — the single-album form is
|
||||
// correlated subqueries over one album, the batch is two grouping
|
||||
// levels over a slice — so the risk is not a typo but a drift in
|
||||
// meaning: a disc's total counted once per file, a duplicate counted
|
||||
// twice, a disc with no total silently covered by one that had one.
|
||||
// Every shape the table above cares about is staged here at once,
|
||||
// because a batch that is only ever asked about one album is not being
|
||||
// asked the question that can go wrong.
|
||||
func TestGetAlbumsCompletenessAgreesWithTheSingleAlbumQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
|
||||
shapes := map[int][]track{
|
||||
1: disc(1, 100, 12, 12),
|
||||
2: disc(1, 200, 9, 12),
|
||||
3: disc(1, 300, 13, 12),
|
||||
4: {{recordingID: 400, disc: 1, number: 1}},
|
||||
5: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
|
||||
6: append(
|
||||
disc(1, 700, 10, 10),
|
||||
track{recordingID: 750, disc: 2, number: 1},
|
||||
),
|
||||
7: append(
|
||||
disc(1, 800, 5, 6),
|
||||
track{recordingID: 899, disc: 1, number: 3, total: 6},
|
||||
),
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(shapes))
|
||||
|
||||
for albumID, tracks := range shapes {
|
||||
stageAlbum(t, lib, albumID, tracks)
|
||||
ids = append(ids, albumIDFor(t, lib, albumID))
|
||||
}
|
||||
|
||||
batch, err := lib.GetAlbumsCompleteness(ids)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumsCompleteness: %v", err)
|
||||
}
|
||||
|
||||
if len(batch) != len(ids) {
|
||||
t.Fatalf("batch answered for %d albums, want %d", len(batch), len(ids))
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
one, err := lib.GetAlbumCompleteness(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumCompleteness(%d): %v", id, err)
|
||||
}
|
||||
|
||||
if got := batch[id]; got != one {
|
||||
t.Errorf("album %d: batch says %+v, single says %+v", id, got, one)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An album with no files is absent from the batch, not zeroed.
|
||||
//
|
||||
// "I have none of this" and "I have no idea" are the third state Known
|
||||
// exists to keep apart, and a caller reading a missing key gets nothing
|
||||
// rather than a confident zero it would have to know to distrust.
|
||||
func TestGetAlbumsCompletenessOmitsAnAlbumWithNoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
|
||||
stageAlbum(t, lib, 1, disc(1, 100, 3, 3))
|
||||
|
||||
held := albumIDFor(t, lib, 1)
|
||||
|
||||
got, err := lib.GetAlbumsCompleteness([]int64{held, 4242})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumsCompleteness: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := got[4242]; ok {
|
||||
t.Errorf("an album with no files answered %+v, want absent", got[4242])
|
||||
}
|
||||
|
||||
if !got[held].Complete {
|
||||
t.Errorf("held album reported %+v, want complete", got[held])
|
||||
}
|
||||
}
|
||||
|
||||
// A caller with nothing to ask about must not issue a query at all —
|
||||
// sqlc's empty-slice branch rewrites the placeholder to NULL, which is
|
||||
// a perfectly valid query returning nothing, so this is about the round
|
||||
// trip rather than the answer.
|
||||
func TestGetAlbumsCompletenessAsksNothingForAnEmptyList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lib, _ := setupTestLibrary(t)
|
||||
|
||||
for _, ids := range [][]int64{nil, {}, {0}, {-1, 0}} {
|
||||
got, err := lib.GetAlbumsCompleteness(ids)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlbumsCompleteness(%v): %v", ids, err)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Errorf("GetAlbumsCompleteness(%v) = %+v, want empty", ids, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,65 @@ func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAlbumsCompleteness answers the same question for a screenful of
|
||||
// albums in one query, keyed by album id.
|
||||
//
|
||||
// A card grid asks this about every card that has a local album behind
|
||||
// it, and one query per card is how a grid of fifty albums becomes
|
||||
// fifty round trips. The answer matters there for the reason it
|
||||
// matters on the album page: an album held 9 tracks of 12 has to show
|
||||
// the count, and a bare tick saying "in your library" is the complaint
|
||||
// this whole rule came from.
|
||||
//
|
||||
// An album with no row in the result is one with no files, and it is
|
||||
// absent rather than zeroed — "I have none of this" and "I have no
|
||||
// idea" are the same third state `Known` exists to keep apart, and a
|
||||
// caller reading a missing key gets nothing rather than a confident 0.
|
||||
func (l *Library) GetAlbumsCompleteness(
|
||||
albumIDs []int64,
|
||||
) (map[int64]AlbumCompleteness, error) {
|
||||
out := make(map[int64]AlbumCompleteness, len(albumIDs))
|
||||
|
||||
if len(albumIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
keys := make([]sql.NullInt64, 0, len(albumIDs))
|
||||
|
||||
for _, id := range albumIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
keys = append(keys, sql.NullInt64{Int64: id, Valid: true})
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
rows, err := l.db.ReadQueries.GetAlbumsCompleteness(l.ctx, keys)
|
||||
if err != nil {
|
||||
l.logger.Error("could not get album completeness in batch",
|
||||
"albums", len(keys), "error", err)
|
||||
|
||||
return nil, fmt.Errorf("could not get album completeness: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
known := row.Known != 0 && row.Expected > 0
|
||||
|
||||
out[row.AlbumID] = AlbumCompleteness{
|
||||
Owned: int(row.Owned),
|
||||
Expected: int(row.Expected),
|
||||
Known: known,
|
||||
Complete: known && row.Owned >= row.Expected,
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAlbumTracks returns one album's tracks in disc/track order.
|
||||
func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) {
|
||||
rows, err := l.db.ReadQueries.GetTracksByAlbum(
|
||||
|
||||
@@ -279,17 +279,19 @@ func (h *MPRISHandler) enqueue(fn func()) {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateMetadata pushes track metadata to D-Bus.
|
||||
func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
|
||||
h.mu.Lock()
|
||||
h.trackID++
|
||||
tid := h.trackID
|
||||
h.mu.Unlock()
|
||||
|
||||
m := map[string]interface{}{
|
||||
// metadataMap builds the org.mpris.MediaPlayer2.Player Metadata value
|
||||
// for one track.
|
||||
//
|
||||
// It is separated from UpdateMetadata, which needs a live D-Bus
|
||||
// connection, so the map's contents can be asserted on: this file is
|
||||
// behind a build tag and everything in it that touches h is reachable
|
||||
// only from a session bus, which is the same reason the Android
|
||||
// contract lives in an untagged androidpayload.go.
|
||||
func metadataMap(meta Metadata, trackID uint64) map[string]any {
|
||||
m := map[string]any{
|
||||
"mpris:trackid": dbus.ObjectPath(
|
||||
fmt.Sprintf(
|
||||
"/org/yellowjacket/Track/%d", tid,
|
||||
"/org/yellowjacket/Track/%d", trackID,
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -306,16 +308,45 @@ func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
|
||||
m["xesam:album"] = meta.Album
|
||||
}
|
||||
|
||||
// Always present, even with nothing to point at.
|
||||
//
|
||||
// Every other key here can be omitted safely because a client
|
||||
// reading the map sees a track with no title or no album and
|
||||
// renders it that way. Art is different: KDE's applet (and
|
||||
// others) treat an *absent* mpris:artUrl as "no news about the
|
||||
// art" and keep drawing whatever the last track had, so playing
|
||||
// something with no cover left the previous album's sleeve on
|
||||
// screen — which reads as the wrong track playing rather than as
|
||||
// missing artwork.
|
||||
//
|
||||
// An empty string is the honest answer and is what the spec's
|
||||
// "URI" type degrades to; a client that cannot load it falls back
|
||||
// to its own placeholder, which is the behaviour wanted.
|
||||
artURL := ""
|
||||
if meta.ArtFilePath != "" {
|
||||
m["mpris:artUrl"] = "file://" + meta.ArtFilePath
|
||||
artURL = "file://" + meta.ArtFilePath
|
||||
}
|
||||
|
||||
m["mpris:artUrl"] = artURL
|
||||
|
||||
if meta.DurationSec > 0 {
|
||||
m["mpris:length"] = int64(
|
||||
meta.DurationSec,
|
||||
) * usPerSec
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// UpdateMetadata pushes track metadata to D-Bus.
|
||||
func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
|
||||
h.mu.Lock()
|
||||
h.trackID++
|
||||
tid := h.trackID
|
||||
h.mu.Unlock()
|
||||
|
||||
m := metadataMap(meta, tid)
|
||||
|
||||
h.enqueue(func() {
|
||||
h.props.SetMust(playerIf, "Metadata", m)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package mediacontrols
|
||||
|
||||
import "testing"
|
||||
|
||||
// The one key that must be present even when it is empty.
|
||||
//
|
||||
// Everything else in the map may be omitted, because a client reading
|
||||
// it renders a track with no title as a track with no title. Art is
|
||||
// different: KDE's applet treats an *absent* mpris:artUrl as no news
|
||||
// about the art and keeps drawing the last one it saw, so a track with
|
||||
// no cover wore the previous album's sleeve — which reads as the wrong
|
||||
// track playing rather than as missing artwork.
|
||||
func TestMetadataMapAlwaysCarriesArtURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
meta Metadata
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no art at all",
|
||||
meta: Metadata{Title: "Blue in Green"},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "art on disk",
|
||||
meta: Metadata{
|
||||
Title: "Blue in Green",
|
||||
ArtFilePath: "/covers/kind-of-blue_lg.jpg",
|
||||
},
|
||||
want: "file:///covers/kind-of-blue_lg.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := metadataMap(tt.meta, 1)
|
||||
|
||||
got, ok := m["mpris:artUrl"]
|
||||
if !ok {
|
||||
t.Fatal("mpris:artUrl is absent; it must always be sent")
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("mpris:artUrl = %v, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The trackid has to change between tracks or a client is entitled to
|
||||
// treat the metadata as describing the same track it already has.
|
||||
func TestMetadataMapTrackIDVaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
first := metadataMap(Metadata{Title: "A"}, 1)["mpris:trackid"]
|
||||
second := metadataMap(Metadata{Title: "B"}, 2)["mpris:trackid"]
|
||||
|
||||
if first == second {
|
||||
t.Errorf("trackid did not change: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// The optional keys stay optional — this is what makes artUrl's
|
||||
// always-present treatment a deliberate exception rather than drift.
|
||||
func TestMetadataMapOmitsEmptyOptionalFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
m := metadataMap(Metadata{}, 1)
|
||||
|
||||
for _, key := range []string{
|
||||
"xesam:title",
|
||||
"xesam:artist",
|
||||
"xesam:album",
|
||||
"mpris:length",
|
||||
} {
|
||||
if _, ok := m[key]; ok {
|
||||
t.Errorf("%s is present for an empty Metadata", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// errTestDecode stands in for a decoder blowing up mid-track.
|
||||
var errTestDecode = errors.New("decode blew up")
|
||||
|
||||
// stalledStreamer never produces a sample and never reports
|
||||
// end-of-stream: (0, true), forever. A damaged file that decodes to
|
||||
// nothing looks like this, and so does any source whose producer has
|
||||
// quietly stopped.
|
||||
type stalledStreamer struct{}
|
||||
|
||||
func (stalledStreamer) Stream(_ [][2]float64) (int, bool) { return 0, true }
|
||||
func (stalledStreamer) Err() error { return nil }
|
||||
|
||||
// failingStreamer produces n good samples and then fails, which is
|
||||
// what a decode error mid-track looks like: the same (0, false) a
|
||||
// finished track returns, distinguishable only by Err.
|
||||
type failingStreamer struct {
|
||||
remaining int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *failingStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
if f.remaining <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
n := min(len(samples), f.remaining)
|
||||
|
||||
for i := range n {
|
||||
samples[i] = [2]float64{1, 1}
|
||||
}
|
||||
|
||||
f.remaining -= n
|
||||
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (f *failingStreamer) Err() error { return f.err }
|
||||
|
||||
// drainUntilEnd calls Stream until it reports end-of-stream, or gives
|
||||
// up. It returns whether the stream ended.
|
||||
//
|
||||
// The give-up bound is wall clock rather than a call count: the stall
|
||||
// budget is a duration, so a tight loop has to actually wait it out.
|
||||
func drainUntilEnd(bs *BufferedStreamer, within time.Duration) bool {
|
||||
buf := make([][2]float64, 512)
|
||||
deadline := time.Now().Add(within)
|
||||
|
||||
for time.Now().Before(deadline) {
|
||||
if _, ok := bs.Stream(buf); !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// A source that stops producing without ever ending is the fault this
|
||||
// whole file exists for: Stream used to answer with silence and ok
|
||||
// forever, so the chain never ended, the player stayed in Playing
|
||||
// with the button showing pause, and the decoder's position never
|
||||
// moved -- a frozen seek bar over a track that was not playing.
|
||||
func TestAStalledSourceEndsTheStream(t *testing.T) {
|
||||
bs := NewBufferedStreamer(stalledStreamer{}, 2048)
|
||||
defer bs.Close()
|
||||
|
||||
if !drainUntilEnd(bs, maxStarvedDuration+2*time.Second) {
|
||||
t.Fatal(
|
||||
"a stalled source never ended the stream: the player " +
|
||||
"would sit in Playing with a frozen position",
|
||||
)
|
||||
}
|
||||
|
||||
if !errors.Is(bs.Err(), errSourceStalled) {
|
||||
t.Fatalf(
|
||||
"expected the stall to be reported, got %v", bs.Err(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Close is the other exit that used to leave `done` false, with the
|
||||
// same consequence: the ring drains and every call after it is
|
||||
// silence that claims to be audio.
|
||||
func TestClosingEndsTheStream(t *testing.T) {
|
||||
bs := NewBufferedStreamer(finiteStreamer(1<<20), 2048)
|
||||
|
||||
// Let the read-ahead fill something, so this exercises the drain
|
||||
// after Close rather than a buffer that was empty anyway.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
bs.Close()
|
||||
|
||||
if !drainUntilEnd(bs, 2*time.Second) {
|
||||
t.Fatal("a closed streamer never reported end-of-stream")
|
||||
}
|
||||
}
|
||||
|
||||
// A source that fails is not a source that finished, and only Err
|
||||
// tells them apart. Before this, the player reported a mid-track
|
||||
// decode failure to the queue as a natural end, so the queue
|
||||
// auto-advanced in silence and counted the broken track as played.
|
||||
func TestAFailedSourceReportsItsError(t *testing.T) {
|
||||
src := &failingStreamer{remaining: 4096, err: errTestDecode}
|
||||
|
||||
bs := NewBufferedStreamer(src, 2048)
|
||||
defer bs.Close()
|
||||
|
||||
if !drainUntilEnd(bs, 2*time.Second) {
|
||||
t.Fatal("a failing source never reported end-of-stream")
|
||||
}
|
||||
|
||||
if !errors.Is(bs.Err(), errTestDecode) {
|
||||
t.Fatalf(
|
||||
"expected the source's error to survive, got %v",
|
||||
bs.Err(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case has to keep working: a source that ends cleanly
|
||||
// ends with no error, or every finished track would be reported as a
|
||||
// failure and skipped.
|
||||
func TestADrainedSourceReportsNoError(t *testing.T) {
|
||||
bs := NewBufferedStreamer(finiteStreamer(4096), 2048)
|
||||
defer bs.Close()
|
||||
|
||||
if !drainUntilEnd(bs, 2*time.Second) {
|
||||
t.Fatal("a finite source never reported end-of-stream")
|
||||
}
|
||||
|
||||
if bs.Err() != nil {
|
||||
t.Fatalf(
|
||||
"a track that finished normally reported %v", bs.Err(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A slow source is exactly what the read-ahead exists to absorb, so
|
||||
// underruns must not be charged cumulatively -- otherwise a file on a
|
||||
// slow disk ends itself partway through.
|
||||
func TestUnderrunsDoNotAccumulateAcrossASlowSource(t *testing.T) {
|
||||
const total = 8192
|
||||
|
||||
src := &slowStreamer{
|
||||
inner: finiteStreamer(total),
|
||||
delay: 2 * time.Millisecond,
|
||||
}
|
||||
|
||||
bs := NewBufferedStreamer(src, 1024)
|
||||
defer bs.Close()
|
||||
|
||||
buf := make([][2]float64, 256)
|
||||
got := 0
|
||||
|
||||
for {
|
||||
n, ok := bs.Stream(buf)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
for i := range n {
|
||||
if buf[i][0] != 0 {
|
||||
got++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if got != total {
|
||||
t.Fatalf(
|
||||
"a slow but healthy source was cut short: got %d of %d "+
|
||||
"samples",
|
||||
got, total,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// beep.Streamer is what the player wraps; keep the type honest.
|
||||
var _ beep.Streamer = (*BufferedStreamer)(nil)
|
||||
@@ -1,6 +1,7 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -34,8 +35,78 @@ type BufferedStreamer struct {
|
||||
done bool
|
||||
err error
|
||||
closed chan struct{}
|
||||
|
||||
// starved counts consecutive Stream calls served with silence
|
||||
// because the ring was empty, and starvedSince is when that run
|
||||
// began. An underrun is legitimate for a moment -- that is what
|
||||
// the read-ahead exists to absorb -- but it is not legitimate
|
||||
// forever, and "forever" is indistinguishable from healthy
|
||||
// playback everywhere above this type: the chain never ends, so
|
||||
// the player stays in Playing with the button showing pause, and
|
||||
// the decoder's position never moves, so the 1 Hz report pins the
|
||||
// seek bar and suppresses its interpolation.
|
||||
starved int
|
||||
starvedSince time.Time
|
||||
|
||||
// underruns accumulates for the life of this streamer, where
|
||||
// starved is reset by every arriving sample.
|
||||
//
|
||||
// The two answer different questions and only the first was being
|
||||
// asked. starved is a *stall* detector: it exists to end a track
|
||||
// whose source has died, so it forgets a run the moment audio
|
||||
// resumes -- which is exactly the case this counts. A hundred 20ms
|
||||
// underruns a minute never approach the give-up threshold and were
|
||||
// invisible to the log, the UI and every test tier, while being
|
||||
// audible as static: an underrun is served as a run of zeros
|
||||
// spliced into the waveform, and a step discontinuity at each edge
|
||||
// is what a click is.
|
||||
underruns UnderrunStats
|
||||
}
|
||||
|
||||
// UnderrunStats is what the ring buffer missed, cumulatively.
|
||||
//
|
||||
// Samples rather than milliseconds because this type does not know the
|
||||
// sample rate -- the player does, and converts at the point of
|
||||
// reporting.
|
||||
type UnderrunStats struct {
|
||||
// Runs is the number of *episodes*: transitions from healthy into
|
||||
// starved. Calls is how many Stream calls were served with
|
||||
// silence, and Samples is how much silence that was.
|
||||
//
|
||||
// Runs is the count that means something audible. One episode is
|
||||
// one pop however many calls it spans, and the ratio of the two is
|
||||
// how long the average episode was -- which is what separates
|
||||
// "clicking" from "dropping out".
|
||||
Runs int64
|
||||
Calls int64
|
||||
Samples int64
|
||||
}
|
||||
|
||||
// The silence fill is bounded by both a duration and a run of calls,
|
||||
// and it needs both.
|
||||
//
|
||||
// Duration alone is the real measure -- the speaker paces itself, so
|
||||
// wall clock is what says whether the source has actually stopped --
|
||||
// but a caller draining in a tight loop (a test, a decode-to-buffer)
|
||||
// makes hundreds of calls in microseconds and would trip nothing.
|
||||
// A call count alone is the opposite failure: the same tight loop
|
||||
// spends the whole budget before the read-ahead goroutine has been
|
||||
// scheduled once, and ends a perfectly good stream at sample zero.
|
||||
//
|
||||
// The duration is longer than the 2 s read-ahead it is there to
|
||||
// outlast, and the count is short enough that the speaker (~200 ms a
|
||||
// call) reaches it well inside that.
|
||||
const (
|
||||
maxStarvedDuration = 3 * time.Second
|
||||
minStarvedCalls = 8
|
||||
)
|
||||
|
||||
// errSourceStalled is returned by Err when the source stopped
|
||||
// producing samples without ever reporting end-of-stream.
|
||||
var errSourceStalled = errors.New(
|
||||
"audio source stopped producing samples",
|
||||
)
|
||||
|
||||
// NewBufferedStreamer creates a BufferedStreamer that pre-fills
|
||||
// bufferSize samples from source via a background goroutine.
|
||||
// A typical bufferSize is 2× the sample rate (~2 seconds of audio).
|
||||
@@ -54,8 +125,24 @@ func NewBufferedStreamer(
|
||||
return bs
|
||||
}
|
||||
|
||||
// finish marks the stream ended, recording err as the reason when
|
||||
// there is one. Every exit from readAhead goes through it: an exit
|
||||
// that leaves done false strands Stream in its underrun branch,
|
||||
// where it returns silence and ok forever.
|
||||
func (bs *BufferedStreamer) finish(err error) {
|
||||
bs.mu.Lock()
|
||||
defer bs.mu.Unlock()
|
||||
|
||||
bs.done = true
|
||||
|
||||
if err != nil && bs.err == nil {
|
||||
bs.err = err
|
||||
}
|
||||
}
|
||||
|
||||
// readAhead continuously reads from the source into the ring buffer
|
||||
// until the source is drained, an error occurs, or Close is called.
|
||||
// It always marks the stream done on the way out.
|
||||
func (bs *BufferedStreamer) readAhead() {
|
||||
// Temporary buffer for reading from source outside the lock.
|
||||
// 512 samples per chunk keeps the critical section short.
|
||||
@@ -63,6 +150,13 @@ func (bs *BufferedStreamer) readAhead() {
|
||||
|
||||
tmp := make([][2]float64, chunkSize)
|
||||
|
||||
// Every exit marks the stream done. An exit that does not is what
|
||||
// stranded Stream in its underrun branch, returning silence and ok
|
||||
// for the rest of the process's life.
|
||||
var exitErr error
|
||||
|
||||
defer func() { bs.finish(exitErr) }()
|
||||
|
||||
for {
|
||||
// Check if closed.
|
||||
select {
|
||||
@@ -72,6 +166,15 @@ func (bs *BufferedStreamer) readAhead() {
|
||||
}
|
||||
|
||||
bs.mu.Lock()
|
||||
|
||||
// Stream gave up waiting for us. Nothing downstream is
|
||||
// listening any more, so filling the ring is work for nobody.
|
||||
if bs.done {
|
||||
bs.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
space := len(bs.ring) - bs.count
|
||||
|
||||
if space == 0 {
|
||||
@@ -115,14 +218,12 @@ func (bs *BufferedStreamer) readAhead() {
|
||||
}
|
||||
|
||||
if !ok {
|
||||
bs.mu.Lock()
|
||||
bs.done = true
|
||||
|
||||
if srcErr := bs.source.Err(); srcErr != nil {
|
||||
bs.err = srcErr
|
||||
}
|
||||
|
||||
bs.mu.Unlock()
|
||||
// A drained source and a failed one both land here and are
|
||||
// not the same event: one is a track that ended, the other
|
||||
// is a track that broke. Err is what tells them apart, and
|
||||
// it is why the player must ask before treating this as a
|
||||
// natural finish.
|
||||
exitErr = bs.source.Err()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -154,7 +255,34 @@ func (bs *BufferedStreamer) Stream(
|
||||
}
|
||||
|
||||
if bs.count == 0 {
|
||||
// Buffer temporarily empty — fill with silence.
|
||||
// The read-ahead has not caught up. Silence buys it time --
|
||||
// but only for a bounded stretch, because "forever" is
|
||||
// reported upward as healthy playback and there is no watchdog
|
||||
// above this to notice otherwise.
|
||||
if bs.starved == 0 {
|
||||
bs.underruns.Runs++
|
||||
}
|
||||
|
||||
bs.underruns.Calls++
|
||||
bs.underruns.Samples += int64(len(samples))
|
||||
|
||||
bs.starved++
|
||||
|
||||
if bs.starvedSince.IsZero() {
|
||||
bs.starvedSince = time.Now()
|
||||
}
|
||||
|
||||
if bs.starved >= minStarvedCalls &&
|
||||
time.Since(bs.starvedSince) > maxStarvedDuration {
|
||||
bs.done = true
|
||||
|
||||
if bs.err == nil {
|
||||
bs.err = errSourceStalled
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for i := range samples {
|
||||
samples[i] = [2]float64{}
|
||||
}
|
||||
@@ -162,6 +290,9 @@ func (bs *BufferedStreamer) Stream(
|
||||
return len(samples), true
|
||||
}
|
||||
|
||||
// Samples arrived, so whatever the stall was, it is over.
|
||||
bs.resetStarvationLocked()
|
||||
|
||||
// Copy available samples from ring buffer.
|
||||
n := len(samples)
|
||||
if n > bs.count {
|
||||
@@ -178,6 +309,20 @@ func (bs *BufferedStreamer) Stream(
|
||||
return n, true
|
||||
}
|
||||
|
||||
// Underruns returns the cumulative underrun count.
|
||||
//
|
||||
// It is a snapshot rather than a live view, and it is read from
|
||||
// outside the audio callback: counting happens in Stream, under the
|
||||
// lock it already takes, because that path has a real-time deadline
|
||||
// and anything that allocates or formats on it is a cause of the
|
||||
// defect it is measuring rather than a measurement of it.
|
||||
func (bs *BufferedStreamer) Underruns() UnderrunStats {
|
||||
bs.mu.Lock()
|
||||
defer bs.mu.Unlock()
|
||||
|
||||
return bs.underruns
|
||||
}
|
||||
|
||||
// Err returns any error encountered by the source streamer.
|
||||
func (bs *BufferedStreamer) Err() error {
|
||||
bs.mu.Lock()
|
||||
@@ -197,6 +342,23 @@ func (bs *BufferedStreamer) Flush() {
|
||||
bs.readPos = 0
|
||||
bs.writPos = 0
|
||||
bs.count = 0
|
||||
|
||||
// A seek empties the ring on purpose, and the refill that follows
|
||||
// is exactly the stall the budget exists to tolerate. Charging it
|
||||
// against a budget the previous underrun already spent would end
|
||||
// the track on a seek near the end of a slow file.
|
||||
bs.resetStarvationLocked()
|
||||
}
|
||||
|
||||
// resetStarvationLocked forgets an underrun run. Must be called with
|
||||
// bs.mu held.
|
||||
//
|
||||
// Deliberately does not touch bs.underruns: forgetting the run is what
|
||||
// makes starved a stall detector, and remembering it is the whole
|
||||
// point of the counter beside it.
|
||||
func (bs *BufferedStreamer) resetStarvationLocked() {
|
||||
bs.starved = 0
|
||||
bs.starvedSince = time.Time{}
|
||||
}
|
||||
|
||||
// LockSource blocks the read-ahead goroutine from touching the
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// An underrun is audible and nothing counted it (#135).
|
||||
//
|
||||
// The distinction these tests exist for is that `starved` and
|
||||
// `underruns` disagree on purpose. `starved` is a stall detector: it is
|
||||
// reset by every arriving sample, because its job is to end a track
|
||||
// whose source has died and a source that is merely slow must not be
|
||||
// cut short (TestUnderrunsDoNotAccumulateAcrossASlowSource, next
|
||||
// door). That reset is exactly what made the audible case invisible --
|
||||
// a hundred short underruns a minute never approach the give-up
|
||||
// threshold, and each one is a run of zeros spliced into the waveform
|
||||
// with a step discontinuity at both edges.
|
||||
|
||||
// TestAnEmptyRingIsCounted is the measurement itself: silence served
|
||||
// for a missing sample is recorded rather than merely tolerated.
|
||||
func TestAnEmptyRingIsCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A source that never produces is the cleanest way to make the
|
||||
// ring empty on demand; the stall budget is far longer than the
|
||||
// handful of calls below.
|
||||
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
|
||||
defer bs.Close()
|
||||
|
||||
if got := bs.Underruns(); got != (UnderrunStats{}) {
|
||||
t.Fatalf("a fresh streamer already reports %+v", got)
|
||||
}
|
||||
|
||||
buf := make([][2]float64, 256)
|
||||
|
||||
for range 3 {
|
||||
if _, ok := bs.Stream(buf); !ok {
|
||||
t.Fatal("the stall budget ran out before the test did")
|
||||
}
|
||||
}
|
||||
|
||||
got := bs.Underruns()
|
||||
|
||||
if got.Calls != 3 {
|
||||
t.Errorf("Calls = %d, want 3", got.Calls)
|
||||
}
|
||||
|
||||
if got.Samples != int64(3*len(buf)) {
|
||||
t.Errorf("Samples = %d, want %d", got.Samples, 3*len(buf))
|
||||
}
|
||||
|
||||
// Three consecutive silent calls are one episode, not three. That
|
||||
// is the number that means something audible: one interruption is
|
||||
// one pop however many callbacks it spans.
|
||||
if got.Runs != 1 {
|
||||
t.Errorf("Runs = %d, want 1 -- an unbroken run is one episode", got.Runs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSilenceIsWhatIsCounted pins what an underrun actually does to the
|
||||
// waveform, which is the reason to count it at all.
|
||||
func TestSilenceIsWhatIsCounted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
|
||||
defer bs.Close()
|
||||
|
||||
buf := make([][2]float64, 64)
|
||||
for i := range buf {
|
||||
buf[i] = [2]float64{0.5, 0.5}
|
||||
}
|
||||
|
||||
n, ok := bs.Stream(buf)
|
||||
if !ok || n != len(buf) {
|
||||
t.Fatalf("Stream = (%d, %v), want (%d, true)", n, ok, len(buf))
|
||||
}
|
||||
|
||||
for i := range buf {
|
||||
if buf[i] != ([2]float64{}) {
|
||||
t.Fatalf("sample %d is %v, want silence", i, buf[i])
|
||||
}
|
||||
}
|
||||
|
||||
if got := bs.Underruns().Samples; got != int64(len(buf)) {
|
||||
t.Errorf("counted %d samples of silence, wrote %d", got, len(buf))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeparateEpisodesAreSeparateRuns is the counter's whole shape:
|
||||
// audio arriving between two underruns makes them two, because that is
|
||||
// two interruptions and two clicks.
|
||||
func TestSeparateEpisodesAreSeparateRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A source that yields nothing until it is fed, so the ring can be
|
||||
// emptied, filled and emptied again on demand.
|
||||
src := &gatedStreamer{}
|
||||
|
||||
bs := NewBufferedStreamer(src, 1024)
|
||||
defer bs.Close()
|
||||
|
||||
buf := make([][2]float64, 128)
|
||||
|
||||
starve := func() {
|
||||
t.Helper()
|
||||
|
||||
for range 2 {
|
||||
if _, ok := bs.Stream(buf); !ok {
|
||||
t.Fatal("the stall budget ran out before the test did")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// feed lets exactly one bufferful through and drains it, so the
|
||||
// ring is empty again on return. Allowing more would mean the
|
||||
// starve() after it drained real audio instead of underrunning,
|
||||
// which is what the first version of this test did -- it reported
|
||||
// one episode and looked like the counter was wrong.
|
||||
feed := func() {
|
||||
t.Helper()
|
||||
|
||||
src.allow(len(buf))
|
||||
|
||||
// The read-ahead is a goroutine, so wait for real samples
|
||||
// rather than assuming they have landed.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
n, ok := bs.Stream(buf)
|
||||
if ok && n > 0 && buf[0] != ([2]float64{}) {
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatal("the source never delivered a sample")
|
||||
}
|
||||
|
||||
starve()
|
||||
feed()
|
||||
starve()
|
||||
|
||||
if got := bs.Underruns().Runs; got < 2 {
|
||||
t.Errorf(
|
||||
"Runs = %d, want at least 2 -- audio in between makes two "+
|
||||
"episodes, not one",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheStallResetDoesNotClearTheCounter is the regression this file
|
||||
// is really about.
|
||||
//
|
||||
// resetStarvationLocked runs on every arriving sample and on every
|
||||
// Flush. If it cleared the cumulative count too, the counter would
|
||||
// report zero on exactly the workload it exists to measure -- a stream
|
||||
// that underruns repeatedly but always recovers -- which is
|
||||
// indistinguishable from healthy playback and is what the code did
|
||||
// before #135.
|
||||
func TestTheStallResetDoesNotClearTheCounter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bs := NewBufferedStreamer(stalledStreamer{}, 1024)
|
||||
defer bs.Close()
|
||||
|
||||
buf := make([][2]float64, 128)
|
||||
|
||||
if _, ok := bs.Stream(buf); !ok {
|
||||
t.Fatal("the stall budget ran out before the test did")
|
||||
}
|
||||
|
||||
before := bs.Underruns()
|
||||
if before.Runs == 0 {
|
||||
t.Fatal("nothing was counted, so the reset cannot be tested")
|
||||
}
|
||||
|
||||
// Both of the ways a run is forgotten.
|
||||
bs.mu.Lock()
|
||||
bs.resetStarvationLocked()
|
||||
bs.mu.Unlock()
|
||||
|
||||
bs.Flush()
|
||||
|
||||
if got := bs.Underruns(); got != before {
|
||||
t.Errorf(
|
||||
"forgetting the stall run also discarded the count: %+v, "+
|
||||
"want %+v",
|
||||
got, before,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnderrunDeltaNeverGoesBackwards covers the one arithmetic trap in
|
||||
// the reporting side.
|
||||
//
|
||||
// The counter belongs to the streamer and the streamer is replaced on
|
||||
// every track, so a baseline carried across a track change is the
|
||||
// previous track's total subtracted from a fresh zero. The load path
|
||||
// resets the baseline, and this clamps as well -- a negative count in a
|
||||
// log line reads as a broken instrument, which would discredit the
|
||||
// measurement rather than merely mis-state it.
|
||||
func TestUnderrunDeltaNeverGoesBackwards(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
now UnderrunStats
|
||||
last UnderrunStats
|
||||
want UnderrunStats
|
||||
}{
|
||||
{
|
||||
name: "ordinary progress",
|
||||
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
|
||||
last: UnderrunStats{Runs: 2, Calls: 10, Samples: 1000},
|
||||
want: UnderrunStats{Runs: 3, Calls: 30, Samples: 3000},
|
||||
},
|
||||
{
|
||||
name: "nothing happened",
|
||||
now: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
|
||||
last: UnderrunStats{Runs: 5, Calls: 40, Samples: 4000},
|
||||
want: UnderrunStats{},
|
||||
},
|
||||
{
|
||||
name: "a new streamer, with a stale baseline",
|
||||
now: UnderrunStats{},
|
||||
last: UnderrunStats{Runs: 9, Calls: 90, Samples: 9000},
|
||||
want: UnderrunStats{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := underrunDelta(tt.now, tt.last); got != tt.want {
|
||||
t.Errorf("underrunDelta = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// gatedStreamer produces only what it has been allowed to, and
|
||||
// otherwise stalls without ending -- so a test can decide exactly when
|
||||
// the ring runs dry.
|
||||
type gatedStreamer struct {
|
||||
mu sync.Mutex
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (g *gatedStreamer) allow(n int) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.remaining += n
|
||||
}
|
||||
|
||||
func (g *gatedStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.remaining <= 0 {
|
||||
return 0, true
|
||||
}
|
||||
|
||||
n := min(len(samples), g.remaining)
|
||||
|
||||
for i := range n {
|
||||
samples[i] = [2]float64{0.25, 0.25}
|
||||
}
|
||||
|
||||
g.remaining -= n
|
||||
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (g *gatedStreamer) Err() error { return nil }
|
||||
@@ -0,0 +1,213 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/internal/testfixtures"
|
||||
)
|
||||
|
||||
// fixtureSampleRate is what cmd/gentestdata writes (audio.go). It is
|
||||
// deliberately not the speaker rate, which is what lets these tests
|
||||
// tell the decoder's format from the player's default.
|
||||
const fixtureSampleRate = 22050
|
||||
|
||||
// newTestPlayer is a player with a context and no database, so the
|
||||
// track-metadata lookup cannot succeed.
|
||||
func newTestPlayer(t *testing.T) *Player {
|
||||
t.Helper()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
rec := events.NewRecorder()
|
||||
|
||||
_ = p.ServiceStartup(
|
||||
events.WithSink(t.Context(), rec),
|
||||
application.ServiceOptions{},
|
||||
)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// loadFileLocked needs no speaker: it decodes, builds the chain and
|
||||
// registers it paused. speaker.Play on an uninitialised device is
|
||||
// what the integration guard elsewhere is about, so these assert on
|
||||
// the state the load computed rather than on playback.
|
||||
|
||||
// p.format used to be assigned once, in the constructor, to the
|
||||
// *speaker's* rate -- so it claimed 44.1 kHz for every file ever
|
||||
// loaded. Play()'s replay-after-finish path resamples from it, so a
|
||||
// finished track played again was resampled from a rate the decoder
|
||||
// never produced: audibly the wrong speed and pitch, and wrong
|
||||
// length and position arithmetic with it.
|
||||
//
|
||||
// The fixtures are 22050 Hz, which is exactly the point -- any of
|
||||
// them disagrees with the speaker rate.
|
||||
func TestLoadRecordsTheDecodersOwnFormat(t *testing.T) {
|
||||
m := testfixtures.Load(t)
|
||||
path := m.Case(t, testfixtures.CaseCoverDedup)[0]
|
||||
|
||||
p := newTestPlayer(t)
|
||||
|
||||
if got := p.format.SampleRate; got != speakerSampleRate {
|
||||
t.Fatalf(
|
||||
"precondition: a fresh player should hold the speaker "+
|
||||
"rate, got %d",
|
||||
got,
|
||||
)
|
||||
}
|
||||
|
||||
if err := p.LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||
}
|
||||
|
||||
if p.format.SampleRate == speakerSampleRate {
|
||||
t.Fatalf(
|
||||
"p.format still holds the speaker rate (%d) after "+
|
||||
"loading a %d Hz file: the replay path would "+
|
||||
"resample from the wrong rate",
|
||||
speakerSampleRate, fixtureSampleRate,
|
||||
)
|
||||
}
|
||||
|
||||
if got := int(p.format.SampleRate); got != fixtureSampleRate {
|
||||
t.Errorf(
|
||||
"expected the decoder's rate %d, got %d",
|
||||
fixtureSampleRate, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// trackLengthMs is written only when the database has a row for the
|
||||
// file and cleared only by UnloadTrack, so a track with no row used
|
||||
// to inherit whatever the last track's duration was -- and every
|
||||
// position report is scaled by it, so the whole seek bar was then
|
||||
// reporting one track's progress on another track's scale.
|
||||
//
|
||||
// There is no database here, so the lookup cannot succeed: exactly
|
||||
// the case that used to inherit.
|
||||
func TestLoadDoesNotInheritThePreviousTracksDuration(t *testing.T) {
|
||||
m := testfixtures.Load(t)
|
||||
path := m.Case(t, testfixtures.CaseCoverDedup)[0]
|
||||
|
||||
p := newTestPlayer(t)
|
||||
|
||||
// Stand in for a previous track whose duration was resolved.
|
||||
p.trackLengthMs = 9_999_000
|
||||
|
||||
if err := p.LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||
}
|
||||
|
||||
if p.trackLengthMs == 9_999_000 {
|
||||
t.Fatal(
|
||||
"the previous track's duration survived the load: every " +
|
||||
"position report for this track would be scaled by it",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A new chain supersedes the old one's pending finished callback.
|
||||
// Without this, a callback that queued for p.mu behind a LoadFile
|
||||
// woke up and rewound, stopped and auto-advanced the *new* track.
|
||||
func TestANewChainSupersedesTheOldFinishedCallback(t *testing.T) {
|
||||
m := testfixtures.Load(t)
|
||||
paths := m.Case(t, testfixtures.CaseCoverDedup)
|
||||
|
||||
if len(paths) < 2 {
|
||||
t.Skip("need two fixture tracks")
|
||||
}
|
||||
|
||||
p := newTestPlayer(t)
|
||||
|
||||
if err := p.LoadFile(paths[0]); err != nil {
|
||||
t.Fatalf("LoadFile(%s): %v", paths[0], err)
|
||||
}
|
||||
|
||||
stale := p.chainID
|
||||
|
||||
if err := p.LoadFile(paths[1]); err != nil {
|
||||
t.Fatalf("LoadFile(%s): %v", paths[1], err)
|
||||
}
|
||||
|
||||
if p.chainID == stale {
|
||||
t.Fatal("loading a second file did not supersede the chain")
|
||||
}
|
||||
|
||||
called := false
|
||||
|
||||
p.SetPlaybackFinishedHandler(func(error) { called = true })
|
||||
|
||||
// The first track's callback, arriving late.
|
||||
p.onPlaybackFinished(stale, nil)
|
||||
|
||||
if called {
|
||||
t.Error(
|
||||
"a superseded chain's callback drove auto-advance: the " +
|
||||
"track that is loaded now would be skipped",
|
||||
)
|
||||
}
|
||||
|
||||
if p.state == Stopped {
|
||||
t.Error(
|
||||
"a superseded chain's callback stopped the current track",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The decoder is read by the read-ahead goroutine and by every
|
||||
// position emit, and those used to be guarded by different mutexes:
|
||||
// the read by srcMu, the position by the speaker lock, which
|
||||
// read-ahead never takes. Under -race this failed on the emit that
|
||||
// LoadFile itself makes.
|
||||
//
|
||||
// It needs the read-ahead goroutine to actually be running, so it
|
||||
// keeps asking for the position for long enough to overlap it.
|
||||
func TestPositionReadsDoNotRaceTheReadAhead(t *testing.T) {
|
||||
m := testfixtures.Load(t)
|
||||
path := m.Case(t, testfixtures.CaseFLACAlbum)[0]
|
||||
|
||||
p := newTestPlayer(t)
|
||||
|
||||
if err := p.LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||
}
|
||||
|
||||
for range 200 {
|
||||
if _, err := p.CurrentPositionSeconds(); err != nil {
|
||||
t.Fatalf("CurrentPositionSeconds: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seeking emits the landing position, and that emit reads the
|
||||
// decoder -- so the source lock the seek holds must be released
|
||||
// before it. A reentrant take here is a deadlock, not a failure,
|
||||
// which is why this test exists rather than a comment.
|
||||
func TestSeekEmitsWithoutDeadlocking(t *testing.T) {
|
||||
m := testfixtures.Load(t)
|
||||
path := m.Case(t, testfixtures.CaseFLACAlbum)[0]
|
||||
|
||||
p := newTestPlayer(t)
|
||||
|
||||
if err := p.LoadFile(path); err != nil {
|
||||
t.Fatalf("LoadFile(%s): %v", path, err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
_ = p.Seek(1)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Seek deadlocked: the position emit re-took the source lock")
|
||||
}
|
||||
}
|
||||
+317
-51
@@ -39,22 +39,35 @@ type Player struct {
|
||||
// via the queue).
|
||||
mu sync.Mutex
|
||||
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
db *database.DB
|
||||
state State
|
||||
currentFile *os.File
|
||||
format beep.Format
|
||||
baseStreamer beep.Streamer
|
||||
seeker beep.StreamSeeker
|
||||
resampled beep.Streamer
|
||||
buffered *BufferedStreamer
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
db *database.DB
|
||||
state State
|
||||
currentFile *os.File
|
||||
format beep.Format
|
||||
baseStreamer beep.Streamer
|
||||
seeker beep.StreamSeeker
|
||||
resampled beep.Streamer
|
||||
buffered *BufferedStreamer
|
||||
|
||||
// lastUnderruns is the previous report, so the 1 Hz log can say
|
||||
// what happened in the last second and stay quiet when nothing did.
|
||||
// It is reset with the streamer, in loadFileLocked.
|
||||
lastUnderruns UnderrunStats
|
||||
control *beep.Ctrl
|
||||
volume *effects.Volume
|
||||
speakerStreamer beep.Streamer
|
||||
playbackFinishedHandler func()
|
||||
playbackFinishedHandler func(error)
|
||||
trackChangeID uint64
|
||||
mediaControls mediacontrols.Handler
|
||||
|
||||
// chainID identifies the streamer chain currently registered with
|
||||
// the speaker. updateStreamers bumps it, and the finished
|
||||
// callback carries the value it was registered with, so a callback
|
||||
// that queued for p.mu behind a LoadFile can tell that the player
|
||||
// has moved on and return rather than rewinding somebody else's
|
||||
// track.
|
||||
chainID uint64
|
||||
mediaControls mediacontrols.Handler
|
||||
|
||||
// duckAmount is the attenuation currently applied on top of the
|
||||
// user's volume, in the same base-2 exponent effects.Volume uses.
|
||||
@@ -63,6 +76,19 @@ type Player struct {
|
||||
// not something the user chose.
|
||||
duckAmount float64
|
||||
|
||||
// systemVolume is what SystemOwnsVolume answers: the platform's own
|
||||
// control is the only one, so ours neither acts nor persists. It is
|
||||
// a field rather than the build constant read directly so that a
|
||||
// test can exercise both sides on any machine. See systemvolume.go.
|
||||
systemVolume bool
|
||||
|
||||
// storedVolume and storedMuted hold the persisted level as it was
|
||||
// found at restore, for a platform whose volume we do not own: the
|
||||
// maximum we then run at is not a level the user chose, so saveState
|
||||
// writes back what it read rather than overwriting it.
|
||||
storedVolume UserVolume
|
||||
storedMuted bool
|
||||
|
||||
// trackLengthMs holds the authoritative track duration in
|
||||
// milliseconds, sourced from the database (which uses the
|
||||
// custom header parser). The go-mp3 decoder's Len() can be
|
||||
@@ -148,6 +174,8 @@ func NewPlayer(logger *slog.Logger, db *database.DB) *Player {
|
||||
logger: logger,
|
||||
db: db,
|
||||
state: Stopped,
|
||||
systemVolume: platformOwnsVolume,
|
||||
storedVolume: DefaultUserVol,
|
||||
baseStreamer: generators.Silence(-1),
|
||||
format: beep.Format{
|
||||
SampleRate: speakerSampleRate,
|
||||
@@ -180,11 +208,18 @@ func (p *Player) InitSpeaker() error {
|
||||
}
|
||||
|
||||
// SetPlaybackFinishedHandler sets a callback invoked when a track
|
||||
// finishes naturally. This allows the queue to drive auto-advance
|
||||
// stops streaming. This allows the queue to drive auto-advance
|
||||
// without circular imports.
|
||||
//
|
||||
// The error says *why* the track stopped: nil for a track that
|
||||
// reached its end, non-nil for one that broke partway through. Both
|
||||
// arrive here because both look identical to the speaker, and only
|
||||
// the queue holds the metadata a PlaybackFailed needs -- but they are
|
||||
// not the same event, and reporting a decode failure as a natural
|
||||
// finish is how a broken file used to auto-advance in silence.
|
||||
//
|
||||
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||
func (p *Player) SetPlaybackFinishedHandler(handler func()) {
|
||||
func (p *Player) SetPlaybackFinishedHandler(handler func(error)) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
@@ -262,9 +297,78 @@ func (p *Player) emitPositionIfPlaying() {
|
||||
return
|
||||
}
|
||||
|
||||
p.reportUnderrunsLocked()
|
||||
p.emitPositionLocked()
|
||||
}
|
||||
|
||||
// underrunDelta is what happened since the last report.
|
||||
//
|
||||
// It clamps at zero rather than subtracting blind, because the counter
|
||||
// belongs to the *streamer* and the streamer is replaced on every
|
||||
// track: a baseline carried across that boundary is the previous
|
||||
// track's total subtracted from a fresh zero, which is negative. That
|
||||
// is repaired at the load (lastUnderruns is reset with the streamer)
|
||||
// and clamped here as well, because a negative count in a log line
|
||||
// reads as a broken instrument and would discredit the measurement
|
||||
// this exists to make.
|
||||
func underrunDelta(now, last UnderrunStats) UnderrunStats {
|
||||
return UnderrunStats{
|
||||
Runs: max(0, now.Runs-last.Runs),
|
||||
Calls: max(0, now.Calls-last.Calls),
|
||||
Samples: max(0, now.Samples-last.Samples),
|
||||
}
|
||||
}
|
||||
|
||||
// reportUnderrunsLocked logs what the ring buffer missed, at most once
|
||||
// a second and only when the number moved. Must be called with p.mu
|
||||
// held.
|
||||
//
|
||||
// **An underrun is audible and nothing counted it** (#135). The ring
|
||||
// serves silence when it is empty, so a run of zeros is spliced into
|
||||
// the waveform and the step discontinuity at each edge is a click; a
|
||||
// series of short ones is static. Everything that makes one likelier
|
||||
// is worse on a phone than on a desktop -- slower storage, a governor
|
||||
// that parks cores, background work, GC -- and no tier here can see it,
|
||||
// since CI's audio device is a null sink chosen because it keeps time.
|
||||
//
|
||||
// Three things about the reporting are deliberate.
|
||||
//
|
||||
// **It is on the 1 Hz position ticker rather than in Stream.** Stream
|
||||
// runs on the speaker callback's real-time deadline, and a log line
|
||||
// there would allocate, format and write on the exact path whose
|
||||
// missed deadline is the defect -- measuring by making it worse.
|
||||
//
|
||||
// **An unchanged count is not logged.** That is emitStatus' rule one
|
||||
// package over: a healthy player is silent, so anything in the log is
|
||||
// news, and the line appears exactly while it is popping. Reading it
|
||||
// off a device means `make android-logs` with the audio audible.
|
||||
//
|
||||
// **It is Info rather than Debug**, because the default level is Info
|
||||
// and a phone has no convenient way to set YJ_LOG_LEVEL -- a debug
|
||||
// line here would be a counter nobody on the affected platform can
|
||||
// read, which is the shape of the bug that made #160 necessary.
|
||||
func (p *Player) reportUnderrunsLocked() {
|
||||
if p.buffered == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stats := p.buffered.Underruns()
|
||||
if stats == p.lastUnderruns {
|
||||
return
|
||||
}
|
||||
|
||||
since := underrunDelta(stats, p.lastUnderruns)
|
||||
p.lastUnderruns = stats
|
||||
|
||||
slog.Info("audio underrun",
|
||||
"runs", since.Runs,
|
||||
"calls", since.Calls,
|
||||
"silenceMs", speakerSampleRate.D(int(since.Samples)).Milliseconds(),
|
||||
"trackRuns", stats.Runs,
|
||||
"trackSilenceMs", speakerSampleRate.D(int(stats.Samples)).Milliseconds(),
|
||||
)
|
||||
}
|
||||
|
||||
// emitPositionLocked pushes the current position to the frontend.
|
||||
// Must be called with p.mu held.
|
||||
func (p *Player) emitPositionLocked() {
|
||||
@@ -424,6 +528,19 @@ func (p *Player) updateStreamers(
|
||||
newBaseStreamer beep.StreamSeeker,
|
||||
sr beep.SampleRate,
|
||||
) error {
|
||||
// A new chain supersedes the old one, so any finished callback the
|
||||
// old one still owes is stale from here on.
|
||||
p.chainID++
|
||||
|
||||
// The previous read-ahead goroutine reads the same decoder this
|
||||
// one is about to, under its own srcMu -- two goroutines, two
|
||||
// mutexes, one decoder that is not safe for concurrent use. The
|
||||
// replay-after-finish path rebuilds from p.seeker without going
|
||||
// through LoadFile, which is where that pair could meet.
|
||||
if p.buffered != nil {
|
||||
p.buffered.Close()
|
||||
}
|
||||
|
||||
// set base streamer
|
||||
p.baseStreamer = newBaseStreamer
|
||||
p.seeker = newBaseStreamer
|
||||
@@ -441,6 +558,12 @@ func (p *Player) updateStreamers(
|
||||
p.resampled, int(speakerSampleRate)*2,
|
||||
)
|
||||
|
||||
// The counter belongs to the streamer, so the baseline it is
|
||||
// reported against has to go with it -- otherwise the first report
|
||||
// of a new track is the previous track's total subtracted from
|
||||
// zero, which is negative and looks like the instrument is broken.
|
||||
p.lastUnderruns = UnderrunStats{}
|
||||
|
||||
// wrap in ctrl streamer to allow play/pause
|
||||
p.control = &beep.Ctrl{Streamer: p.buffered}
|
||||
|
||||
@@ -474,23 +597,57 @@ func (p *Player) startPaused() {
|
||||
p.control.Paused = true
|
||||
speaker.Unlock()
|
||||
|
||||
// Captured, not read at callback time: by then p.chainID names
|
||||
// whatever is loaded *now*, which is the thing the guard exists to
|
||||
// distinguish this chain from.
|
||||
chainID := p.chainID
|
||||
buffered := p.buffered
|
||||
|
||||
// The beep.Callback runs with the speaker mutex held, so we
|
||||
// dispatch to a goroutine that can safely acquire p.mu.
|
||||
speaker.Play(beep.Seq(
|
||||
p.speakerStreamer,
|
||||
beep.Callback(func() {
|
||||
go p.onPlaybackFinished()
|
||||
// Asked here rather than under p.mu: this is the chain that
|
||||
// just ended, and by the time the goroutine holds the lock
|
||||
// p.buffered may be a different one.
|
||||
var err error
|
||||
if buffered != nil {
|
||||
err = buffered.Err()
|
||||
}
|
||||
|
||||
go p.onPlaybackFinished(chainID, err)
|
||||
}),
|
||||
))
|
||||
|
||||
p.state = Paused
|
||||
}
|
||||
|
||||
// onPlaybackFinished handles the natural end of a track. It is
|
||||
// called on a new goroutine from the beep callback (which holds
|
||||
// the speaker lock) so that it can safely acquire p.mu.
|
||||
func (p *Player) onPlaybackFinished() {
|
||||
// onPlaybackFinished handles a track that stopped streaming, whether
|
||||
// it ended or broke. It is called on a new goroutine from the beep
|
||||
// callback (which holds the speaker lock) so that it can safely
|
||||
// acquire p.mu.
|
||||
//
|
||||
// chainID names the streamer chain the callback fired for and srcErr
|
||||
// says why it stopped.
|
||||
func (p *Player) onPlaybackFinished(chainID uint64, srcErr error) {
|
||||
p.mu.Lock()
|
||||
|
||||
// The player has moved on while this callback queued for the lock
|
||||
// -- a user pressing Next during the last second of a track is
|
||||
// enough. Everything below is about the *current* track: rewinding
|
||||
// the decoder, saying playback stopped, asking the queue to
|
||||
// advance. Doing any of it now would do it to the wrong track.
|
||||
if chainID != p.chainID {
|
||||
p.mu.Unlock()
|
||||
p.logger.Debug(
|
||||
"Ignoring finished callback for a superseded chain",
|
||||
"chain", chainID, "current", p.chainID,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.state = Stopped
|
||||
handler := p.playbackFinishedHandler
|
||||
mc := p.mediaControls
|
||||
@@ -501,10 +658,11 @@ func (p *Player) onPlaybackFinished() {
|
||||
// the Stopped state anyway, so this only moves the decoder.
|
||||
p.rewindLocked()
|
||||
p.emitPositionLocked()
|
||||
p.mu.Unlock()
|
||||
|
||||
// Emit Wails events outside the lock — these are non-blocking
|
||||
// calls that don't need player state.
|
||||
// Emitted under p.mu, like every other transition in this file.
|
||||
// Outside it, a Play() taking the lock in the gap emits `playing`
|
||||
// first and this stale `stopped` lands last -- leaving the button
|
||||
// showing play over a track that is audibly running.
|
||||
p.emitPlaybackFinished()
|
||||
|
||||
events.Emit(
|
||||
@@ -513,6 +671,8 @@ func (p *Player) onPlaybackFinished() {
|
||||
map[string]string{"state": string(Stopped)},
|
||||
)
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
// Notify media controls outside the lock. The track just
|
||||
// ended so position is 0.
|
||||
if mc != nil {
|
||||
@@ -521,12 +681,19 @@ func (p *Player) onPlaybackFinished() {
|
||||
)
|
||||
}
|
||||
|
||||
p.logger.Info("Playback finished naturally")
|
||||
if srcErr != nil {
|
||||
p.logger.Error(
|
||||
"Playback stopped: the audio source failed",
|
||||
"err", srcErr,
|
||||
)
|
||||
} else {
|
||||
p.logger.Info("Playback finished naturally")
|
||||
}
|
||||
|
||||
// Notify queue for auto-advance. Called without p.mu held
|
||||
// because it re-enters the player via LoadFile/Play.
|
||||
if handler != nil {
|
||||
handler()
|
||||
handler(srcErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +754,18 @@ func (p *Player) loadFileLocked(filePath string) error {
|
||||
|
||||
p.currentFile = f
|
||||
|
||||
// The decoder's own format, kept for the paths that rebuild the
|
||||
// chain later: Play()'s replay branch resamples from it, so a
|
||||
// stale rate there plays a finished track back at the wrong speed.
|
||||
p.format = format
|
||||
|
||||
// The previous track's duration must not outlive it. This is set
|
||||
// again by emitTrackChanged below, but only when the database has
|
||||
// a row for the file -- and every position this player reports is
|
||||
// scaled by it, so inheriting means every report is wrong by the
|
||||
// ratio between two unrelated tracks.
|
||||
p.trackLengthMs = 0
|
||||
|
||||
if err := p.updateStreamers(
|
||||
streamer, format.SampleRate,
|
||||
); err != nil {
|
||||
@@ -791,6 +970,10 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.systemVolume {
|
||||
return
|
||||
}
|
||||
|
||||
p.setVolumeLocked(desiredVolume)
|
||||
p.emitVolumeChanged()
|
||||
p.saveState()
|
||||
@@ -839,6 +1022,10 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.systemVolume {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.setVolumeLocked(p.getUserVolume() + UserVolume(deltaVolume))
|
||||
p.emitVolumeChanged()
|
||||
p.saveState()
|
||||
@@ -869,6 +1056,14 @@ func (p *Player) MuteToggle() error {
|
||||
return errNoAudioFileLoaded
|
||||
}
|
||||
|
||||
// Mute is a level of zero by another name, so it goes with the rest
|
||||
// of the volume where the system owns it -- and it would be the one
|
||||
// state on such a platform the user could not get out of, since with
|
||||
// no control rendered there is nothing left to un-mute with.
|
||||
if p.systemVolume {
|
||||
return nil
|
||||
}
|
||||
|
||||
speaker.Lock()
|
||||
p.volume.Silent = !p.volume.Silent
|
||||
speaker.Unlock()
|
||||
@@ -906,6 +1101,8 @@ func (p *Player) CurrentPosition() (int, error) {
|
||||
return 0, errNoAudioFileLoaded
|
||||
}
|
||||
|
||||
defer p.lockSourceLocked()()
|
||||
|
||||
speaker.Lock()
|
||||
pos := math.Round(
|
||||
100.0 * float64(p.seeker.Position()) /
|
||||
@@ -924,6 +1121,28 @@ func (p *Player) Seek(targetSeconds int) error {
|
||||
return p.seekLocked(targetSeconds)
|
||||
}
|
||||
|
||||
// lockSourceLocked blocks the read-ahead goroutine from touching the
|
||||
// decoder and returns the function that releases it, so a caller can
|
||||
// `defer p.lockSourceLocked()()`.
|
||||
//
|
||||
// Reading the decoder's position is a read *of the decoder*, and the
|
||||
// speaker lock does not exclude the read-ahead goroutine -- it never
|
||||
// takes it. That was a genuine data race on every position emit,
|
||||
// once a second for the whole of playback.
|
||||
//
|
||||
// srcMu is not reentrant, so nothing that already holds it may call
|
||||
// this; seekSourceLocked exists to keep that region free of emits.
|
||||
// Must be called with p.mu held.
|
||||
func (p *Player) lockSourceLocked() func() {
|
||||
if p.buffered == nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
p.buffered.LockSource()
|
||||
|
||||
return p.buffered.UnlockSource
|
||||
}
|
||||
|
||||
// rewindLocked returns the decoder to the start of the track without
|
||||
// touching playback state. Must be called with p.mu held.
|
||||
func (p *Player) rewindLocked() {
|
||||
@@ -959,6 +1178,46 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
return fmt.Errorf("cannot get track length: %w", err)
|
||||
}
|
||||
|
||||
// The source lock is released before anything below is emitted:
|
||||
// emitPositionLocked reads the decoder's position and takes the
|
||||
// same lock, which is not reentrant.
|
||||
seekErr := p.seekSourceLocked(targetSeconds, lengthSecs)
|
||||
if seekErr != nil {
|
||||
p.logger.Warn(
|
||||
"Seek failed, playback will start from "+
|
||||
"the beginning",
|
||||
"target-seconds", targetSeconds,
|
||||
"err", seekErr,
|
||||
)
|
||||
|
||||
// The optimistic move the UI already made has to be taken
|
||||
// back, and only the backend knows it did not happen.
|
||||
events.Emit(p.ctx, events.SeekFailed)
|
||||
p.emitPositionLocked()
|
||||
|
||||
return fmt.Errorf("failed to seek: %w", seekErr)
|
||||
}
|
||||
|
||||
if p.mediaControls != nil {
|
||||
p.mediaControls.NotifySeek(targetSeconds)
|
||||
}
|
||||
|
||||
// Report the landing position immediately rather than leaving the
|
||||
// UI to guess until the next tick — this is the half of H-3 that
|
||||
// desynced the seek bar by 30 s over four keyboard seeks.
|
||||
p.emitPositionLocked()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seekSourceLocked moves the decoder and flushes the stale read-ahead
|
||||
// behind it. It owns the source lock for exactly that long and
|
||||
// emits nothing, so its caller is free to read the position
|
||||
// afterwards. Must be called with p.mu held.
|
||||
func (p *Player) seekSourceLocked(
|
||||
targetSeconds int,
|
||||
lengthSecs int,
|
||||
) error {
|
||||
// Block the read-ahead goroutine from reading the source while
|
||||
// we seek it. The decoder (e.g. FLAC's bufseekio.ReadSeeker) is
|
||||
// not safe for concurrent Read+Seek, and read-ahead runs on its
|
||||
@@ -1014,19 +1273,11 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
if seekErr != nil {
|
||||
speaker.Unlock()
|
||||
|
||||
p.logger.Warn(
|
||||
"Seek failed, playback will start from "+
|
||||
"the beginning",
|
||||
"target-seconds", targetSeconds,
|
||||
"samples", samples,
|
||||
"err", seekErr,
|
||||
p.logger.Debug(
|
||||
"seek rejected by the decoder",
|
||||
"samples", samples, "err", seekErr,
|
||||
)
|
||||
|
||||
// The optimistic move the UI already made has to be taken
|
||||
// back, and only the backend knows it did not happen.
|
||||
events.Emit(p.ctx, events.SeekFailed)
|
||||
p.emitPositionLocked()
|
||||
|
||||
return fmt.Errorf("failed to seek: %w", seekErr)
|
||||
}
|
||||
|
||||
@@ -1039,15 +1290,6 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
||||
p.buffered.Flush()
|
||||
}
|
||||
|
||||
if p.mediaControls != nil {
|
||||
p.mediaControls.NotifySeek(targetSeconds)
|
||||
}
|
||||
|
||||
// Report the landing position immediately rather than leaving the
|
||||
// UI to guess until the next tick — this is the half of H-3 that
|
||||
// desynced the seek bar by 30 s over four keyboard seeks.
|
||||
p.emitPositionLocked()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1140,6 +1382,10 @@ func (p *Player) seekerLengthSecsLocked() (int, error) {
|
||||
return 0, errNoAudioFileLoaded
|
||||
}
|
||||
|
||||
// Len is fixed for the life of the decoder, so unlike Position it
|
||||
// races with nothing and needs no source lock -- which it must not
|
||||
// take anyway: displayPositionSecsLocked calls this while holding
|
||||
// it, and srcMu is not reentrant.
|
||||
speaker.Lock()
|
||||
length := p.seeker.Len() / int(p.format.SampleRate)
|
||||
speaker.Unlock()
|
||||
@@ -1156,6 +1402,8 @@ func (p *Player) displayPositionSecsLocked() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
defer p.lockSourceLocked()()
|
||||
|
||||
speaker.Lock()
|
||||
pos := p.seeker.Position()
|
||||
total := p.seeker.Len()
|
||||
@@ -1266,7 +1514,15 @@ func (p *Player) saveState() {
|
||||
volume := int64(DefaultUserVol)
|
||||
muted := false
|
||||
|
||||
if p.volume != nil {
|
||||
switch {
|
||||
case p.systemVolume:
|
||||
// The maximum this platform runs at is not a level anybody
|
||||
// chose, so it is not one to remember. Writing back what
|
||||
// restore found keeps the row a description of the user's
|
||||
// setting without needing a second query that omits the column.
|
||||
volume = int64(p.storedVolume)
|
||||
muted = p.storedMuted
|
||||
case p.volume != nil:
|
||||
volume = int64(p.getUserVolume())
|
||||
muted = p.volume.Silent
|
||||
}
|
||||
@@ -1350,11 +1606,20 @@ func (p *Player) restoreStateLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
vol := clampVolume(UserVolume(state.Volume))
|
||||
p.setVolumeLocked(vol)
|
||||
if p.systemVolume {
|
||||
// Remembered, not applied: the device's keys are the volume
|
||||
// control here, so the player runs wide open and hands the
|
||||
// stored level back untouched at the next save.
|
||||
p.storedVolume = clampVolume(UserVolume(state.Volume))
|
||||
p.storedMuted = state.Muted
|
||||
p.setVolumeLocked(MaxUserVol)
|
||||
} else {
|
||||
vol := clampVolume(UserVolume(state.Volume))
|
||||
p.setVolumeLocked(vol)
|
||||
|
||||
if state.Muted {
|
||||
p.volume.Silent = true
|
||||
if state.Muted {
|
||||
p.volume.Silent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Restore last track if the file still exists.
|
||||
@@ -1394,8 +1659,9 @@ func (p *Player) restoreStateLocked() {
|
||||
}
|
||||
|
||||
p.logger.Info("Player state restored",
|
||||
"volume", vol,
|
||||
"muted", state.Muted,
|
||||
"volume", p.getUserVolume(),
|
||||
"muted", p.volume.Silent,
|
||||
"systemVolume", p.systemVolume,
|
||||
"trackPath", state.LastTrackPath,
|
||||
"positionSeconds", state.LastPositionSeconds,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package player
|
||||
|
||||
// Who owns the volume, and what follows when it is not us.
|
||||
//
|
||||
// On Android the hardware keys *are* the volume control and the
|
||||
// framework mixes our stream against the device level, so a second
|
||||
// control inside the app is a slider that moves something the user
|
||||
// already moved (#64). Where that is true the player's own level sits
|
||||
// at maximum, nothing changes it, and nothing persists it.
|
||||
//
|
||||
// **The predicate is named after the capability, not the platform.**
|
||||
// The frontend asks "is there a volume for me to control", which is a
|
||||
// question about this build; asking "is this a phone" instead would
|
||||
// key the answer to a viewport, and an Android tablet at 600px or more
|
||||
// would then draw the bottom bar's slider over a level pinned at
|
||||
// maximum -- a control that cannot act, which is the thing
|
||||
// `library-status-indicator` already settled is worse than none.
|
||||
//
|
||||
// **Only `platformOwnsVolume` is behind a build tag**, in two files
|
||||
// that declare nothing else. A tagged file is compiled by nothing
|
||||
// `make lint` or `make test` runs and is untestable off a phone, which
|
||||
// is the reasoning `mediacontrols/androidpayload.go` states for
|
||||
// keeping its contract out of one -- so everything decidable here is
|
||||
// decided against `Player.systemVolume`, a field a test sets either
|
||||
// way, and the tag decides only what that field starts as.
|
||||
//
|
||||
// The one thing this must not disturb is ducking. `SetDuck` applies
|
||||
// its attenuation by re-applying the *user's* level through
|
||||
// `setVolumeLocked`, so pinning that level to maximum leaves the
|
||||
// offset arithmetic exactly as it was: an OS asking us to get out of
|
||||
// the way of a navigation prompt is not the user setting a volume, and
|
||||
// it is the only thing that may move the output on such a platform.
|
||||
|
||||
// SystemOwnsVolume reports whether the platform's own control is the
|
||||
// only volume control there is, so this app neither offers one nor
|
||||
// remembers a level.
|
||||
//
|
||||
// It is bound: the frontend renders no `<volume-control>` when it is
|
||||
// true, at any width.
|
||||
func (p *Player) SystemOwnsVolume() bool {
|
||||
return p.systemVolume
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build android
|
||||
|
||||
package player
|
||||
|
||||
// platformOwnsVolume is true on Android: volume is the device's, set
|
||||
// with the hardware keys, and `mediacontrols`' Android handler
|
||||
// implements no volume callback for the same reason.
|
||||
//
|
||||
// See systemvolume.go for why this constant is the whole of what a
|
||||
// build tag decides here.
|
||||
const platformOwnsVolume = true
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !android
|
||||
|
||||
package player
|
||||
|
||||
// platformOwnsVolume is false everywhere but Android: a desktop mixer
|
||||
// is per-application, so our level is the one the user reaches for.
|
||||
//
|
||||
// See systemvolume.go for why this constant is the whole of what a
|
||||
// build tag decides here.
|
||||
const platformOwnsVolume = false
|
||||
@@ -0,0 +1,215 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gopxl/beep/v2/effects"
|
||||
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// pinnedPlayer is a player on a platform whose volume belongs to the
|
||||
// device. The field is set rather than the build constant read,
|
||||
// because the constant is true on exactly one platform and no tier
|
||||
// here runs on it -- see systemvolume.go.
|
||||
func pinnedPlayer(t *testing.T, db *database.DB) *Player {
|
||||
t.Helper()
|
||||
|
||||
p := NewPlayer(slog.Default(), db)
|
||||
p.systemVolume = true
|
||||
p.volume = &effects.Volume{Base: 2}
|
||||
p.setVolumeLocked(MaxUserVol)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// TestSystemVolumeRefusesEveryWayToChangeTheLevel is the first half of
|
||||
// #64: where the device owns the volume, ours sits at maximum and none
|
||||
// of the three routes to a level moves it. Mute is in that list
|
||||
// because it is a level of zero by another name, and because with no
|
||||
// control rendered it is the one state on such a platform there would
|
||||
// be nothing to get out of.
|
||||
func TestSystemVolumeRefusesEveryWayToChangeTheLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := pinnedPlayer(t, nil)
|
||||
|
||||
if !p.SystemOwnsVolume() {
|
||||
t.Fatal("SystemOwnsVolume() = false on a pinned player")
|
||||
}
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("starting volume = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
p.SetVolume(20)
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("volume after SetVolume(20) = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
if err := p.ChangeVolume(-30); err != nil {
|
||||
t.Fatalf("ChangeVolume: %v", err)
|
||||
}
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("volume after ChangeVolume(-30) = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
if err := p.MuteToggle(); err != nil {
|
||||
t.Fatalf("MuteToggle: %v", err)
|
||||
}
|
||||
|
||||
if p.volume.Silent {
|
||||
t.Error("MuteToggle silenced a player whose volume the system owns")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnUnpinnedPlayerStillChangesItsVolume is the other side of the
|
||||
// same switch. Without it the test above passes on a player that
|
||||
// refuses everything, which is what a mis-wired field would produce.
|
||||
func TestAnUnpinnedPlayerStillChangesItsVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := NewPlayer(slog.Default(), nil)
|
||||
p.volume = &effects.Volume{Base: 2}
|
||||
p.setVolumeLocked(MaxUserVol)
|
||||
|
||||
if p.SystemOwnsVolume() {
|
||||
t.Fatal("SystemOwnsVolume() = true off Android")
|
||||
}
|
||||
|
||||
p.SetVolume(20)
|
||||
|
||||
if got := p.getUserVolume(); got != 20 {
|
||||
t.Errorf("volume after SetVolume(20) = %d, want 20", got)
|
||||
}
|
||||
|
||||
if err := p.MuteToggle(); err != nil {
|
||||
t.Fatalf("MuteToggle: %v", err)
|
||||
}
|
||||
|
||||
if !p.volume.Silent {
|
||||
t.Error("MuteToggle did not silence an ordinary player")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemVolumeStillDucks is the issue's second Finding, made a
|
||||
// test: pinning the user's level must leave the OS's attenuation
|
||||
// working, because a duck is not a volume the user chose and is the
|
||||
// only thing that may move the output on such a platform.
|
||||
func TestSystemVolumeStillDucks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := pinnedPlayer(t, nil)
|
||||
open := p.volume.Volume
|
||||
|
||||
p.SetDuck(true)
|
||||
|
||||
if p.volume.Volume >= open {
|
||||
t.Errorf(
|
||||
"ducked output = %v, want less than %v", p.volume.Volume, open,
|
||||
)
|
||||
}
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("user volume while ducked = %d, want %d", got, MaxUserVol)
|
||||
}
|
||||
|
||||
// A refused SetVolume must not disturb the offset either: it
|
||||
// returns before setVolumeLocked, which is what re-applies it.
|
||||
ducked := p.volume.Volume
|
||||
|
||||
p.SetVolume(10)
|
||||
|
||||
if p.volume.Volume != ducked {
|
||||
t.Errorf(
|
||||
"output after a refused SetVolume = %v, want %v",
|
||||
p.volume.Volume, ducked,
|
||||
)
|
||||
}
|
||||
|
||||
p.SetDuck(false)
|
||||
|
||||
if p.volume.Volume != open {
|
||||
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, open)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemVolumeWritesBackTheLevelItFound is the rest of the
|
||||
// Direction: "make sure nothing writes a persisted volume from that
|
||||
// platform". The maximum the player runs at is synthetic, so saving
|
||||
// must not record it over whatever the row already said.
|
||||
func TestSystemVolumeWritesBackTheLevelItFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// A level set by some earlier, unpinned session.
|
||||
writer := NewPlayer(slog.Default(), db)
|
||||
writer.volume = &effects.Volume{Base: 2}
|
||||
writer.setVolumeLocked(30)
|
||||
writer.SaveState()
|
||||
|
||||
p := pinnedPlayer(t, db)
|
||||
p.RestoreState()
|
||||
|
||||
if got := p.getUserVolume(); got != MaxUserVol {
|
||||
t.Errorf("restored volume = %d, want %d (the level is pinned)", got, MaxUserVol)
|
||||
}
|
||||
|
||||
if p.volume.Silent {
|
||||
t.Error("restore muted a player whose volume the system owns")
|
||||
}
|
||||
|
||||
p.SaveState()
|
||||
|
||||
state, err := db.Queries.GetPlayerState(db.Ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlayerState: %v", err)
|
||||
}
|
||||
|
||||
if state.Volume != 30 {
|
||||
t.Errorf("persisted volume = %d, want 30 (untouched)", state.Volume)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform sweeps the
|
||||
// source, because the pair of tagged files is the one thing here no
|
||||
// tier compiles both halves of: `make lint` and `make test` build the
|
||||
// `!android` side only, so a deleted or edited android file fails
|
||||
// nothing until somebody has a phone in their hand.
|
||||
func TestPlatformVolumeOwnershipIsDeclaredOncePerPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := map[string]string{
|
||||
"systemvolume_other.go": "const platformOwnsVolume = false",
|
||||
"systemvolume_android.go": "const platformOwnsVolume = true",
|
||||
}
|
||||
|
||||
tags := map[string]string{
|
||||
"systemvolume_other.go": "//go:build !android",
|
||||
"systemvolume_android.go": "//go:build android",
|
||||
}
|
||||
|
||||
for name, decl := range want {
|
||||
src, err := os.ReadFile(filepath.Join(".", name))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.Contains(string(src), decl) {
|
||||
t.Errorf("%s does not declare %q", name, decl)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(src), tags[name]) {
|
||||
t.Errorf("%s does not carry %q", name, tags[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ func (q *Queue) emitTracksModified(
|
||||
Index: index,
|
||||
Positions: positions,
|
||||
CurrentIndex: q.currentIndex,
|
||||
Source: q.source,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,6 +219,56 @@ func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The append clears the source, and the delta is the only event those
|
||||
// paths emit — so if it does not carry the source, the frontend keeps
|
||||
// the label it was last given and goes on offering a link back to an
|
||||
// album the queue no longer holds until something forces a full state.
|
||||
func TestEmit_AppendDeltaCarriesClearedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec := setupRecordedQueue(t)
|
||||
paths := seedAudioFiles(t, db, 4)
|
||||
|
||||
q.SetQueue(
|
||||
paths[:3], 0, false,
|
||||
Source{Type: "album", ID: 1, Label: "Abbey Road"},
|
||||
)
|
||||
|
||||
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
|
||||
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
|
||||
}
|
||||
|
||||
rec.Reset()
|
||||
q.AddTrack(paths[3])
|
||||
|
||||
if got := modifiedOf(t, rec).Source; got != (Source{}) {
|
||||
t.Errorf("delta source = %+v, want zero value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// And a delta that did not clear it still reports the source it has,
|
||||
// or the frontend would drop a perfectly good label on every removal.
|
||||
func TestEmit_NonAppendDeltaCarriesSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db, rec := setupRecordedQueue(t)
|
||||
paths := seedAudioFiles(t, db, 4)
|
||||
|
||||
album := Source{Type: "album", ID: 1, Label: "Abbey Road"}
|
||||
q.SetQueue(paths, 0, false, album)
|
||||
|
||||
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
|
||||
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
|
||||
}
|
||||
|
||||
rec.Reset()
|
||||
q.RemoveTrack(3)
|
||||
|
||||
if got := modifiedOf(t, rec).Source; got != album {
|
||||
t.Errorf("delta source = %+v, want %+v", got, album)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_RemoveTracksReportsPositions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestFallback_TriggersOnNaturalFinish(t *testing.T) {
|
||||
q.SetFallbackSource(fake)
|
||||
|
||||
q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
|
||||
q.OnPlaybackFinished()
|
||||
q.OnPlaybackFinished(nil)
|
||||
|
||||
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
|
||||
waitUntil(t, func() bool {
|
||||
@@ -159,7 +159,7 @@ func TestFallback_EmptyResultLeavesQueueExhausted(t *testing.T) {
|
||||
q.SetFallbackSource(fake)
|
||||
|
||||
q.SetQueue(seedPaths, 0, false, Source{})
|
||||
q.OnPlaybackFinished()
|
||||
q.OnPlaybackFinished(nil)
|
||||
|
||||
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestFallback_StaleResolutionDiscarded(t *testing.T) {
|
||||
q.SetFallbackSource(fake)
|
||||
|
||||
q.SetQueue(seedPaths, 0, false, Source{})
|
||||
q.OnPlaybackFinished() // starts resolving, blocked on gate
|
||||
q.OnPlaybackFinished(nil) // starts resolving, blocked on gate
|
||||
|
||||
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// errTestDecode stands in for a decoder blowing up mid-track.
|
||||
var errTestDecode = errors.New("decode blew up")
|
||||
|
||||
// currentIndex == -1 against a non-empty queue is a state this
|
||||
// package produces on purpose: onQueueExhausted(false) sets it and
|
||||
// deliberately leaves the finished track loaded in the player, so it
|
||||
// stays on the now-playing bar. Pressing play from there and letting
|
||||
// it finish re-enters OnPlaybackFinished with exactly that pair --
|
||||
// which used to index q.tracks[-1] and panic, on a goroutine
|
||||
// dispatched from the audio callback with no caller to recover it.
|
||||
func TestFinishedWithNoCurrentTrackDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
index int
|
||||
}{
|
||||
{"exhausted queue leaves -1", -1},
|
||||
{"index past the end", 3},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, _, _ := setupRecordedQueue(t)
|
||||
q.tracks = []Track{
|
||||
{FilePath: "/a.mp3"},
|
||||
{FilePath: "/b.mp3"},
|
||||
}
|
||||
q.currentIndex = tt.index
|
||||
|
||||
// The assertion is that this returns at all.
|
||||
q.OnPlaybackFinished(nil)
|
||||
|
||||
if q.currentIndex != tt.index {
|
||||
t.Errorf(
|
||||
"an out-of-range index was acted on: %d became %d",
|
||||
tt.index, q.currentIndex,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A track that broke mid-playback is not a track that was listened
|
||||
// to. The player cannot say so itself -- the metadata is here -- so
|
||||
// it hands the reason over and this is where it becomes a
|
||||
// PlaybackFailed rather than a silent auto-advance.
|
||||
func TestAFailedTrackIsReportedAndNotCountedAsAPlay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, _, rec := setupRecordedQueue(t)
|
||||
q.tracks = []Track{
|
||||
{FilePath: "/a.mp3", Title: "A", AudioFileID: 1},
|
||||
{FilePath: "/b.mp3", Title: "B", AudioFileID: 2},
|
||||
}
|
||||
q.currentIndex = 0
|
||||
|
||||
q.OnPlaybackFinished(errTestDecode)
|
||||
|
||||
if _, ok := rec.Last(events.PlaybackFailed); !ok {
|
||||
t.Errorf(
|
||||
"a track that failed mid-playback told the user nothing; "+
|
||||
"got %v",
|
||||
rec.Names(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,45 @@
|
||||
package queue
|
||||
|
||||
// OnPlaybackFinished is called when a track finishes playing naturally.
|
||||
// This drives the auto-advance behavior and records the play.
|
||||
func (q *Queue) OnPlaybackFinished() {
|
||||
// OnPlaybackFinished is called when a track stops streaming. This
|
||||
// drives the auto-advance behavior and records the play.
|
||||
//
|
||||
// srcErr says why the track stopped: nil for one that reached its
|
||||
// end, non-nil for one that broke partway through. The player cannot
|
||||
// tell the user which, because the metadata lives here -- so a failure
|
||||
// is reported as PlaybackFailed and *not* recorded as a play, while
|
||||
// the advance happens either way. Before this, a file that failed
|
||||
// mid-track advanced in silence and was counted as listened to.
|
||||
//
|
||||
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||
func (q *Queue) OnPlaybackFinished(srcErr error) {
|
||||
q.mu.Lock()
|
||||
|
||||
if len(q.tracks) == 0 {
|
||||
// currentIndex is -1 whenever the queue has been exhausted, and
|
||||
// onQueueExhausted deliberately leaves the finished track loaded
|
||||
// in the player -- so a natural finish can re-enter here against a
|
||||
// queue that is not empty and an index that is not valid. Every
|
||||
// other path in this package bounds-checks before indexing; this
|
||||
// one panicked, on a goroutine with no caller to recover it.
|
||||
if q.currentIndex < 0 || q.currentIndex >= len(q.tracks) {
|
||||
q.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the track that just finished before advancing.
|
||||
finishedID := q.tracks[q.currentIndex].AudioFileID
|
||||
finished := q.tracks[q.currentIndex]
|
||||
finishedID := finished.AudioFileID
|
||||
|
||||
if srcErr != nil {
|
||||
q.emitPlaybackFailed(finished, srcErr)
|
||||
}
|
||||
|
||||
// A track that broke was not listened to.
|
||||
recordFinished := func() {
|
||||
if srcErr == nil {
|
||||
q.recordPlay(finishedID)
|
||||
}
|
||||
}
|
||||
|
||||
// Repeat One: replay the current track.
|
||||
if q.repeatMode == RepeatOne {
|
||||
@@ -21,7 +48,7 @@ func (q *Queue) OnPlaybackFinished() {
|
||||
}
|
||||
|
||||
q.mu.Unlock()
|
||||
q.recordPlay(finishedID)
|
||||
recordFinished()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -31,7 +58,7 @@ func (q *Queue) OnPlaybackFinished() {
|
||||
// Queue exhausted — this is the extension point for a future fallback playlist.
|
||||
q.onQueueExhausted(false)
|
||||
q.mu.Unlock()
|
||||
q.recordPlay(finishedID)
|
||||
recordFinished()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -44,12 +71,12 @@ func (q *Queue) OnPlaybackFinished() {
|
||||
if !q.playCurrentOrSkip(true, q.nextIndex) {
|
||||
q.onQueueExhausted(false)
|
||||
q.mu.Unlock()
|
||||
q.recordPlay(finishedID)
|
||||
recordFinished()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
q.emitIndexChanged()
|
||||
q.mu.Unlock()
|
||||
q.recordPlay(finishedID)
|
||||
recordFinished()
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
|
||||
|
||||
// The first track finished: auto-advance lands on the missing
|
||||
// file and must step over it rather than stopping dead.
|
||||
q.OnPlaybackFinished()
|
||||
q.OnPlaybackFinished(nil)
|
||||
|
||||
if got := q.GetState().CurrentIndex; got != 2 {
|
||||
t.Errorf("currentIndex after skipping: got %d, want 2", got)
|
||||
@@ -183,7 +183,7 @@ func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
|
||||
|
||||
q.SetQueue(paths, 0, false, Source{})
|
||||
q.Play()
|
||||
q.OnPlaybackFinished()
|
||||
q.OnPlaybackFinished(nil)
|
||||
|
||||
if q.GetState().CurrentIndex != -1 {
|
||||
t.Errorf(
|
||||
|
||||
@@ -156,12 +156,21 @@ type PlaybackFailure struct {
|
||||
}
|
||||
|
||||
// TracksModified is the payload for the QueueTracksModified event.
|
||||
//
|
||||
// Source is carried because an append is exactly what can *invalidate*
|
||||
// it: a queue built from one album stops being that album the moment a
|
||||
// track from somewhere else is added to it. The delta is the only event
|
||||
// those paths emit, so without this the frontend would keep the label
|
||||
// it was last given and go on saying "Playing from" an album that is no
|
||||
// longer what is queued — an event carrying what its consumer needs, so
|
||||
// nothing has to invalidate anything.
|
||||
type TracksModified struct {
|
||||
Action string `json:"action"`
|
||||
Tracks []Track `json:"tracks,omitempty"`
|
||||
Index int `json:"index"`
|
||||
Positions []int `json:"positions,omitempty"`
|
||||
CurrentIndex int `json:"currentIndex"`
|
||||
Source Source `json:"source"`
|
||||
}
|
||||
|
||||
// Queue manages an ordered list of tracks for playback.
|
||||
@@ -455,6 +464,8 @@ func (q *Queue) AddTrack(filePath string) {
|
||||
q.generateShuffleOrder()
|
||||
}
|
||||
|
||||
q.dropSource()
|
||||
|
||||
q.persistAddTrack(track)
|
||||
q.persistState()
|
||||
q.emitTracksModified(
|
||||
@@ -505,6 +516,8 @@ func (q *Queue) AddTracks(filePaths []string) {
|
||||
q.generateShuffleOrder()
|
||||
}
|
||||
|
||||
q.dropSource()
|
||||
|
||||
q.persistAddTracks(newTracks)
|
||||
q.persistState()
|
||||
q.emitTracksModified(
|
||||
@@ -563,6 +576,8 @@ func (q *Queue) InsertNextTracks(filePaths []string) {
|
||||
q.generateShuffleOrder()
|
||||
}
|
||||
|
||||
q.dropSource()
|
||||
|
||||
q.persistInsertTracks(newTracks, insertPos)
|
||||
q.persistState()
|
||||
q.emitTracksModified(
|
||||
@@ -613,6 +628,8 @@ func (q *Queue) InsertNext(filePath string) {
|
||||
q.generateShuffleOrder()
|
||||
}
|
||||
|
||||
q.dropSource()
|
||||
|
||||
q.persistInsertTracks([]Track{track}, insertPos)
|
||||
q.persistState()
|
||||
q.emitTracksModified(
|
||||
@@ -680,6 +697,8 @@ func (q *Queue) InsertTracksAt(filePaths []string, index int) {
|
||||
q.generateShuffleOrder()
|
||||
}
|
||||
|
||||
q.dropSource()
|
||||
|
||||
q.persistInsertTracks(newTracks, index)
|
||||
q.persistState()
|
||||
q.emitTracksModified(
|
||||
@@ -1537,6 +1556,31 @@ func (q *Queue) reindexPositions() {
|
||||
}
|
||||
}
|
||||
|
||||
// dropSource forgets which collection the queue was built from.
|
||||
//
|
||||
// A Source is a claim that everything queued came from one album,
|
||||
// playlist, genre or artist, and the frontend renders it as a
|
||||
// "Playing from X" link back to that page. Adding or inserting a track
|
||||
// makes the claim false — the queue is now that album *plus* something
|
||||
// else — so every path that does so calls this.
|
||||
//
|
||||
// It was set by SetQueue and cleared in exactly one place, Clear, so a
|
||||
// label survived every append. It is persisted too (source_type /
|
||||
// source_id / source_label on the queue state row), which is what made
|
||||
// a wrong label outlive the session that earned it: an album queued on
|
||||
// Monday, added to on Tuesday, still offered a link back to that album
|
||||
// on Friday.
|
||||
//
|
||||
// Removing, reordering and shuffling deliberately do not call this. A
|
||||
// queue with a track taken out of it, or played in another order, is
|
||||
// still that album — the link still goes somewhere true. Only the
|
||||
// arrival of a track from elsewhere makes it a lie.
|
||||
//
|
||||
// The caller must hold q.mu.
|
||||
func (q *Queue) dropSource() {
|
||||
q.source = Source{}
|
||||
}
|
||||
|
||||
// commitMutation persists the current queue state after a mutation.
|
||||
// When reindex is true, track positions are renumbered first.
|
||||
// The caller must hold q.mu.
|
||||
|
||||
@@ -126,6 +126,117 @@ func TestClear_ResetsSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A queue built from one album stops being that album the moment a
|
||||
// track from somewhere else joins it, so every path that adds one
|
||||
// drops the source. Before this, SetQueue was the only writer and
|
||||
// Clear the only clearer, so "Playing from Abbey Road" outlived every
|
||||
// append — and, being persisted, every restart too.
|
||||
func TestAppendPathsDropSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
album := Source{Type: "album", ID: 1, Label: "Abbey Road"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
append func(q *Queue, paths []string)
|
||||
}{
|
||||
{
|
||||
name: "AddTrack",
|
||||
append: func(q *Queue, paths []string) {
|
||||
q.AddTrack(paths[5])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AddTracks",
|
||||
append: func(q *Queue, paths []string) {
|
||||
q.AddTracks(paths[5:7])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InsertNext",
|
||||
append: func(q *Queue, paths []string) {
|
||||
q.InsertNext(paths[5])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InsertNextTracks",
|
||||
append: func(q *Queue, paths []string) {
|
||||
q.InsertNextTracks(paths[5:7])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "InsertTracksAt",
|
||||
append: func(q *Queue, paths []string) {
|
||||
q.InsertTracksAt(paths[5:7], 1)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db := setupTestQueue(t)
|
||||
paths := seedAudioFiles(t, db, 8)
|
||||
|
||||
q.SetQueue(paths[:5], 0, false, album)
|
||||
|
||||
if got := q.GetState().Source; got != album {
|
||||
t.Fatalf("source before append: got %+v, want %+v", got, album)
|
||||
}
|
||||
|
||||
tt.append(q, paths)
|
||||
|
||||
if got := q.GetState().Source; got != (Source{}) {
|
||||
t.Errorf(
|
||||
"source after %s: got %+v, want zero value",
|
||||
tt.name, got,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Removing and reordering deliberately do not drop it: a queue with a
|
||||
// track taken out of it is still that album, and the link still goes
|
||||
// somewhere true.
|
||||
func TestRemoveAndMoveKeepSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
album := Source{Type: "album", ID: 1, Label: "Abbey Road"}
|
||||
|
||||
t.Run("RemoveTrack", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db := setupTestQueue(t)
|
||||
paths := seedAudioFiles(t, db, 5)
|
||||
|
||||
q.SetQueue(paths, 0, false, album)
|
||||
q.RemoveTrack(3)
|
||||
|
||||
if got := q.GetState().Source; got != album {
|
||||
t.Errorf("source after RemoveTrack: got %+v, want %+v", got, album)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MoveQueueTracks", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
q, db := setupTestQueue(t)
|
||||
paths := seedAudioFiles(t, db, 5)
|
||||
|
||||
q.SetQueue(paths, 0, false, album)
|
||||
q.MoveQueueTracks([]int{0}, 3)
|
||||
|
||||
if got := q.GetState().Source; got != album {
|
||||
t.Errorf(
|
||||
"source after MoveQueueTracks: got %+v, want %+v",
|
||||
got, album,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetQueue_WithStartIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -24,10 +24,19 @@ func DefaultBindings() map[string]string {
|
||||
"player.repeat": "R",
|
||||
"player.mute": "M",
|
||||
|
||||
// Navigation (Global scope)
|
||||
// Navigation (Global scope). Back and forward are the browser's
|
||||
// own combination on every platform, which is the whole design
|
||||
// brief for them: the app has one global history and this is the
|
||||
// gesture people already have for it. The modifier is what keeps
|
||||
// them clear of `player.seekBack`/`seekForward`, which are the
|
||||
// bare arrows -- a binding is matched on its full canonical
|
||||
// string, so "Alt+Left" and "Left" are different keys and not a
|
||||
// conflict.
|
||||
"nav.search": "/",
|
||||
"nav.searchAlt": "Ctrl+F",
|
||||
"nav.queue": "Q",
|
||||
"nav.back": "Alt+Left",
|
||||
"nav.forward": "Alt+Right",
|
||||
|
||||
// App actions
|
||||
"app.selectAll": "Ctrl+A",
|
||||
|
||||
@@ -28,6 +28,7 @@ var (
|
||||
errUnsupportedOp = errors.New("unsupported operator")
|
||||
errInvalidSortField = errors.New("invalid sort field: not in allowed field list")
|
||||
errNotNumeric = errors.New("value must be numeric")
|
||||
errInvalidMatch = errors.New("match must be \"all\" or \"any\"")
|
||||
)
|
||||
|
||||
// Rule represents a single filter condition for a smart playlist.
|
||||
@@ -37,13 +38,45 @@ type Rule struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// MatchType decides how a rule set's conditions combine.
|
||||
//
|
||||
// The rules used to be joined with " AND " and nothing else, so a
|
||||
// playlist could only ever narrow: "jazz released after 1960" was
|
||||
// expressible and "jazz or blues" was not, which is most of what
|
||||
// anyone reaches for a second rule to say.
|
||||
type MatchType string
|
||||
|
||||
const (
|
||||
// MatchAll requires every rule to hold — the historical behaviour,
|
||||
// and what an empty match means so that every rule set written
|
||||
// before this existed keeps the meaning it was saved with.
|
||||
MatchAll MatchType = "all"
|
||||
// MatchAny requires at least one rule to hold.
|
||||
MatchAny MatchType = "any"
|
||||
)
|
||||
|
||||
// joiner returns the SQL keyword that combines two conditions.
|
||||
// An unrecognised value cannot reach here — ParseRuleSet rejects one
|
||||
// — so the default is about the empty string, which is every rule set
|
||||
// saved before this field existed.
|
||||
func (m MatchType) joiner() string {
|
||||
if m == MatchAny {
|
||||
return " OR "
|
||||
}
|
||||
|
||||
return " AND "
|
||||
}
|
||||
|
||||
// RuleSet holds the complete filter configuration for a smart
|
||||
// playlist, including optional sort and limit.
|
||||
type RuleSet struct {
|
||||
Rules []Rule `json:"rules"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
SortField string `json:"sort_field,omitempty"`
|
||||
SortDir string `json:"sort_dir,omitempty"`
|
||||
Rules []Rule `json:"rules"`
|
||||
// Match is "all" or "any"; empty means "all". It is omitempty so
|
||||
// an untouched playlist's stored JSON does not change shape.
|
||||
Match MatchType `json:"match,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
SortField string `json:"sort_field,omitempty"`
|
||||
SortDir string `json:"sort_dir,omitempty"`
|
||||
}
|
||||
|
||||
// fieldMap maps user-facing rule field names to track_metadata column
|
||||
@@ -116,7 +149,12 @@ const genreDelimiter = "||"
|
||||
// slice of rules. It is a pure function — no database access needed.
|
||||
// Returns the clause (without the leading "WHERE"), the parameter
|
||||
// args, and any validation error.
|
||||
func BuildWhereClause(rules []Rule) (string, []any, error) {
|
||||
//
|
||||
// match decides how the conditions combine; an empty match is MatchAll,
|
||||
// which is what every rule set saved before the field existed means.
|
||||
func BuildWhereClause(
|
||||
rules []Rule, match MatchType,
|
||||
) (string, []any, error) {
|
||||
if len(rules) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
@@ -179,7 +217,28 @@ func BuildWhereClause(rules []Rule) (string, []any, error) {
|
||||
args = append(args, condArgs...)
|
||||
}
|
||||
|
||||
return strings.Join(conditions, " AND "), args, nil
|
||||
// Under OR, each condition is parenthesised; under AND it is not.
|
||||
//
|
||||
// The asymmetry is deliberate rather than an omission. AND is the
|
||||
// tighter operator in SQL, so an OR-join has to protect any
|
||||
// condition that contains a top-level AND of its own or the halves
|
||||
// come apart: `days_since_played less_than` is
|
||||
// `last_played IS NOT NULL AND <expr> < ?`, which read without
|
||||
// brackets under an OR-join happens to still parse correctly and
|
||||
// would stop doing so the moment a condition grows a top-level OR.
|
||||
// Bracketing under AND would be a no-op semantically and would
|
||||
// rewrite the clause every existing test pins, so the brackets go
|
||||
// exactly where they change something.
|
||||
if match == MatchAny {
|
||||
bracketed := make([]string, len(conditions))
|
||||
for i, cond := range conditions {
|
||||
bracketed[i] = "(" + cond + ")"
|
||||
}
|
||||
|
||||
conditions = bracketed
|
||||
}
|
||||
|
||||
return strings.Join(conditions, match.joiner()), args, nil
|
||||
}
|
||||
|
||||
// validateOperator checks that the operator is valid for the field
|
||||
@@ -599,7 +658,7 @@ func Evaluate(
|
||||
start := time.Now()
|
||||
logger := db.Logger()
|
||||
|
||||
where, args, err := BuildWhereClause(ruleSet.Rules)
|
||||
where, args, err := BuildWhereClause(ruleSet.Rules, ruleSet.Match)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"smart playlist rule error: %w", err,
|
||||
@@ -1036,6 +1095,16 @@ func ParseRuleSet(jsonStr string) (RuleSet, error) {
|
||||
)
|
||||
}
|
||||
|
||||
// A match nobody recognises would otherwise fall through to AND,
|
||||
// which is a playlist quietly returning the wrong tracks rather
|
||||
// than refusing to be saved. This is the only place a rule set
|
||||
// enters the backend, so it is the only place that has to ask.
|
||||
if rs.Match != "" && rs.Match != MatchAll && rs.Match != MatchAny {
|
||||
return RuleSet{}, fmt.Errorf(
|
||||
"%w: %q", errInvalidMatch, rs.Match,
|
||||
)
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package smartplaylist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -170,7 +171,7 @@ func TestBuildWhereClause_TextIs(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -189,7 +190,7 @@ func TestBuildWhereClause_TextIsNot(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "artist", Operator: "is_not", Value: "Queen"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -209,7 +210,7 @@ func TestBuildWhereClause_TextContains(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "title", Operator: "contains", Value: "Black"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -231,7 +232,7 @@ func TestBuildWhereClause_TextDoesNotContain(t *testing.T) {
|
||||
Field: "title", Operator: "does_not_contain",
|
||||
Value: "Black",
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -251,7 +252,7 @@ func TestBuildWhereClause_TextStartsWith(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "title", Operator: "starts_with", Value: "Back"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -270,7 +271,7 @@ func TestBuildWhereClause_TextEndsWith(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "title", Operator: "ends_with", Value: "Black"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -292,7 +293,7 @@ func TestBuildWhereClause_TextIsAnyOf(t *testing.T) {
|
||||
Field: "artist", Operator: "is_any_of",
|
||||
Value: `["Queen","AC/DC"]`,
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -312,7 +313,7 @@ func TestBuildWhereClause_NumericIs(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "year", Operator: "is", Value: "1980"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -331,7 +332,7 @@ func TestBuildWhereClause_NumericIsNot(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "year", Operator: "is_not", Value: "1980"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -350,7 +351,7 @@ func TestBuildWhereClause_NumericGreaterThan(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "year", Operator: "greater_than", Value: "2000"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -369,7 +370,7 @@ func TestBuildWhereClause_NumericLessThan(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "year", Operator: "less_than", Value: "1980"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -391,7 +392,7 @@ func TestBuildWhereClause_NumericBetween(t *testing.T) {
|
||||
Field: "year", Operator: "between",
|
||||
Value: "1975,1985",
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -414,7 +415,7 @@ func TestBuildWhereClause_NumericBetweenJSON(t *testing.T) {
|
||||
Field: "year", Operator: "between",
|
||||
Value: `["1975","1985"]`,
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -434,7 +435,7 @@ func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "genre", Operator: "is", Value: "Rock"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -466,7 +467,7 @@ func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "genre", Operator: "is_not", Value: "Rock"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -495,7 +496,7 @@ func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) {
|
||||
Field: "genre", Operator: "is_any_of",
|
||||
Value: `["Rock","Pop"]`,
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -524,7 +525,7 @@ func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) {
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "genre", Operator: "contains", Value: "Rock"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -557,7 +558,7 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
{Field: "year", Operator: "greater_than", Value: "1975"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -572,6 +573,110 @@ func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWhereClause_MultipleRulesOR(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clause, args, err := BuildWhereClause([]Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
{Field: "year", Operator: "greater_than", Value: "1975"},
|
||||
}, MatchAny)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := "(artist_name = ? COLLATE NOCASE) OR (year > ?)"
|
||||
if clause != want {
|
||||
t.Errorf("clause = %q, want %q", clause, want)
|
||||
}
|
||||
|
||||
if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {
|
||||
t.Errorf("args = %v, want [Queen 1975]", args)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty match is what every rule set saved before the field existed
|
||||
// carries, and it has to keep meaning AND — a playlist silently
|
||||
// widening to OR on upgrade is the whole risk of adding this field.
|
||||
func TestBuildWhereClause_EmptyMatchIsAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rules := []Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
{Field: "year", Operator: "greater_than", Value: "1975"},
|
||||
}
|
||||
|
||||
empty, _, err := BuildWhereClause(rules, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
all, _, err := BuildWhereClause(rules, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if empty != all {
|
||||
t.Errorf("empty match = %q, want the same as MatchAll %q",
|
||||
empty, all)
|
||||
}
|
||||
}
|
||||
|
||||
// A condition carrying its own top-level AND is what makes the
|
||||
// bracketing under OR load-bearing: `days_since_played less_than`
|
||||
// is two predicates, and both belong to the same rule.
|
||||
func TestBuildWhereClause_ORBracketsCompoundCondition(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clause, _, err := BuildWhereClause([]Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
{
|
||||
Field: "days_since_played",
|
||||
Operator: "less_than",
|
||||
Value: "30",
|
||||
},
|
||||
}, MatchAny)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(clause, "(last_played IS NOT NULL AND") {
|
||||
t.Errorf(
|
||||
"compound condition is not bracketed under OR: %q",
|
||||
clause,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRuleSet_RejectsUnknownMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseRuleSet(`{"rules":[],"match":"either"}`)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an unknown match type")
|
||||
}
|
||||
|
||||
if !errors.Is(err, errInvalidMatch) {
|
||||
t.Errorf("err = %v, want errInvalidMatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRuleSet_AcceptsAnyAndAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, want := range []MatchType{MatchAll, MatchAny} {
|
||||
rs, err := ParseRuleSet(
|
||||
`{"rules":[],"match":"` + string(want) + `"}`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("match %q: unexpected error: %v", want, err)
|
||||
}
|
||||
|
||||
if rs.Match != want {
|
||||
t.Errorf("match = %q, want %q", rs.Match, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -581,7 +686,7 @@ func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
|
||||
Field: "genre", Operator: "does_not_contain",
|
||||
Value: "Punk",
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -609,7 +714,7 @@ func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
|
||||
func TestBuildWhereClause_EmptyRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clause, args, err := BuildWhereClause(nil)
|
||||
clause, args, err := BuildWhereClause(nil, MatchAll)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -631,7 +736,7 @@ func TestBuildWhereClause_InvalidField(t *testing.T) {
|
||||
Field: "nonexistent", Operator: "is",
|
||||
Value: "anything",
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid field, got nil")
|
||||
}
|
||||
@@ -654,7 +759,7 @@ func TestBuildWhereClause_InvalidOperatorForNumeric(t *testing.T) {
|
||||
|
||||
_, _, err := BuildWhereClause([]Rule{
|
||||
{Field: "year", Operator: "contains", Value: "1980"},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"expected error for text operator on numeric field",
|
||||
@@ -676,7 +781,7 @@ func TestBuildWhereClause_InvalidOperatorForText(t *testing.T) {
|
||||
Field: "artist", Operator: "greater_than",
|
||||
Value: "Queen",
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"expected error for numeric operator on text field",
|
||||
@@ -723,6 +828,80 @@ func TestEvaluate_TextIs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two rules that share no track at all: under AND this is empty, and
|
||||
// under OR it is the union. Before Match existed only the first was
|
||||
// expressible, so a playlist could only ever narrow — "jazz or blues"
|
||||
// had no way to be said.
|
||||
func TestEvaluate_MatchAnyUnionsWhereMatchAllIntersects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
seedSmartPlaylistData(t, db)
|
||||
|
||||
// Queen has two tracks; Beyoncé has one; no track is by both.
|
||||
rules := []Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
{Field: "artist", Operator: "is", Value: "Beyoncé"},
|
||||
}
|
||||
|
||||
all, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAll})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(all): %v", err)
|
||||
}
|
||||
|
||||
if len(all) != 0 {
|
||||
t.Errorf("match=all returned %d tracks, want 0", len(all))
|
||||
}
|
||||
|
||||
either, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAny})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(any): %v", err)
|
||||
}
|
||||
|
||||
if len(either) != 3 {
|
||||
t.Fatalf("match=any returned %d tracks, want 3", len(either))
|
||||
}
|
||||
|
||||
for _, tr := range either {
|
||||
if tr.ArtistName != "Queen" && tr.ArtistName != "Beyoncé" {
|
||||
t.Errorf(
|
||||
"track %q has artist %q, want Queen or Beyoncé",
|
||||
tr.TrackName, tr.ArtistName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An empty match is what every playlist saved before the field existed
|
||||
// carries, and it has to keep meaning AND all the way through Evaluate
|
||||
// — a stored playlist silently widening on upgrade is the only real
|
||||
// risk in adding this.
|
||||
func TestEvaluate_EmptyMatchStillIntersects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
seedSmartPlaylistData(t, db)
|
||||
|
||||
tracks, err := Evaluate(db, RuleSet{
|
||||
Rules: []Rule{
|
||||
{Field: "artist", Operator: "is", Value: "Queen"},
|
||||
{Field: "year", Operator: "greater_than", Value: "1979"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
|
||||
// Only "Another One Bites the Dust" (Queen, 1980) satisfies both.
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("got %d tracks, want 1", len(tracks))
|
||||
}
|
||||
|
||||
if want := "Another One Bites the Dust"; tracks[0].TrackName != want {
|
||||
t.Errorf("got %q, want %q", tracks[0].TrackName, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluate_ArtworkEnrichment verifies the presentation-only
|
||||
// cover-art and MusicBrainz-ID fields are attached to matched tracks
|
||||
// by the batched fetchArtwork pass (they are no longer part of the
|
||||
@@ -1340,7 +1519,7 @@ func TestSQLInjection_FieldName(t *testing.T) {
|
||||
Field: "title; DROP TABLE playlists",
|
||||
Operator: "is", Value: "x",
|
||||
},
|
||||
})
|
||||
}, MatchAll)
|
||||
if err == nil {
|
||||
t.Fatal(
|
||||
"expected error for injected field name, got nil",
|
||||
|
||||
@@ -52,6 +52,75 @@ func UseHomeOverride(base string) {
|
||||
_ = os.Setenv(envHomeOverride, base)
|
||||
}
|
||||
|
||||
// envTempDir is the variable Go's os.TempDir() reads, and through it
|
||||
// every library in the process that asks for a temporary file.
|
||||
const envTempDir = "TMPDIR"
|
||||
|
||||
// tempDirName is the subdirectory of the app's own storage that
|
||||
// becomes that answer.
|
||||
const tempDirName = "tmp"
|
||||
|
||||
// UseTempDir gives the process a temporary directory that exists.
|
||||
//
|
||||
// **Android has no /tmp and hands an app no TMPDIR**, and Go's
|
||||
// os.TempDir() falls back to "/tmp" when the variable is unset -- so
|
||||
// every library in the process that wants scratch space is handed a
|
||||
// path that has never existed. SQLite is the one that noticed: an
|
||||
// INSERT ... SELECT large enough to spill returned
|
||||
// SQLITE_IOERR_GETTEMPPATH (disk I/O error 6410), which is how the
|
||||
// champion search index came to fail its rebuild on every launch while
|
||||
// the app otherwise looked healthy (#190).
|
||||
//
|
||||
// It is the *class* that is fixed here rather than that statement.
|
||||
// Anything that spills fails the same way on that platform -- large
|
||||
// sorts, large joins, VACUUM -- so the repair belongs at the process's
|
||||
// one answer to the question rather than at each caller. The
|
||||
// alternative considered was PRAGMA temp_store = MEMORY, which is
|
||||
// cheaper and more local and is a promise that every future spill fits
|
||||
// in RAM on a phone; the catalog is the largest thing in this app and
|
||||
// that is not a promise worth making silently.
|
||||
//
|
||||
// The rules are UseHomeOverride's, for the same reasons. **An empty
|
||||
// base is a no-op**, because that is what
|
||||
// application.Mobile.StoragePath() returns on desktop -- so this needs
|
||||
// no build tag and changes nothing off mobile, where /tmp is real. And
|
||||
// **an explicit TMPDIR wins**, so anyone who set one deliberately gets
|
||||
// what they asked for; nothing sets it on the platform this exists for.
|
||||
//
|
||||
// It returns its error rather than swallowing it because a temp
|
||||
// directory that could not be created is the same silent failure one
|
||||
// step earlier, and since #160 a log line on that platform is
|
||||
// something a person can actually read.
|
||||
func UseTempDir(base string) error {
|
||||
if base == "" || os.Getenv(envTempDir) != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, tempDirName)
|
||||
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("could not make the temp directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Writability is checked rather than assumed: the whole failure
|
||||
// this repairs is a directory that is named and cannot be used, and
|
||||
// MkdirAll on an existing unwritable directory succeeds.
|
||||
probe, err := os.CreateTemp(dir, "probe")
|
||||
if err != nil {
|
||||
return fmt.Errorf("temp directory %s is not writable: %w", dir, err)
|
||||
}
|
||||
|
||||
name := probe.Name()
|
||||
_ = probe.Close()
|
||||
_ = os.Remove(name)
|
||||
|
||||
if err := os.Setenv(envTempDir, dir); err != nil {
|
||||
return fmt.Errorf("could not set %s: %w", envTempDir, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getUserDirPath returns and creates the path for a user directory.
|
||||
func getUserDirPath(dt dirType) (string, error) {
|
||||
path, err := resolveUserDirPath(dt)
|
||||
|
||||
@@ -87,3 +87,116 @@ func TestUseHomeOverride(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// UseTempDir carries UseHomeOverride's two rules for the same reasons,
|
||||
// plus one of its own: the directory it names has to be usable.
|
||||
//
|
||||
// **The only tier that can compile the platform this exists for is a
|
||||
// phone**, so everything decidable off one is decided here -- which is
|
||||
// androidpayload.go's discipline, and is why the platform call is a
|
||||
// parameter rather than something this package reaches for. The
|
||||
// device's half is a single measurement: no /tmp, no TMPDIR (#190).
|
||||
func TestUseTempDir(t *testing.T) {
|
||||
t.Run("an empty base is a no-op", func(t *testing.T) {
|
||||
// This is the desktop case in full: StoragePath() answers ""
|
||||
// off mobile, where /tmp is real and must be left alone.
|
||||
t.Setenv(envTempDir, "")
|
||||
|
||||
if err := UseTempDir(""); err != nil {
|
||||
t.Fatalf("UseTempDir(\"\") = %v, want nil", err)
|
||||
}
|
||||
|
||||
if got := os.Getenv(envTempDir); got != "" {
|
||||
t.Errorf("%s = %q, want it untouched", envTempDir, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an explicit TMPDIR wins", func(t *testing.T) {
|
||||
const chosen = "/somewhere/deliberate"
|
||||
|
||||
// The base is taken before TMPDIR moves, because t.TempDir()
|
||||
// reads TMPDIR too -- which is the same fact this function is
|
||||
// about, met from the other side.
|
||||
base := t.TempDir()
|
||||
|
||||
t.Setenv(envTempDir, chosen)
|
||||
|
||||
if err := UseTempDir(base); err != nil {
|
||||
t.Fatalf("UseTempDir = %v, want nil", err)
|
||||
}
|
||||
|
||||
if got := os.Getenv(envTempDir); got != chosen {
|
||||
t.Errorf("%s = %q, want the explicit %q", envTempDir, got, chosen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("points at a real directory under the base", func(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
|
||||
t.Setenv(envTempDir, "")
|
||||
|
||||
if err := UseTempDir(base); err != nil {
|
||||
t.Fatalf("UseTempDir = %v, want nil", err)
|
||||
}
|
||||
|
||||
got := os.Getenv(envTempDir)
|
||||
|
||||
want := filepath.Join(base, tempDirName)
|
||||
if got != want {
|
||||
t.Fatalf("%s = %q, want %q", envTempDir, got, want)
|
||||
}
|
||||
|
||||
// The whole failure being repaired is a temp directory that is
|
||||
// named and does not exist, so naming one is not enough.
|
||||
info, err := os.Stat(got)
|
||||
if err != nil {
|
||||
t.Fatalf("the temp directory was named but not created: %v", err)
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("%s is not a directory", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("os.TempDir then answers with it", func(t *testing.T) {
|
||||
// The point of setting the variable at all: this is what every
|
||||
// library in the process reads, SQLite's driver included.
|
||||
base := t.TempDir()
|
||||
|
||||
t.Setenv(envTempDir, "")
|
||||
|
||||
if err := UseTempDir(base); err != nil {
|
||||
t.Fatalf("UseTempDir = %v, want nil", err)
|
||||
}
|
||||
|
||||
if got := os.TempDir(); got != filepath.Join(base, tempDirName) {
|
||||
t.Errorf("os.TempDir() = %q, want the directory we made", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unwritable directory is an error, not a silent success", func(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("root can write anywhere, so there is nothing to refuse")
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
|
||||
// MkdirAll on an existing directory succeeds whatever its
|
||||
// mode, so without the write probe this case would set TMPDIR
|
||||
// to a directory nothing can use -- which is the bug again,
|
||||
// one directory over.
|
||||
if err := os.Mkdir(filepath.Join(base, tempDirName), 0o500); err != nil {
|
||||
t.Fatalf("prepare the unwritable directory: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(envTempDir, "")
|
||||
|
||||
if err := UseTempDir(base); err == nil {
|
||||
t.Fatal("UseTempDir accepted a directory it cannot write to")
|
||||
}
|
||||
|
||||
if got := os.Getenv(envTempDir); got != "" {
|
||||
t.Errorf("%s was set to %q despite the failure", envTempDir, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-55
@@ -4,18 +4,28 @@ includes:
|
||||
common: ../Taskfile.yml
|
||||
|
||||
vars:
|
||||
# The *installed* package name, which every adb-driven task below uses
|
||||
# to uninstall, launch and filter. It must agree with `applicationId`
|
||||
# in app/build.gradle, and nothing enforces that.
|
||||
# APP_ID is an *assertion*, not a setting, and it has no default.
|
||||
#
|
||||
# ANDROID.md says to set this in build/config.yml. That does not work
|
||||
# in beta.8, checked both ways: `wails3 task` builds its var set from
|
||||
# CLI KEY=VALUE arguments and the Taskfile tree only -- nothing reads
|
||||
# config.yml -- and even when set it feeds only these adb commands,
|
||||
# never Gradle. So the identity is declared twice, here and in
|
||||
# build.gradle, and a change to one alone means the official run and
|
||||
# deploy tasks address a package that is not installed.
|
||||
APP_ID: '{{.APP_ID | default "app.yellowjacket"}}'
|
||||
# It used to be the id every adb-driven task below uninstalled,
|
||||
# launched and filtered, defaulting to "app.yellowjacket". It could
|
||||
# never have been a setting: `wails3 task` builds its var set from CLI
|
||||
# KEY=VALUE arguments and the Taskfile tree only -- nothing reads
|
||||
# build/config.yml, contrary to ANDROID.md, checked with --dry -- and
|
||||
# even when set it fed only the adb commands, never Gradle. So the
|
||||
# identity was declared twice, here and as `applicationId` in
|
||||
# app/build.gradle, with nothing enforcing that they agree.
|
||||
#
|
||||
# They did not agree. The debug buildType carries
|
||||
# `applicationIdSuffix ".dev"`, so the tasks that assemble a debug APK
|
||||
# addressed the *release* id -- on a device, the user's installed app
|
||||
# and their library (#159).
|
||||
#
|
||||
# The id is now read back from the built APK by scripts/android-
|
||||
# pkgid.sh, so the thing installed and the thing launched agree by
|
||||
# construction. Passing APP_ID= says "this build had better declare
|
||||
# that id", and the deploy refuses before touching anything if it does
|
||||
# not -- which is the check that would have caught #159 statically.
|
||||
APP_ID: '{{.APP_ID | default ""}}'
|
||||
MIN_SDK: '21'
|
||||
TARGET_SDK: '35'
|
||||
# The emulator runs the host architecture; physical devices are arm64
|
||||
@@ -372,9 +382,7 @@ tasks:
|
||||
ARCH: '{{.ARCH | default .HOST_ARCH}}'
|
||||
cmds:
|
||||
- task: ensure-emulator
|
||||
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
|
||||
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
|
||||
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
|
||||
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
|
||||
|
||||
run:
|
||||
summary: Build, install and launch a debug build in the Android Emulator
|
||||
@@ -383,9 +391,7 @@ tasks:
|
||||
- task: build
|
||||
cmds:
|
||||
- task: assemble:apk
|
||||
- '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
|
||||
- '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
|
||||
- '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
|
||||
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
|
||||
|
||||
device:list:
|
||||
summary: Lists connected Android devices and emulators (serials)
|
||||
@@ -400,25 +406,7 @@ tasks:
|
||||
ARCH: arm64
|
||||
cmds:
|
||||
- task: assemble:apk
|
||||
- |
|
||||
DEVICE='{{.DEVICE_ID | default ""}}'
|
||||
if [ -z "$DEVICE" ]; then
|
||||
DEVICE="${DEVICE_ID:-}"
|
||||
fi
|
||||
if [ -z "$DEVICE" ]; then
|
||||
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
|
||||
fi
|
||||
if [ -z "$DEVICE" ]; then
|
||||
echo "Error: no connected physical Android device found."
|
||||
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
|
||||
echo "Find connected device serials with: {{.ADB}} devices"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
|
||||
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
|
||||
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
|
||||
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
|
||||
preconditions:
|
||||
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||
@@ -430,25 +418,7 @@ tasks:
|
||||
vars:
|
||||
ARCH: arm64
|
||||
cmds:
|
||||
- |
|
||||
DEVICE='{{.DEVICE_ID | default ""}}'
|
||||
if [ -z "$DEVICE" ]; then
|
||||
DEVICE="${DEVICE_ID:-}"
|
||||
fi
|
||||
if [ -z "$DEVICE" ]; then
|
||||
DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
|
||||
fi
|
||||
if [ -z "$DEVICE" ]; then
|
||||
echo "Error: no connected physical Android device found."
|
||||
echo "Pass DEVICE_ID=<serial> to target a device explicitly."
|
||||
echo "Find connected device serials with: {{.ADB}} devices"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
|
||||
"{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
|
||||
"{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
|
||||
"{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
|
||||
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target device{{if .DEVICE_ID}} --serial "{{.DEVICE_ID}}"{{end}}{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
|
||||
preconditions:
|
||||
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
|
||||
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
|
||||
|
||||
@@ -891,13 +891,41 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity going away is not the app shutting down.
|
||||
*
|
||||
* <p>The scaffold called {@code bridge.shutdown()} here, which is
|
||||
* the natural reading of onDestroy and is wrong for this app twice
|
||||
* over. Android destroys and recreates an activity for a
|
||||
* configuration change the manifest does not declare, under memory
|
||||
* pressure, and on every background if the user has "Don't keep
|
||||
* activities" on -- all **without restarting the process**. And
|
||||
* when the user really does leave, this app's reason for existing
|
||||
* in the background is that a song is playing, which is what the
|
||||
* {@code mediaPlayback} foreground service is holding the process
|
||||
* alive for. Either way, tearing the Go side down here would stop
|
||||
* the music.
|
||||
*
|
||||
* <p>It was harmless only by accident: {@code nativeShutdown} calls
|
||||
* {@code App.Quit()}, whose Android {@code destroy()} is an empty
|
||||
* method, and {@code Run()}'s deferred {@code shutdownServices()}
|
||||
* can never fire because Android's {@code platformRun} is
|
||||
* {@code select{}} and does not return. So no {@code
|
||||
* ServiceShutdown} has ever run on Android, and removing this call
|
||||
* changes nothing today -- it stops the day someone implements
|
||||
* {@code destroy()} from silently killing playback on a rotation.
|
||||
*
|
||||
* <p>There is no callback for "the process is going away"; Android
|
||||
* simply kills it. Durability on this platform is the persist
|
||||
* writers, which submit on every mutation rather than at exit.
|
||||
*
|
||||
* <p>See #52, and CLAUDE.md, "An activity is a view onto the
|
||||
* process".
|
||||
*/
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
unregisterSystemEventReceivers();
|
||||
if (bridge != null) {
|
||||
bridge.shutdown();
|
||||
}
|
||||
if (webView != null) {
|
||||
webView.destroy();
|
||||
}
|
||||
|
||||
@@ -129,7 +129,24 @@ public class WailsBridge {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the native Go library
|
||||
* Initialize the native Go library.
|
||||
*
|
||||
* <p><b>{@code initialized} is deliberately per-instance, and making
|
||||
* it {@code static} is the trap this comment exists for.</b> A
|
||||
* recreated activity builds a new bridge and calls this again, in a
|
||||
* process where the native library is already loaded and Go's
|
||||
* {@code main()} is already running -- so "initialise once per
|
||||
* process" looks like exactly the right rule. It is not, because
|
||||
* {@code nativeInit} does <i>two</i> things: it runs
|
||||
* {@code go mainFunc()}, and it stores the global JNI reference to
|
||||
* <i>this</i> bridge. Skip it and Go keeps executing JavaScript
|
||||
* against the destroyed activity's WebView: the app opens, renders,
|
||||
* and never receives another backend event.
|
||||
*
|
||||
* <p>So this is called every time, and the half that must not repeat
|
||||
* is latched on the Go side instead, at the top of {@code main()} --
|
||||
* which is also where the damage was ({@code os.Exit(1)}), and the
|
||||
* only place that can see it. See #52.
|
||||
*/
|
||||
public void initialize() {
|
||||
if (initialized) {
|
||||
|
||||
+16
-6
@@ -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
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# Config Improvement Suggestions
|
||||
|
||||
Remaining suggestions for improving the configuration system in YellowJacket.
|
||||
|
||||
## 2. Thread Safety Concerns
|
||||
|
||||
The current `Config` struct lacks synchronization:
|
||||
- `Load()` and `Save()` can race with concurrent reads
|
||||
- `handleConfigUpdate()` in library mutates `l.conf.DirectoryPath` without locks
|
||||
|
||||
**Suggestion:** Add a `sync.RWMutex` to protect config access, especially if config is read during scans.
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
// ...
|
||||
}
|
||||
|
||||
func (c *Config) Load() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Nil Safety in Validation
|
||||
|
||||
In `config.go`, validation only runs if `c.Library != nil`, but `handleConfigPost` dereferences `postedConfig.Library` without checking for nil:
|
||||
|
||||
```go
|
||||
if postedConfig.Library != nil {
|
||||
c.Library = postedConfig.Library
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** Partially addressed in the event refactor, but consider adding explicit nil checks in `Validate()` as well.
|
||||
|
||||
## 4. Inconsistent Error Handling on HTTP Responses
|
||||
|
||||
In `httphandler.go:28-31`, `WriteHeader` is called *after* rendering the error template, which won't work as expected (headers must be set before writing body):
|
||||
|
||||
```go
|
||||
c.formSubmitError(err.Error()).Render(r.Context(), w)
|
||||
w.WriteHeader(http.StatusInternalServerError) // Too late!
|
||||
```
|
||||
|
||||
**Fix:** Set the status code before rendering:
|
||||
|
||||
```go
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
c.formSubmitError(err.Error()).Render(r.Context(), w)
|
||||
```
|
||||
|
||||
## 5. Make `scanWorkerCount` Configurable
|
||||
|
||||
There's a TODO at `library.go:289`:
|
||||
```go
|
||||
// TODO: make configurable via Config.
|
||||
var scanWorkerCount = goruntime.NumCPU()
|
||||
```
|
||||
|
||||
**Suggestion:** Add this to `library.Config`:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
DirectoryPath Directory `form:"Directory" schema:"directory,required"`
|
||||
ScanWorkers int `form:"ScanWorkers" schema:"scan_workers"`
|
||||
}
|
||||
```
|
||||
|
||||
Then in `NewLibrary()` or `Scan()`:
|
||||
|
||||
```go
|
||||
workers := l.conf.ScanWorkers
|
||||
if workers <= 0 {
|
||||
workers = goruntime.NumCPU()
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Consider Config Defaults
|
||||
|
||||
Currently if no config exists, an empty one is saved. Consider providing sensible defaults (e.g., common music directories like `~/Music`).
|
||||
|
||||
```go
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Library == nil {
|
||||
c.Library = &library.Config{}
|
||||
}
|
||||
if c.Library.DirectoryPath == "" {
|
||||
// Try common music directories
|
||||
home, _ := os.UserHomeDir()
|
||||
musicDir := filepath.Join(home, "Music")
|
||||
if info, err := os.Stat(musicDir); err == nil && info.IsDir() {
|
||||
c.Library.DirectoryPath = library.Directory(musicDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Config Reload/Watch Capability
|
||||
|
||||
The config is only loaded at startup. Consider adding:
|
||||
- File watcher for external config changes (using `fsnotify`)
|
||||
- Explicit reload method callable from UI
|
||||
|
||||
```go
|
||||
func (c *Config) Watch() error {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for event := range watcher.Events {
|
||||
if event.Op&fsnotify.Write == fsnotify.Write {
|
||||
c.Load()
|
||||
// Emit event for listeners
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return watcher.Add(c.filePath)
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Validation Should Return Structured Errors
|
||||
|
||||
Currently validation returns combined errors. Consider returning a structured validation result that the UI can map to specific fields for better user feedback.
|
||||
|
||||
```go
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
type ValidationResult struct {
|
||||
Valid bool
|
||||
Errors []ValidationError
|
||||
}
|
||||
|
||||
func (c *Config) ValidateStructured() ValidationResult {
|
||||
var result ValidationResult
|
||||
result.Valid = true
|
||||
|
||||
if c.Library != nil {
|
||||
if err := c.Library.Validate(); err != nil {
|
||||
result.Valid = false
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "Library.DirectoryPath",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Use Standard Library for Config Paths
|
||||
|
||||
The path construction in `system/userdata.go` doesn't respect `$XDG_CONFIG_HOME` on Linux or use the standard Go `os.UserConfigDir()`.
|
||||
|
||||
**Current implementation:**
|
||||
```go
|
||||
case "linux":
|
||||
return fmt.Sprintf("/home/%s/%s/yellowjacket", username, unixSubdirs[dt]), nil
|
||||
```
|
||||
|
||||
**Suggested improvement:**
|
||||
```go
|
||||
func GetUserConfigDirPath() (string, error) {
|
||||
baseDir, err := os.UserConfigDir() // Respects XDG_CONFIG_HOME
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get user config directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(baseDir, "yellowjacket")
|
||||
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return "", fmt.Errorf("could not create config directory: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
```
|
||||
|
||||
This approach:
|
||||
- Respects `$XDG_CONFIG_HOME` on Linux
|
||||
- Uses proper macOS paths (`~/Library/Application Support`)
|
||||
- Uses `%AppData%` on Windows
|
||||
- Is more portable and follows platform conventions
|
||||
@@ -1,53 +0,0 @@
|
||||
# Development Overview
|
||||
|
||||
YellowJacket is a moderately complex application. This document gives an overview of how development of it works.
|
||||
|
||||
## Logical Breakdown
|
||||
|
||||
YellowJacket can be thought about in a heirarchy of logical modules and components. The borders of these logical sections are mostly represented in the code and directory structure as well.
|
||||
|
||||
- Frontend
|
||||
- UI Components (see [Lit](###lit-web-components))
|
||||
- Backend
|
||||
- App
|
||||
- Asset Handler
|
||||
- Logging
|
||||
- System
|
||||
- Player
|
||||
- Library
|
||||
- Config
|
||||
- Database
|
||||
- Queries (see [sqlc](###sqlc))
|
||||
|
||||
## Dependencies
|
||||
|
||||
YellowJacket uses many tools and libraries to provide its functionality.
|
||||
This section lists each of these dependencies and explains how they are used.
|
||||
|
||||
### [Wails](https://wails.io)
|
||||
|
||||
Used to create desktop apps with Go and web technologies.
|
||||
|
||||
### [SQLite](https://github.com/mattn/go-sqlite3?tab=readme-ov-file#go-sqlite3)
|
||||
|
||||
Used for local database.
|
||||
|
||||
### [sqlc](https://sqlc.dev/)
|
||||
|
||||
Used to generate Go code from SQL.
|
||||
|
||||
### [Templ](https://templ.guide/)
|
||||
|
||||
Used to generate HTML templates with Go code.
|
||||
|
||||
### [Beep](https://github.com/gopxl/beep?tab=readme-ov-file#beep)
|
||||
|
||||
Used for audio playback.
|
||||
|
||||
### [Lit Web Components](https://lit.dev/)
|
||||
|
||||
Used for dynamic/reactive frontend components.
|
||||
|
||||
### [HTMX](https://htmx.org/)
|
||||
|
||||
Used for requesting HTML fragments from the backend and rendering them on the frontend.
|
||||
-1648
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user