Compare commits

..
Author SHA1 Message Date
yonlu 97f2eb85ee docs: put the closing keyword where Gitea will actually read it
CI / check (push) Skipped
CI / e2e (push) Skipped
CLAUDE.md said the Closes list was unreliable and to close by hand. It
is unreliable for a specific reason, and the rule can say what works.

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

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

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

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

Closes #98
Closes #100
2026-08-18 17:13:30 -04:00
278 changed files with 2978 additions and 36021 deletions
-17
View File
@@ -139,23 +139,6 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. Two reasons it is worst here. The APK goes
# to the *generic* registry, which is readable without
# credentials so Obtainium can poll a plain URL — a beta would
# be offered to every device on it. And the versionCode maths
# below splits on dots and would read "1" out of "0-beta",
# producing a code that is wrong rather than a build that
# fails: Android orders releases by that integer and refuses
# anything not greater than what is installed.
case "$v" in
*-*)
echo "v$v is a prerelease; not publishing an APK for it"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
# Android orders releases by an integer and refuses anything
-16
View File
@@ -72,22 +72,6 @@ jobs:
exit 0
fi
# A prerelease is not a shipment either, and this trigger is
# `v*` — which matches `v0.4.0-beta.1`. Nothing produces one
# today; the guard is here because the thing that would is
# semantic-release's `prerelease: true` channel, a one-line
# change in .releaserc.yml whose blast radius is four public
# package channels. Same argument as release.yml's
# `chore(release):` guard: cheap, against something a future
# edit turns on somewhere else entirely.
case "$v" in
*-*)
echo "$v is a prerelease; not packaging it for pacman"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "building $v"
-13
View File
@@ -95,19 +95,6 @@ jobs:
exit 0
fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. The mildest of the four — assets attach to
# the prerelease's own Gitea release and no package manager
# reads them — but four workflows sharing one trigger should
# share one answer about what a shipment is.
case "$v" in
*-*)
echo "$v is a prerelease; not attaching desktop assets"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
-12
View File
@@ -56,18 +56,6 @@ 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"
+8 -54
View File
@@ -1,36 +1,11 @@
name: Release
# The sixth workflow, and the one that decides whether the other three
# 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.
# run at all. On every push to main it reads the Conventional Commits
# since the last tag, and if any of them is releasable it writes the
# changelog, pushes the tag, and creates the Gitea release whose body is
# that changelog section. The publishing workflows are keyed on `v*`, so
# the tag push is what starts them.
#
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
@@ -44,12 +19,9 @@ 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.
@@ -191,32 +163,14 @@ 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 \
@@ -224,5 +178,5 @@ jobs:
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release $dry \
semantic-release \
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
-107
View File
@@ -1,107 +0,0 @@
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
-5
View File
@@ -88,8 +88,3 @@ 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
-142
View File
@@ -1,142 +0,0 @@
---
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.
+2 -21
View File
@@ -130,13 +130,7 @@ 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. **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.
which its own warning had been read twice.
- **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:
@@ -158,7 +152,7 @@ only climb when it cannot.
| You changed | Run | Cost |
|---|---|---|
| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app |
| …and it renders differently | `make ui-visual` | + 10 baselines, opt-in, never gates |
| …and it renders differently | `make ui-visual` | + 6 baselines, opt-in |
| Any Go code | `make test` | 3 passes, ~2 min |
| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app |
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
@@ -180,13 +174,6 @@ less than it looks.)
Two rules about climbing:
- **If you moved a component's geometry, run `make ui-visual` and
refresh that component's baseline in the same commit.** Nothing else
will: it is the one tier in this repo no hook and no CI job runs, and
it cannot be one — its references are machine-specific, measured in
[references/ui-tier.md](references/ui-tier.md). Four of them drifted
across three merges before anyone noticed (#196). Read the image;
never bless a reference you did not cause.
- **A component test passing is not the app rendering.** If you touched
anything in `frontend/src`, verify it in the real app too — start it
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
@@ -208,12 +195,6 @@ 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,29 +8,13 @@ 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).
## Two facts that make failure invisible
## Three facts that make failure invisible
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.
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
**`os.Exit` is a silent death.** `main()` ends several failure paths in
`os.Exit(1)`. From Android's side that is a process that vanished:
@@ -50,10 +34,7 @@ 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 — 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.
`main()` left. Work backwards through its `os.Exit(1)` paths.
## What to run
@@ -213,13 +194,9 @@ 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 | **runs** (2026-08-20) | — |
| arm64, real device | — | unverified, still |
**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.
**A physical arm64 device remains the only verification path.**
### What was fixed to get here
@@ -233,11 +210,6 @@ 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
@@ -277,25 +249,10 @@ 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 # debug build + install + launch on a phone
wails3 task android:deploy-device # release build, same
wails3 task android:run:device # same, first connected physical device
wails3 task android:deploy-device # production APK to a device
wails3 task android:bundle:fat # AAB, for a Play Store upload
wails3 task android:studio # open build/android/ in Android Studio
wails3 task android:device:list
@@ -303,16 +260,6 @@ 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`
@@ -322,51 +269,16 @@ 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 read back from the APK
## The identity is declared twice
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` 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`.
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
adb-driven task uninstalls, launches and filters. **Nothing enforces
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
that file (verified with `--dry`), and even when set it feeds only the
adb commands, never Gradle. Change both or the official `run`/`deploy`
tasks address a package that is not installed.
Related, and it will bite once: the launcher activity is
`com.wails.app.MainActivity` and the applicationId is
@@ -375,27 +287,6 @@ 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
@@ -420,91 +311,6 @@ 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
@@ -534,130 +340,6 @@ 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
@@ -78,58 +78,9 @@ synchronously.
Microtasks and not a timer, deliberately: a timer hangs forever under
the suites that install fake ones.
## The visual tier does not gate, and that is measured (#196)
`make ui-visual` is the same suite with nine `toMatchScreenshot`
baselines switched on. **Nothing runs it but a person**, deliberately,
and the reason is a number rather than a preference: the committed
baselines were recorded on Arch, and replayed in a bare `ubuntu:24.04`
container — CI's `check` image — three of them fail for reasons that
have nothing to do with any component.
| baseline | Arch | ubuntu:24.04 |
|---|---|---|
| `page-header` filtered-by-search | passes | ratio 0.03 differ, against a 0.02 allowance |
| `track-info` | passes | ratio 0.03 differ |
| `seek-bar` | 1152×18 | 1152×17 |
The two references that were genuinely stale did not even agree about
their *new* size — `now-playing` renders 1152×65 on Arch and 1152×64 in
the container. So moving CI's `check` job from `make ui-test` to
`make ui-visual` is not a one-line change: it needs a second,
container-recorded baseline set, which every local run would then fail
against. That is the same trap the other way round, and a pre-push hook
is the same fault again — one machine's baselines against everybody
else's renderer.
So the tier stays local and opt-in, and the rule that replaces the gate
is:
- **A change that moves a component's geometry refreshes that
component's reference in the same commit, having read the image.**
Look at the PNG; the dimensions in the failure message are the cheap
half of the answer.
- **Never refresh a reference you did not cause.** #196 exists because
four of them drifted across three unrelated merges, and every red run
made the next person likelier to stop running the tier than to read
it.
- **State the world the shot is taken in.** The stores are singletons,
so a visual case that sets nothing photographs whatever the previous
case left behind — which is how the sidebar's baseline came to have
Tracks lit and `now-playing`'s to be playing from a dynamic mix.
- **Record one file with `make ui-visual-update UI_ARGS=<path>`**, and
check `git status` before committing either way. That filter is only
honoured since #204: the recipe was a bare `--update`, and vitest
takes the following positional as the flag's value, so the path was
swallowed and *every* baseline was re-recorded — blessing any stale
one in silence.
What the tier is worth, for the record: it is a *layout* check, blind to
colour (the component tier has no `:root`, so it renders the fallbacks —
`make ui-visual` passed unchanged through a whole palette rewrite,
twice), and it has caught one thing nothing else could — swapping
`library-status-indicator`'s `<button>` for a `<span>` lost the UA
stylesheet's `box-sizing` and grew the badge 36→38px.
Visual baselines are font-hinting and compositing sensitive, which is
why they are opt-in: they only mean anything on the machine that
recorded them.
## Bindings
-1595
View File
File diff suppressed because it is too large Load Diff
@@ -1,327 +0,0 @@
# 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.
@@ -1,408 +0,0 @@
# 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`
5070ms *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.
+3 -14
View File
@@ -1,19 +1,8 @@
# semantic-release configuration.
#
# 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.
# Runs on pushes to main from .gitea/workflows/release.yml: determine the
# version from the Conventional Commits since the last tag, write the
# changelog, commit it, push the tag, and create the Gitea release.
#
# **There is no `@semantic-release/github` plugin here and there must not
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
+5 -10
View File
@@ -5,20 +5,15 @@ 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`. 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).
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.
**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 a release
run would cut right now, and the workflow's own `dry_run` input answers
the same question from CI.
be worse than no file at all. `make release-dry` prints what the next
merge would release.
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
+31 -1639
View File
File diff suppressed because it is too large Load Diff
-173
View File
@@ -1,173 +0,0 @@
# Contributing to YellowJacket
This is the contributor's half of the [README](README.md): how to build it, how
to check a change, and how a change gets in. [`CLAUDE.md`](CLAUDE.md) is the
deep reference — the architecture, and the reasons behind the shape of it —
and is worth reading before a change of any size, because most of this
codebase's traps are written down there and nowhere else.
## Building from source
YellowJacket is [Go](https://go.dev/) with a [Lit](https://lit.dev/)/TypeScript
frontend, bridged by [Wails v3](https://wails.io/).
| Tool | Version |
|------|---------|
| Go | 1.25+ |
| Node.js | 22+ |
| pnpm | 10+ |
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
to install globally; `make setup` fetches it with the rest of the tooling.
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
WebKitGTK 6.0 by default:
```bash
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
```
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
release builds. macOS and Windows need no extra system packages. Run
`go tool wails3 doctor` to check your environment.
```bash
make setup # install tooling, frontend packages and the git hooks
make dev # run with hot-reload
make build-dev # debug build with symbols
make build-prod # production build (stripped and trimmed)
make android # the arm64 APK, into bin/
```
The `Makefile` is the front door and carries a one-line description against
each target; `Taskfile.yml` and `build/<platform>/Taskfile.yml` are the build
implementation behind it and are not called directly.
## Generated code
Two generators run from `go generate ./...`, which `make generate` wraps:
**sqlc** turns `backend/database/sql/queries/` into Go in
`backend/database/sql/sqlcgen/`, and **templ** turns `.templ` files into
`*_templ.go` beside them. Never edit either output by hand — run
`make generate` after touching a `.sql` or a `.templ` file.
The TypeScript bindings in `frontend/bindings/` are generated by `wails3`
rather than by `go generate`, so they are a separate step: `make bindings`
regenerates them and `make bindings-check` fails if they are stale.
`frontend/src/events.ts` is generated too, from `backend/events/events.go`.
A pre-commit hook checks that all of this is fresh, so the usual way to meet it
is a failing commit rather than a bug.
## Checking a change
Run the tier the change actually demands, not the cheapest one.
| Change | Command |
|---|---|
| Go | `make lint` and `make test` — both cover all three build configurations |
| A frontend component or store | `make ui-test` (Vitest in a real Chromium, no backend) |
| A user-visible flow | `make e2e`, against a running `make dev-headless` |
| CSS | `make css-check` — see the Chrome 113 note below |
| Anything cosmetic | look at a screenshot; several bugs here were invisible to every assertion and obvious in an image |
`make test` needs the fixture library, which is generated rather than
committed — it runs `make testdata` itself (about a second).
The end-to-end tier drives the real app with no display at all: `make
dev-headless` starts it in the background on `:34115` (add `SEED=<name>` for a
seeded library, built by `make sandbox-seed NAME=<name>`), `make dev-logs` tails
it and `make dev-stop` stops it. **Check the port before starting one** — if
`:34115` is already answering, someone else's app is there, and a green result
about their build is worse than no result.
Two smaller checks exist because the failure they catch is silent:
`make bindings-check` (stale generated bindings) and `make css-check`, which is
two passes — one fails on a `css` literal ended early by a backtick inside a
comment, the other on a nested CSS rule that begins with a bare element
selector. Chrome 113 is what the reference Android device renders with, and it
drops such a rule without a word.
`make vulncheck` runs govulncheck over the module.
## The issue tracker is the source of truth
Work is described by issues before it is described by branches, and the tracker
is shared with people who cannot see your terminal.
- **Search before starting**, closed issues included: `./scripts/issue.sh search
<terms>`. "That was fixed three weeks ago" is the cheapest possible answer.
- **Claim before the first edit**, not before the commit:
`./scripts/issue.sh claim <n>` sets the assignee, applies `Status/In Progress`
and comments with the branch, so the work is visibly taken *while it is being
done*. It refuses if somebody else holds it — talk to them rather than working
around it.
- **If no issue covers the work, open one first** (`./scripts/issue.sh new`).
- **Findings get filed.** A bug tripped over on the way to something else is an
issue with a reproduction, not a wider diff and not a sentence in a chat log.
- **#73 is the roadmap** and states the order the backlog should be worked in.
`scripts/issue.sh` is the whole interface (`list`, `mine`, `search`, `show`,
`new`, `claim`, `unclaim`, `comment`, `close`, `label`, `depends`, `labels`) and
wants a `GITEA_TOKEN` with `write:issue`. The labels are a taxonomy rather than
tags: `Kind/*`, `Area/*`, `Priority/*`, `Platform/*`, plus `Reviewed/*` and
`Status/*`, of which the last two are exclusive scopes.
## Commits and pull requests
`main` is protected, so a branch and a PR are the only way in. Branch from
`origin/main`, and name the branch after the issue (`fix/140-…`, `feat/25-…`).
Commit subjects are [Conventional Commits](https://www.conventionalcommits.org/)
`type(scope): subject`, imperative, ≤72 characters — and are enforced by a
`commit-msg` hook and by CI (`make commit-check`). This is load-bearing rather
than decorative: semantic-release reads the **type** to decide the next version,
so a CI-only change is `ci:` and never `fix(ci):`, which would ship a patch
release. `make release-dry` prints what a release would cut right now.
**The closing keyword goes in the commit body**, one issue per line, because
Gitea parses commit messages that reach `main` and does not parse the PR body:
```
docs: rewrite the README as a landing page
<why>
Closes #50
```
A PR body carries a commit-to-issue table, the verification you actually ran
(with results), and a `Closes` list for whoever reads it.
## Style
- **Go** — golangci-lint v2, strict: `err113` (static errors), `nlreturn`,
`wsl_v5`, `godot`, `sloglint`, `perfsprint`, and imports grouped stdlib →
third-party → `yellowjacket/…` by gci.
- **TypeScript** — strict mode, no implicit `any`, no unused locals or
parameters.
- Match the surrounding code. Where `CLAUDE.md` explains why something is shaped
the way it is, that shape is load-bearing and there is usually a test pinning
it.
Hooks do most of the enforcing (`lefthook.yml`, installed by `make setup`):
pre-commit runs vet, lint, the codegen checks, the frontend typecheck and the
two CSS checks in parallel; pre-push runs the Go suite and the UI tier,
deliberately one after the other rather than together.
## Where the rest of the documentation is
- [`CLAUDE.md`](CLAUDE.md) — architecture and constraints, in depth.
- [`docs/PROFILING.md`](docs/PROFILING.md) — Go pprof and frontend profiling.
- [`docs/android-release.md`](docs/android-release.md) — the APK, its signing
key, and what the release workflow checks.
- [`docs/index-cache.md`](docs/index-cache.md) — the search-index build cache
and why it has a snapshot.
- [`packaging/arch/README.md`](packaging/arch/README.md),
[`packaging/homebrew/README.md`](packaging/homebrew/README.md) — the two
package channels.
- `.planning/` — design documents and measured history, not a queue. The queue
is the tracker.
+9 -23
View File
@@ -160,11 +160,8 @@ ui-watch: ## Same suite, in watch mode
ui-visual: ## Run the suite including screenshot comparisons
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
# `--update=true`, never a bare `--update`: vitest takes the following
# positional as the flag's value, so `--update <path>` swallows the path
# and re-records every baseline in the repo instead of the one named.
ui-visual-update: ## Re-record the screenshot baselines (UI_ARGS=<path> to filter)
@cd frontend && YJ_VISUAL=1 npx vitest run --update=true $(UI_ARGS)
ui-visual-update: ## Re-record the screenshot baselines
@cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS)
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
@cd frontend && pnpm install && npx playwright install chromium
@@ -175,17 +172,12 @@ 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. 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.
# the suite failing to import. Four sessions, three plans. Instant.
.PHONY: css-check
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs
@cd frontend && node scripts/check-css-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.
@@ -200,21 +192,15 @@ 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 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.
# What a merge to main would release, without releasing it. Reads the
# same .releaserc.yml CI does, so "why did that not cut a version" is
# answerable locally instead of by pushing and watching. Needs no
# credentials: --dry-run neither tags nor publishes.
#
# The pins must stay identical to release.yml's, which is where the note
# on holding the conventionalcommits preset at 9 lives -- at 10 the
# release notes come out empty with everything green.
release-dry: ## Print the version a release run would cut right now
release-dry: ## Print the version a merge to main would release
@npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
+80 -107
View File
@@ -2,139 +2,112 @@
*Music how it was meant to bee.*
YellowJacket plays the music you already own. Point it at your folders and it
scans them, reads the tags and the cover art, and gives you a library you can
browse, search, queue and tidy up — on your own machine, with no account, no
streaming service and no telemetry.
YellowJacket is a fast, cross-platform desktop music player for your local
collection. It plays your files, keeps your library tidy, and helps you discover
and organize your music — all in a clean, responsive interface. No accounts, no
streaming, no telemetry: just your music on your machine.
It plays **MP3**, **FLAC**, **OGG Vorbis** and **WAV**, on **Linux** and
**Android**, and builds from source on **macOS**.
Runs on **Linux**, **macOS**, and **Windows**.
![The track list, with something playing](docs/images/library.png)
## Features
## What it does
### Play your music
- Plays **MP3, FLAC, OGG Vorbis, and WAV**
- Play, pause, seek, and volume control with a mute toggle
- Gapless, glitch-free seeking backed by a read-ahead buffer
- A queue you can add to, reorder, and shuffle, with play-next support
- Shuffle and repeat (off / all / one)
- Picks up right where you left off — remembers your track, position, and volume between sessions
- Media-key and MPRIS support on Linux, so your desktop's playback controls just work
**Plays your files.** Play, pause, seek and volume with a mute toggle; a
read-ahead buffer so seeking is instant rather than gappy; a queue you can add
to, reorder and shuffle, with play-next; shuffle and repeat (off / all / one).
It remembers the track, the position and the queue between sessions, and it
answers your desktop's media keys — MPRIS on Linux, a media notification and
lock-screen controls on Android.
### Keep your library organized
- Point it at your music folders and it scans them automatically
- Reads tags and embedded cover art, and de-duplicates artwork so it isn't stored twice
- Incremental sync — only new or changed files get reprocessed, and deleted files are cleaned up
- Browse by **album**, **artist**, or **genre**, or search across everything
- Mark favorites and see what you've been listening to with play history
- Edit track tags directly when something's off
**Keeps the library tidy.** It scans the folders you give it and rescans only
what changed, so a big library costs its full scan once. It de-duplicates
embedded cover art rather than storing the same image a hundred times, notices
files that have gone away, and spots duplicate tracks. Browse by album, artist
or genre, search across everything, mark favourites, and see what you have been
playing.
### Playlists
- Create playlists, drag tracks in, and reorder them
- **Smart playlists** that build themselves from rules (by genre, rating, play count, and more)
- Pin a default playlist and spot duplicate tracks at a glance
**Playlists, and playlists that write themselves.** Drag tracks in and reorder
them, or describe what you want — genre, play count, how long since you played
it — and let a smart playlist keep itself up to date.
**Explore and auto-tag, from the MusicBrainz catalog.** Explore browses artists,
releases and genres from the catalog rather than only from what you own, so an
album page can tell you that you have nine of its twelve tracks. Auto-tag
matches your files against MusicBrainz and fills in the metadata that is
missing, with a review step before anything is written to disk. Lyrics search
finds a track from a line you remember.
Explore needs its catalog, which is a one-off ~0.6 GB download from
**Settings → Search Index**. It asks first on a metered connection, and
everything else in the app works without it.
### Discover and clean up (powered by MusicBrainz)
- **Explore** — browse artists, releases, and genres from the MusicBrainz catalog, not just what's already in your library
- **Auto-tag** — match your files against MusicBrainz to fill in correct artist, album, and track metadata, with a review step before anything is written
- **Lyrics search** — find a track by a line you remember
## Install
Every download comes from the
Download the latest build for your platform from the
[releases page](https://git.ljones.me/yonlu/yellowjacket/releases).
### Linux
| Platform | Download |
|----------|----------|
| Linux | `yellowjacket-linux-amd64` |
| macOS | `yellowjacket-darwin-universal.app.zip` (Apple Silicon + Intel) |
| Windows | `yellowjacket-windows-amd64.exe` |
Download `yellowjacket-<version>-linux-amd64.tar.gz` from the latest release and
unpack it. It holds the binary, a `.desktop` entry and an icon.
Prefer to build it yourself? See [Building from source](#building-from-source).
On **Arch**, install it from the package registry instead and get updates with
the rest of your system — the one-time key import and `pacman.conf` block are in
[`packaging/arch/README.md`](packaging/arch/README.md):
```bash
sudo pacman -Sy yellowjacket
```
### Android
Install the APK from the release page, or from the URL below, which always
points at the newest build:
```
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
```
That URL needs no credentials, so [Obtainium](https://obtainium.imranr.dev/) can
poll it directly and keep the app up to date. The build is `arm64-v8a` only, and
[`docs/android-release.md`](docs/android-release.md) says why.
### macOS
Homebrew builds it from source on your own Mac — there is no prebuilt `.app`,
because a signed macOS bundle needs a macOS machine to produce it and the
release runner is a Linux container.
```bash
brew install shadow-puppet/yellowjacket/yellowjacket
```
See [`packaging/homebrew/README.md`](packaging/homebrew/README.md).
### Windows
Not published. It cross-compiles cleanly, but no Windows build of this app has
ever been *run*, and nothing here can exercise one — so shipping it would be a
promise that cannot be kept. You can still build it yourself: see
[`CONTRIBUTING.md`](CONTRIBUTING.md).
### Coming from a 1.x install?
Versions restarted at **0.0.1** when releases became automatic, which every
package manager reads as a downgrade. It costs one reinstall, once — the details
are with each channel: [Homebrew](packaging/homebrew/README.md#upgrading-from-1x-needs-a-reinstall-once),
[Android](docs/android-release.md#the-1x-installs-cannot-be-upgraded-to-00x).
## First run
## Getting started
1. Launch YellowJacket.
2. Add the folder your music lives in — the first-run wizard asks, and
**Settings → Libraries** is where you add more later.
3. Watch the scan finish. It reports progress, and you can browse while it runs.
4. Queue something and press play.
2. Open **Settings** and add the folder(s) where your music lives.
3. Let the initial scan finish — you'll see progress as it works.
4. Browse by album, artist, or genre, queue something up, and press play.
Your library and settings stay on your machine:
Your library and settings are stored locally:
| | Linux / macOS | Windows |
|---|---|---|
| Config | `~/.config/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\config` |
| Library data | `~/.local/share/yellowjacket/` | `%LOCALAPPDATA%\yellowjacket\data` |
Setting `YJ_HOME` moves both, which is how you keep a second library separate.
## Building from source
## More screenshots
YellowJacket is built with [Go](https://go.dev/) and a
[Lit](https://lit.dev/)/TypeScript frontend, bridged by the
[Wails](https://wails.io/) framework.
An album page knows what you own, and says so:
**Prerequisites**
![An album page, with two discs and the transport playing](docs/images/album.png)
| Tool | Version |
|------|---------|
| Go | 1.25+ |
| Node.js | 22+ |
| pnpm | 10+ |
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
The home page suggests somewhere to start rather than opening on a wall of
everything:
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
to install globally; `make setup` fetches it with the rest of the tooling.
![The home page's shelves](docs/images/home.png)
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
WebKitGTK 6.0 by default:
## Contributing, and the rest of the documentation
```bash
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
```
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — build it from source, run the tests,
and how a change gets in.
- [`CLAUDE.md`](CLAUDE.md) — the deep reference: the architecture and the reasons
behind the shape of it.
- [The issue tracker](https://git.ljones.me/yonlu/yellowjacket/issues) is what
is wanted and what is being worked on; **#73** is the roadmap.
- [Releases](https://git.ljones.me/yonlu/yellowjacket/releases) double as the
changelog — every one is generated from the commits it contains.
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
release builds.
macOS and Windows need no extra system packages. Run `go tool wails3 doctor` to
check your environment.
**Build**
```bash
make setup # install tooling and git hooks
make dev # run with hot-reload
make build-prod # produce a release binary
```
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.
-55
View File
@@ -1,55 +0,0 @@
//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)
}
-250
View File
@@ -1,250 +0,0 @@
// 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
}
-355
View File
@@ -1,355 +0,0 @@
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")
}
}
-30
View File
@@ -8,7 +8,6 @@ import (
"log/slog"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/tagtotals"
)
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
@@ -29,8 +28,6 @@ const (
FieldYear = "year"
FieldTrackNumber = "track_number"
FieldDiscNumber = "disc_number"
FieldTotalTracks = "total_tracks"
FieldTotalDiscs = "total_discs"
FieldCoverArt = "cover_art"
)
@@ -421,32 +418,5 @@ func buildChanges(
changes[FieldDiscNumber] = track.DiscNumber
}
// The totals are what says "2 of 10" rather than a bare tick, and
// dropping them here is what made autotagging an album *erase* the
// evidence: the release becomes MBID-matched while the field
// GetAlbumCompleteness reads stays absent.
//
// They are written unconditionally where the candidate has a
// tracklist, not only when they differ from the local value, because
// the common case is a file that declares no total at all -- which
// compares equal to nothing and would be skipped by a diff guard.
if tracks, discs := tagtotals.For(
candidatePositions(cand), track.DiscNumber,
); tracks > 0 {
changes[FieldTotalTracks] = tracks
changes[FieldTotalDiscs] = discs
}
return changes
}
// candidatePositions is the candidate's tracklist as bare positions.
func candidatePositions(cand Candidate) []tagtotals.Position {
out := make([]tagtotals.Position, 0, len(cand.Tracks))
for _, t := range cand.Tracks {
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
}
return out
}
-90
View File
@@ -1,90 +0,0 @@
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)
}
})
}
}
-28
View File
@@ -17,34 +17,6 @@ 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
-31
View File
@@ -168,34 +168,3 @@ 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)
}
}
}
-145
View File
@@ -1,145 +0,0 @@
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
}
-320
View File
@@ -1,320 +0,0 @@
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)
}
}
}
-38
View File
@@ -1,38 +0,0 @@
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])
}
}
}
+1 -119
View File
@@ -544,7 +544,7 @@ func (c *Config) SetDefaultPage(page string) error {
c.General.ApplyDefaults()
}
c.General.DefaultPage = View(page)
c.General.DefaultPage = DefaultPage(page)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
@@ -666,124 +666,6 @@ 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 {
-40
View File
@@ -188,43 +188,3 @@ 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")
}
}
+26 -96
View File
@@ -5,14 +5,28 @@ import (
"fmt"
)
// DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = ViewHome
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
var (
errUnknownDefaultPage = errors.New("unknown default page")
errViewCannotLaunch = errors.New("view cannot be the launch page")
// 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
var errUnknownDefaultPage = errors.New("unknown default page")
// QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to.
type QueueFallback string
@@ -32,52 +46,18 @@ 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 View `toml:"DefaultPage"`
DefaultPage DefaultPage `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
}
@@ -91,17 +71,15 @@ func (c *GeneralConfig) ApplyDefaults() {
func (c *GeneralConfig) Validate() error {
c.ApplyDefaults()
spec, known := LookupView(string(c.DefaultPage))
if !known {
switch c.DefaultPage {
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
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.
@@ -111,51 +89,3 @@ 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
}
-103
View File
@@ -1,103 +0,0 @@
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
}
-307
View File
@@ -1,307 +0,0 @@
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)
}
}
}
+7 -25
View File
@@ -13,31 +13,13 @@ const (
// enforces this at runtime; it is also the floor below which a
// reported size is treated as bogus and not persisted.
//
// **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.
// 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.
MinWidth = 800
// MinHeight is the smallest allowed window height in pixels.
MinHeight = 600
+1 -48
View File
@@ -40,8 +40,7 @@ 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 = '')
LIMIT ?;
AND (mbid IS NULL OR mbid = '');
-- name: DeleteAlbum :exec
DELETE FROM albums WHERE id = ?;
@@ -136,49 +135,3 @@ 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,35 +342,3 @@ 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;
+2 -96
View File
@@ -8,7 +8,6 @@ package sqlcgen
import (
"context"
"database/sql"
"strings"
)
const deleteAlbum = `-- name: DeleteAlbum :exec
@@ -235,103 +234,10 @@ 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 {
@@ -339,8 +245,8 @@ type GetAlbumsWithPendingReleaseMBIDRow struct {
PendingReleaseMbid sql.NullString
}
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context, limit int64) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID, limit)
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
if err != nil {
return nil, err
}
@@ -231,82 +231,6 @@ 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,
-32
View File
@@ -12,7 +12,6 @@ import (
"strconv"
"strings"
"yellowjacket/backend/tagtotals"
"yellowjacket/backend/tagwriter"
)
@@ -276,25 +275,6 @@ 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)
}
@@ -302,18 +282,6 @@ func (i *Importer) tagFile(p plannedFile, dl Download) error {
return nil
}
// expectedPositions is the download's resolved tracklist as bare
// positions.
func expectedPositions(expected []ExpectedTrack) []tagtotals.Position {
out := make([]tagtotals.Position, 0, len(expected))
for _, t := range expected {
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
}
return out
}
// destinationFor computes a file's library path from the template.
func (i *Importer) destinationFor(
p plannedFile,
-74
View File
@@ -446,77 +446,3 @@ 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])
}
}
+31 -44
View File
@@ -2,7 +2,6 @@ package explore
import (
"context"
"database/sql"
"log/slog"
"math"
"sort"
@@ -13,7 +12,6 @@ import (
"golang.org/x/sync/singleflight"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
)
@@ -281,32 +279,37 @@ func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
// 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)
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,
)
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
}
@@ -332,7 +335,7 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
job.progress(i, len(pending))
release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
@@ -341,19 +344,12 @@ func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
continue
}
// 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 {
_, 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 {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
@@ -2216,7 +2212,6 @@ func (e *Service) gatherTopCandidates(
ArtistType: a.Type,
Country: a.Country,
InLibrary: a.InLibrary,
LocalID: a.LocalID,
},
category: "artist",
qualityScore: quality,
@@ -2248,7 +2243,6 @@ func (e *Service) gatherTopCandidates(
ArtistType: a.Type,
Country: a.Country,
InLibrary: a.InLibrary,
LocalID: a.LocalID,
},
category: "artist",
qualityScore: quality,
@@ -2281,7 +2275,6 @@ func (e *Service) gatherTopCandidates(
PrimaryType: rg.PrimaryType,
Year: year,
InLibrary: rg.InLibrary,
LocalID: rg.LocalID,
},
category: "release_group",
qualityScore: quality,
@@ -2327,7 +2320,6 @@ func (e *Service) gatherTopCandidates(
PrimaryType: rg.PrimaryType,
Year: year,
InLibrary: rg.InLibrary,
LocalID: rg.LocalID,
},
category: "release_group",
qualityScore: quality,
@@ -2355,7 +2347,6 @@ func (e *Service) gatherTopCandidates(
CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName,
InLibrary: r.InLibrary,
LocalID: r.LocalID,
},
category: "recording",
qualityScore: quality,
@@ -2412,7 +2403,6 @@ func (e *Service) gatherTopCandidates(
CAAReleaseMBID: r.CAAReleaseMBID,
ReleaseName: r.ReleaseName,
InLibrary: r.InLibrary,
LocalID: r.LocalID,
},
category: "recording",
qualityScore: quality,
@@ -2435,7 +2425,6 @@ func (e *Service) gatherTopCandidates(
ArtistType: m.ArtistType,
Country: m.Country,
InLibrary: m.InLibrary || m.LocalArtistID > 0,
LocalID: m.LocalArtistID,
},
category: "artist",
qualityScore: quality,
@@ -2456,7 +2445,6 @@ func (e *Service) gatherTopCandidates(
PrimaryType: m.PrimaryType,
Year: year,
InLibrary: m.InLibrary || m.LocalReleaseGroupID > 0,
LocalID: m.LocalReleaseGroupID,
},
category: "release_group",
qualityScore: quality,
@@ -2471,7 +2459,6 @@ func (e *Service) gatherTopCandidates(
ArtistMBID: m.ArtistMBID,
Length: m.Duration,
InLibrary: m.InLibrary || m.LocalRecordingID > 0,
LocalID: m.LocalRecordingID,
},
category: "recording",
qualityScore: quality,
-250
View File
@@ -1,250 +0,0 @@
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,
)
}
}
-94
View File
@@ -82,100 +82,6 @@ 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
+1 -15
View File
@@ -2562,19 +2562,6 @@ 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
@@ -2607,8 +2594,7 @@ 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 OR in_library = 1)
WHERE entity_type = ? AND `+p.column+` IS NOT NULL
AND NOT EXISTS (`+p.exists+`)`,
dbEntityType(p.entityType),
)
+1 -10
View File
@@ -41,16 +41,7 @@ type TopResult struct {
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
ReleaseName string `json:"releaseName,omitempty"`
// Library status — populated from index cross-reference columns.
//
// LocalID is the one the cards read. It is the local row behind
// this entity — an album, a file, an artist — and it is set and
// cleared by a test against `audio_files`, so it means "there is
// something of mine here". InLibrary is written by the same pass
// but is a one-way ratchet the prune can only clear alongside a
// local id, so it is the weaker of the two and stays for scoring
// (`fwInLibrary`), which is where an approximate answer is fine.
InLibrary bool `json:"inLibrary"`
LocalID int64 `json:"localId,omitempty"`
InLibrary bool `json:"inLibrary"`
}
// MBArtist is a Wails-friendly projection of a MusicBrainz artist.
-109
View File
@@ -232,112 +232,3 @@ func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
t.Errorf("empty album reported %+v, want zero and unknown", got)
}
}
// The batch and the single-album query are two spellings of one
// question, and the thing worth pinning is that they never disagree.
//
// They are genuinely different SQL — the single-album form is
// correlated subqueries over one album, the batch is two grouping
// levels over a slice — so the risk is not a typo but a drift in
// meaning: a disc's total counted once per file, a duplicate counted
// twice, a disc with no total silently covered by one that had one.
// Every shape the table above cares about is staged here at once,
// because a batch that is only ever asked about one album is not being
// asked the question that can go wrong.
func TestGetAlbumsCompletenessAgreesWithTheSingleAlbumQuery(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
shapes := map[int][]track{
1: disc(1, 100, 12, 12),
2: disc(1, 200, 9, 12),
3: disc(1, 300, 13, 12),
4: {{recordingID: 400, disc: 1, number: 1}},
5: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
6: append(
disc(1, 700, 10, 10),
track{recordingID: 750, disc: 2, number: 1},
),
7: append(
disc(1, 800, 5, 6),
track{recordingID: 899, disc: 1, number: 3, total: 6},
),
}
ids := make([]int64, 0, len(shapes))
for albumID, tracks := range shapes {
stageAlbum(t, lib, albumID, tracks)
ids = append(ids, albumIDFor(t, lib, albumID))
}
batch, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if len(batch) != len(ids) {
t.Fatalf("batch answered for %d albums, want %d", len(batch), len(ids))
}
for _, id := range ids {
one, err := lib.GetAlbumCompleteness(id)
if err != nil {
t.Fatalf("GetAlbumCompleteness(%d): %v", id, err)
}
if got := batch[id]; got != one {
t.Errorf("album %d: batch says %+v, single says %+v", id, got, one)
}
}
}
// An album with no files is absent from the batch, not zeroed.
//
// "I have none of this" and "I have no idea" are the third state Known
// exists to keep apart, and a caller reading a missing key gets nothing
// rather than a confident zero it would have to know to distrust.
func TestGetAlbumsCompletenessOmitsAnAlbumWithNoFiles(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
stageAlbum(t, lib, 1, disc(1, 100, 3, 3))
held := albumIDFor(t, lib, 1)
got, err := lib.GetAlbumsCompleteness([]int64{held, 4242})
if err != nil {
t.Fatalf("GetAlbumsCompleteness: %v", err)
}
if _, ok := got[4242]; ok {
t.Errorf("an album with no files answered %+v, want absent", got[4242])
}
if !got[held].Complete {
t.Errorf("held album reported %+v, want complete", got[held])
}
}
// A caller with nothing to ask about must not issue a query at all —
// sqlc's empty-slice branch rewrites the placeholder to NULL, which is
// a perfectly valid query returning nothing, so this is about the round
// trip rather than the answer.
func TestGetAlbumsCompletenessAsksNothingForAnEmptyList(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
for _, ids := range [][]int64{nil, {}, {0}, {-1, 0}} {
got, err := lib.GetAlbumsCompleteness(ids)
if err != nil {
t.Fatalf("GetAlbumsCompleteness(%v): %v", ids, err)
}
if len(got) != 0 {
t.Errorf("GetAlbumsCompleteness(%v) = %+v, want empty", ids, got)
}
}
}
-59
View File
@@ -244,65 +244,6 @@ 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(
-8
View File
@@ -74,14 +74,6 @@ func ExtractTags(path string) (*TrackMetadata, error) {
// ExtractTagsFromReader reads metadata from an io.ReadSeeker.
func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
// The container decides, so this is asked before tag.ReadFrom and
// not after its failure: a WAV's tags live in a RIFF chunk that
// dhowden/tag cannot see, and its fallback -- an ID3v1 trailer --
// would otherwise outrank them.
if meta, ok := wavTags(r); ok {
return meta, nil
}
m, err := tag.ReadFrom(r)
if err != nil {
// No tags found is not necessarily an error - return empty metadata
-68
View File
@@ -1,68 +0,0 @@
package metadata
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"yellowjacket/backend/riff"
)
// wavTags reads the ID3v2 tag a WAV carries in its RIFF "id3 " chunk,
// which is where backend/tagwriter puts it and where dhowden/tag --
// having no RIFF reader at all -- cannot look. Without this a WAV
// scans as an untagged file however carefully it was tagged.
//
// ok is false when r is not a RIFF/WAVE container, and the read
// position is restored either way so the caller can carry on.
func wavTags(r io.ReadSeeker) (*TrackMetadata, bool) {
start, err := r.Seek(0, io.SeekCurrent)
if err != nil {
return nil, false
}
id3Data, chunkErr := riff.ID3Chunk(r)
if _, err := r.Seek(start, io.SeekStart); err != nil {
return nil, false
}
switch {
case chunkErr == nil:
return wavTagsFrom(id3Data), true
// Not ours to read: let the ordinary dispatch have the file.
case errors.Is(chunkErr, riff.ErrNotRIFF), errors.Is(chunkErr, riff.ErrNotWAVE):
return nil, false
// A RIFF container we cannot get a tag out of -- no chunk, an RF64
// file, a truncated header. That is a file with no readable tags,
// which is what the scanner's filename fallback is for.
default:
return &TrackMetadata{}, true
}
}
// wavTagsFrom parses the bytes of a WAV's ID3v2 chunk.
func wavTagsFrom(id3Data []byte) *TrackMetadata {
meta, err := extractID3v2Lenient(bytes.NewReader(id3Data))
if err != nil {
// A tag holding no frames is not a damaged tag: writing every
// field back out empty leaves one, and warning about it would
// put a fault on a file that has none.
if errors.Is(err, ErrTagsUnreadable) {
return &TrackMetadata{}
}
return &TrackMetadata{
TagReadWarning: fmt.Errorf("%w: %w", ErrTagsUnreadable, err),
}
}
// extractID3v2Lenient names MP3, being the recovery path for one.
meta.FileFormat = strings.ToUpper(strings.TrimPrefix(string(WAV), "."))
return meta
}
-188
View File
@@ -1,188 +0,0 @@
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)
+9 -171
View File
@@ -1,7 +1,6 @@
package player
import (
"errors"
"sync"
"time"
@@ -35,78 +34,8 @@ 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).
@@ -125,24 +54,8 @@ 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.
@@ -150,13 +63,6 @@ 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 {
@@ -166,15 +72,6 @@ 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 {
@@ -218,12 +115,14 @@ func (bs *BufferedStreamer) readAhead() {
}
if !ok {
// 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()
bs.mu.Lock()
bs.done = true
if srcErr := bs.source.Err(); srcErr != nil {
bs.err = srcErr
}
bs.mu.Unlock()
return
}
@@ -255,34 +154,7 @@ func (bs *BufferedStreamer) Stream(
}
if bs.count == 0 {
// 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
}
// Buffer temporarily empty — fill with silence.
for i := range samples {
samples[i] = [2]float64{}
}
@@ -290,9 +162,6 @@ 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 {
@@ -309,20 +178,6 @@ 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()
@@ -342,23 +197,6 @@ 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
-279
View File
@@ -1,279 +0,0 @@
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 }
-213
View File
@@ -1,213 +0,0 @@
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")
}
}
+51 -317
View File
@@ -39,35 +39,22 @@ 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
// 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
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
control *beep.Ctrl
volume *effects.Volume
speakerStreamer beep.Streamer
playbackFinishedHandler func(error)
playbackFinishedHandler func()
trackChangeID uint64
// 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
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.
@@ -76,19 +63,6 @@ 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
@@ -174,8 +148,6 @@ 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,
@@ -208,18 +180,11 @@ func (p *Player) InitSpeaker() error {
}
// SetPlaybackFinishedHandler sets a callback invoked when a track
// stops streaming. This allows the queue to drive auto-advance
// finishes naturally. 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(error)) {
func (p *Player) SetPlaybackFinishedHandler(handler func()) {
p.mu.Lock()
defer p.mu.Unlock()
@@ -297,78 +262,9 @@ 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() {
@@ -528,19 +424,6 @@ 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
@@ -558,12 +441,6 @@ 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}
@@ -597,57 +474,23 @@ 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() {
// 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)
go p.onPlaybackFinished()
}),
))
p.state = Paused
}
// 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) {
// 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() {
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
@@ -658,11 +501,10 @@ func (p *Player) onPlaybackFinished(chainID uint64, srcErr error) {
// the Stopped state anyway, so this only moves the decoder.
p.rewindLocked()
p.emitPositionLocked()
p.mu.Unlock()
// 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.
// Emit Wails events outside the lock — these are non-blocking
// calls that don't need player state.
p.emitPlaybackFinished()
events.Emit(
@@ -671,8 +513,6 @@ func (p *Player) onPlaybackFinished(chainID uint64, srcErr error) {
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 {
@@ -681,19 +521,12 @@ func (p *Player) onPlaybackFinished(chainID uint64, srcErr error) {
)
}
if srcErr != nil {
p.logger.Error(
"Playback stopped: the audio source failed",
"err", srcErr,
)
} else {
p.logger.Info("Playback finished naturally")
}
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(srcErr)
handler()
}
}
@@ -754,18 +587,6 @@ 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 {
@@ -970,10 +791,6 @@ func (p *Player) SetVolume(desiredVolume UserVolume) {
p.mu.Lock()
defer p.mu.Unlock()
if p.systemVolume {
return
}
p.setVolumeLocked(desiredVolume)
p.emitVolumeChanged()
p.saveState()
@@ -1022,10 +839,6 @@ 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()
@@ -1056,14 +869,6 @@ 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()
@@ -1101,8 +906,6 @@ func (p *Player) CurrentPosition() (int, error) {
return 0, errNoAudioFileLoaded
}
defer p.lockSourceLocked()()
speaker.Lock()
pos := math.Round(
100.0 * float64(p.seeker.Position()) /
@@ -1121,28 +924,6 @@ 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() {
@@ -1178,46 +959,6 @@ 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
@@ -1273,11 +1014,19 @@ func (p *Player) seekSourceLocked(
if seekErr != nil {
speaker.Unlock()
p.logger.Debug(
"seek rejected by the decoder",
"samples", samples, "err", seekErr,
p.logger.Warn(
"Seek failed, playback will start from "+
"the beginning",
"target-seconds", targetSeconds,
"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)
}
@@ -1290,6 +1039,15 @@ func (p *Player) seekSourceLocked(
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
}
@@ -1382,10 +1140,6 @@ 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()
@@ -1402,8 +1156,6 @@ func (p *Player) displayPositionSecsLocked() int {
return 0
}
defer p.lockSourceLocked()()
speaker.Lock()
pos := p.seeker.Position()
total := p.seeker.Len()
@@ -1514,15 +1266,7 @@ func (p *Player) saveState() {
volume := int64(DefaultUserVol)
muted := false
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:
if p.volume != nil {
volume = int64(p.getUserVolume())
muted = p.volume.Silent
}
@@ -1606,20 +1350,11 @@ func (p *Player) restoreStateLocked() {
}
}
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)
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.
@@ -1659,9 +1394,8 @@ func (p *Player) restoreStateLocked() {
}
p.logger.Info("Player state restored",
"volume", p.getUserVolume(),
"muted", p.volume.Silent,
"systemVolume", p.systemVolume,
"volume", vol,
"muted", state.Muted,
"trackPath", state.LastTrackPath,
"positionSeconds", state.LastPositionSeconds,
)
-42
View File
@@ -1,42 +0,0 @@
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
}
-11
View File
@@ -1,11 +0,0 @@
//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
-10
View File
@@ -1,10 +0,0 @@
//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
-215
View File
@@ -1,215 +0,0 @@
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])
}
}
}
+3 -3
View File
@@ -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(nil)
q.OnPlaybackFinished()
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(nil)
q.OnPlaybackFinished()
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(nil) // starts resolving, blocked on gate
q.OnPlaybackFinished() // starts resolving, blocked on gate
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
-78
View File
@@ -1,78 +0,0 @@
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(),
)
}
}
+9 -36
View File
@@ -1,45 +1,18 @@
package queue
// 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) {
// OnPlaybackFinished is called when a track finishes playing naturally.
// This drives the auto-advance behavior and records the play.
func (q *Queue) OnPlaybackFinished() {
q.mu.Lock()
// 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) {
if len(q.tracks) == 0 {
q.mu.Unlock()
return
}
// Capture the track that just finished before advancing.
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)
}
}
finishedID := q.tracks[q.currentIndex].AudioFileID
// Repeat One: replay the current track.
if q.repeatMode == RepeatOne {
@@ -48,7 +21,7 @@ func (q *Queue) OnPlaybackFinished(srcErr error) {
}
q.mu.Unlock()
recordFinished()
q.recordPlay(finishedID)
return
}
@@ -58,7 +31,7 @@ func (q *Queue) OnPlaybackFinished(srcErr error) {
// Queue exhausted — this is the extension point for a future fallback playlist.
q.onQueueExhausted(false)
q.mu.Unlock()
recordFinished()
q.recordPlay(finishedID)
return
}
@@ -71,12 +44,12 @@ func (q *Queue) OnPlaybackFinished(srcErr error) {
if !q.playCurrentOrSkip(true, q.nextIndex) {
q.onQueueExhausted(false)
q.mu.Unlock()
recordFinished()
q.recordPlay(finishedID)
return
}
q.emitIndexChanged()
q.mu.Unlock()
recordFinished()
q.recordPlay(finishedID)
}
+2 -2
View File
@@ -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(nil)
q.OnPlaybackFinished()
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(nil)
q.OnPlaybackFinished()
if q.GetState().CurrentIndex != -1 {
t.Errorf(
-186
View File
@@ -1,186 +0,0 @@
// Package riff reads the chunk layout of a RIFF/WAVE container.
//
// It exists because both halves of WAV tagging need it and neither can
// import the other: backend/tagwriter writes a WAV's tags into a RIFF
// "id3 " chunk and already imports backend/metadata, which is what has
// to read them back out. backend/tagtotals is the precedent.
//
// The two readers here are deliberately different. Parse holds every
// chunk's data in memory, which is what rewriting a file needs; a WAV's
// audio *is* the "data" chunk, so doing that on the scan path would
// read every library file in full. ID3Chunk seeks over what it is not
// looking for instead. Both walk the same headers.
package riff
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
)
// Sentinel errors describing a container this package will not read.
var (
ErrRF64NotSupported = errors.New("RF64 files are not yet supported")
ErrNotRIFF = errors.New("not a RIFF file")
ErrNotWAVE = errors.New("not a WAVE file")
ErrNoID3Chunk = errors.New("no ID3 chunk in RIFF file")
)
// Chunk holds a single RIFF sub-chunk (ID + raw data).
type Chunk struct {
ID [4]byte
Data []byte
}
// IsID3 reports whether id is that of an ID3v2 RIFF chunk. Both
// lowercase "id3 " and uppercase "ID3 " are accepted.
func IsID3(id [4]byte) bool {
return strings.ToLower(string(id[:3])) == "id3"
}
// Parse reads every RIFF sub-chunk from r, in order, starting at the
// reader's current position. It rejects RF64 files and non-WAVE
// containers with descriptive errors. The parser is lenient: it
// tolerates a missing final padding byte and ignores the declared
// RIFF size.
func Parse(r io.Reader) ([]Chunk, error) {
if err := readContainer(r); err != nil {
return nil, err
}
var chunks []Chunk
for {
id, size, err := nextHeader(r)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
// Copied rather than allocated up front, as ID3Chunk does: the
// size is four bytes off the file, so a truncated one is free to
// declare a chunk larger than the whole of itself.
var data bytes.Buffer
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
}
chunks = append(chunks, Chunk{ID: id, Data: data.Bytes()})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if size%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// ID3Chunk returns the payload of the ID3v2 chunk of the RIFF/WAVE
// container at the reader's current position, seeking over every other
// chunk rather than reading it. It returns ErrNoID3Chunk when the
// container carries no such chunk, and leaves the read position
// unspecified either way.
func ID3Chunk(r io.ReadSeeker) ([]byte, error) {
if err := readContainer(r); err != nil {
return nil, err
}
for {
id, size, err := nextHeader(r)
if errors.Is(err, io.EOF) {
return nil, ErrNoID3Chunk
}
if err != nil {
return nil, err
}
if !IsID3(id) {
// Odd-length chunks carry a padding byte. Seeking past
// the end of the file is not an error; the next header
// read is what reports the end.
if _, err := r.Seek(int64(size)+int64(size%2), io.SeekCurrent); err != nil {
return nil, fmt.Errorf("skip chunk %q: %w", id, err)
}
continue
}
// Copied rather than allocated up front: a truncated file is
// free to declare a chunk larger than the whole of itself.
var data bytes.Buffer
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
}
return data.Bytes(), nil
}
}
// readContainer consumes the 12-byte RIFF/WAVE header at the reader's
// current position.
func readContainer(r io.Reader) error {
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return ErrRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return fmt.Errorf("%w: got %q", ErrNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return fmt.Errorf("read RIFF size: %w", err)
}
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return fmt.Errorf("%w: got %q", ErrNotWAVE, form)
}
return nil
}
// nextHeader reads one sub-chunk header. It returns io.EOF once the
// chunks are exhausted, including for a header cut short.
func nextHeader(r io.Reader) ([4]byte, uint32, error) {
var id [4]byte
_, err := io.ReadFull(r, id[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return id, 0, io.EOF
}
if err != nil {
return id, 0, fmt.Errorf("read chunk ID: %w", err)
}
var size uint32
if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
return id, 0, fmt.Errorf("read chunk size for %q: %w", id, err)
}
return id, size, nil
}
-254
View File
@@ -1,254 +0,0 @@
package riff_test
import (
"bytes"
"encoding/binary"
"errors"
"runtime"
"testing"
"yellowjacket/backend/riff"
)
// chunk is one sub-chunk to put in a test container.
type chunk struct {
id string
data []byte
}
// buildRIFF assembles a container from magic, form type and chunks,
// padding odd-length chunks the way a writer must.
func buildRIFF(magic, form string, chunks []chunk) []byte {
var body bytes.Buffer
body.WriteString(form)
for _, c := range chunks {
body.WriteString(c.id)
_ = binary.Write(&body, binary.LittleEndian, uint32(len(c.data)))
body.Write(c.data)
if len(c.data)%2 != 0 {
body.WriteByte(0)
}
}
var out bytes.Buffer
out.WriteString(magic)
_ = binary.Write(&out, binary.LittleEndian, uint32(body.Len()))
out.Write(body.Bytes())
return out.Bytes()
}
func TestID3Chunk_FindsTheTagPastTheAudio(t *testing.T) {
t.Parallel()
tests := []struct {
name string
chunks []chunk
want string
}{
{
name: "after an odd-length chunk",
chunks: []chunk{
{id: "fmt ", data: make([]byte, 16)},
{id: "LIST", data: []byte("INFOodd")},
{id: "data", data: make([]byte, 200)},
{id: "id3 ", data: []byte("ID3vTAG")},
},
want: "ID3vTAG",
},
{
// The chunk ID is written both ways in the wild, and the
// writer accepts either, so the reader must too.
name: "uppercase ID3",
chunks: []chunk{
{id: "data", data: make([]byte, 8)},
{id: "ID3 ", data: []byte("upper")},
},
want: "upper",
},
{
name: "first chunk",
chunks: []chunk{
{id: "id3 ", data: []byte("first")},
{id: "data", data: make([]byte, 8)},
},
want: "first",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
r := bytes.NewReader(buildRIFF("RIFF", "WAVE", tc.chunks))
got, err := riff.ID3Chunk(r)
if err != nil {
t.Fatalf("ID3Chunk: %v", err)
}
if string(got) != tc.want {
t.Errorf("chunk data: got %q, want %q", got, tc.want)
}
})
}
}
func TestID3Chunk_RejectsWhatItCannotRead(t *testing.T) {
t.Parallel()
tests := []struct {
name string
bytes []byte
want error
}{
{
name: "no ID3 chunk",
bytes: buildRIFF("RIFF", "WAVE", []chunk{{id: "data", data: []byte{1, 2}}}),
want: riff.ErrNoID3Chunk,
},
{
name: "no chunks at all",
bytes: buildRIFF("RIFF", "WAVE", nil),
want: riff.ErrNoID3Chunk,
},
{
name: "not RIFF",
bytes: []byte("ID3\x03\x00\x00\x00\x00\x00\x00\x00\x00"),
want: riff.ErrNotRIFF,
},
{
name: "not WAVE",
bytes: buildRIFF("RIFF", "AVI ", []chunk{{id: "id3 ", data: []byte("x")}}),
want: riff.ErrNotWAVE,
},
{
name: "RF64",
bytes: buildRIFF("RF64", "WAVE", []chunk{{id: "id3 ", data: []byte("x")}}),
want: riff.ErrRF64NotSupported,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := riff.ID3Chunk(bytes.NewReader(tc.bytes))
if !errors.Is(err, tc.want) {
t.Errorf("ID3Chunk error: got %v, want %v", err, tc.want)
}
})
}
}
// A file cut short mid-chunk is a file with no tag, not a reason to
// allocate the size it claims: the declared size is four bytes any
// truncation can leave saying 4 GB.
func TestID3Chunk_ToleratesATruncatedFile(t *testing.T) {
t.Parallel()
full := buildRIFF("RIFF", "WAVE", []chunk{
{id: "data", data: make([]byte, 64)},
{id: "id3 ", data: []byte("tag")},
})
t.Run("cut inside the audio", func(t *testing.T) {
t.Parallel()
_, err := riff.ID3Chunk(bytes.NewReader(full[:32]))
if !errors.Is(err, riff.ErrNoID3Chunk) {
t.Errorf("ID3Chunk error: got %v, want %v", err, riff.ErrNoID3Chunk)
}
})
t.Run("cut inside the tag", func(t *testing.T) {
t.Parallel()
if _, err := riff.ID3Chunk(bytes.NewReader(full[:len(full)-2])); err == nil {
t.Error("ID3Chunk: got nil error for a truncated tag chunk")
}
})
}
// Parse is the writer's half and reads every chunk into memory, which
// is what preserving them needs.
func TestParse_ReadsEveryChunkInOrder(t *testing.T) {
t.Parallel()
raw := buildRIFF("RIFF", "WAVE", []chunk{
{id: "fmt ", data: make([]byte, 16)},
{id: "LIST", data: []byte("INFOodd")},
{id: "id3 ", data: []byte("tag")},
})
chunks, err := riff.Parse(bytes.NewReader(raw))
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []string{"fmt ", "LIST", "id3 "}
if len(chunks) != len(want) {
t.Fatalf("chunk count: got %d, want %d", len(chunks), len(want))
}
for i, id := range want {
if got := string(chunks[i].ID[:]); got != id {
t.Errorf("chunk %d: got %q, want %q", i, got, id)
}
}
if !riff.IsID3(chunks[2].ID) || string(chunks[2].Data) != "tag" {
t.Errorf("id3 chunk: got %q", chunks[2].Data)
}
// The padding byte after an odd chunk is not part of its data.
if string(chunks[1].Data) != "INFOodd" {
t.Errorf("odd chunk data: got %q, want %q", chunks[1].Data, "INFOodd")
}
}
// A chunk size is four bytes read off the file, so a truncated or
// malformed WAV is free to declare a chunk larger than the whole of
// itself. Parse must grow with what arrives rather than with what was
// claimed.
//
// This measures the allocation instead of the error because the error
// is the same either way: a build sizing its buffer from the header
// reports the truncation correctly, having asked the allocator for a
// gigabyte on the way. Deliberately not parallel — TotalAlloc is
// process-wide, and a test paused beside another one is measuring it
// too.
func TestParse_DoesNotAllocateWhatAChunkClaims(t *testing.T) {
// Large enough that a header-sized buffer is unmistakable, in a
// container of a few dozen bytes.
const declared = 1 << 30
var raw bytes.Buffer
raw.WriteString("RIFF")
_ = binary.Write(&raw, binary.LittleEndian, uint32(declared+12))
raw.WriteString("WAVE")
raw.WriteString("data")
_ = binary.Write(&raw, binary.LittleEndian, uint32(declared))
raw.WriteString("and then the file ends")
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
if _, err := riff.Parse(bytes.NewReader(raw.Bytes())); err == nil {
t.Fatal("Parse: got nil error for a chunk larger than the file holding it")
}
runtime.ReadMemStats(&after)
if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 {
t.Errorf("Parse allocated %d bytes reading a %d-byte file whose chunk header claimed %d",
grew, raw.Len(), declared)
}
}
+1 -10
View File
@@ -24,19 +24,10 @@ func DefaultBindings() map[string]string {
"player.repeat": "R",
"player.mute": "M",
// 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.
// Navigation (Global scope)
"nav.search": "/",
"nav.searchAlt": "Ctrl+F",
"nav.queue": "Q",
"nav.back": "Alt+Left",
"nav.forward": "Alt+Right",
// App actions
"app.selectAll": "Ctrl+A",
-69
View File
@@ -52,75 +52,6 @@ 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)
-113
View File
@@ -87,116 +87,3 @@ 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)
}
})
}
-56
View File
@@ -1,56 +0,0 @@
// Package tagtotals derives the totals a tag's "5/12" form declares.
//
// It exists because the two writers that know a release's full
// tracklist -- the autotag apply pass and the download importer --
// must not import each other or the tag writer, and because getting
// the denominator wrong is invisible: a total that is too large marks
// a complete album incomplete forever, and nothing fails.
package tagtotals
// Position is one track's place in a release. A zero Disc means the
// release did not say, which is disc 1.
type Position struct {
Disc int
Track int
}
// For returns the totals to write on a file sitting on disc `disc`:
// how many tracks that disc has, and how many discs the release has.
//
// The track total is **per disc** and not the release's track count,
// because that is what the tag form means and what
// GetAlbumCompleteness sums -- summing a release total once per disc
// would multiply a two-disc album's expectation by two.
//
// Tracks are counted by distinct position rather than by row: a
// tracklist that lists a position twice is a defect in the source, and
// counting it twice would put an album permanently out of reach of its
// own total.
func For(all []Position, disc int) (tracks, discs int) {
disc = normaliseDisc(disc)
seenTracks := make(map[int]struct{}, len(all))
seenDiscs := make(map[int]struct{}, 1)
for _, p := range all {
d := normaliseDisc(p.Disc)
seenDiscs[d] = struct{}{}
if d != disc || p.Track <= 0 {
continue
}
seenTracks[p.Track] = struct{}{}
}
return len(seenTracks), len(seenDiscs)
}
// normaliseDisc treats an undeclared disc as disc 1.
func normaliseDisc(d int) int {
if d <= 0 {
return 1
}
return d
}
-92
View File
@@ -1,92 +0,0 @@
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)
}
})
}
}
+1 -10
View File
@@ -183,15 +183,6 @@ 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
@@ -216,7 +207,7 @@ func syncDatabase(
AlbumID: albumID,
TrackNumber: trackNum,
DiscNumber: discNum,
TotalTracks: totalTracks,
TotalTracks: old.TotalTracks,
Year: year,
Composer: composer,
Comment: old.Comment,
-5
View File
@@ -101,11 +101,6 @@ 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},
}
+11 -63
View File
@@ -6,7 +6,6 @@ import (
"log/slog"
"os"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
@@ -67,10 +66,17 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
tag.SetYear(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 := 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))
}
if v, ok := changes[FieldComposer].(string); ok {
tag.DeleteFrames("TCOM")
@@ -84,64 +90,6 @@ func applyTextChanges(tag *id3v2.Tag, changes TagChanges) {
}
}
// applyPositionFrame writes an ID3v2 position frame (TRCK or TPOS) in
// the "n/N" form the readers parse.
//
// The number and the total are separate diff entries and either may be
// absent, so the frame's *existing* value is the base: writing a total
// alone must not discard the number that is already there, and writing
// a number alone must not discard a total the file already declared.
// A total with no number at all is not written, since "/12" says
// nothing a reader can use.
func applyPositionFrame(
tag *id3v2.Tag, description string, changes TagChanges, numKey, totalKey string,
) {
_, hasNum := changes[numKey]
_, hasTotal := changes[totalKey]
if !hasNum && !hasTotal {
return
}
frameID := tag.CommonID(description)
num, total := parseXofN(
strings.TrimRight(tag.GetTextFrame(frameID).Text, "\x00 \t\n\r"),
)
if v, ok := asInt(changes[numKey]); ok {
num = v
}
if v, ok := asInt(changes[totalKey]); ok {
total = v
}
if num <= 0 {
return
}
value := strconv.Itoa(num)
if total > 0 {
value += "/" + strconv.Itoa(total)
}
tag.DeleteFrames(frameID)
tag.AddTextFrame(frameID, id3v2.EncodingUTF8, value)
}
// parseXofN splits an ID3v2 "n/N" position value. A bare "n" yields a
// zero total, and anything unparseable yields zeros — the same reading
// dhowden/tag gives the frame.
func parseXofN(s string) (int, int) {
numText, totalText, _ := strings.Cut(s, "/")
num, _ := strconv.Atoi(strings.TrimSpace(numText))
total, _ := strconv.Atoi(strings.TrimSpace(totalText))
return num, total
}
// applyCoverArtChanges handles the FieldCoverArt entry in the diff map.
//
// - []byte with len > 0: embed the given image as front cover.
-2
View File
@@ -166,8 +166,6 @@ var oggFieldMappings = []struct { //nolint:gochecknoglobals // field mapping tab
{FieldYear, "DATE", true},
{FieldTrackNumber, "TRACKNUMBER", true},
{FieldDiscNumber, "DISCNUMBER", true},
{FieldTotalTracks, "TRACKTOTAL", true},
{FieldTotalDiscs, "DISCTOTAL", true},
{FieldComposer, "COMPOSER", false},
}
-28
View File
@@ -333,31 +333,3 @@ func TestWriteTrackTags_DBSync(t *testing.T) {
t.Error("expected FTS5 result for 'New Title'")
}
}
// The row is what the album page reads, and it is only refreshed by a
// scan. Leaving total_tracks at whatever the last scan saw means an
// album autotagged just now stays "unknown" -- a plain tick on an album
// the user holds two tracks of -- until a full rescan happens to run.
func TestWriteTrackTags_PersistsTheTotal(t *testing.T) {
db := database.NewTestDB(t)
dir := t.TempDir()
trackID := seedTestTrack(t, db, createPipelineTestMP3(t, dir))
tw := NewTagWriter(testLogger(), db, &mockPlayer{}, &mockPipelineLocker{})
if err := tw.WriteTrackTags(trackID, TagChanges{
FieldTrackNumber: 2,
FieldTotalTracks: 10,
}); err != nil {
t.Fatalf("WriteTrackTags: %v", err)
}
af, err := db.Queries.GetAudioFile(context.Background(), trackID)
if err != nil {
t.Fatalf("get audio file: %v", err)
}
if !af.TotalTracks.Valid || af.TotalTracks.Int64 != 10 {
t.Errorf("total_tracks: got %v, want 10", af.TotalTracks)
}
}
-9
View File
@@ -26,15 +26,6 @@ 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.
-200
View File
@@ -1,200 +0,0 @@
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 was the exception until #104 -- dhowden/tag has no RIFF reader,
// so metadata.ExtractTags could not see a WAV's ID3 chunk and this
// case read the chunk itself, which is a test of the writer wearing
// the shape of a round trip. All four go through the scanner now.
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: viaScanner,
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)
})
}
+105 -11
View File
@@ -8,22 +8,116 @@ import (
"io"
"log/slog"
"os"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/fileutil"
"yellowjacket/backend/riff"
)
// errFileTooLargeForWAV is the one RIFF error that belongs to the
// writer; reading rejects a container in backend/riff.
var errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
// Sentinel errors for WAV RIFF operations.
var (
errRF64NotSupported = errors.New("RF64 files are not yet supported")
errNotRIFF = errors.New("not a RIFF file")
errNotWAVE = errors.New("not a WAVE file")
errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")
)
// riffChunk holds a single RIFF sub-chunk (ID + raw data).
type riffChunk struct {
id [4]byte
data []byte
}
// parseRIFF reads all RIFF sub-chunks from r. It rejects RF64 files
// and non-WAVE containers with descriptive errors. The parser is
// lenient on read: it tolerates missing padding bytes and ignores
// the declared RIFF size.
func parseRIFF(r io.ReadSeeker) ([]riffChunk, error) {
// Read 4-byte container magic.
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return nil, fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return nil, errRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return nil, fmt.Errorf("%w: got %q", errNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return nil, fmt.Errorf("read RIFF size: %w", err)
}
// Read 4-byte form type.
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return nil, fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return nil, fmt.Errorf("%w: got %q", errNotWAVE, form)
}
// Read sub-chunks until EOF.
var chunks []riffChunk
for {
var chunkID [4]byte
_, err := io.ReadFull(r, chunkID[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read chunk ID: %w", err)
}
var chunkSize uint32
if err := binary.Read(r, binary.LittleEndian, &chunkSize); err != nil {
return nil, fmt.Errorf("read chunk size for %q: %w", chunkID, err)
}
data := make([]byte, chunkSize)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", chunkID, err)
}
chunks = append(chunks, riffChunk{id: chunkID, data: data})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if chunkSize%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// isID3ChunkID returns true if id represents an ID3v2 RIFF chunk.
// Both lowercase "id3 " and uppercase "ID3 " are accepted.
func isID3ChunkID(id [4]byte) bool {
s := strings.ToLower(string(id[:3]))
return s == "id3"
}
// writeRIFF writes a complete RIFF/WAVE container to w, preserving
// the given chunks in order and appending the id3Data as the final
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would
// exceed the 4 GB RIFF limit.
func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
// Calculate total RIFF payload size:
// 4 bytes (WAVE form type)
// + for each preserved chunk: 8 (header) + len(data) + padding
@@ -31,7 +125,7 @@ func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
riffPayload := uint64(4)
for _, c := range chunks {
sz := uint64(len(c.Data))
sz := uint64(len(c.data))
riffPayload += 8 + sz
if sz%2 != 0 {
@@ -68,7 +162,7 @@ func writeRIFF(w io.Writer, chunks []riff.Chunk, id3Data []byte) error {
// Write each preserved chunk.
for _, c := range chunks {
if err := writeChunk(w, c.ID, c.Data); err != nil {
if err := writeChunk(w, c.id, c.data); err != nil {
return err
}
}
@@ -131,7 +225,7 @@ func writeWavTags(
return fmt.Errorf("open wav for reading: %w", err)
}
allChunks, err := riff.Parse(f)
allChunks, err := parseRIFF(f)
// Close immediately — we need the handle released before
// AtomicWrite creates the replacement file.
@@ -143,13 +237,13 @@ func writeWavTags(
// Separate preserved chunks from existing ID3 data.
var (
preserved []riff.Chunk
preserved []riffChunk
existingID3 []byte
)
for _, c := range allChunks {
if riff.IsID3(c.ID) {
existingID3 = c.Data
if isID3ChunkID(c.id) {
existingID3 = c.data
} else {
preserved = append(preserved, c)
}
+21 -108
View File
@@ -12,7 +12,6 @@ import (
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/metadata"
"yellowjacket/backend/riff"
)
// createTestWAV builds a minimal valid WAV file with an optional
@@ -271,88 +270,6 @@ func TestWriteWavTags_PartialUpdate(t *testing.T) {
assertStrField(t, "Composer", meta.Composer, "Original Composer")
}
// The writer has always been correct and the reader could not see it:
// a WAV tagged by this app scanned as an untagged file, so editing
// tags, autotagging a folder or importing a WAV download all appeared
// to work and changed nothing the library could show (#104). So this
// asserts the write through metadata.ExtractTags -- the reader the
// scan uses -- rather than through the id3 chunk.
func TestWriteWavTags_ReadBackByTheScanner(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := createTestWAV(t, dir, "scanner.wav", nil)
art := tinyJPEG(t)
changes := TagChanges{
FieldTitle: "Some Song",
FieldArtist: "Some Artist",
FieldAlbum: "Some Album",
FieldAlbumArtist: "Some Album Artist",
FieldGenre: "Rock",
FieldYear: 2024,
FieldTrackNumber: 3,
FieldComposer: "Some Composer",
FieldCoverArt: art,
}
if err := writeWavTags(testLogger(), path, changes); err != nil {
t.Fatalf("writeWavTags: %v", err)
}
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if meta.TagReadWarning != nil {
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
}
assertStrField(t, "Title", meta.Title, "Some Song")
assertStrField(t, "Artist", meta.Artist, "Some Artist")
assertStrField(t, "Album", meta.Album, "Some Album")
assertStrField(t, "AlbumArtist", meta.AlbumArtist, "Some Album Artist")
assertStrField(t, "Genre", meta.Genre, "Rock")
assertStrField(t, "Composer", meta.Composer, "Some Composer")
assertStrField(t, "FileFormat", meta.FileFormat, "WAV")
assertIntField(t, "Year", meta.Year, 2024)
assertIntField(t, "TrackNumber", meta.TrackNumber, 3)
if !strings.HasPrefix(meta.TagFormat, "ID3v2") {
t.Errorf("TagFormat: got %q, want an ID3v2 version", meta.TagFormat)
}
if meta.Picture == nil {
t.Fatal("expected cover art, got nil")
}
if !bytes.Equal(meta.Picture.Data, art) {
t.Errorf("picture data mismatch: got %d bytes, want %d",
len(meta.Picture.Data), len(art))
}
}
// An untagged WAV is a file with no tags, not a file with a problem:
// the scanner falls back to the filename and must not be handed a
// warning to surface about it.
func TestUntaggedWav_ReadsAsEmptyWithoutAWarning(t *testing.T) {
t.Parallel()
path := createTestWAV(t, t.TempDir(), "bare.wav", nil)
meta, err := metadata.ExtractTags(path)
if err != nil {
t.Fatalf("ExtractTags: %v", err)
}
if meta.TagReadWarning != nil {
t.Errorf("TagReadWarning: %v", meta.TagReadWarning)
}
assertStrField(t, "Title", meta.Title, "")
}
func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Parallel()
@@ -365,7 +282,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open original: %v", err)
}
origChunks, err := riff.Parse(origFile)
origChunks, err := parseRIFF(origFile)
_ = origFile.Close()
if err != nil {
@@ -375,7 +292,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// Record original chunk data by ID string.
origData := map[string][]byte{}
for _, c := range origChunks {
origData[string(c.ID[:])] = c.Data
origData[string(c.id[:])] = c.data
}
// Write a tag to trigger RIFF rewrite.
@@ -392,7 +309,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
t.Fatalf("open after write: %v", err)
}
newChunks, err := riff.Parse(newFile)
newChunks, err := parseRIFF(newFile)
_ = newFile.Close()
if err != nil {
@@ -403,7 +320,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
origNonID3 := 0
for _, c := range origChunks {
if !riff.IsID3(c.ID) {
if !isID3ChunkID(c.id) {
origNonID3++
}
}
@@ -411,7 +328,7 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
newNonID3 := 0
for _, c := range newChunks {
if !riff.IsID3(c.ID) {
if !isID3ChunkID(c.id) {
newNonID3++
}
}
@@ -442,17 +359,17 @@ func TestWriteWavTags_ChunkPreservation(t *testing.T) {
// in chunks and its data matches want byte-for-byte.
func checkChunkPreserved(
t *testing.T,
chunks []riff.Chunk,
chunks []riffChunk,
idStr string,
want []byte,
) {
t.Helper()
for _, c := range chunks {
if string(c.ID[:]) == idStr {
if !bytes.Equal(c.Data, want) {
if string(c.id[:]) == idStr {
if !bytes.Equal(c.data, want) {
t.Errorf("chunk %q data changed: got %d bytes, want %d",
idStr, len(c.Data), len(want))
idStr, len(c.data), len(want))
}
return
@@ -513,7 +430,7 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
buf.WriteString("WAVE")
// Minimal ds64 chunk (required for RF64 but we just need
// enough bytes for riff.Parse to hit the RF64 rejection).
// enough bytes for parseRIFF to hit the RF64 rejection).
buf.WriteString("ds64")
_ = binary.Write(&buf, binary.LittleEndian, uint32(28)) //nolint:mnd
buf.Write(make([]byte, 28)) //nolint:mnd
@@ -537,12 +454,9 @@ func TestWriteWavTags_RejectsRF64(t *testing.T) {
// readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing
// the RIFF structure and reading the id3 chunk with bogem/id3v2.
//
// metadata.ExtractTags reads a WAV since #104 and is what the round
// trips assert through. This stays for the two cases that are about
// the bytes rather than about the scan: a tag with every frame
// cleared, which no reader reports as anything, and the chunk
// preservation test, which is already parsing the container itself.
// dhowden/tag's ReadFrom does not support WAV files, and its
// ReadID3v2Tags fails on empty tags (after clearing all frames).
// Using bogem/id3v2.ParseReader handles all cases correctly.
func readWavID3Tags(
t *testing.T,
path string,
@@ -556,17 +470,17 @@ func readWavID3Tags(
defer func() { _ = f.Close() }()
chunks, err := riff.Parse(f)
chunks, err := parseRIFF(f)
if err != nil {
t.Fatalf("riff.Parse: %v", err)
t.Fatalf("parseRIFF: %v", err)
}
// Find the id3 chunk.
var id3Data []byte
for _, c := range chunks {
if riff.IsID3(c.ID) {
id3Data = c.Data
if isID3ChunkID(c.id) {
id3Data = c.data
break
}
@@ -608,20 +522,19 @@ func readWavID3Tags(
}
}
// 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.
// Track number (TRCK).
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, meta.TotalTracks = parseXofN(tf.Text)
meta.TrackNumber = atoiSafe(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, meta.TotalDiscs = parseXofN(tf.Text)
meta.DiscNumber = atoiSafe(tf.Text)
}
}
+55 -25
View File
@@ -4,28 +4,18 @@ includes:
common: ../Taskfile.yml
vars:
# APP_ID is an *assertion*, not a setting, and it has no default.
# 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.
#
# 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 ""}}'
# 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"}}'
MIN_SDK: '21'
TARGET_SDK: '35'
# The emulator runs the host architecture; physical devices are arm64
@@ -382,7 +372,9 @@ tasks:
ARCH: '{{.ARCH | default .HOST_ARCH}}'
cmds:
- task: ensure-emulator
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- '"{{.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'
run:
summary: Build, install and launch a debug build in the Android Emulator
@@ -391,7 +383,9 @@ tasks:
- task: build
cmds:
- task: assemble:apk
- './scripts/android-deploy.sh --apk "{{.BIN_DIR}}/{{.APP_NAME}}.apk" --target emulator{{if .APP_ID}} --expect "{{.APP_ID}}"{{end}}'
- '"{{.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'
device:list:
summary: Lists connected Android devices and emulators (serials)
@@ -406,7 +400,25 @@ tasks:
ARCH: arm64
cmds:
- task: assemble:apk
- './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}}'
- |
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
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
@@ -418,7 +430,25 @@ tasks:
vars:
ARCH: arm64
cmds:
- './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}}'
- |
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
preconditions:
- sh: '[ -x "{{.ADB}}" ] || command -v adb'
msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
@@ -891,41 +891,13 @@ 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,24 +129,7 @@ public class WailsBridge {
}
/**
* 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.
* Initialize the native Go library
*/
public void initialize() {
if (initialized) {
+6 -16
View File
@@ -7,22 +7,12 @@ 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, 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.
**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.
## The 1.x installs cannot be upgraded to 0.0.x
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

+24 -58
View File
@@ -1,12 +1,6 @@
import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* How far the scroll test scrolls. One constant, because the guard and
* the assertion have to agree about it they did not, which is #133.
*/
const SCROLL_TARGET = 80;
/**
* Plan 007 phase 5: expanding an album shows its tracks.
*
@@ -110,30 +104,20 @@ test.describe('the album dropdown', () => {
await app.setViewportSize({ width: 900, height: 600 });
try {
// The container has to be a scroller at all, which is the thing
// the defect behind this spec broke and is a property rather
// than a moment.
await expect
.poll(() => scrollRange(app))
.toMatchObject({ overflowY: 'auto' });
await expect.poll(() => scrollRange(app)).toMatchObject({
scrollable: true,
overflowY: 'auto',
});
// **Scrolling it and reading it back are one round trip** (#151).
//
// #133 made the guard ask for the range this needs rather than
// for "scrollable at all", which was necessary and is not
// sufficient: a guard and the write it guards are separate
// `evaluate` calls, so the grid can satisfy the range and settle
// out of it before the write lands. It still does — observed as
// `Expected 80, Received 10` in the second of three consecutive
// full-suite runs, with the spec green alone on the same app
// straight afterwards.
//
// Polling harder cannot close a window between two moments; only
// removing the window can. So the probe sets `scrollTop` and
// returns what it reads back, in one page-side call, and the
// poll retries *that* — which also means the assertion is about
// what the grid did rather than about what it was ready to do.
await expect.poll(() => scrollTo(app, SCROLL_TARGET)).toBe(SCROLL_TARGET);
await app.evaluate(() => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
if (sc) sc.scrollTop = 80;
});
expect(await scrollTop(app)).toBe(80);
// And the dropdown it opens is on screen, wherever the manager
// decides that leaves the scroll. It is *not* "the position is
@@ -264,45 +248,27 @@ async function closeDropdown(app: Page): Promise<void> {
});
}
/** Whether the grid is a scroller at all, which is what the bug broke. */
/** Whether the grid can scroll at all, which decides if a probe can move. */
async function scrollRange(app: Page) {
return app.evaluate((target) => {
return app.evaluate(() => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
return {
// Reported for the failure message rather than waited on: `room`
// was the guard #133 strengthened, and #151 is that a guard in
// its own round trip cannot speak for the write in the next one.
// `scrollTo` below is the assertion now; this says *why* it did
// not reach the target when it does not.
room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
scrollable: !!sc && sc.scrollHeight > sc.clientHeight + 40,
overflowY: sc ? getComputedStyle(sc).overflowY : '',
};
}, SCROLL_TARGET);
});
}
/**
* Scroll the grid and report where it actually landed, in one call.
*
* The whole point is that the set and the read share a moment: a
* `scrollTop` write is clamped to the range *at the instant it lands*,
* so reading it back in a second round trip asks a container that may
* have re-laid out in between.
*/
async function scrollTo(app: Page, target: number): Promise<number> {
return app.evaluate((to) => {
const sc = document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container');
if (!sc) return -1;
sc.scrollTop = to;
return sc.scrollTop;
}, target);
async function scrollTop(app: Page): Promise<number> {
return app.evaluate(
() =>
document
.querySelector('cover-grid')
?.shadowRoot?.querySelector('.grid-scroll-container')?.scrollTop ?? -1,
);
}
/** Whether the open dropdown is inside the scroll container's viewport. */
-244
View File
@@ -15,49 +15,12 @@ import { test, expect } from '../support/fixtures.js';
*
* What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold.
*
* **And `data-active-view` is not the behaviour.** Every assertion here
* used to be that attribute, which the shell sets on every path
* including `_isBack` so this file was green throughout #72, in
* which both navs highlighted the view the user had just *left*. The
* shell's own bookkeeping was the one thing that was already right;
* what a person sees is `aria-current`, and that is asserted below as
* well. This is the same trap `layout-overflow.spec.ts` set for #69: a
* spec named for the behaviour, measuring the plumbing.
*/
type Page = import('@playwright/test').Page;
const activeView = (page: Page) =>
page.getByTestId('main-content');
/** A common phone, where the bottom bar is the primary navigation. */
const PHONE = { width: 390, height: 844 };
/**
* The nav item for a destination, in whichever navigation is on screen.
*
* Both navs carry a button named `Albums`, and only one of them is ever
* in the accessibility tree the other is `display: none` so the
* role query resolves to the one the user can see at this viewport.
* That is the point: the highlight has to be right in both, and #72 was
* two different-looking symptoms of one cause.
*/
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/**
* `aria-current="page"` is the accessible fact and the assertion worth
* making; `.active` is a class and could be restyled without breaking
* anything real.
*/
async function expectHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'page');
}
async function expectNotHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'false');
}
/**
* Open an artist's detail view, which is the deepest ordinary route.
*
@@ -79,114 +42,6 @@ async function openAnArtist(app: Page): Promise<void> {
);
}
/**
* The global back/forward control (#6).
*
* It is desktop chrome hidden below 900px, where the sidebar has
* already given up its labels so these set a desktop viewport
* explicitly rather than trusting the runner's default.
*/
const DESKTOP = { width: 1280, height: 800 };
const backButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Back' });
const forwardButton = (page: Page) =>
page.locator('nav-history').getByRole('button', { name: 'Forward' });
test.describe('global back and forward', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DESKTOP);
});
test('offers nothing at launch, in either direction', async ({ app }) => {
// The launch entry is *replaced*, not pushed, so there is nothing
// of ours behind it — and a Back button that is live at the root
// is a press that does nothing on desktop and, on Android, the
// press that should have exited the app (#142). This assertion is
// what pins that: it failed before the launch navigation stopped
// recording two entries.
await expect(backButton(app)).toBeDisabled();
await expect(forwardButton(app)).toBeDisabled();
});
test('walks the history in both directions, and says which are available', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeDisabled();
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
// Standing in the middle of the list: both directions live, which
// is the state a single depth counter cannot express.
await expect(backButton(app)).toBeEnabled();
await expect(forwardButton(app)).toBeEnabled();
await forwardButton(app).click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await expect(forwardButton(app)).toBeDisabled();
});
test('reaches the detail view a tab click left behind', async ({ app }) => {
// The report, exactly: the album is one entry away the whole time,
// and before this control the only way back to it was a button
// that had gone off screen with the view it belonged to.
await app.getByTestId('nav-artists').click();
await openAnArtist(app);
await app.getByTestId('nav-tracks').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks');
await backButton(app).click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-artist-details',
);
});
test('drops the forward list when the user navigates from the middle', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await app.getByTestId('nav-tracks').click();
await backButton(app).click();
await expect(forwardButton(app)).toBeEnabled();
// A browser truncates here, and so does this: what was ahead is no
// longer reachable, and a Forward button still offering it would
// be pointing at an entry that has been overwritten.
await app.getByTestId('nav-genres').click();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'genres');
await expect(forwardButton(app)).toBeDisabled();
await expect(backButton(app)).toBeEnabled();
});
test('is absent below the desktop band, where nothing needs it', async ({
app,
}) => {
// Alt+Left/Right survive at every width, the detail views keep
// their own back buttons and the phone has the platform's gesture
// — so this is a control standing down, not an action becoming
// unreachable. It is hidden at 899 because the top bar is what
// runs out of room first below 900 (#143).
await app.setViewportSize({ width: 899, height: 600 });
await expect(app.locator('nav-history')).toBeHidden();
await app.setViewportSize({ width: 390, height: 844 });
await expect(app.locator('nav-history')).toBeHidden();
});
});
test.describe('the back gesture', () => {
test('leaves a detail view for the view it was opened from', async ({
app,
@@ -216,105 +71,6 @@ test.describe('the back gesture', () => {
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
test('leaves the nav highlighting the view it landed on, not the one it left', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expectHighlighted(app, 'Albums');
await app.getByTestId('nav-tracks').click();
await expectHighlighted(app, 'Tracks');
await app.goBack();
// #72, and the half of it the report did not describe: this is
// desktop, and before the shell published the active view *both*
// navs stayed on Tracks. An absent highlight reads as a glitch; a
// confident wrong one is worse, and any back across two primary
// views produced it.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
await expectNotHighlighted(app, 'Tracks');
});
test('keeps the parent destination lit while a detail view is open', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expectHighlighted(app, 'Artists');
await openAnArtist(app);
// A detail view is not a destination in either nav, and the user is
// still inside Artists. `app-sidebar` did this by accident -- it
// guarded on its own item list, so an unmatched name left the
// highlight alone -- and that accident is why the sidebar looked
// right on a detail view while the tab bar lit nothing. This test
// therefore passed before the fix and is here to keep the rule from
// being lost while the others are made to pass; the *tab bar's*
// half of it is the phone test below, which did not.
await expectHighlighted(app, 'Artists');
await app.goBack();
await expectHighlighted(app, 'Artists');
});
test('the tab bar survives the same journey on a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
// The reported shape: Albums, open an album, press back. The tab
// bar had a highlight, then no highlight at all, and never got it
// back — `bottom-nav` took the detail view's name, matched it
// against no tab, and lit nothing.
await navItem(app, 'Albums').click();
await expectHighlighted(app, 'Albums');
await app.locator('cover-grid').getByText('Glass Harbour').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expectHighlighted(app, 'Albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
});
test('the drawer sidebar opens on the page you are standing on', async ({
app,
}) => {
await app.setViewportSize(PHONE);
await navItem(app, 'Tracks').click();
await expectHighlighted(app, 'Tracks');
// A third symptom of the same cause, found while measuring #72 and
// not in the report: `bottom-nav` mounts its `<app-sidebar>` when
// the drawer opens, so that copy had heard no `navigate` at all and
// showed its own default — Home, from any page in the app. An event
// has no answer for a listener that was not there; a store does.
await navItem(app, 'More').click();
// The element carrying the testid is the `wa-drawer` host, which
// always reports hidden -- what is visible is the `<dialog>` in its
// shadow root -- so the drawer being open is asserted of the
// sidebar it holds rather than of itself.
const drawer = app.getByTestId('nav-drawer');
await expect(drawer.locator('app-sidebar')).toBeVisible();
await expect(drawer.getByTestId('nav-tracks')).toHaveAttribute(
'aria-current',
'page',
);
await expect(drawer.getByTestId('nav-home')).toHaveAttribute(
'aria-current',
'false',
);
});
test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
-147
View File
@@ -1,147 +0,0 @@
import { test, expect, callBinding, NO_QUEUE_SOURCE } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
* The bottom bar's two promises (#23, #42): the transport is centred in
* the window, and the volume is a slider rather than a popup.
*
* **"Centred" is measured against the window, not against the space
* left over**, which is the whole of #23. The bar was
* `320px 1fr auto`, so the transport sat in the middle of what the
* metadata and the queue button did not use its centre was ~140px
* right of the window's at every size, which reads as an alignment
* mistake rather than as a layout choice.
*
* The mechanism is that the outer two columns are the same width, so
* this asserts the *outcome* (centre lines up) rather than the CSS. A
* spec that checked `grid-template-columns` would pass on any build
* that kept the declaration and broke the result.
*/
/** Where the transport sits, against where the window's centre is. */
const geometry = (app: Page) =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('.bottom-bar')!;
const player = document.querySelector<HTMLElement>('audio-player')!;
const b = bar.getBoundingClientRect();
const p = player.getBoundingClientRect();
const seek = player.shadowRoot
?.querySelector('seek-bar')
?.shadowRoot?.querySelector('wa-slider');
return {
offset: Math.round(p.left + p.width / 2 - (b.left + b.width / 2)),
barHeight: Math.round(b.height),
seekWidth: seek ? Math.round(seek.getBoundingClientRect().width) : -1,
};
});
/** Something has to be playing before the transport draws a seek bar. */
async function play(app: Page): Promise<void> {
const paths = await app.evaluate(async () => {
const tracks = (await window.__yjEvents.call(
'library.Library.GetTracks',
[0],
10_000,
)) as { FilePath: string }[];
return tracks.slice(0, 3).map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [
paths,
0,
false,
NO_QUEUE_SOURCE,
]);
await callBinding(app, 'queue.Queue.Play');
await expect(app.getByTestId('now-playing-title')).not.toBeEmpty();
}
test.describe('the bottom bar', () => {
test.afterEach(async ({ app }) => {
await callBinding(app, 'queue.Queue.Clear').catch(() => {
/* already empty */
});
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* Four widths, because a centring bug is a function of width: the old
* layout was off by half the difference between the two outer
* columns, so it was wrong by a different amount at each one and
* exactly right at none.
*/
for (const width of [800, 900, 1100, 1440]) {
test(`centres the transport in the window at ${width}px`, async ({
app,
}) => {
await app.setViewportSize({ width, height: 700 });
await play(app);
await expect.poll(() => geometry(app).then((g) => g.offset)).toBe(0);
});
}
/**
* The seek bar is what the centring is *paid for* with, so it is
* asserted rather than assumed.
*
* Reserving the metadata's full width on both sides centres the
* transport perfectly and squeezes the control you drag: measured
* during this work at **61px of track at 800px**, against 257 before
* the change. The side columns are capped at a quarter of the bar for
* that reason, and this is the number that says so 246 at 800px,
* which is parity with the uncentred layout.
*/
test('does not pay for the centring with the seek bar', async ({ app }) => {
await app.setViewportSize({ width: 800, height: 700 });
await play(app);
await expect
.poll(() => geometry(app).then((g) => g.seekWidth))
.toBeGreaterThan(200);
});
/**
* #42: the slider is simply there. Three gestures click open, drag,
* click closed is what a bottom bar has room not to ask for.
*/
test('shows the volume slider without a click', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
const volume = app.locator('.bottom-bar volume-control');
await expect(volume).toBeVisible();
await expect(volume.locator('wa-slider')).toBeVisible();
});
/**
* And the inline icon is the mute toggle, because with the slider
* beside it there is nothing left to disclose. The name follows the
* action rather than the state for the same reason.
*/
test('names the inline icon after what it does', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await expect(
app.locator('.bottom-bar volume-control').getByRole('button', {
name: 'Mute',
}),
).toBeVisible();
});
/**
* The bar is a fixed 4em row and the transport sits in it. A slider
* with a label grows `#slider` by 8px unless `wa-slider-label.css`
* suppresses it, which moved the whole bar the last time so the
* height is pinned here rather than left to a screenshot.
*/
test('stays 4em tall', async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
await play(app);
await expect.poll(() => geometry(app).then((g) => g.barHeight)).toBe(64);
});
});
+9 -4
View File
@@ -29,13 +29,18 @@ test.describe('a control says what it controls', () => {
});
test('the volume slider is announced as Volume', async ({ app }) => {
// No disclosure to open first, and no state to put back afterwards:
// #42 made the slider inline, so it is simply there. The assertion
// is unchanged — the *name* is the subject here, and the route to
// the control got shorter rather than different.
// The popup renders no slider at all while closed, the same way the
// queue panel renders no list — so this has to open it first.
await app.getByRole('button', { name: /volume/i }).click();
await expect(
app.getByRole('slider', { name: 'Volume' }),
).toBeVisible();
// Leave the transport as it was found: the specs share one page in
// file order, and an open popup covers the buttons beneath it.
await app.keyboard.press('Escape');
await app.locator('body').click({ position: { x: 5, y: 5 } });
});
test('naming the slider did not move the transport', async ({ app }) => {
+1 -8
View File
@@ -36,16 +36,9 @@ test.describe('a failed binding says so', () => {
// Libraries is the one section that starts expanded (H-22), so ask
// the disclosure what state it is in rather than assuming one — a
// blind click used to expand it and now collapses it.
//
// By role and name, not by `.header`: since #27 the section also
// contains a `job-panel`, and an open `job-details-drawer` inside
// it carries the same class. That only bites once a job exists,
// which is why it showed up on the *second* engine of a CI run and
// not the first.
const disclosure = page
.locator('config-section[heading="Libraries"]')
.getByRole('button', { name: 'Libraries' })
.first();
.locator('.header');
if ((await disclosure.getAttribute('aria-expanded')) === 'false') {
await disclosure.click();
-318
View File
@@ -1,318 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* #69: the Playlists header's buttons could not be reached.
*
* Three text buttons Import (91px), New Playlist (122px), New Smart
* Playlist (162px), 390px in total inside a header that gets 700px at
* 900×600. "New Smart Playlist" rendered **114 of its 162px**, and at
* phone width the Android report was the plain version of it: you
* cannot scroll to reach them, and scrolling is not how page controls
* should be exposed anyway.
*
* **`layout-overflow.spec.ts` passes on the broken build**, which is why
* this file exists rather than a case being added there. That spec
* asserts the *shell* needs no sideways scrolling; clipping *inside* a
* component is invisible to it. So the measurement here is per-button
* and per-header, against the widths the app promises.
*
* Plan 018's size matrix is the promise being kept: **no action is ever
* unreachable at any supported size.** These are its three bands.
*/
const VIEWPORTS = [
// Desktop's worst case, and not the enforced minimum: the sidebar
// collapses to icons *below* 900, so the content area is 843px at 899
// and 700px at 900. Testing "the minimum" and stopping misses it.
{ name: '900×600 (widest sidebar, narrowest content)', width: 900, height: 600 },
{ name: '800×600 (the enforced minimum)', width: 800, height: 600 },
{ name: '390×780 (phone)', width: 390, height: 780 },
// WCAG 1.4.10's reflow target, which plan 018 promises the app fits.
{ name: '320×600 (400% zoom)', width: 320, height: 600 },
];
/** Every action the Playlists header can offer, in declared order. */
const ACTIONS = ['Import', 'New Playlist', 'New Smart Playlist'];
/**
* What the header is actually rendering, measured rather than inferred.
*
* A shadow query is the wrong tool for *asserting* that is what
* `getByRole` below is for but it is the right one for a measurement,
* because the number this issue is about (a button 48px wider than the
* box holding it) is not in the accessibility tree at all.
*/
const headerFit = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const root = document
.querySelector('[data-testid="main-content"] playlist-view')
?.shadowRoot?.querySelector('page-header')?.shadowRoot;
if (!root) return null;
const header = root.querySelector<HTMLElement>('.page-header')!;
const box = header.getBoundingClientRect();
const title = root.querySelector<HTMLElement>('h1')!;
const clipped = [
...root.querySelectorAll<HTMLElement>('.action, .more-button'),
]
.filter((b) => !b.hidden)
.filter((b) => {
const r = b.getBoundingClientRect();
return r.right > box.right + 1 || r.left < box.left - 1;
})
.map((b) => b.dataset['actionId'] ?? 'more');
return {
overflow: header.scrollWidth - header.clientWidth,
clipped,
titleTruncated: title.scrollWidth > title.clientWidth + 1,
buttons: [...root.querySelectorAll<HTMLElement>('.action')]
.filter((b) => !b.hidden)
.map((b) => b.textContent?.trim() ?? ''),
menu: [
...root.querySelectorAll('#page-header-overflow wa-dropdown-item'),
].map((i) => i.textContent?.trim() ?? ''),
};
});
test.describe('the page header never clips an action', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
});
for (const vp of VIEWPORTS) {
test(`every action is reachable at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled: the fit is decided by a ResizeObserver, so it settles a
// frame after the resize rather than with it.
await expect
.poll(async () => (await headerFit(app))?.clipped)
.toEqual([]);
const fit = (await headerFit(app))!;
expect(fit.overflow).toBeLessThanOrEqual(0);
// Between them, buttons and menu account for all three. This is
// the assertion the issue asks for: not "it fits" but "nothing
// was dropped to make it fit".
expect([...fit.buttons, ...fit.menu].sort()).toEqual([...ACTIONS].sort());
});
}
/**
* The title gives way before an action does.
*
* Once the heading can ellipsis it absorbs the pressure, and
* `scrollWidth` then reports a header that fits perfectly while the
* heading reads "Playlis…" this issue's own failure mode moved from
* the button to the title, and invisible to exactly the measurement
* that missed it the first time. At the desktop sizes there is always
* an action to collapse instead.
*/
test('does not truncate the heading to keep a button', async ({ app }) => {
for (const vp of VIEWPORTS.slice(0, 2)) {
await app.setViewportSize({ width: vp.width, height: vp.height });
await expect
.poll(async () => (await headerFit(app))?.titleTruncated)
.toBe(false);
}
});
/**
* Asserted through the accessibility tree, never a shadow query. An
* overflow menu is exactly the shape that grows a nameless control,
* and this repo has shipped one four times most recently the
* queue's own close button.
*/
test('the overflow is a named control that opens a named menu', async ({
app,
}) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await expect(more).toHaveAttribute('aria-expanded', 'false');
await more.click();
await expect(more).toHaveAttribute('aria-expanded', 'true');
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// Collapsed at 900×600: Import (lowest priority) and New Smart
// Playlist. New Playlist stays a button because it is the drop
// target, and a closed menu cannot be one.
await expect(
menu.getByRole('menuitem', { name: 'Import' }),
).toBeVisible();
await expect(
app.getByRole('button', { name: 'New Playlist', exact: true }),
).toBeVisible();
});
/**
* The phone case is the original report. Every action is in the menu
* at 390px, and the menu is reachable by name which is the whole of
* "these need to be reachable in a sensible way".
*/
test('offers every action from the menu on a phone', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
const more = app.getByRole('button', { name: 'More actions' });
await expect(more).toBeVisible();
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
for (const label of ACTIONS) {
await expect(menu.getByRole('menuitem', { name: label })).toBeVisible();
}
});
/**
* Escape closes it and focus goes back to the trigger `MenuKeyboard`
* is shared with every other menu in the app precisely so this is not
* a second keyboard model, and this is what proves it was wired up
* rather than merely imported.
*/
test('takes the keyboard, and gives it back', async ({ app }) => {
await app.setViewportSize({ width: 900, height: 600 });
const more = app.getByRole('button', { name: 'More actions' });
await more.click();
const menu = app.getByRole('menu', { name: 'More actions' });
await expect(menu).toBeVisible();
// The first item takes focus on open. `wa-dropdown-item` sets its
// own role in its own first update, so this is polled rather than
// read: a query at the host's updateComplete finds nothing, which
// reads exactly like a menu that refused to take focus.
await expect
.poll(async () =>
app.evaluate(() => {
// Stops where `MenuKeyboard`'s own `deepActiveElement` stops:
// on the *host* whose shadow root has no active element.
// Descending unconditionally lands inside the focused
// `wa-dropdown-item`'s own shadow root, where nothing is
// focused — which reads exactly like a menu that refused the
// keyboard, on a build where it did not.
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el?.textContent?.trim() ?? null;
}),
)
.toBe('Import');
await app.keyboard.press('Escape');
await expect(more).toHaveAttribute('aria-expanded', 'false');
await expect(more).toBeFocused();
});
/**
* New Playlist is a drop target, and declaring it as data must not
* take that away which is why a `PageAction` carries the drop
* handlers rather than the header owning a notion of dropping.
*
* Nothing covered this before, in either tier, and it is the one
* behaviour the migration could plausibly have destroyed silently:
* dragging still *looks* fine against a button that no longer
* accepts anything.
*/
test('New Playlist still accepts a dropped track', async ({ app }) => {
await app.setViewportSize({ width: 1280, height: 800 });
const button = app.getByRole('button', {
name: 'New Playlist',
exact: true,
});
await expect(button).toBeVisible();
const result = await app.evaluate(async () => {
const view = document.querySelector(
'[data-testid="main-content"] playlist-view',
)!;
const target = view.shadowRoot!
.querySelector('page-header')!
.shadowRoot!.querySelector('[data-testid="page-action-new-playlist"]')!;
const data = new DataTransfer();
data.setData(
'application/x-yj-tracks',
JSON.stringify({ filePaths: ['/tmp/dropped.mp3'] }),
);
const fire = (type: string) =>
target.dispatchEvent(
new DragEvent(type, {
bubbles: true,
cancelable: true,
dataTransfer: data,
}),
);
fire('dragover');
await new Promise((r) => setTimeout(r, 50));
// The affordance is the host's state reaching the header's
// button, which is the half a plain handler call would not prove.
const highlighted = target.classList.contains('drag-over');
fire('drop');
await new Promise((r) => setTimeout(r, 200));
return {
highlighted,
opened: view.shadowRoot!.querySelector('.create-form') !== null,
};
});
expect(result).toEqual({ highlighted: true, opened: true });
// Leave the view as it was found.
await app.keyboard.press('Escape');
});
/**
* An action given back when the window widens again. The collapsed
* set is a function of the current width and not of how it got there
* a rule that only ever *added* to it would never widen.
*/
test('gives the buttons back when the window grows', async ({ app }) => {
await app.setViewportSize({ width: 390, height: 780 });
await expect.poll(async () => (await headerFit(app))?.buttons).toEqual([]);
await app.setViewportSize({ width: 1440, height: 900 });
await expect
.poll(async () => (await headerFit(app))?.buttons)
.toEqual(ACTIONS);
await expect.poll(async () => (await headerFit(app))?.menu).toEqual([]);
});
});
-124
View File
@@ -1,124 +0,0 @@
import { test, expect, waitForEvent } from '../support/fixtures.js';
/**
* The Jobs tab folded into the places the work is started (#27).
*
* The assertion worth making is not that the tab is gone that is one
* line of a table but that **nothing became unreachable when it
* went**. Scanning is the case that mattered: the per-library controls
* lived only on that page, and the tab's own comment says they had been
* moved there out of Settings in the first place.
*
* `#24` wrote down one sentence covering all three size bands: *no
* action is ever unreachable at any supported size*. Deleting a
* destination is exactly the change that can quietly break it.
*/
type Page = import('@playwright/test').Page;
const section = (page: Page, heading: string) =>
page.locator(`config-page config-section[heading="${heading}"]`);
async function openSettings(page: Page, heading: string): Promise<void> {
await page.getByTestId('nav-settings').click();
// The section's own disclosure, by role rather than by `.header`:
// an open Libraries section also contains `job-details-drawer`,
// whose own header matches that class and makes it ambiguous.
const header = section(page, heading)
.getByRole('button', { name: heading })
.first();
await expect(header).toBeVisible();
if ((await header.getAttribute('aria-expanded')) === 'false') {
await header.click();
}
await expect(header).toHaveAttribute('aria-expanded', 'true');
}
test.describe('background jobs live where the work is started', () => {
test('the Jobs destination is gone', async ({ app }) => {
await expect(app.getByTestId('nav-jobs')).toHaveCount(0);
// And it is not offered as a launch page either, which is the copy
// of the destination list that is easiest to forget.
await openSettings(app, 'General');
const options = await section(app, 'General')
.locator('select')
.first()
.locator('option')
.allTextContents();
expect(options).not.toContain('Jobs');
});
/**
* Scanning is startable from Settings Libraries, and the job that
* results is visible there with its controls. One assertion covers
* both halves, because a Scan All that started nothing would leave
* the panel empty and read exactly like a panel that does not work.
*/
test('a scan is started and watched in Settings', async ({ app }) => {
await openSettings(app, 'Libraries');
const libraries = section(app, 'Libraries');
await libraries.getByRole('button', { name: 'Scan All' }).click();
await waitForEvent(app, 'LibraryScanComplete', { timeoutMs: 60_000 });
const panel = libraries.locator('job-panel');
await expect(panel.locator('job-row')).toHaveCount(1, { timeout: 10_000 });
// The generic affordances are the point of the panel: the tier
// list and the progress rings the other surfaces already had
// cannot open a log.
await expect(
panel.getByRole('button', { name: /^Details/ }),
).toBeVisible();
});
/** A finished job dismisses from where it is shown. */
test('a finished scan can be dismissed in place', async ({ app }) => {
await openSettings(app, 'Libraries');
const panel = section(app, 'Libraries').locator('job-panel');
const dismiss = panel.getByRole('button', { name: /^Dismiss/ }).first();
await expect(dismiss).toBeVisible({ timeout: 10_000 });
await dismiss.click();
await expect(panel.locator('job-row')).toHaveCount(0);
});
/**
* Full rescan is destructive and asks first. It is asserted at the
* dialog rather than through it running one against the seeded app
* would delete the library the rest of the suite reads.
*/
test('Full Rescan asks before it does anything', async ({ app }) => {
await openSettings(app, 'Libraries');
await section(app, 'Libraries')
.getByRole('button', { name: 'Full Rescan' })
.click();
const dialog = app.getByRole('dialog', { name: 'Full rescan' });
await expect(dialog).toBeVisible();
// The message is read off the *host*, not the dialog: a wa-dialog
// keeps its slotted content in the host's shadow root, so
// `toContainText` on the dialog itself sees only Web Awesome's
// chrome.
await expect(app.locator('confirm-dialog')).toContainText(
'deletes all library data',
);
await app.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).toBeHidden();
});
});
-204
View File
@@ -1,204 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* #62. On a phone, background work is shown in the notification band
* and the header indicator stands down.
*
* The report was that the indicator's popover "is obscured by other UI,
* so it cannot be read while jobs run". Worth saying plainly: **that
* symptom did not reproduce in this tier.** Measured at the device's
* own 424x439 viewport, the popover was neither clipped nor covered
* `elementFromPoint` at its centre returned the indicator at every
* width tried. So this is not a fix for a stacking bug, and a spec
* asserting one would be a spec asserting something that was never
* true here.
*
* What is true regardless, and is what these assert:
*
* - a popover is a **disclosure**, and it is anchored to a bar 3.25em
* tall on a screen 439px tall. Background work is the one thing a
* phone should not make you open something to see.
* - #57 deletes that bar and is *blocked on this issue*, because the
* indicator needs somewhere else to live first. Somewhere else is
* the band, and the test that matters for #57 is that the bar no
* longer holds the indicator at all.
*
* This is the media-query tier by necessity: a query inside a shadow
* root is answered by the viewport, and `notification-host` decides
* whether the panel *exists* from `matchMedia`. The component tier
* cannot set either.
*/
type Page = import('@playwright/test').Page;
const JOBS = [
{
id: 'phone:scan',
kind: 'library-scan',
state: 'running',
title: 'Scanning Music',
current: 40,
total: 100,
caps: { pausable: true, cancellable: true },
},
{
id: 'phone:idx',
kind: 'index-build',
state: 'running',
title: 'Building the search index',
current: 2,
total: 9,
caps: { pausable: true, cancellable: true },
},
];
/** The panel the band renders. Playwright's CSS engine pierces open
* shadow roots, which is what keeps this one line. */
const bandPanel = (page: Page) => page.locator('job-band').locator('job-panel');
const PHONE = { width: 424, height: 439 };
const DESKTOP = { width: 1100, height: 800 };
test.describe('background jobs on a phone', () => {
test('are shown in the band, without opening anything', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
// Both jobs, drawn by real `job-row`s -- asking the rows what they
// hold rather than reading the panel's text, which would pass
// whether or not a row rendered. Playwright's CSS engine pierces
// open shadow roots, which is what makes this one line;
// `querySelectorAll` does not, and stops at `job-panel`.
await expect(bandPanel(app).locator('job-row')).toHaveCount(2);
await expect(
bandPanel(app).locator('job-row').first(),
).toContainText('Scanning Music');
});
/**
* The #57 assertion. Not "the indicator is invisible" that could be
* true because the bar overflowed but that the shell's own rule
* puts it away at this width.
*/
test('leave the top bar, which is what #57 is waiting for', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
await expect(app.locator('job-indicator')).toBeHidden();
});
/**
* The property the first attempt at this got wrong, so it is the one
* worth pinning: the band is **in the layout**, not over it.
*
* A fixed band reads fine in a screenshot and is unusable -- at
* 424x439 a compact panel is ~200px of a 439px screen and it covers
* what is under it. Four specs failed on that version, two
* phone-shell journeys and the header's action menu, because the
* panel was intercepting the taps. So: nothing of the app is
* underneath it, and the main panel starts below it.
*/
test('push the content down rather than covering it', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
const before = await app
.getByTestId('main-content')
.evaluate((el) => el.getBoundingClientRect().top);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const after = await app.evaluate(() => {
const band = document.querySelector('job-band') as HTMLElement;
const main = document.querySelector(
'[data-testid="main-content"]',
) as HTMLElement;
const b = band.getBoundingClientRect();
const m = main.getBoundingClientRect();
// What the browser reports at the band's own centre. If this is
// anything but the band, the band is sitting on top of it.
const hit = document.elementFromPoint(
Math.round(b.x + b.width / 2),
Math.round(b.y + b.height / 2),
);
return {
mainTop: m.top,
bandBottom: b.bottom,
withinViewport: b.bottom <= window.innerHeight + 0.5,
hit: hit?.tagName.toLowerCase() ?? null,
};
});
expect({
pushed: after.mainTop > before,
mainClearsBand: after.mainTop >= after.bandBottom - 0.5,
withinViewport: after.withinViewport,
hit: after.hit,
}).toEqual({
pushed: true,
mainClearsBand: true,
withinViewport: true,
hit: 'job-band',
});
});
/**
* A running job repaints several times a second. The stack it sits
* beside is `role="status" aria-live="polite"`, and a progress bar
* inside a live region is a screen reader reading a number out over
* and over so the two are siblings in the band rather than one
* list, and this is what says so.
*/
test('are not inside the live region they sit beside', async ({
app,
testctl,
}) => {
await app.setViewportSize(PHONE);
await testctl.emit('JobsChanged', JOBS);
await expect(bandPanel(app)).toBeVisible();
const insideLiveRegion = await app.evaluate(() => {
const band = document.querySelector('job-band');
// Neither the band itself nor anything it is nested in may be a
// live region -- `closest` answers both at once.
return !!band?.closest('[aria-live]') || band?.hasAttribute('aria-live');
});
expect(insideLiveRegion).toBe(false);
});
/**
* `bottom-nav` rendering its duplicate `<app-sidebar>` unconditionally
* broke 30 specs with "resolved to 2 elements" on a viewport where it
* was not even visible. Settings already holds four `job-panel`s, so
* a fifth that answers for *every* kind is the same trap which is
* why the band decides from `matchMedia` whether the element exists
* rather than hiding it with CSS.
*/
test('do not leave a second panel behind on a desktop', async ({
app,
testctl,
}) => {
await app.setViewportSize(DESKTOP);
await testctl.emit('JobsChanged', JOBS);
await expect(app.locator('job-indicator')).toBeVisible();
await expect(bandPanel(app)).toHaveCount(0);
});
});
+24 -67
View File
@@ -26,21 +26,7 @@ const MIN_VIEWPORT = { width: 800, height: 600 };
const VIEWPORTS = [
{ name: '1440×900', width: 1440, height: 900 },
{ name: '1024×768', width: 1024, height: 768 },
// Not the minimum, and that is the point (#24). The sidebar collapses
// to icons *below* 900, so the main panel is 843px at 899 and 700px
// at 900 — the narrowest content area any desktop width produces is
// here, not at the enforced floor. A list that stopped at the minimum
// was missing its own worst case.
{ name: '900×600 (the widest sidebar, so the narrowest content)', width: 900, height: 600 },
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
// Below the enforced minimum on purpose, and for the reason 700×480
// is below it further down: a scaled display or a large system font
// lands the layout here without the window ever being dragged there,
// and 600 is the last width before the phone layout takes over. The
// *narrowest header* is a different question from the narrowest
// content area and has a different answer — this one (#143), where
// the bar was 611px inside 600 sitting still.
{ name: '600×600 (the bottom of the Compact band)', width: 600, height: 600 },
];
/**
@@ -101,10 +87,9 @@ test.describe('the app fits in its own window', () => {
)
.toBe(true);
// Settings is the one that was unreachable: it is last in the nav,
// and the pane used to clip rather than scroll. (Jobs was the other
// half of this until #27 folded it into Settings.)
for (const view of ['explore', 'settings'] as const) {
// Settings and Jobs are the two that were unreachable: they are
// last in the nav, and the pane used to clip rather than scroll.
for (const view of ['jobs', 'settings'] as const) {
const item = app.getByTestId(`nav-${view}`);
await item.scrollIntoViewIfNeeded();
@@ -132,45 +117,27 @@ test.describe('the app fits in its own window', () => {
// be dragged here, but a scaled display or a large system font can
// still land the layout in it, and clipping the nav with no scroll
// is the failure that made Settings unreachable.
//
// The scroll and the measurement share one `evaluate` — #151's
// rule, which this already had — and the whole probe is polled,
// which it did not: a viewport change settles asynchronously, so a
// single attempt reads whatever the sidebar happened to be doing.
// The probe is safe to repeat because scrolling to the bottom twice
// is scrolling to the bottom.
await expect
.poll(() =>
app.locator('app-sidebar').evaluate((el) => {
const settings = el.shadowRoot?.querySelector<HTMLElement>(
'[data-testid="nav-settings"]',
);
const reachable = await app.locator('app-sidebar').evaluate((el) => {
const settings = el.shadowRoot?.querySelector<HTMLElement>(
'[data-testid="nav-settings"]',
);
if (!settings) return null;
if (!settings) return null;
el.scrollTop = el.scrollHeight;
el.scrollTop = el.scrollHeight;
const item = settings.getBoundingClientRect();
const pane = el.getBoundingClientRect();
const item = settings.getBoundingClientRect();
const pane = el.getBoundingClientRect();
return (
item.bottom <= Math.ceil(pane.bottom) &&
item.top >= Math.floor(pane.top)
);
}),
)
.toBe(true);
return item.bottom <= Math.ceil(pane.bottom) && item.top >= Math.floor(pane.top);
});
expect(reachable).toBe(true);
});
});
test.describe('the title block fits its bar', () => {
test('the hgroup stays inside the 4em top bar', async ({ app }) => {
// Stated rather than inherited from whatever ran last. Since #143
// the wordmark is visually hidden at widths where the bar cannot
// afford it, so a test about its *vertical* fit has to say which
// width it is asking about.
await app.setViewportSize({ width: 1440, height: 900 });
// The state a11y.29 landed in. The pair is flex-centred and a UA
// gives an `h1` a 0.67em top margin, so the block measured 67px
// inside 64 — pre-existing, and invisible until dropping the h3's
@@ -272,26 +239,16 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
test(`no scrollbar appears at ${vp.name}`, async ({ app }) => {
await app.setViewportSize({ width: vp.width, height: vp.height });
// Polled, for the reason the track-row test above is: since #143
// the top bar's fit is *measured* — a ResizeObserver decides what
// it can afford at this width — so a single read taken straight
// after the resize races the observer and reports the frame
// before it. Read once, this passed alone and failed in the full
// suite, which is the shape of a timing assumption rather than of
// a defect.
//
// The other half of the assertion: at every size this app
// promises, the fix costs nothing. A scrollbar that is always
// there is a worse answer than the clipping it replaced.
await expect
.poll(() =>
app.evaluate(() => {
const de = document.documentElement;
const excess = await app.evaluate(() => {
const de = document.documentElement;
return de.scrollWidth - de.clientWidth;
}),
)
.toBe(0);
return de.scrollWidth - de.clientWidth;
});
// The other half: at every size this app promises, the fix costs
// nothing. A scrollbar that is always there is a worse answer
// than the clipping it replaced.
expect(excess).toBe(0);
});
}
});
+127
View File
@@ -0,0 +1,127 @@
import { test, expect } from '../support/fixtures.js';
/**
* Long-press is the touch route to a context menu (plan 016 B2 phase 3).
*
* The component tier proves the gesture in isolation, against markup it
* built itself. What it cannot prove is the half that made this one
* listener instead of six: that the synthetic event reaches the handler
* a *real* component bound `track-list` delegates its `contextmenu`
* on the `lit-virtualizer` rather than binding one per row and that
* the real `wa-popup` menu opens from it, which is a path with its own
* history of opening and then refusing to work (see
* `menu-keyboard.spec.ts`).
*
* The pointer events are dispatched rather than performed: this project
* runs Desktop Chrome and Desktop Safari, neither of which has touch,
* and a device tier does not exist. So this is honest about what it
* checks the app's own listeners, on the app's own DOM, from the
* events a touch would produce and not about a real finger.
*/
/** A common small phone, as in `phone-shell.spec.ts`. */
const PHONE = { width: 390, height: 844 };
/** Comfortably past the module's 500ms hold. */
const HELD = 900;
type Page = import('@playwright/test').Page;
/** The track list's menu panel, or null while it is not rendered. */
const panel = (page: Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/**
* Press the first track row, optionally dragging partway through the
* shape of a scroll that begins on a row, which must not open a menu.
*/
async function pressFirstRow(
page: Page,
opts: { driftY?: number } = {},
): Promise<void> {
await page.evaluate((drift) => {
// `.track-row`, not `[role="row"]`: the column header is a row too,
// and it is the *first* one -- a press on it is correctly ignored,
// which reads exactly like the gesture not working.
const row = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.track-row');
if (!row) throw new Error('no track row to press');
const box = row.getBoundingClientRect();
const x = Math.round(box.left + box.width / 2);
const y = Math.round(box.top + box.height / 2);
const send = (type: string, dy = 0) =>
row.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerType: 'touch',
isPrimary: true,
clientX: x,
clientY: y + dy,
}),
);
send('pointerdown');
if (drift) send('pointermove', drift);
}, opts.driftY ?? 0);
}
test.describe('long-press opens the track menu', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test.afterEach(async ({ app }) => {
// Every other spec file runs against a desktop, and the viewport
// belongs to the shared context rather than to this file.
await app.setViewportSize({ width: 1440, height: 900 });
});
test('reaches the delegated handler and opens the real menu', async ({
app,
}) => {
await expect.poll(() => panel(app)).toBeNull();
await pressFirstRow(app);
await expect
.poll(() => panel(app), { timeout: HELD + 2000 })
.toMatchObject({ role: 'menu', label: 'Track actions' });
// The same panel Shift+F10 opens, items and all -- not an empty
// popup that happened to become visible.
expect((await panel(app))?.items).toBeGreaterThan(0);
});
test('does not open one for a press that turns into a scroll', async ({
app,
}) => {
await pressFirstRow(app, { driftY: 40 });
await app.waitForTimeout(HELD);
expect(await panel(app)).toBeNull();
});
});
-140
View File
@@ -1,140 +0,0 @@
import { test, expect } from '../support/fixtures.js';
/**
* The web view's own tap highlight, and what replaced it (#54).
*
* Two halves, and each is here because no other tier can see it.
*
* **The highlight is killed by one declaration on `html`**, which
* reaches the app's shadow roots because `-webkit-tap-highlight-color`
* is inherited and inheritance crosses a shadow boundary. That is a
* property of `index.css`, and `index.css` is loaded by the real app
* and by nothing else the component tier mounts a component with no
* page stylesheet at all, which is the same reason the theme's ramps
* are invisible to it.
*
* **The press state is measured rather than read.** The component tier
* asserts the shape of the stylesheet (which rule is inside which
* query, and that the press selector carries a state class), because
* `:active` cannot be forced there. Here there is a real pointer: hold
* the button down on a real row of the real list and read what the row
* became. That is the assertion that would fail if the rule were
* hoisted, renamed, or lost to `.selected`.
*
* What neither half is, is the device. Chrome 113's WebView is where
* the grey box was reported and where a finger is; the numbers from it
* are on the PR.
*/
type Page = import('@playwright/test').Page;
/** The phone this work was measured against, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
/** The computed tap-highlight colour of a node inside a shadow root. */
const tapHighlight = (page: Page, host: string, inner: string) =>
page.evaluate(
([hostSel, innerSel]) => {
const el = document
.querySelector(hostSel!)
?.shadowRoot?.querySelector(innerSel!);
if (!el) return null;
return getComputedStyle(el).getPropertyValue(
'-webkit-tap-highlight-color',
);
},
[host, inner],
);
test.describe('the tap highlight', () => {
test('is transparent inside a shadow root, from one rule on html', async ({
app,
browserName,
}) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
const row = await tapHighlight(app, 'track-list', '.track-row');
expect(row).not.toBeNull();
// The property is a WebKit extension that only iOS honours, so an
// engine is free not to report one at all. Chromium always does —
// measured at rgba(0, 0, 0, 0.18) with the rule removed, which is
// the grey box the report describes — so the assertion is not
// skippable there, and nothing this app can do makes the property
// disappear on an engine that has it.
test.skip(
row === '',
`${browserName} reports no -webkit-tap-highlight-color to read`,
);
expect(row).toBe('rgba(0, 0, 0, 0)');
});
});
test.describe('the press state that replaced it', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await expect(app.locator('track-list').first()).toBeVisible();
});
test.afterEach(async ({ app }) => {
await app.mouse.up();
await app.setViewportSize({ width: 1440, height: 900 });
});
test('shows on the row being pressed, and on that row only', async ({
app,
}) => {
const rows = await app.evaluate(() => {
const found = document
.querySelector('track-list')
?.shadowRoot?.querySelectorAll('.track-row');
if (!found || found.length < 2) return null;
const rect = found[1]!.getBoundingClientRect();
return {
x: Math.round(rect.x + rect.width / 2),
y: Math.round(rect.y + rect.height / 2),
};
});
expect(rows).not.toBeNull();
const backgrounds = () =>
app.evaluate(() => {
const found = document
.querySelector('track-list')!
.shadowRoot!.querySelectorAll('.track-row');
return {
pressed: getComputedStyle(found[1]!).backgroundColor,
neighbour: getComputedStyle(found[2]!).backgroundColor,
};
});
await app.mouse.move(rows!.x, rows!.y);
await app.mouse.down();
const held = await backgrounds();
// The press overlay, from the theme rather than from a literal in
// a component: rgba(255, 255, 255, 0.12) on both dark ramps.
expect(held.pressed).toBe('rgba(255, 255, 255, 0.12)');
expect(held.neighbour).not.toBe(held.pressed);
await app.mouse.up();
});
});
+1 -1
View File
@@ -28,7 +28,7 @@ const EXPECTED_MIN_ICONS = 5;
const VIEWS = [
'home', 'tracks', 'albums', 'artists', 'genres', 'playlists',
'explore', 'downloads', 'autotag', 'settings',
'explore', 'downloads', 'jobs', 'settings',
];
type IconState = { name: string; hasSvg: boolean };
+4 -6
View File
@@ -1,4 +1,4 @@
import { test, expect, navigateTo } from '../support/fixtures.js';
import { test, expect } from '../support/fixtures.js';
/**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page
@@ -21,6 +21,7 @@ const VIEWS: [string, string, boolean][] = [
['tracks', 'Tracks', true],
['explore', 'Explore', false],
['downloads', 'Downloads', false],
['jobs', 'Background jobs', false],
];
/** The header lives in the view's shadow root, inside its own. */
@@ -52,16 +53,13 @@ const TAGS: Record<string, string> = {
tracks: 'track-list',
explore: 'explore-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
};
test.describe('every primary view says what it is', () => {
test('each one has the shared header, with a heading', async ({ app }) => {
// By event rather than by nav item: a destination is not
// guaranteed to have one any more (#25 — Downloads is absent
// without a download client), and every one of these is still a
// primary view with a header, which is what this spec is about.
for (const [view, heading, hasCount] of VIEWS) {
await navigateTo(app, view);
await app.getByTestId(`nav-${view}`).click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
view,

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