Compare commits

..
1 Commits
Author SHA1 Message Date
logan 42e4448e60 test(database): the next destructive repair fails a test, not a volume
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
The fix for the dropped catalog pins one table in one wrong shape, which
is the failure that happened. What cost the rebuild was more general: a
destructive repair added at `database.NewDB` -- the chokepoint every
binary in this project shares -- without asking which binary it runs in.
The next one will have a different name and a different reason.

So `TestNoCacheTableIsRetiredHere` asserts the outcome instead: put every
`datamap` Cache table into a shape the schema has moved past, open the
database the way cmd/indexbuild does, and require all of them to still be
there. Driving it from `datamap.ByKind` is what makes it cover tables
nobody remembered -- flipping the policy back fails on five, including
the two artist-credit tables added the same day, where the existing test
fails on one. It asserts the rows survive too, because SQLite does an
implicit DELETE before a DROP and a repair that recreated the table would
look identical. And it accepts an error from `NewDB`, because that is the
documented trade: loud is recoverable, gone is not.

`scripts/index-cache-snapshot.sh` covers the half no test can reach. The
volume holds the only copy of a catalog that costs hours of someone
else's bandwidth to re-derive. `VACUUM INTO` rather than `cp`, since a
byte copy of a live SQLite file is a corrupt file of plausible size; the
resumable staging directory is skipped; and each snapshot is reopened and
asked for its catalog row count before anything is rotated out. A corrupt
source and an empty catalog were both exercised: each exits non-zero,
removes its own output, and leaves the previous snapshots alone.

docs/index-cache.md is the restore, and the reason to bother: a restored
snapshot resolves to `refresh` and folds in the listens since, which is
minutes against the 3-23h this rebuild has been estimating.
2026-08-17 13:54:35 -04:00
60 changed files with 829 additions and 3050 deletions
-47
View File
@@ -129,18 +129,6 @@ jobs:
fi fi
v="${v#v}" v="${v#v}"
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. It is skipped cleanly
# rather than failing the guard below, because a 45-minute red
# run against a tag that was never meant to ship is noise, and
# this is the most expensive of the four workflows a tag fires.
if [ "$v" = "0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
# Android orders releases by an integer and refuses anything # Android orders releases by an integer and refuses anything
# not greater than what is installed. 1.3.1 -> 10301, which # not greater than what is installed. 1.3.1 -> 10301, which
# increases as long as minor and patch stay below 100. # increases as long as minor and patch stay below 100.
@@ -155,24 +143,9 @@ jobs:
echo "version=$v" >> "$GITHUB_OUTPUT" echo "version=$v" >> "$GITHUB_OUTPUT"
echo "code=$code" >> "$GITHUB_OUTPUT" echo "code=$code" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "building $v (versionCode $code)" echo "building $v (versionCode $code)"
# Releases restarted at 0.0.1 when they became automatic (plan
# 017), so versionCode restarted at 1 — *below* the 10300 an
# installed 1.3.0 build carries. Android refuses a downgrade
# outright, and the only remedy is an uninstall, which takes the
# user's library with it. Said here because this is the file
# that computes the number.
if [ "$code" -lt 10600 ]; then
echo
echo "note: versionCode $code is below the 10600 that v1.6.0 shipped."
echo " An existing install must be removed before this one will"
echo " install, and that removal takes its library with it."
fi
- name: Go toolchain - name: Go toolchain
if: steps.version.outputs.skip == 'false'
run: | run: |
set -eu set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
@@ -183,7 +156,6 @@ jobs:
/cache/tool/go/bin/go version /cache/tool/go/bin/go version
- name: Node toolchain - name: Node toolchain
if: steps.version.outputs.skip == 'false'
run: | run: |
set -eu set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
@@ -195,7 +167,6 @@ jobs:
# but still spends minutes verifying, so the guards are what make # but still spends minutes verifying, so the guards are what make
# this cheap on every run after the first. # this cheap on every run after the first.
- name: Android SDK and NDK (cached) - name: Android SDK and NDK (cached)
if: steps.version.outputs.skip == 'false'
run: | run: |
set -eu set -eu
mkdir -p "$ANDROID_HOME/cmdline-tools" mkdir -p "$ANDROID_HOME/cmdline-tools"
@@ -255,7 +226,6 @@ jobs:
# that are *verbatim* a secret, so a trimmed one could print in # that are *verbatim* a secret, so a trimmed one could print in
# clear — or repeating the trimming logic in both. # clear — or repeating the trimming logic in both.
- name: Build the signed APK - name: Build the signed APK
if: steps.version.outputs.skip == 'false'
working-directory: /src working-directory: /src
env: env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
@@ -379,7 +349,6 @@ jobs:
- name: Verify the APK - name: Verify the APK
id: apk id: apk
if: steps.version.outputs.skip == 'false'
working-directory: /src working-directory: /src
run: | run: |
set -eu set -eu
@@ -421,7 +390,6 @@ jobs:
# file, so `latest` is deleted first. Credentials are the same # file, so `latest` is deleted first. Credentials are the same
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with. # OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
- name: Publish to the Gitea package registry - name: Publish to the Gitea package registry
if: steps.version.outputs.skip == 'false'
working-directory: /src working-directory: /src
env: env:
VERSION: ${{ steps.version.outputs.version }} VERSION: ${{ steps.version.outputs.version }}
@@ -457,18 +425,3 @@ jobs:
echo echo
echo "Obtainium URL:" echo "Obtainium URL:"
echo " $base/latest/yellowjacket.apk" echo " $base/latest/yellowjacket.apk"
# The generic registry is what Obtainium polls; the release page is
# what a person looks at. Same file, already built and already
# verified by the step above — so this cannot publish something the
# signature check would have refused.
- name: Attach the APK to the release
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \
"yellowjacket-${VERSION}-android-arm64.apk"
+2 -65
View File
@@ -1,23 +1,8 @@
name: Build & publish Arch package name: Build & publish Arch package
# Keyed on the tag, not on main. It used to publish on every push,
# deriving a version from `git describe` — so the registry accumulated a
# package per merge and none of them corresponded to anything a user
# could be told to install. release.yml decides what a release is now,
# and this builds the tag it cuts.
on: on:
push: push:
tags: ["v*"] branches: [main]
workflow_dispatch:
inputs:
version:
description: "Version to build (default: the latest v* tag)"
required: false
concurrency:
group: arch-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
arch-package: arch-package:
@@ -32,7 +17,6 @@ jobs:
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label. # Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
ARCH_REPO: stable ARCH_REPO: stable
steps: steps:
@@ -42,9 +26,8 @@ jobs:
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch. # gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
# These must match the PKGBUILD's depends=() — makepkg installs # These must match the PKGBUILD's depends=() — makepkg installs
# nothing itself, so a mismatch fails at link time, not at check time. # nothing itself, so a mismatch fails at link time, not at check time.
# jq is scripts/release-asset.sh's, not the build's.
pacman -Syu --noconfirm --needed \ pacman -Syu --noconfirm --needed \
base-devel git go nodejs pnpm curl sudo jq \ base-devel git go nodejs pnpm curl sudo \
webkitgtk-6.0 gtk4 alsa-lib webkitgtk-6.0 gtk4 alsa-lib
- name: Create unprivileged build user - name: Create unprivileged build user
@@ -53,43 +36,15 @@ jobs:
install -d -o builder -g builder /build install -d -o builder -g builder /build
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
# v0.0.0 is semantic-release's version floor, not a shipment — see
# the bootstrap step in release.yml. A clean skip rather than a
# failure: a red run against a tag that was never meant to ship is
# noise, and this is one of the four workflows that would otherwise
# fire on it.
- name: Resolve the version
id: version
run: |
set -eu
v="${{ inputs.version }}"
[ -n "$v" ] || v="$REF_NAME"
case "$v" in v*) ;; *) v="v$v" ;; esac
if [ "$v" = "v0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "building $v"
- name: Clone repo at the pushed commit - name: Clone repo at the pushed commit
if: steps.version.outputs.skip == 'false'
run: | run: |
# Token auth works for private repos and needs no SSH key in CI. # Token auth works for private repos and needs no SSH key in CI.
sudo -u builder git clone \ sudo -u builder git clone \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \ "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
/build/yellowjacket /build/yellowjacket
# A tag push carries the tag's own commit in $SHA, so this checks
# out exactly what was tagged. pkgver() then reads the tag from
# the clone's own git history.
sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA" sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA"
- name: Build package with makepkg - name: Build package with makepkg
if: steps.version.outputs.skip == 'false'
run: | run: |
cd /build/yellowjacket/packaging/arch cd /build/yellowjacket/packaging/arch
# Point the PKGBUILD at this local clone / exact commit; pkgver() then # Point the PKGBUILD at this local clone / exact commit; pkgver() then
@@ -99,7 +54,6 @@ jobs:
makepkg -f --noconfirm --cleanbuild makepkg -f --noconfirm --cleanbuild
- name: Publish to the Gitea Arch registry - name: Publish to the Gitea Arch registry
if: steps.version.outputs.skip == 'false'
run: | run: |
cd /build/yellowjacket/packaging/arch cd /build/yellowjacket/packaging/arch
# makepkg also produces a -debug package (detached symbols); end users # makepkg also produces a -debug package (detached symbols); end users
@@ -113,20 +67,3 @@ jobs:
--upload-file "$pkg" \ --upload-file "$pkg" \
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}" "${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
done done
# The pacman registry is for people who have added it to pacman.conf;
# the release page is for everyone else. Same file, and it is
# already built.
- name: Attach the package to the release
if: steps.version.outputs.skip == 'false'
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
cd /build/yellowjacket/packaging/arch
for pkg in yellowjacket-*.pkg.tar.zst; do
case "$pkg" in
yellowjacket-debug-*) continue ;;
esac
/build/yellowjacket/scripts/release-asset.sh "$TAG" "$(pwd)/$pkg"
done
+3 -17
View File
@@ -1,6 +1,6 @@
name: CI name: CI
# The other five workflows package, publish or release; none of them test # The other three workflows package and publish; none of them test
# anything, so a green tick on this repo used to mean "the Arch package # anything, so a green tick on this repo used to mean "the Arch package
# built", which is not the question anyone was asking. This is the # built", which is not the question anyone was asking. This is the
# workflow that gates. # workflow that gates.
@@ -9,23 +9,9 @@ name: CI
# before being written here, so every step below is a transcription of # before being written here, so every step below is a transcription of
# something observed working rather than something expected to. # something observed working rather than something expected to.
# **A branch push and its PR are the same commit, and testing it twice
# costs the only runner there is.** `branches: ['**']` here meant every
# PR booked four runs — `check` and `e2e` for the branch push, then both
# again for `refs/pull/N/head` — on a host with capacity 1, where the
# queue is shared with an index build that can hold it for three hours.
#
# `pull_request` covers feature branches, and `main` is kept because a
# post-merge run is the record of the trunk's health. Since main now
# refuses direct pushes, that run happens exactly once per merge.
#
# The trade is explicit: a branch pushed with **no** PR open gets no CI.
# That is consistent with the workflow this repo committed to — every
# change goes through a PR — and the signal returns the moment one is
# opened, on the same commit.
on: on:
push: push:
branches: [main] branches: ['**']
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -92,7 +78,7 @@ jobs:
# Cloned by hand rather than with actions/checkout: that is a JS # Cloned by hand rather than with actions/checkout: that is a JS
# action and needs node inside the job container before any step # action and needs node inside the job container before any step
# has had a chance to install it. Same approach as the other # has had a chance to install it. Same approach as the other
# other workflows in this directory. # three workflows in this directory.
- name: Clone repo at this commit - name: Clone repo at this commit
run: | run: |
set -eu set -eu
-176
View File
@@ -1,176 +0,0 @@
name: Attach the desktop build to the release
# The Arch package goes to the pacman registry and the APK to the generic
# one, but a release page with nothing on it to download is a release page
# nobody can use. This builds the plain Linux x86_64 binary and attaches
# it, so "get the latest version" has an answer that needs no package
# manager at all.
#
# **Linux only, and macOS is not an oversight.** `GOOS=darwin
# CGO_ENABLED=0` fails at `wails/v3/pkg/mac: build constraints exclude all
# Go files` — the darwin backend is Objective-C behind cgo, so a .app
# needs a macOS host, and the runner is a Linux container. That is
# exactly why the Homebrew formula builds from source on the user's own
# Mac, and it stays the macOS channel.
#
# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a
# couple of seconds — nothing in the audio, database or webview path needs
# cgo there), and is deliberately not published: no Windows build of this
# app has ever been run, and no tier here can exercise one. Shipping it
# would be a promise nothing in this repo can keep. Revisit when someone
# has actually booted it.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Version to build and attach (default: the latest v* tag)"
required: false
concurrency:
group: desktop-assets-${{ github.ref }}
cancel-in-progress: true
jobs:
linux:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
volumes:
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
env:
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
REF_NAME: ${{ github.ref_name }}
DEBIAN_FRONTEND: noninteractive
GO_VERSION: '1.25.0'
npm_config_store_dir: /cache/pnpm-store
steps:
# The same set ci.yml's check job installs: the app is cgo, and
# without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa`
# before anything is compiled.
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git jq build-essential pkg-config \
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
git -C /src checkout --quiet --detach "$SHA"
git config --global --add safe.directory /src
git -C /src log --oneline -1
- name: Resolve the version
id: version
working-directory: /src
run: |
set -eu
v="${{ inputs.version }}"
if [ -z "$v" ]; then
case "$REF_NAME" in
v*) v="$REF_NAME" ;;
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;;
esac
fi
case "$v" in v*) ;; *) v="v$v" ;; esac
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. Nothing is built for
# it, and this is a clean skip rather than a failure because a
# red run against a tag that was never meant to ship is noise.
if [ "$v" = "v0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to build"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
echo "building $v"
- name: Go toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
mkdir -p /cache/tool && rm -rf /cache/tool/go
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
fi
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
/cache/tool/go/bin/go version
- name: Node toolchain
if: steps.version.outputs.skip == 'false'
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
corepack enable
node --version
# `make build-prod` is the production task: -trimpath and -w -s are
# already in it, so only the version stamp is passed, through the
# LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml.
# (`wails3 build` has no -ldflags of its own; that was v2.)
- name: Build
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
set -eu
export PATH="/src/scripts/toolbin:$PATH"
commit=$(git rev-parse --short HEAD)
go generate ./...
go tool wails3 task build \
LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'"
# Described, never run: main.go has no flag parsing, so any
# invocation here would try to open a window in a container with
# no display and hang the job rather than printing a version.
test -x bin/yellowjacket
ls -la bin/yellowjacket
file bin/yellowjacket || true
# The .desktop file and the icon go in the tarball because without
# them the binary is a window with no menu entry — the Arch package
# installs both, and this is the same app for people not using it.
- name: Package the tarball
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
dir="yellowjacket-${VERSION}-linux-amd64"
mkdir -p "/tmp/$dir"
cp bin/yellowjacket "/tmp/$dir/"
cp packaging/arch/yellowjacket.desktop "/tmp/$dir/"
cp frontend/src/assets/images/icons/music/compact-disc.svg \
"/tmp/$dir/yellowjacket.svg"
tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir"
ls -la "/tmp/${dir}.tar.gz"
- name: Attach it to the release
if: steps.version.outputs.skip == 'false'
working-directory: /src
env:
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -eu
./scripts/release-asset.sh "$TAG" \
"/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz"
+2 -28
View File
@@ -14,15 +14,6 @@ on:
push: push:
tags: tags:
- "v*" - "v*"
workflow_dispatch:
inputs:
version:
description: "Version to sync (default: the pushed tag)"
required: false
concurrency:
group: homebrew-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
sync-formula: sync-formula:
@@ -39,25 +30,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Compute version and tarball checksum - name: Compute version and tarball checksum
id: version
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${{ inputs.version }}" TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0
[ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1 VERSION="${TAG#v}" # e.g. 1.3.0
case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac
VERSION="${TAG#v}" # e.g. 0.0.1
# v0.0.0 is semantic-release's version floor, not a shipment —
# see the bootstrap step in release.yml. Skipped cleanly rather
# than failing: this one would otherwise push a formula for a
# version that does not exist into a *public* tap.
if [ "$VERSION" = "0.0.0" ]; then
echo "v0.0.0 is the version floor, not a release; nothing to sync"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz" TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
echo "Fetching ${TARBALL}" echo "Fetching ${TARBALL}"
@@ -77,7 +53,6 @@ jobs:
echo "SHA256=${SHA256}" >> "$GITHUB_ENV" echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
- name: Render the formula with the new version and checksum - name: Render the formula with the new version and checksum
if: steps.version.outputs.skip == 'false'
run: | run: |
set -euo pipefail set -euo pipefail
src="packaging/homebrew/Formula/yellowjacket.rb" src="packaging/homebrew/Formula/yellowjacket.rb"
@@ -91,7 +66,6 @@ jobs:
cat yellowjacket.rb cat yellowjacket.rb
- name: Push to the Homebrew tap repo - name: Push to the Homebrew tap repo
if: steps.version.outputs.skip == 'false'
run: | run: |
set -euo pipefail set -euo pipefail
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
+22 -20
View File
@@ -7,29 +7,31 @@ name: Search index maintenance
# import older than 6mo -> rebuild (re-import from the newest dump) # import older than 6mo -> rebuild (re-import from the newest dump)
# otherwise -> refresh (fold in new incremental listens) # otherwise -> refresh (fold in new incremental listens)
# #
# **There is deliberately no `push` trigger, and restoring one is a # A refresh is cheap and no-ops when nothing new has been published, so
# decision rather than a cleanup.** A refresh is individually cheap, so # running it on every push to main is safe.
# running it on every push to main looked free; what it actually does is
# put an unattended job that mutates the only copy of a ~205 GB catalog
# on the same trigger as an ordinary code change, on a runner with
# capacity 1.
# #
# That is not hypothetical. On 2026-08-17 `fix(database): retire a table # ---------------------------------------------------------------------
# whose shape the schema moved past` landed on main, green — the CI # TEMPORARY (2026-08-17): the `push` trigger is off while the catalog
# database is deliberately in the older encoding, so the stale-shape # rebuilds.
# repair judged its `explore_index` stale and dropped it, and this job
# fell back to a full import from the dumps. `fix(database): never
# retire the catalog the index build derives` stops that specific repair
# and cannot undo it. Every push to main then booked another `budget`
# (3h) of the one runner while ordinary CI queued behind it.
# #
# So the rule this file is an instance of: **a job that mutates state # `fix(database): retire a table whose shape the schema moved past`
# which cannot be rebuilt in ten minutes is triggered deliberately, not # dropped this job's `explore_index` on its first run -- the CI database
# by a push.** The weekly cron keeps the catalog current, and # is deliberately in the older encoding, so the repair judged it stale --
# workflow_dispatch resumes or forces a build — indexbuild picks up from # and the job fell back to a full ~205 GB import from the dumps.
# its checkpoint either way, so nothing is lost by not running on every # `fix(database): never retire the catalog the index build derives`
# merge. See docs/index-cache.md for the snapshot and the restore. # stops it happening again but cannot undo it.
#
# Until that import reports complete, every push to main books another
# `budget` (3h) of a runner with capacity 1, and ordinary CI queues
# behind it. The weekly cron and workflow_dispatch still resume the
# build, which is all it needs: indexbuild picks up from its checkpoint.
#
# RESTORE the two `push` lines below once a run reports
# `complete=true`. Nothing else here changed.
# ---------------------------------------------------------------------
on: on:
# push:
# branches: [main]
schedule: schedule:
# Weekly update pass. The 6-month rebuild is triggered by the same # Weekly update pass. The 6-month rebuild is triggered by the same
# command when it notices the import has aged out. # command when it notices the import has aged out.
-182
View File
@@ -1,182 +0,0 @@
name: Release
# The sixth workflow, and the one that decides whether the other three
# run at all. On every push to main it reads the Conventional Commits
# since the last tag, and if any of them is releasable it writes the
# changelog, pushes the tag, and creates the Gitea release whose body is
# that changelog section. The publishing workflows are keyed on `v*`, so
# the tag push is what starts them.
#
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
# workflow's own token (go-gitea#33123). The token is what decides this,
# not the workflow — so semantic-release is handed a repositoryUrl
# carrying a *user* PAT, and the resulting push is attributed to a person
# and triggers the `v*` workflows normally.
#
# That limitation is used deliberately in the bootstrap step below, where
# a tag that must *not* trigger anything is pushed with the Actions token
# instead.
on:
push:
branches: [main]
workflow_dispatch:
# Cutting a tag is not a thing to cancel halfway: a superseded run must
# finish, not be killed between `git push --tags` and the release POST.
concurrency:
group: release-main
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
container:
image: ubuntu:24.04
env:
SERVER_URL: ${{ github.server_url }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.repository }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
DEBIAN_FRONTEND: noninteractive
steps:
- name: System packages
run: |
set -eu
apt-get update -qq
apt-get install -y -qq --no-install-recommends ca-certificates curl git jq
- name: Node toolchain
run: |
set -eu
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq --no-install-recommends nodejs
node --version
# By hand rather than actions/checkout, like the other five: that is
# a JS action and needs node inside the container before any step has
# installed it. The full history is required — semantic-release
# reads tags and walks commits, and a shallow clone silently makes
# every release look like the first one.
- name: Clone repo at this commit
run: |
set -eu
git clone --quiet \
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
# -B main rather than --detach, which the other five workflows
# use: semantic-release resolves the release branch and then
# pushes a commit and a tag to it, and a detached HEAD is a
# worse starting point for both than a local branch named after
# the one being released. Pinned to this commit, not to
# whatever main points at by the time the container started.
git -C /src checkout --quiet -B main "${{ github.sha }}"
git config --global --add safe.directory /src
git -C /src log --oneline -1
# Nothing currently pushes a `chore(release):` commit — main is a
# protected branch, so .releaserc.yml carries no @semantic-release/git
# and the release page is the changelog. This guard is kept for the
# day someone adds that plugin back: without it the commit-back is a
# push to the branch this workflow runs on, and the loop is a release
# per release. Six lines against that is cheap.
- name: Skip a changelog commit, if one ever exists
id: guard
working-directory: /src
run: |
set -eu
subject=$(git log -1 --format='%s')
case "$subject" in
"chore(release):"*)
echo "this is the release commit itself; nothing to do"
echo "skip=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "skip=false" >> "$GITHUB_OUTPUT"
;;
esac
# semantic-release calls the first release of a repo with no tags
# 1.0.0, and offers no option to say otherwise. A floor tag is the
# only way to start at 0.0.1, so this creates one — once, ever.
#
# **It is pushed with the Actions token on purpose.** v0.0.0 is a
# floor, not a shipment: pushing it with a user PAT would start the
# Arch, Homebrew and Android workflows for a version that does not
# exist. The very limitation the header describes is what makes
# this inert.
- name: Seed the version floor
if: steps.guard.outputs.skip == 'false'
working-directory: /src
env:
ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -eu
git fetch --quiet --tags origin
if [ -n "$(git tag --list 'v[0-9]*')" ]; then
echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
exit 0
fi
# Prefer the Actions token because a ref it pushes starts no
# workflow, which is the whole point for a tag that is a floor
# rather than a shipment. Falling back to the PAT is safe
# rather than merely convenient: all four publishing workflows
# skip v0.0.0 explicitly, so the worst case is four jobs that
# start and immediately say there is nothing to build.
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
# **On the parent, not on HEAD.** The floor marks what has
# already been released, so tagging the commit being pushed
# leaves nothing between the floor and HEAD — semantic-release
# then correctly reports there is nothing to release, which is
# exactly what the first run of this workflow did. HEAD^ is the
# first parent, so on the merge commit this fires for it is main
# as it was before the merge, and everything the merge brought
# in is releasable.
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
if [ -z "$floor" ]; then
echo "HEAD has no parent, so no commit can precede the floor" >&2
exit 1
fi
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
git tag v0.0.0 "$floor"
git push --quiet \
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
refs/tags/v0.0.0
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
# Pinned rather than installed into the repo: this is a Go project
# and a package.json at its root invites the npm plugin and every
# tool that looks for one. conventional-changelog-conventionalcommits
# is in the list because both the analyzer and the notes generator
# name that preset and neither depends on it.
#
# **That preset is held at 9 and the reason is worth keeping.** At
# 10 it is silently incompatible with the writer that
# release-notes-generator@14 pulls in (^8): every release note comes
# out as a bare `## 0.0.1 (date)` heading with **no sections and no
# commits under it**, and nothing errors. The version would have
# been right, the tag would have been right, every job would have
# been green, and the release body would have been empty. Check the
# notes, not the exit code, before moving any of these.
- name: Run semantic-release
if: steps.guard.outputs.skip == 'false'
working-directory: /src
run: |
set -eu
git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app"
npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
-p @semantic-release/release-notes-generator@14 \
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release \
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
-4
View File
@@ -84,7 +84,3 @@ build/android/build/
build/android/.gradle/ build/android/.gradle/
build/android/gen/ build/android/gen/
build/android/overlay.json 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
-38
View File
@@ -3445,41 +3445,3 @@ their own output, and leave the previous snapshots in place.
makes it worth having: a restored snapshot resolves to `refresh` and makes it worth having: a restored snapshot resolves to `refresh` and
folds in the incremental listens since — minutes, against the 3–23 h a folds in the incremental listens since — minutes, against the 3–23 h a
rebuild was estimating. rebuild was estimating.
## A green release pipeline can ship an empty changelog (2026-08-18)
`conventional-changelog-conventionalcommits@10` is silently incompatible
with the writer `@semantic-release/release-notes-generator@14` depends on
(`conventional-changelog-writer@^8`). Every release note renders as a bare
`## 0.0.1 (date)` heading with **no sections and no commits under it**, no
step fails, and the release ships with an empty body.
It is pinned to `9` in `.gitea/workflows/release.yml` and in
`make release-dry`, which must stay identical. **Check the rendered notes,
never the exit code** — this is invisible to every tick in the pipeline.
## semantic-release needs push rights to the branch even when it never pushes to it (2026-08-18)
Core runs `git push --dry-run HEAD:<branch>` as a permission check, before
and independently of any plugin. With `@semantic-release/git` removed
nothing ever pushes to `main`, and the check still runs.
Two things this looked like and was not:
- **Not branch protection.** A `--dry-run` push does not reach the
pre-receive hook: pushing one to protected `main` with a write-scoped
token succeeds. So `main`'s `enable_push: false` is not what fails here.
- **A flat `403 Forbidden`, not Gitea's protection message.** That is the
tell. `PACKAGE_TOKEN` had package-write and repo-*read* — enough to
clone a private repo, so every other workflow was fine — and needed
`write:repository`.
## A tag-triggered workflow runs the workflow file at the *tagged* commit (2026-08-18)
Not the one on `main`. Moving `v0.0.0` onto a pre-merge commit ran that
commit's version of `homebrew-formula.yml`, which predated the `v0.0.0`
skip guard added in the same plan, and it pushed a `0.0.0` formula to the
public tap.
A guard added today does not protect a tag that points at yesterday. When
re-pointing a tag, check what the workflows looked like *there*.
@@ -1,87 +0,0 @@
# 017 — Releases that happen by themselves
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
Commits since the last tag, cuts the tag and the Gitea release whose body
is the generated changelog, and the four publishing workflows build that
tag and attach their artifacts. Nothing is released by hand.
## What it looks like now
`release.yml` on push to `main` → semantic-release → tag → four `v*`
workflows in parallel (serialised in practice by the capacity-1 runner):
| workflow | publishes | attaches |
| --- | --- | --- |
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
| `homebrew-formula` | the public tap | — (builds from source) |
Verified on the real thing: all five green, three assets on the release,
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
## The five decisions, and what they cost
1. **semantic-release, not a shell script.** The first draft of this plan
proposed hand-rolling it and the argument did not survive checking:
`@semantic-release/exec` is first-party and current, and the
Gitea-shaped part is one `curl`. What I would have hand-rolled —
commit parsing, semver ordering, note rendering — is the part with the
edge cases and none of it is Gitea-shaped.
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
before it was checked: last published 2022, `got@10`, and no peer
dependency on semantic-release at all.
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
commit-back is rejected by the pre-receive hook — and would be
rejected *after* the tag was pushed, leaving a tagged release the run
reports as failed. The release page is the changelog;
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
signpost.
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
once. Documented in `packaging/homebrew/README.md` and
`docs/android-release.md`.
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
because no build of it has ever been *run*.
## Four things that only showed up by running it
- **`conventional-changelog-conventionalcommits@10` renders empty
notes.** Silently: right version, right tag, every step green, and a
release body that is a bare `## 0.0.1 (date)` heading with nothing
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
the reason beside both. **Check the rendered notes, never the exit
code.**
- **semantic-release core dry-run-pushes to the release branch** as a
permission check, independently of any plugin. `PACKAGE_TOKEN` had
package-write and repo-*read* — enough to clone, not enough for this —
and it failed with a flat `403 Forbidden` that reads exactly like
branch protection. It is not: a `--dry-run` push never reaches the
pre-receive hook, which a one-line experiment settled. The token needed
`write:repository`.
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
commit itself it leaves nothing between the floor and HEAD, and
semantic-release correctly reports there is nothing to release. The
first run did exactly that and cut nothing.
- **A tag-triggered workflow runs from the tagged commit's tree.**
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
general: a guard added today does not protect a tag pointing at
yesterday.
## Two mechanisms confirmed, having been assumptions
- **A tag pushed with a user PAT does start the `v*` workflows**; one
pushed with the Actions token does not (go-gitea#33123). Both halves
are load-bearing and both were observed: the floor seed triggered
nothing, and the release tag triggered all four.
- **Tags are not protected** on this repo, only `main` — which is what
lets semantic-release tag at all.
## Left behind deliberately
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
four publishers skip it by name.
+16 -52
View File
@@ -1,20 +1,6 @@
# semantic-release configuration. # semantic-release configuration
# # Runs on main branch pushes to auto-determine version from conventional commits.
# Runs on pushes to main from .gitea/workflows/release.yml: determine the # Creates a git tag + GitHub Release draft; a separate workflow builds binaries.
# version from the Conventional Commits since the last tag, write the
# changelog, commit it, push the tag, and create the Gitea release.
#
# **There is no `@semantic-release/github` plugin here and there must not
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
# community plugin (@saithodev/semantic-release-gitea) was considered and
# rejected: last published 2022, depends on got@10, and declares no peer
# dependency on semantic-release at all — i.e. untested against anything
# since v19, against a core now at v25. `exec` is first-party, current,
# and the Gitea-shaped part is one curl.
#
# The type list below is the one scripts/commit-check.sh enforces the
# grammar for — keep the two in step, or semantic-release will silently
# decline to release something the commit hook accepted.
branches: branches:
- main - main
@@ -77,41 +63,19 @@ plugins:
section: Build section: Build
hidden: true hidden: true
# Render the notes to a file. # Write CHANGELOG.md.
#
# **This plugin is here to carry the notes, not to maintain a document.**
# It is how they reach the Gitea API *without being interpolated into a
# shell command*: release notes are rendered commit messages — arbitrary
# text carrying backticks, quotes and `$` — so templating
# ${nextRelease.notes} into `publishCmd` would be a shell injection with
# the commit log as its input. scripts/gitea-release.sh reads the top
# section of this file instead, and the only thing interpolated below is
# a semver string.
#
# The target is a gitignored build artifact rather than CHANGELOG.md,
# because nothing commits it back — see below.
- - "@semantic-release/changelog" - - "@semantic-release/changelog"
- changelogFile: .release-notes.md - changelogFile: CHANGELOG.md
changelogTitle: "# Release notes"
# Create the Gitea release, whose body is that section. # Commit the changelog back to the repo.
# `publish` runs after `prepare`, so the tag already exists by here. - - "@semantic-release/git"
- - "@semantic-release/exec" - assets:
- publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}" - CHANGELOG.md
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
# **There is deliberately no @semantic-release/git here.**
#
# `main` is a protected branch with `enable_push: false` and an empty
# push whitelist, so a changelog commit-back would be rejected by the
# pre-receive hook — *after* the tag had already been pushed, leaving a
# tagged release the run then reported as failed. The alternative was to
# whitelist the CI user, which weakens a protection someone set on
# purpose and lets a bot push to main without passing the checks every
# human PR has to.
#
# So the release page is the changelog. Tags are not protected, so the
# tag push semantic-release does itself is unaffected. CHANGELOG.md in
# the repo is a signpost to the releases page and is not written by any
# of this; a file that claimed to be a changelog and silently stopped
# updating would be worse than no file at all.
# Create the GitHub Release (draft, so the build workflow can attach binaries).
- - "@semantic-release/github"
- draft: true
successComment: false
failComment: false
releasedLabels: false
-1
View File
@@ -1 +0,0 @@
CLAUDE.md
+372 -16
View File
@@ -1,21 +1,377 @@
# Changelog ## [1.3.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.3...v1.3.0) (2026-03-20)
The changelog is the releases page: ### Features
<https://git.ljones.me/yonlu/yellowjacket/releases> * **09-01:** add scan control events and cancelled metrics field ([c695024](https://github.com/onion-4-dinner/yellowjacket/commit/c695024241a7513b8fedb3fbf7ff364d0515b392))
* **09-01:** add scan control fields and per-scan cancellable context ([cf22e52](https://github.com/onion-4-dinner/yellowjacket/commit/cf22e52a64850a80b9fcc63c21d81313e6bd56ab))
* **09-02:** add frontend keyboard shortcut service, store, and controller ([40d4815](https://github.com/onion-4-dinner/yellowjacket/commit/40d48151dd798b57eed9f54a572ae4735356d09e))
* **09-02:** add shortcuts config package with default bindings and Wails persistence ([6285ca9](https://github.com/onion-4-dinner/yellowjacket/commit/6285ca9dc4e6f211197e377d01c485b1ef65c300))
* **09-03:** add scan control UI with pause/resume/cancel and confirmation dialog ([3914369](https://github.com/onion-4-dinner/yellowjacket/commit/391436927c826f2f17a4523be7829aefc04a6b12))
* **09-04:** add keyboard shortcuts section to config page with conflict detection ([0451fb3](https://github.com/onion-4-dinner/yellowjacket/commit/0451fb38805ff2c27e43deb152daa892e733d2db))
* **10-01:** implement migration 6 and pre-migration backup ([1179f56](https://github.com/onion-4-dinner/yellowjacket/commit/1179f56c3680112692e71e8dc7ce946446fa8a8a))
* **10-01:** update SQL schema files for multi-library fresh installs ([535855b](https://github.com/onion-4-dinner/yellowjacket/commit/535855b383a457dd2be3298b4361313bef22b39d))
* **10-02:** add migration 6 integration tests and NewTestDBWithLibrary helper ([bc15189](https://github.com/onion-4-dinner/yellowjacket/commit/bc151891b50e59e41da2e00dbfafbecaad11b4ac))
* **10-02:** add sqlc queries for libraries and update playlist queries for phantom support ([02548dd](https://github.com/onion-4-dinner/yellowjacket/commit/02548dd55e59b28f3d6c8d9614f209140c979250))
* **11-01:** per-library scan pipeline with queue coordinator ([943db1c](https://github.com/onion-4-dinner/yellowjacket/commit/943db1cf274bdf59daf28ab6c20f78ef5ef53105))
* **11-02:** update config-page with per-library progress display and queue-aware cancel dialog ([d01591d](https://github.com/onion-4-dinner/yellowjacket/commit/d01591d6cc054a63b832c05a3164a72fdcaba342))
* **11-02:** update library-manager with per-library progress and Scan All button ([d61f122](https://github.com/onion-4-dinner/yellowjacket/commit/d61f122b567e8ac2b30fa96c637cbebc14493c89))
* **12-01:** add queue compaction method and wire removal hooks ([5995dfd](https://github.com/onion-4-dinner/yellowjacket/commit/5995dfd01d61cd4d2c0749eeeee2a1f93b739d68))
* **12-01:** implement library CRUD methods and orphan cleanup pipeline ([bd44f83](https://github.com/onion-4-dinner/yellowjacket/commit/bd44f8306c9129b9420ad81938bcf8105a1cb55a))
* **12-02:** make config sections collapsible with chevron dropdown ([12c6782](https://github.com/onion-4-dinner/yellowjacket/commit/12c678284c7582bd85cd52722f4d405b0bd0e20f))
* **12-02:** remove Libraries sidebar nav item and view routing ([e199712](https://github.com/onion-4-dinner/yellowjacket/commit/e199712a56e1cb3c0fc43d3340abb892a6f5fa7b))
* **12-02:** replace config-page library section with full library management UI ([ffc5d96](https://github.com/onion-4-dinner/yellowjacket/commit/ffc5d9639cf7c916a4f846590ae0d67cf13afe27))
* **12-02:** selectable library list with checkbox scan targeting ([13a42ae](https://github.com/onion-4-dinner/yellowjacket/commit/13a42aea2287d7ed0ec9ff9856f52c1fa7767338))
* **12-02:** show scan progress bar inline in library list entry ([df824c6](https://github.com/onion-4-dinner/yellowjacket/commit/df824c6989e92b2aefaa1ddf05b131ee319612d8))
* **13-01:** add library-filtered Go query methods and FTS search ([5f7de50](https://github.com/onion-4-dinner/yellowjacket/commit/5f7de5060a5bc557b96203267de694ef366ed507))
* **13-01:** add library-filtered sqlc queries for all browse views ([5cc58ce](https://github.com/onion-4-dinner/yellowjacket/commit/5cc58ce66ab70d8d5a570df5067f79ae2201037e))
* **13-02:** add library filter dropdown and wire all views to respect active filter ([42b8cf9](https://github.com/onion-4-dinner/yellowjacket/commit/42b8cf9f52133499ffcd7363bd39dd0c1069e091))
* **15-01:** migrate FTS5 search_index to contentless_delete=1 ([cb5155b](https://github.com/onion-4-dinner/yellowjacket/commit/cb5155b8906357ff77c5c579d57d02cf2eec6abe))
* **15-02:** create backend/fileutil package with AtomicWrite ([4d64b5d](https://github.com/onion-4-dinner/yellowjacket/commit/4d64b5dcfe43951e8ec63383bbf72c99107c63c4))
* **16-01:** add selectAll() to SelectionController and dispatch shortcut:select-all event ([f567762](https://github.com/onion-4-dinner/yellowjacket/commit/f5677628ef283b67370630b564f23178e43da3d2))
* **16-01:** wire shortcut:select-all listener in track-list, queue-panel, and playlist-view ([906ea28](https://github.com/onion-4-dinner/yellowjacket/commit/906ea28751ce9f96fdeeb9410ab5f6518f09fcb9))
* **16-02:** add go-flac dependencies and implement FLAC tag writer ([3642cbe](https://github.com/onion-4-dinner/yellowjacket/commit/3642cbe0d58f8912a786a4fc5380c40403add94a))
* **16-03:** implement DB sync module for tag write pipeline ([2966079](https://github.com/onion-4-dinner/yellowjacket/commit/2966079625cd42412411429af02184d015526e9b))
* **16-03:** WriteTrackTags pipeline with player safety, scan mutex, events, and app wiring ([64322f9](https://github.com/onion-4-dinner/yellowjacket/commit/64322f93538515d5a3e486dc14691b9c9dcf6f66))
* **17-01:** add TrackMetadataChanged handler and remove selection gate on Track Details ([fc5cf70](https://github.com/onion-4-dinner/yellowjacket/commit/fc5cf70e4c1be3d3f1545c140db5202601a08109))
* **17-01:** add WriteTrackTagsByPath and ImageFilePicker backend methods ([4235b4a](https://github.com/onion-4-dinner/yellowjacket/commit/4235b4a4d555882ce86628a88dd4e4eeee2c9097))
* **17-02:** implement save flow, cover art editing, and error handling ([265a9ea](https://github.com/onion-4-dinner/yellowjacket/commit/265a9ea8ceba893f956a03546e9ac4189adc7716))
* **18-01:** add BatchWriteProgress event constant ([3dba0e1](https://github.com/onion-4-dinner/yellowjacket/commit/3dba0e143c091327d305d39d2fa7a687ec47e172))
* **18-01:** add BatchWriteTrackTags with progress, cancellation, and partial failure ([f557ffd](https://github.com/onion-4-dinner/yellowjacket/commit/f557ffd652179b7cf8f8ff4a06824f30edf08007))
* **18-02:** add batch edit mode to track-details component ([6dab32b](https://github.com/onion-4-dinner/yellowjacket/commit/6dab32b36b497d54e8645e969aa79737ad3523ab))
* **18-02:** wire batch track-details to all 4 view context menus ([656985a](https://github.com/onion-4-dinner/yellowjacket/commit/656985add92663440baebb871f8cd6d5723117fd))
* **19-01:** implement WAV RIFF parser/writer and writeWavTags ([e6610ff](https://github.com/onion-4-dinner/yellowjacket/commit/e6610ff15e041213b6898ad48ff63b7060b312e7))
* **20-01:** implement OGG Vorbis tag writer with custom page parser and CRC32 ([5e98c03](https://github.com/onion-4-dinner/yellowjacket/commit/5e98c036342b9e174abdc6d00db21c2e2901f18b))
* **quick-17:** create playlist-details subpage component ([dc5c7d6](https://github.com/onion-4-dinner/yellowjacket/commit/dc5c7d6ca6cfbfac15546c048f1b33aaf47209c6))
* **quick-18:** replace track-info with multi-column grid layout in playlist-details ([ce23177](https://github.com/onion-4-dinner/yellowjacket/commit/ce2317722870f932792dc6456a63235ff4611466))
Every release there is generated from the Conventional Commits it ### Bug Fixes
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 * **09-05:** emit VolumeChanged event and persist state in ChangeVolume and MuteToggle ([bb3fd20](https://github.com/onion-4-dinner/yellowjacket/commit/bb3fd204f0895f357a14479b40754f397aae74c4))
protected branch, so nothing pushes a changelog commit back to it — and a * **10-01:** move library_id index to migration 6 to fix existing DB startup ([75b2a34](https://github.com/onion-4-dinner/yellowjacket/commit/75b2a349ebd6fada5cbc92bfae9854cc2cd53c63))
file that claimed to be a changelog while silently never updating would * **12-02:** claim orphaned tracks when adding library with matching path ([f60b6b5](https://github.com/onion-4-dinner/yellowjacket/commit/f60b6b525546ef77a3329fe92f03f336b7435a0e))
be worse than no file at all. `make release-dry` prints what the next * **12-02:** count failed saves as skipped so scan progress bar advances ([b36e472](https://github.com/onion-4-dinner/yellowjacket/commit/b36e472212957ff089f4f5d35f3978a754e23502))
merge would release. * **12-02:** delete artist_credit_artist before artist_credit in removal pipeline ([890284d](https://github.com/onion-4-dinner/yellowjacket/commit/890284ddb1d0fb95e423bddf27b40fb0db2d11e5))
* **12-02:** dismiss inline rename on click outside ([9272b06](https://github.com/onion-4-dinner/yellowjacket/commit/9272b060bf98118e37f19a8c0834034691bfe6a2))
* **12-02:** downgrade per-file save error to Debug, add warning count to scan summary ([cf18c39](https://github.com/onion-4-dinner/yellowjacket/commit/cf18c39dbd849d60218228cf1d2285ab2071e788))
* **12-02:** invalidate library store cache on LibraryRemoved event ([b093fbb](https://github.com/onion-4-dinner/yellowjacket/commit/b093fbb10a24054c4ef62b0bd13f28d9bfe6f121))
* **12-02:** keep Add Library button visible during scan ([649e516](https://github.com/onion-4-dinner/yellowjacket/commit/649e516aa30090665e9f10e89c1ccce378e36b96))
* **12-02:** move Add Library button inline with scan buttons ([771345d](https://github.com/onion-4-dinner/yellowjacket/commit/771345dd9d3870b3a907e1cce09c7456ab7ccd85))
* **12-02:** move scan buttons above library list, default to none selected ([ba3f840](https://github.com/onion-4-dinner/yellowjacket/commit/ba3f840a28fe2c6ca40c558305814d29c233d6e0))
* **12-02:** refresh library track counts after scan completes ([1f872aa](https://github.com/onion-4-dinner/yellowjacket/commit/1f872aa005a9405d9bc1f64a4b1dd2f1f1d4a16c))
* **12-02:** reorder orphan cleanup to delete FK children before recordings ([1d735c3](https://github.com/onion-4-dinner/yellowjacket/commit/1d735c3a5f5a78996d6ddbe5c787adf040fe2f21))
* **12-02:** replace removed Scan() import with ScanAllLibraries() ([0559822](https://github.com/onion-4-dinner/yellowjacket/commit/05598224e4d5532d2e2a3a7e5d3b5411240b1024))
* **12-02:** resolve phantom tracks caused by empty library root after TOML cleanup ([717e249](https://github.com/onion-4-dinner/yellowjacket/commit/717e249c368fd1cc8d5c8f945c352175708691cf))
* **12-02:** serialize ScanWarning.Err as string instead of error interface ([ac8cbb3](https://github.com/onion-4-dinner/yellowjacket/commit/ac8cbb3296bd561a305627668c211dce7209df25))
* **12-02:** soft scan claims orphaned library_id=0 tracks on startup ([1ad099a](https://github.com/onion-4-dinner/yellowjacket/commit/1ad099a9d35fc722475e238d3443fd5473566acd))
* **12-02:** soft scan on launch — only scan libraries with changed file counts ([92c4d23](https://github.com/onion-4-dinner/yellowjacket/commit/92c4d23a9a1e545fab497816ee3dce43a181cded))
* **12-02:** wait for scan to stop before library removal, surface errors in UI ([cf00498](https://github.com/onion-4-dinner/yellowjacket/commit/cf004986c95732d00208e83467267904ea3f2ef6))
* **13-02:** auto-resolve phantom playlist tracks after library scan ([93262b9](https://github.com/onion-4-dinner/yellowjacket/commit/93262b9ae0f737d2893839ac585776207b3b44b6))
* **13-02:** defer virtualizer event delegation until element exists ([f05d2bb](https://github.com/onion-4-dinner/yellowjacket/commit/f05d2bb603f5ea827164466fd0795a6c6e662529))
* **13-02:** resolve phantom playlist tracks using M3U8 paths after scan ([9f595b7](https://github.com/onion-4-dinner/yellowjacket/commit/9f595b7ac10c2191b5469004901cbbc1331c1abb))
* **14-01:** downgrade main-panel from contain:strict to layout+style+paint ([4b7d35d](https://github.com/onion-4-dinner/yellowjacket/commit/4b7d35d7ec4c8b14453a8f8250cd154b8c4c2537))
* **14-perf:** fix scroll jumping and input latency ([3b2e189](https://github.com/onion-4-dinner/yellowjacket/commit/3b2e189e7d0e6d00393d087565190fd307774257))
* **17-02:** fix cover art replace and remove ([d7c2965](https://github.com/onion-4-dinner/yellowjacket/commit/d7c2965752ae0ac9009d00f2431d5919a24558b7))
* **17-02:** handle float64 numeric values from Wails JSON deserialization ([900db2e](https://github.com/onion-4-dinner/yellowjacket/commit/900db2e56cca254873a3a5a7a384008feac4211b))
* **17-02:** refresh cover art URLs after save ([8cd4914](https://github.com/onion-4-dinner/yellowjacket/commit/8cd4914842f61c0c6b49e0216c7816e201a3c94a))
* **17-02:** refresh track-details dialog data after successful save ([ffcdc41](https://github.com/onion-4-dinner/yellowjacket/commit/ffcdc41b0d4fad8ed428dbaa55f6cdd38c096822))
* **18-02:** add field labels above title/artist/album inputs in batch edit mode ([9df2d67](https://github.com/onion-4-dinner/yellowjacket/commit/9df2d6764a0b0566dda33cff675debea4a61dea8))
* **18-02:** add field labels to all track-details states (single/batch, read/edit) ([d430ad8](https://github.com/onion-4-dinner/yellowjacket/commit/d430ad884bfd38bea93389d8be730ff00388a7be))
* **19-01:** add album_artist TPE2 mapping to applyTextChanges ([8f4c4a0](https://github.com/onion-4-dinner/yellowjacket/commit/8f4c4a0c2b14eeeaeccb972a40addb11f3d65437))
* preserve scroll position in cached grid views ([54df917](https://github.com/onion-4-dinner/yellowjacket/commit/54df917ffdd69c4f7ffaeccf2d161261ca80d84e))
* **queue-panel:** set flow layout _itemSize to match actual track item height ([288d9de](https://github.com/onion-4-dinner/yellowjacket/commit/288d9deae22d437fcd7857b368827db7b62c24f6))
* **queue-panel:** suppress virtualizer scroll corrections during scrollbar drag ([0bd8cef](https://github.com/onion-4-dinner/yellowjacket/commit/0bd8cefa00dcae2f8bd9579de2aefd58e0a9e6c9))
* **quick-19:** multi-root path resolution for playlist M3U8 tracks ([9144ded](https://github.com/onion-4-dinner/yellowjacket/commit/9144dedc2742925dc252d491763b4f2929238d0e))
* **S21/T01:** fix all lint warnings and upgrade wsl to wsl_v5 ([f16157a](https://github.com/onion-4-dinner/yellowjacket/commit/f16157a2134cbeb1787ff851d4875d77f2f3f86b))
History before `v0.0.1` is in `git log`. The versions before it were cut ### Performance
by hand and are not on the releases page; the entries this file used to
hold were generated against a GitHub remote this project no longer has, * **12-02:** increase scan batch size from 50 to 300 ([21ea71e](https://github.com/onion-4-dinner/yellowjacket/commit/21ea71e2575d76258bd81d89ab8ac883aa3bed36))
and every link in them was dead. * **12-02:** skip FTS5 rebuild during library removal ([30f4461](https://github.com/onion-4-dinner/yellowjacket/commit/30f4461e6957e20d3dc607fa0886a75b5c21b3cf))
* **14-01:** add CSS containment to app shell layout boundaries ([efa06f7](https://github.com/onion-4-dinner/yellowjacket/commit/efa06f7edf1e4acdc3d8865cad264403257ae40d))
* **14-01:** add GPU promotion and containment to all scroll containers ([ac8a52e](https://github.com/onion-4-dinner/yellowjacket/commit/ac8a52e110f9f8ebdc3433b60594370352126a18))
* **14-02:** replace innerHTML navigation with view caching system ([ad91043](https://github.com/onion-4-dinner/yellowjacket/commit/ad9104374a628342e0ea30cf409ff43de2c2f86e))
* **14-03:** add notification batching to queue store and granular change tracking to library store ([d0c05dc](https://github.com/onion-4-dinner/yellowjacket/commit/d0c05dc1d43a4fe12cc07f3cff25375b08a74ba0))
* **14-03:** eliminate per-item closure allocation in scroll render paths ([2f7ed70](https://github.com/onion-4-dinner/yellowjacket/commit/2f7ed7030425ed0ebb7a1a186917a79a7b26b850))
* **14-04:** RAF-throttle scroll position saves and add overflow-anchor to queue panel ([6ca0b3c](https://github.com/onion-4-dinner/yellowjacket/commit/6ca0b3c5a84769af064ebe45a6eaac014d1a270a))
* auto-detect NVIDIA+Wayland for DMABuf workaround ([915591a](https://github.com/onion-4-dinner/yellowjacket/commit/915591aea962beb60da2e96ac0f57307f646f675))
* inline SVGs, memoize grid slices, batch store notifications ([a4eac39](https://github.com/onion-4-dinner/yellowjacket/commit/a4eac394cebefd29d0ebcb4b1e331444dcb8fbaf))
* reduce software rendering overhead for NVIDIA+Wayland ([199c910](https://github.com/onion-4-dinner/yellowjacket/commit/199c91013fd806f6aefce49357df8a32b46faaa0))
### Refactoring
* **quick-17:** simplify playlist-view to navigate instead of expand ([955cd68](https://github.com/onion-4-dinner/yellowjacket/commit/955cd68be2dbf7a9071ef1c93084d687b59b6bd7))
## [1.2.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.1...v1.2.2) (2026-03-06)
### Bug Fixes
* recover from go-mp3 seek panic on startup ([#86](https://github.com/onion-4-dinner/yellowjacket/issues/86)) ([2f9d9f8](https://github.com/onion-4-dinner/yellowjacket/commit/2f9d9f8508b90b6188fe894c282c5b8e330e8046))
## [1.2.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.0...v1.2.1) (2026-03-06)
### Bug Fixes
* **deps:** pin go-webview2 to v1.0.21 for Wails v2 compat ([25f0fe8](https://github.com/onion-4-dinner/yellowjacket/commit/25f0fe81560eeff36a0b2beb52ce1bdf13d5e122))
## [1.2.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.3...v1.2.0) (2026-03-06)
### Features
* **02-02:** add ScanWarning type and reclassify scan errors as warnings ([e6866de](https://github.com/onion-4-dinner/yellowjacket/commit/e6866ded9dc0ea30ff942cd31b6c5ea3269e9584))
* **03-01:** create NewTestDB helper for in-memory SQLite test databases ([bae9d70](https://github.com/onion-4-dinner/yellowjacket/commit/bae9d70d23157ef4e79e60dd713d9a02ab63790b))
* **03-01:** extract shared applyPRAGMAs and add production PRAGMAs to NewDB ([d348815](https://github.com/onion-4-dinner/yellowjacket/commit/d34881530adda7fb75be84737798da46d17bfa8c))
* **06-01:** create track_metadata VIEW schema and migration 4 ([9c7e5a9](https://github.com/onion-4-dinner/yellowjacket/commit/9c7e5a96344a81bf132de487b4763f1dc3ff6df9))
* **06-02:** create Go→TypeScript event constant codegen tool ([3e9edd0](https://github.com/onion-4-dinner/yellowjacket/commit/3e9edd05e87395499ac24e456640d1f6d9b97f04))
* **06-03:** migrate lookupChunk to sqlc-generated LookupTrackMetaByPaths query ([2221a68](https://github.com/onion-4-dinner/yellowjacket/commit/2221a68459850a837c996c6e6d2bc95d41b20fb3))
* **08-01:** define design token CSS custom properties for icon sizes and type scale ([1444a66](https://github.com/onion-4-dinner/yellowjacket/commit/1444a66bb201ce5fdf16552a32bcd281089c64ed))
* **08-04:** apply design tokens to cover-grid, track-list, queue-panel, and detail components ([1303422](https://github.com/onion-4-dinner/yellowjacket/commit/1303422e69c27d528363900b3ca5287a48cc9f8e))
* **08-04:** convert sidebar em-based spacing to px and apply icon/type tokens ([aed90d7](https://github.com/onion-4-dinner/yellowjacket/commit/aed90d7b1710d0c5cece2e4956c0a6ce77b9a999))
* add scan progress bar with phase indicator ([a28b4d1](https://github.com/onion-4-dinner/yellowjacket/commit/a28b4d1e0673658824750d4c702359321dc9a78e))
* **quick-001:** add multi-file picker and batch import support ([c34e4ad](https://github.com/onion-4-dinner/yellowjacket/commit/c34e4ad029c119bff8f70a07ccc6bca58b11ea3c))
* **quick-001:** regenerate bindings and update frontend for multi-import ([2a542bf](https://github.com/onion-4-dinner/yellowjacket/commit/2a542bf3bcdc7772edb1aceb41f488774494f656))
* **quick-002:** add CountPlaylistsByName SQL query and regenerate sqlc ([04b2088](https://github.com/onion-4-dinner/yellowjacket/commit/04b2088b28b84a4d4df25b23d97112c5a955dff1))
* **quick-002:** add uniquePlaylistName helper and wire into ImportPlaylist ([8ba8bbe](https://github.com/onion-4-dinner/yellowjacket/commit/8ba8bbe7bed2ecff97613ebaa42a49a662050353))
* **quick-006:** remove list icon from playlists, add favorites icon to default ([3c19766](https://github.com/onion-4-dinner/yellowjacket/commit/3c19766fd0885d4171cf9929db6d69a3d5c1a3ff))
* **quick-11:** add configurable log level via YJ_LOG_LEVEL env var ([55b4902](https://github.com/onion-4-dinner/yellowjacket/commit/55b4902fac7b7f2c04ad5efac398ecedc5fedc2f))
* **quick-11:** add make dev-debug target for verbose logging ([c45bca4](https://github.com/onion-4-dinner/yellowjacket/commit/c45bca411ba1d4f32deea6027acf91237173dd15))
* **quick-12:** add favorite icon to album dropdown track rows ([12a0bbc](https://github.com/onion-4-dinner/yellowjacket/commit/12a0bbc89c19128485d597a61bd16bd0786450ad))
* **quick-15:** add BufferedStreamer with goroutine read-ahead ([85b23ac](https://github.com/onion-4-dinner/yellowjacket/commit/85b23acb24a048d2f7b85808e477bb991ae124e6))
* **quick-15:** insert BufferedStreamer into player pipeline and increase speaker buffer ([8a0b16a](https://github.com/onion-4-dinner/yellowjacket/commit/8a0b16a4ec08a95bfd3834c8216e21dce854432d))
* **quick-3:** add playlist-level multi-select state and selection handling ([e13151f](https://github.com/onion-4-dinner/yellowjacket/commit/e13151ffa5dc86e41ce242421679d65a740c3af0))
* **quick-3:** wire playlist context menu for batch delete of selected playlists ([c92ced2](https://github.com/onion-4-dinner/yellowjacket/commit/c92ced2c74e72bfc123c880c047462dc969cde34))
* **quick-4:** add 'Set as Default Playlist' context menu option ([9971b63](https://github.com/onion-4-dinner/yellowjacket/commit/9971b635b81fe3f8621c80a6664eccb3e1fc4bb8))
* **quick-5:** add CreatedAt/UpdatedAt to playlist Summary struct ([bdaff47](https://github.com/onion-4-dinner/yellowjacket/commit/bdaff478e802ee5c0745327c52dd9b190fcfef7d))
* **quick-5:** add sort dropdown UI and client-side sorting to playlist view ([5c07485](https://github.com/onion-4-dinner/yellowjacket/commit/5c074855351f1363cc7918837a78bbd3c0b7ebf5))
* **quick-7:** add PinDefault config field with backend getter/setter ([6e123bd](https://github.com/onion-4-dinner/yellowjacket/commit/6e123bd47f55e6d565f20bf7f19950e65f80787f))
* **quick-7:** wire frontend pin-default-playlist feature end-to-end ([e6378e1](https://github.com/onion-4-dinner/yellowjacket/commit/e6378e1f0d3b0f2a7604b8ef6097dba9050cdd16))
* **quick-8:** add FindDuplicateTracksInPlaylist backend method ([83de934](https://github.com/onion-4-dinner/yellowjacket/commit/83de934c39ca7d850a8b5925c90e6d0b3fe0a487))
* **quick-8:** create duplicate-tracks-dialog component ([9f3ba2b](https://github.com/onion-4-dinner/yellowjacket/commit/9f3ba2b9d474fa30dcb4934b01d4650e0d0d3cba))
* **quick-8:** wire duplicate detection into playlist-picker and playlist-view ([917a79a](https://github.com/onion-4-dinner/yellowjacket/commit/917a79a8d6e30dddd2170323bb26692386794872))
### Bug Fixes
* **01-01:** add mutex protection to Queue, Library, and Playlist SetContext methods ([daaa6b7](https://github.com/onion-4-dinner/yellowjacket/commit/daaa6b7f9779385979fe9dddae4e7bb388b3e5fb))
* **01-01:** collapse Player.SetContext double-lock into single acquisition ([3abaeba](https://github.com/onion-4-dinner/yellowjacket/commit/3abaeba3afb0f4d0edb81e26ca55b31bf59990ac))
* **02-01:** eliminate package-level startupErr and fix config file permissions ([2a86408](https://github.com/onion-4-dinner/yellowjacket/commit/2a864082017e489ffa086c136f1002277a77a7c4))
* **02-01:** log MPRIS callback errors instead of discarding them ([0860b2f](https://github.com/onion-4-dinner/yellowjacket/commit/0860b2fd4b2250da1eeb80c21f14fdf341697501))
* **08-02:** revert repeat() inside lit-virtualizer, restore .renderItem + .keyFunction ([72ef719](https://github.com/onion-4-dinner/yellowjacket/commit/72ef719ba70eeca0fa4bae47df092706f6fbaeed))
* drop+recreate contentless FTS5 index instead of DELETE ([8e9a616](https://github.com/onion-4-dinner/yellowjacket/commit/8e9a61603779eacbee7013b9bc760b315baf782a))
* **frontend:** reposition search indicator into toolbar and fix album cover art lookup ([a29137b](https://github.com/onion-4-dinner/yellowjacket/commit/a29137b2ba4c6b33ce9a5f868cbd6013e0e3b116))
* include full track metadata in GetAudioFilesByReleaseGroup query ([97f256d](https://github.com/onion-4-dinner/yellowjacket/commit/97f256d67f463d752f7adc5b400c4bf34eae1df1))
* **quick-10:** add migration 5 and fix entity cache for composite album key ([d43ba7b](https://github.com/onion-4-dinner/yellowjacket/commit/d43ba7bd0c7ace2a9ed71990a19498f8e9f90751))
* **quick-10:** update release_groups schema and queries for composite uniqueness ([999ab96](https://github.com/onion-4-dinner/yellowjacket/commit/999ab967beb9107a3f30ba287acbffad22f0b0de))
* **quick-13:** resolve lint issues in main source files ([e1a95e6](https://github.com/onion-4-dinner/yellowjacket/commit/e1a95e65a9f0f436b2e2d92befa9c881b6e8e430))
* **quick-14:** add roll-back-on-failure to queue index advancement ([2820de2](https://github.com/onion-4-dinner/yellowjacket/commit/2820de2510560fcd6d1015c18542d5ac30468247))
* **quick-9:** set fixed height on queue track items for stable virtualizer scroll ([ebde5e5](https://github.com/onion-4-dinner/yellowjacket/commit/ebde5e5a8bc4da8f40bef8f171c7ed86c213a336))
### Performance
* **07-01:** add incremental persistence helpers for queue mutations ([cdd17db](https://github.com/onion-4-dinner/yellowjacket/commit/cdd17db27509908514c21517631306655a2b3bd7))
* **07-01:** eliminate redundant lookups in SetQueue Phase 2 ([ced58fe](https://github.com/onion-4-dinner/yellowjacket/commit/ced58fe6a93d6f220137562b8ff09ffc33c69266))
* **07-02:** defer eagerFetch to after DOM ready for instant app shell ([cd98ad6](https://github.com/onion-4-dinner/yellowjacket/commit/cd98ad6dc8c2e4e6e0f01a48099b0c0511bf5a98))
* **08-01:** add queueMicrotask coalescing to library store and debounce search input ([3bf66ed](https://github.com/onion-4-dinner/yellowjacket/commit/3bf66ed125ed55bfbde95b0bc973710c2f2243b8))
* **08-02:** migrate cover-grid, artists-view, and genres-view virtualizers to repeat() directive ([1c3514d](https://github.com/onion-4-dinner/yellowjacket/commit/1c3514da1d0491b9758d7a6f9f72d59ef78fc8ed))
* **08-02:** migrate track-list and queue-panel virtualizers to repeat() directive ([d2d7d8c](https://github.com/onion-4-dinner/yellowjacket/commit/d2d7d8c6ce22923772cae4858b02804d15f74bb7))
* **08-03:** optimize column rendering and apply classMap to queue-panel renderTrackItem ([62f41c2](https://github.com/onion-4-dinner/yellowjacket/commit/62f41c24910632b270f9f5765e20e48db4b95ec9))
* **08-03:** replace class string construction with classMap directive in renderTrackRow ([ad21027](https://github.com/onion-4-dinner/yellowjacket/commit/ad210278fc20729dc76390e6bba9bff050549046))
### Refactoring
* **06-01:** consolidate search queries to use track_metadata VIEW ([9159b40](https://github.com/onion-4-dinner/yellowjacket/commit/9159b409dcd2afaa7dcc97bf5b0694edf85f06a4))
* **quick-14:** make playOrLoadCurrentTrack and playCurrentTrack return bool ([6eeddda](https://github.com/onion-4-dinner/yellowjacket/commit/6eeddda97669258cc5b7ba175a3c98d598a2871f))
## [1.1.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.2...v1.1.3) (2026-02-21)
### Bug Fixes
* add typescript as explicit devDependency and auto-install frontend deps in setup ([#70](https://github.com/onion-4-dinner/yellowjacket/issues/70)) ([7316587](https://github.com/onion-4-dinner/yellowjacket/commit/73165877fa79656ab9bc6f60bd8e9e52d6be206c))
* use local tsc binary in pre-commit hook to avoid PATH issues ([#71](https://github.com/onion-4-dinner/yellowjacket/issues/71)) ([6079e55](https://github.com/onion-4-dinner/yellowjacket/commit/6079e558ff913d38c7f1c4aeb52cc09474c4ed20))
## [1.1.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.1...v1.1.2) (2026-02-15)
### Bug Fixes
* r2 upload ([#69](https://github.com/onion-4-dinner/yellowjacket/issues/69)) ([0252466](https://github.com/onion-4-dinner/yellowjacket/commit/0252466f615b4e2fd9694790c6d311a9eac1ccf2))
## [1.1.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.0...v1.1.1) (2026-02-15)
### Bug Fixes
* **ci:** remove build-check job from CI workflow ([#66](https://github.com/onion-4-dinner/yellowjacket/issues/66)) ([42d3f45](https://github.com/onion-4-dinner/yellowjacket/commit/42d3f45d85afa694e9545997af3ff4ac814ad021))
## [1.1.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.3...v1.1.0) (2026-02-15)
### Features
* **ci:** upload release artifacts to Cloudflare R2 ([#65](https://github.com/onion-4-dinner/yellowjacket/issues/65)) ([8985084](https://github.com/onion-4-dinner/yellowjacket/commit/89850848cbf7783e5c85348ff18f7cd11d60231a))
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-15)
### ⚠ BREAKING CHANGES
* **deps:** update module github.com/evilmartians/lefthook to v2 (#61)
* **deps:** update actions/checkout action to v6 (#45)
* **deps:** update dependency vite to v7 (#53)
### Bug Fixes
* resolve all lint errors and make linting a required CI check ([#62](https://github.com/onion-4-dinner/yellowjacket/issues/62)) ([30b2480](https://github.com/onion-4-dinner/yellowjacket/commit/30b2480df49f57878b0e8c923da6ad8d6fe99416))
* virtual list and cover grid ([#63](https://github.com/onion-4-dinner/yellowjacket/issues/63)) ([7579a76](https://github.com/onion-4-dinner/yellowjacket/commit/7579a768be84225ed46db4e7a90781f3e30e2953))
### Miscellaneous
* **deps:** update actions/checkout action to v6 ([#45](https://github.com/onion-4-dinner/yellowjacket/issues/45)) ([2d6e221](https://github.com/onion-4-dinner/yellowjacket/commit/2d6e22105d2daed1dc5b586c0442e2941949a165))
* **deps:** update dependency vite to v7 ([#53](https://github.com/onion-4-dinner/yellowjacket/issues/53)) ([f0006c4](https://github.com/onion-4-dinner/yellowjacket/commit/f0006c4c4335b60b58cccdd29de4792965e39694))
* **deps:** update module github.com/evilmartians/lefthook to v2 ([#61](https://github.com/onion-4-dinner/yellowjacket/issues/61)) ([e32b217](https://github.com/onion-4-dinner/yellowjacket/commit/e32b2179129ae7f26037697a125710ff7587566d))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### ⚠ BREAKING CHANGES
* **deps:** update actions/setup-node action to v6 (#48)
* **deps:** update dependency stylelint-config-standard to v40 (#52)
* **deps:** update dependency node to v24 (#51)
* **deps:** update dependency vite-plugin-static-copy to v3 (#54)
* **deps:** update golangci/golangci-lint-action action to v9 (#55)
* **deps:** update amannn/action-semantic-pull-request action to v6 (#50)
* **deps:** update actions/upload-artifact action to v6 (#49)
* **deps:** update actions/setup-go action to v6 (#47)
* **deps:** update actions/download-artifact action to v7 (#46)
### Bug Fixes
* **ci:** use allowedPostUpgradeCommands for Renovate post-upgrade tasks ([#60](https://github.com/onion-4-dinner/yellowjacket/issues/60)) ([0aef483](https://github.com/onion-4-dinner/yellowjacket/commit/0aef483b3cccd0616fd5be2d06d0856b46851d09))
### Miscellaneous
* **deps:** update actions/download-artifact action to v7 ([#46](https://github.com/onion-4-dinner/yellowjacket/issues/46)) ([1910f99](https://github.com/onion-4-dinner/yellowjacket/commit/1910f99cf64e9bdc5ce91e89cab254ecca15d030))
* **deps:** update actions/setup-go action to v6 ([#47](https://github.com/onion-4-dinner/yellowjacket/issues/47)) ([8911fb2](https://github.com/onion-4-dinner/yellowjacket/commit/8911fb2400047cf2f3dfa719edc1d1bf474cdaa5))
* **deps:** update actions/setup-node action to v6 ([#48](https://github.com/onion-4-dinner/yellowjacket/issues/48)) ([d7382fd](https://github.com/onion-4-dinner/yellowjacket/commit/d7382fd8444b6618dbfe991f5f97231528a07f13))
* **deps:** update actions/upload-artifact action to v6 ([#49](https://github.com/onion-4-dinner/yellowjacket/issues/49)) ([a2c644b](https://github.com/onion-4-dinner/yellowjacket/commit/a2c644b00eed83acc0ed38a2eb8c73868b7b79af))
* **deps:** update amannn/action-semantic-pull-request action to v6 ([#50](https://github.com/onion-4-dinner/yellowjacket/issues/50)) ([643ba27](https://github.com/onion-4-dinner/yellowjacket/commit/643ba27f066164aeb47e8d9aaf20fe98b9b69d30))
* **deps:** update dependency node to v24 ([#51](https://github.com/onion-4-dinner/yellowjacket/issues/51)) ([e7d3971](https://github.com/onion-4-dinner/yellowjacket/commit/e7d39711078ce86b0c029f0d03ff81162c5dc28a))
* **deps:** update dependency stylelint-config-standard to v40 ([#52](https://github.com/onion-4-dinner/yellowjacket/issues/52)) ([422aabc](https://github.com/onion-4-dinner/yellowjacket/commit/422aabcc07e9700ff189302b363e13d87c69163a))
* **deps:** update dependency vite-plugin-static-copy to v3 ([#54](https://github.com/onion-4-dinner/yellowjacket/issues/54)) ([77fa643](https://github.com/onion-4-dinner/yellowjacket/commit/77fa6435a5298f58ef83607d99c59b876132c66c))
* **deps:** update golangci/golangci-lint-action action to v9 ([#55](https://github.com/onion-4-dinner/yellowjacket/issues/55)) ([aedb7d1](https://github.com/onion-4-dinner/yellowjacket/commit/aedb7d1e6d204c56c468dd26b340752fd6bfeaeb))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* resolve Renovate repo detection and pre-push hook hang ([#36](https://github.com/onion-4-dinner/yellowjacket/issues/36)) ([b205889](https://github.com/onion-4-dinner/yellowjacket/commit/b205889128f01e9eb75b607cf7c4034887cda3f4))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
* rename downloaded artifacts to platform-specific names for release ([e3bda0e](https://github.com/onion-4-dinner/yellowjacket/commit/e3bda0e2fc7700fad382cabe00aeb46f91fbb0a0))
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-14)
### Bug Fixes
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### Bug Fixes
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
### Bug Fixes
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
### Bug Fixes
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
### Bug Fixes
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
## 1.0.0 (2026-02-14)
### Features
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
+12 -171
View File
@@ -263,29 +263,6 @@ rather than renaming them.
plan 013's stated "delete and rescan" — and `Derived` from Owned. plan 013's stated "delete and rescan" — and `Derived` from Owned.
A table the schema no longer describes at all goes too; 013 left A table the schema no longer describes at all goes too; 013 left
seven behind plus `schema_migrations`. seven behind plus `schema_migrations`.
- **Whether a stale `Cache` table may be rebuilt is a build tag**, and
it is the most expensive thing in this file to get wrong. In the app
the catalog is *downloaded*, so a wrong shape costs a minute of
re-fetching the artifact and keeping it costs every Explore read. In
`cmd/indexbuild` the catalog is *derived*, and the only way back is
the ~205 GB dump stream the `/cache` volume exists to avoid — so
`retireStaleCache` is false there (`staleshape_policy_indexbuild.go`)
and `TestTheCatalogSurvivesAStaleShape` fails the moment it is not.
`TestNoCacheTableIsRetiredHere` is the same assertion made of *every*
`datamap` Cache table rather than one, because the risk is not that
shape recurring — it is the next destructive repair added to
`database.NewDB`, the chokepoint every binary here shares, without
asking which binary it is in.
This is written down because it already happened: the repair shipped
without the distinction and dropped the real CI catalog on its first
run, with `reason="column entity_type is TEXT, schema declares
INTEGER"`. The mismatch was genuine — that database is deliberately
kept in the older encoding, which `fix(indexexport): read an index
older than the binary` exists to tolerate — so it would have been
dropped on *every* run. The consequence is that a future
`explore_index` column fails the index job loudly on `applySchema`
rather than silently costing it a rebuild, which is the trade a
human should get to make.
- **The drops are one transaction with `defer_foreign_keys`.** Those - **The drops are one transaction with `defer_foreign_keys`.** Those
legacy tables reference each other, so dropping them in any order legacy tables reference each other, so dropping them in any order
fails on whichever goes first, and turning foreign keys *off* fails on whichever goes first, and turning foreign keys *off*
@@ -576,56 +553,6 @@ selected as a literal `0`. Adding the column to the importer's SELECT
list without that is how a published artifact — which nobody can re-cut list without that is how a published artifact — which nobody can re-cut
retroactively — starts failing with `no such column`. retroactively — starts failing with `no such column`.
**A credit is ordered parts, and the string is derived from them.** A
track credited to several artists had exactly one navigable artist and
the rest were punctuation: `primaryArtist()` string-parses the credit,
strips a " feat. " clause and discards the guest, and deliberately does
not split on `&`, `with` or `,` because those live inside real artist
names ("Simon & Garfunkel"). Measured on a real 26,069-file library,
**13%** of recordings are multi-artist upstream while only **0.86%** of
files carry a structured multi-artist tag — mp3 carries *zero* files
with multiple `MUSICBRAINZ_ARTISTID` across 19,840 — so this cannot be
a tag-parsing feature. (The "3 credits of 2,823" figure that justified
plan 013's removal of the credit tables measured our own *writer*:
`cachedLinkArtist` ran once per credit, so a collaboration could never
have been recorded. Dropping the join table was still right on cost.)
`artist_credit_part` / `artist_credit_ref` carry the decomposition for
multi-artist credits only — a single-artist credit is already
`explore_index`'s own `artist_name`, and storing those would triple the
table to say nothing. Five things about it are load-bearing:
- **Join phrases are assembly instructions, not disassembly ones.**
`creditLink` concatenates parts, so link boundaries are known by
construction. Locating a `credited_name` *inside* the stored credit
string would reintroduce the fault this exists to fix: that string may
come from the file's tags while the parts come from the catalog, and
the two disagree for ~1 in 3 multi-artist credits (`'Skrillex feat.
Swae Lee'` tagged against `'Skrillex & Swae Lee'` upstream).
- **`credited_name` is stored per row**, never joined from `artists`:
MusicBrainz credits "Snoop Dogg" on a track by the artist called
"Snoop Doggy Dogg". Display follows the credit, navigation the MBID.
- **The lookup is keyed on the recording MBID**, which the catalog and a
local file both carry (`library.Track.RecordingMBID`), so one binding
serves Explore and the library's own lists — which is why this needed
no local table. `file_artists` remains the offline-resilience step and
is deliberately *not* declared until something writes it.
- **Absence is cached as an answer.** `credit-store.ts` stores `[]` for
a single-artist credit — *asked*, not *answered* — or the ~87% that
have nothing to decompose are re-requested on every render forever.
`request()` is per-row and coalesces into one call per frame, because
a virtualized list cannot hand over "the whole list": 50,000 rows is
100 queries for the ~30 on screen.
- **The dump is a third source, and it had to be.** The canonical dump
CI already streams has no join phrases and no as-credited names, and
the JSON dumps cover 153,691 recordings of ~35M with *zero* overlap
against a real library. So `mbdump.tar.bz2` — 7.1 GB, ~13.7 min in
pure-Go bzip2, whose members are alphabetical, which is what lets one
pass resolve an entity's credit without buffering 35M recordings. The
pass runs on **every** mode, because a complete import means
`refresh`, which never enters the importer at all, and it reports
whether it populated anything so `changed` republishes the artifact.
**A 0.6 GB download asks about the connection first.** `explore`'s **A 0.6 GB download asks about the connection first.** `explore`'s
catalog artifact had no network awareness at all, which on a phone is a catalog artifact had no network awareness at all, which on a phone is a
month's data allowance spent without being asked (plan 016 B4). month's data allowance spent without being asked (plan 016 B4).
@@ -2027,31 +1954,13 @@ Pre-commit hooks verify generated code is fresh — always run `make generate` a
two in step or semantic-release will decline to release something the two in step or semantic-release will decline to release something the
check accepted. check accepted.
`.releaserc.yml` **is** what runs now, from `release.yml`, and it is why `.releaserc.yml` is a complete semantic-release config that **nothing
the commit grammar is load-bearing rather than decorative: a merge to currently runs** — no workflow invokes it, and `CHANGELOG.md` is not
`main` whose commits are all `chore`/`ci`/`docs` releases nothing, and a being written by it. That is deliberate for now (wiring it means pushing
mistyped `feat` ships a minor version. `make release-dry` answers "what tags, committing a changelog back, and interacting with the three
would this merge release" without pushing. publish workflows); it is recorded here rather than implied, because
this file claimed for five phases that commitlint gated CI and that
**`@semantic-release/github` is not in that config and must not be.** semantic release ran, and neither was true.
Gitea's API is `/api/v1` and is not GitHub's surface, so
`@semantic-release/exec` calls `scripts/gitea-release.sh` instead — one
`POST`, which is the whole of the Gitea-shaped work. The community
plugin (`@saithodev/semantic-release-gitea`) was considered and
rejected: last published 2022, on `got@10`, declaring no peer
dependency on semantic-release at all.
Two things in it fail *silently* and are therefore pinned with their
reasons. **The notes come from `CHANGELOG.md`, not from an argument**:
release notes are rendered commit messages — arbitrary text carrying
backticks, quotes and `$` — so templating `${nextRelease.notes}` into
`publishCmd` would be a shell injection whose input is the commit log.
And **`conventional-changelog-conventionalcommits` is held at 9**,
because at 10 it is quietly incompatible with the writer
`release-notes-generator@14` pulls in: every release note renders as a
bare `## 0.0.1 (date)` heading with no sections and no commits beneath
it, no step fails, and the release ships with an empty body. Check the
rendered notes, never the exit code.
## Testing ## Testing
@@ -2060,85 +1969,17 @@ Tests use `database.NewTestDB(t)` for in-memory SQLite, built by the same
## Git Workflow ## Git Workflow
Feature branches and PRs are the only way in: **`main` is a protected Feature branches and PRs are the norm, but direct pushes to `main` are allowed. Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
branch** (`enable_push: false`, an empty push whitelist, and `CI / check*`
+ `CI / e2e*` as required status checks), so a direct push is rejected by
the pre-receive hook. This file said otherwise for a long time. Tags are
*not* protected, which is what lets `release.yml` push one.
Pre-commit runs vet, lint, codegen check, and frontend typecheck in parallel. Pre-push runs the full test suite.
## CI ## CI
Seven workflows in `.gitea/workflows/`. Five of them package and Five workflows in `.gitea/workflows/`. Four of them package and
publish (`arch-package`, `homebrew-formula`, `index-artifact`, publish (`arch-package`, `homebrew-formula`, `index-artifact`,
`android-apk`, `desktop-assets`); `release.yml` decides *whether* four of `android-apk`); only `ci.yml` gates, and it is the one to look at when
those run at all; only `ci.yml` gates, and it is the one to look at when
deciding whether a push was healthy. deciding whether a push was healthy.
**`release.yml` is the entry point for all of it.** On every push to **`android-apk.yml` is the only one keyed on a tag and the only one
`main` it reads the Conventional Commits since the last tag and, if any that can lose something irrecoverable.** It builds the signed
is releasable, writes the changelog, pushes the tag and creates the Gitea
release whose body is that changelog section. `arch-package`,
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
`v*`, so **the tag push is what starts them** — nothing is released by
hand any more.
Four things about it are load-bearing:
- **The tag is pushed with a user PAT, not the Actions token.** Gitea,
like GitHub, does not start a workflow from a ref pushed by a
workflow's own token (go-gitea#33123). The token is what decides this,
so `PACKAGE_TOKEN` is handed to semantic-release as the
`repositoryUrl` credential and the push is attributed to a person.
- **That same limitation is used deliberately, once.** semantic-release
calls the first release of a tagless repo `1.0.0` and offers no way to
say otherwise, so a `v0.0.0` floor tag is what makes the first release
`0.0.1` — and it is pushed with the *Actions* token precisely so it
triggers nothing. All four publishers additionally skip `v0.0.0`
explicitly, cleanly rather than by failing, because a floor is not a
shipment.
- **The release page is the changelog, and that follows from the branch
protection.** `@semantic-release/git` would push a `chore(release):`
commit back to `main`, which the pre-receive hook rejects — *after* the
tag had been pushed, leaving a tagged release the run then reports as
failed. Whitelisting the CI user was the alternative and was declined:
it weakens a protection someone set on purpose and lets a bot push to
`main` without the checks every human PR passes. So the plugin is
absent, `@semantic-release/changelog` writes to a gitignored
`.release-notes.md` purely to carry the notes into
`scripts/gitea-release.sh`, and `CHANGELOG.md` is a signpost to the
releases page rather than a file that would silently stop updating.
The workflow keeps its `chore(release):` guard anyway, for the day
someone adds the plugin back.
- **An asset upload waits for the release to exist.** semantic-release
pushes the tag in `prepare` and creates the release in `publish`, so
the tag push that starts these workflows happens *before* there is a
release id to attach to. `scripts/release-asset.sh` polls for it. The
capacity-1 runner serialises things enough that this would usually work
by accident, which is the worst kind of bug.
**Releases restarted at `0.0.1`, which is a downgrade on every channel.**
pacman and Homebrew both silently offer no upgrade from the old `1.x`,
and Android refuses the install outright — its remedy is an uninstall
that takes the user's library. This was chosen over pacman's `epoch` and
over offsetting `versionCode`, on the grounds that both are permanent and
a reinstall is once. `packaging/homebrew/README.md` and
`docs/android-release.md` say so where a user would look.
**`desktop-assets.yml` publishes Linux and nothing else, and macOS is not
an oversight.** `GOOS=darwin CGO_ENABLED=0` fails at
`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin
backend is Objective-C behind cgo, so a `.app` needs a macOS host and the
runner is a Linux container. That is exactly why the Homebrew formula
builds from source on the user's own Mac. Windows *does* cross-compile
cleanly (`GOOS=windows CGO_ENABLED=0`, a couple of seconds — oto uses
WinMM through `x/sys`, sqlite is modernc's pure-Go driver, WebView2 is
COM syscalls, MPRIS is `linux && !android`-tagged) and is deliberately
not published: no Windows build of this app has ever been *run*, and no
tier here can exercise one.
**`android-apk.yml` is the one that can lose something irrecoverable.** It builds the signed
`arm64-v8a` APK (the only ABI Android can run this app on — see `arm64-v8a` APK (the only ABI Android can run this app on — see
`app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is `app/build.gradle`) on every `v*` tag and publishes it to the *generic* registry, which is
readable without credentials — the reason Obtainium can poll a plain readable without credentials — the reason Obtainium can poll a plain
+5 -25
View File
@@ -179,12 +179,10 @@ bindings-check: ## Fail if the generated bindings are stale
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
@cd frontend && node scripts/check-css-literals.mjs @cd frontend && node scripts/check-css-literals.mjs
# .pi/ and CLAUDE.md document commands, and a doc that documents a # .pi/ documents commands, and a skill that documents a command wrongly
# command wrongly is worse than no doc: an agent runs it confidently. # is worse than no skill: an agent runs it confidently. Every command
# Every command in them is a make target on purpose, so this is # in there is a make target on purpose, so this is checkable.
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the skill-check: ## Fail if .pi/ documents a make target that does not exist
# two harnesses cannot drift onto two descriptions of one project.
skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md is not a symlink
@./scripts/skill-check.sh @./scripts/skill-check.sh
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long # Conventional Commits, which CLAUDE.md claimed CI enforced for a long
@@ -192,24 +190,6 @@ 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 commit-check: ## Fail if a commit subject is not a Conventional Commit
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE)) @./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
# What a merge to main would release, without releasing it. Reads the
# same .releaserc.yml CI does, so "why did that not cut a version" is
# answerable locally instead of by pushing and watching. Needs no
# credentials: --dry-run neither tags nor publishes.
#
# The pins must stay identical to release.yml's, which is where the note
# on holding the conventionalcommits preset at 9 lives -- at 10 the
# release notes come out empty with everything green.
release-dry: ## Print the version a merge to main would release
@npx --yes \
-p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \
-p @semantic-release/release-notes-generator@14 \
-p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \
semantic-release --dry-run --no-ci
# v3 generates TypeScript into frontend/bindings/, nested by Go import # v3 generates TypeScript into frontend/bindings/, nested by Go import
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the # path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
# constant prefix, so a call site imports '@go/library/library.js'. # constant prefix, so a call site imports '@go/library/library.js'.
@@ -225,7 +205,7 @@ bindings: ## Regenerate frontend/bindings from the bound Go services
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \ sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
perf perf-compare \ perf perf-compare \
ui-test ui-watch ui-visual ui-visual-update ui-setup \ ui-test ui-watch ui-visual ui-visual-update ui-setup \
bindings bindings-check skill-check commit-check release-dry bindings bindings-check skill-check commit-check
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR: # Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so # on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
+2 -3
View File
@@ -411,10 +411,9 @@ func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error
formats = append(formats, string(f)) formats = append(formats, string(f))
} }
c.Downloads.MinKbps = prefs.MinKbps c.Downloads.MinFileSizeMB = prefs.MinSizeMB
c.Downloads.MaxKbps = prefs.MaxKbps
c.Downloads.PreferredKbps = prefs.PreferredKbps
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
c.Downloads.AllowedFormats = formats c.Downloads.AllowedFormats = formats
if err := c.Save(); err != nil { if err := c.Save(); err != nil {
+11 -28
View File
@@ -34,29 +34,13 @@ type UserConfig struct {
// in one burst that every provider sees as a flood. // in one burst that every provider sees as a flood.
WantedBatch int `toml:"WantedBatch"` WantedBatch int `toml:"WantedBatch"`
// MinKbps, MaxKbps and PreferredKbps bound and nudge what auto-pick // MinFileSizeMB, MaxFileSizeMB and PreferredFileSizeMB bound and
// (interactive or via the request list) may grab without asking. // nudge what auto-pick (interactive or via the request list) may
// Zero on any of them is permissive: see AutoDownloadPrefs. // grab without asking. Zero on any of them is permissive: see
// // AutoDownloadPrefs.
// They replaced MinFileSizeMB / MaxFileSizeMB / MinFileSizeMB int `toml:"MinFileSizeMB"`
// PreferredFileSizeMB, which were megabytes and so said nothing MaxFileSizeMB int `toml:"MaxFileSizeMB"`
// without knowing how long the release was. The old keys are PreferredFileSizeMB int `toml:"PreferredFileSizeMB"`
// deliberately *not* read back: a number that meant "300 MB" cannot
// be reinterpreted as a bitrate without knowing the album it was
// aimed at, so migrating it would be inventing an intent the user
// never expressed. An existing config falls back to no window,
// which is the permissive default and matches a fresh install —
// and MaxFileSizeMB is the one that does carry over, because a
// ceiling on total bytes still means exactly what it did.
MinKbps int `toml:"MinKbps"`
MaxKbps int `toml:"MaxKbps"`
PreferredKbps int `toml:"PreferredKbps"`
// MaxFileSizeMB is a hard ceiling on a candidate's total size, kept
// in megabytes on purpose — it is a question about disk space, not
// about quality, and it has to apply to a candidate whose bitrate
// cannot be worked out at all.
MaxFileSizeMB int `toml:"MaxFileSizeMB"`
// AllowedFormats restricts auto-pick to these formats. Empty means // AllowedFormats restricts auto-pick to these formats. Empty means
// no restriction. Values are Format strings ("flac", "mp3", ...). // no restriction. Values are Format strings ("flac", "mp3", ...).
@@ -72,11 +56,10 @@ func (c *UserConfig) AutoDownloadPrefs() AutoDownloadPrefs {
} }
return AutoDownloadPrefs{ return AutoDownloadPrefs{
MinKbps: c.MinKbps, MinSizeMB: c.MinFileSizeMB,
MaxKbps: c.MaxKbps, MaxSizeMB: c.MaxFileSizeMB,
PreferredKbps: c.PreferredKbps, PreferredSizeMB: c.PreferredFileSizeMB,
MaxSizeMB: c.MaxFileSizeMB, AllowedFormats: formats,
AllowedFormats: formats,
} }
} }
+10 -8
View File
@@ -236,12 +236,6 @@ func (m *Manager) AutoPickable(dl Download, ranked []Candidate) bool {
return AutoPickable(dl, ranked, m.preferences()) return AutoPickable(dl, ranked, m.preferences())
} }
// AutoPickVeto wraps the package function the same way, and is what the
// request list quotes back to the user.
func (m *Manager) AutoPickVeto(dl Download, ranked []Candidate) string {
return AutoPickVeto(dl, ranked, m.preferences())
}
// Reload rebuilds every provider from stored config. Called at startup // Reload rebuilds every provider from stored config. Called at startup
// and after any provider settings change. // and after any provider settings change.
// //
@@ -618,8 +612,16 @@ func (m *Manager) Attempt(
return false, "", err return false, "", err
} }
if veto := m.AutoPickVeto(dl, ranked); veto != "" { if !m.AutoPickable(dl, ranked) {
return false, veto, nil best := ranked[0]
return false, fmt.Sprintf(
"best of %d found is not a confident enough match "+
"(match %.0f%%, quality %.0f%%)",
len(ranked),
best.Match.Overall*100, //nolint:mnd // percent
best.Quality.Overall*100,
), nil
} }
if err := m.store.CreateDownload(ctx, dl); err != nil { if err := m.store.CreateDownload(ctx, dl); err != nil {
+6 -44
View File
@@ -218,17 +218,8 @@ func TestManagerEndToEndAutoPick(t *testing.T) {
}, "staging was never released, or the library was never rescanned") }, "staging was never released, or the library was never rescanned")
} }
// Two equally good copies are not an ambiguity — they are a spare. // An ambiguous result set must park for the user rather than guess.
// func TestManagerWaitsWhenAmbiguous(t *testing.T) {
// This asserted the opposite for as long as auto-pick required 0.08 of
// daylight over the runner-up, and that rule was wrong in exactly the
// case it fired hardest: a popular album turns up several *correct*
// copies, all matching the tracklist, differing only in format and
// seeders. There is no question there about what to fetch, only about
// which copy, and the ranking already answers that — closest to the
// preferred bitrate first. A candidate does not have to be better than
// the field, only good enough on its own terms.
func TestManagerAutoPicksAmongEquallyGoodCopies(t *testing.T) {
t.Parallel() t.Parallel()
f := newManagerFixture(t) f := newManagerFixture(t)
@@ -246,41 +237,11 @@ func TestManagerAutoPicksAmongEquallyGoodCopies(t *testing.T) {
t.Fatalf("Start: %v", err) t.Fatalf("Start: %v", err)
} }
if veto := f.manager.AutoPickVeto(dl, ranked); veto != "" { if f.manager.AutoPickable(dl, ranked) {
t.Fatalf("two equally good copies must auto-pick, got veto: %s", veto) t.Fatal("two equivalent candidates must not auto-pick")
}
waitForDownloadState(t, f.store, dl.ID, StateComplete)
// Exactly one of them was fetched, not both.
if grabs := a.GrabCalls + b.GrabCalls; grabs != 1 {
t.Errorf("grabs = %d, want exactly 1", grabs)
}
}
// The user can still pick explicitly when auto-pick is not what
// happened — a candidate the ranking did not choose is still grabbable.
func TestManagerPickIsExplicit(t *testing.T) {
t.Parallel()
f := newManagerFixture(t)
a := fakeWithAlbum(1, "source-a", ".flac")
b := fakeWithAlbum(2, "source-b", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, a)
f.manager.installProvider(Config{ID: 2, Priority: 50}, b)
// No tracklist: never auto-picks, so the result set parks for the
// user and Pick is the only way anything is fetched.
dl := fourTrackDownload()
dl.Expected = nil
ranked, err := f.manager.Start(context.Background(), dl)
if err != nil {
t.Fatalf("Start: %v", err)
} }
// Nothing was grabbed while waiting for the user.
if a.GrabCalls != 0 || b.GrabCalls != 0 { if a.GrabCalls != 0 || b.GrabCalls != 0 {
t.Errorf( t.Errorf(
"grabs happened without a pick: a=%d b=%d", "grabs happened without a pick: a=%d b=%d",
@@ -297,6 +258,7 @@ func TestManagerPickIsExplicit(t *testing.T) {
t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID) t.Errorf("stored request id = %s, want %s", stored.ID, dl.ID)
} }
// The user picks the second one explicitly.
if err := f.manager.Pick( if err := f.manager.Pick(
context.Background(), dl.ID, ranked[1].ID, context.Background(), dl.ID, ranked[1].ID,
); err != nil { ); err != nil {
+75 -311
View File
@@ -1,7 +1,6 @@
package download package download
import ( import (
"fmt"
"math" "math"
"sort" "sort"
"strings" "strings"
@@ -35,103 +34,39 @@ const (
weightArtistFit = 0.12 weightArtistFit = 0.12
) )
// Quality sub-weights. Each set sums to 1.0. // Quality sub-weights. They sum to 1.0 along with weightSizeFit below.
//
// There are two of them because a stated preference changes what the
// other numbers are *for*. `formatRank` and `bitrateScore` are the
// app guessing at how good a copy is — FLAC over MP3, 320 over 128 —
// and that guess exists precisely because the user has not said. Once
// they have, the guess should not outvote them: with the old single set
// a preference of 320 kbps moved a candidate's score by at most 0.05
// against the 0.42 riding on format, so asking for 320 and being handed
// a FLAC every time was the *designed* behaviour. That is the same
// fault the megabyte window had — a preference the user can express and
// the ranking can ignore.
const ( const (
weightFormat = 0.42 weightFormat = 0.42
weightBitrate = 0.23 weightBitrate = 0.23
weightHealth = 0.20 weightHealth = 0.20
weightPriority = 0.10 weightPriority = 0.10
weightBitrateFit = 0.05 weightSizeFit = 0.05
) )
// Quality sub-weights when the user has named a preferred bitrate.
// The weight comes off format and bitrate — the two proxies the
// preference replaces — and health and priority are untouched, since
// neither is a stand-in for anything the user just said.
const (
statedWeightFormat = 0.20
statedWeightBitrate = 0.10
statedWeightHealth = 0.20
statedWeightPriority = 0.10
statedWeightBitrateFit = 0.40
)
// qualityWeights picks the set, in the order scoreQuality applies them.
func qualityWeights(p AutoDownloadPrefs) (
format, bitrate, health, priority, fit float64,
) {
if p.PreferredKbps > 0 {
return statedWeightFormat,
statedWeightBitrate,
statedWeightHealth,
statedWeightPriority,
statedWeightBitrateFit
}
return weightFormat,
weightBitrate,
weightHealth,
weightPriority,
weightBitrateFit
}
// unanchoredCap bounds the match score of a free-text request. Without // unanchoredCap bounds the match score of a free-text request. Without
// an MBID there is no tracklist to be right about, so a confident- // an MBID there is no tracklist to be right about, so a confident-
// looking score would be a lie — and auto-pick keys off this. // looking score would be a lie — and auto-pick keys off this.
const unanchoredCap = 0.65 const unanchoredCap = 0.65
// AutoDownloadPrefs gates and scores what AutoPickable may choose // AutoDownloadPrefs gates and scores what AutoPickable may choose
// without asking. Zero values are permissive: no bitrate window, no // without asking. Zero values are permissive: no size window and no
// size ceiling and no format restriction. // format restriction.
//
// **The window is a rate, not a size.** It used to be three numbers in
// megabytes, which cannot mean anything on their own: 300 MB is a
// generous FLAC single and a suspiciously small boxset, and the user
// setting the number has no idea which release the pipeline will
// eventually apply it to. A bitrate is the same statement normalised
// by how long the music is, so one number holds across a 9-minute EP
// and a 3-hour opera — and it is the unit the thing being described is
// actually measured in. The runtime is known for every request
// auto-pick can act on (`Download.Expected` carries per-track lengths,
// and an anchored request is the only kind that reaches here), so this
// costs no extra lookup.
type AutoDownloadPrefs struct { type AutoDownloadPrefs struct {
// MinKbps and MaxKbps bound the average bitrate auto-pick will // MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
// grab. Zero means no bound on that side. A candidate outside the // means no bound on that side. A candidate outside the window is
// window is filtered out of auto-pick entirely, not merely scored // filtered out of auto-pick entirely, not merely scored down — a
// down — a 96 kbps rip of the right album is not a worse copy the // tiny "sampler" torrent or a boxset ten times the expected size is
// user might accept, it is one they said not to take unattended. // usually the wrong thing entirely, not a worse copy of the right
// // thing.
// For reference: 320 is the top of MP3, ~500–1000 is FLAC depending MinSizeMB int `json:"minSizeMb"`
// on the material, and anything under ~128 is a transcode.
MinKbps int `json:"minKbps"`
MaxKbps int `json:"maxKbps"`
// PreferredKbps nudges the score toward a target rate within the
// window, and breaks the tie when several candidates are equally
// good matches. Zero disables the nudge; bitrateFit then returns a
// neutral value that does not affect ranking.
PreferredKbps int `json:"preferredKbps"`
// MaxSizeMB is a hard ceiling on the whole candidate, and it is
// deliberately still a size. It answers a different question from
// the window above — not "is this the quality I want" but "is this
// going to fill the disk" — and it has to hold even for a candidate
// whose bitrate cannot be worked out, which is exactly the shape a
// mislabelled boxset arrives in. Zero means no ceiling.
MaxSizeMB int `json:"maxSizeMb"` MaxSizeMB int `json:"maxSizeMb"`
// PreferredSizeMB nudges the score toward a target size within the
// min/max window (a lossless rip and a heavily-padded lossless rip
// can both pass the window). Zero disables the nudge; sizeFit then
// returns a neutral value that does not affect ranking.
PreferredSizeMB int `json:"preferredSizeMb"`
// AllowedFormats restricts auto-pick to candidates whose audio // AllowedFormats restricts auto-pick to candidates whose audio
// files are all in one of these formats. Empty means no // files are all in one of these formats. Empty means no
// restriction. // restriction.
@@ -139,31 +74,17 @@ type AutoDownloadPrefs struct {
} }
// eligible reports whether a candidate may be auto-picked under these // eligible reports whether a candidate may be auto-picked under these
// preferences: inside the bitrate window and the size ceiling (when // preferences: within the size window (when set) and, when a format
// set) and, when a format list is given, every audio file in an // list is given, every audio file in an allowed format.
// allowed format. func (p AutoDownloadPrefs) eligible(c Candidate) bool {
//
// `runtimeMillis` is how long the requested release is, and 0 means
// nobody knows. An unknown runtime **passes** the bitrate window
// rather than failing it: the window is a statement about quality, and
// refusing everything the moment a tracklist is missing a length would
// turn a gap in MusicBrainz into a silent embargo. The size ceiling
// still applies, which is why it exists separately.
func (p AutoDownloadPrefs) eligible(c Candidate, runtimeMillis int64) bool {
const bytesPerMB = 1 << 20 const bytesPerMB = 1 << 20
if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB { if p.MinSizeMB > 0 && c.TotalSize < int64(p.MinSizeMB)*bytesPerMB {
return false return false
} }
if kbps := candidateKbps(c, runtimeMillis); kbps > 0 { if p.MaxSizeMB > 0 && c.TotalSize > int64(p.MaxSizeMB)*bytesPerMB {
if p.MinKbps > 0 && kbps < float64(p.MinKbps) { return false
return false
}
if p.MaxKbps > 0 && kbps > float64(p.MaxKbps) {
return false
}
} }
if len(p.AllowedFormats) == 0 { if len(p.AllowedFormats) == 0 {
@@ -186,14 +107,11 @@ func (p AutoDownloadPrefs) eligible(c Candidate, runtimeMillis int64) bool {
// filter returns only the candidates these preferences allow to be // filter returns only the candidates these preferences allow to be
// auto-picked, in the same (already ranked) order. // auto-picked, in the same (already ranked) order.
func (p AutoDownloadPrefs) filter( func (p AutoDownloadPrefs) filter(ranked []Candidate) []Candidate {
ranked []Candidate,
runtimeMillis int64,
) []Candidate {
out := make([]Candidate, 0, len(ranked)) out := make([]Candidate, 0, len(ranked))
for _, c := range ranked { for _, c := range ranked {
if p.eligible(c, runtimeMillis) { if p.eligible(c) {
out = append(out, c) out = append(out, c)
} }
} }
@@ -201,116 +119,32 @@ func (p AutoDownloadPrefs) filter(
return out return out
} }
// bitrateFit scores how close a candidate's average bitrate is to // sizeFit scores how close totalSize is to PreferredSizeMB, 0..1,
// PreferredKbps, falling off linearly as it doubles or halves away // falling off linearly as the size doubles or halves away from it.
// from it. // Returns a neutral 0.5 when no preference is set, so the absence of a
// // preference does not bias ranking.
// The range is **0.5 to 1.0, not 0 to 1**, and the floor is the point. func (p AutoDownloadPrefs) sizeFit(totalSize int64) float64 {
// This carries 0.40 of the quality score once a preference is set, so a
// span down to zero would let a preference of 320 kbps push a perfectly
// good FLAC under `minQuality` and out of auto-pick altogether —
// turning "I like 320" into "never take anything else", silently. A
// preference may promote the copy that matches it; it may not
// disqualify the others. That is what `MinKbps`/`MaxKbps` are for, and
// they say so out loud.
//
// Returns the neutral floor when no preference is set or the rate
// cannot be worked out, so neither an absent preference nor an absent
// runtime biases ranking.
func (p AutoDownloadPrefs) bitrateFit(
c Candidate,
runtimeMillis int64,
) float64 {
const ( const (
neutral = 0.5 bytesPerMB = 1 << 20
span = 0.5 neutral = 0.5
) )
if p.PreferredKbps <= 0 { if p.PreferredSizeMB <= 0 || totalSize <= 0 {
return neutral return neutral
} }
kbps := candidateKbps(c, runtimeMillis) preferred := float64(p.PreferredSizeMB) * bytesPerMB
if kbps <= 0 { ratio := float64(totalSize) / preferred
return neutral
}
ratio := kbps / float64(p.PreferredKbps)
if ratio < 1 { if ratio < 1 {
ratio = 1 / ratio ratio = 1 / ratio
} }
// ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half // ratio is now >= 1: 1.0 is an exact match, 2.0 is double or half
// the preferred rate, where the closeness term reaches 0. // the preferred size. Falls to 0 at 2x away and beyond.
return neutral + span*clamp01(1-(ratio-1)) fit := 1 - (ratio - 1)
}
// candidateKbps is a candidate's average audio bitrate, or 0 when it return clamp01(fit)
// cannot be worked out.
//
// Two sources, in this order, and the order matters:
//
// - **Derived from bytes over runtime**, which is the honest one. It
// covers lossless (where a stated bitrate rarely exists), it cannot
// be lied to by a filename, and it is what the user's window means.
// Only the *audio* files count: cover scans and a log file are not
// part of the bitrate, and a folder with 30 MB of artwork would
// otherwise read as a better rip than the same music without it.
// - **The mean stated bitrate**, when the runtime is unknown. Weaker
// — a provider that parses it from an MP3 header states it and one
// that guesses from the filename also "states" it — but a number
// from the file itself beats no number at all.
func candidateKbps(c Candidate, runtimeMillis int64) float64 {
const bitsPerByte = 8
audio := c.AudioFiles()
if len(audio) == 0 {
return 0
}
if runtimeMillis > 0 {
var bytes int64
for _, f := range audio {
bytes += f.Size
}
if bytes > 0 {
// bytes×8 bits over seconds, expressed in kbps: the two
// factors of 1000 (millis→seconds, bits→kilobits) cancel.
return float64(bytes) * bitsPerByte /
float64(runtimeMillis)
}
}
var (
sum int
count int
)
for _, f := range audio {
if f.Bitrate > 0 {
sum += f.Bitrate
count++
}
}
if count == 0 {
return 0
}
return float64(sum) / float64(count)
}
// runtimeMillis is how long the requested release is, summed over its
// expected tracklist. Zero when the tracklist is absent or carries no
// lengths, which is what every caller here treats as "unknown".
func (d Download) runtimeMillis() int64 {
var total int64
for _, t := range d.Expected {
total += t.LengthMillis
}
return total
} }
// Score fills a candidate's Match, Quality and Score fields. // Score fills a candidate's Match, Quality and Score fields.
@@ -326,9 +160,7 @@ func Score(dl Download, c Candidate, priority int, prefs AutoDownloadPrefs) Cand
c.Files = mergeMatched(c.Files, matched) c.Files = mergeMatched(c.Files, matched)
c.Match = scoreMatch(dl, c, audio, titleFit) c.Match = scoreMatch(dl, c, audio, titleFit)
c.Quality = scoreQuality( c.Quality = scoreQuality(c, audio, priority, prefs)
c, audio, priority, prefs, dl.runtimeMillis(),
)
c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall c.Score = weightMatch*c.Match.Overall + weightQuality*c.Quality.Overall
@@ -447,12 +279,11 @@ func scoreQuality(
audio []CandidateFile, audio []CandidateFile,
priority int, priority int,
prefs AutoDownloadPrefs, prefs AutoDownloadPrefs,
runtimeMillis int64,
) QualityScore { ) QualityScore {
q := QualityScore{ q := QualityScore{
Health: clamp01(c.Health), Health: clamp01(c.Health),
Priority: clamp01(float64(priority) / 100.0), Priority: clamp01(float64(priority) / 100.0),
BitrateFit: prefs.bitrateFit(c, runtimeMillis), SizeFit: prefs.sizeFit(c.TotalSize),
} }
if len(audio) == 0 { if len(audio) == 0 {
@@ -479,13 +310,11 @@ func scoreQuality(
q.FormatRank = worst q.FormatRank = worst
q.Bitrate = bitrateScore(audio) q.Bitrate = bitrateScore(audio)
wFormat, wBitrate, wHealth, wPriority, wFit := qualityWeights(prefs) q.Overall = weightFormat*q.FormatRank +
weightBitrate*q.Bitrate +
q.Overall = wFormat*q.FormatRank + weightHealth*q.Health +
wBitrate*q.Bitrate + weightPriority*q.Priority +
wHealth*q.Health + weightSizeFit*q.SizeFit
wPriority*q.Priority +
wFit*q.BitrateFit
if q.Mixed { if q.Mixed {
q.Overall *= 0.9 q.Overall *= 0.9
@@ -615,19 +444,6 @@ func Rank(
return out[i].Match.Overall > out[j].Match.Overall return out[i].Match.Overall > out[j].Match.Overall
} }
// Closest to the preferred bitrate wins the tie.
//
// This is what decides which copy is taken now that auto-pick
// no longer requires the winner to be clear of the field: when
// several candidates are equally good matches of equal overall
// quality, the one the user said they wanted the shape of is
// the answer, ahead of provider priority. With no preference
// set every BitrateFit is the same neutral value and this
// falls through, exactly as before.
if out[i].Quality.BitrateFit != out[j].Quality.BitrateFit {
return out[i].Quality.BitrateFit > out[j].Quality.BitrateFit
}
if out[i].Quality.Priority != out[j].Quality.Priority { if out[i].Quality.Priority != out[j].Quality.Priority {
return out[i].Quality.Priority > out[j].Quality.Priority return out[i].Quality.Priority > out[j].Quality.Priority
} }
@@ -638,58 +454,19 @@ func Rank(
return out return out
} }
// Auto-pick gates. Named rather than inlined because AutoPickVeto // AutoPickable reports whether a ranked list has a clear enough winner
// reports which of them refused, and a number in a sentence the user // to grab without asking. It demands an anchored request, a high match,
// reads should be the same number the decision used. // decent quality, and daylight between first and second place — if two
const ( // candidates are close, the choice is the user's.
minMatch = 0.85
minQuality = 0.5
)
// AutoPickable reports whether a ranked list has a candidate worth
// grabbing without asking: an anchored request with a tracklist behind
// it, and a candidate that clears the match and quality bars inside the
// user's guardrails.
//
// **It does not require the winner to be better than the runner-up.**
// It used to demand 0.08 of daylight on the combined score, which meant
// the check fired hardest in the case it was never written for: a
// popular album turns up five *correct* copies, all matching the
// tracklist at 95%+ and differing only in format and seeders, their
// scores land within a point of each other, and auto-pick refused
// forever on the grounds that the choice was the user's. It was not.
// There was no question about *what* to fetch, only about which copy —
// and abundance is the one condition under which that question matters
// least. A candidate does not need to be the best one, only one that
// meets the criteria; where several do, `Rank` puts the one closest to
// the preferred bitrate first.
func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool { func AutoPickable(dl Download, ranked []Candidate, prefs AutoDownloadPrefs) bool {
return AutoPickVeto(dl, ranked, prefs) == "" const (
} minMatch = 0.85
minQuality = 0.5
minLead = 0.08
)
// AutoPickVeto returns the reason auto-pick declined, or "" when it if !dl.Anchored() || len(ranked) == 0 {
// would go ahead. return false
//
// It exists because "it rejected all of them" was indistinguishable
// from "it found nothing good". The request list's message was built
// from `ranked[0]` — the best candidate *before* the size and format
// guardrails, and before the lead check — so a request refused because
// the user's maximum size excluded every copy, or because three equally
// good copies were found, reported "best of 12 found is not a confident
// enough match (match 96%, quality 88%)". Numbers that clear both
// thresholds, beside a refusal, is a message that teaches the user the
// matcher is broken. Each gate names itself now.
func AutoPickVeto(
dl Download,
ranked []Candidate,
prefs AutoDownloadPrefs,
) string {
if len(ranked) == 0 {
return "nothing found"
}
if !dl.Anchored() {
return "the request is free text, so there is no release to be right about"
} }
// An anchor with no tracklist behind it is an anchor in name only: // An anchor with no tracklist behind it is an anchor in name only:
@@ -697,42 +474,29 @@ func AutoPickVeto(
// is exactly the evidence a wrong-album candidate also has. This // is exactly the evidence a wrong-album candidate also has. This
// matters most for the request list, where nobody is watching. // matters most for the request list, where nobody is watching.
if len(dl.Expected) == 0 { if len(dl.Expected) == 0 {
return "no tracklist for this release is known yet, so a candidate cannot be checked against it" return false
} }
// The guardrails apply before the match and quality checks: a // The guardrails apply before the match/quality/lead checks: a
// candidate outside the allowed bitrate, size or format is not a // candidate outside the allowed size or format is not a worse
// worse choice, it is not a choice auto-pick may make at all, so it // choice, it is not a choice auto-pick may make at all, so it must
// must not count as "the winner" either. // not count as "the winner" nor as "second place" for the lead
eligible := prefs.filter(ranked, dl.runtimeMillis()) // check below.
eligible := prefs.filter(ranked)
if len(eligible) == 0 { if len(eligible) == 0 {
return fmt.Sprintf( return false
"all %d found are outside the auto-download bitrate, size or format limits",
len(ranked),
)
} }
best := eligible[0] best := eligible[0]
if best.Match.Overall < minMatch || best.Quality.Overall < minQuality {
if best.Match.Overall < minMatch { return false
return fmt.Sprintf(
"best of %d found matches this release only %.0f%% (needs %.0f%%)",
len(ranked),
best.Match.Overall*100, //nolint:mnd // percent
minMatch*100, //nolint:mnd // percent
)
} }
if best.Quality.Overall < minQuality { if len(eligible) > 1 && best.Score-eligible[1].Score < minLead {
return fmt.Sprintf( return false
"best of %d found is the right release but scores %.0f%% on quality (needs %.0f%%)",
len(ranked),
best.Quality.Overall*100, //nolint:mnd // percent
minQuality*100, //nolint:mnd // percent
)
} }
return "" return true
} }
// mergeMatched copies MatchedTo assignments from the audio-only slice // mergeMatched copies MatchedTo assignments from the audio-only slice
+57 -360
View File
@@ -1,34 +1,6 @@
package download package download
import ( import "testing"
"strings"
"testing"
)
// trackMillis is five minutes; okComputer's four of them make a
// twenty-minute release, which is what turns a candidate's byte count
// into a bitrate the assertions below can name.
const trackMillis = 5 * 60 * 1000
// okComputerRuntime is that release's runtime, for the helpers that
// need it directly.
const okComputerRuntime = 4 * trackMillis
// kbpsCandidate builds an annotated candidate whose audio adds up to
// the given average bitrate over okComputer's runtime.
func kbpsCandidate(id, ext string, kbps int) Candidate {
// bits = kbps × 1000 × (runtimeMillis / 1000), so the thousands
// cancel and the byte count is kbps × runtimeMillis / 8.
const bitsPerByte = 8
total := int64(kbps) * okComputerRuntime / bitsPerByte
c := candidateFor(id, allTitles(), ext, total/int64(len(allTitles())))
c.Files = AnnotateFiles(c.Files)
c.TotalSize = total
return c
}
// okComputer is the reference request used across ranking tests. // okComputer is the reference request used across ranking tests.
func okComputer() Download { func okComputer() Download {
@@ -36,15 +8,11 @@ func okComputer() Download {
ReleaseMBID: "mbid-ok-computer", ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead", Artist: "Radiohead",
Album: "OK Computer", Album: "OK Computer",
// Four five-minute tracks: twenty minutes, so a candidate's
// bitrate is a number these tests can state exactly. Without
// lengths there is no runtime and the bitrate window has
// nothing to divide by.
Expected: []ExpectedTrack{ Expected: []ExpectedTrack{
{Position: 1, Title: "Airbag", LengthMillis: trackMillis}, {Position: 1, Title: "Airbag"},
{Position: 2, Title: "Paranoid Android", LengthMillis: trackMillis}, {Position: 2, Title: "Paranoid Android"},
{Position: 3, Title: "Subterranean Homesick Alien", LengthMillis: trackMillis}, {Position: 3, Title: "Subterranean Homesick Alien"},
{Position: 4, Title: "Exit Music (For a Film)", LengthMillis: trackMillis}, {Position: 4, Title: "Exit Music (For a Film)"},
}, },
} }
} }
@@ -219,7 +187,7 @@ func TestUnanchoredMatchIsCapped(t *testing.T) {
} }
} }
func TestAutoPickableRequiresAnchorAndTracklist(t *testing.T) { func TestAutoPickableRequiresAnchorAndLead(t *testing.T) {
t.Parallel() t.Parallel()
dl := okComputer() dl := okComputer()
@@ -243,18 +211,14 @@ func TestAutoPickableRequiresAnchorAndTracklist(t *testing.T) {
} }
}) })
// Two identical copies are a spare, not an ambiguity. This t.Run("two close candidates are not", func(t *testing.T) {
// asserted the opposite while auto-pick required daylight over the
// runner-up — a rule that made abundance the thing that stopped a
// request being satisfied, which is backwards.
t.Run("two equally good candidates still are", func(t *testing.T) {
t.Parallel() t.Parallel()
twin := best twin := best
twin.ID = "twin" twin.ID = "twin"
if !AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) { if AutoPickable(dl, []Candidate{best, twin}, AutoDownloadPrefs{}) {
t.Error("identical good candidates must auto-pick") t.Error("identical candidates must not auto-pick")
} }
}) })
@@ -336,11 +300,18 @@ func TestProviderPriorityBreaksTies(t *testing.T) {
} }
} }
const mb = 1 << 20
func TestAutoDownloadPrefsEligible(t *testing.T) { func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Parallel() t.Parallel()
flacCandidate := kbpsCandidate("c", ".flac", 900) flacCandidate := candidateFor("c", allTitles(), ".flac", 30_000_000)
mp3Candidate := kbpsCandidate("c", ".mp3", 128) flacCandidate.Files = AnnotateFiles(flacCandidate.Files)
flacCandidate.TotalSize = 300 * mb
mp3Candidate := candidateFor("c", allTitles(), ".mp3", 3_000_000)
mp3Candidate.Files = AnnotateFiles(mp3Candidate.Files)
mp3Candidate.TotalSize = 30 * mb
tests := []struct { tests := []struct {
name string name string
@@ -350,25 +321,18 @@ func TestAutoDownloadPrefsEligible(t *testing.T) {
}{ }{
{"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true}, {"zero value is permissive", AutoDownloadPrefs{}, flacCandidate, true},
{ {
"within the bitrate window", "within min/max window",
AutoDownloadPrefs{MinKbps: 320, MaxKbps: 1200}, AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 500},
flacCandidate, true, flacCandidate, true,
}, },
{ {
"below the minimum bitrate", "below minimum",
AutoDownloadPrefs{MinKbps: 500}, AutoDownloadPrefs{MinSizeMB: 400},
mp3Candidate, false,
},
{
"above the maximum bitrate",
AutoDownloadPrefs{MaxKbps: 500},
flacCandidate, false, flacCandidate, false,
}, },
{ {
// The ceiling is bytes, not a rate, and it is the guard "above maximum",
// that still works when the bitrate cannot be worked out. AutoDownloadPrefs{MaxSizeMB: 200},
"above the hard size ceiling",
AutoDownloadPrefs{MaxSizeMB: 50},
flacCandidate, false, flacCandidate, false,
}, },
{ {
@@ -387,131 +351,57 @@ func TestAutoDownloadPrefsEligible(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
got := tt.prefs.eligible(tt.c, okComputerRuntime) if got := tt.prefs.eligible(tt.c); got != tt.want {
if got != tt.want {
t.Errorf("eligible() = %v, want %v", got, tt.want) t.Errorf("eligible() = %v, want %v", got, tt.want)
} }
}) })
} }
} }
// A release nobody knows the length of cannot be judged on bitrate, and
// the window must not become a silent embargo because MusicBrainz is
// missing a track length. The size ceiling still applies — that is why
// it is a separate field.
func TestBitrateWindowPassesAnUnknownRuntime(t *testing.T) {
t.Parallel()
c := kbpsCandidate("c", ".mp3", 128)
prefs := AutoDownloadPrefs{MinKbps: 900}
if !prefs.eligible(c, 0) {
t.Error("an unknown runtime must pass the bitrate window")
}
if prefs.eligible(c, okComputerRuntime) {
t.Error("a known runtime must still be judged")
}
ceiling := AutoDownloadPrefs{MaxSizeMB: 1}
if ceiling.eligible(c, 0) {
t.Error("the size ceiling must apply even with no runtime")
}
}
// Artwork is not part of the bitrate. A folder carrying 30 MB of
// scans would otherwise read as a better rip than the same music
// without them, which is backwards.
func TestBitrateIgnoresNonAudioFiles(t *testing.T) {
t.Parallel()
c := kbpsCandidate("c", ".mp3", 320)
bare := candidateKbps(c, okComputerRuntime)
c.Files = append(c.Files, CandidateFile{
Path: "Radiohead - OK Computer/cover.jpg",
Size: 30 << 20,
})
c.Files = AnnotateFiles(c.Files)
if got := candidateKbps(c, okComputerRuntime); got != bare {
t.Errorf("bitrate with artwork = %f, want %f", got, bare)
}
}
// Where no runtime is known, a stated per-file bitrate is better than
// no answer at all.
func TestBitrateFallsBackToTheStatedRate(t *testing.T) {
t.Parallel()
c := candidateFor("c", allTitles(), ".mp3", 3_000_000)
for i := range c.Files {
c.Files[i].Bitrate = 192
}
c.Files = AnnotateFiles(c.Files)
if got := candidateKbps(c, 0); got != 192 {
t.Errorf("stated bitrate = %f, want 192", got)
}
}
func TestAutoDownloadPrefsFilter(t *testing.T) { func TestAutoDownloadPrefsFilter(t *testing.T) {
t.Parallel() t.Parallel()
lossy := kbpsCandidate("lossy", ".mp3", 128) small := candidateFor("small", allTitles(), ".flac", 10_000_000)
lossless := kbpsCandidate("lossless", ".flac", 900) small.TotalSize = 50 * mb
prefs := AutoDownloadPrefs{MinKbps: 500} big := candidateFor("big", allTitles(), ".flac", 30_000_000)
big.TotalSize = 500 * mb
filtered := prefs.filter( prefs := AutoDownloadPrefs{MinSizeMB: 100, MaxSizeMB: 600}
[]Candidate{lossy, lossless}, okComputerRuntime,
)
if len(filtered) != 1 || filtered[0].ID != "lossless" { filtered := prefs.filter([]Candidate{small, big})
if len(filtered) != 1 || filtered[0].ID != "big" {
t.Errorf("filter() = %v, want only the in-window candidate", filtered) t.Errorf("filter() = %v, want only the in-window candidate", filtered)
} }
} }
func TestAutoDownloadPrefsBitrateFit(t *testing.T) { func TestAutoDownloadPrefsSizeFit(t *testing.T) {
t.Parallel() t.Parallel()
const neutral = 0.5 const neutral = 0.5
tests := []struct { tests := []struct {
name string name string
prefs AutoDownloadPrefs prefs AutoDownloadPrefs
c Candidate totalSize int64
want float64 want float64
}{ }{
{ {"no preference is neutral", AutoDownloadPrefs{}, 300 * mb, neutral},
"no preference is neutral",
AutoDownloadPrefs{},
kbpsCandidate("c", ".flac", 900), neutral,
},
{ {
"exact match scores 1", "exact match scores 1",
AutoDownloadPrefs{PreferredKbps: 320}, AutoDownloadPrefs{PreferredSizeMB: 300},
kbpsCandidate("c", ".mp3", 320), 1.0, 300 * mb, 1.0,
}, },
{ {
// The floor is neutral, not zero: this term carries 0.40 "double the preferred size scores 0",
// of the quality score once a preference is set, and a AutoDownloadPrefs{PreferredSizeMB: 300},
// span to zero would let "I like 320" quietly disqualify 600 * mb, 0.0,
// every FLAC from auto-pick.
"double the preferred rate falls to the neutral floor",
AutoDownloadPrefs{PreferredKbps: 320},
kbpsCandidate("c", ".flac", 640), neutral,
}, },
{ {
"half the preferred rate falls to the neutral floor", "half the preferred size scores 0",
AutoDownloadPrefs{PreferredKbps: 320}, AutoDownloadPrefs{PreferredSizeMB: 300},
kbpsCandidate("c", ".mp3", 160), neutral, 150 * mb, 0.0,
},
{
"an unknowable rate is neutral",
AutoDownloadPrefs{PreferredKbps: 320},
kbpsCandidate("c", ".mp3", 320), neutral,
}, },
} }
@@ -519,223 +409,30 @@ func TestAutoDownloadPrefsBitrateFit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
// The last case deliberately withholds the runtime. if got := tt.prefs.sizeFit(tt.totalSize); got != tt.want {
runtime := int64(okComputerRuntime) t.Errorf("sizeFit(%d) = %f, want %f", tt.totalSize, got, tt.want)
if tt.name == "an unknowable rate is neutral" {
runtime = 0
}
if got := tt.prefs.bitrateFit(tt.c, runtime); got != tt.want {
t.Errorf("bitrateFit() = %f, want %f", got, tt.want)
} }
}) })
} }
} }
// An otherwise-perfect candidate must not auto-pick when it falls // An otherwise-perfect candidate must not auto-pick when it falls
// outside the configured guardrails: they apply before the match and // outside the configured size guard: the guardrail applies before the
// quality checks, not as one more input averaged into them. // match/quality/lead checks, not as one more input averaged into them.
func TestAutoPickableRejectsCandidateOutsideTheGuardrails(t *testing.T) { func TestAutoPickableRejectsCandidateOutsideSizeGuard(t *testing.T) {
t.Parallel() t.Parallel()
dl := okComputer() dl := okComputer()
best := Score(dl, kbpsCandidate("a", ".flac", 900), 50, AutoDownloadPrefs{}) best := Score(dl, candidateFor("a", allTitles(), ".flac", 30_000_000), 50, AutoDownloadPrefs{})
best.TotalSize = 500 * mb
if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) { if !AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{}) {
t.Fatal("expected this candidate to be auto-pickable with no guardrails") t.Fatal("expected this candidate to be auto-pickable with no guardrails")
} }
if AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{MaxKbps: 320}) { tight := AutoDownloadPrefs{MinSizeMB: 10, MaxSizeMB: 100}
t.Error("candidate above the bitrate window must not auto-pick")
}
if AutoPickable(dl, []Candidate{best}, AutoDownloadPrefs{MaxSizeMB: 1}) { if AutoPickable(dl, []Candidate{best}, tight) {
t.Error("candidate above the size ceiling must not auto-pick") t.Error("candidate outside the size guard must not auto-pick")
}
}
// The refusal has to name the gate that refused.
//
// Before AutoPickVeto, every one of these came back as the same
// sentence built from `ranked[0]` — the best candidate before the size
// and format guardrails — so a request refused because the user's size
// window excluded every copy reported a match and a quality that both
// cleared their thresholds. A refusal quoting numbers that pass is
// what made the matcher look broken from outside.
func TestAutoPickVetoNamesTheGate(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(
dl,
candidateFor("a", allTitles(), ".flac", 30_000_000),
50,
AutoDownloadPrefs{},
)
// candidateFor sizes the files and leaves TotalSize at 0, which is
// what the guardrails read.
sized := func(c Candidate, total int64) Candidate {
c.TotalSize = total
return c
}
tests := []struct {
name string
dl Download
ranked []Candidate
prefs AutoDownloadPrefs
wantSub string
}{
{
name: "nothing found",
dl: dl,
ranked: nil,
wantSub: "nothing found",
},
{
name: "free text",
dl: Download{Artist: "Radiohead", Album: "OK Computer"},
ranked: []Candidate{best},
wantSub: "free text",
},
{
name: "no tracklist behind the anchor",
dl: Download{
ReleaseMBID: "mbid-ok-computer",
Artist: "Radiohead",
Album: "OK Computer",
},
ranked: []Candidate{best},
wantSub: "no tracklist",
},
{
// The candidate is 120 MB and the window tops out at 1 MB:
// the old message reported its match and quality instead.
name: "outside the size window",
dl: dl,
ranked: []Candidate{sized(best, 120<<20)},
prefs: AutoDownloadPrefs{MaxSizeMB: 1},
wantSub: "bitrate, size or format limits",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := AutoPickVeto(tt.dl, tt.ranked, tt.prefs)
if !strings.Contains(got, tt.wantSub) {
t.Errorf("veto = %q, want it to mention %q", got, tt.wantSub)
}
})
}
}
// A clear winner has no veto at all — the sentence is empty, which is
// what AutoPickable reads.
func TestAutoPickVetoIsEmptyForAClearWinner(t *testing.T) {
t.Parallel()
dl := okComputer()
best := Score(
dl,
candidateFor("a", allTitles(), ".flac", 30_000_000),
50,
AutoDownloadPrefs{},
)
weak := Score(
dl,
candidateFor("b", allTitles()[:2], ".mp3", 1_000_000),
50,
AutoDownloadPrefs{},
)
if got := AutoPickVeto(dl, []Candidate{best, weak}, AutoDownloadPrefs{}); got != "" {
t.Errorf("veto = %q, want none", got)
}
}
// With several candidates that all clear the bar, the preferred
// bitrate decides which one is taken.
//
// This is what replaced the daylight requirement. Auto-pick no longer
// refuses when the field is close; it takes the copy nearest the shape
// the user asked for, which is the question they actually answered in
// Settings.
func TestPreferredBitrateBreaksTheTie(t *testing.T) {
t.Parallel()
dl := okComputer()
prefs := AutoDownloadPrefs{PreferredKbps: 320}
// Same album, same completeness, same health, same provider — the
// only difference between them is the rate.
lossless := kbpsCandidate("lossless", ".flac", 900)
perfect := kbpsCandidate("perfect", ".mp3", 320)
ranked := Rank(
dl, []Candidate{lossless, perfect}, nil, prefs,
)
if ranked[0].ID != "perfect" {
t.Errorf(
"winner = %q (fit %f) over %q (fit %f), want the 320 kbps copy",
ranked[0].ID, ranked[0].Quality.BitrateFit,
ranked[1].ID, ranked[1].Quality.BitrateFit,
)
}
if AutoPickVeto(dl, ranked, prefs) != "" {
t.Error("a close field must still auto-pick")
}
}
// With no preference set, nothing changes: BitrateFit is the same
// neutral value for every candidate and the older tie-breaks decide.
func TestNoPreferredBitrateLeavesRankingAlone(t *testing.T) {
t.Parallel()
dl := okComputer()
lossless := kbpsCandidate("lossless", ".flac", 900)
lossy := kbpsCandidate("lossy", ".mp3", 320)
ranked := Rank(
dl, []Candidate{lossy, lossless}, nil, AutoDownloadPrefs{},
)
if ranked[0].ID != "lossless" {
t.Errorf(
"winner = %q, want the lossless copy on format alone",
ranked[0].ID,
)
}
}
// A preferred bitrate promotes the copy that matches it and must never
// disqualify the ones that do not. It carries 0.40 of the quality
// score, so a fit spanning down to zero would put a perfectly good FLAC
// under minQuality and out of auto-pick — turning a preference into a
// prohibition without saying so. MinKbps and MaxKbps are how a user
// says that on purpose.
func TestAPreferredBitrateNeverDisqualifies(t *testing.T) {
t.Parallel()
dl := okComputer()
far := AutoDownloadPrefs{PreferredKbps: 128}
lossless := Score(dl, kbpsCandidate("flac", ".flac", 900), 50, far)
if lossless.Quality.Overall < minQuality {
t.Errorf(
"quality = %f under a far-off preference, want >= %f",
lossless.Quality.Overall, minQuality,
)
}
if veto := AutoPickVeto(dl, []Candidate{lossless}, far); veto != "" {
t.Errorf("a far-off preference vetoed the candidate: %s", veto)
} }
} }
-40
View File
@@ -20,41 +20,6 @@ func newServiceFixture(t *testing.T) serviceFixture {
mf := newManagerFixture(t) mf := newManagerFixture(t)
svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore()) svc := NewService(slogDiscard(), mf.manager, mf.store, NewMemSecretStore())
// Every test here is about the durable Request that `StartDownload`
// leaves behind, and none of them is about the download itself -- but
// their fixture is an anchored four-track request with a healthy
// provider, which is exactly what `AutoPickable` says yes to. So
// `Manager.Start` was firing `go m.grab(...)`, detached and with
// `context.WithoutCancel`, and the test then raced it.
//
// It lost, twice, in CI (`check` on c03c0b8, and nowhere locally):
//
// service_test.go:66: state = "satisfied", want wanted
// testing.go:1369: TempDir RemoveAll cleanup: ... directory not empty
//
// The first is the request reaching its *next* state before the
// assertion read it; the second is that same goroutine still writing
// into `t.TempDir()` after the test returned. One cause, two shapes.
//
// Putting the candidate outside the auto-pick guardrails stops the
// grab from ever starting, which is better than waiting for it: there
// is no goroutine to be slow, so the tests state what they mean
// ("the request exists, in this state") without a timing assumption
// underneath. A test that does want the download has `managerFixture`
// and sets its own preferences.
//
// The guard is a *format* the fake never produces, and it used to be
// `MaxSizeMB: 1`, which never fired: the size gates read
// `Candidate.TotalSize`, which real providers fill and the fake
// leaves at zero, and zero is under every ceiling. So the grab went
// ahead anyway and the second failure shape above — the TempDir
// cleanup race — kept happening, reproducibly, roughly one run in
// fifteen. A guard has to be keyed on something the fixture
// actually sets.
mf.manager.SetPreferences(AutoDownloadPrefs{
AllowedFormats: []Format{FormatWMA},
})
return serviceFixture{managerFixture: mf, svc: svc} return serviceFixture{managerFixture: mf, svc: svc}
} }
@@ -193,11 +158,6 @@ func TestManualDownloadSatisfiesRequestOnSuccess(t *testing.T) {
f := newServiceFixture(t) f := newServiceFixture(t)
ctx := context.Background() ctx := context.Background()
// This is the one test here that is *about* the download, so it
// undoes the fixture's guard rather than relying on it — which is
// what it was doing implicitly while the guard did not work.
f.manager.SetPreferences(AutoDownloadPrefs{})
provider := fakeWithAlbum(1, "source", ".flac") provider := fakeWithAlbum(1, "source", ".flac")
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider) f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
+1 -6
View File
@@ -302,12 +302,7 @@ type QualityScore struct {
Bitrate float64 `json:"bitrate"` Bitrate float64 `json:"bitrate"`
Health float64 `json:"health"` // seeders, free slots Health float64 `json:"health"` // seeders, free slots
Priority float64 `json:"priority"` // user's per-provider preference Priority float64 `json:"priority"` // user's per-provider preference
// BitrateFit is closeness to the preferred *rate*, which is what SizeFit float64 `json:"sizeFit"` // closeness to the preferred download size
// the auto-download window is expressed in. It replaced a
// `SizeFit` measured in megabytes: a size means nothing without
// knowing how long the music is, so the same number described a
// generous single and a suspiciously small boxset.
BitrateFit float64 `json:"bitrateFit"`
// Mixed marks a candidate whose files are not all the same format, // Mixed marks a candidate whose files are not all the same format,
// which usually means a hand-assembled folder rather than a rip. // which usually means a hand-assembled folder rather than a rip.
+6 -25
View File
@@ -46,42 +46,23 @@ func TestCacheMiss(t *testing.T) {
} }
} }
// TestCacheTTLExpiry checks both halves of the TTL contract, and uses two
// entries to do it.
//
// **No assertion here may depend on an upper bound of elapsed wall-clock
// time**, which is what the single-entry version of this test did: it set
// a 1s TTL and immediately asserted a *hit*, so on a loaded runner — one
// goroutine descheduled for over a second while the rest of the suite
// runs — the entry was correctly gone and the test failed with "expected
// cache hit immediately after set". It did exactly that in CI while
// passing five times out of five locally.
//
// Sleeping *past* a TTL is always safe, so the expiry half keeps a short
// one; the presence half gets a TTL nothing can outrun.
func TestCacheTTLExpiry(t *testing.T) { func TestCacheTTLExpiry(t *testing.T) {
c := newTestCache(t) c := newTestCache(t)
data := []byte(`{"ephemeral":true}`) data := []byte(`{"ephemeral":true}`)
c.Set("ttl-live-key", data, time.Hour, "", "") c.Set("ttl-test-key", data, 1*time.Second, "", "")
c.Set("ttl-expiring-key", data, 1*time.Second, "", "")
if _, ok := c.Get("ttl-live-key"); !ok { // Verify it's there immediately.
t.Fatal("expected a cache hit on an entry with an hour to live") if _, ok := c.Get("ttl-test-key"); !ok {
t.Fatal("expected cache hit immediately after set")
} }
// Wait for the short one to expire. // Wait for expiry.
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
if _, ok := c.Get("ttl-expiring-key"); ok { if _, ok := c.Get("ttl-test-key"); ok {
t.Error("expected cache miss after TTL expiry, got hit") t.Error("expected cache miss after TTL expiry, got hit")
} }
// And the long-lived entry is still there, which is what says the
// sweep above expired an entry rather than the cache.
if _, ok := c.Get("ttl-live-key"); !ok {
t.Error("the hour-long entry expired too")
}
} }
func TestCacheMBID(t *testing.T) { func TestCacheMBID(t *testing.T) {
+5 -49
View File
@@ -25,26 +25,8 @@ const (
// where cached cover art thumbnails are stored. // where cached cover art thumbnails are stored.
thumbnailDir = CoverArtCacheDirName thumbnailDir = CoverArtCacheDirName
// thumbnailTimeout is the HTTP timeout for fetching a thumbnail, // thumbnailTimeout is the HTTP timeout for fetching a thumbnail.
// and it has to cover a redirect the Cover Art Archive does not thumbnailTimeout = 10 * time.Second
// serve itself.
//
// `coverartarchive.org` answers `front-250` with a 307 to an
// Internet Archive storage node (`dn######.us.archive.org`), and
// those nodes are routinely slow: measured against the twelve
// albums on Explore's own shelves, a successful fetch took 14–16 s
// and a failing one 13–17 s. At 10 s *every* cover on the page
// timed out — 24 cards, 5 of which had art, all of those from the
// disk cache — which reads as "Explore has no album art" rather
// than as a slow upstream, because a timeout writes nothing and
// says nothing.
//
// 30 s is chosen to clear that measured range with room, not to be
// generous: the fetch is off the critical path (each one is its own
// goroutine behind an 8/s limiter, and the frontend renders a
// placeholder until it lands), so the cost of waiting is nothing
// and the cost of giving up early is a blank page.
thumbnailTimeout = 30 * time.Second
// thumbnailMaxSize is the maximum image size to cache (2 MB). // thumbnailMaxSize is the maximum image size to cache (2 MB).
thumbnailMaxSize = 2 * 1024 * 1024 thumbnailMaxSize = 2 * 1024 * 1024
@@ -115,20 +97,6 @@ func (p *CoverArtProxy) GetThumbnail(
return "" return ""
} }
// A 404 is an answer, and it is already on disk.
//
// `writeCache(mbid, nil)` has recorded "the archive has no art for
// this" as an empty file since this was written, and nothing has
// ever read it back: `readCache` returns "" for an empty file,
// which is indistinguishable from a miss, so every art-less release
// group was re-fetched from the network on every render that asked
// about it. On Explore's shelves a third of the cards are art-less,
// so that was a third of the page spending a live CAA request to be
// told again what the last one said.
if p.knownMissing(releaseGroupMBID) {
return ""
}
// Source 3: fetch from Cover Art Archive (slow, cached to disk). // Source 3: fetch from Cover Art Archive (slow, cached to disk).
url := CoverArtGroupURL(releaseGroupMBID) url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url) data, cacheable, err := p.fetch(url)
@@ -209,9 +177,8 @@ func (p *CoverArtProxy) GetCandidateThumbnail(
} }
} }
// Network fetch on release group — unless a previous one was told // Network fetch on release group.
// there is none. See `knownMissing`. if releaseGroupMBID != "" {
if releaseGroupMBID != "" && !p.knownMissing(releaseGroupMBID) {
url := CoverArtGroupURL(releaseGroupMBID) url := CoverArtGroupURL(releaseGroupMBID)
data, cacheable, err := p.fetch(url) data, cacheable, err := p.fetch(url)
@@ -227,7 +194,7 @@ func (p *CoverArtProxy) GetCandidateThumbnail(
} }
// Network fetch on release (fallback). // Network fetch on release (fallback).
if releaseMBID != "" && !p.knownMissing(releaseMBID) { if releaseMBID != "" {
url := CoverArtURL(releaseMBID) url := CoverArtURL(releaseMBID)
data, cacheable, err := p.fetch(url) data, cacheable, err := p.fetch(url)
@@ -318,17 +285,6 @@ func (p *CoverArtProxy) cachePath(mbid string) string {
return filepath.Join(p.cacheDir, mbid+".jpg") return filepath.Join(p.cacheDir, mbid+".jpg")
} }
// knownMissing reports whether a previous fetch was told the archive
// has no art for this MBID — the empty file `writeCache(mbid, nil)`
// leaves behind. It is deliberately separate from `readCache`, which
// answers "what are the bytes" and cannot express the difference
// between no answer and an answer of none.
func (p *CoverArtProxy) knownMissing(mbid string) bool {
info, err := os.Stat(p.cachePath(mbid))
return err == nil && info.Size() == 0
}
func (p *CoverArtProxy) readCache(mbid string) string { func (p *CoverArtProxy) readCache(mbid string) string {
path := p.cachePath(mbid) path := p.cachePath(mbid)
+14 -140
View File
@@ -289,13 +289,8 @@ func (l *Library) scanInternal(
l.mu.Unlock() l.mu.Unlock()
}() }()
// The configured mode, not a hardcoded "auto". `ScanConcurrency`
// has been a validated config field with three values and one
// caller passing a constant, so choosing `ssd` or `hdd` by hand
// did nothing at all.
diskProfile := system.ProfileForPath(libraryPath)
workerCount := resolveScanWorkerCount( workerCount := resolveScanWorkerCount(
l.conf.ScanConcurrency, ScanConcurrencyAuto,
libraryPath, libraryPath,
) )
@@ -305,10 +300,6 @@ func (l *Library) scanInternal(
"libraryName", libraryName, "libraryName", libraryName,
"libraryPath", libraryPath, "libraryPath", libraryPath,
"workers", workerCount, "workers", workerCount,
"mode", l.conf.ScanConcurrency,
"device", diskProfile.Device,
"rotational", diskProfile.Rotational,
"queueDepth", diskProfile.QueueDepth,
) )
// Helper to build a ScanProgress with library identification. // Helper to build a ScanProgress with library identification.
@@ -827,7 +818,7 @@ func (l *Library) scanInternal(
g := new(errgroup.Group) g := new(errgroup.Group)
g.SetLimit(workerCount) g.SetLimit(workerCount)
for work := range readaheadWork(scanCtx, workChan, diskProfile) { for work := range workChan {
g.Go(func() error { g.Go(func() error {
if err := l.waitIfPaused(scanCtx); err != nil { if err := l.waitIfPaused(scanCtx); err != nil {
return err return err
@@ -1294,101 +1285,9 @@ func surveyAudioFiles(
return count, maxModTime return count, maxModTime
} }
// How many extraction workers a spinning disk gets, and why it is two // hddWorkerCount is the maximum number of concurrent extraction
// numbers rather than one. // workers when the library resides on a spinning disk.
// const hddWorkerCount = 2
// Extraction is not CPU work — every parser here reads headers and
// returns — so on a spinning disk the whole cost is seek latency, and
// the only question worth asking is how many reads should be in flight
// at once. That has two different right answers and the drive says
// which:
//
// - A drive with command queueing (NCQ: /sys/block/<dev>/device/
// queue_depth reports 31 or 32 on any SATA disk with it enabled)
// reorders outstanding reads into the order its head passes over
// them. Handing it several at once is most of why a parallel scan
// beats a serial one at all, and four is where the returns flatten:
// the drive needs a few requests to have anything to reorder, and
// past that it is queueing requests it was already going to
// service in that order.
// - A drive without it — queue_depth 1, which is what a USB bridge
// or a pre-2004 disk reports — services one command at a time in
// the order given. Every extra worker there is one more seek
// competing for one head, and the scan gets *slower* the harder it
// is pushed. Two is kept rather than one because the readahead
// hints (see readaheadWork) do the overlapping that concurrency
// was standing in for, and one worker cannot hide a stall.
//
// This used to be a flat 2 for anything rotational, which is a
// pre-NCQ assumption: it left a modern spinning disk with a quarter of
// the queue depth it can use.
const (
hddWorkerCountQueued = 4
hddWorkerCountSerial = 2
)
// Readahead tuning.
const (
// readaheadDepth is how many files ahead of the workers the
// prefetcher runs. It is the channel's buffer, so it is also the
// number of `WILLNEED` hints outstanding at once — comfortably more
// than a queueing drive's 32-command window is worth filling with
// one library, and small enough that a cancelled scan is not
// holding a long tail of queued reads.
readaheadDepth = 16
// readaheadBytes is how much of each file to pull in. Everything
// the scanner reads lives at the head: ID3v2 and FLAC's
// STREAMINFO/VORBIS_COMMENT/PICTURE blocks, and the first MPEG
// frame with its Xing header. 512 KB covers a tag carrying
// embedded cover art, which is the large case — and reading a
// little too much sequentially costs a spinning disk almost
// nothing next to the seek that got there.
readaheadBytes = 512 << 10
)
// readaheadWork forwards scan work while asking the kernel to fetch
// each file's header before a worker reaches it.
//
// The buffered channel *is* the lookahead: this goroutine runs ahead
// of the workers until the buffer fills, hinting every file as it goes,
// so by the time a worker takes an item the read it needs has been in
// flight for `readaheadDepth` files' worth of parsing. That is the
// only thing that helps a spinning disk here, because the per-file work
// is already header-only — every parser in `backend/metadata` reads a
// few hundred bytes and returns, so the scan is not waiting on CPU or
// on bytes, it is waiting on the head to arrive.
//
// It runs on rotational disks only. An SSD has no seek to hide and
// already has one worker per core; issuing hints there is pure syscall
// overhead against an OS readahead that is already ahead of us.
func readaheadWork(
ctx context.Context,
in <-chan scanWork,
profile system.DiskProfile,
) <-chan scanWork {
if !profile.Rotational {
return in
}
out := make(chan scanWork, readaheadDepth)
go func() {
defer close(out)
for work := range in {
hintReadahead(work.absolutePath, readaheadBytes)
select {
case out <- work:
case <-ctx.Done():
return
}
}
}()
return out
}
// resolveScanWorkerCount returns the number of concurrent // resolveScanWorkerCount returns the number of concurrent
// extraction workers based on the configured concurrency mode // extraction workers based on the configured concurrency mode
@@ -1397,45 +1296,20 @@ func resolveScanWorkerCount(
mode ScanConcurrency, mode ScanConcurrency,
libraryPath string, libraryPath string,
) int { ) int {
return workersForProfile(
mode,
system.ProfileForPath(libraryPath),
goruntime.NumCPU(),
)
}
// workersForProfile is the policy on its own, so it can be tested
// against drives this machine does not have.
//
// `hdd` and `ssd` override what the device says rather than being a
// separate branch: the mode is the user overruling detection, and
// detection is right about the queue depth either way — a user who
// picks `hdd` on a queueing drive still wants that drive's queue used.
func workersForProfile(
mode ScanConcurrency,
profile system.DiskProfile,
cpus int,
) int {
spinning := profile.Rotational
switch mode { switch mode {
case ScanConcurrencySSD: case ScanConcurrencySSD:
spinning = false return goruntime.NumCPU()
case ScanConcurrencyHDD: case ScanConcurrencyHDD:
spinning = true return min(hddWorkerCount, goruntime.NumCPU())
case ScanConcurrencyAuto: default: // auto
} if system.IsRotationalDisk(libraryPath) {
return min(
hddWorkerCount, goruntime.NumCPU(),
)
}
if !spinning { return goruntime.NumCPU()
return cpus
} }
workers := hddWorkerCountSerial
if profile.Queues() {
workers = hddWorkerCountQueued
}
return min(workers, cpus)
} }
// scanWork represents a file to be processed by a worker. // scanWork represents a file to be processed by a worker.
-38
View File
@@ -1,38 +0,0 @@
//go:build linux
package library
import (
"os"
"golang.org/x/sys/unix"
)
// hintReadahead asks the kernel to start fetching the head of a file
// that is about to be read.
//
// `POSIX_FADV_WILLNEED` returns immediately and queues the read, which
// is the whole point: on a spinning disk the first access to a file
// costs a seek of several milliseconds, and that latency can only be
// hidden by having the next seek already in flight while the current
// file is being parsed. A drive with command queueing can then service
// the queued reads in head order rather than in the order they were
// asked for.
//
// Errors are dropped on purpose. This is a hint: a file that has since
// been deleted, a filesystem that does not implement fadvise, or a
// permission the walk saw and this open does not, all mean "no
// prefetch", never "fail the scan". The read that follows is what
// reports a genuine problem.
func hintReadahead(path string, bytes int64) {
f, err := os.Open(path)
if err != nil {
return
}
defer func() { _ = f.Close() }()
_ = unix.Fadvise(
int(f.Fd()), 0, bytes, unix.FADV_WILLNEED,
)
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux
package library
// hintReadahead is a no-op off Linux.
//
// macOS has `F_RDADVISE` and Windows has `FILE_FLAG_SEQUENTIAL_SCAN`,
// and neither is wired up here for the reason the scan concurrency
// heuristic is not either: this package cannot tell a spinning disk
// from an SSD on those platforms (see system.ProfileForPath), so it
// would be prefetching without knowing whether prefetching is what the
// device wants.
func hintReadahead(_ string, _ int64) {}
-122
View File
@@ -1,122 +0,0 @@
package library
import (
"context"
"testing"
"yellowjacket/backend/system"
)
// How many workers a scan gets is decided by two facts about the
// device, and the second one is new: a spinning disk that can queue
// commands wants several reads in flight, and one that cannot wants
// almost none. Before this it was a flat 2 for anything rotational,
// which is a pre-NCQ assumption — a modern SATA disk reports a queue
// depth of 32 and was being given a quarter of what it can use.
func TestWorkersForProfile(t *testing.T) {
t.Parallel()
const cpus = 16
ssd := system.DiskProfile{Device: "sda", QueueDepth: 32}
hddQueued := system.DiskProfile{
Device: "sdb", Rotational: true, QueueDepth: 32,
}
hddSerial := system.DiskProfile{
Device: "sdc", Rotational: true, QueueDepth: 1,
}
// Neither NVMe nor a device-mapper volume publishes queue_depth.
// An unknown depth must not be read as "cannot queue", or every
// such device would be scanned as if it were a 2003 drive.
unknown := system.DiskProfile{Device: "dm-0", Rotational: true}
tests := []struct {
name string
mode ScanConcurrency
profile system.DiskProfile
want int
}{
{"ssd auto", ScanConcurrencyAuto, ssd, cpus},
{"queueing hdd auto", ScanConcurrencyAuto, hddQueued, hddWorkerCountQueued},
{"serial hdd auto", ScanConcurrencyAuto, hddSerial, hddWorkerCountSerial},
{"unknown depth queues", ScanConcurrencyAuto, unknown, hddWorkerCountQueued},
// The mode overrules detection about the *disk*, never about
// its queue: forcing hdd on a queueing drive still uses it.
{"forced hdd on an ssd", ScanConcurrencyHDD, ssd, hddWorkerCountQueued},
{"forced ssd on an hdd", ScanConcurrencySSD, hddQueued, cpus},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := workersForProfile(tt.mode, tt.profile, cpus); got != tt.want {
t.Errorf(
"workersForProfile(%q, %+v) = %d, want %d",
tt.mode, tt.profile, got, tt.want,
)
}
})
}
}
// A machine with fewer cores than the policy asks for gets its cores.
func TestWorkersNeverExceedTheCPUCount(t *testing.T) {
t.Parallel()
hdd := system.DiskProfile{Rotational: true, QueueDepth: 32}
if got := workersForProfile(ScanConcurrencyAuto, hdd, 1); got != 1 {
t.Errorf("single-core hdd = %d workers, want 1", got)
}
}
// The prefetch stage must forward every item and nothing else: it is a
// pass-through with a side effect, and a scan that drops a file because
// of a *hint* would be a spectacular way to lose part of a library.
func TestReadaheadForwardsEveryFile(t *testing.T) {
t.Parallel()
in := make(chan scanWork, 4)
for _, p := range []string{"/a", "/b", "/c", "/d"} {
in <- scanWork{absolutePath: p}
}
close(in)
var got []string
for w := range readaheadWork(
context.Background(),
in,
system.DiskProfile{Rotational: true, QueueDepth: 32},
) {
got = append(got, w.absolutePath)
}
want := []string{"/a", "/b", "/c", "/d"}
if len(got) != len(want) {
t.Fatalf("forwarded %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("item %d = %q, want %q", i, got[i], want[i])
}
}
}
// On an SSD the stage is not inserted at all — the channel comes back
// unchanged, so a scan there pays nothing for a feature it cannot use.
func TestReadaheadIsSkippedOnSolidState(t *testing.T) {
t.Parallel()
in := make(chan scanWork)
out := readaheadWork(
context.Background(), in, system.DiskProfile{QueueDepth: 32},
)
if out != (<-chan scanWork)(in) {
t.Error("an ssd must get the original channel, unwrapped")
}
}
+49 -126
View File
@@ -16,107 +16,32 @@ var errNoBlockDevice = errors.New(
"no matching block device found", "no matching block device found",
) )
// DiskProfile is what the scanner needs to know about the device a
// library sits on. Both fields are about the same question — how many
// reads should be in flight at once — and they answer different halves
// of it, so they travel together rather than as two probes.
type DiskProfile struct {
// Device is the whole-disk kernel name ("sdb"), or "" when the
// path could not be resolved to one.
Device string
// Rotational is /sys/block/<dev>/queue/rotational: true for a
// spinning disk, where a seek costs milliseconds.
Rotational bool
// QueueDepth is /sys/block/<dev>/device/queue_depth — how many
// commands the drive will accept and reorder at once. This is
// NCQ: a SATA disk with it enabled reports 31 or 32, and one
// without reports 1. Zero means the file was not there to read,
// which is the case for anything that is not a SCSI/SATA device
// (NVMe, MMC, device-mapper, loop, a VM's virtio disk).
//
// It is the difference between concurrency helping and hurting.
// With queueing, several outstanding reads let the drive service
// them in the order its head passes over them, which is most of
// why a parallel scan is faster at all. Without it, every extra
// worker is one more seek competing for one head, and the scan
// gets slower the harder it is pushed.
QueueDepth int
}
// Queues reports whether the drive can reorder outstanding commands.
//
// An unknown depth (0) counts as queueing: everything that does not
// publish this file is a device where concurrency is fine — NVMe has
// its own queues, virtio and device-mapper are not the physical layer
// at all. The only case worth being careful about is the one that
// says so explicitly.
func (p DiskProfile) Queues() bool {
return p.QueueDepth != 1
}
// IsRotationalDisk reports whether the block device backing the // IsRotationalDisk reports whether the block device backing the
// given path is a rotational (spinning) disk. Returns false on any // given path is a rotational (spinning) disk. Detection uses the
// error (assumes SSD). // Linux sysfs interface at /sys/block/<dev>/queue/rotational.
// Returns false on any error (assumes SSD).
func IsRotationalDisk(path string) bool { func IsRotationalDisk(path string) bool {
return ProfileForPath(path).Rotational dev, err := deviceForPath(path)
}
// ProfileForPath describes the device backing a filesystem path. A
// path that cannot be resolved yields the zero profile, which reads as
// "not rotational, queueing" — the permissive answer, since assuming a
// spinning disk on an SSD would halve a scan for nothing.
func ProfileForPath(path string) DiskProfile {
dev, err := diskForPath(path)
if err != nil { if err != nil {
return DiskProfile{} return false
} }
return DiskProfile{ rotational, err := os.ReadFile(
Device: dev, filepath.Join(
Rotational: sysfsInt(dev, "queue", "rotational") == 1, "/sys/block", dev, "queue", "rotational",
QueueDepth: sysfsInt(dev, "device", "queue_depth"), ),
}
}
// sysfsInt reads one small integer out of /sys/block/<dev>/<parts...>,
// returning 0 when it is absent or unparseable. Every attribute here
// is optional: sysfs layout varies by driver, and a missing file is
// "this device does not say", never an error worth propagating.
func sysfsInt(dev string, parts ...string) int {
p := filepath.Join(
append([]string{"/sys/block", dev}, parts...)...,
) )
data, err := os.ReadFile(p) //nolint:gosec // sysfs, name from the kernel
if err != nil { if err != nil {
return 0 return false
} }
n, err := strconv.Atoi(strings.TrimSpace(string(data))) return strings.TrimSpace(string(rotational)) == "1"
if err != nil {
return 0
}
return n
} }
// diskForPath resolves a filesystem path to the *whole disk* backing // deviceForPath resolves a filesystem path to its underlying block
// it — "sdb" for a file on "sdb3". // device name (e.g. "sda") by matching the device major:minor
// // from stat(2) against /sys/block/ entries.
// It goes through /sys/dev/block/<major>:<minor>, which the kernel func deviceForPath(path string) (string, error) {
// maintains as a symlink to the device's own sysfs directory, and then
// walks up to the parent when that directory turns out to be a
// partition. The previous implementation scanned /sys/block comparing
// dev numbers and, failing an exact match, took the first entry whose
// *major* agreed — and every SATA disk shares major 8. So a library on
// /dev/sdb3 resolved to whatever /sys/block listed first, which is
// alphabetical, which is sda. On the machine this was found on that
// meant a 6 TB spinning disk was read as the SSD next to it and scanned
// with one worker per core. Matching on major alone cannot be right
// whenever a machine has two disks, which is the case this exists for.
func diskForPath(path string) (string, error) {
var st syscall.Stat_t var st syscall.Stat_t
if err := syscall.Stat(path, &st); err != nil { if err := syscall.Stat(path, &st); err != nil {
return "", fmt.Errorf( return "", fmt.Errorf(
@@ -124,50 +49,48 @@ func diskForPath(path string) (string, error) {
) )
} }
// Linux packs dev_t as 12 bits of major and 20 of minor, split // Extract major and minor device numbers.
// across the word. Masking the low byte of each — which is what major := (st.Dev >> 8) & 0xff
// this used to do — is right only for the first 256 of either. minor := st.Dev & 0xff
major := unixMajor(uint64(st.Dev))
minor := unixMinor(uint64(st.Dev))
link := filepath.Join( // Scan /sys/block/ for a matching device.
"/sys/dev/block", entries, err := os.ReadDir("/sys/block")
strconv.FormatUint(major, 10)+":"+
strconv.FormatUint(minor, 10),
)
target, err := filepath.EvalSymlinks(link)
if err != nil { if err != nil {
return "", fmt.Errorf( return "", fmt.Errorf(
"%w: %s (%w)", errNoBlockDevice, link, err, "could not read /sys/block: %w", err,
) )
} }
// A partition's directory sits inside its disk's, and only the majorStr := strconv.FormatUint(major, 10)
// disk carries `queue`. Climb at most one level: sysfs nests a devStr := majorStr + ":" +
// partition exactly one deep under its disk. strconv.FormatUint(minor, 10)
name := filepath.Base(target)
if _, err := os.Stat(filepath.Join(target, "queue")); err != nil { for _, entry := range entries {
name = filepath.Base(filepath.Dir(target)) devFile := filepath.Join(
} "/sys/block", entry.Name(), "dev",
if name == "" || name == "." || name == string(filepath.Separator) {
return "", fmt.Errorf(
"%w for %d:%d", errNoBlockDevice, major, minor,
) )
data, err := os.ReadFile(devFile)
if err != nil {
continue
}
content := strings.TrimSpace(string(data))
if content == devStr {
return entry.Name(), nil
}
// The filesystem might be on a partition (e.g. sda1)
// whose parent block device is sda. Check if the
// major number matches.
parts := strings.SplitN(content, ":", 2)
if len(parts) == 2 && parts[0] == majorStr {
return entry.Name(), nil
}
} }
return name, nil return "", fmt.Errorf(
} "%w for %s", errNoBlockDevice, devStr,
)
// unixMajor and unixMinor decode a Linux dev_t. Spelled out rather
// than taken from golang.org/x/sys/unix so this file stays readable
// beside the encoding it is undoing.
func unixMajor(dev uint64) uint64 {
return (dev>>8)&0xfff | (dev >> 32 & ^uint64(0xfff))
}
func unixMinor(dev uint64) uint64 {
return dev&0xff | (dev >> 12 & ^uint64(0xff))
} }
-25
View File
@@ -2,34 +2,9 @@
package system package system
// DiskProfile is what the scanner needs to know about the device a
// library sits on. See the Linux implementation for what each field
// means; off Linux nothing fills them, because neither macOS nor
// Windows publishes an equivalent of sysfs's `rotational` and
// `queue_depth` without going through platform APIs this package
// deliberately does not link.
type DiskProfile struct {
Device string
Rotational bool
QueueDepth int
}
// Queues reports whether the drive can reorder outstanding commands.
// Always true here: an unknown depth is the permissive answer, and
// assuming otherwise would halve every scan on every Mac.
func (p DiskProfile) Queues() bool {
return p.QueueDepth != 1
}
// IsRotationalDisk reports whether the block device backing the // IsRotationalDisk reports whether the block device backing the
// given path is a rotational (spinning) disk. On non-Linux // given path is a rotational (spinning) disk. On non-Linux
// platforms this always returns false (assumes SSD). // platforms this always returns false (assumes SSD).
func IsRotationalDisk(_ string) bool { func IsRotationalDisk(_ string) bool {
return false return false
} }
// ProfileForPath describes the device backing a filesystem path. Off
// Linux that is the zero profile, which reads as "an SSD that queues".
func ProfileForPath(_ string) DiskProfile {
return DiskProfile{}
}
+1 -27
View File
@@ -3,33 +3,7 @@
`.gitea/workflows/android-apk.yml` builds a signed `arm64-v8a` APK on `.gitea/workflows/android-apk.yml` builds a signed `arm64-v8a` APK on
every `v*` tag and publishes it to Gitea's every `v*` tag and publishes it to Gitea's
**generic** package registry, which is readable without credentials — **generic** package registry, which is readable without credentials —
which is what lets Obtainium poll a plain URL with no token. It also which is what lets Obtainium poll a plain URL with no token.
attaches the same file to the Gitea release, which is what a person
looking at the release page downloads.
**Tags are not pushed by hand any more.** `.gitea/workflows/release.yml`
reads the Conventional Commits on every merge to `main`, decides the
version, and pushes the tag this workflow is keyed on — so releasing the
APK means merging a `fix:` or `feat:` commit, not running `git tag`. The
`workflow_dispatch` path below remains, for rebuilding a tag that already
exists.
## The 1.x installs cannot be upgraded to 0.0.x
Releases restarted at **0.0.1** when they became automatic (plan 017).
`versionCode` is computed as `maj*10000 + min*100 + pat`, so 0.0.1 is
**1** against the **10300** an installed 1.3.0 build carries — and
**Android refuses a downgrade outright**, with
`INSTALL_FAILED_VERSION_DOWNGRADE`.
The only way through is `adb uninstall app.yellowjacket` (or the
launcher's own uninstall) before installing 0.0.1, **and that takes the
device's library, playlists and play counts with it** — the same loss the
signing key section below exists to prevent, arrived at from the other
direction. This was chosen deliberately over offsetting `versionCode` by
a constant, on the grounds that the honest number is worth one reinstall
while an offset is permanent. The workflow prints a warning whenever the
code it computes is below 10600.
``` ```
https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk https://git.ljones.me/api/packages/yonlu/generic/yellowjacket-android/latest/yellowjacket.apk
+2 -2
View File
@@ -7,7 +7,7 @@ import { test, expect, callBinding } from '../support/fixtures.js';
* and produced two: every one of the eight call sites was a two-way * and produced two: every one of the eight call sites was a two-way
* ternary, so an album already on the request list showed a plus and * ternary, so an album already on the request list showed a plus and
* said "is not in your library" — on the same page, forty pixels from a * said "is not in your library" — on the same page, forty pixels from a
* filled button reading "Requested". * filled button reading "Wanted".
* *
* This spec exists at this tier rather than only in the component one * This spec exists at this tier rather than only in the component one
* because of what it drags in with it: reaching the requested state is * because of what it drags in with it: reaching the requested state is
@@ -181,7 +181,7 @@ test.describe('the requested badge', () => {
const ds = document.querySelector('explore-album-details') const ds = document.querySelector('explore-album-details')
?.shadowRoot; ?.shadowRoot;
const btn = [...(ds?.querySelectorAll('wa-button') ?? [])].find( const btn = [...(ds?.querySelectorAll('wa-button') ?? [])].find(
(b) => /Requested/.test(b.textContent ?? ''), (b) => /Wanted/.test(b.textContent ?? ''),
); );
return btn?.querySelector('wa-icon')?.getAttribute('name') ?? ''; return btn?.querySelector('wa-icon')?.getAttribute('name') ?? '';
@@ -3,53 +3,29 @@
/** /**
* AutoDownloadPrefs gates and scores what AutoPickable may choose * AutoDownloadPrefs gates and scores what AutoPickable may choose
* without asking. Zero values are permissive: no bitrate window, no * without asking. Zero values are permissive: no size window and no
* size ceiling and no format restriction. * format restriction.
*
* **The window is a rate, not a size.** It used to be three numbers in
* megabytes, which cannot mean anything on their own: 300 MB is a
* generous FLAC single and a suspiciously small boxset, and the user
* setting the number has no idea which release the pipeline will
* eventually apply it to. A bitrate is the same statement normalised
* by how long the music is, so one number holds across a 9-minute EP
* and a 3-hour opera — and it is the unit the thing being described is
* actually measured in. The runtime is known for every request
* auto-pick can act on (`Download.Expected` carries per-track lengths,
* and an anchored request is the only kind that reaches here), so this
* costs no extra lookup.
*/ */
export interface AutoDownloadPrefs { export interface AutoDownloadPrefs {
/** /**
* MinKbps and MaxKbps bound the average bitrate auto-pick will * MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
* grab. Zero means no bound on that side. A candidate outside the * means no bound on that side. A candidate outside the window is
* window is filtered out of auto-pick entirely, not merely scored * filtered out of auto-pick entirely, not merely scored down — a
* down — a 96 kbps rip of the right album is not a worse copy the * tiny "sampler" torrent or a boxset ten times the expected size is
* user might accept, it is one they said not to take unattended. * usually the wrong thing entirely, not a worse copy of the right
* * thing.
* For reference: 320 is the top of MP3, ~500–1000 is FLAC depending
* on the material, and anything under ~128 is a transcode.
*/
"minKbps": number;
"maxKbps": number;
/**
* PreferredKbps nudges the score toward a target rate within the
* window, and breaks the tie when several candidates are equally
* good matches. Zero disables the nudge; bitrateFit then returns a
* neutral value that does not affect ranking.
*/
"preferredKbps": number;
/**
* MaxSizeMB is a hard ceiling on the whole candidate, and it is
* deliberately still a size. It answers a different question from
* the window above — not "is this the quality I want" but "is this
* going to fill the disk" — and it has to hold even for a candidate
* whose bitrate cannot be worked out, which is exactly the shape a
* mislabelled boxset arrives in. Zero means no ceiling.
*/ */
"minSizeMb": number;
"maxSizeMb": number; "maxSizeMb": number;
/**
* PreferredSizeMB nudges the score toward a target size within the
* min/max window (a lossless rip and a heavily-padded lossless rip
* can both pass the window). Zero disables the nudge; sizeFit then
* returns a neutral value that does not affect ranking.
*/
"preferredSizeMb": number;
/** /**
* AllowedFormats restricts auto-pick to candidates whose audio * AllowedFormats restricts auto-pick to candidates whose audio
* files are all in one of these formats. Empty means no * files are all in one of these formats. Empty means no
@@ -511,13 +487,9 @@ export interface QualityScore {
"priority": number; "priority": number;
/** /**
* BitrateFit is closeness to the preferred *rate*, which is what * closeness to the preferred download size
* the auto-download window is expressed in. It replaced a
* `SizeFit` measured in megabytes: a size means nothing without
* knowing how long the music is, so the same number described a
* generous single and a suspiciously small boxset.
*/ */
"bitrateFit": number; "sizeFit": number;
/** /**
* Mixed marks a candidate whose files are not all the same format, * Mixed marks a candidate whose files are not all the same format,
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M288.1-32c9 0 17.3 5.1 21.4 13.1L383 125.3 542.9 150.7c8.9 1.4 16.3 7.7 19.1 16.3s.5 18-5.8 24.4L441.7 305.9 467 465.8c1.4 8.9-2.3 17.9-9.6 23.2s-17 6.1-25 2L288.1 417.6 143.8 491c-8 4.1-17.7 3.3-25-2s-11-14.2-9.6-23.2L134.4 305.9 20 191.4c-6.4-6.4-8.6-15.8-5.8-24.4s10.1-14.9 19.1-16.3l159.9-25.4 73.6-144.2c4.1-8 12.4-13.1 21.4-13.1zm0 76.8L230.3 158c-3.5 6.8-10 11.6-17.6 12.8l-125.5 20 89.8 89.9c5.4 5.4 7.9 13.1 6.7 20.7l-19.8 125.5 113.3-57.6c6.8-3.5 14.9-3.5 21.8 0l113.3 57.6-19.8-125.5c-1.2-7.6 1.3-15.3 6.7-20.7l89.8-89.9-125.5-20c-7.6-1.2-14.1-6-17.6-12.8L288.1 44.8z"/></svg>

Before

Width:  |  Height:  |  Size: 889 B

@@ -10,7 +10,6 @@ import type {
VisibilityChangedEvent, VisibilityChangedEvent,
} from '@lit-labs/virtualizer'; } from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { gridSpacingFor } from '@utils/grid-spacing';
import { import {
GetAlbumsByArtist, GetAlbumsByArtist,
GetFilePathsByAlbums, GetFilePathsByAlbums,
@@ -148,6 +147,8 @@ export class ArtistsView
// ----- Grid spacing constants ----- // ----- Grid spacing constants -----
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5; private static readonly CARD_PADDING = 5;
private get imageSize(): number { private get imageSize(): number {
@@ -176,41 +177,20 @@ export class ArtistsView
private createGridLayout() { private createGridLayout() {
const w = this.cardSize ?? CARD_SIZE_DEFAULT; const w = this.cardSize ?? CARD_SIZE_DEFAULT;
const h = w + this.cardTextHeight; const h = w + this.cardTextHeight;
const gap = ArtistsView.GRID_GAP;
// One number for the gap, the row gap and the padding: whatever const pad = ArtistsView.GRID_PADDING;
// a row could not spend on another card, shared out equally, so
// the outside is never wider than the inside. See
// `utils/grid-spacing.ts`.
const spacing = this.spacingFor(this.containerWidth);
this.lastLayoutSpacing = spacing;
return grid({ return grid({
itemSize: { itemSize: {
width: `${w}px`, width: `${w}px`,
height: `${h}px`, height: `${h}px`,
}, },
gap: `${spacing}px`, gap: `${gap}px`,
padding: `${spacing}px`, padding: `${pad}px`,
justify: 'start', justify: 'center',
}); });
} }
/** The width the grid lays itself out in. */
private get containerWidth(): number {
return (
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
)?.clientWidth ||
this.clientWidth ||
0
);
}
private spacingFor(width: number): number {
return gridSpacingFor(width, this.cardSize);
}
/** Sort direction for the artist grid. /** Sort direction for the artist grid.
* *
* There is only one key to sort by: `library.Artist` carries a * There is only one key to sort by: `library.Artist` carries a
@@ -498,8 +478,6 @@ export class ArtistsView
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.detachWheelListener(); this.detachWheelListener();
this.gridResizeObserver?.disconnect();
this.gridResizeObserver = null;
} }
/** The wheel listener and the scroll debounce belong to the grid /** The wheel listener and the scroll debounce belong to the grid
@@ -752,34 +730,10 @@ export class ArtistsView
* ================================================================ */ * ================================================================ */
private lastLayoutWidth = 0; private lastLayoutWidth = 0;
private lastLayoutSpacing = 0;
/** Watches the scroller so a window resize rebuilds the layout:
* the spacing is derived from its width, and nothing else asks
* this view to update when only that changes. */
private gridResizeObserver: ResizeObserver | null = null;
private observeGridWidth() {
const container =
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
);
if (!container || this.gridResizeObserver) return;
this.gridResizeObserver = new ResizeObserver(() =>
this.requestUpdate(),
);
this.gridResizeObserver.observe(container);
}
private updateGridLayout() { private updateGridLayout() {
this.observeGridWidth();
if ( if (
this.cardSize === this.lastLayoutWidth && this.cardSize === this.lastLayoutWidth
this.lastLayoutSpacing ===
this.spacingFor(this.containerWidth)
) { ) {
return; return;
} }
@@ -86,10 +86,9 @@ export class DownloadClients extends LitElement {
/** Working copy of the auto-download guardrails. */ /** Working copy of the auto-download guardrails. */
@state() @state()
private prefs: download.AutoDownloadPrefs = { private prefs: download.AutoDownloadPrefs = {
minKbps: 0, minSizeMb: 0,
maxKbps: 0,
preferredKbps: 0,
maxSizeMb: 0, maxSizeMb: 0,
preferredSizeMb: 0,
allowedFormats: [], allowedFormats: [],
} as download.AutoDownloadPrefs; } as download.AutoDownloadPrefs;
@@ -285,72 +284,25 @@ export class DownloadClients extends LitElement {
: nothing} : nothing}
<div class="form"> <div class="form">
<!-- Bitrate, not megabytes. A size means nothing
on its own: 300 MB is a generous single and a
suspiciously small boxset, and whoever fills
this in has no idea which release it will be
applied to. A rate is the same statement
divided by how long the music is, so one number
holds across an EP and an opera. -->
<div class="field-row"> <div class="field-row">
<wa-input <wa-input
label="Minimum bitrate (kbps)" label="Minimum size (MB)"
type="number" type="number"
min="0" min="0"
placeholder="No minimum" placeholder="No minimum"
.value=${this.prefs.minKbps ? String(this.prefs.minKbps) : ''} .value=${this.prefs.minSizeMb ? String(this.prefs.minSizeMb) : ''}
@input=${(e: Event) => { @input=${(e: Event) => {
this.prefs = { this.prefs = {
...this.prefs, ...this.prefs,
minKbps: Number((e.target as HTMLInputElement).value) || 0, minSizeMb: Number((e.target as HTMLInputElement).value) || 0,
}; };
}} }}
></wa-input> ></wa-input>
<wa-input <wa-input
label="Maximum bitrate (kbps)" label="Maximum size (MB)"
type="number" type="number"
min="0" min="0"
placeholder="No maximum" placeholder="No maximum"
.value=${this.prefs.maxKbps ? String(this.prefs.maxKbps) : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
maxKbps: Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
<wa-input
label="Preferred bitrate (kbps)"
type="number"
min="0"
placeholder="No preference"
.value=${this.prefs.preferredKbps
? String(this.prefs.preferredKbps)
: ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
preferredKbps:
Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
</div>
<div class="requires">
320 is the top of MP3; a FLAC rip is usually
500–1000 depending on the music. Preferred
decides between copies that are otherwise equally
good — it never rules one out, which is what the
minimum and maximum are for.
</div>
<div class="field-row">
<wa-input
label="Never grab more than (MB)"
type="number"
min="0"
placeholder="No limit"
.value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''} .value=${this.prefs.maxSizeMb ? String(this.prefs.maxSizeMb) : ''}
@input=${(e: Event) => { @input=${(e: Event) => {
this.prefs = { this.prefs = {
@@ -359,14 +311,22 @@ export class DownloadClients extends LitElement {
}; };
}} }}
></wa-input> ></wa-input>
</div> <wa-input
label="Preferred size (MB)"
<div class="requires"> type="number"
A ceiling on the download itself, in case a min="0"
mislabelled boxset gets through. Still a size placeholder="No preference"
because it is a question about disk space, and .value=${this.prefs.preferredSizeMb
because it has to apply to a candidate whose ? String(this.prefs.preferredSizeMb)
bitrate cannot be worked out at all. : ''}
@input=${(e: Event) => {
this.prefs = {
...this.prefs,
preferredSizeMb:
Number((e.target as HTMLInputElement).value) || 0,
};
}}
></wa-input>
</div> </div>
<div> <div>
@@ -19,7 +19,6 @@ import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller'; import { SearchController } from '@store/controllers/search-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle'; import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid'; import { RovingGridController } from '@utils/roving-grid';
import { gridColumnsFor, gridSpacingFor } from '@utils/grid-spacing';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store'; import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -98,36 +97,19 @@ export class CoverGrid
private lastAlbumsRef: library.Album[] | null = private lastAlbumsRef: library.Album[] | null =
null; null;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5; private static readonly CARD_PADDING = 5;
private ctxMenu = new ContextMenuController(this); private ctxMenu = new ContextMenuController(this);
private favCtrl = new FavoritesController(this); private favCtrl = new FavoritesController(this);
private selMgr = new AlbumSelectionManager(); private selMgr = new AlbumSelectionManager();
private scrollMgr = new ScrollManager(this, { private scrollMgr = new ScrollManager(this, {
columnsFor: (width: number) => this.columnsFor(width), GRID_GAP: CoverGrid.GRID_GAP,
spacingFor: (width: number) => this.spacingFor(width), GRID_PADDING: CoverGrid.GRID_PADDING,
}); });
/**
* How many cards fit across `width`, by the same arithmetic the
* virtualizer's `space-evenly` grid uses — no gap and no padding
* are reserved, because both come out of what is left over.
*
* The scroll manager restores a position by rebuilding the grid's
* geometry, so this and `spacingFor` must agree with the layout
* rather than approximate it; they were two constants that no
* longer describe anything once the spacing became elastic.
*/
columnsFor(width: number): number {
return gridColumnsFor(width, this.cardWidth);
}
/** The spacing that width produces: between columns, between rows,
* and around the outside, all the same number. */
spacingFor(width: number): number {
return gridSpacingFor(width, this.cardWidth);
}
private lastSelectedAlbumIndex: number | null = null; private lastSelectedAlbumIndex: number | null = null;
private lastSelectedTrackIndex: number | null = null; private lastSelectedTrackIndex: number | null = null;
@@ -166,30 +148,10 @@ export class CoverGrid
} }
// Virtualizer grid layout instance — recreated when // Virtualizer grid layout instance — recreated when
// the card size or the container width changes. // the card size changes.
private gridLayout = this.createGridLayout(); private gridLayout = this.createGridLayout();
private gridLayoutWidth = 0; private gridLayoutWidth = 0;
/** The spacing the current layouts were built with. */
private gridLayoutSpacing = 0;
/** Watches the scroll container so a window resize rebuilds the
* layout: the spacing is derived from its width, and nothing else
* asks this component to update when only that changes. */
private gridResizeObserver: ResizeObserver | null =
null;
private observeGridWidth(): void {
const container = this.scrollContainer;
if (!container || this.gridResizeObserver) return;
this.gridResizeObserver = new ResizeObserver(
() => this.requestUpdate(),
);
this.gridResizeObserver.observe(container);
}
/** /**
* Secondary layout for the "after" virtualizer in * Secondary layout for the "after" virtualizer in
* split mode. Uses zero top padding so there is no * split mode. Uses zero top padding so there is no
@@ -207,49 +169,22 @@ export class CoverGrid
} }
const h = w + this.cardTextHeight; const h = w + this.cardTextHeight;
const gap = CoverGrid.GRID_GAP;
// The spacing is whatever the row could not spend on another const pad = CoverGrid.GRID_PADDING;
// card, shared out equally — so it is the same number between
// two cards, between two rows, and down each outside edge.
// See `utils/grid-spacing.ts` for why it is computed rather
// than handed to the virtualizer as `space-evenly`.
const spacing = this.spacingFor(
this.containerWidth,
);
if (!noTopPad) {
this.gridLayoutSpacing = spacing;
}
return grid({ return grid({
itemSize: { itemSize: {
width: `${w}px`, width: `${w}px`,
height: `${h}px`, height: `${h}px`,
}, },
gap: `${spacing}px`, gap: `${gap}px`,
padding: noTopPad padding: noTopPad
? `0 ${spacing}px ${spacing}px` ? `0 ${pad}px ${pad}px`
: `${spacing}px`, : `${pad}px`,
justify: 'start', justify: 'center',
}); });
} }
/**
* The width the grid lays itself out in.
*
* Read from the scroll container when there is one; before the
* first render there is not, and the fallback only has to be
* plausible — the layout is rebuilt from the real width as soon as
* one exists.
*/
private get containerWidth(): number {
return (
this.scrollContainer?.clientWidth ||
this.clientWidth ||
0
);
}
private dragImageEl: HTMLElement | null = null; private dragImageEl: HTMLElement | null = null;
// -- Memoisation caches for filtered albums -- // -- Memoisation caches for filtered albums --
@@ -531,9 +466,6 @@ export class CoverGrid
); );
this.wheelListenerAttached = false; this.wheelListenerAttached = false;
this.gridResizeObserver?.disconnect();
this.gridResizeObserver = null;
this.scrollMgr.teardown(); this.scrollMgr.teardown();
this.scrollMgr.revealContainer( this.scrollMgr.revealContainer(
this.scrollContainer, this.scrollContainer,
@@ -671,18 +603,10 @@ export class CoverGrid
this.wheelListenerAttached = true; this.wheelListenerAttached = true;
} }
this.observeGridWidth(); // Recreate the virtualizer grid layout when
// the card size changes.
// Recreate the virtualizer grid layout when the card size
// changes — or when the spacing the container width produces
// does, since that is now a derived number rather than a
// constant. Keyed on the spacing rather than on the width, or
// every pixel of a drag rebuilds a layout that would come out
// the same.
const cardSizeChanged = const cardSizeChanged =
this.gridLayoutWidth !== this.cardWidth || this.gridLayoutWidth !== this.cardWidth;
this.gridLayoutSpacing !==
this.spacingFor(this.containerWidth);
if (cardSizeChanged) { if (cardSizeChanged) {
this.gridLayout = this.createGridLayout(); this.gridLayout = this.createGridLayout();
@@ -6,21 +6,12 @@ import type { LibraryController } from '@store/controllers/library-controller';
import type { GridEntry } from './cover-grid-types.js'; import type { GridEntry } from './cover-grid-types.js';
/** /**
* Grid geometry, asked of the host rather than written down. * Grid spacing constants shared between the scroll
* * manager and the host component.
* These were two constants, `GRID_GAP` and `GRID_PADDING`, which stopped
* describing anything the moment the grid's spacing became elastic: the
* gap, the padding and the column count are all derived from the
* container width now, and a scroll position rebuilt from a stale 8px
* lands in the wrong row.
*/ */
export interface GridConstants { export interface GridConstants {
/** Columns that fit across `width`. */ readonly GRID_GAP: number;
columnsFor(width: number): number; readonly GRID_PADDING: number;
/** The spacing `width` produces — between columns, between rows,
* and around the outside, all the same number. */
spacingFor(width: number): number;
} }
/** /**
@@ -284,8 +275,8 @@ export class ScrollManager {
return; return;
} }
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
const pad = gap; const pad = this.gc.GRID_PADDING;
const rowStep = const rowStep =
this.host.cardHeight + gap; this.host.cardHeight + gap;
@@ -302,7 +293,7 @@ export class ScrollManager {
() => { () => {
const rowStep = const rowStep =
this.host.cardHeight + this.host.cardHeight +
this.spacing(container); this.gc.GRID_GAP;
if (this.pendingFocus === null) { if (this.pendingFocus === null) {
this.isResizing = true; this.isResizing = true;
@@ -360,7 +351,7 @@ export class ScrollManager {
container: HTMLElement, container: HTMLElement,
rowStep: number, rowStep: number,
): void { ): void {
const pad = this.spacing(container); const pad = this.gc.GRID_PADDING;
const cols = this.currentColumnCount; const cols = this.currentColumnCount;
const filtered = const filtered =
this.host.cachedFilteredAlbums; this.host.cachedFilteredAlbums;
@@ -419,15 +410,17 @@ export class ScrollManager {
): number { ): number {
if (!container) return 1; if (!container) return 1;
return this.gc.columnsFor( const gap = this.gc.GRID_GAP;
container.clientWidth, const pad = this.gc.GRID_PADDING;
); const availableWidth =
} container.clientWidth - pad * 2;
/** The grid's current spacing, which is also its padding. */ return Math.max(
private spacing(container?: HTMLElement): number { 1,
return this.gc.spacingFor( Math.floor(
container?.clientWidth ?? 800, (availableWidth + gap) /
(this.host.cardWidth + gap),
),
); );
} }
@@ -446,7 +439,7 @@ export class ScrollManager {
container?: HTMLElement, container?: HTMLElement,
): number { ): number {
const cols = this.getColumnCount(container); const cols = this.getColumnCount(container);
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
return ( return (
cols * this.host.cardWidth + cols * this.host.cardWidth +
@@ -467,7 +460,7 @@ export class ScrollManager {
const cols = this.getColumnCount(container); const cols = this.getColumnCount(container);
const colIndex = idx % cols; const colIndex = idx % cols;
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
return ( return (
colIndex * colIndex *
@@ -604,8 +597,8 @@ export class ScrollManager {
if (!this.host.splitMode) return raw; if (!this.host.splitMode) return raw;
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
const pad = gap; const pad = this.gc.GRID_PADDING;
const columns = const columns =
this.getColumnCount(container); this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap; const rowStep = this.host.cardHeight + gap;
@@ -685,8 +678,8 @@ export class ScrollManager {
if (expandedIndex < 0) return; if (expandedIndex < 0) return;
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
const pad = gap; const pad = this.gc.GRID_PADDING;
const columns = const columns =
this.getColumnCount(container); this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap; const rowStep = this.host.cardHeight + gap;
@@ -779,8 +772,8 @@ export class ScrollManager {
if (idx < 0) return; if (idx < 0) return;
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
const pad = gap; const pad = this.gc.GRID_PADDING;
const cols = const cols =
this.getColumnCount(container); this.getColumnCount(container);
const rowStep = this.host.cardHeight + gap; const rowStep = this.host.cardHeight + gap;
@@ -861,8 +854,9 @@ export class ScrollManager {
this.getExpandedAlbumIndex(); this.getExpandedAlbumIndex();
if (idx >= 0) { if (idx >= 0) {
const gap = this.spacing(container); const gap = this.gc.GRID_GAP;
const pad = gap; const pad =
this.gc.GRID_PADDING;
const cols = const cols =
this.getColumnCount( this.getColumnCount(
container, container,
@@ -400,7 +400,7 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
private renderEmptyRequests() { private renderEmptyRequests() {
return html` return html`
<div class="empty"> <div class="empty">
Nothing requested yet. Use “Request this” on an album or artist Nothing requested yet. Use “Want this” on an album or artist
to add it here. to add it here.
</div> </div>
`; `;
@@ -2680,7 +2680,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
slot="start" slot="start"
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'} name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
></wa-icon> ></wa-icon>
${this.isRequested ? 'Requested' : 'Request this'} ${this.isRequested ? 'Wanted' : 'Want this'}
</wa-button> </wa-button>
`; `;
} }
@@ -2753,7 +2753,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
slot="icon" slot="icon"
name=${requested ? 'xmark' : 'bookmark'} name=${requested ? 'xmark' : 'bookmark'}
></wa-icon> ></wa-icon>
${requested ? 'Cancel Request' : 'Request This'} ${requested ? 'Cancel Request' : 'Want This'}
</wa-dropdown-item> </wa-dropdown-item>
` `
: nothing} : nothing}
@@ -1509,24 +1509,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
if (url) { if (url) {
this.thumbnailCache.set(req.mbid, url); this.thumbnailCache.set(req.mbid, url);
this.requestUpdate(); this.requestUpdate();
return;
} }
// An empty answer is not necessarily "there
// is no art" — a slow Internet Archive node
// is answered by a timeout, which looks
// exactly the same from here. Drop the
// in-flight marker so the next time this
// release group is on screen it is asked
// again; the backend records a genuine 404
// on disk and answers that one instantly,
// so a real miss costs nothing to re-ask.
this.thumbnailCache.delete(req.mbid);
}) })
.catch(() => { .catch(() => {});
this.thumbnailCache.delete(req.mbid);
});
} }
}) })
.catch(() => { .catch(() => {
@@ -10,7 +10,6 @@ import type {
VisibilityChangedEvent, VisibilityChangedEvent,
} from '@lit-labs/virtualizer'; } from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { gridSpacingFor } from '@utils/grid-spacing';
import { import {
GetFilePathsByGenres, GetFilePathsByGenres,
} from '@go/library/library.js'; } from '@go/library/library.js';
@@ -156,6 +155,8 @@ export class GenresView
// ----- Grid spacing constants ----- // ----- Grid spacing constants -----
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5; private static readonly CARD_PADDING = 5;
private get imageSize(): number { private get imageSize(): number {
@@ -184,41 +185,20 @@ export class GenresView
private createGridLayout() { private createGridLayout() {
const w = this.cardSize ?? CARD_SIZE_DEFAULT; const w = this.cardSize ?? CARD_SIZE_DEFAULT;
const h = w + this.cardTextHeight; const h = w + this.cardTextHeight;
const gap = GenresView.GRID_GAP;
// One number for the gap, the row gap and the padding: whatever const pad = GenresView.GRID_PADDING;
// a row could not spend on another card, shared out equally, so
// the outside is never wider than the inside. See
// `utils/grid-spacing.ts`.
const spacing = this.spacingFor(this.containerWidth);
this.lastLayoutSpacing = spacing;
return grid({ return grid({
itemSize: { itemSize: {
width: `${w}px`, width: `${w}px`,
height: `${h}px`, height: `${h}px`,
}, },
gap: `${spacing}px`, gap: `${gap}px`,
padding: `${spacing}px`, padding: `${pad}px`,
justify: 'start', justify: 'center',
}); });
} }
/** The width the grid lays itself out in. */
private get containerWidth(): number {
return (
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
)?.clientWidth ||
this.clientWidth ||
0
);
}
private spacingFor(width: number): number {
return gridSpacingFor(width, this.cardSize);
}
/** Sort key and direction for the genre grid (H-19: it had none). */ /** Sort key and direction for the genre grid (H-19: it had none). */
@state() @state()
private sortField: 'name' | 'tracks' = 'name'; private sortField: 'name' | 'tracks' = 'name';
@@ -503,8 +483,6 @@ export class GenresView
override disconnectedCallback() { override disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this.detachWheelListener(); this.detachWheelListener();
this.gridResizeObserver?.disconnect();
this.gridResizeObserver = null;
} }
/** See artists-view: off-screen the grid cannot be scrolled, and /** See artists-view: off-screen the grid cannot be scrolled, and
@@ -759,34 +737,10 @@ export class GenresView
* ================================================================ */ * ================================================================ */
private lastLayoutWidth = 0; private lastLayoutWidth = 0;
private lastLayoutSpacing = 0;
/** Watches the scroller so a window resize rebuilds the layout:
* the spacing is derived from its width, and nothing else asks
* this view to update when only that changes. */
private gridResizeObserver: ResizeObserver | null = null;
private observeGridWidth() {
const container =
this.renderRoot?.querySelector<HTMLElement>(
'.grid-scroll-container',
);
if (!container || this.gridResizeObserver) return;
this.gridResizeObserver = new ResizeObserver(() =>
this.requestUpdate(),
);
this.gridResizeObserver.observe(container);
}
private updateGridLayout() { private updateGridLayout() {
this.observeGridWidth();
if ( if (
this.cardSize === this.lastLayoutWidth && this.cardSize === this.lastLayoutWidth
this.lastLayoutSpacing ===
this.spacingFor(this.containerWidth)
) { ) {
return; return;
} }
@@ -48,7 +48,7 @@ export type LibraryStatus =
* *
* Colours and glyphs: * Colours and glyphs:
* - in-library → green circle, check mark * - in-library → green circle, check mark
* - queued → amber circle, bookmark ("on your list") * - queued → amber circle, hourglass
* - not-in-library → grey circle, plus sign * - not-in-library → grey circle, plus sign
* *
* Usage: * Usage:
@@ -241,23 +241,12 @@ export class LibraryStatusIndicator extends LitElement {
} }
`; `;
/**
* The glyph for each state.
*
* `queued` is a **bookmark**, not the hourglass it used to be. An
* hourglass says "wait, this is under way", which overstates what a
* request is: nothing may be downloading, nothing may ever be found,
* and the user can leave one sitting on the list indefinitely. A
* bookmark says the honest thing — it is on your list — and reads as
* the opposite of the plus that put it there, which is what a
* toggle's two states have to do.
*/
private iconName(): string { private iconName(): string {
switch (this.status) { switch (this.status) {
case 'in-library': case 'in-library':
return 'check'; return 'check';
case 'queued': case 'queued':
return 'bookmark'; return 'hourglass-half';
default: default:
return 'plus'; return 'plus';
} }
@@ -287,7 +276,7 @@ export class LibraryStatusIndicator extends LitElement {
if (this.actionable) { if (this.actionable) {
return this.status === 'queued' return this.status === 'queued'
? `Cancel the request for ${kind}${name}` ? `Cancel the request for ${kind}${name}`
: `Request ${kind}${name}`; : `Want ${kind}${name}`;
} }
switch (this.status) { switch (this.status) {
@@ -365,6 +354,25 @@ export class LibraryStatusIndicator extends LitElement {
} }
const title = this.tooltip(); const title = this.tooltip();
const icon = this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing;
if (this.actionable) {
return html`
<button
class="badge"
type="button"
title=${title}
aria-label=${title}
?disabled=${this.busy}
@click=${this.onActivate}
@keydown=${this.onKeydown}
>
${icon}
</button>
`;
}
// The ring stands in for the icon wherever the icon would go — // The ring stands in for the icon wherever the icon would go —
// including inside the button, because a partly-held album is // including inside the button, because a partly-held album is
@@ -307,7 +307,7 @@ export class NowPlayingView extends LitElement {
: `Add ${track.title} to ${this.favCtrl.playlistName}`} : `Add ${track.title} to ${this.favCtrl.playlistName}`}
@click=${this.toggleFavorite} @click=${this.toggleFavorite}
> >
<wa-icon name=${this.favCtrl.iconFor(favorited)}></wa-icon> <wa-icon name=${this.favCtrl.iconName}></wa-icon>
</button> </button>
</div> </div>
@@ -527,7 +527,7 @@ export class NowPlaying extends LitElement {
)} )}
> >
<wa-icon <wa-icon
name=${this.favCtrl.iconFor(isFav)} name=${this.favCtrl.iconName}
variant=${favVariant} variant=${favVariant}
></wa-icon> ></wa-icon>
</button> </button>
@@ -1756,7 +1756,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
${entry.summary.ID === this.favCtrl.playlistId ${entry.summary.ID === this.favCtrl.playlistId
? html`<wa-icon ? html`<wa-icon
class="playlist-icon" class="playlist-icon"
name=${this.favCtrl.iconFor(true)} name=${this.favCtrl.iconName}
></wa-icon>` ></wa-icon>`
: entry.summary.IsSmart : entry.summary.IsSmart
? html`<wa-icon ? html`<wa-icon
-1
View File
@@ -16,7 +16,6 @@
# fetch-icons.mjs for why that is not negotiable. Re-vendor with: # fetch-icons.mjs for why that is not negotiable. Re-vendor with:
# node frontend/scripts/fetch-icons.mjs # node frontend/scripts/fetch-icons.mjs
regular/heart regular/heart
regular/star
solid/arrow-down-wide-short solid/arrow-down-wide-short
solid/arrow-left solid/arrow-left
solid/arrow-rotate-right solid/arrow-rotate-right
@@ -74,36 +74,12 @@ export class FavoritesController
} }
/** /**
* The icon name for the current icon style, unfilled. * Returns the icon name for the current icon style.
*
* Prefer `iconFor(isFav)` — this getter is the name of the *empty*
* glyph, which is what every caller that does not know the state
* should draw.
*/ */
get iconName(): string { get iconName(): string {
return this.iconFor(false); return this.iconStyle === 'star'
} ? 'star'
: 'heart';
/**
* The glyph for one track's favourite state.
*
* **A filled shape means favourited and an outline means not**, in
* every list in the app. Nine components rendered `iconName`, which
* was the *solid* glyph in both states — so "not a favourite" was a
* filled heart in a duller colour, and the only thing separating
* the two states was hue. That fails for anyone who cannot see the
* difference between them, and reads as "everything is a favourite"
* to everyone else. `track-list` and `album-dropdown` already drew
* it correctly, from inline SVG paths of their own; this is the
* same rule for the `<wa-icon>` call sites.
*
* The Font Awesome family is part of the name — `regular/heart` is
* the outline, a bare `heart` is the solid one (`src/icons`).
*/
iconFor(favorited: boolean): string {
const shape = this.iconStyle === 'star' ? 'star' : 'heart';
return favorited ? shape : `regular/${shape}`;
} }
// =============================================================== // ===============================================================
-58
View File
@@ -1,58 +0,0 @@
/**
* Even spacing for the three card grids — albums, artists, genres.
*
* All three used `justify: 'center'` with a fixed 8px gap and 8px
* padding, which gives the row a fixed width and pushes everything left
* over to the two margins: on a 1440px window the albums grid drew its
* cards 16px apart inside 78px of nothing down each side. The outside
* was five times the inside.
*
* The fix is to spend the leftover on the spacing instead, so there is
* one number: between two cards, between two rows, and down each edge.
* The virtualizer has a word for that — `justify: 'space-evenly'` with
* `gap: 'auto'` — and it cannot be used, because it fits
* `floor(width / cardWidth)` columns without reserving the gap it is
* about to need: a width one card short of exact fits seven cards a
* pixel apart. Deciding the column count here is what puts a floor
* under the spacing, and the grid is then given plain numbers.
*/
/** The narrowest the spacing is allowed to get. */
export const MIN_GRID_SPACING = 8;
/**
* How many cards of `cardWidth` fit across `width`.
*
* A row of c cards spends c×cardWidth on cards and (c+1)×spacing on the
* spaces between and beside them, so c is bounded by
* (width − spacing) / (cardWidth + spacing) at the minimum spacing.
*/
export function gridColumnsFor(
width: number,
cardWidth: number,
): number {
if (cardWidth <= 0) return 1;
const fit = Math.floor(
(width - MIN_GRID_SPACING) / (cardWidth + MIN_GRID_SPACING),
);
return Math.max(1, fit);
}
/**
* The spacing `width` produces — the gap, the row gap and the padding,
* which are all the same number.
*/
export function gridSpacingFor(
width: number,
cardWidth: number,
): number {
const columns = gridColumnsFor(width, cardWidth);
const leftover = width - columns * cardWidth;
return Math.max(
MIN_GRID_SPACING,
Math.floor(leftover / (columns + 1)),
);
}
@@ -120,7 +120,7 @@ describe('the context menu on an artist page release', () => {
expect(items).toContain('Add to Queue'); expect(items).toContain('Add to Queue');
expect(items).toContain('Play Next'); expect(items).toContain('Play Next');
// Owned: there is nothing left to ask for. // Owned: there is nothing left to ask for.
expect(items).not.toContain('Request This'); expect(items).not.toContain('Want This');
}); });
it('offers a request, and no playback, for a release nobody owns', async () => { it('offers a request, and no playback, for a release nobody owns', async () => {
@@ -132,7 +132,7 @@ describe('the context menu on an artist page release', () => {
expect(items).not.toContain('Play'); expect(items).not.toContain('Play');
expect(items).not.toContain('Add to Queue'); expect(items).not.toContain('Add to Queue');
expect(items).toContain('Request This'); expect(items).toContain('Want This');
expect(items).toContain('View on MusicBrainz'); expect(items).toContain('View on MusicBrainz');
}); });
@@ -148,7 +148,7 @@ describe('the context menu on an artist page release', () => {
// …but a `local:` id names nothing upstream, and wanting something // …but a `local:` id names nothing upstream, and wanting something
// already in the library is not a thing to offer. // already in the library is not a thing to offer.
expect(items).not.toContain('View on MusicBrainz'); expect(items).not.toContain('View on MusicBrainz');
expect(items).not.toContain('Request This'); expect(items).not.toContain('Want This');
}); });
it('opens from the keyboard on Shift+F10', async () => { it('opens from the keyboard on Shift+F10', async () => {
+1 -1
View File
@@ -166,7 +166,7 @@ describe('<library-status-indicator>', () => {
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name')); glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
} }
expect(glyphs).toEqual(['check', 'bookmark', 'plus']); expect(glyphs).toEqual(['check', 'hourglass-half', 'plus']);
}); });
it('phrases its label around the entity it describes', async () => { it('phrases its label around the entity it describes', async () => {
@@ -249,7 +249,7 @@ describe('<library-status-indicator> as a control', () => {
const el = await badge({ requestMbid: 'rg-1' }); const el = await badge({ requestMbid: 'rg-1' });
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe( expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Request album "Abbey Road"', 'Want album "Abbey Road"',
); );
await update(el, { status: 'queued' }); await update(el, { status: 'queued' });
+2
View File
@@ -0,0 +1,2 @@
{
}
+1 -12
View File
@@ -1,17 +1,6 @@
# Maintainer: yonlu <yj@yellowjacket.app> # Maintainer: yonlu <yj@yellowjacket.app>
pkgname=yellowjacket pkgname=yellowjacket
# A fallback and the default tag for a manual build; pkgver() below is what pkgver=1.3.0
# actually decides the version, from the clone's own git history. Versions
# restarted at 0.0.1 when releases became automatic (plan 017) — which is a
# *downgrade* from the 1.x packages already in the registry, so pacman offers
# no upgrade and an existing install has to be removed and reinstalled once:
#
# pacman -R yellowjacket && pacman -S yellowjacket
#
# `epoch=1` would have avoided that for one line, and was declined: an epoch
# can never be removed, and it would put a permanent `1:` in front of every
# version string this package will ever have.
pkgver=0.0.1
pkgrel=1 pkgrel=1
pkgdesc="Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag" pkgdesc="Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag"
arch=('x86_64') arch=('x86_64')
-15
View File
@@ -22,21 +22,6 @@ brew install shadow-puppet/yellowjacket/yellowjacket
no separate `brew tap` step. To build the tip of `main` instead of the latest no separate `brew tap` step. To build the tip of `main` instead of the latest
release, add `--HEAD`. release, add `--HEAD`.
### Upgrading from 1.x needs a reinstall, once
Releases became automatic and restarted at **0.0.1** (plan 017), which is
*lower* than the `1.3.0` this tap last published. Homebrew compares versions
and has no equivalent of pacman's `epoch`, so `brew upgrade` sees a downgrade
and offers **nothing at all** — silently, which is indistinguishable from the
tap having gone stale.
```bash
brew uninstall yellowjacket && brew install shadow-puppet/yellowjacket/yellowjacket
```
Nothing is stored inside the Cellar, so this costs a rebuild and no data. It is
a one-time step: 0.0.2 onwards upgrade normally.
## How publishing works ## How publishing works
This directory holds the **canonical** formula. The tap users install from lives This directory holds the **canonical** formula. The tap users install from lives
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
#
# Create the Gitea release for a version semantic-release has just tagged.
#
# This is `@semantic-release/exec`'s publishCmd, and it exists because
# Gitea's API is /api/v1 and @semantic-release/github speaks GitHub's.
# That is the whole of the Gitea-shaped work: one POST.
#
# **The notes come from a file, not from an argument.** Release notes are
# rendered commit messages — arbitrary text carrying backticks, quotes and
# `$` — so interpolating ${nextRelease.notes} into a shell command would
# be an injection whose input is the commit log. @semantic-release/changelog
# has already written them to .release-notes.md by the time `publish` runs,
# so the only thing crossing the shell boundary here is a semver string,
# which is validated below anyway.
#
# That file is a gitignored build artifact, not a document: `main` is a
# protected branch, so nothing commits a changelog back to it and the
# release page is the changelog. See .releaserc.yml.
#
# Usage: scripts/gitea-release.sh <version> # e.g. 0.0.1
#
# Environment (all set by .gitea/workflows/release.yml):
# SERVER_URL https://git.ljones.me
# OWNER yonlu
# REPO yonlu/yellowjacket
# PACKAGE_TOKEN a user PAT with write access
set -euo pipefail
cd "$(dirname "$0")/.."
version="${1:?usage: gitea-release.sh <version>}"
# Validated rather than trusted: this is the one value that reaches a URL
# and a JSON document, and semantic-release is not the only thing that
# could ever call this.
if ! printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "gitea-release: '$version' is not a semver version" >&2
exit 1
fi
: "${SERVER_URL:?SERVER_URL is not set}"
: "${REPO:?REPO is not set}"
: "${PACKAGE_TOKEN:?PACKAGE_TOKEN is not set}"
tag="v${version}"
# The top section of the changelog is this release's notes: everything
# from the first `## ` heading to the one after it. awk rather than sed
# so the "there is no second heading" case (the first release) needs no
# special handling.
notes=$(awk '
/^## / { seen++; if (seen > 1) exit }
seen { print }
' .release-notes.md)
if [ -z "$notes" ]; then
echo "gitea-release: found no release section at the top of .release-notes.md" >&2
echo ' the changelog plugin runs in prepare and this runs in publish, so' >&2
echo ' an empty section means the plugin order in .releaserc.yml moved.' >&2
exit 1
fi
echo "gitea-release: creating $tag from $(printf '%s' "$notes" | wc -l) lines of notes"
# jq builds the body, so a backtick or a quote in a commit subject is data
# rather than syntax.
payload=$(jq -n \
--arg tag "$tag" \
--arg name "$tag" \
--arg body "$notes" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
code=$(curl -sS -o /tmp/gitea-release.out -w '%{http_code}' \
-X POST \
-H "Authorization: token ${PACKAGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload" \
"${SERVER_URL}/api/v1/repos/${REPO}/releases")
case "$code" in
201)
echo "gitea-release: created ${SERVER_URL}/${REPO}/releases/tag/${tag}"
;;
409)
# Already there. The correct outcome for a re-run of the same tag,
# and not a failure — the publish workflows are idempotent for the
# same reason.
echo "gitea-release: $tag already has a release; leaving it alone"
;;
*)
echo "gitea-release: POST /releases returned $code" >&2
cat /tmp/gitea-release.out >&2
exit 1
;;
esac
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
#
# Attach a built artifact to the Gitea release for a tag.
#
# **It waits for the release to exist, and that is the point of the
# file.** semantic-release pushes the tag in its `prepare` step and
# creates the release object in `publish` — so the tag push, which is
# what starts every publishing workflow, happens *before* there is a
# release id to upload to. A fast publisher can therefore arrive first.
#
# The runner has capacity 1, which serialises things enough that this
# would usually work by accident; that is the worst kind of bug, so the
# wait is explicit and a timeout is a loud failure rather than a silently
# skipped asset.
#
# Usage: scripts/release-asset.sh <tag> <file> [upload-name]
#
# Environment:
# SERVER_URL https://git.ljones.me
# REPO yonlu/yellowjacket
# PACKAGE_TOKEN a user PAT with write access
set -euo pipefail
tag="${1:?usage: release-asset.sh <tag> <file> [name]}"
file="${2:?usage: release-asset.sh <tag> <file> [name]}"
name="${3:-$(basename "$file")}"
: "${SERVER_URL:?SERVER_URL is not set}"
: "${REPO:?REPO is not set}"
: "${PACKAGE_TOKEN:?PACKAGE_TOKEN is not set}"
[ -s "$file" ] || { echo "release-asset: $file is missing or empty" >&2; exit 1; }
auth="Authorization: token ${PACKAGE_TOKEN}"
api="${SERVER_URL}/api/v1/repos/${REPO}"
# Up to five minutes. A release that has not appeared by then means the
# release job failed, and this should say so rather than time out quietly.
release_id=""
for attempt in $(seq 1 60); do
release_id=$(curl -sS -H "$auth" "${api}/releases/tags/${tag}" |
jq -r 'if type == "object" and has("id") then .id else empty end')
if [ -n "$release_id" ]; then
echo "release-asset: release for $tag is id $release_id (after ${attempt} check(s))"
break
fi
[ "$attempt" -eq 1 ] && echo "release-asset: waiting for the release for $tag to be created"
sleep 5
done
if [ -z "$release_id" ]; then
echo "release-asset: no release for $tag after 5 minutes." >&2
echo " The tag is pushed in semantic-release's prepare step and the release" >&2
echo " is created in publish, so this means the release job did not get that" >&2
echo " far. Check the run of release.yml for this commit." >&2
exit 1
fi
# Gitea refuses a duplicate asset name rather than replacing it, so a
# re-run of the same tag deletes the old one first. That keeps a manual
# workflow_dispatch rebuild idempotent, which is the only reason anyone
# re-runs one of these.
existing=$(curl -sS -H "$auth" "${api}/releases/${release_id}/assets" |
jq -r --arg n "$name" '.[]? | select(.name == $n) | .id')
if [ -n "$existing" ]; then
echo "release-asset: replacing the existing '$name' (asset $existing)"
curl -sS -o /dev/null -H "$auth" -X DELETE \
"${api}/releases/${release_id}/assets/${existing}"
fi
echo "release-asset: uploading $name ($(du -h "$file" | cut -f1))"
code=$(curl -sS -o /tmp/release-asset.out -w '%{http_code}' \
-H "$auth" \
-X POST \
-F "attachment=@${file};filename=${name}" \
"${api}/releases/${release_id}/assets?name=${name}")
if [ "$code" != "201" ]; then
echo "release-asset: upload returned $code" >&2
cat /tmp/release-asset.out >&2
exit 1
fi
echo "release-asset: attached $name to $tag"
+14 -71
View File
@@ -1,48 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Every command in the agent-facing docs is a `make` target on purpose: # Every command in .pi/ is a `make` target on purpose: the Makefile is
# the Makefile is the source of truth for *how* to invoke something, and # the source of truth for *how* to invoke something, and the skill only
# the docs only decide *which* and *in what order*. This check keeps # decides *which* and *in what order*. This check keeps that honest —
# that honest — a renamed or deleted target turns into a failing commit # a renamed or deleted target turns into a failing commit rather than
# rather than into an agent confidently running a command that no longer # into an agent confidently running a command that no longer exists.
# exists.
# #
# It checks two things. Usage: scripts/skill-check.sh # It extracts every `make <target>` mentioned under .pi/ and asserts the
# # target exists. Usage: scripts/skill-check.sh
# **Every `make <target>` named in an agent-facing doc exists.** The
# scanned set is `.pi/` *and* CLAUDE.md, which is the half that was
# missing: CLAUDE.md names 27 targets and nothing verified one of them,
# so the file the agents trust most was the file least checked.
#
# **AGENTS.md is a symlink to CLAUDE.md.** This repo is worked on by
# two agent harnesses that read different files by convention — Claude
# Code reads CLAUDE.md, others read AGENTS.md — and two harnesses
# reading two descriptions of one project is how they come to hold
# different beliefs about it. A symlink makes that impossible by
# construction; a *copy* would pass every other check in this repo while
# silently drifting, which is exactly the failure being prevented, so
# the symlink itself is asserted rather than its contents compared.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# The symlink half runs even without .pi/, since it is not about .pi/.
if [ -e AGENTS.md ] || [ -L AGENTS.md ]; then
if [ ! -L AGENTS.md ]; then
echo "skill-check: AGENTS.md is a regular file, not a symlink to CLAUDE.md." >&2
echo " Two harnesses would read two descriptions of one project." >&2
echo " Fix: rm AGENTS.md && ln -s CLAUDE.md AGENTS.md" >&2
exit 1
fi
target="$(readlink AGENTS.md)"
if [ "$target" != "CLAUDE.md" ]; then
echo "skill-check: AGENTS.md points at '$target', expected CLAUDE.md." >&2
exit 1
fi
fi
[ -d .pi ] || exit 0 [ -d .pi ] || exit 0
# `make -pq` prints the database including every rule, without running # `make -pq` prints the database including every rule, without running
@@ -52,37 +21,11 @@ targets="$({ make -pqRr 2>/dev/null || true; } |
awk '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {sub(/:.*/, "", $0); print}' | awk '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ {sub(/:.*/, "", $0); print}' |
sort -u)" sort -u)"
# A mention counts only when it is code: backticked (`make ui-test`) # A mention counts only when it is code: backticked (`make ui-test`) or
# anywhere, or at the start of a line **inside a fenced block**. Bare # the first thing on a line, as in a fenced block. Bare prose is not
# prose is not scanned, because English says things like "a renamed make # scanned, because English says things like "a renamed make target".
# target". mentioned="$(grep -rhoE '(`|^)make [a-z][a-z0-9-]*' .pi --include='*.md' |
# sed 's/^`//' | awk '{print $2}' | sort -u)"
# The fence is why this is awk rather than one grep. Line-start alone is
# not evidence of code in a file that is mostly hard-wrapped prose: the
# sentence "Two green branches do not / make a green merge" wrapped onto
# a line beginning `make a`, and the check duly failed on a target called
# `a`. Inside a fence it is code; outside one it is a sentence that
# happened to break there, and a check that fails on reflow gets
# disabled rather than fixed.
#
# AGENTS.md is deliberately not in this list: it is a symlink to
# CLAUDE.md, asserted above, so scanning it would report every failure
# twice under two names.
mentioned="$({ find .pi -name '*.md' 2>/dev/null; echo CLAUDE.md; } |
xargs awk '
FNR == 1 { fence = 0 }
/^```/ { fence = !fence; next }
{
rest = $0
while (match(rest, /`make [a-z][a-z0-9-]*/)) {
print substr(rest, RSTART + 6, RLENGTH - 6)
rest = substr(rest, RSTART + RLENGTH)
}
if (fence && match($0, /^make [a-z][a-z0-9-]*/)) {
print substr($0, 6, RLENGTH - 5)
}
}
' | sort -u)"
missing="" missing=""
@@ -93,10 +36,10 @@ for t in $mentioned; do
done done
if [ -n "$missing" ]; then if [ -n "$missing" ]; then
echo "skill-check: the agent docs name make targets that do not exist:" >&2 echo "skill-check: .pi/ documents make targets that do not exist:" >&2
for t in $missing; do for t in $missing; do
echo " make $t" >&2 echo " make $t" >&2
grep -rln "make $t" .pi CLAUDE.md --include='*.md' | sed 's/^/ /' >&2 grep -rln "make $t" .pi --include='*.md' | sed 's/^/ /' >&2
done done
echo "Fix the docs, or restore the target." >&2 echo "Fix the docs, or restore the target." >&2
exit 1 exit 1