Compare commits
301
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e1c444cc | ||
|
|
4f32d4e13c | ||
|
|
4f628b1f52 | ||
|
|
2100f0022f | ||
|
|
52038dc5ae | ||
|
|
0d331666d6 | ||
|
|
6a5a3c33dc | ||
|
|
1668b9e0d2 | ||
|
|
ec64dbded0 | ||
|
|
dad852a8a0 | ||
|
|
30c6b665f1 | ||
|
|
d034d6e571 | ||
|
|
25ea1f3511 | ||
|
|
842fe47e9e | ||
|
|
168e588387 | ||
|
|
7eb55bd378 | ||
|
|
f31331c83b | ||
|
|
6a22601af7 | ||
|
|
ce9951b93a | ||
|
|
99a45401c7 | ||
|
|
dd76bd2fa7 | ||
|
|
dee176c0f7 | ||
|
|
75a24f98b6 | ||
|
|
327785e5ec | ||
|
|
7ba5d321f6 | ||
|
|
f126dd7397 | ||
|
|
510d3470f9 | ||
|
|
d78830aa52 | ||
|
|
a72d1f68ed | ||
|
|
60f1c5a6b2 | ||
|
|
11ba7b3180 | ||
|
|
7f8e185d7c | ||
|
|
42483c4b61 | ||
|
|
deea6ad06d | ||
|
|
fba608fdbd | ||
|
|
f59490b113 | ||
|
|
6cca57f229 | ||
|
|
ea3edde697 | ||
|
|
14e3ab574c | ||
|
|
4b2eec5703 | ||
|
|
3871d37fdb | ||
|
|
09b005557c | ||
|
|
ef5574d18b | ||
|
|
31dafb0ce0 | ||
|
|
9e7e7ce5a1 | ||
|
|
9aaa8beb99 | ||
|
|
2e29e67664 | ||
|
|
f26b44db08 | ||
|
|
b43172a60c | ||
|
|
2be6fb3066 | ||
|
|
867ced8c81 | ||
|
|
fd71ef53c5 | ||
|
|
e3b64f9255 | ||
|
|
c7e5a4f086 | ||
|
|
f65822c4b2 | ||
|
|
32d4dc2c82 | ||
|
|
218e4f5e99 | ||
|
|
56a5ff99fe | ||
|
|
af4b28b0d7 | ||
|
|
4ee5b4b473 | ||
|
|
a70a7ed9eb | ||
|
|
de2cb2693a | ||
|
|
880adff12c | ||
|
|
d6f7412e9d | ||
|
|
1ab767a317 | ||
|
|
ac8f86eb00 | ||
|
|
47bd9ef211 | ||
|
|
b801fa533a | ||
|
|
8879192097 | ||
|
|
f76ee96ac4 | ||
|
|
23f5a0c53a | ||
|
|
502b814a65 | ||
|
|
c19a806298 | ||
|
|
67eeb75e7b | ||
|
|
fe1fbefee7 | ||
|
|
de04339494 | ||
|
|
998ce75fb6 | ||
|
|
4b392cb4c4 | ||
|
|
8d2109b87e | ||
|
|
b741b01cdf | ||
|
|
8a757c9bb4 | ||
|
|
d714bd7090 | ||
|
|
d64b069053 | ||
|
|
5490b2423e | ||
|
|
ffc9490a32 | ||
|
|
dc6625d33a | ||
|
|
dc8db159f9 | ||
|
|
86e7444603 | ||
|
|
2365806d18 | ||
|
|
7d348f243a | ||
|
|
ddd04623f7 | ||
|
|
9ad1477b1e | ||
|
|
70ab3ddf94 | ||
|
|
4f7529c315 | ||
|
|
bb21072386 | ||
|
|
ead1354e4d | ||
|
|
ae85df0dad | ||
|
|
6e7e349e63 | ||
|
|
f9ba9a87d7 | ||
|
|
e4efec6f0c | ||
|
|
99f355b2fc | ||
|
|
c79d4d47a3 | ||
|
|
8efed2dd2b | ||
|
|
12af6ec1f7 | ||
|
|
f3d1ae1c8c | ||
|
|
5af545e38d | ||
|
|
9da3967dd9 | ||
|
|
c8d94a8203 | ||
|
|
43d78a731a | ||
|
|
a3926704cc | ||
|
|
ea53d4f15b | ||
|
|
ea16e07c46 | ||
|
|
4f47c85208 | ||
|
|
603728a3fb | ||
|
|
018d857746 | ||
|
|
a7ac2b4a3e | ||
|
|
d347809e6e | ||
|
|
a5ffcc22e3 | ||
|
|
f18691560d | ||
|
|
c84a9069ef | ||
|
|
f967916550 | ||
|
|
3fa7c7734b | ||
|
|
cceeb40b16 | ||
|
|
2926ecd4b4 | ||
|
|
ff3c4003cb | ||
|
|
def596a99e | ||
|
|
14f78c0b57 | ||
|
|
7cea238e71 | ||
|
|
4f2f1827ab | ||
|
|
e454e4074b | ||
|
|
c518ac8c73 | ||
|
|
977f624123 | ||
|
|
23f3d4b3b0 | ||
|
|
8d46c4abb7 | ||
|
|
f714fe513d | ||
|
|
087c69ac8d | ||
|
|
bb7dde1963 | ||
|
|
446380e3a9 | ||
|
|
e07f248cc8 | ||
|
|
90ac6e0825 | ||
|
|
4e3c953acf | ||
|
|
ede183d026 | ||
|
|
481c9dca65 | ||
|
|
4025106234 | ||
|
|
a3134f997f | ||
|
|
3607fe445e | ||
|
|
61d549a9d5 | ||
|
|
2b84bc53e9 | ||
|
|
282dab43eb | ||
|
|
cc9df4004c | ||
|
|
b5d70ac1cd | ||
|
|
9118c16fe3 | ||
|
|
fe67849e57 | ||
|
|
21b303ba7c | ||
|
|
9375f25629 | ||
|
|
905654cc84 | ||
|
|
219fa3c615 | ||
|
|
c4e055ce51 | ||
|
|
10eca353ab | ||
|
|
88fc50afb8 | ||
|
|
19c68d73a7 | ||
|
|
41c41a860e | ||
|
|
4bf59b45b7 | ||
|
|
fc99d9e0d7 | ||
|
|
90f1239fba | ||
|
|
b2fe1cb1e0 | ||
|
|
065a879190 | ||
|
|
89882b4863 | ||
|
|
18a08daa91 | ||
|
|
aa59773d22 | ||
|
|
a4777f26b6 | ||
|
|
92faa9741b | ||
|
|
bf0a53e64c | ||
|
|
7be4a02e31 | ||
|
|
ad9c25a5a2 | ||
|
|
a83a127e31 | ||
|
|
4b9114fd8d | ||
|
|
e049a71458 | ||
|
|
0c944f2382 | ||
|
|
75525b67e4 | ||
|
|
85768dc489 | ||
|
|
1a221a40d3 | ||
|
|
0821deb877 | ||
|
|
31ada14111 | ||
|
|
20139394f3 | ||
|
|
eb139cf872 | ||
|
|
ae82fd2233 | ||
|
|
3c3197df4b | ||
|
|
e16bd245bd | ||
|
|
887a9324b4 | ||
|
|
fcb484ead5 | ||
|
|
48de41cd69 | ||
|
|
66a6ee63ab | ||
|
|
10660c8168 | ||
|
|
441b67daaa | ||
|
|
026f26bdf6 | ||
|
|
73dc80bdc9 | ||
|
|
760021ea5a | ||
|
|
63ec068add | ||
|
|
a2ff0aed4c | ||
|
|
12e75ee24c | ||
|
|
792e87298b | ||
|
|
266e7032dd | ||
|
|
d6b48fb3ac | ||
|
|
3bf27e3fd5 | ||
|
|
185eb1b125 | ||
|
|
b3556d825c | ||
|
|
bf4f352117 | ||
|
|
1062b7c0bc | ||
|
|
48abecb830 | ||
|
|
e1c07438e9 | ||
|
|
6e563f3846 | ||
|
|
186f6a5839 | ||
|
|
590a0d86dd | ||
|
|
36af7090d9 | ||
|
|
3e142f8c35 | ||
|
|
3d375adab1 | ||
|
|
e3d492e130 | ||
|
|
e6f30b6e43 | ||
|
|
351798fd66 | ||
|
|
40984f6086 | ||
|
|
786d9c6110 | ||
|
|
0019310ca4 | ||
|
|
1940cb548f | ||
|
|
37e3373db9 | ||
|
|
8d5d8af297 | ||
|
|
9ce79ee416 | ||
|
|
b3a0814f24 | ||
|
|
2c576fa1e8 | ||
|
|
544dbdb4db | ||
|
|
087eb77875 | ||
|
|
6fb7b5ea11 | ||
|
|
369810e06b | ||
|
|
e51cb13662 | ||
|
|
3d65da0529 | ||
|
|
52cbef27c4 | ||
|
|
c03c0b8ec4 | ||
|
|
8c48105ca3 | ||
|
|
1c4d6ca9a1 | ||
|
|
6bf832a4ba | ||
|
|
4f8257ef72 | ||
|
|
b505959934 | ||
|
|
d0250a2133 | ||
|
|
de2b324e20 | ||
|
|
2c78b58207 | ||
|
|
a9852c18a0 | ||
|
|
409bfd5e89 | ||
|
|
0eeef6048e | ||
|
|
4fc0cdeab7 | ||
|
|
eb059a3d71 | ||
|
|
dcabec8b1d | ||
|
|
b3737d30af | ||
|
|
0bfa2136be | ||
|
|
b1cdef8769 | ||
|
|
d661836347 | ||
|
|
28eecf0a97 | ||
|
|
e8690476bd | ||
|
|
7e0be8fa30 | ||
|
|
1b05dde382 | ||
|
|
29299d17da | ||
|
|
57fbbdf0d2 | ||
|
|
df2e9ea777 | ||
|
|
c99c8efa11 | ||
|
|
904786b941 | ||
|
|
b6651310ea | ||
|
|
da38b865fc | ||
|
|
ced537ecf2 | ||
|
|
e14a34fccf | ||
|
|
78576b8da9 | ||
|
|
01706c6053 | ||
|
|
f7dc76c955 | ||
|
|
ed975019dc | ||
|
|
0c7f34ab90 | ||
|
|
a7a33527c4 | ||
|
|
0c6ca72cf1 | ||
|
|
68468e5378 | ||
|
|
6fbb62730d | ||
|
|
48b37f6301 | ||
|
|
66182f82cd | ||
|
|
18aba34c08 | ||
|
|
b98840ee37 | ||
|
|
dd17a4d8eb | ||
|
|
e7748f1fd5 | ||
|
|
1128881e8d | ||
|
|
cad3d1339b | ||
|
|
453d5df0da | ||
|
|
84963e38bd | ||
|
|
deb3f3da7e | ||
|
|
60779c41c3 | ||
|
|
a4ada725a2 | ||
|
|
04114eabae | ||
|
|
162c68769f | ||
|
|
c9905fbcff | ||
|
|
4471db3aef | ||
|
|
f47b2db308 | ||
|
|
e7873bded3 | ||
|
|
edb13a6f39 | ||
|
|
20fbf28f2a | ||
|
|
878cf4b561 | ||
|
|
dc890d1fcc | ||
|
|
dcc40b1781 |
@@ -0,0 +1,491 @@
|
||||
name: Build & publish the Android APK
|
||||
|
||||
# The fifth workflow, and the second that publishes. It builds a signed
|
||||
# arm64-v8a APK on every version tag and puts it in
|
||||
# Gitea's *generic* package registry, which — unlike the repository — is
|
||||
# readable without credentials. That is what lets an Obtainium client
|
||||
# poll a plain URL with no token and no public mirror of the source.
|
||||
#
|
||||
# **Why its own file rather than a job in ci.yml.** `ci.yml` runs on
|
||||
# every branch push and is the workflow that gates; this one runs on
|
||||
# tags only, takes tens of minutes on a cold cache, and the runner has
|
||||
# capacity 1. Hanging it off the gate would put every push behind an
|
||||
# SDK download.
|
||||
#
|
||||
# **Why it is keyed on the tag.** The ljos pipeline this is modelled on
|
||||
# computes a version in CI and cuts the release itself, then gates the
|
||||
# Android job on `needs.release.outputs.version != ''` with an
|
||||
# `always()` whose absence silently kills the manual path. This repo
|
||||
# has no release automation — tags are pushed by hand and
|
||||
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
|
||||
# version and none of that machinery, or its failure modes, is needed.
|
||||
#
|
||||
# It deliberately does **not** carry `continue-on-error`. In ljos the
|
||||
# Android job shared a pipeline with a server deploy that must never go
|
||||
# red over a phone build; here it is standalone and can neither delay
|
||||
# nor redden anything, so a release step that fails silently would be
|
||||
# strictly worse than one that fails visibly.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (default: the latest v* tag)"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
apk:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
# /cache/tool holds the Go toolchain ci.yml already downloads.
|
||||
# The other three are this workflow's own and are ~4 GB between
|
||||
# them, which is most of its wall clock on a cold run:
|
||||
# android-sdk the SDK, the NDK and the platform (~2 GB)
|
||||
# gradle GRADLE_USER_HOME — the wrapper distribution and
|
||||
# the AGP dependency graph (~700 MB)
|
||||
# pnpm-store shared with ci.yml
|
||||
# Every path must be inside the runner's `valid_volumes` allowlist:
|
||||
# a directory outside it makes the job **fail to start**, rather
|
||||
# than silently skipping the mount.
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||
- /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
|
||||
- /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 }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
SHA: ${{ github.sha }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GO_VERSION: '1.25.0'
|
||||
npm_config_store_dir: /cache/pnpm-store
|
||||
# The Go half wants the NDK; the Gradle half wants a platform.
|
||||
ANDROID_HOME: /cache/android-sdk
|
||||
ANDROID_SDK_ROOT: /cache/android-sdk
|
||||
GRADLE_USER_HOME: /cache/gradle
|
||||
# Pinned, not "whatever sdkmanager installs": newer NDKs have
|
||||
# broken the Wails Android build before, and r26d is what plan
|
||||
# 015 phase 0 was verified against.
|
||||
NDK_VERSION: 26.3.11579264
|
||||
# The registry package name. Obtainium watches
|
||||
# <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
|
||||
PACKAGE_NAME: yellowjacket-android
|
||||
|
||||
steps:
|
||||
# libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
|
||||
# nothing in this job builds a desktop app: `wails3` is the task
|
||||
# runner the whole Android build goes through, and the CLI links
|
||||
# the GTK/WebKit bindings, so `go tool wails3` cannot compile
|
||||
# without them. libasound2-dev is oto's `pkg-config -- alsa`
|
||||
# probe, for the same reason (the *Android* build uses oboe, not
|
||||
# ALSA — this is the host toolchain only).
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl git jq unzip zip \
|
||||
build-essential pkg-config \
|
||||
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||
openjdk-21-jdk-headless
|
||||
|
||||
# By hand rather than actions/checkout: that is a JS action and
|
||||
# needs node inside the container before any step has installed
|
||||
# it. Same approach as the other four workflows.
|
||||
- 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
|
||||
|
||||
# A tag push carries the version in its own name. A manual run has
|
||||
# no tag, so it takes the input or falls back to the latest v* tag,
|
||||
# which is what a hand-triggered rebuild wants anyway.
|
||||
- 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]*' 2>/dev/null || echo "v0.0.0") ;;
|
||||
esac
|
||||
fi
|
||||
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
|
||||
|
||||
# Nor is a prerelease, and this trigger is `v*`, which matches
|
||||
# `v0.4.0-beta.1`. Two reasons it is worst here. The APK goes
|
||||
# to the *generic* registry, which is readable without
|
||||
# credentials so Obtainium can poll a plain URL — a beta would
|
||||
# be offered to every device on it. And the versionCode maths
|
||||
# below splits on dots and would read "1" out of "0-beta",
|
||||
# producing a code that is wrong rather than a build that
|
||||
# fails: Android orders releases by that integer and refuses
|
||||
# anything not greater than what is installed.
|
||||
case "$v" in
|
||||
*-*)
|
||||
echo "v$v is a prerelease; not publishing an APK for it"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Android orders releases by an integer and refuses anything
|
||||
# not greater than what is installed. 1.3.1 -> 10301, which
|
||||
# increases as long as minor and patch stay below 100.
|
||||
IFS=. read -r maj min pat <<EOF
|
||||
$v
|
||||
EOF
|
||||
code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
|
||||
if [ "$code" -le 0 ]; then
|
||||
echo "refusing to build version '$v' (versionCode $code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "code=$code" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||
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
|
||||
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
|
||||
|
||||
# Idempotent by directory check. sdkmanager is itself idempotent
|
||||
# but still spends minutes verifying, so the guards are what make
|
||||
# this cheap on every run after the first.
|
||||
- name: Android SDK and NDK (cached)
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
|
||||
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
echo "command line tools: installing"
|
||||
cd /tmp
|
||||
curl -fsSL -o tools.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q tools.zip
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools/latest"
|
||||
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||
else
|
||||
echo "command line tools: cached"
|
||||
fi
|
||||
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
|
||||
install_if_missing() {
|
||||
if [ -d "$ANDROID_HOME/$2" ]; then
|
||||
echo "$1: cached"
|
||||
else
|
||||
echo "$1: installing"
|
||||
yes | sdkmanager --install "$1" >/dev/null
|
||||
fi
|
||||
}
|
||||
# android-35 matches compileSdk/targetSdk in
|
||||
# build/android/app/build.gradle. No system image and no
|
||||
# emulator: this job builds, it does not run.
|
||||
install_if_missing "platform-tools" "platform-tools"
|
||||
install_if_missing "platforms;android-35" "platforms/android-35"
|
||||
install_if_missing "build-tools;34.0.0" "build-tools/34.0.0"
|
||||
install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}"
|
||||
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
|
||||
du -sh "$ANDROID_HOME" || true
|
||||
|
||||
# **Signing is not optional past the first install.** Android
|
||||
# refuses to update an app whose signing key changed and the only
|
||||
# remedy is an uninstall, which takes the user's library with it.
|
||||
# build.gradle falls back to the *debug* keystore when these are
|
||||
# absent, and that key differs between every machine and every
|
||||
# runner — so publishing an unsigned build is a decision to
|
||||
# reinstall by hand for ever. Fail instead.
|
||||
# **Signing is not optional past the first install.** Android
|
||||
# refuses to update an app whose signing key changed and the only
|
||||
# remedy is an uninstall, which takes the user's library with it.
|
||||
# build.gradle falls back to the *debug* keystore when these are
|
||||
# absent, and that key differs between every machine and every
|
||||
# runner — so publishing an unsigned build is a decision to
|
||||
# reinstall by hand for ever. Fail instead.
|
||||
#
|
||||
# Decode, check and build are one step on purpose. Splitting them
|
||||
# would mean either handing the password to a later step through
|
||||
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
|
||||
# that are *verbatim* a secret, so a trimmed one could print in
|
||||
# clear — or repeating the trimming logic in both.
|
||||
- name: Build the signed APK
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
YJ_VERSION: ${{ steps.version.outputs.version }}
|
||||
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${KEYSTORE_B64:-}" ]; then
|
||||
echo "ANDROID_KEYSTORE_B64 is not set."
|
||||
echo
|
||||
echo "Building without it signs with the debug key, and every future"
|
||||
echo "update then fails with a signature mismatch. See"
|
||||
echo "docs/android-release.md for the keytool command and the secrets."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
|
||||
echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The path is decided here rather than composed in an `env:`
|
||||
# block: `${{ env.HOME }}` evaluates to an empty string in
|
||||
# Gitea's expression context, which turns "$HOME/x.jks" into
|
||||
# "/x.jks" — reported by Gradle as a missing file, a minute in.
|
||||
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
|
||||
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
|
||||
chmod 600 "$keystore"
|
||||
|
||||
# **A secret pasted into a web form very often carries a
|
||||
# trailing newline**, and a password is compared byte for byte.
|
||||
# Trim CR and LF from all three, and say so when it mattered —
|
||||
# "the keystore did not open" with a correct password is an
|
||||
# unpleasant thing to debug blind.
|
||||
pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
|
||||
if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
|
||||
echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
|
||||
fi
|
||||
alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
|
||||
keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')
|
||||
|
||||
# Describe the artifact before trying to open it. A truncated
|
||||
# or mis-pasted base64 yields a file that is the wrong size or
|
||||
# has no keystore header at all, and that is a different
|
||||
# problem from a wrong password.
|
||||
size=$(stat -c %s "$keystore")
|
||||
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
|
||||
echo "keystore: $size bytes, first four bytes: $magic"
|
||||
|
||||
# The fingerprint of the decoded file, so "is the secret the
|
||||
# keystore I have locally?" is answerable without guessing.
|
||||
# A hash of a *public* certificate store gives nothing away,
|
||||
# and the alternative is comparing byte counts by eye.
|
||||
#
|
||||
# sha256sum ~/path/to/yellowjacket-release.jks
|
||||
#
|
||||
# A password that is right for one keystore and wrong for
|
||||
# another is indistinguishable from a wrong password, and this
|
||||
# is the line that distinguishes them.
|
||||
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
|
||||
case "$magic" in
|
||||
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
|
||||
"fe ed fe ed") echo " header: legacy JKS" ;;
|
||||
*) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;;
|
||||
esac
|
||||
|
||||
# Open it here rather than letting Gradle discover the problem
|
||||
# at :app:validateSigningRelease, a minute of build time in and
|
||||
# reported as a missing file rather than a bad password.
|
||||
if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
|
||||
echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
|
||||
echo " password length after trimming: ${#pass}" >&2
|
||||
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
|
||||
echo >&2
|
||||
|
||||
# A password pasted *with its shell quotes* is the one
|
||||
# remaining cause that looks identical to a wrong password:
|
||||
# the secret is two characters longer than the password and
|
||||
# nothing in the error says so. Naming it is safe --
|
||||
# stripping the quotes and carrying on would not be, since a
|
||||
# password may legitimately contain them.
|
||||
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
|
||||
if [ "$unquoted" != "$pass" ] &&
|
||||
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
|
||||
echo " ** it opens with the surrounding quotes removed. **" >&2
|
||||
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
|
||||
echo >&2
|
||||
fi
|
||||
echo "Check it locally with the same two values:" >&2
|
||||
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
|
||||
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "keystore opens with the supplied password"
|
||||
|
||||
# And check the alias now, for the same reason. It defaults to
|
||||
# `yellowjacket`, so a keystore created with any other alias
|
||||
# would otherwise fail deep inside Gradle.
|
||||
if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
|
||||
echo "alias '$alias_want' is not in this keystore. It holds:" >&2
|
||||
sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2
|
||||
echo "Set ANDROID_KEY_ALIAS to one of those." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "alias '$alias_want': present"
|
||||
|
||||
ANDROID_KEYSTORE_FILE="$keystore"
|
||||
ANDROID_KEYSTORE_PASSWORD="$pass"
|
||||
ANDROID_KEY_ALIAS="$alias_want"
|
||||
ANDROID_KEY_PASSWORD="$keypass"
|
||||
export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
|
||||
export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD
|
||||
|
||||
# ANDROID_SDK is passed explicitly: the Makefile defaults it to
|
||||
# ~/Android/Sdk, which is the developer-machine layout and not
|
||||
# this container's.
|
||||
make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"
|
||||
|
||||
- name: Verify the APK
|
||||
id: apk
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
apk=bin/yellowjacket.apk
|
||||
[ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
|
||||
bt="$ANDROID_HOME/build-tools/34.0.0"
|
||||
|
||||
ls -la "$apk"
|
||||
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
|
||||
|
||||
# arm64 and *only* arm64. x86_64 Android cannot run this app
|
||||
# (modernc's raw lstat against Android's seccomp filter, which
|
||||
# is every x86_64 device and not merely the emulator), so an
|
||||
# x86_64 slice would be ~31 MB that runs nowhere -- and its
|
||||
# reappearance would mean someone had put the ABI back in
|
||||
# app/build.gradle without knowing that.
|
||||
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
|
||||
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
|
||||
|
||||
# The identity the pipeline exists to keep stable.
|
||||
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
|
||||
echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }
|
||||
|
||||
echo
|
||||
"$bt/apksigner" verify --print-certs "$apk" |
|
||||
grep -E 'Signer #1 certificate (DN|SHA-256 digest)'
|
||||
|
||||
# A build signed with the debug key installs once and can never
|
||||
# be updated. It must never reach the registry.
|
||||
if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
|
||||
echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
echo "Record that SHA-256. If it ever changes, updates will fail."
|
||||
|
||||
# Two copies: a versioned one for history and a fixed `latest` URL
|
||||
# for Obtainium to watch. Gitea refuses to overwrite an existing
|
||||
# file, so `latest` is deleted first. Credentials are the same
|
||||
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
|
||||
- name: Publish to the Gitea package registry
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
|
||||
apk=bin/yellowjacket.apk
|
||||
|
||||
put() {
|
||||
code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
|
||||
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
--upload-file "$apk" "$1")
|
||||
echo " -> $1 : $code"
|
||||
# 409 is "already there", which is the correct outcome for a
|
||||
# re-run of the same tag and not a failure.
|
||||
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
|
||||
cat /tmp/put.out >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "publishing the versioned copy"
|
||||
put "$base/$VERSION/yellowjacket-$VERSION.apk"
|
||||
|
||||
echo "clearing the previous latest"
|
||||
curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \
|
||||
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
-X DELETE "$base/latest/yellowjacket.apk" || true
|
||||
|
||||
echo "publishing latest"
|
||||
put "$base/latest/yellowjacket.apk"
|
||||
|
||||
echo
|
||||
echo "Obtainium URL:"
|
||||
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"
|
||||
@@ -1,8 +1,23 @@
|
||||
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:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
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:
|
||||
arch-package:
|
||||
@@ -17,14 +32,20 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
SHA: ${{ github.sha }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
|
||||
ARCH_REPO: stable
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Wails v3 resolves GTK4 + WebKitGTK 6.0 by default; webkit2gtk-4.1 +
|
||||
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
|
||||
# These must match the PKGBUILD's depends=() — makepkg installs
|
||||
# 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 \
|
||||
base-devel git go nodejs pnpm curl sudo \
|
||||
webkit2gtk-4.1 gtk3 alsa-lib
|
||||
base-devel git go nodejs pnpm curl sudo jq \
|
||||
webkitgtk-6.0 gtk4 alsa-lib
|
||||
|
||||
- name: Create unprivileged build user
|
||||
run: |
|
||||
@@ -32,15 +53,59 @@ jobs:
|
||||
install -d -o builder -g builder /build
|
||||
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
|
||||
|
||||
# A prerelease is not a shipment either, and this trigger is
|
||||
# `v*` — which matches `v0.4.0-beta.1`. Nothing produces one
|
||||
# today; the guard is here because the thing that would is
|
||||
# semantic-release's `prerelease: true` channel, a one-line
|
||||
# change in .releaserc.yml whose blast radius is four public
|
||||
# package channels. Same argument as release.yml's
|
||||
# `chore(release):` guard: cheap, against something a future
|
||||
# edit turns on somewhere else entirely.
|
||||
case "$v" in
|
||||
*-*)
|
||||
echo "$v is a prerelease; not packaging it for pacman"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "building $v"
|
||||
|
||||
- name: Clone repo at the pushed commit
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
# Token auth works for private repos and needs no SSH key in CI.
|
||||
sudo -u builder git clone \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
|
||||
/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"
|
||||
|
||||
- name: Build package with makepkg
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
cd /build/yellowjacket/packaging/arch
|
||||
# Point the PKGBUILD at this local clone / exact commit; pkgver() then
|
||||
@@ -50,6 +115,7 @@ jobs:
|
||||
makepkg -f --noconfirm --cleanbuild
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
cd /build/yellowjacket/packaging/arch
|
||||
# makepkg also produces a -debug package (detached symbols); end users
|
||||
@@ -63,3 +129,20 @@ jobs:
|
||||
--upload-file "$pkg" \
|
||||
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
|
||||
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
|
||||
|
||||
+36
-25
@@ -1,6 +1,6 @@
|
||||
name: CI
|
||||
|
||||
# The other three workflows package and publish; none of them test
|
||||
# The other five workflows package, publish or release; none of them test
|
||||
# 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
|
||||
# workflow that gates.
|
||||
@@ -9,9 +9,23 @@ name: CI
|
||||
# before being written here, so every step below is a transcription of
|
||||
# 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:
|
||||
push:
|
||||
branches: ['**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -66,17 +80,19 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
# libwebkit2gtk-4.1-dev and libasound2-dev are not optional:
|
||||
# libwebkitgtk-6.0-dev and libasound2-dev are not optional:
|
||||
# the app is cgo, and without alsa.pc oto/v3 fails at
|
||||
# `pkg-config --cflags -- alsa` before anything is compiled.
|
||||
# ubuntu:24.04 ships webkitgtk-6.0, which is what wails v3
|
||||
# builds against by default.
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl git jq build-essential pkg-config \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev ffmpeg
|
||||
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev ffmpeg
|
||||
|
||||
# Cloned by hand rather than with actions/checkout: that is a JS
|
||||
# action and needs node inside the job container before any step
|
||||
# has had a chance to install it. Same approach as the other
|
||||
# three workflows in this directory.
|
||||
# other workflows in this directory.
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
@@ -169,7 +185,7 @@ jobs:
|
||||
working-directory: /src
|
||||
run: make ui-test
|
||||
|
||||
# frontend/wailsjs is generated by `wails`, not by `go generate`,
|
||||
# frontend/bindings is generated by `wails3`, not by `go generate`,
|
||||
# so the codegen pre-commit hook does not cover it.
|
||||
- name: Bindings are current
|
||||
working-directory: /src
|
||||
@@ -182,7 +198,9 @@ jobs:
|
||||
run: make skill-check
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# Job 2: the real app, under a virtual display. #
|
||||
# Job 2: the real app, headless. v3's `-tags server` needs no #
|
||||
# display, so the Xvfb this job used to wrap everything in is gone. #
|
||||
# `dbus-run-session` stays, for MPRIS. #
|
||||
# ---------------------------------------------------------------- #
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -217,8 +235,8 @@ jobs:
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl git jq build-essential pkg-config \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev \
|
||||
xvfb dbus dbus-x11 ffmpeg libasound2t64 \
|
||||
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||
dbus dbus-x11 ffmpeg libasound2t64 \
|
||||
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
||||
|
||||
- name: Clone repo at this commit
|
||||
@@ -245,24 +263,17 @@ jobs:
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
corepack enable
|
||||
|
||||
# scripts/seed-sandbox.sh drives the real AddLibrary binding
|
||||
# through playwright-cli, so the CLI has to be on PATH.
|
||||
- name: Playwright CLI
|
||||
run: npm install -g @playwright/cli
|
||||
|
||||
# @playwright/cli is gone with v2. seed-sandbox.sh drove the real
|
||||
# AddLibrary binding through a browser because `window.go` was the
|
||||
# only way in; v3 answers the same call over HTTP, so the seed is
|
||||
# curl now and needs no CLI, no second Chromium and no shared
|
||||
# PLAYWRIGHT_BROWSERS_PATH revision dance.
|
||||
- name: Browsers
|
||||
working-directory: /src/e2e
|
||||
run: |
|
||||
set -eu
|
||||
# PLAYWRIGHT_BROWSERS_PATH unifies the *location*, not the
|
||||
# *revisions*: @playwright/cli bundles its own playwright-core
|
||||
# pinned to a different Chromium build than @playwright/test,
|
||||
# so each installs its own into the shared directory. Drop
|
||||
# either line and the other fails with "Browser chromium is
|
||||
# not installed; expected executable at ...".
|
||||
pnpm install --frozen-lockfile
|
||||
npx playwright install --with-deps chromium webkit
|
||||
playwright-cli install-browser chromium
|
||||
|
||||
# oto/v3 talks to libasound directly, and a container has no
|
||||
# PulseAudio socket to fall back on — so it needs a default device
|
||||
@@ -285,10 +296,10 @@ jobs:
|
||||
#
|
||||
# PulseAudio's null sink is timer-scheduled and does pace — the
|
||||
# 0.76 s over is the buffer draining, not a rate error; 12 s of
|
||||
# audio takes 13.5 s. Verified under the private session bus and
|
||||
# Xvfb that dev-headless.sh runs the app in. It needs no system
|
||||
# D-Bus and no kernel module, which is why it is reachable from a
|
||||
# container at all.
|
||||
# audio takes 13.5 s. Verified under the private session bus
|
||||
# dev-headless.sh runs the app in. It needs no system D-Bus and
|
||||
# no kernel module, which is why it is reachable from a container
|
||||
# at all.
|
||||
- name: Real-time audio sink
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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
|
||||
|
||||
# Nor is a prerelease, and this trigger is `v*`, which matches
|
||||
# `v0.4.0-beta.1`. The mildest of the four — assets attach to
|
||||
# the prerelease's own Gitea release and no package manager
|
||||
# reads them — but four workflows sharing one trigger should
|
||||
# share one answer about what a shipment is.
|
||||
case "$v" in
|
||||
*-*)
|
||||
echo "$v is a prerelease; not attaching desktop assets"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
|
||||
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"
|
||||
@@ -14,6 +14,15 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "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:
|
||||
sync-formula:
|
||||
@@ -30,10 +39,37 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute version and tarball checksum
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0
|
||||
VERSION="${TAG#v}" # e.g. 1.3.0
|
||||
TAG="${{ inputs.version }}"
|
||||
[ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1
|
||||
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
|
||||
|
||||
# Nor is a prerelease, and this trigger is `v*`, which matches
|
||||
# `v0.4.0-beta.1`. It matters most here of the four: the tap
|
||||
# is public, and `brew upgrade` would offer a beta to everyone
|
||||
# on it.
|
||||
case "$VERSION" in
|
||||
*-*)
|
||||
echo "$TAG is a prerelease; not syncing it to a public tap"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
||||
|
||||
echo "Fetching ${TARBALL}"
|
||||
@@ -53,6 +89,7 @@ jobs:
|
||||
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Render the formula with the new version and checksum
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="packaging/homebrew/Formula/yellowjacket.rb"
|
||||
@@ -66,6 +103,7 @@ jobs:
|
||||
cat yellowjacket.rb
|
||||
|
||||
- name: Push to the Homebrew tap repo
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
|
||||
|
||||
@@ -7,11 +7,29 @@ name: Search index maintenance
|
||||
# import older than 6mo -> rebuild (re-import from the newest dump)
|
||||
# otherwise -> refresh (fold in new incremental listens)
|
||||
#
|
||||
# A refresh is cheap and no-ops when nothing new has been published, so
|
||||
# running it on every push to main is safe.
|
||||
# **There is deliberately no `push` trigger, and restoring one is a
|
||||
# decision rather than a cleanup.** A refresh is individually cheap, so
|
||||
# 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
|
||||
# database is deliberately in the older encoding, so the stale-shape
|
||||
# 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
|
||||
# which cannot be rebuilt in ten minutes is triggered deliberately, not
|
||||
# by a push.** The weekly cron keeps the catalog current, and
|
||||
# workflow_dispatch resumes or forces a build — indexbuild picks up from
|
||||
# its checkpoint either way, so nothing is lost by not running on every
|
||||
# merge. See docs/index-cache.md for the snapshot and the restore.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly update pass. The 6-month rebuild is triggered by the same
|
||||
# command when it notices the import has aged out.
|
||||
@@ -33,6 +51,10 @@ on:
|
||||
|
||||
# Runs share one persistent working directory, so they must not overlap.
|
||||
# A push landing mid-build waits rather than corrupting the checkpoint.
|
||||
#
|
||||
# That directory holds the only copy of a catalog nothing can cheaply
|
||||
# re-derive: see docs/index-cache.md for the snapshot it takes and the
|
||||
# restore, which is minutes against the hours a rebuild costs.
|
||||
concurrency:
|
||||
group: search-index
|
||||
cancel-in-progress: false
|
||||
@@ -42,7 +64,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# CGO is not needed: the project uses the pure-Go modernc sqlite
|
||||
# driver, and neither command imports the Wails app.
|
||||
# driver, and neither command imports the Wails app — which is a
|
||||
# claim with a test behind it now (cmd/indexbuild/deps_test.go),
|
||||
# because the v3 migration quietly broke it and this job was where
|
||||
# that surfaced.
|
||||
image: golang:1.25
|
||||
# This host path must exist on the runner and be listed verbatim in
|
||||
# act_runner's container.valid_volumes. It holds explore-staging/
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
name: Release
|
||||
|
||||
# The sixth workflow, and the one that decides whether the other three
|
||||
# run at all. It reads the Conventional Commits since the last tag, and
|
||||
# if any of them is releasable it writes the changelog, pushes the tag,
|
||||
# and creates the Gitea release whose body is that changelog section.
|
||||
# The publishing workflows are keyed on `v*`, so the tag push is what
|
||||
# starts them.
|
||||
#
|
||||
# **It is triggered by hand, and there is deliberately no `push`
|
||||
# trigger.** There was one, on `main`, which made the trigger "a PR was
|
||||
# merged" and nothing else: eight releases in twenty-two hours
|
||||
# (v0.0.1 -> v0.3.1) for one session's work, each fanning out to four
|
||||
# publishers on a runner with capacity 1, so ~40 packaging jobs shipped
|
||||
# three issues and ordinary PR CI queued behind them. A version per
|
||||
# merged PR is a version per unit of *work*, not per *shipment*, and
|
||||
# pacman, Homebrew and Obtainium see every one.
|
||||
#
|
||||
# Nothing else had to change to batch them: semantic-release already
|
||||
# reads every commit since the last tag, so five fixes and two feats
|
||||
# become one minor release with all seven in the notes. Release
|
||||
# frequency was only ever how often this file fired.
|
||||
#
|
||||
# This is the rule `index-artifact.yml` states and is the other instance
|
||||
# of: **a job that mutates state which cannot be rebuilt in ten minutes
|
||||
# is triggered deliberately, not by a push.** A release here is a tag,
|
||||
# a Gitea release, an Arch package, a Homebrew formula, a signed APK and
|
||||
# desktop assets — and an Android version going backwards costs the user
|
||||
# their library (docs/android-release.md).
|
||||
#
|
||||
# A schedule was considered and rejected: a cron batches without anyone
|
||||
# having to remember, but it puts the decision back on a timer, which is
|
||||
# the thing being removed.
|
||||
#
|
||||
# **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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Report what would be released and stop"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
# Cutting a tag is not a thing to cancel halfway: a superseded run must
|
||||
# finish, not be killed between `git push --tags` and the release POST.
|
||||
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.
|
||||
# The point of a manual trigger is deliberateness, and deliberate
|
||||
# means being able to look before pulling the lever. `--dry-run`
|
||||
# reports the version and the notes and writes nothing: no tag, no
|
||||
# release, no publishers. `make release-dry` is the same answer
|
||||
# locally; this is it from the runner, against the same commit and
|
||||
# the same tag history, which is what actually decides.
|
||||
- name: Run semantic-release
|
||||
if: steps.guard.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
set -eu
|
||||
git config user.name "yellowjacket-ci"
|
||||
git config user.email "yj@yellowjacket.app"
|
||||
|
||||
# Anything but a literal "true" releases for real. A typo in a
|
||||
# dispatch box must not silently turn a shipment into a no-op
|
||||
# that reports success — the failure worth avoiding is the one
|
||||
# where nothing happens and the run is green.
|
||||
dry=""
|
||||
if [ "${DRY_RUN:-false}" = "true" ]; then
|
||||
echo "DRY RUN — no tag will be pushed and no release created"
|
||||
dry="--dry-run"
|
||||
fi
|
||||
|
||||
npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@13 \
|
||||
-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 \
|
||||
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Unclaim
|
||||
|
||||
# A `Closes #N` footer in a commit body closes the issue on merge — and
|
||||
# leaves `Status/In Progress` on it, because Gitea's auto-close touches
|
||||
# state and nothing else. So #100 was closed and simultaneously marked
|
||||
# as being actively worked on, and `scripts/issue.sh close` (which does
|
||||
# drop the label) is exactly the thing the footer exists to avoid
|
||||
# calling.
|
||||
#
|
||||
# **This hooks the close, not the merge.** Stripping the label in the
|
||||
# PR would work and would be a per-PR habit; habits are what the footer
|
||||
# removed. `issues: [closed]` covers every path an issue can close by —
|
||||
# the footer on merge, `issue.sh close`, someone clicking Close in the
|
||||
# web UI — and asks nothing of anyone at any of them.
|
||||
#
|
||||
# **Reopening deliberately does not restore it.** Reopening says the
|
||||
# work was not finished, not that somebody is at a keyboard doing it
|
||||
# now; the claim gets re-made by whoever picks it up.
|
||||
#
|
||||
# **This is not instant, and should not be described as it.** The
|
||||
# runner has capacity 1 and is shared with an index build that can hold
|
||||
# it for three hours, so a label tweak can queue behind one. Stale for
|
||||
# an afternoon beats stale forever, which is what it was.
|
||||
#
|
||||
# The audit that answers "is this still firing" stays in CLAUDE.md and
|
||||
# is one command:
|
||||
#
|
||||
# ./scripts/issue.sh list --state closed --label "Status/In Progress"
|
||||
#
|
||||
# A workflow that silently stops working is the failure mode this whole
|
||||
# area has already produced once.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
unclaim:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
|
||||
steps:
|
||||
- name: Drop the claim label
|
||||
# **Inside a container the act runner selects `sh`, not bash**, so
|
||||
# `set -o pipefail` fails the job on its second line with "Illegal
|
||||
# option" and the step never reaches the API. `homebrew-formula.yml`
|
||||
# carries the same `set -euo pipefail` without trouble because it
|
||||
# runs with **no container**, on the host image where bash is the
|
||||
# default — so "another workflow does it" is not evidence here.
|
||||
shell: bash
|
||||
env:
|
||||
# The automatic Actions token, as release.yml uses for the
|
||||
# floor tag. It needs no more than write access to this repo.
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# `ca-certificates` is named because `--no-install-recommends`
|
||||
# skips it, and `ubuntu:24.04` ships no CA bundle of its own —
|
||||
# so curl comes up unable to verify TLS against our own Gitea
|
||||
# and fails with "error setting certificate file" (exit 77).
|
||||
# Every other containerised workflow here spells it out for the
|
||||
# same reason; this one did not, and cost a release cycle.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl jq >/dev/null
|
||||
|
||||
label_id=$(
|
||||
curl -sSf -H "Authorization: token $TOKEN" "$API/labels?limit=100" |
|
||||
jq -r '.[] | select(.name == "Status/In Progress") | .id'
|
||||
)
|
||||
|
||||
# The label not existing is a repo somebody reorganised, not a
|
||||
# failure of this run — say so and stop, rather than failing a
|
||||
# job on every close from then on.
|
||||
if [ -z "$label_id" ]; then
|
||||
echo "unclaim: no 'Status/In Progress' label in this repo; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# DELETE is idempotent here: an issue that never carried the
|
||||
# label answers the same as one that did, which is what makes
|
||||
# this safe to run on *every* close rather than only the ones
|
||||
# that were claimed.
|
||||
# The body is captured, not discarded, so a refusal is
|
||||
# diagnosable from this log alone. Whether the automatic
|
||||
# token carries issue-write scope is still unproven, and
|
||||
# "DELETE returned 403" without Gitea's own sentence costs
|
||||
# another merge to find out which of the two it is.
|
||||
body=$(mktemp)
|
||||
code=$(
|
||||
curl -sS -o "$body" -w '%{http_code}' -X DELETE \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$API/issues/$ISSUE/labels/$label_id"
|
||||
)
|
||||
|
||||
case "$code" in
|
||||
204) echo "unclaim: #$ISSUE is closed and unclaimed" ;;
|
||||
*)
|
||||
echo "unclaim: DELETE returned $code for #$ISSUE" >&2
|
||||
cat "$body" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+42
-2
@@ -1,6 +1,6 @@
|
||||
frontend/dist
|
||||
node_modules
|
||||
build
|
||||
build/bin/
|
||||
test_data
|
||||
test.db
|
||||
|
||||
@@ -42,7 +42,7 @@ Thumbs.db
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
# build/ holds v3 build assets and is tracked; only its output is not.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -53,3 +53,43 @@ vendor/
|
||||
coverage/
|
||||
.cache/
|
||||
tmp/
|
||||
bin/
|
||||
|
||||
# Task's checksum cache, written by every `wails3 task` run.
|
||||
.task/
|
||||
|
||||
# Generated by build/linux/Taskfile.yml's generate:dotdesktop from
|
||||
# build/config.yml on every build, and consumed by the deb/rpm/AppImage
|
||||
# packaging tasks that depend on it. A derived file with one source.
|
||||
build/linux/yellowjacket.desktop
|
||||
|
||||
# iOS is not carried. `wails3 update build-assets` regenerates the tree
|
||||
# whether or not anything asks for it, so it is ignored rather than
|
||||
# deleted-and-rediscovered on every asset refresh, and its includes:
|
||||
# entry is dropped from Taskfile.yml.
|
||||
#
|
||||
# build/android/ *is* carried — see plan 015. Note that `update
|
||||
# build-assets` does NOT regenerate it (only `generate build-assets`
|
||||
# does, and that rewrites the whole of build/), so the tree is committed
|
||||
# and edited by hand like any other source. Only its output is ignored,
|
||||
# below.
|
||||
build/ios/
|
||||
|
||||
# Android build output. jniLibs holds the ~30 MB per-ABI c-shared
|
||||
# libraries the Go build produces; gen/ and overlay.json are written by
|
||||
# `wails3 android overlay:gen`; the rest is Gradle's.
|
||||
build/android/app/src/main/jniLibs/
|
||||
build/android/app/build/
|
||||
build/android/build/
|
||||
build/android/.gradle/
|
||||
build/android/gen/
|
||||
build/android/overlay.json
|
||||
|
||||
# Written by @semantic-release/changelog purely to carry the release notes
|
||||
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||
.release-notes.md
|
||||
|
||||
# Agent session log: local scratch, not repo memory (that is CLAUDE.md
|
||||
# and .planning/). Written by the scheduled backlog runs.
|
||||
.pi/journal.md
|
||||
.pi/schedule-prompts.json
|
||||
|
||||
@@ -29,6 +29,17 @@ linters:
|
||||
- usetesting
|
||||
- whitespace
|
||||
- wsl_v5
|
||||
exclusions:
|
||||
paths:
|
||||
# Wails scaffold, not ours. `build/android/` is generated by
|
||||
# `wails3 generate build-assets` and carried verbatim (plan 015),
|
||||
# and it contains one Go file -- scripts/deps/install_deps.go, the
|
||||
# interactive SDK installer behind `task android:install:deps`.
|
||||
# It trips 24 of the strict linters above, and reformatting
|
||||
# upstream's file to our house style would be undone by the next
|
||||
# refresh and would make the diff against upstream unreadable.
|
||||
# `make android-setup` is what this repo uses instead.
|
||||
- build/android/
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
# Work log
|
||||
|
||||
Temporal memory: what happened and what's next. Structure lives in
|
||||
`CLAUDE.md`, operational instructions in `.pi/skills/yellowjacket-dev/`,
|
||||
measured discoveries in `.planning/NOTES.md`. Don't duplicate those here.
|
||||
|
||||
## Current state
|
||||
|
||||
Plan 005 (agent development harness) is **complete — all seven
|
||||
phases**. Everything from phase 1 onward is still **uncommitted**: one
|
||||
large but coherent working-tree diff, nothing pushed.
|
||||
|
||||
All four tiers verified green from a cold, cleaned state:
|
||||
`make ui-test` 313 passed, `make lint` 0 issues × 3 configurations,
|
||||
`make test` green × 3 passes, `make e2e` 19 passed. Both CI jobs
|
||||
verified green in a bare `ubuntu:24.04` container, including 19/19 on
|
||||
WebKit.
|
||||
|
||||
**Committed and pushed** as `5ca6cad` (the harness) + `ccacd67` (a CI
|
||||
fix), and **green on the real runner**: job `check` ~4 min, job `e2e`
|
||||
~3 min with 19/19 chromium *and* 19/19 webkit. One commit rather than
|
||||
seven because the working tree was the end state, not per-phase
|
||||
snapshots — `Makefile`, `CLAUDE.md` and `lefthook.yml` are touched by
|
||||
nearly every phase, so a split would have been fabricated history.
|
||||
|
||||
Still unverified, because no run has failed yet: the
|
||||
`actions/upload-artifact` step (`continue-on-error`, so it cannot mask
|
||||
a real failure) and whether pnpm honours `npm_config_store_dir` for
|
||||
store caching. Worth checking the next time a spec legitimately fails.
|
||||
|
||||
- [ ] `gitea_ci`'s `job_logs` returns 404 on Gitea 1.27.1 — the endpoint
|
||||
is not exposed. Logs come from the VPS instead: `zstdcat` the file
|
||||
under `gitea/actions_log/<owner>/<repo>/<xx>/<task_id>.log.zst`,
|
||||
and note `zstdcat` is not in the gitea container, so
|
||||
`docker cp` it out first. Job status is `action_run_job.status`
|
||||
(1 success, 2 failure, 4 skipped, 5 waiting, 6 running).
|
||||
Probably belongs in the `gitea` skill, not here.
|
||||
|
||||
Open items deliberately not fixed: WAV tags are write-only
|
||||
(`TestWAVTagsAreNotReadableYet`), `themeStore.loadFromBackend`'s failure
|
||||
handler cannot recover, `backend/playlist` has no CRUD suite.
|
||||
|
||||
## Log
|
||||
|
||||
### 2026-08-11 — cold skill run, then phase 7 (CI)
|
||||
|
||||
- **Followed the skill cold first**, as the last session asked. It
|
||||
works: app up from a wiped `.dev/`, an undocumented flow driven
|
||||
(queue panel + shuffle, asserted on `QueueModeChanged`), stopped —
|
||||
~1 minute, no dead ends. One real config bug: `outputDir` in
|
||||
`.playwright/cli.config.json` resolves against **cwd**, not the
|
||||
config file's directory (only `initScript` does that), so snapshots
|
||||
were landing above the repo and a *stale* one from the previous
|
||||
session answered `ls -t` instead. That cost a DOM walk to disprove a
|
||||
regression that did not exist. Four smaller doc gaps fixed
|
||||
(`sandbox-seed` already runs `testdata`; `ui-setup`/`e2e-setup` were
|
||||
undocumented prerequisites; `snapshot` prints a path; `dev-stop`
|
||||
leaves the browser open), plus `dev-headless.sh`'s own banner, which
|
||||
was suggesting the bare `window.go` call its next paragraph warns
|
||||
against.
|
||||
- **Built both CI jobs as container scripts before writing any YAML**,
|
||||
then transcribed the YAML back out and re-ran it to prove the
|
||||
transcription. Push-and-see is a bad loop on a self-hosted runner.
|
||||
- **It found a real bug immediately**: `make lint` omitted
|
||||
`webkit2_41` on all three passes, so it was linting configurations
|
||||
nothing builds. Invisible on Arch (which still ships
|
||||
`webkit2gtk-4.0.pc`), fatal on Ubuntu 24.04. Tag sets now match
|
||||
`make test`.
|
||||
- **Both open decisions settled by measurement**: ALSA `null` PCM for
|
||||
audio (no daemon; the elapsed clock really advances), dead-address
|
||||
stub for the explore artifact (and setting it for the *app* run, not
|
||||
just seeding, is worth 8x on suite wall clock). **WebKit is a
|
||||
required step** — it had never been run anywhere, so one throwaway
|
||||
container run replaced a coin flip with 19/19 at +11 s.
|
||||
|
||||
### 2026-08-10 — phase 6, pi affordances
|
||||
|
||||
- Added `.pi/skills/yellowjacket-dev/` as a directory rather than a flat
|
||||
file: only the description is always in context, so `SKILL.md` stays
|
||||
short enough that reading it whole is never a decision, and the deeper
|
||||
material sits in `references/{harness,fixtures,ui-tier,schema-change}.md`.
|
||||
- Settled the CLAUDE.md-vs-skill split **grammatically, not topically**,
|
||||
because a topical split is what rots — every new fact gets two
|
||||
plausible homes. Three docs, three tenses: NOTES.md is past
|
||||
(measured, dated, append-only), CLAUDE.md is present (what the system
|
||||
is), the skill is imperative (what to run). A new paragraph's tense
|
||||
decides where it goes.
|
||||
- The five gotchas (binding timeouts, first-run wizard, `pkill -f`,
|
||||
seeds-by-running, WebKit-is-CI-only) went **inline in SKILL.md**, not
|
||||
into a reference: you need them before the failure, not after.
|
||||
- Trimmed CLAUDE.md's "Fixtures and the headless harness" section by
|
||||
about half — the command sequences and gotchas it was carrying are now
|
||||
the skill's, and leaving both would have created exactly the duplicate
|
||||
description this repo has a standing rule against.
|
||||
- Added `make skill-check` / `scripts/skill-check.sh` + a pre-commit
|
||||
hook: every command in `.pi/**/*.md` must be a real `make` target, so
|
||||
the Makefile stays the source of truth for invocation and a renamed
|
||||
target fails a commit instead of misleading an agent later. Verified
|
||||
it fails (it caught its own not-yet-created target) and passes.
|
||||
- Added the `/e2e` prompt template: promoting a hand-driven
|
||||
`playwright-cli` session into a spec is a transcription with four
|
||||
fixed substitutions (refs → testids, sleeps → `waitForEvent`, raw
|
||||
`window.go` → `callBinding`, short fixture → `LONG_TRACK`), plus three
|
||||
runs — pass, pass again, pass after a DB restore — because the usual
|
||||
failure is a spec depending on state the hand-driving left behind.
|
||||
- One shell trap: under `set -euo pipefail`, `x="$(make -pqRr | …)"`
|
||||
fails the whole assignment, because `make -q` exits non-zero when a
|
||||
target is out of date and `pipefail` propagates it.
|
||||
|
||||
### Earlier
|
||||
|
||||
Phases 1–5 of plan 005: fixture generator and manifest, headless launch
|
||||
and seeds, the event bridge + `data-testid` pass + `backend/testctl` +
|
||||
`e2e/`, the Vitest component tier + `make bindings-check`, and the
|
||||
`events.Emit` wrapper with its in-process service-event tests. Recaps
|
||||
and the five "verified end to end" blocks are in
|
||||
`.planning/plans/active/005-agent-development-harness.md`; the lessons
|
||||
are in `.planning/NOTES.md`.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
description: Take on the next actionable backlog issue end to end, and stop
|
||||
---
|
||||
Take on exactly one issue from the YellowJacket backlog, end to end, and stop.
|
||||
|
||||
Repo: yonlu/yellowjacket at https://git.ljones.me — API base
|
||||
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket, auth with
|
||||
`-H "Authorization: token $GITEA_TOKEN"`. Default branch is `main`.
|
||||
|
||||
## 1. Orient before you pick
|
||||
|
||||
Read, in this order: `CLAUDE.md` (the architecture and the reasons behind
|
||||
it), `.pi/journal.md` (what happened last), `.planning/NOTES.md` (what was
|
||||
already considered and rejected), and `.planning/plans/active/`. Do not skip
|
||||
this because the issue looks small — most of this codebase's traps are
|
||||
written down in exactly one of those four places, and the ones that bite are
|
||||
the ones you didn't read.
|
||||
|
||||
## 2. Pick the issue
|
||||
|
||||
List open issues. Choose the single highest-value one that is *actionable
|
||||
right now*:
|
||||
|
||||
- Order by `Priority/Critical` → `High` → `Medium` → `Low`. Within a tier,
|
||||
prefer `Reviewed/Confirmed`, then `Kind/Bug` over `Kind/Enhancement` over
|
||||
`Kind/Feature`.
|
||||
- Consult issue #73 (the roadmap) — if it sequences the candidates, that
|
||||
ordering wins over the label ordering.
|
||||
- **Skip** anything labelled `Status/Blocked`, `Status/In Progress`,
|
||||
`Status/Abandoned`, `Reviewed/Won't Fix`, `Reviewed/Duplicate`,
|
||||
`Reviewed/Invalid`, or already carrying an open PR.
|
||||
- **Skip anything someone else is already on.** The label is not the only
|
||||
claim, because a concurrent session may not have applied it — several pi
|
||||
sessions run against this repo from separate worktrees under
|
||||
`~/.paseo/worktrees/`. Run `git ls-remote --heads origin` and skip any
|
||||
issue whose number or slug matches an existing branch (`60-…`,
|
||||
`fix/<slug>`). A duplicated fix costs more than a skipped issue.
|
||||
- **Skip** anything that cannot be verified without hardware you do not
|
||||
have: physical-device Android behaviour (audio output, on-device file
|
||||
writes, real gesture input). A browser at 424px is not a phone — see the
|
||||
Chrome 113 section of `CLAUDE.md`.
|
||||
- **Skip** intermittent-failure issues unless you can reproduce the failure
|
||||
on demand within a few minutes. Chasing a 1-in-3 flake is an unbounded
|
||||
task and does not belong in a scheduled run.
|
||||
- If nothing qualifies, say so, do nothing, and stop. An empty run is a
|
||||
correct outcome.
|
||||
|
||||
## 3. Claim it
|
||||
|
||||
Add `Status/In Progress` to the issue and comment that you are picking it
|
||||
up. Then branch:
|
||||
|
||||
```
|
||||
git fetch origin && git checkout -b <type>/<short-slug> origin/main
|
||||
```
|
||||
|
||||
`<type>` matches the issue's `Kind` (`fix/`, `feat/`, `refactor/`, `test/`,
|
||||
`docs/`, `ci/`). Branch from `origin/main`, never by checking out `main`
|
||||
itself — this repo is worked from several git worktrees at once and `main`
|
||||
is checked out in one of them, so `git checkout main` fails outright.
|
||||
|
||||
## 4. Do the work
|
||||
|
||||
Fix the issue that was reported and nothing else. Match the surrounding
|
||||
code's style. Follow the constraints in `CLAUDE.md` rather than reasoning
|
||||
from first principles — where it explains why something is shaped the way it
|
||||
is, that shape is load-bearing and there is usually a test pinning it.
|
||||
|
||||
**Anything else you discover becomes a new issue, not a bigger diff.** File
|
||||
it with the right `Area/`, `Kind/`, `Priority/` labels, describe the
|
||||
symptom before the theory, and link it from your PR. Scope creep is the
|
||||
failure mode this instruction exists to prevent.
|
||||
|
||||
If the work turns out to be materially larger than the issue implied, stop:
|
||||
comment on the issue with what you found and what it would actually take,
|
||||
remove `Status/In Progress`, push nothing, and end the run.
|
||||
|
||||
## 5. Verify — the right tier, not the cheapest one
|
||||
|
||||
Run `make generate` if you touched `.sql` or `.templ`, and `make bindings`
|
||||
if you changed a bound Go signature. Then run what the change actually
|
||||
demands:
|
||||
|
||||
- Go change → `make lint` and `make test` (both cover all three build
|
||||
configurations).
|
||||
- Frontend component or store → `make ui-test`.
|
||||
- User-visible flow → `make e2e` against `make dev-headless`. **Check the
|
||||
port first**: `ss -ltn | grep 34115`. If it is occupied, another worktree
|
||||
is already running the app — do not start a second one and do not run
|
||||
`make e2e`. Attaching to someone else's build produces a green result
|
||||
about code that is not yours, which is worse than no result. Either
|
||||
choose an issue that does not need this tier, or stop and say why.
|
||||
- Anything cosmetic or layout-related → look at a screenshot. Several bugs
|
||||
in this repo's history were invisible to every assertion and obvious in an
|
||||
image.
|
||||
|
||||
A tier you skipped is a claim you did not check. If a tier fails for reasons
|
||||
unrelated to your change, say so explicitly rather than quietly moving on.
|
||||
|
||||
## 6. Keep the documentation true
|
||||
|
||||
If you changed structure, behaviour, or a constraint, update `CLAUDE.md` in
|
||||
the same commit. That file is this project's memory; a change that leaves it
|
||||
describing the old shape is worse than no change. Append a short entry to
|
||||
`.pi/journal.md` covering what you did, what you verified, and what you left
|
||||
open.
|
||||
|
||||
## 7. Commit and open the PR
|
||||
|
||||
Conventional Commits, imperative subject, ≤72 chars, scope optional. The
|
||||
body explains *why*. Push the branch — never push to `main`, never
|
||||
force-push.
|
||||
|
||||
Open the PR:
|
||||
|
||||
```
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://git.ljones.me/api/v1/repos/yonlu/yellowjacket/pulls \
|
||||
-d '{"head":"<branch>","base":"main","title":"<subject>","body":"<body>"}'
|
||||
```
|
||||
|
||||
The body states: what the issue was, what you changed and why, **which
|
||||
verification tiers you ran and their results**, anything you deliberately
|
||||
did not do, and `Closes #<n>`.
|
||||
|
||||
Then wait for CI (`ci.yml`, jobs `check` and `e2e`) and report the result on
|
||||
the PR. If it fails, read the log — `gitea_ci`'s `job_logs` 404s on this
|
||||
Gitea build, so use
|
||||
`GET /api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` for per-step
|
||||
status and `GET /api/v1/repos/yonlu/yellowjacket/actions/jobs/<id>/logs` for
|
||||
the log — and fix it. Two consecutive failed CI runs on the same cause: stop,
|
||||
comment what you know on the PR, and leave it for a human.
|
||||
|
||||
**Do not merge.** Comment on the issue linking the PR, leave
|
||||
`Status/In Progress` on, and end the run.
|
||||
|
||||
## Finally
|
||||
|
||||
Report in three lines: which issue you took, what state it is in
|
||||
(PR open / CI green / stopped and why), and any issues you filed.
|
||||
@@ -21,12 +21,21 @@ here has disappeared.
|
||||
Fifteen things cost a cycle each the first time. They are here, not in a
|
||||
reference, because you need them *before* the failure, not after.
|
||||
|
||||
- **Time out every binding call.** A bound Go method called with wrong
|
||||
argument types makes the backend log `error parsing arguments` and
|
||||
**never fire the callback**, so the promise hangs forever. Use
|
||||
`window.__yjEvents.call(path, args, ms)` (browser) or `callBinding`
|
||||
(specs), never a bare `window.go.…`. When one hangs anyway,
|
||||
`make dev-logs` — `.dev/app.log` is the only place the reason appears.
|
||||
- **Call a binding through the bridge.** `window.go` does not exist
|
||||
under Wails v3 — the bindings are bundled modules, not a global — so
|
||||
use `window.__yjEvents.call(path, args, ms)` (browser) or
|
||||
`callBinding` (specs). Both post to the runtime's own endpoint by
|
||||
method name, so they work on any page, including one with no init
|
||||
script.
|
||||
|
||||
A bad call now *rejects*, and says why: a wrong type comes back as a
|
||||
TypeError naming the argument, a wrong count as
|
||||
`expects 4 arguments, got 3`, an unknown method as a ReferenceError.
|
||||
Under v2 the backend logged `error parsing arguments` and never fired
|
||||
the callback, so `.dev/app.log` was the only place the reason
|
||||
appeared and the timeout was the only thing that made the mistake
|
||||
visible. The timeout is still there, but now it means a genuinely
|
||||
hung request.
|
||||
- **Nothing is clickable on a fresh `YJ_HOME`.** `<first-run-wizard>`
|
||||
intercepts all pointer events until a library exists, and the click
|
||||
fails with a Playwright interception error that reads like a selector
|
||||
@@ -87,6 +96,15 @@ reference, because you need them *before* the failure, not after.
|
||||
Run against the `bulk` seed a measurement session left behind and a
|
||||
third of them fail (13 of 36, when it was measured), in a list that
|
||||
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
|
||||
- **The catalog is stubbed out locally now, like CI.**
|
||||
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
|
||||
because it was the only launcher that did not — `seed-sandbox.sh` and
|
||||
`ci.yml` always have. Without it the app downloads the real ~1M-row
|
||||
Explore catalog into the run's `YJ_HOME`, and specs that stage their
|
||||
own catalog rows then search a million real ones and fail *locally
|
||||
only*, which reads as a regression and is an environment. Pass
|
||||
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
|
||||
explore by hand.
|
||||
- **…and the suite spends state it cannot always give back.**
|
||||
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
|
||||
of the eleven the seed has, and does not put it back — so around the
|
||||
@@ -112,7 +130,13 @@ reference, because you need them *before* the failure, not after.
|
||||
what you otherwise get is `Property 'scroll' does not exist on type
|
||||
'CSSResult'` pointing at a line of prose, or every test in the suite
|
||||
failing to import. It went in after the trap cost a fourth session in
|
||||
which its own warning had been read twice.
|
||||
which its own warning had been read twice. **The same command carries
|
||||
a second CSS check**: a nested rule whose selector starts with an
|
||||
element name (`audio-player { … }` rather than `& audio-player { … }`)
|
||||
is silently dropped by the device's Chrome 113 and by nothing else, so
|
||||
every tier you can run renders it correctly. Run it after touching
|
||||
`index.css` or any `css` literal; a rule directly inside a top-level
|
||||
`@media` is not nested and is not flagged.
|
||||
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
|
||||
says it is not.** That endpoint 404s on this Gitea build. The REST
|
||||
API answers, with the `GITEA_TOKEN` already in the environment:
|
||||
@@ -142,6 +166,7 @@ only climb when it cannot.
|
||||
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
|
||||
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
|
||||
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
|
||||
| Anything that has to survive on a phone | `make android-smoke` against a booted emulator | ~1 min + setup |
|
||||
|
||||
Two targets are once-per-clone prerequisites that are **not**
|
||||
dependencies of the targets needing them, so on a fresh checkout each
|
||||
@@ -176,6 +201,12 @@ Two rules about climbing:
|
||||
- **Do not write an e2e spec first.** Drive the flow by hand, then
|
||||
promote it with `/e2e`. Specs written blind assert on selectors that
|
||||
do not exist.
|
||||
- **Not every view has a nav item.** Since #25 the destinations are
|
||||
configurable, Autotag is hidden by default and Downloads is absent
|
||||
until a download client exists — so `getByTestId('nav-<view>')` waits
|
||||
30 s for a locator that will never resolve. `navigateTo(page, view)`
|
||||
(`e2e/support/fixtures.ts`) dispatches the app's own `navigate` event.
|
||||
Click the nav item when the *nav* is what the spec is about.
|
||||
|
||||
Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
|
||||
`make bindings-check`, `make css-check` and — from `frontend/` —
|
||||
@@ -387,10 +418,10 @@ never get the shell back.
|
||||
when iterating on a single package:
|
||||
|
||||
```bash
|
||||
go test -tags webkit2_41 ./backend/player/ # the app build
|
||||
go test -tags webkit2_41 -run TestName ./backend/player/
|
||||
go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/... # dump importer
|
||||
go test -tags "webkit2_41 dev" ./backend/testctl/... # control surface
|
||||
go test ./backend/player/ # the app build
|
||||
go test -run TestName ./backend/player/
|
||||
go test -tags indexbuild ./backend/explore/... ./cmd/... # dump importer
|
||||
go test -tags dev ./backend/testctl/... # control surface
|
||||
```
|
||||
|
||||
Forgetting the tag gives a build error that looks like a missing
|
||||
@@ -417,3 +448,9 @@ fails the build otherwise, including in files no lint pass compiles.
|
||||
and what breaks in it.
|
||||
- [schema-change.md](references/schema-change.md) — the two-file
|
||||
schema/migration checklist.
|
||||
- [android-tier.md](references/android-tier.md) — the emulator tier,
|
||||
and the three reasons a failure there looks like a success. **Read
|
||||
its first section before running anything on Android**: Go's stdout
|
||||
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
|
||||
and ActivityManager restarts a dying app fast enough that `pidof`
|
||||
always answers.
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
# The Android tier
|
||||
|
||||
A sixth tier, and the only one where **the app failing looks exactly
|
||||
like the app working**. Read the first section before you run anything;
|
||||
it is the difference between a diagnosis and an afternoon.
|
||||
|
||||
This tier answers "does the phone build run", nothing else. It is not a
|
||||
spec tier, it does not run in CI, and the app is not a usable Android
|
||||
player yet (plan 015 says why, at length).
|
||||
|
||||
## Two facts that make failure invisible
|
||||
|
||||
There were three. The first was that **Go's stdout does not reach
|
||||
logcat** — an Android app's fd 1 and 2 go to `/dev/null`, so every
|
||||
`slog` line the app wrote was discarded, including the one naming the
|
||||
error it was about to exit on. That is fixed (#160):
|
||||
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
|
||||
selected in `main()` by build tag, and the app's whole diagnostic
|
||||
stream now arrives under the `yellowjacket` tag, which `make
|
||||
android-logs` filters for.
|
||||
|
||||
What remains true about it is the part that misleads: **`setprop
|
||||
log.redirect-stdio true` still does not help**, because it redirects
|
||||
the *Java* runtime's `System.out` and the Go code is a c-shared native
|
||||
library. Nothing that reaches logcat here does so through stdout, so
|
||||
anything printed with `fmt.Println` is still lost. Log with `slog`.
|
||||
|
||||
The tag is a fixed string rather than the application id, and that is
|
||||
load-bearing rather than tidy: the debug build carries
|
||||
`applicationIdSuffix ".dev"` so it can be installed beside the release
|
||||
app, and it is the only build whose WebView can be inspected — so a tag
|
||||
derived from the id would be filtered out on the one build anybody
|
||||
debugging this app is running.
|
||||
|
||||
**`os.Exit` is a silent death.** `main()` ends several failure paths in
|
||||
`os.Exit(1)`. From Android's side that is a process that vanished:
|
||||
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
|
||||
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
|
||||
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
|
||||
dropbox. All three of the places you would look are empty, and the one
|
||||
signal that is present — SIGKILL — reads as "the system killed it",
|
||||
which is the wrong hypothesis.
|
||||
|
||||
**ActivityManager restarts it, so a dead app looks alive.** A
|
||||
crash-looping app is respawned several times a second, so `pidof` always
|
||||
answers and `am start` always reports `Status: ok`. "Did it start" is
|
||||
the wrong question. `make android-smoke` asks the right one — is it the
|
||||
*same pid* a few seconds later.
|
||||
|
||||
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
|
||||
followed immediately by a new pid doing the same thing. That means the
|
||||
native library loaded, the JNI bridge came up, Go's `main()` ran, and
|
||||
`main()` left. Work backwards through its `os.Exit(1)` paths — and
|
||||
since #160, **read the `E/yellowjacket` line above it first**, because
|
||||
every one of those paths logs the error before it exits. That line is
|
||||
what #52 spent months without.
|
||||
|
||||
## What to run
|
||||
|
||||
One-time, ~3.5 GB:
|
||||
|
||||
```bash
|
||||
make android-setup # SDK pieces + the yj-test AVD, idempotent
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
|
||||
make android-emulator # boot headless in the background, wait for boot
|
||||
make android-install # adb install -r
|
||||
make android-smoke # launch, then assert the same pid survives 10s
|
||||
make android-logs # filtered logcat, follow
|
||||
make android-emulator-stop # console kill, then the saved PID
|
||||
```
|
||||
|
||||
`make android-smoke SECONDS=30` for a longer window. On failure it
|
||||
prints the last 40 app-relevant logcat lines and how to read them.
|
||||
|
||||
Never `pkill -f emulator` — the pattern matches the invoking shell's own
|
||||
command line and kills it, silently dropping the rest of your compound
|
||||
command. The emulator is addressed by its saved pid in
|
||||
`.dev/emulator.pid`, same discipline as `make dev-stop`.
|
||||
|
||||
**adb is addressed by AVD name, not by whatever is plugged in.** The
|
||||
script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before
|
||||
any device command, because a second emulator (another project's, or
|
||||
this one's own corpse left `offline` by a previous run) makes a bare
|
||||
`adb` fail with "more than one device" — which `cmd_install` reported
|
||||
as *"no device — run 'make android-emulator' first"* immediately after
|
||||
that had succeeded. Serials are assigned in boot order and change
|
||||
between runs, so the AVD name is the identity. Set `ANDROID_SERIAL`
|
||||
yourself and it is honoured; one device that is not ours (a phone) is
|
||||
taken as the target.
|
||||
|
||||
## Things that cost a cycle
|
||||
|
||||
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
|
||||
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
|
||||
build-tools but `platforms/` is *empty*, so Gradle fails with a
|
||||
compileSdk error that reads like a version mismatch. The Makefile
|
||||
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
|
||||
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
|
||||
separately, because the Go half wants the NDK and the Gradle half
|
||||
wants the platform and they are in different places.
|
||||
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
|
||||
`android-ndk-26`). Newer NDKs have broken the Wails Android build
|
||||
before. CI pins the same one.
|
||||
- **Without KVM the emulator still works and is unusably slow** — a 30 s
|
||||
boot becomes tens of minutes, which reads as a hung target rather than
|
||||
a slow one. `make android-setup` checks and warns.
|
||||
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
|
||||
the previous run's app state, and a smoke result that depends on what
|
||||
the last run left behind is not a result.
|
||||
- **The logcat filter is not optional.** The emulator emits thousands of
|
||||
lines a second, nearly all WindowManager transitions; an unfiltered
|
||||
`adb logcat` buries the six lines that matter. `make android-logs`
|
||||
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
|
||||
`DEBUG` and `libc:F`.
|
||||
- **`run-as` does not work on a release-signed APK** (`package not
|
||||
debuggable`), so you cannot read the app's data directory or its
|
||||
environment that way. Ask the device instead, or build a debug variant.
|
||||
- **The `google_apis` system image, not `default`.** This app is a
|
||||
WebView app; `google_apis` ships the Chrome-based WebView that
|
||||
actually renders it.
|
||||
|
||||
## The current state of the build
|
||||
|
||||
**The app starts. The x86_64 emulator cannot run it, and that is not a
|
||||
bug in the app.**
|
||||
|
||||
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
|
||||
database layer, sits on — issues a **raw `lstat` syscall on
|
||||
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
|
||||
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
|
||||
syscall 6 on x86_64, because bionic never issues it, so the process
|
||||
takes `SIGSYS` the first time anything touches the database:
|
||||
|
||||
```
|
||||
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
|
||||
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
|
||||
```
|
||||
|
||||
**arm64 is unaffected, and structurally so.** There is no `lstat`
|
||||
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
|
||||
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
|
||||
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
|
||||
`syscall` package already uses `fstatat` on both architectures, which
|
||||
is why this is *only* the modernc path.
|
||||
|
||||
So: **verify on arm64, and on this machine that means a real device.**
|
||||
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
|
||||
says nothing about your change.
|
||||
|
||||
**Do not reach for an arm64 system image — it will not run here, and
|
||||
finding that out costs a 3.8 GB download.** Emulator 37 refuses
|
||||
outright:
|
||||
|
||||
```
|
||||
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
|
||||
emulator on x86_64 host. System image must match the host
|
||||
architecture.
|
||||
```
|
||||
|
||||
Google dropped cross-architecture emulation; there is no flag. The
|
||||
options are an arm64 host, a physical device, or `adb connect` to one.
|
||||
|
||||
**The x86_64 ABI is therefore gone from the build** (`abiFilters` in
|
||||
`build/android/app/build.gradle`, `android:package` rather than
|
||||
`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$`
|
||||
assertion in `android-apk.yml` that fails if it comes back). It could
|
||||
not run on any Android until modernc fixes this — x86 Chromebooks
|
||||
included — and dropping it took the artifact from 27 MB to 15.9 MB.
|
||||
The tombstone was at least honest while it lasted: unlike the
|
||||
`os.Exit` that came before it, it left a real crash record with a
|
||||
backtrace.
|
||||
|
||||
### The emulator still installs it, and it still does not run
|
||||
|
||||
The obvious guess about dropping x86_64 — that `make android-install`
|
||||
would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong,
|
||||
and was measured wrong before it was written down.** Google's
|
||||
`google_apis` x86_64 images carry arm64 translation:
|
||||
|
||||
```
|
||||
ro.product.cpu.abilist = x86_64,arm64-v8a
|
||||
```
|
||||
|
||||
So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so`
|
||||
and runs it (the tombstone says `Guest architecture: 'arm64'`). It then
|
||||
dies **before any of our code**, with SIGILL rather than SIGSYS:
|
||||
|
||||
```
|
||||
signal 4 (SIGILL), code -6 (SI_TKILL)
|
||||
#00 pc 00000000015911d0 .../lib/arm64/libwails.so
|
||||
```
|
||||
|
||||
Disassembling that offset names the reason exactly:
|
||||
|
||||
```
|
||||
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
|
||||
```
|
||||
|
||||
That is Go's `internal/cpu` reading the arm64 CPU-feature ID register
|
||||
at runtime init, which the translator does not implement. So it is not
|
||||
"our Go program is unlucky": **no Go binary starts under this
|
||||
translation layer**, and no amount of work on this app changes it.
|
||||
|
||||
The three failures are worth holding side by side, because each looks
|
||||
like the app's fault and none is:
|
||||
|
||||
| build | on x86_64 Android | signal |
|
||||
|---|---|---|
|
||||
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||
| arm64, real device | **runs** (2026-08-20) | — |
|
||||
|
||||
**A physical arm64 device remains the only verification path**, and it
|
||||
has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34,
|
||||
arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs,
|
||||
launches and stays up; `make android-smoke SECONDS=60` passes on it.
|
||||
What that run *found* is the lifecycle fault below.
|
||||
|
||||
### What was fixed to get here
|
||||
|
||||
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
|
||||
`default:` returning `errUnsupportedOS`, so Android failed at startup
|
||||
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
|
||||
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
|
||||
StoragePath())` before anything asks for a path — a documented,
|
||||
build-tag-free API that returns `""` on desktop, where the setter is a
|
||||
no-op. `backend/system` gained no import of the Wails application
|
||||
package, which matters for the same reason `backend/events` is split by
|
||||
the `indexbuild` tag.
|
||||
|
||||
**And `main()` is now latched to one run per process** (#52). That is
|
||||
the second `os.Exit(1)` in this file's history and it had the same
|
||||
signature as the first, which is the argument for #160: both were named
|
||||
exactly by an `slog` line that went to `/dev/null`.
|
||||
|
||||
### What is still not done
|
||||
|
||||
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||
still dead weight. Everything in plan 016's section A is now built:
|
||||
storage access, an in-app folder picker (Android's directory dialog
|
||||
returns an error, since the Storage Access Framework yields tree URIs
|
||||
rather than paths), MPRIS excluded, and a MediaSession with a transport
|
||||
notification and audio focus.
|
||||
|
||||
### Compiling the `android`-tagged Go by hand
|
||||
|
||||
`make lint` and `make test` never see it: their three tag sets are all
|
||||
linux/amd64, so the only thing that compiles `backend/mediacontrols/
|
||||
android.go` is `make android` — a full APK build for a Go type error.
|
||||
The short way round:
|
||||
|
||||
```bash
|
||||
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
|
||||
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
|
||||
```
|
||||
|
||||
**`CXX` is not optional.** Without it the oboe C++ sources in `oto`
|
||||
compile against the host sysroot and fail on `android/log.h` and
|
||||
`sys/system_properties.h`, which reads like a broken or missing NDK.
|
||||
Restrict it to `./backend/...`: `./...` additionally builds
|
||||
`build/android/gen`, a scaffold shim that only resolves inside the
|
||||
wails task and fails with `undefined: main` on its own.
|
||||
|
||||
A Go method added to a bound service also reaches the frontend unless
|
||||
it says not to — `//wails:ignore` above the func, which `make bindings`
|
||||
then honours. `Player.SetDuck` is driven by OS audio focus and carries
|
||||
one.
|
||||
|
||||
## The scaffold's own tasks
|
||||
|
||||
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||
they are the right thing to reach for when you want something one-off:
|
||||
|
||||
> **These four were unsafe until #159 and are now the way in.** All of
|
||||
> them began with `adb uninstall {{.APP_ID}}`, where `APP_ID` defaulted
|
||||
> to `app.yellowjacket` — the **release** id — while `run` and
|
||||
> `run:device` build the **debug** variant, whose id is
|
||||
> `app.yellowjacket.dev`. So they uninstalled the user's app, taking
|
||||
> the library with it, installed a different package, and then failed
|
||||
> to launch the one they had removed.
|
||||
>
|
||||
> They share `scripts/android-deploy.sh` now, which **never**
|
||||
> uninstalls (`install -r`, and a changed signing certificate is
|
||||
> reported with the command rather than acted on), reads the package id
|
||||
> back out of the built APK, and refuses a target that is not the kind
|
||||
> the task names. There is nothing left to avoid; the manual sequence
|
||||
> below is kept because it is still the smallest thing that works.
|
||||
|
||||
```
|
||||
wails3 task android:run # debug build + emulator install + launch
|
||||
wails3 task android:run:device # debug build + install + launch on a phone
|
||||
wails3 task android:deploy-device # release build, same
|
||||
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||
wails3 task android:studio # open build/android/ in Android Studio
|
||||
wails3 task android:device:list
|
||||
wails3 task android:logs:all
|
||||
wails3 task android:clean
|
||||
```
|
||||
|
||||
**`run` and `deploy-emulator` mean the emulator, and now say so to
|
||||
adb.** They used a bare `adb install`, which with exactly one device
|
||||
attached picks that device whatever it is — so with a phone plugged in
|
||||
and no emulator running, the task whose summary reads "in the Android
|
||||
Emulator" installed on the phone. They pass `--target emulator` and
|
||||
refuse with `make android-emulator` as the remedy.
|
||||
|
||||
**`DEVICE_ID=<serial>` still names a device, and several attached
|
||||
devices is now an error rather than a silent pick of the first.**
|
||||
|
||||
Two are deliberately **not** wrapped. `android:logs` greps logcat for
|
||||
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
|
||||
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
|
||||
does not match it) and misses `ActivityManager`'s "has died" line, which
|
||||
is the one that tells you it crashed; `make android-logs` filters by tag
|
||||
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
|
||||
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||
sequenced.
|
||||
|
||||
## The identity is read back from the APK
|
||||
|
||||
It used to be **declared twice**, and that is what #159 was.
|
||||
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
||||
installs; `APP_ID` in `build/android/Taskfile.yml` was what every
|
||||
adb-driven task uninstalled, launched and filtered, and nothing
|
||||
enforced that they agree. They did not: the debug buildType carries
|
||||
`applicationIdSuffix ".dev"`, so every task that assembles a debug APK
|
||||
addressed the release id. This file flagged the hazard for five phases
|
||||
and it cashed out twice — once as a wrong `am start`, once as an
|
||||
uninstall of the user's library.
|
||||
|
||||
**`scripts/android-pkgid.sh` is the one answer now.** It prints the
|
||||
package id an APK declares (`aapt2 dump packagename`, falling back to
|
||||
`aapt dump badging`), and the deploy path installs and launches *that*.
|
||||
The APK is the authority because the task that installs it has just
|
||||
built it: whatever Gradle resolved the applicationId to, suffixes and
|
||||
flavours included, is in the file, and no default can disagree with it.
|
||||
An APK it cannot read is a hard failure, never a fallback to a written
|
||||
down default — guessing is the bug.
|
||||
|
||||
**`APP_ID` survives as an assertion, not a setting**, and has no
|
||||
default. `wails3 task android:run APP_ID=app.yellowjacket` says "this
|
||||
build had better declare that id" and is refused, naming both, *before*
|
||||
anything is installed or a device is even chosen. It could never have
|
||||
been a setting: `ANDROID.md`'s advice to put it in `build/config.yml`
|
||||
does not work in beta.8 — `wails3 task` never reads that file (verified
|
||||
with `--dry`) — and even when set it fed only the adb commands, never
|
||||
Gradle.
|
||||
|
||||
`scripts/android-emulator.sh` derives `PKG` the same way, from
|
||||
`bin/yellowjacket.apk` when one is built, so `make android-install`,
|
||||
`android-launch`, `android-logs` and `android-smoke` follow whichever
|
||||
variant is actually in `bin/`. `YJ_ANDROID_PKG` still overrides, and
|
||||
the old literal survives only for a tree with no APK built yet.
|
||||
|
||||
**The uninstall is gone and is not coming back.** It existed to make
|
||||
the bare `install` on the next line work at all — without `-r` Android
|
||||
refuses an install over an existing package — so `install -r` removes
|
||||
the *reason* for it rather than merely removing it. What is left is the
|
||||
one case an uninstall really is the remedy, a changed signing
|
||||
certificate, and that is exactly the case where performing it silently
|
||||
costs the user their library. So it is named and not done, which is the
|
||||
answer `scripts/android-emulator.sh` had already reached for
|
||||
`make android-install`.
|
||||
|
||||
Related, and it will bite once: the launcher activity is
|
||||
`com.wails.app.MainActivity` and the applicationId is
|
||||
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
|
||||
resolves the leading dot against the *applicationId* and fails with a
|
||||
class-not-found that reads like a broken build. Always the
|
||||
fully-qualified form.
|
||||
|
||||
**`wails3 task android:run:device` is the way to put a debug build on a
|
||||
real device**, since #159. What #52 used, before it was safe, was the
|
||||
longer form, and it is still the smallest thing that works if you want
|
||||
no script between you and adb:
|
||||
|
||||
```bash
|
||||
wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
|
||||
adb install -r bin/yellowjacket.apk # -r, never uninstall
|
||||
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity
|
||||
```
|
||||
|
||||
The id in that last line is the one thing to keep an eye on by hand —
|
||||
`./scripts/android-pkgid.sh bin/yellowjacket.apk` is what the tasks ask,
|
||||
and it is a good habit before any `am start` written out in full.
|
||||
|
||||
`YJ_ANDROID_PKG=app.yellowjacket.dev` still overrides what
|
||||
`scripts/android-emulator.sh` — and therefore `make android-smoke`,
|
||||
`android-logs`, `android-launch` — addresses, but it is rarely needed
|
||||
now: that default is read from `bin/yellowjacket.apk`, so it already
|
||||
follows whichever variant was built last.
|
||||
|
||||
## What only a device can answer
|
||||
|
||||
The emulator cannot run this app (three separate reasons, none of them
|
||||
ours — see plan 016), so the phone in someone's pocket is a tier, and
|
||||
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
|
||||
the whole of A4 and found two faults **no other tier can see**:
|
||||
|
||||
- **The back gesture.** `MainActivity.onBackPressed` asks
|
||||
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
|
||||
so no spec had ever called `page.goBack()` and the app had never
|
||||
pushed a history entry — back quit from any depth. It is a history
|
||||
entry per navigation now, which is also what made it assertable in the
|
||||
browser tier (`e2e/specs/back-navigation.spec.ts`).
|
||||
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
|
||||
transport and the tab bar sat under the gesture bar. **A browser
|
||||
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
|
||||
keep passing on a build the device is clipping 48dp off. Insets are
|
||||
handled in `applyWindowInsets()`.
|
||||
|
||||
So when asking for a device run, ask about what the platform *adds* —
|
||||
system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
**The third such fault was the activity lifecycle** (#52), and it is
|
||||
the one to re-check after touching `main()`, `WailsBridge` or
|
||||
`MainActivity`. Android destroys and recreates an activity **without
|
||||
restarting the process**, and Wails' `nativeInit` — which
|
||||
`MainActivity.onCreate` calls — runs `go mainFunc()` every time. So
|
||||
Go's `main()` ran again on a live app, `app.Run()` refused (`a.starting`
|
||||
is still true behind Android's `select{}`), and the `os.Exit(1)` under
|
||||
it took the healthy first app down with it.
|
||||
|
||||
### The lifecycle check, and how to trigger it on demand
|
||||
|
||||
This is the regression guard for #52 on this tier, because no other
|
||||
tier runs `main()` on Android at all. The Go-side guard
|
||||
(`TestMainClaimsBeforeItDoesAnything`) catches work creeping above the
|
||||
latch; only the device catches the latch not working.
|
||||
|
||||
**Trigger a relaunch with a configuration change the manifest does not
|
||||
declare.** `AndroidManifest.xml` lists
|
||||
`orientation|screenSize|keyboardHidden|uiMode`, so those are handled
|
||||
in-place and are *not* triggers. `fontScale` is not listed, and it is a
|
||||
one-liner:
|
||||
|
||||
```bash
|
||||
adb shell settings put system font_scale 1.15 # restore the old value after
|
||||
```
|
||||
|
||||
That is the same in-process destroy/recreate that "Don't keep
|
||||
activities", a locale change and a memory trim produce, but on demand.
|
||||
|
||||
**"Don't keep activities" is the report's own lever and did not work on
|
||||
this device**: `settings put global always_finish_activities 1` reads
|
||||
back as `1`, `am set-always-finish-activities` does not exist on this
|
||||
build, and the activity was never finished on backgrounding. Do not
|
||||
spend an afternoon on it; use the config change.
|
||||
|
||||
**The assertion is the pid, and the tell is two bridge inits in one.**
|
||||
|
||||
```bash
|
||||
adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"
|
||||
```
|
||||
|
||||
Healthy is one pid appearing twice — the process surviving the
|
||||
recreation:
|
||||
|
||||
```
|
||||
I/WailsBridge(28420): Wails bridge initialized
|
||||
I/WailsBridge(28420): Wails bridge initialized <- same pid, recreated
|
||||
```
|
||||
|
||||
Broken is that pair followed within a second by:
|
||||
|
||||
```
|
||||
I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
|
||||
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
|
||||
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state
|
||||
```
|
||||
|
||||
Two things about reading that. **`has died: fg TOP` is not a memory
|
||||
kill** — the system does not reclaim the foreground process, so this is
|
||||
the app leaving of its own accord. And there is **no crash record
|
||||
anywhere**: `logcat -b crash` is empty, no `AndroidRuntime`, no
|
||||
`libc: Fatal signal`, no tombstone. That is the `os.Exit` signature,
|
||||
and it is why "the system killed it" is the wrong first hypothesis.
|
||||
|
||||
**Surviving is only half of it — check the recreated WebView is still
|
||||
wired to the running app.** A plausible-looking fix (making
|
||||
`WailsBridge.initialized` static, so the second `nativeInit` is skipped)
|
||||
keeps the process alive and silently breaks this, because `nativeInit`
|
||||
is also what re-points the JNI reference at the new bridge. Go would go
|
||||
on executing JavaScript against the destroyed activity's WebView: the
|
||||
app opens, renders, and never receives another backend event.
|
||||
|
||||
Ask the page, after a relaunch and a resume:
|
||||
|
||||
```bash
|
||||
make android-inspect
|
||||
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
|
||||
# background and foreground the app, then:
|
||||
make android-eval EXPR='JSON.stringify(window.__probe)'
|
||||
```
|
||||
|
||||
A healthy build answers with events from the live services —
|
||||
`["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]`.
|
||||
`[]` means the bridge reference is stale.
|
||||
|
||||
## Asking the device, not just looking at it
|
||||
|
||||
A real phone can be inspected, and that turns this tier from "reported
|
||||
symptoms" into evidence. Three commands:
|
||||
|
||||
```bash
|
||||
make android-screenshot # what the screen shows (.dev/ by default)
|
||||
make android-inspect # forward the WebView's devtools socket
|
||||
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
|
||||
```
|
||||
|
||||
Four things about it, each of which costs an hour if met cold:
|
||||
|
||||
- **Only a `debuggable` build has a devtools socket**, and a debug build
|
||||
carries `applicationIdSuffix ".dev"` so it installs **beside** the
|
||||
release app. That matters more than convenience: the two are signed by
|
||||
different certificates, and Android's only remedy for a changed
|
||||
certificate is an uninstall, which takes the user's library with it.
|
||||
Never uninstall to make room for a build.
|
||||
- **Playwright cannot drive it.** `connectOverCDP` calls
|
||||
`Browser.setDownloadBehavior`, a WebView answers "Browser context
|
||||
management is not supported", and the connection dies before the first
|
||||
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
|
||||
WebSocket for that reason.
|
||||
- **Wireless adb drops when the screen sleeps.** The symptoms are
|
||||
`device offline` mid-session and a `fetch failed` from the eval
|
||||
script. Plug in over USB for anything longer than a couple of probes.
|
||||
- **The socket name carries the pid**, which changes on every launch, so
|
||||
it is resolved rather than remembered.
|
||||
- **A reinstall resets the runtime permissions**, and the grant dialog
|
||||
is a separate activity that takes focus — so the app is up, `am start`
|
||||
reports "delivered to currently running top-most instance", and
|
||||
`pidof` is empty because it never got to the foreground.
|
||||
`dumpsys window | grep mCurrentFocus` naming
|
||||
`GrantPermissionsActivity` is the tell. `adb shell pm grant
|
||||
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
|
||||
`POST_NOTIFICATIONS`) ahead of the launch skips it.
|
||||
|
||||
### Getting the app into a state worth measuring
|
||||
|
||||
A fresh install is **not** a neutral starting point, and three things
|
||||
about it will each cost you a measurement.
|
||||
|
||||
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
|
||||
`dev-headless.sh` and in CI and is *real* here, so the app spends its
|
||||
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
|
||||
screen** while it does. Every vertical number taken in that state is
|
||||
wrong -- one #51 measurement had the album art at 0px and it was
|
||||
entirely this.
|
||||
|
||||
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
|
||||
cleanly. **It then starts again within seconds.** So stop it
|
||||
*immediately before* the measurement rather than once at the beginning,
|
||||
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
|
||||
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
|
||||
that otherwise keep the band open.
|
||||
|
||||
**A library added over the bridge does not dismiss the first-run
|
||||
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
|
||||
checks for an existing library once, on mount, and its "Get Started"
|
||||
button gates on a directory chosen *in the wizard* -- so it stays up
|
||||
with a correctly disabled button over everything you are trying to
|
||||
measure. Nothing is broken; reload the page and it is gone. This reads
|
||||
exactly like a tap being swallowed, which is the expensive part.
|
||||
|
||||
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
|
||||
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
|
||||
`AddLibrary` takes the plain path; a push into
|
||||
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
|
||||
there. Some builds additionally want
|
||||
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
|
||||
the app opens the *system* "All files access" screen on launch -- so
|
||||
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
|
||||
that, not a crash.
|
||||
|
||||
### A note on quoting `make android-eval`
|
||||
|
||||
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
|
||||
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
|
||||
snippet containing a string literal -- breaks in a way that reads as a
|
||||
JavaScript error. Put the expression in a file and pass it positionally:
|
||||
|
||||
```bash
|
||||
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
|
||||
```
|
||||
|
||||
That is the same script `make android-eval` wraps, so nothing is lost.
|
||||
Two things worth knowing about it: it does **not** await a promise, so
|
||||
an async call has to park its result (`window.__r = ...`) and be read
|
||||
back in a second eval; and the shim from the section below is lost on
|
||||
every reload and every app restart, along with the devtools socket,
|
||||
whose name carries the pid.
|
||||
|
||||
### Calling a binding on the device
|
||||
|
||||
**The runtime call does not go over HTTP on Android**, and this is worth
|
||||
knowing before an hour is spent on it. The WebView cannot deliver a
|
||||
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
|
||||
calls through the `addJavascriptInterface` bridge instead: the
|
||||
@wailsio/runtime installs a `customTransport` that calls
|
||||
`window.wails.invokeAsync(id, payload)` and receives the answer on
|
||||
`window._wailsAndroidCallback`. Two consequences:
|
||||
|
||||
- **`.playwright/init-events.js` does not transfer to the device.** Its
|
||||
outbound half hooks `fetch`, which sees nothing here, and its
|
||||
`call()` posts to `/wails/runtime`, which answers
|
||||
`Invalid runtime call: missing object value` — the interceptor got the
|
||||
URL with no body. Its *inbound* half is still right, because
|
||||
`dispatchWailsEvent` is the entry point in every mode.
|
||||
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
|
||||
the bundle captured its reference at module scope, so a wrapper
|
||||
installed afterwards records nothing. That is why the harness is an
|
||||
`initScript` and not a step in a spec.
|
||||
|
||||
What works is to borrow the bridge, chaining the runtime's own callback
|
||||
so its pending calls still resolve:
|
||||
|
||||
```js
|
||||
const pending = new Map();
|
||||
const prev = window._wailsAndroidCallback;
|
||||
window._wailsAndroidCallback = (id, response, error) => {
|
||||
if (!pending.has(id)) return prev && prev(id, response, error);
|
||||
const p = pending.get(id); pending.delete(id);
|
||||
const env = JSON.parse(response || "{}");
|
||||
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
|
||||
};
|
||||
window.__yj = { call(name, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = "yj" + Math.random().toString(36).slice(2);
|
||||
pending.set(id, { resolve, reject });
|
||||
window.wails.invokeAsync(id, JSON.stringify({
|
||||
object: 0, method: 0, windowName: "",
|
||||
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
|
||||
clientId: window._wails.clientId,
|
||||
}));
|
||||
});
|
||||
} };
|
||||
```
|
||||
|
||||
That turns the device into a tier that can be *driven* rather than only
|
||||
looked at — `__yj.call("player.Player.LoadFile", [path])` and
|
||||
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
|
||||
#53 was measured. Names are the Go ones (`GetTracks`, not
|
||||
`GetAllTracks`); an unknown one comes back as a plain
|
||||
`unknown bound method name`, so a wrong guess is loud.
|
||||
|
||||
**Getting audio onto the phone**: `adb push` into
|
||||
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
|
||||
files are not there — scoped storage. `/sdcard/Music/...` plus
|
||||
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
|
||||
plain path. The generated fixtures are **~2 seconds** each, which is
|
||||
fine for a scan and useless for watching a seek bar, so synthesise a
|
||||
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
|
||||
|
||||
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||
other tier runs a current Chromium or WebKit, so a spec that passes at
|
||||
that viewport says nothing about the phone — 113 has no Popover API and
|
||||
no relaxed CSS nesting, and a dropped CSS declaration renders as
|
||||
"present but wrong", which is the hardest failure to read from a
|
||||
picture. Get the version first; it reframes every other symptom.
|
||||
@@ -63,5 +63,24 @@ Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead
|
||||
address on purpose, so no seed depends on what the explore artifact
|
||||
server happened to be serving.
|
||||
|
||||
Rebuild a seed after any schema change, or the restored database is
|
||||
migrated on open in a way the seed's author never saw.
|
||||
Rebuild a seed after any schema change. Nothing migrates a restored
|
||||
database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed
|
||||
keeps its old columns, the app starts, and the first query dies on
|
||||
`no such column`.
|
||||
|
||||
**Restoring the seed does not disable the artifact fetch — only
|
||||
*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone,
|
||||
so on a developer machine the restored app immediately downloads and
|
||||
imports the real ~1.1M-row catalog, through the one writer connection,
|
||||
while whatever you started it for is running. A full `make e2e` against
|
||||
that reported **14 failures** that were all contention; the same suite
|
||||
against the same seed with
|
||||
|
||||
```bash
|
||||
YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default
|
||||
```
|
||||
|
||||
is the configuration CI runs (`ci.yml` sets exactly that address) and is
|
||||
what to use before believing a failure. The tell is in `.dev/app.log` —
|
||||
an import logging progress — and in how the failures look: timeouts
|
||||
spread across unrelated specs rather than one surface being wrong.
|
||||
|
||||
@@ -74,9 +74,14 @@ behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
|
||||
- **`snapshot` writes a file, it does not print the tree.** The
|
||||
command prints a path under `outputDir`; read that. Only the tail
|
||||
is echoed.
|
||||
- **Three separate browser caches.** `playwright-cli`, `@playwright/test`
|
||||
- **Two separate browser caches.** `@playwright/test`
|
||||
(`make e2e-setup`) and the Vitest provider (`make ui-setup`) each
|
||||
download their own Chromium. One working is no guarantee for the next.
|
||||
download their own Chromium. One working is no guarantee for the
|
||||
other. There used to be a third: `playwright-cli` was a *required*
|
||||
dependency because `scripts/seed-sandbox.sh` drove `AddLibrary`
|
||||
through a real page, `window.go` being v2's only way in. v3 answers
|
||||
the same call over HTTP, so the seed is `curl` now and the CLI is
|
||||
only an exploratory convenience.
|
||||
- **`getByRole('button', { name })` matches substrings.** "Play" also
|
||||
matches "Add queue to playlist"; transport controls need
|
||||
`exact: true`.
|
||||
|
||||
@@ -1,66 +1,76 @@
|
||||
# Changing the database schema
|
||||
|
||||
The reasoning — why there are two files, what the old 48-step migration
|
||||
chain got wrong, and when squashing is legitimate — is in `CLAUDE.md`
|
||||
under *Backend packages → database*. Read it once. This is the
|
||||
checklist.
|
||||
The reasoning — why the local library is shaped like files rather than
|
||||
like MusicBrainz, and what the metadata tables cost before they went —
|
||||
is in `CLAUDE.md` under *Backend packages → database*. Read it once.
|
||||
This is the checklist.
|
||||
|
||||
**A brand-new table needs one file, not two.** The rule below is about
|
||||
a *column added to a table that already exists*. `applySchema` runs
|
||||
every file in `sql/schemas/` on every open, so a
|
||||
`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a
|
||||
migration for it would be a second description of the same table — the
|
||||
thing the third rule forbids. Its indexes go in the schema file too,
|
||||
because the column and the index arrive together.
|
||||
**There is one description of the schema and no migration chain.**
|
||||
`sql/schemas/*.sql` declares the current shape; `applySchema` runs every
|
||||
file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent.
|
||||
`sql/migrations/`, `applyMigrations` and `schema_migrations` were
|
||||
squashed away with plan 013. So:
|
||||
|
||||
**Adding a table or a column is one edit to one file.**
|
||||
|
||||
```bash
|
||||
make generate # sqlc + templ
|
||||
go test ./backend/database/ ./backend/datamap/
|
||||
make test
|
||||
```
|
||||
|
||||
A new table has a second gate: **`backend/datamap`**. Add an entry
|
||||
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
|
||||
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
|
||||
wants an explicit exemption with a note, because authored data is what
|
||||
a user cannot get back.
|
||||
wants an explicit exemption with a note, because authored data is what a
|
||||
user cannot get back. If a *column* holds a different Kind from its
|
||||
table (an authored flag on an owned projection, a fetched value beside a
|
||||
tag-derived one), say so in the entry's note; `audio_files` and `lyrics`
|
||||
are the worked examples.
|
||||
|
||||
Adding a **column** to an existing table needs **two** files, not one:
|
||||
**Existing databases are not migrated.** Nothing upgrades a database
|
||||
from an older shape — delete your dev `YJ_HOME` and rescan, and rebuild
|
||||
any seed you rely on (`make sandbox-seed NAME=default`). Revisit this
|
||||
once real user databases exist in the wild.
|
||||
|
||||
1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT
|
||||
EXISTS`, the literal target shape, what sqlc reads and what a fresh
|
||||
install gets verbatim. Add the new column **last** in the
|
||||
`CREATE TABLE`.
|
||||
2. **`backend/database/sql/migrations/NNNN_description.sql`** — the
|
||||
`ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an
|
||||
existing database to the same shape. Schema files are a no-op against
|
||||
a table that already exists, so without this an upgrade never gets
|
||||
the column.
|
||||
**A stale one fails at the first query, not at open**, which is worth
|
||||
knowing before you read the error. `applySchema` is
|
||||
`CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns
|
||||
and gains nothing; the app then starts fine and dies on
|
||||
`no such column: title`. Every tier that does not *run the app* — unit
|
||||
tests, `make ui-test`, `tsc` — is green while this is true, because
|
||||
they build their database from the current schema. `make e2e` and
|
||||
`make dev` are the two that will tell you, and only after the seed has
|
||||
been rebuilt.
|
||||
|
||||
Then:
|
||||
## The four ways this goes wrong
|
||||
|
||||
```bash
|
||||
make generate # sqlc + templ
|
||||
go test -tags webkit2_41 ./backend/database/ # migration + column-order tests
|
||||
make test
|
||||
```
|
||||
- **A query file must be ASCII.** sqlc's parameter rewriter works on
|
||||
byte offsets, so a single non-ASCII character in a *query* comment
|
||||
(an em dash, a curly quote) shifts every placeholder and generates
|
||||
garbage like `SELECid` — a parse error a long way from its cause.
|
||||
Schema files are not rewritten and may contain anything.
|
||||
- **A slice and a named parameter do not compose.** `sqlc.slice`
|
||||
expands to N placeholders, but `sqlc.arg` is numbered independently,
|
||||
so the two in one query bind the wrong values —
|
||||
`GetFilePathsByAlbums([1,2], 0)` read album id 2 as the library id.
|
||||
Where a query needs both, return the column and filter in Go.
|
||||
- **A write wearing a query's shape still needs the writer.**
|
||||
`QueryContext`/`QueryRow` route to the query-only read pool, so an
|
||||
`INSERT ... RETURNING` through one fails at runtime with "attempt to
|
||||
write a readonly database (8)". Use `ExecContext`, or
|
||||
`QueryRowWriter`. `TestNoWritesOnTheReadPool` walks the tree for it.
|
||||
- **A view is dropped and recreated.** `CREATE VIEW IF NOT EXISTS`
|
||||
no-ops against a database holding the old definition, so
|
||||
`track_metadata.sql` opens with `DROP VIEW IF EXISTS`.
|
||||
|
||||
Rebuild any seed you rely on (`make sandbox-seed NAME=default`) and
|
||||
delete your own dev `YJ_HOME` if you want to see the fresh-install path
|
||||
rather than the migrated one.
|
||||
|
||||
## The three ways this goes wrong
|
||||
|
||||
- **Column order must match between the two paths.** `ADD COLUMN`
|
||||
always appends, so a migrated column declared anywhere but last in
|
||||
`CREATE TABLE` leaves fresh and upgraded installs disagreeing on
|
||||
order — and sqlc binds `SELECT *` positionally, so one of them
|
||||
silently reads the wrong field.
|
||||
`TestMigrations_ColumnOrderMatchesFreshInstall` is the regression test.
|
||||
- **Do not put an index on a migrated column in `sql/schemas/`.**
|
||||
Schema files run *before* migrations, against a database that may not
|
||||
have the column yet, and the predicate fails. Declare the index in the
|
||||
migration, after the `ALTER TABLE`.
|
||||
- **Do not add a third description of the schema anywhere.** A
|
||||
migration's `ADD COLUMN` failing with "duplicate column name" against
|
||||
an already-current database is expected and tolerated, not an error to
|
||||
route around.
|
||||
## Where things go
|
||||
|
||||
New queries go in `backend/database/sql/queries/`; generated Go lands in
|
||||
`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests
|
||||
use `database.NewTestDB(t)`, built by the same `applySchema` production
|
||||
uses, so the two cannot diverge.
|
||||
`backend/database/sql/sqlcgen/`, which is never edited by hand. Anything
|
||||
returning a track selects from the `track_metadata` view rather than
|
||||
re-joining — that is why there is one row type and one mapper.
|
||||
|
||||
Tests use `database.NewTestDB(t)`, built by the same `applySchema`
|
||||
production uses, and seed rows with `database.InsertTestTrack(t, db,
|
||||
database.TestTrack{...})` rather than assembling inserts by hand.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# The component and store tier (`make ui-test`)
|
||||
|
||||
313 tests in a real Chromium in ~2 s with no Wails, no backend, no
|
||||
seeded library and no virtual display. This is the cheapest coverage
|
||||
available and where the bulk of UI regression belongs.
|
||||
757 tests in a real Chromium with no Wails, no backend, no seeded
|
||||
library and no virtual display. This is the cheapest coverage available
|
||||
and where the bulk of UI regression belongs.
|
||||
|
||||
```bash
|
||||
make ui-setup # once: the Vitest provider's own Chromium
|
||||
@@ -15,12 +15,20 @@ make ui-test UI_ARGS='store/queue' # filter
|
||||
|
||||
## How it works
|
||||
|
||||
`frontend/wailsjs/` is a pure passthrough — every binding is
|
||||
`window.go[svc][Type][Method](args)`, every runtime call is
|
||||
`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts`
|
||||
replaces **those two globals and nothing else**, and the tests then
|
||||
exercise the *real* generated bindings and the *real* store code. No
|
||||
module mocking, and no second description of the Wails layer.
|
||||
Wails v3 routes every runtime call — bindings, event emits, window,
|
||||
dialogs, clipboard — through one IPC transport, and `setTransport()` is
|
||||
a public seam for replacing it. So
|
||||
`frontend/test/support/wails-fake.ts` replaces **that and nothing
|
||||
else**, and the tests then exercise the *real* generated bindings, the
|
||||
*real* runtime and the *real* store code. No module mocking, and no
|
||||
second description of the Wails layer.
|
||||
|
||||
A binding call carries a *method ID* (an FNV-1a hash of the Go method's
|
||||
fully-qualified name), not a name, so the fake derives the ID → path
|
||||
map from the generated tree at setup: each package's `index.ts`
|
||||
re-exports its service under the Go type's real name, which is the one
|
||||
place that casing survives. A path that never maps records as `#<id>`
|
||||
and fails the assertion naming it.
|
||||
|
||||
```ts
|
||||
emit(Events.QueueChanged, payload); // push a backend event
|
||||
@@ -31,11 +39,19 @@ lastArgs('queue.Queue.SetQueue');
|
||||
const el = await fixture('now-playing'); // mount; shadow()/text() query it
|
||||
```
|
||||
|
||||
The dispatcher mirrors wails' own `desktop/events.js`, including
|
||||
`maxCallbacks` expiry and the fact that a frontend `EventsEmit`
|
||||
notifies local listeners *before* Go.
|
||||
Delivery is not mirrored — `emit()` goes through the runtime's own
|
||||
`window._wails.dispatchWailsEvent`, which is the entry point the
|
||||
backend's push uses, so listener expiry and ordering are the runtime's
|
||||
real code. What *is* mirrored is one line of Go: how
|
||||
`EventManager.Emit` packs variadic data into an event's single `data`
|
||||
field (none is null, one is the value, more is the slice).
|
||||
|
||||
## Four things that will cost you time
|
||||
A frontend `Events.Emit` no longer notifies local listeners before Go —
|
||||
v3 calls the backend, which sends the event back out to every window.
|
||||
The page still sees its own emit, one round trip later rather than
|
||||
synchronously.
|
||||
|
||||
## Five things that will cost you time
|
||||
|
||||
- **Store singletons are constructed at module import**, before any test
|
||||
can stub. `test/setup.ts` therefore carries import-time defaults for
|
||||
@@ -54,6 +70,13 @@ notifies local listeners *before* Go.
|
||||
- **`@lit-labs/virtualizer` never produces two identical frames**, so
|
||||
`toMatchScreenshot` on `<queue-panel>` fails with "could not capture a
|
||||
stable screenshot" rather than a diff. Assert on its rows instead.
|
||||
- **A v3 binding settles several microtasks after a v2 one did** — it
|
||||
goes through `Call()`, an async `runtimeCallWithID`, the transport and
|
||||
a `CancellablePromise`, where v2's `window.go` proxy resolved one
|
||||
promise. `fixture()` drains microtasks between two renders so a
|
||||
component that loads in `firstUpdated` is loaded when it returns.
|
||||
Microtasks and not a timer, deliberately: a timer hangs forever under
|
||||
the suites that install fake ones.
|
||||
|
||||
Visual baselines are font-hinting and compositing sensitive, which is
|
||||
why they are opt-in: they only mean anything on the machine that
|
||||
@@ -61,14 +84,15 @@ recorded them.
|
||||
|
||||
## Bindings
|
||||
|
||||
`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`,
|
||||
`frontend/bindings/` is generated by `wails3`, **not** by `go generate`,
|
||||
so the pre-commit codegen check does not cover it — a renamed Go bound
|
||||
method first shows up at runtime, as a call that never settles.
|
||||
|
||||
```bash
|
||||
make bindings-check # ~1.5 s, also a pre-commit hook
|
||||
make bindings-check # ~3.5 s warm, also a pre-commit hook
|
||||
make bindings # regenerate for real
|
||||
```
|
||||
|
||||
The generator rewrites `wailsjs/runtime/*` as mode 755 every run; that
|
||||
is churn, not drift, and the check ignores it.
|
||||
No build tags are passed: the generator is a static analyser that sees
|
||||
only the configuration it is told about, and the one that matters is
|
||||
the one users run, which is the default tag set.
|
||||
|
||||
+2586
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ reviews. Nothing was changed.
|
||||
|
||||
Findings below are numbered `H-n` (hands-on) and cross-reference the
|
||||
static reports where they overlap. The reconciliation plan built from
|
||||
all four files is `.planning/plans/pending/007-ui-reconciliation.md`.
|
||||
all four files is `.planning/plans/completed/007-ui-reconciliation.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# 019 — The Android touch model
|
||||
|
||||
**Issue:** #63 (`Area/Library-UI`, `Kind/Feature`, `Priority/High`)
|
||||
**Depends on:** #60 (bottom-sheet menus) — closed, merged as PR #176
|
||||
**Relates:** #67 (inline links into the menu), #71 ("More" nav), #54
|
||||
(native feel), #5/#8 (selection, drag to queue — the desktop semantics
|
||||
being diverged from)
|
||||
**Status:** in flight.
|
||||
|
||||
#73 puts #60 first in Phase 4 because it is "the presentation every
|
||||
other item needs", and this is the next one. The Direction on #63 asks
|
||||
for the interaction model to be designed as one piece before any of it
|
||||
is built, because it *reassigns an existing gesture* rather than adding
|
||||
one — `utils/long-press.ts` currently owns the 500ms hold, and every
|
||||
context menu in the app is downstream of it.
|
||||
|
||||
This document is that design. Everything below is a measurement, or an
|
||||
argument for one of the choices #63 leaves open.
|
||||
|
||||
---
|
||||
|
||||
## The mapping
|
||||
|
||||
| gesture | pointer is a finger | pointer is a mouse |
|
||||
|---|---|---|
|
||||
| single tap / click | **play the row** | select the row |
|
||||
| double | — | play the row |
|
||||
| long press (500ms) | **enter selection mode** | — |
|
||||
| right-click | — | context menu |
|
||||
| swipe right | **add to queue** | — |
|
||||
| drag | reorder / drag to playlist | reorder / drag to playlist |
|
||||
|
||||
Three of those are #63's report unchanged. Two are decisions it left
|
||||
open, and one is a deliberate divergence.
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — the predicate is the pointer, not the platform
|
||||
|
||||
#63 says "the row component needs a platform-aware interaction layer
|
||||
rather than shared handlers". It needs an interaction layer; it should
|
||||
not be platform-aware.
|
||||
|
||||
**The question a row has to answer is not "am I on Android" or "is the
|
||||
viewport under 600px" but "what made this event".** `pointerType ===
|
||||
'touch'`, read off the event that is being handled, which is already
|
||||
how `long-press.ts` decides (`if (e.pointerType !== 'touch') return`)
|
||||
and is the only such test in the frontend today.
|
||||
|
||||
This is #64's rule — the predicate is named after the capability, not
|
||||
the platform — and it carries #64's warning with it. Keyed on a width:
|
||||
|
||||
- an Android **tablet** at 600px or more gets click-selects /
|
||||
double-click-plays on a touchscreen, which is the exact inversion
|
||||
this issue exists to fix, on the platform it exists for;
|
||||
- a **touchscreen laptop** cannot be described at all, because both
|
||||
pointers are live in the same session on the same row;
|
||||
- and a narrow desktop window gets phone semantics with a mouse.
|
||||
|
||||
Per event, all three are right for free, and there is no second
|
||||
declaration of what a phone does — the thing CLAUDE.md declines to add
|
||||
every time it comes up.
|
||||
|
||||
**Measured, so this is not an assumption about the WebView.** On the
|
||||
reference device (TLP301, Android 14, WebView Chrome 113, 424x439),
|
||||
driving a real tap with `adb shell input tap`:
|
||||
|
||||
```
|
||||
[["down","touch",78,94],["touchstart","touchstart",0,0],["up","touch",78,94]]
|
||||
```
|
||||
|
||||
`PointerEvent` exists, `pointerType` is `"touch"`, `maxTouchPoints` is
|
||||
5, and `(pointer: coarse)` / `(hover: none)` both match.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — there is no double-tap, and the number is why
|
||||
|
||||
#63 asks for *single tap → play* **and** *double tap → context menu*.
|
||||
Those two cannot both be honoured. The first tap of a double tap is
|
||||
indistinguishable from a single tap until the interval expires, so
|
||||
"tap plays" necessarily becomes "tap waits to find out whether you
|
||||
meant something else, then plays". The app already owns that constant:
|
||||
`utils/explore-link.ts` holds a navigation for `DOUBLE_CLICK_GRACE_MS
|
||||
= 250` for precisely this reason.
|
||||
|
||||
**What it would be added to, measured on the device.** Six runs, from
|
||||
the play command to the backend's `TrackChanged`:
|
||||
|
||||
```
|
||||
155, 123, 85, 56, 91 ms median ~100
|
||||
```
|
||||
|
||||
So the app's primary interaction is ~100ms, and a double-tap
|
||||
discriminator makes it ~350 — **3.5x, of which 250ms is spent
|
||||
deliberately doing nothing** — paid on every track anyone ever plays,
|
||||
in order to reach a menu.
|
||||
|
||||
It is also against the platform's convention, which counts for more
|
||||
than usual here because this is the phone build and nothing else:
|
||||
long-press is *how you select* on Android (Gmail, Files, Photos),
|
||||
double-tap is zoom or nothing, and a list's menu is either the
|
||||
long-press sheet or a per-row overflow.
|
||||
|
||||
**So the menu and the selection action bar become the same surface**,
|
||||
which is the convention and removes a concept rather than adding one.
|
||||
Long-press selects the row it was made on and raises the action bar;
|
||||
the bar's actions *are* the context menu's actions, contextualised to
|
||||
whatever is selected — one row or forty. #60's bottom sheet stays
|
||||
behind it as the overflow, so `contextMenuStyles`, `MenuKeyboard` and
|
||||
`menu-surface` are reused rather than reimplemented.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — tap-to-play and selection mode ship together
|
||||
|
||||
The obvious phase order is "tap plays first, it is the smallest
|
||||
change". It is wrong, and the reason is a capability that exists today
|
||||
and is easy to miss.
|
||||
|
||||
**A touch user can already multi-select**: tap selects (the desktop
|
||||
semantics, which a finger currently gets), and the long-press menu then
|
||||
acts on the selection. Move tap to play without shipping selection mode
|
||||
in the same change and there is a window — a release, if it lands — in
|
||||
which selecting forty tracks to add to a playlist is impossible on a
|
||||
phone. That is a regression dressed as an increment.
|
||||
|
||||
So phase 1 is both, or neither.
|
||||
|
||||
---
|
||||
|
||||
## What the code looks like now
|
||||
|
||||
| surface | how it binds | selection |
|
||||
|---|---|---|
|
||||
| `track-list` | delegated on the virtualizer: `click`, `dblclick`, `contextmenu`, `dragstart` | `SelectionController` |
|
||||
| `queue-panel` | delegated, same shape | `SelectionController` |
|
||||
| `playlist-details` | per row | `SelectionController` |
|
||||
| `smart-playlist-details` | per row | `SelectionController` |
|
||||
|
||||
All four already share `SelectionController`, and all four resolve a
|
||||
row from an event by `data-index` / `data-file-path` on the row. So the
|
||||
gesture layer has one shape to talk to, and "selection mode" is a flag
|
||||
on the controller they already have rather than a fifth concept.
|
||||
|
||||
`utils/long-press.ts` is one document-capture listener that synthesises
|
||||
a `contextmenu` — the seam that needed no component to opt in. **This
|
||||
plan keeps that shape and changes what the gesture means**, which is
|
||||
why it is a rewrite of that file rather than a second listener set: two
|
||||
document listeners both claiming the 500ms hold is the fault the file's
|
||||
own header warns about.
|
||||
|
||||
---
|
||||
|
||||
## Two measurements that decide the implementation
|
||||
|
||||
**`touch-action` is `auto` on both the virtualizer and the rows.** With
|
||||
`auto` the browser owns panning on both axes, so a horizontal drag can
|
||||
be claimed as a scroll and our gesture ends in `pointercancel`
|
||||
mid-swipe. A row that wants a horizontal swipe has to declare
|
||||
`touch-action: pan-y`: the browser keeps the vertical pan (which is the
|
||||
virtualizer's scroll, and must stay native or the list stutters) and
|
||||
hands us the horizontal axis. This is the single most likely way for
|
||||
swipe-to-queue to "work in Chromium and not on the phone".
|
||||
|
||||
**The row is 424x52 on the device**, so a swipe threshold in px is a
|
||||
fraction of a row height, not of a screen.
|
||||
|
||||
**And the third one was found by building phase 1 and then running it**
|
||||
— it is not something any browser tier can report. Chrome 113's Android
|
||||
WebView **fires its own `contextmenu` on a long press**. `long-press.ts`
|
||||
stood down when a trusted one arrived, which was right while both paths
|
||||
ended in the same place; once a hold can mean selection mode they end
|
||||
in different places, and standing down means the gesture silently does
|
||||
the *old* thing. Measured, before the fix:
|
||||
|
||||
```
|
||||
{"log":["contextmenu isTrusted=true"],
|
||||
"state":{"bar":null,"menuActive":true,"selected":1}}
|
||||
```
|
||||
|
||||
`yj-long-press` was never announced at all, the context menu opened,
|
||||
and all 26 tests in the component tier passed — dispatched pointer
|
||||
events do not make a browser synthesise a `contextmenu`.
|
||||
|
||||
So the browser's event is a **trigger, not a competitor**: the gesture
|
||||
is announced from it, and only a component that claims it suppresses
|
||||
the native menu. Unclaimed, it propagates untouched. That is the same
|
||||
"browser wins" outcome, reached by asking instead of assuming — and
|
||||
verified both ways on the device, a track row entering selection mode
|
||||
and an album card still opening its menu.
|
||||
|
||||
The tier could not *find* it and can *hold* it: a test cannot dispatch
|
||||
a trusted event, but this module has always told its own apart by
|
||||
identity rather than `isTrusted`, so an untrusted one from a test takes
|
||||
exactly the browser's path.
|
||||
|
||||
---
|
||||
|
||||
## A tier note: this one can be driven, not only measured
|
||||
|
||||
`adb shell input tap|swipe` reaches the WebView as real pointer events,
|
||||
which the log above is evidence of. So for the first time the Android
|
||||
tier can *perform* the thing under test rather than describe the page
|
||||
afterwards — a long press is `input swipe X Y X Y 600`, a swipe right
|
||||
is `input swipe X Y X+N Y 120`.
|
||||
|
||||
Device CSS pixels from device pixels, on this phone:
|
||||
`css = (device - 59) / 2.564` vertically, `css = device / 2.564`
|
||||
horizontally (measured from the tap above: 200,300 arrived as 78,94).
|
||||
|
||||
This does not make the device a spec tier — it does not run in CI and
|
||||
`make ui-test` still has to carry the assertions. It makes "does the
|
||||
gesture actually fire on Chrome 113" answerable in seconds.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase 1 — the seam, tap-to-play, selection mode.** `utils/
|
||||
touch-gestures.ts` replacing `long-press.ts`: pointer-typed
|
||||
recognition of tap / long-press / horizontal swipe, dispatched as
|
||||
composed custom events so a delegated listener in any shadow root
|
||||
still works. `SelectionController` gains a mode. `track-list` acts on
|
||||
tap and enters the mode on long press. The action bar.
|
||||
|
||||
**Phase 2 — swipe right to queue**, with the `touch-action: pan-y`
|
||||
finding above and a reveal-and-snap affordance.
|
||||
|
||||
**Phase 3 — the other three surfaces**, which is mostly wiring, since
|
||||
they already share the controller.
|
||||
|
||||
**Phase 4 — what this leaves behind.** The inline `explore-link`s in a
|
||||
row are a single-click target inside a row whose single tap now plays;
|
||||
that conflict is #67's, and this plan should not pre-empt its answer
|
||||
beyond making tap-to-play win on touch.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does selection mode have an escape other than the bar's own
|
||||
close?** Back is the platform's answer and the shell already owns
|
||||
the history stack (#6/#55). Pushing an entry for a *mode* rather
|
||||
than a place is the same argument #55 settled for the overlaid
|
||||
queue, and it should probably be settled the same way — but the
|
||||
queue is a screen and a selection mode is not, so it wants its own
|
||||
paragraph rather than an assumption.
|
||||
2. **Does a tap on a row's favourite icon still toggle it in normal
|
||||
mode?** It is inside the row and the row now plays. It has to keep
|
||||
working — it is a 44px target since #56 — so the gesture layer needs
|
||||
the same "a control inside the row wins" rule the keyboard service
|
||||
has for a focused control that owns a key.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
# 011 — An owned artist's discography, whole and offline
|
||||
|
||||
**Status:** built, **not yet verified against a real library**. Lint,
|
||||
the three Go test configurations, `tsc` and the Vitest suite all pass;
|
||||
what has *not* happened is a run against a seeded library with real
|
||||
MusicBrainz traffic, which is the only thing that can show the pass
|
||||
completing an artist end to end. Do that before moving this to
|
||||
`completed/`.
|
||||
**Branch:** main
|
||||
**Created:** 2026-08-13
|
||||
**Depends on:** nothing
|
||||
**Related:** 010 (owned albums, offline) — the same rate limiter, the
|
||||
next layer down. 010 warms *tracklists*; this warms the *list of
|
||||
albums*. Read 010's "the rate limiter is the whole design constraint"
|
||||
section before building either.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
`BackfillLibraryDiscographies` sounds like it does this and does not.
|
||||
Per owned artist, `indexOneArtist` (`searchindex.go:1910`) fetches from
|
||||
ListenBrainz:
|
||||
|
||||
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
|
||||
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
|
||||
|
||||
and both drop anything under `indexMinPopularity` (50 listens). So what
|
||||
an owned artist's page shows offline is **their fifty most-listened
|
||||
release groups**, not their discography. For an artist with a long tail
|
||||
— early EPs, live albums, splits, anything regional — the missing rows
|
||||
are precisely the ones a user who owns that artist is most likely to be
|
||||
looking for.
|
||||
|
||||
**It is also untyped.** LB's `top-release-groups-for-artist` returns no
|
||||
secondary types, so the first view of every backfilled artist has no
|
||||
EP / Live / Compilation / Soundtrack distinction — the discography
|
||||
renders as one undifferentiated list.
|
||||
|
||||
MusicBrainz's browse-by-artist has both the full list and the types,
|
||||
and `BrowseReleaseGroups` (`explore.go:589`) already knows it: on
|
||||
finding no secondary types on any indexed row it fires the browse **in
|
||||
a goroutine, for next time**, and `AddFromCache` writes the result into
|
||||
the index. So the fix is not new machinery. It is running that call
|
||||
deliberately, once per owned artist, at scan time instead of
|
||||
accidentally, on view, one artist at a time.
|
||||
|
||||
## What to build
|
||||
|
||||
Extend the existing post-scan pass — it is already bounded, resumable,
|
||||
idempotent and ordered by owned-track count, which is the shape this
|
||||
needs and the proven one in this codebase.
|
||||
|
||||
Per unenriched owned artist, in addition to today's LB fetches:
|
||||
|
||||
1. **`BrowseReleaseGroups`, paged to exhaustion.** `musicbrainz.go:318`
|
||||
issues a single `Paginator{Limit: MaxLimit}` with no offset loop, so
|
||||
a prolific artist is silently truncated at 100 release groups. Page
|
||||
until a short response. This is the one change that makes the word
|
||||
*full* honest, and it is a change to a function the interactive path
|
||||
also calls — which is a win, not a risk.
|
||||
2. **`SimilarArtists`.** `similar_artist_map` is not in the shipped
|
||||
artifact and is filled lazily on view (`explore.go:905`), so it is
|
||||
empty for every artist nobody has opened. It is one LB labs call and
|
||||
already persists; folding it in here costs a request and removes the
|
||||
page's last routine network dependency.
|
||||
|
||||
Deliberately **not** in scope: cover art for non-owned release groups.
|
||||
It is roughly *RGs per artist* fetches rather than one — an order of
|
||||
magnitude more requests than everything else here combined — and a
|
||||
missing thumbnail degrades to a placeholder, where a missing release
|
||||
group degrades to a page that is quietly wrong. Covers stay lazy.
|
||||
|
||||
## Four things that bite
|
||||
|
||||
**`discog_fetched` is one boolean and would now cover three fetches
|
||||
with different failure modes.** Today it is set only if an LB fetch
|
||||
returned rows (`indexOneArtist:1962`), which is the right rule for one
|
||||
call and useless for three — an MB failure would either permanently
|
||||
claim the artist as done or force the LB fetches to repeat. Track the
|
||||
facets separately. Prefer **a new table keyed by artist MBID** over new
|
||||
`explore_index` columns: `artifactimport.go:95` enumerates the columns
|
||||
the artifact merge preserves, so a flag column added there is a second
|
||||
place to remember, and forgetting it silently wipes every mark on the
|
||||
next artifact update. A new table is also the single-file schema case
|
||||
(`CREATE TABLE IF NOT EXISTS`, no migration) and needs a `datamap`
|
||||
entry — `Cache` / `Swept`, since it is re-derivable.
|
||||
|
||||
**The `hasSecondaryTypes` heuristic re-fires forever for an artist who
|
||||
has none.** An artist whose discography is entirely plain albums writes
|
||||
`secondary_types = ''` on every row, so the "we must be missing them"
|
||||
test is true on every visit and browses again (cheaply — 7-day
|
||||
`cacheTTLEntity` — but forever). An explicit per-artist "browsed at"
|
||||
mark retires the heuristic, which is a second reason for the table
|
||||
above.
|
||||
|
||||
**Popularity is safe, and only because of the upsert rule.**
|
||||
`AddFromCache` writes `Popularity: 0` for every browsed release group;
|
||||
`upsertIndexConflictSQL:2180` is "highest wins", so it cannot clobber
|
||||
the LB figures. The consequence is one to state rather than fix:
|
||||
`TopReleaseGroupsByArtist` orders by popularity descending, so the deep
|
||||
cuts this plan adds sort below the top fifty. That is the correct
|
||||
order.
|
||||
|
||||
**The MB limiter is shared — and the priority work this needed is
|
||||
done.** ~~One `NewRateLimiter()` at 1 req/s serves this,
|
||||
`PrefetchReleases`, and every interactive browse~~ — 010 says that and
|
||||
it is wrong on the detail: `e.mb` runs on `mbSearchLimiter`,
|
||||
`NewRateLimiterBurst(3, 1)`, while the 1/s `NewRateLimiter()` at
|
||||
`explore.go:84` is the *artist image* limiter. Both were shared with
|
||||
background work and both are FIFO, which was the real problem.
|
||||
|
||||
Shipped ahead of this plan (same session it was written):
|
||||
|
||||
- `RateLimiter.WithBackgroundLane(perSecond)` plus
|
||||
`WithBackgroundPriority(ctx)` — a marked caller yields entirely while
|
||||
any interactive wait is outstanding, and is paced at MB's own 1/s
|
||||
rather than the interactive burst rate. The marker is a context value
|
||||
so a backfill and a detail page can call the same
|
||||
`MusicBrainzClient` method and be treated differently.
|
||||
- Both existing backfills mark their context, including the artist
|
||||
image resolution (`GetArtistImage` takes a `ctx` now for no reason
|
||||
other than carrying that marking).
|
||||
- `jobs.KindCatalogEnrich` and `startBackfillJob` — both backfills are
|
||||
registered, cancellable, and show progress. No job is registered
|
||||
when there is nothing to do, which is every launch once the library
|
||||
is covered.
|
||||
|
||||
So this plan inherits the lane: mark the new fetches background and add
|
||||
them to the existing job's progress. What it must **not** do is treat
|
||||
"a backfill is now polite" as licence to widen it without measuring —
|
||||
the yield gate protects latency, not the origin's patience.
|
||||
|
||||
## Done when
|
||||
|
||||
- An owned artist's page, opened for the first time after a scan,
|
||||
renders their complete typed discography with no network call —
|
||||
including release groups under the popularity floor and beyond the
|
||||
first 100.
|
||||
- Similar artists render offline for an owned artist nobody has opened.
|
||||
- An interactive browse issued while the backfill runs is not delayed
|
||||
by it.
|
||||
- The backfill appears in the jobs indicator and can be paused and
|
||||
cancelled.
|
||||
- A second run after a completed one does approximately nothing, and an
|
||||
artifact update does not undo a completed one.
|
||||
@@ -0,0 +1,160 @@
|
||||
# 012 — What we ask the network for, and what we already had
|
||||
|
||||
> **Completed.** Findings 1, 2 and 4 shipped. Finding 3 — the bound-but-uncalled methods — is now **#86**.
|
||||
|
||||
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
|
||||
configs), `tsc` and 752 Vitest tests pass; **not driven against the
|
||||
real app**, so the numbers below are read off the code, not measured.
|
||||
|
||||
One claim in the audit was wrong and is corrected in finding 3:
|
||||
`CheckLibraryMBIDs` is *not* dead — `downloadcatalog.go:152` calls it.
|
||||
It has no *frontend* caller, which is what was checked and not what was
|
||||
written.
|
||||
**Branch:** none yet
|
||||
**Created:** 2026-08-13
|
||||
**Related:** 010 (owned albums offline), 011 (owned artists' discography)
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Every frontend call site that can reach the network, and the backend
|
||||
method behind it. The question asked of each: *is there a local answer
|
||||
first, and if we do go out, do we go out once for many things or many
|
||||
times for one?*
|
||||
|
||||
## What is already right, and is the standard the rest is measured against
|
||||
|
||||
- **Every catalog read is index-first.** `LookupArtist`,
|
||||
`LookupReleaseGroup`, `BrowseReleaseGroups`,
|
||||
`TopRecordingsForArtist`, `TopReleaseGroupsForArtist`,
|
||||
`SimilarArtists` and `ResolveReleaseGroupMBIDs` all answer from
|
||||
`explore_index` / `similar_artist_map` and only fall through on a
|
||||
miss — several kick a background fetch and return empty rather than
|
||||
blocking, with a `*Ready` event to re-read.
|
||||
- **Album art has the right shape:** seed from the library, one
|
||||
`GetThumbnails` batch that is *cached-only by contract*, then
|
||||
per-item `GetThumbnail` calls that stream in
|
||||
(`explore-view.ts:1445`). Nothing waits on a batch of network
|
||||
fetches.
|
||||
- **Artist art has the right shape in exactly one place:**
|
||||
`seedSimilarArtistImagesFromLibrary`
|
||||
(`explore-artist-details.ts:1627`) — library store, then disk-only
|
||||
`GetArtistImageCachedPath`, fired in parallel, zero network calls.
|
||||
It is the model for finding 1.
|
||||
|
||||
## Finding 1 — Explore's artist images: no disk check, and serial
|
||||
|
||||
`explore-view.ts:1526-1546`. `loadArtistImages` seeds from
|
||||
`libraryStore.cachedArtists` — i.e. **owned artists only**, which on a
|
||||
catalog search is a small minority of results — and then, for every
|
||||
remaining artist:
|
||||
|
||||
```ts
|
||||
const url = await GetArtistImageURL(a.mbid); // in a for loop
|
||||
```
|
||||
|
||||
Two faults, both fixed by patterns already in the codebase:
|
||||
|
||||
- **No cached-path pass.** `GetArtistImageCachedPath` and
|
||||
`GetArtistImageCached` are disk-only and free, and neither is used
|
||||
here. An artist whose portrait is already on disk from a previous
|
||||
search still takes the resolution path.
|
||||
- **`await` in a loop.** `GetArtistImageURL` is the *resolving* entry
|
||||
point: on a miss it does MB artist-rels (on the 1/s artist-image
|
||||
limiter) → Wikidata → Wikipedia → a Wikimedia image download. Serial
|
||||
awaits mean 8 unresolved artists are 8 of those end to end, each
|
||||
blocking the next, while the equivalent album-art path fires all of
|
||||
them at once.
|
||||
|
||||
The same "resolver used where a cache check belongs" appears at
|
||||
`top-results-row.ts:218` and `artist-details.ts:207` (both fire in
|
||||
parallel, so only the first fault applies, and both are small-N).
|
||||
|
||||
**Fix:** disk-cached pass first, then network in parallel. A
|
||||
`GetArtistImagesCached(mbids []string) map[string]string` mirroring
|
||||
`GetThumbnails` would make it one IPC call instead of N — see finding 4
|
||||
for why that is not `GetArtistImages`.
|
||||
|
||||
## Finding 2 — The artist page prefetches tracklists twice, or four times
|
||||
|
||||
`prefetchReleases` (`explore-artist-details.ts:1531`) is called from
|
||||
**both** `fetchTopReleaseGroups` (:1467) and `fetchReleaseGroups`
|
||||
(:1506), and `PrefetchReleases` fires up to **8** `BrowseReleases` per
|
||||
call — the most expensive request the app makes (every version of a
|
||||
release group, with `recordings` and `media`).
|
||||
|
||||
The top release groups are a subset of the discography, so the two
|
||||
calls are asking about overlapping sets; the backend's
|
||||
`BrowseReleasesCached` guard stops a *literal* repeat, which means the
|
||||
second call spends its 8 slots on the next 8 uncached albums rather
|
||||
than doing nothing. One page view is therefore up to 16 browses — and
|
||||
on a cold artist, `ArtistDiscographyReady` re-runs both fetchers
|
||||
(:945, :948), taking it to 32.
|
||||
|
||||
Worse, some of that is now provably wasted: since tag-derived
|
||||
completeness landed (`dcc40b1`), **a complete, MBID-matched album opens
|
||||
with no catalog call at all**, so warming its tracklist buys nothing.
|
||||
|
||||
**Fix, in order of value:**
|
||||
|
||||
1. Prefetch once, from the union of both lists, after both resolve.
|
||||
2. Skip release groups that are owned and complete —
|
||||
`GetAlbumCompleteness` already answers this locally.
|
||||
3. Revisit the cap of 8 with the other two in place. Plan 010 flags
|
||||
the same number from the other direction.
|
||||
|
||||
## Finding 3 — Batch helpers with no caller (one of which was live)
|
||||
|
||||
`CheckLibraryMBIDs`, `GetPopularityBatch` and `GetArtistImages` are
|
||||
bound to the frontend and have **no call site in `frontend/src`**.
|
||||
They are the batch shapes a future N+1 would want, and their existence
|
||||
is presumably why the N+1s above were not noticed.
|
||||
|
||||
**`CheckLibraryMBIDs` is not dead** — `downloadcatalog.go:152` calls
|
||||
it from Go, one MBID at a time. Deleting it broke the build, which is
|
||||
how that was found; it is kept, with a comment saying who its consumer
|
||||
is. Read "no frontend caller" as exactly that, and grep both languages
|
||||
before removing a bound method.
|
||||
|
||||
Note `GetArtistImages` is not the helper finding 1 needs: it resolves
|
||||
names through `libMBID.AllArtistMBIDs()`, so it only answers for
|
||||
artists **in the library** — the exact set Explore's search results are
|
||||
not. Either give it an MBID-keyed sibling or replace it.
|
||||
|
||||
Also bound with no caller, and worth a separate decision about whether
|
||||
the feature is live at all: `GetTrackLyrics`, `GenerateMix`,
|
||||
`GetArtistPlayCount`, `GetLibrarySimilarArtists`,
|
||||
`GetCandidateThumbnail`.
|
||||
|
||||
## Finding 4 — One more background pass with no job and no priority
|
||||
|
||||
`BackfillLibraryLyrics` (`lyrics.go:129`) is a bare `go` call: bounded
|
||||
by passes and per-track (LRCLIB has no batch endpoint, so per-track is
|
||||
correct), but with no `jobs` registration and no
|
||||
`WithBackgroundPriority` marking. It runs on its own limiter, so it
|
||||
starves nothing today — but it is invisible and uncancellable, which is
|
||||
the gap 011 just closed for the other two backfills.
|
||||
|
||||
## Not a finding, recorded so it is not re-audited
|
||||
|
||||
- `GetThumbnails` returning only cached entries is deliberate and
|
||||
documented; the per-item follow-up is the streaming half, not an
|
||||
N+1.
|
||||
- `explore-artist-details` calling both `TopReleaseGroupsForArtist`
|
||||
(50) and `BrowseReleaseGroups` (200) reads overlapping rows from the
|
||||
index twice, but both are local queries feeding two different
|
||||
sections. Not worth merging.
|
||||
- The newest components (`home-view`, `catalog-scope-notice`,
|
||||
`page-header`, the notification stack, `shortcuts-overlay`) make no
|
||||
network calls at all. `home-view` is `GetShelves` + `GetAlbumTracks`,
|
||||
both local.
|
||||
|
||||
## Done when
|
||||
|
||||
- An Explore search with no owned artists in it makes zero artist-image
|
||||
network calls for portraits already on disk, and resolves the rest
|
||||
concurrently.
|
||||
- Opening an artist page issues one prefetch pass, over albums that are
|
||||
not already fully owned.
|
||||
- The bound-but-uncalled batch helpers are either wired or removed.
|
||||
@@ -0,0 +1,639 @@
|
||||
# 013 — The database audit
|
||||
|
||||
**Status:** **complete** (2026-08-16). R1–R10 landed, the album page
|
||||
that prompted the audit with them, and the one part of R5 that ships
|
||||
*in the artifact* — a per-release-group track denominator — landed as
|
||||
plan 014.
|
||||
The audit below is unchanged from when it was written — the measurements
|
||||
describe the *old* shape and are the reason for the new one.
|
||||
**Branch:** none
|
||||
**Created:** 2026-08-15
|
||||
**Supersedes:** the four-part album-page fix sketched in conversation
|
||||
(it survives, reduced, as R1 and R3 below)
|
||||
**Related:** 010 (owned albums offline), 011 (owned artists'
|
||||
discography), 012 (API call audit), 002 (data lifecycle)
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
Every number here is measured against the **real 25,966-track library**
|
||||
at `~/.local/share/yellowjacket/yj.db` (copied read-only), not against
|
||||
a fixture and not inferred from the code. Where a claim rests on a
|
||||
capability rather than a count — "sqlc can do X" — it was executed, not
|
||||
assumed.
|
||||
|
||||
The brief: *efficiency and simplicity — the minimum required to achieve
|
||||
our featureset*, with fewer lines and a smaller database as evidence
|
||||
rather than as the goal. Two named sources of confusion to resolve:
|
||||
**local versus remote** versions of a thing, **files versus tracks**,
|
||||
and **indexed versus live** lookups. One added constraint: **avoid
|
||||
hitting APIs by storing intelligently, without a ridiculous base
|
||||
install.**
|
||||
|
||||
---
|
||||
|
||||
## The measurements
|
||||
|
||||
### The database is 1.00 GB, and 78% of it is one table
|
||||
|
||||
| object | size | rows |
|
||||
|---|---|---|
|
||||
| `explore_index` | 383 MB | 2,052,200 |
|
||||
| its five indexes + `UNIQUE(mbid)` | 395 MB | — |
|
||||
| its two FTS tables | 85 MB | 2,052,200 + 96,451 |
|
||||
| `recordings` | 38 MB (27 MB of it lyrics) | 26,778 |
|
||||
| `lyrics_index` | 18 MB | 24,294 |
|
||||
| `artist_metadata` | 12 MB | 7,673 |
|
||||
| `http_cache` | 9 MB | 2,930 |
|
||||
| `audio_files` | 5 MB | 25,966 |
|
||||
| everything else | < 10 MB | — |
|
||||
|
||||
The local library — the part that is *the user's* — is about 50 MB.
|
||||
The catalog and its indexes are 780 MB.
|
||||
|
||||
### Inside `explore_index`, half the bytes are three text columns
|
||||
|
||||
| column | bytes | note |
|
||||
|---|---|---|
|
||||
| `mbid` | 70 MB | 36-char text; 16 bytes as a blob |
|
||||
| `artist_mbid` | 70 MB | same, and it is a foreign key in disguise |
|
||||
| `caa_release_mbid` | 62 MB | same |
|
||||
| `entity_type` | 18 MB | three distinct values, stored as words |
|
||||
| `title` / `artist_name` / `release_name` | 74 MB | real data |
|
||||
|
||||
Five columns are declared, shipped in the artifact, selected in every
|
||||
query, and **empty**: `aliases` (0 rows), `sort_name` (0),
|
||||
`disambiguation` (0), `country` (69 rows of 2.05 M), `artist_type`
|
||||
(72). `aliases` is additionally a column in *both* FTS tables, so the
|
||||
tokenizer indexes nothing, twice.
|
||||
|
||||
### Two 50 MB indexes have a `WHERE` clause that excludes 0.3% of rows
|
||||
|
||||
`idx_explore_title_lower` (53 MB) and `idx_explore_artist_lower`
|
||||
(48 MB) are `WHERE popularity > 0`. 2,046,645 of 2,052,200 rows satisfy
|
||||
that. They are full indexes wearing a partial index's clothes, and they
|
||||
exist to serve one exact-match tier (`ExactMatches`,
|
||||
`searchindex.go:1298`) that the champion FTS — 96,451 rows, 2 MB —
|
||||
already covers the popular half of.
|
||||
|
||||
### The local library models many-to-many relationships that are all 1:1
|
||||
|
||||
| claim | measured |
|
||||
|---|---|
|
||||
| recordings with more than one file | **0** |
|
||||
| recordings in more than one release group | **0** |
|
||||
| artist credits with more than one artist | **3** of 2,823 |
|
||||
| files sharing a recording | **0** |
|
||||
|
||||
`recordings` (26,778) is one row per file. `release_group_recordings`
|
||||
(26,778) is one row per file. `artist_credit` (2,823) and
|
||||
`artist_credit_artist` (2,826) differ by three.
|
||||
|
||||
### …and it leaks rows that outlive the files
|
||||
|
||||
| orphan | count |
|
||||
|---|---|
|
||||
| `recordings` with no `audio_files` row | **812** (218 carry MBIDs) |
|
||||
| `release_groups` with no file underneath | **216** |
|
||||
| `artists` credited on no file | **260** |
|
||||
| `explore_index` rows flagged **`in_library` with no file behind them** | **129** recordings, 2 release groups, 1 artist |
|
||||
|
||||
That last row is the bug reported today, in the user's own data.
|
||||
|
||||
### The query surface
|
||||
|
||||
| surface | count |
|
||||
|---|---|
|
||||
| sqlc queries | 235 (7,850 generated Go lines) |
|
||||
| raw SQL call sites outside sqlc | 188 |
|
||||
| bound IPC methods | 272 |
|
||||
| `X` / `XByLibrary` query twins | 14 (8 of them exposed as separate bindings) |
|
||||
| copies of the "one row per file with its metadata" projection | **9**, plus the view that already defines it |
|
||||
|
||||
`mapTrackRow` takes **22 positional arguments** and is called from 9
|
||||
places, because each duplicated query generates its own row struct.
|
||||
|
||||
### The data directory is 8.5 GB — the database is the small part
|
||||
|
||||
| path | size | of which |
|
||||
|---|---|---|
|
||||
| `artist-images/` | 5.4 GB | **4,125 MB is candidate images no code path reads**; 1,222 MB is primaries + tiers for **5,770 artists** in a library with **1,301** |
|
||||
| `covers/` | 1.4 GB | **1,134 MB is originals**; all three rendered tiers together are 110 MB |
|
||||
| `ffmpeg/` | 283 MB | bundled binary |
|
||||
| `yj.db` | 1.0 GB | above |
|
||||
| `yj.db.bak` + `.bak.20260309` | 452 MB | nothing deletes these |
|
||||
| art caches (`cover-art-cache`, `artist-image-cache`) | 81 MB | catalog art, fine |
|
||||
|
||||
The 4.1 GB of unreachable artist candidates is the bug `CLAUDE.md`
|
||||
records as fixed; this install still carries it, so **the janitor jobs
|
||||
have never run here**. Worth confirming they run at all before
|
||||
declaring that one closed.
|
||||
|
||||
---
|
||||
|
||||
## The diagnosis
|
||||
|
||||
Everything below is downstream of one thing.
|
||||
|
||||
**There are three different notions of "a track" in this app, and the
|
||||
code keeps asking the wrong one.**
|
||||
|
||||
1. **A file** — a row in `audio_files`. The only thing that is
|
||||
unambiguously *yours*: it has a path, it plays.
|
||||
2. **A local entity** — a row in `recordings` / `release_groups` /
|
||||
`artists`. Created by a scan *from* a file, but with an independent
|
||||
lifetime: nothing deletes it when the file goes, and retagging a
|
||||
file **creates a new one and abandons the old**
|
||||
(`library.go:1722` repoints `audio_files.recording_id` at a fresh
|
||||
recording; `pruneOrphanedMetadata` only runs on the scan's
|
||||
*deleted-file* branch, `library.go:982`). This is where the 812
|
||||
orphans come from — and autotagging is the machine that makes them.
|
||||
3. **A catalog entity** — a row in `explore_index`, downloaded, global,
|
||||
identical for every user.
|
||||
|
||||
"Is this mine" is asked of **(2)** almost everywhere, and answered by
|
||||
**(1)** whenever the user actually does something:
|
||||
|
||||
- `LibraryMBIDIndex.CheckMBIDs` (`librarymbid.go:64`) is literally
|
||||
`SELECT mbid FROM recordings WHERE mbid IN (…)`. It sets `inLibrary`
|
||||
on every catalog tracklist.
|
||||
- `pruneStaleLocalCrossReferences` (`searchindex.go:2480`) clears
|
||||
`explore_index.in_library` when the **`recordings` row** disappears —
|
||||
not when the file does. Hence 129 phantom "you own this" rows.
|
||||
- `albumLibraryStatus()` in `explore-album-details.ts` ORs four claims
|
||||
of decreasing confidence, none of which is "a file exists".
|
||||
- But `GetFilePathsByRecordingMBIDs`, which every *action* goes
|
||||
through, joins `audio_files`. It is the only one that tells the
|
||||
truth.
|
||||
|
||||
So a retagged file leaves behind a recording carrying the **old** MBID;
|
||||
the catalog matches that MBID; the row renders owned, undimmed, with a
|
||||
Play button; and every action on it fails with "could not be found in
|
||||
your library" — on a fully-tagged library. The user's instinct that the
|
||||
check is fragile is correct, and the fragility is not the live lookup.
|
||||
**The live lookup is the only part that is right.**
|
||||
|
||||
The same confusion explains "files vs tracks" and "local vs remote":
|
||||
tables (2) exist to be a local mirror of the catalog's shape, so a
|
||||
"track" is sometimes a file, sometimes a mirror row, sometimes a
|
||||
catalog row, and the three are joined by MBID — a key that **two of the
|
||||
three can lack or lie about**.
|
||||
|
||||
---
|
||||
|
||||
## Findings and recommendations
|
||||
|
||||
### R1 — Ownership is "a file exists". Say it once, in SQL.
|
||||
|
||||
*Cheap, immediate, and it fixes the reported bug.*
|
||||
|
||||
- `CheckMBIDs`' `recordings` and `release_groups` branches gain a join
|
||||
to `audio_files`. (`artists` too, via credit.)
|
||||
- `pruneStaleLocalCrossReferences` tests for a file, not for a local
|
||||
row.
|
||||
- `pruneOrphanedMetadata` runs after the retag path as well as the
|
||||
delete path — or, better, is deleted along with the tables that need
|
||||
it (R2).
|
||||
- One-shot cleanup of the 812/216/260 existing orphans at open.
|
||||
|
||||
**Effect:** 129 lying rows in this library become honest; the class
|
||||
cannot recur while (2) exists.
|
||||
|
||||
### R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one
|
||||
|
||||
*The big one. It is what makes R1 structural rather than a patch.*
|
||||
|
||||
The local model imitates MusicBrainz's normalization — `artist_credit`
|
||||
is an MB concept — for a dataset in which **every relationship it
|
||||
models is 1:1** (measured above). The cost of that imitation:
|
||||
|
||||
- 5 tables (`recordings`, `release_group_recordings`, `artist_credit`,
|
||||
`artist_credit_artist`, `release_to_rg` — the last has **0 rows** and
|
||||
no schema-file writer) and ~12 indexes.
|
||||
- A 6-way join in every read, including a `MIN(release_group_id)`
|
||||
subquery repeated in **11 places** to undo a many-to-many that never
|
||||
happens, and a "first credited artist" subquery in **9** to undo
|
||||
another (the row-multiplication bug class documented at length in
|
||||
`CLAUDE.md`, which serves 3 rows).
|
||||
- An orphan-cleanup subsystem (`GetOrphaned*IDs` ×3, `Count*References`
|
||||
×2, `pruneOrphanedMetadata`) that exists only because these rows can
|
||||
outlive their file — and which does not actually work (812 orphans).
|
||||
- The entire phantom-ownership class above.
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```
|
||||
audio_files id, path, library_id, …, title, track_no, disc_no, year,
|
||||
composer, comment, artist_credit TEXT, artist_id→artists,
|
||||
album_id→albums, recording_mbid, modified_at, …
|
||||
albums id, name, artist_id, mbid, year, original_year,
|
||||
cover_art_id, total_tracks… (genuinely many files→1)
|
||||
artists id, name, mbid (genuinely many→1)
|
||||
genres + file_genres (genuinely many↔many:
|
||||
107k rows / 26k files)
|
||||
```
|
||||
|
||||
`artist_credit` survives as **text on the file** (display: "A feat.
|
||||
B") plus `artist_id` (the primary artist, for grouping) — which is
|
||||
everything the UI does with it today, minus the join that multiplies
|
||||
rows.
|
||||
|
||||
**Effect:** a row exists iff a file exists, so R1 becomes a foreign key
|
||||
rather than a rule anyone can forget. Removes 5 tables, ~12 indexes,
|
||||
~30 sqlc queries, the orphan subsystem, both repeated subqueries, and
|
||||
the `AUTOMATIC COVERING INDEX` SQLite builds on every library load.
|
||||
Estimated −1,500 to −2,500 lines across `backend/library`,
|
||||
`backend/database/sql/*` and `sqlcgen`.
|
||||
|
||||
**Cost:** one real migration of user data (not an `ADD COLUMN`), and it
|
||||
touches autotag, tagwriter, playlist matching and the explore xref.
|
||||
This is the item to sequence carefully; everything else is independent
|
||||
of it.
|
||||
|
||||
### R3 — One projection, one row type, one mapper
|
||||
|
||||
`track_metadata` (the view) already *is* the canonical "one row per
|
||||
file" definition, and **only the raw-SQL search paths use it**
|
||||
(`search.go`, `lyrics_search.go`). Every sqlc query re-implements it —
|
||||
9 copies, which have already drifted: the view prefers
|
||||
`rg.original_year` for `year`, `GetAllTracksWithFullMetadata` uses
|
||||
`r.year`. The same library shows a different year depending on which
|
||||
screen you are on.
|
||||
|
||||
**Verified, not assumed:** sqlc generates cleanly against the view —
|
||||
`SELECT * FROM track_metadata WHERE …` yields one `TrackMetadatum`
|
||||
struct with correct types (run during this audit).
|
||||
|
||||
And the 14 `X`/`XByLibrary` twins collapse into one query each:
|
||||
|
||||
```sql
|
||||
WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
|
||||
OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))
|
||||
```
|
||||
|
||||
**Measured cost of the collapse: none.** Scoped-with-OR 23 ms, scoped
|
||||
direct 21 ms, unscoped 145 ms over the full 26k rows.
|
||||
|
||||
**Effect:** −14 queries, −8 bindings, −8 frontend branches, 9 row
|
||||
structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly −2,000
|
||||
generated lines and −300 hand-written ones, and the year inconsistency
|
||||
cannot exist.
|
||||
|
||||
### R4 — Put `explore_index` on a diet (~200 MB, no feature loss)
|
||||
|
||||
| change | saved |
|
||||
|---|---|
|
||||
| `mbid`, `artist_mbid`, `caa_release_mbid` as 16-byte blobs | ~110 MB in the table |
|
||||
| …and the same keys in `UNIQUE(mbid)` (99 MB) and `idx_explore_index_artist_mbid` (131 MB) | ~70–100 MB |
|
||||
| `entity_type` → INTEGER | 18 MB + index |
|
||||
| drop `aliases`, `sort_name`, `disambiguation` (0 rows); reconsider `country`/`artist_type` (69/72 rows) | small bytes, real clarity — and one fewer empty FTS column |
|
||||
| make the two `LOWER()` indexes' partial predicate *mean* something (`popularity >= championPopThreshold OR in_library`), or retire the tier onto the champion FTS | up to 101 MB |
|
||||
|
||||
Better still for `artist_mbid`: it is a foreign key spelled as text.
|
||||
An integer reference to the artist row is 8 bytes instead of 36 and
|
||||
makes the 131 MB index a fraction of its size.
|
||||
|
||||
**Also worth separating:** `in_library`, `local_*_id`, `is_similar` and
|
||||
`discog_fetched` are *personalization* stored inside the *shipped
|
||||
catalog* table, which is why the artifact import has to merge by
|
||||
explicit column list and why `artist_enrichment` had to become its own
|
||||
table for exactly this reason. Measured: `in_library` and
|
||||
`local_*_id IS NOT NULL` agree on **every one of 2,052,200 rows** —
|
||||
they are the same fact stored twice. A `library_xref(mbid, kind,
|
||||
local_id)` side table would make the catalog table purely the artifact
|
||||
and delete the merge-by-column-list rule.
|
||||
|
||||
### R5 — Ask the network less, without a bigger install
|
||||
|
||||
Present state (from `musicbrainz.go:17-27`): search 24 h, **entity 7
|
||||
days**, releases 90 days. MusicBrainz entity data changes on the order
|
||||
of *never* for the fields we read, and 251 of 2,930 cache rows are
|
||||
already expired on this install — so a fully-populated artist page
|
||||
re-fetches itself weekly, forever.
|
||||
|
||||
- **Raise `cacheTTLEntity` to a year** (or drop expiry and revalidate
|
||||
in the background). Cost: bytes already stored. Benefit: the
|
||||
steady-state network cost of browsing your own library goes to
|
||||
roughly zero.
|
||||
- **Ship a per-release-group `total_tracks` in the artifact.** 010
|
||||
correctly rejects shipping *tracklists* (the per-artist track budget
|
||||
would truncate them, and "Play 7 of 9" for a twelve-track album is a
|
||||
confident lie). But the **denominator** is one small integer per
|
||||
release group — 400,677 rows, ~2 bytes — and it is exactly what
|
||||
`albumLibraryStatus`/`ownership()` needs to say complete /
|
||||
incomplete / unknown for a catalog album with no local tags. Tiny,
|
||||
honest, and it does not depend on coverage.
|
||||
- **Keep 010's per-user backfill** for the tracklists themselves; this
|
||||
does not replace it, it shrinks what it has to cover.
|
||||
- `http_cache` has no size bound and no vacuum beyond expiry. Give it a
|
||||
ceiling.
|
||||
|
||||
### R6 — The 5.3 GB on disk that no feature needs
|
||||
|
||||
- **4,125 MB of artist candidate images** that nothing reads (the
|
||||
documented bug — but the janitors have not run on this install;
|
||||
verify they run at all).
|
||||
- Artist images exist for **5,770 artists** in a **1,301-artist**
|
||||
library. Fetching art for artists you do not own is the same
|
||||
"prefetch everything" instinct as the discography backfill 011
|
||||
corrected.
|
||||
- **1,134 MB of cover originals** versus 110 MB for all three rendered
|
||||
tiers. Nothing renders the original; and it is re-derivable from the
|
||||
audio file itself, which is on disk by definition. Keep `_lg` as the
|
||||
largest and drop originals — that is 1.1 GB with no visible change.
|
||||
- `yj.db.bak` (394 MB) and `yj.db.bak.20260309` (58 MB) accumulate with
|
||||
nothing to clean them.
|
||||
|
||||
This is the largest single win available and it does not touch the
|
||||
schema.
|
||||
|
||||
### R7 — Redundant indexes and dead columns
|
||||
|
||||
Five indexes are prefixes of an existing UNIQUE/PK and can be dropped
|
||||
outright (they cost write time on every insert):
|
||||
|
||||
`idx_recording_genres_recording_id` ⊂ `UNIQUE(recording_id, genre_id)` ·
|
||||
`idx_similar_artist_map_source` ⊂ `PK(source, similar)` ·
|
||||
`idx_artist_credit_artist_artist_id` ⊂ `UNIQUE(artist_id, credit_id)` ·
|
||||
`idx_artist_metadata_mbid` ⊂ `PK(mbid, source)` ·
|
||||
`idx_artist_images_mbid` ⊂ `UNIQUE(artist_mbid, source, source_url)`.
|
||||
|
||||
Dead data:
|
||||
|
||||
- **`recordings.genre`** — populated on 25,619 rows at every scan and
|
||||
**read by nothing**. Every genre read goes through
|
||||
`recording_genres` + `genres`. Write-only column.
|
||||
- **`release_groups.total_tracks` / `total_discs`** — 0 rows populated;
|
||||
the feature that needed them put the number on
|
||||
`release_group_recordings` instead.
|
||||
- **`release_to_rg`** — 0 rows, no writer in any schema file.
|
||||
- `libraries.sql` carries a doc comment about `download_requests`,
|
||||
pasted from another file. Small, but it is the kind of drift the
|
||||
two-file schema rule exists to catch.
|
||||
|
||||
### R8 — One genuine N+1
|
||||
|
||||
`mixCandidates` (`explore/mix.go:181`) issues
|
||||
`GetGenreNamesByFilePath` **per candidate path**, inside a loop over
|
||||
similar artists, inside a loop over seed artists. Twenty seeds × twenty
|
||||
similar × thirty paths is 12,000 single-row queries for one mix. It is
|
||||
one query with an `IN` clause, or one query for the whole weighted set.
|
||||
(`mixSeedProfile` above it is the same shape, bounded by seed size.)
|
||||
|
||||
Nothing else in the tree matches this pattern — a scan of every query
|
||||
issued inside a loop turned up 72 candidates and this is the only real
|
||||
one.
|
||||
|
||||
### R9 — The IPC surface has internals in it
|
||||
|
||||
Bound and reachable from the frontend today: `AcquirePipelineLock`,
|
||||
`ReleasePipelineLock`, `SetJobRegistry`, `SetScanHooks`,
|
||||
`SetRescanHooks`, `SetRemovalHooks`, `MusicBrainz`, `CAALimiter`,
|
||||
`PopulateLocalCrossReferences`. v3's generator binds every exported
|
||||
method; these want to be unexported or moved off the service type.
|
||||
Free lines, and one less way to wedge the app from a console.
|
||||
|
||||
### R10 — The test DB is not the shape production runs
|
||||
|
||||
`NewTestDB` shares one in-memory connection and leaves `readDB` nil, so
|
||||
`reader()` returns the writer. That is why the read-pool write bug
|
||||
(documented in `CLAUDE.md`) reached a user, and why
|
||||
`TestNoWritesOnTheReadPool` had to be a tree-walk instead of a test.
|
||||
Giving the test DB two handles over one shared in-memory file would let
|
||||
that be an ordinary test.
|
||||
|
||||
---
|
||||
|
||||
## What I recommend leaving alone
|
||||
|
||||
- **The download subsystem** (requests / downloads / items). Three
|
||||
tables, clean lifetimes, well argued in the schema comments. The
|
||||
`download_wants` table in this install is the pre-rename name; the
|
||||
rename migration will clear it on next launch.
|
||||
- **The champion FTS.** 96k rows, 2 MB, a real latency tier.
|
||||
- **The dual write/read handle**, WAL, and the persist-writer queues.
|
||||
These are recent, measured, and correct.
|
||||
- **File paths as the frontend's identity for a track.** Integer ids
|
||||
would be cheaper over IPC, but `CLAUDE.md`'s argument (an index goes
|
||||
stale on re-sort/refilter, a path does not) is right, and the cost is
|
||||
bounded.
|
||||
- **Storing lyrics locally** (27 MB + 18 MB index for 24k tracks). That
|
||||
is the API-avoidance trade working exactly as intended.
|
||||
|
||||
---
|
||||
|
||||
## What landed (2026-08-15 / 16)
|
||||
|
||||
### The third pass: the album page, which is where the report came from
|
||||
|
||||
The audit started from a user report — a fully-tagged library saying
|
||||
"not in your library", on hover rather than on click — and R1 fixed the
|
||||
half of that which lives in SQL. The other half was the page: ownership
|
||||
was four claims OR'd into a tick, and the context menu asked the backend
|
||||
per row, as the menu opened.
|
||||
|
||||
`explore-album-details` now resolves the displayed tracklist's file
|
||||
paths **once**, from `updated()`, into one `filePaths` map that the
|
||||
badge, the Play count, the dimmed rows and every menu item read. The
|
||||
synthesised local tracks carry their own `FilePath`, so a library album
|
||||
costs no lookup at all; a catalog tracklist costs one batched
|
||||
`GetFilePathsByRecordingMBIDs`. `catalogScope()` no longer returns
|
||||
`'library'` here — that was the second complaint in the same report, and
|
||||
the artist page keeps it because a library-only *artist* really is
|
||||
missing sections.
|
||||
|
||||
Two bugs fell out of doing it this way, and neither is the one that was
|
||||
reported:
|
||||
|
||||
- The render loop. Guarding the lookup on `filePaths` (answered) rather
|
||||
than on `askedFor` (asked) re-requests every *unowned* MBID forever,
|
||||
because an unowned MBID never lands in the map.
|
||||
- "No release data available" over a tracklist held in memory.
|
||||
`loadLocalTracks` rebuilt the version list only when catalog releases
|
||||
existed, but the "Your Library" entry is synthesised *from* the local
|
||||
tracks — so the no-releases case was the one case it skipped. Nothing
|
||||
caught it because the old ownership check answered from the local
|
||||
album id and never needed the tracklist to exist.
|
||||
|
||||
### The second pass: R5–R10
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| the two exact-match indexes | 101 MB | **3 MB** (predicate narrowed to the champion set; plan unchanged, measured) |
|
||||
| cover art on disk | original + 3 tiers | **3 tiers** — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it |
|
||||
| browsed artist art | 90-day expiry, no ceiling | expiry **plus a 256 MB budget**, oldest evicted first; owned artists never in it |
|
||||
| MusicBrainz entity TTL | 7 days | **1 year**, with a 128 MB ceiling on the response cache |
|
||||
| redundant indexes | 5 | **0** (3 dropped here, 2 went with their tables) |
|
||||
| internal methods on the IPC surface | 24 | **0** (`//wails:ignore`; 272 → 248 bound methods) |
|
||||
| test DB | one handle, `readDB` nil | **two handles**, the shape production runs |
|
||||
|
||||
The catalog line is R4, finished the day after: MBIDs stored as 16 raw
|
||||
bytes and entity types as codes, measured by converting the real
|
||||
2,052,200-row catalog through the shipped schema. It needed no artifact
|
||||
rebuild — the importer asks the artifact which encoding it carries and
|
||||
converts the older text form on the way in. Plan 014 has the detail.
|
||||
|
||||
Two of those repaid immediately. Giving the test database its own
|
||||
read pool **caught three tests writing through it** on the first run —
|
||||
the exact bug class that reached a user as "attempt to write a readonly
|
||||
database" and that `TestNoWritesOnTheReadPool` had to walk the source
|
||||
tree to find. And the artist-image sweep's own test turned out to seed
|
||||
an `artists` row with no file and call it owned: the phantom this whole
|
||||
audit is about, sitting in the fixture of the test that guards it.
|
||||
|
||||
**One finding in this audit was wrong.** `aliases`, `sort_name`,
|
||||
`disambiguation`, `country` and `artist_type` are not dead columns. They
|
||||
are empty on that install because the artist-enrichment pass had barely
|
||||
run (which is finding 011's subject), but `indexOneArtist` writes all
|
||||
five, and `aliases` is an FTS column that makes an artist findable by
|
||||
alias. They stay.
|
||||
|
||||
### The first pass: R2, carrying R1 and R3
|
||||
|
||||
R2 shipped with R1 and R3 inside it, because the collapse made them
|
||||
free rather than separate work. No migration: fresh installs only, by
|
||||
the user's decision, so `sql/migrations/` went with it.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| local tables | 9 | 5 (`audio_files`, `albums`, `artists`, `genres`, `file_genres`) |
|
||||
| sqlc queries | 235 | 185 |
|
||||
| generated Go | 7,850 | 6,023 |
|
||||
| bound IPC methods | 272 | 264 |
|
||||
| copies of the track projection | 9 + the view | the view |
|
||||
| `X`/`XByLibrary` query twins | 14 | 0 |
|
||||
| migration files + runner | 7 + ~120 lines | 0 |
|
||||
| **net** | | **−5,070 lines** across 122 files |
|
||||
|
||||
Gone: `recordings`, `release_group_recordings`, `artist_credit`,
|
||||
`artist_credit_artist`, `pruneOrphanedMetadata`'s four sweeps,
|
||||
`RemoveLibrary`'s eight, `mapTrackRow`'s 22 positional arguments, and
|
||||
340 lines of `tagwriter/dbsync.go` that existed to relink and then
|
||||
un-orphan those tables.
|
||||
|
||||
Ownership is now a file in every one of the places that used to ask a
|
||||
metadata table: `CheckMBIDs`, `collectLibraryEntities`,
|
||||
`pruneStaleLocalCrossReferences` and `GetFilePathsByRecordingMBIDs`.
|
||||
|
||||
Three things found on the way, each written down where it can be hit
|
||||
again (`CLAUDE.md`, `references/schema-change.md`):
|
||||
|
||||
- **sqlc's parameter rewriter is byte-offset based**, so one em dash in
|
||||
a *query* comment corrupts generation into `SELECid`.
|
||||
- **`sqlc.slice` and `sqlc.arg` do not compose** — slice expansion
|
||||
renumbers, so `GetFilePathsByAlbums([1,2], 0)` read album id 2 as the
|
||||
library id. Caught by a test, not by a type.
|
||||
- **`release_to_rg` looked dead and was not**: 0 rows on any ordinary
|
||||
install, because only a local `indexbuild` fills it, and the daily
|
||||
incremental refresh reads it. Restored.
|
||||
|
||||
Verified: `make lint` (3 configurations), `go test ./...` plus the
|
||||
`indexbuild` and `dev` tag passes, `tsc --noEmit`, `make ui-test`
|
||||
(768), and a new end-to-end test that scans the real fixture library
|
||||
and asserts no row outlives its file
|
||||
(`TestScan_FixtureLibraryLeavesNothingBehind`).
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
**Revised 2026-08-15, after the compatibility constraint was lifted:**
|
||||
breaking changes are acceptable and the schema may be squashed. That
|
||||
inverts the order — R2 was last only because of the migration, and it
|
||||
*subsumes* R1 (ownership becomes a foreign key) and reshapes R3 (the
|
||||
projection is defined over the new tables). Doing R1 and R3 against the
|
||||
old shape first would be work thrown away.
|
||||
|
||||
1. **R2** — the schema collapse, with the rebuild below. It carries R1
|
||||
and R3 with it.
|
||||
2. **R6** — reclaim the 5.3 GB on disk; confirm the janitors run.
|
||||
3. **R7 / R9 / R8 / R10** — the small correctness and hygiene items.
|
||||
4. **R4** — the `explore_index` diet. Artifact rebuild + format bump.
|
||||
5. **R5** — cache TTLs (trivial) and the shipped denominator (rides
|
||||
along with R4's artifact change).
|
||||
|
||||
### "Break everything" has a floor, and it is not the schema
|
||||
|
||||
Reshaping tables freely is fine. **Dropping the database is not**, and
|
||||
the numbers say so — a wipe-and-rescan would destroy:
|
||||
|
||||
| | count | why a rescan does not restore it |
|
||||
|---|---|---|
|
||||
| files marked `user_confirmed` | **25,014** | the user's autotag review decisions |
|
||||
| reviewed tagging folders (`confirmed`/`skipped`) | **2,109** | ditto, plus every `skipped` becomes pending again |
|
||||
| rows in `recordings.lyrics` | **24,294** | an unknown share came from **LRCLIB**, not from tags — re-fetching them is precisely the API traffic we are trying to avoid |
|
||||
| playlists / playlist tracks | 22 / 1,917 | `Authored`; nothing else has them |
|
||||
|
||||
So the change ships as a **one-shot in-place rebuild**: create the new
|
||||
tables, `INSERT … SELECT` across, drop the old ones, in a single
|
||||
transaction at open. Seconds on 26k rows, ~40 lines of SQL, no
|
||||
migration *chain* and no rollback path — which is the freedom that was
|
||||
actually being asked for. `sql/migrations/` gets squashed into
|
||||
`sql/schemas/` at the same time (`NOTES.md` already blesses this
|
||||
pre-1.0).
|
||||
|
||||
### Two tables are classified as one Kind and hold another
|
||||
|
||||
`backend/datamap` already encodes what is safe to lose (`Owned` and
|
||||
`Derived` rebuild from the files; `Cache` is expensive; `Authored` is
|
||||
irreplaceable). The audit found two places where the *column* disagrees
|
||||
with the *table's* entry, which is exactly why a wipe looked cheaper
|
||||
than it is:
|
||||
|
||||
- **`audio_files.tag_status`** — the table is `Owned` (a projection of
|
||||
the files), but `user_confirmed` / `user_skipped_permanent` are
|
||||
**`Authored`**: a decision the user made that exists nowhere else.
|
||||
- **`recordings.lyrics`** — the table is `Owned`, but lyrics fetched by
|
||||
the LRCLIB backfill are **`Cache`**, and nothing records which of the
|
||||
24,294 rows came from a tag and which from the network.
|
||||
|
||||
The new schema fixes both by construction: lyrics move to their own
|
||||
MBID-keyed table with a `source` column (so they survive any rebuild of
|
||||
the owned tables, and the provenance question becomes answerable), and
|
||||
`tag_status`' authored values are carried across explicitly rather than
|
||||
recomputed.
|
||||
|
||||
**Expected outcome if all of it lands:** database ~1.0 GB → ~0.75 GB,
|
||||
data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go
|
||||
7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters —
|
||||
one definition of "this is mine" that a file either satisfies or does
|
||||
not.
|
||||
|
||||
## The open questions, answered
|
||||
|
||||
1. **R2's migration** — the user's call, and it was "just assume this
|
||||
new version will only be installed by a new user". So there is no
|
||||
in-place rebuild and no chain: `sql/schemas/` is the whole
|
||||
description. An existing `YJ_HOME` does not open (its `audio_files`
|
||||
has `recording_id` and none of the tag columns, and
|
||||
`CREATE TABLE IF NOT EXISTS` cannot add them) — delete and rescan,
|
||||
and rebuild any seed with `make sandbox-seed`.
|
||||
2. **R4's artifact format** — no break was needed. The importer asks
|
||||
the artifact what it carries rather than trusting a version, so the
|
||||
published text-form artifact still imports. Plan 014 has it.
|
||||
3. **Yes, the janitors run.** `Runner.Start` calls `RunDue` immediately
|
||||
and `lastRun` is in-memory, so every launch runs everything due.
|
||||
The 4.1 GB survived because `OrphanedArtistImagesJob` joined a bare
|
||||
MBID onto a *sharded* directory — deleting the rows and leaving the
|
||||
files, which is worse than not running — and because
|
||||
`StrayArtistImageFilesJob` did not exist. Both are fixed; it was a
|
||||
bug report, not a cleanup.
|
||||
|
||||
## Measured on the finished refactor
|
||||
|
||||
| | expected | actual |
|
||||
|---|---|---|
|
||||
| sqlc queries | ~180 | **185** |
|
||||
| generated Go | ~5,000 | **6,024** |
|
||||
| bound methods | ~255 | **248** |
|
||||
| `explore_index` + indexes | — | **780 MB → 405 MB** |
|
||||
|
||||
## The one recommendation not taken
|
||||
|
||||
R4's "better still" for `artist_mbid`: an integer reference to the
|
||||
artist row (8 bytes) rather than the 16 raw bytes it now stores. It is
|
||||
a further ~30 MB on `idx_explore_index_artist_mbid`, and the reason to
|
||||
stop short is that the *artifact* carries MBIDs and not local ids, so
|
||||
the import would have to resolve every row against a table it is in the
|
||||
middle of filling. Worth its own argument, not a footnote to this one.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 014 — The catalog's compact encoding, and the denominator it owed
|
||||
|
||||
**Status:** **complete** (2026-08-16). The encoding landed first; the
|
||||
per-release-group `total_tracks` denominator landed with the album page
|
||||
that spends it.
|
||||
**Branch:** none
|
||||
**Created:** 2026-08-16
|
||||
**Depends on:** nothing
|
||||
**Related:** 013 (the database audit, which measured all of this), 010
|
||||
(owned albums offline), 001 (ship core index)
|
||||
|
||||
---
|
||||
|
||||
## The encoding
|
||||
|
||||
Measured on the real 2,052,200-row catalog, converting it through the
|
||||
shipped schema (not a projection):
|
||||
|
||||
| object | before | after |
|
||||
|---|---|---|
|
||||
| `explore_index` | 383 MB | **242 MB** |
|
||||
| `idx_explore_index_artist_mbid` | 131 MB | **65 MB** |
|
||||
| `UNIQUE(mbid)` | 99 MB | **54 MB** |
|
||||
| `idx_explore_index_entity_pop` | 47 MB | **28 MB** |
|
||||
| `idx_explore_caa_release` | 17 MB | **11 MB** |
|
||||
| the two `LOWER()` indexes | 101 MB | **3 MB** (013) |
|
||||
| **total** | **780 MB** | **405 MB** |
|
||||
|
||||
Every row converted with the `CHECK` constraints live, which is also a
|
||||
result: no MBID in a real 2 M-row catalog is malformed.
|
||||
|
||||
**No format bump, and no rebuilt artifact needed.** The importer asks
|
||||
the artifact what encoding it carries (`typeof(mbid)`) and converts on
|
||||
the way in if it is the old text form, so the artifact already
|
||||
published keeps working and the exporter switches whenever CI next
|
||||
runs. That is strictly better than the version negotiation this plan
|
||||
originally proposed.
|
||||
|
||||
The silent-failure risk the plan was written around was handled by
|
||||
making the failure loud instead of by avoiding the change: a `CHECK` on
|
||||
the column turns a stringly write into an error at the insert, the
|
||||
22-column projection became one constant and one scanner instead of
|
||||
four copies, and `TestStoredEncodingRoundTrips` sweeps every read path
|
||||
in the package. It found one real bug on its first run — the artifact
|
||||
probe was asking the read pool, where the attached artifact does not
|
||||
exist.
|
||||
|
||||
## The denominator
|
||||
|
||||
`total_tracks` on `explore_index`, ~2 bytes across 400,677 release
|
||||
groups. It makes "do I have all of this" answerable offline for an
|
||||
album whose **files declared no total**, which is a great deal of any
|
||||
untagged library and the one thing `GetAlbumCompleteness` cannot answer
|
||||
from tags. 010 rightly rejected shipping whole tracklists — the
|
||||
per-artist track budget truncates them, and a truncated tracklist is a
|
||||
confident lie about which tracks exist. A denominator has no such
|
||||
problem, and the album page spends it as one: the numerator stays
|
||||
local (distinct track numbers on disk), only the denominator is
|
||||
borrowed, and only where the tags have none.
|
||||
|
||||
Four things about it are load-bearing.
|
||||
|
||||
**It is counted before the popularity filter.** `cmd/indexbuild` counts
|
||||
the canonical dump's rows per kept release, which is that release's
|
||||
track count because the dump carries one row per recording per
|
||||
canonical release. Counting the *kept* recordings instead would say
|
||||
"9" about a twelve-track album whose other three nobody has played —
|
||||
worse than saying nothing, and the same class of lie as the truncated
|
||||
tracklist. `TestDumpImportEndToEnd` has an unplayed track on a fixture
|
||||
album for exactly this: three tracks in the total, two indexed as
|
||||
recordings.
|
||||
|
||||
**Zero means "the catalog does not say"**, which is the same third
|
||||
state the local answer already has. An album neither side can total
|
||||
wears no ring rather than a wrong one.
|
||||
|
||||
**Adding a column to the importer's SELECT is how you break every
|
||||
artifact already published.** `artifactHasTotals()` asks the attached
|
||||
artifact whether the column exists, the same way and on the same handle
|
||||
as `artifactStoresText()`, and selects a literal `0` when it does not.
|
||||
Verified by forcing the probe true: the older shape then fails with
|
||||
`no such column: total_tracks`, which is what a shipped build would
|
||||
have done to a file nobody can re-cut retroactively.
|
||||
|
||||
**A test seeder that binds the upsert's parameters by hand is not
|
||||
"breaking where the app breaks".** Three of them did, on the argument
|
||||
that a schema change should fail the tests in the same place — and what
|
||||
it actually produced was `missing argument with index 25`, three files
|
||||
at a time, for a column none of them cares about. They go through
|
||||
`upsertBatch` now, which is the one writer, and keep the property they
|
||||
wanted: a field written to the wrong column still fails there.
|
||||
|
||||
## Done when
|
||||
|
||||
- [x] `GetAlbumCompleteness`'s gap is answerable for a catalog album the
|
||||
library has no tags for, with no network call.
|
||||
- [x] The artifact grows by less than a megabyte (~800 kB at 400,677
|
||||
release groups).
|
||||
- [x] An artifact published before the column still imports.
|
||||
@@ -0,0 +1,385 @@
|
||||
# 015 — Android release pipeline
|
||||
|
||||
> **Completed.** The pipeline ships a signed APK from CI on every `v*` tag; `docs/android-release.md` is its operating document.
|
||||
|
||||
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||
generic package registry so Obtainium can poll a plain URL.
|
||||
|
||||
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
|
||||
`android:` job has been through the failure modes already. Most of what
|
||||
follows is a transcription of that job onto this repo's conventions;
|
||||
where it differs, the difference is argued.
|
||||
|
||||
## What this is not
|
||||
|
||||
**This ships a pipeline, not a usable Android music player.** The
|
||||
success criterion is a signed, installable APK that launches — not an
|
||||
app anyone would want. Explicitly out of scope, and each is real:
|
||||
|
||||
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
|
||||
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
|
||||
linux` file is in the build and MPRIS will look for a session bus that
|
||||
does not exist. It compiles; it will error at runtime.
|
||||
- `backend/system` resolves XDG paths. Android has no XDG.
|
||||
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
|
||||
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
|
||||
measured minimum, a transport bar. None of that is a phone layout.
|
||||
- The library scanner walks a filesystem Android does not grant.
|
||||
|
||||
Those are the *next* plan, if there is one. Conflating them with this one
|
||||
is how a build pipeline takes six weeks.
|
||||
|
||||
## Phase 0 — the gate [DONE 2026-08-16]
|
||||
|
||||
**Passed, further than asked.** No source changes were needed; a full
|
||||
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
|
||||
the environment and four non-obvious findings are in
|
||||
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
|
||||
library in the release APK's phone ABI, fixed here.
|
||||
|
||||
**It also installs and launches on an emulator, and then exits.** One
|
||||
line stops it: `backend/system/buildUserDirPath` switches on
|
||||
`runtime.GOOS` and Android takes the `default:` branch returning
|
||||
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
|
||||
after the JNI bridge comes up. That is the *first* thing that stops it,
|
||||
not the only one — see the "not this" section above, all of which is
|
||||
still true and still out of scope.
|
||||
|
||||
The emulator tier that found it is now part of the harness:
|
||||
`scripts/android-emulator.sh`, the `make android-*` targets, and
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
|
||||
because the failure is invisible in all three places anyone would look
|
||||
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
|
||||
the app fast enough that `pidof` always answers — so the tier's
|
||||
assertion is "same pid after N seconds", not "it started".
|
||||
|
||||
Original phase 0 text follows, kept because its reasoning is what the
|
||||
later phases rest on.
|
||||
|
||||
|
||||
Everything downstream is wasted if the c-shared link fails. Establish it
|
||||
by hand, locally, before writing a line of YAML.
|
||||
|
||||
Already established, by probe rather than by assumption:
|
||||
|
||||
```
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
|
||||
```
|
||||
|
||||
compiles the entire tree. Exactly two packages fail, and both fail only
|
||||
because their Android implementation is cgo:
|
||||
|
||||
- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe**
|
||||
C++ backend. Oto supports Android natively; there is no Java audio
|
||||
glue to write.
|
||||
- `wails/v3/pkg/application` — `mobile_features_android.go` needs the
|
||||
JNI bridge.
|
||||
|
||||
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
|
||||
every `backend/` package are clean. **No source changes are known to be
|
||||
required**, which is the single most surprising finding here and the
|
||||
reason this plan is worth doing at all.
|
||||
|
||||
What Phase 0 must actually verify:
|
||||
|
||||
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
|
||||
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
|
||||
this build.
|
||||
2. Generate the scaffolding (Phase 1) and run
|
||||
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
|
||||
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
|
||||
exists and is an ARM64 shared object.
|
||||
4. Repeat for `amd64` (the emulator ABI).
|
||||
|
||||
**If the link fails, stop and re-plan.** The likely culprits, in order:
|
||||
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
|
||||
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
|
||||
combined with the generated `main_android.gen.go` overlay.
|
||||
|
||||
Deliverable: a note in `.planning/NOTES.md` recording the exact command
|
||||
and the NDK version that produced a `.so`, or the reason it cannot.
|
||||
|
||||
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
|
||||
|
||||
Done as a side-effect of phase 0, which could not run without it. One
|
||||
correction to the text below: **step 1 is wrong.** `update
|
||||
build-assets` does not generate the android tree (NOTES.md explains);
|
||||
it was generated with `generate build-assets` into a scratch dir and
|
||||
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
|
||||
were done as written.
|
||||
|
||||
|
||||
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
|
||||
entry was dropped from `Taskfile.yml` during plan 009. That was correct
|
||||
when nothing could target Android and is what has to be undone.
|
||||
|
||||
1. `wails3 task common:update:build-assets` — beta.8 embeds
|
||||
`internal/commands/build_assets/android/`, so this generates the tree.
|
||||
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
|
||||
to a comment so the asymmetry is explained rather than looking like an
|
||||
oversight.
|
||||
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
|
||||
`includes:`.
|
||||
4. **Gitignore the tree's own output**, or the repo grows a few hundred
|
||||
Gradle intermediates. ljos has exactly this problem — its
|
||||
`app/build/android/app/build/**` is committed. Ignore:
|
||||
- `build/android/app/build/`
|
||||
- `build/android/app/src/main/jniLibs/`
|
||||
- `build/android/overlay.json` and `build/android/gen/`
|
||||
5. `make build-prod` and `make test` still pass — the new include must
|
||||
not perturb the desktop path.
|
||||
|
||||
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
|
||||
section already says `build/`'s platform metadata is regenerated from
|
||||
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
|
||||
by hand. Extend that paragraph to name `build/android/app/build.gradle`
|
||||
specifically, because the loss is silent and the symptom (a debug-signed
|
||||
APK) appears months later as a failed update.
|
||||
|
||||
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
|
||||
|
||||
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
|
||||
beta.3: the release signing config already exists** and reads the four
|
||||
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
|
||||
phase was identity and versioning only. Verified end to end:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| package | `app.yellowjacket` (was `com.wails.app`) |
|
||||
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
|
||||
| label | `YellowJacket` |
|
||||
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
|
||||
| ABIs | arm64-v8a + x86_64, both production-stripped |
|
||||
|
||||
Installs and launches under the new identity. Still exits on the known
|
||||
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
|
||||
|
||||
Two things this phase learned that the text below did not know:
|
||||
|
||||
- **The identity has to be declared twice.** `applicationId` in
|
||||
`app/build.gradle` is what Gradle installs; `APP_ID` in
|
||||
`build/android/Taskfile.yml` is what every adb-driven task targets.
|
||||
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
|
||||
nothing in beta.8, verified with `--dry`. Both are set, each
|
||||
commented pointing at the other.
|
||||
- **The launcher activity is not under the applicationId.** It stays
|
||||
`com.wails.app.MainActivity` (the scaffold's Java package), so
|
||||
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
|
||||
the wrong package and fails. `scripts/android-emulator.sh` carries the
|
||||
fully-qualified name and a comment saying why.
|
||||
|
||||
The `keytool` PKCS12 note below was confirmed verbatim: given a
|
||||
`-keypass` differing from `-storepass` it prints "Different store and
|
||||
key passwords not supported for PKCS12 KeyStores. Ignoring
|
||||
user-specified -keypass value."
|
||||
|
||||
Original phase 2 text follows.
|
||||
|
||||
|
||||
Edit `build/android/app/build.gradle`, following ljos's, whose comments
|
||||
are worth reading before writing this:
|
||||
|
||||
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
|
||||
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
|
||||
Java package, not the app identity).
|
||||
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
|
||||
— **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
|
||||
parentheses to `versionCode` first, so the cast reads as
|
||||
`versionCode("1") as Integer`, which sets a String and then casts the
|
||||
setter's null return; Gradle fails the whole project with "Value is
|
||||
null" at that line.
|
||||
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
|
||||
- `abiFilters 'arm64-v8a', 'x86_64'`.
|
||||
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
|
||||
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
|
||||
back to the debug keystore only when no keystore is supplied.
|
||||
|
||||
**Android orders releases by an integer and refuses anything not greater
|
||||
than what is installed.** A hardcoded `versionCode 1` means the first
|
||||
install is the last: every later build is rejected as a downgrade and the
|
||||
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
|
||||
and patch stay under 100.
|
||||
|
||||
**Signing is not optional past the first install.** Android refuses to
|
||||
update an app whose signing key changed, and the debug keystore differs
|
||||
between every machine and every runner — so an unsigned CI build is a
|
||||
decision to reinstall by hand forever. The job must **refuse to build**
|
||||
without the keystore rather than quietly produce an APK that can never be
|
||||
updated.
|
||||
|
||||
There is **one password and two required secrets**. keytool has defaulted
|
||||
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
|
||||
cannot hold a separate key password — given `-keypass` it warns and
|
||||
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
|
||||
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
|
||||
cannot exist is how someone sets a wrong value and debugs Gradle at
|
||||
midnight.
|
||||
|
||||
Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task
|
||||
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
|
||||
documented target that does not exist, so document it only once it does.
|
||||
|
||||
## Phase 3 — the workflow [DONE 2026-08-16]
|
||||
|
||||
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
|
||||
the operating document its error messages point at (phase 4's
|
||||
documentation half; the secrets themselves still have to be created by
|
||||
hand — see the table there).
|
||||
|
||||
Three departures from the text below, all argued in the file:
|
||||
|
||||
- **No `continue-on-error`.** The plan inherited it from ljos, where
|
||||
the Android job shares a pipeline with a server deploy that must
|
||||
never go red over a phone build. Here it is standalone and can
|
||||
neither delay nor redden anything, so a release step that fails
|
||||
silently is strictly worse than one that fails visibly.
|
||||
- **No cached `wails3` binary.** The plan budgeted for ljos's
|
||||
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
|
||||
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
|
||||
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
|
||||
WebKit *dev* headers are still installed, because `go tool wails3`
|
||||
links them.
|
||||
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
|
||||
~700 MB a run.
|
||||
|
||||
Four publish-gates were added and each was checked against a real APK:
|
||||
both ABIs present, `versionCode` equal to the one derived from the tag,
|
||||
a non-empty artifact, and **not signed with the debug key** — verified
|
||||
by pointing the check at a deliberately debug-signed build, which it
|
||||
refused.
|
||||
|
||||
Rehearsed locally with the exact CI invocation
|
||||
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
|
||||
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
|
||||
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
|
||||
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
|
||||
|
||||
Original phase 3 text follows.
|
||||
|
||||
|
||||
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
|
||||
`ci.yml` runs on every branch push and is the workflow that gates; the
|
||||
runner is capacity 1, and a 45-minute Android build in it would put every
|
||||
push behind an SDK download.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
This is where the baseline genuinely diverges. ljos computes its version
|
||||
in CI (`scripts/next-version.sh`) and gates the Android job on
|
||||
`needs.release.outputs.version != ''`, with an `always()` whose absence
|
||||
would silently kill the manual path. **This repo has no release
|
||||
automation** — tags are pushed by hand and `homebrew-formula.yml` already
|
||||
keys on `v*`. So there is no `needs:`, no `always()`, and no status
|
||||
function to get wrong: the tag *is* the version, and a dispatch falls
|
||||
back to `git describe --tags --abbrev=0`.
|
||||
|
||||
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
|
||||
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
|
||||
needing node before any step has installed it):
|
||||
|
||||
```yaml
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||
```
|
||||
|
||||
The SDK path must be inside the runner's `valid_volumes` allowlist —
|
||||
a directory outside it makes the job **fail to start**, not silently skip
|
||||
the mount. `/cache/tool` is already allowed and already holds the Go
|
||||
toolchain `ci.yml` downloads.
|
||||
|
||||
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
|
||||
tag's other three workflows must not go red over a phone build, and a
|
||||
backstop because a wedged SDK download must not hold the only runner slot
|
||||
for hours.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
|
||||
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
|
||||
Android cross-build, which uses oboe.
|
||||
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
|
||||
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
|
||||
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
|
||||
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
|
||||
sdkmanager is itself idempotent but still spends minutes verifying,
|
||||
which is why the explicit directory guards are there. ~3 GB and most of
|
||||
the job's wall clock on the first run; a directory listing after.
|
||||
4. **wails3.** Cheaper here than in ljos, which pins
|
||||
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
|
||||
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
|
||||
already pinned by `go.mod` and there is nothing to drift. It still
|
||||
*links* GTK and WebKit, so cache the built binary in
|
||||
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
|
||||
ljos's finding that **caching the binary alone turned a slow job into
|
||||
a broken one**: `wails3` is dynamically linked, so the runtime
|
||||
packages are needed even on a cache hit. Here they are already in
|
||||
step 1.
|
||||
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
|
||||
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
|
||||
`frontend/dist`, so nothing Go-side typechecks without it.
|
||||
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
|
||||
unset, with the sentence explaining why (Phase 2). Decide the absolute
|
||||
path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}`
|
||||
evaluates to an empty string in Gitea's expression context**, which
|
||||
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
|
||||
fifty-five seconds into a Gradle run.
|
||||
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
|
||||
opens with `keytool -list` *before* Gradle does (Gradle only notices at
|
||||
`:app:validateSigningRelease`, a minute in, and reports it as a missing
|
||||
file), then `make android`.
|
||||
8. **Verify the signature.** `apksigner verify --print-certs`, and print
|
||||
the SHA-256 with the note that a change to it breaks every future
|
||||
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
|
||||
`head -1` exits early, the producer takes SIGPIPE, and the step fails
|
||||
with 141 *after* printing a perfectly good APK. Use `find … -print
|
||||
-quit` and a captured variable.
|
||||
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
|
||||
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
|
||||
credential pair `arch-package.yml` already uses, not ljos's
|
||||
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
|
||||
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
|
||||
Gitea refuses to overwrite, so delete `latest` first. The generic
|
||||
registry is readable **without credentials**, which is what lets
|
||||
Obtainium poll a plain URL with no token and no public source mirror.
|
||||
|
||||
## Phase 4 — secrets and documentation
|
||||
|
||||
Secrets to create on the repo (all under Settings → Actions → Secrets):
|
||||
|
||||
| Secret | Required | Note |
|
||||
|---|---|---|
|
||||
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
|
||||
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
|
||||
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
|
||||
|
||||
Write the keytool command, the Obtainium URL and the signing-key warning
|
||||
into a docs page — this is the part of ljos's setup that lives in
|
||||
`docs/clients.md` and is referenced from the workflow's error messages,
|
||||
so the messages have somewhere to point.
|
||||
|
||||
Then extend CLAUDE.md's CI section: it currently says "four workflows,
|
||||
three of them package and publish; only `ci.yml` gates". That becomes
|
||||
five, with the same sentence still true.
|
||||
|
||||
## Order and stopping points
|
||||
|
||||
Phase 0 gates everything. Phases 1–2 are one commit's worth of work and
|
||||
are verifiable locally without CI. Phase 3 is the only part that needs a
|
||||
runner, and its first run will be slow and will probably fail once on
|
||||
something in the SDK step — budget for that rather than treating it as a
|
||||
setback.
|
||||
|
||||
**Stop after Phase 0 if the c-shared link does not work.** Every later
|
||||
phase is scaffolding for a build that does not exist, and the honest
|
||||
outcome is a NOTES.md entry saying which package cannot cross-compile and
|
||||
what it would take.
|
||||
@@ -0,0 +1,339 @@
|
||||
# 015 — Multi-artist credits, navigable
|
||||
|
||||
> **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it.
|
||||
|
||||
## The problem
|
||||
|
||||
A track credited to more than one artist has exactly one navigable
|
||||
artist in this app, and the others are punctuation.
|
||||
|
||||
`audio_files` carries `artist_credit` (the credit as tagged, for
|
||||
display) and `artist_id` (one artist, for grouping and browsing).
|
||||
`primaryArtist()` (`backend/library/artistcredit.go:53`) resolves that
|
||||
one artist by *string-parsing* the credit: it strips a " feat. "
|
||||
clause, and deliberately does not split on `&`, `x`, `with` or `,`
|
||||
because those appear inside real artist names. So "Lana Del Rey ft.
|
||||
Sean Lennon" stores Lana Del Rey and discards Sean Lennon entirely,
|
||||
and "Alina Baraz & Galimatias" stores one artist whose name is the
|
||||
whole credit.
|
||||
|
||||
### What the measurement says
|
||||
|
||||
Measured 2026-08-16 against a real 26,069-file library (19,840 mp3,
|
||||
6,229 flac; 57 unreadable, m4a/ogg not examined), plus an 80+80
|
||||
MusicBrainz `inc=artist-credits` sample.
|
||||
|
||||
- **13%** of a random sample of the library's recordings have more
|
||||
than one credited artist in MusicBrainz (10 of 79 resolved).
|
||||
Extrapolates to ~3,250 of the 24,989 files carrying a recording
|
||||
MBID.
|
||||
- **0.86%** of files (224) carry any structured multi-artist signal in
|
||||
their own tags. mp3 carries **zero** files with multiple
|
||||
`MUSICBRAINZ_ARTISTID` values across 19,840 files; flac has 87.
|
||||
- **1,286** files say "feat." in `ARTIST`; **1,159 of them (90%)**
|
||||
have nothing structured behind it. A sample of 80 such files was
|
||||
multi-artist in MB **80 of 80 times**.
|
||||
|
||||
CLAUDE.md currently justifies plan 013's removal of `artist_credit` /
|
||||
`artist_credit_artist` with "3 credits of 2,823 listed more than one
|
||||
artist". That figure measured **our own writer**, not the library:
|
||||
`cachedLinkArtist` was called exactly once per credit
|
||||
(`e7748f1^:backend/library/library.go:1842`), so a collaboration could
|
||||
never have been recorded, and the three were resolution collisions on
|
||||
shared credit text. Dropping the join table was still correct — it only
|
||||
ever held one row, so it was pure join cost — but the stated evidence
|
||||
does not support "multi-artist is rare". Correcting that claim is part
|
||||
of this plan.
|
||||
|
||||
### Why the tags cannot answer it
|
||||
|
||||
Deriving the decomposition locally, with no network, works **79% of the
|
||||
time** (169 of 215 files with a multi-value `ARTISTS` tag: mp3 69/105,
|
||||
flac 100/110), and the failures are systematic rather than random:
|
||||
|
||||
```
|
||||
ARTIST = '2Pac feat. Snoop Dogg, Nate Dogg, Hussein Fatal & Yaki Kadafi'
|
||||
ARTISTS = ['2Pac', 'Snoop Doggy Dogg', 'Nate Dogg', 'Fatal', 'Yaki Kadafi']
|
||||
```
|
||||
|
||||
`ARTISTS` holds **canonical** artist names; `ARTIST` holds
|
||||
**as-credited** names. Locating one inside the other fails on
|
||||
"Snoop Doggy Dogg" vs "Snoop Dogg", on "Fatal" vs "Hussein Fatal", and
|
||||
on Unicode (`Michel'le` vs `Michel’le`, `K-Ci` vs `K‐Ci` — U+2010, not
|
||||
a hyphen). That distinction is precisely what a join phrase encodes,
|
||||
and it is why this cannot be a tag-parsing feature.
|
||||
|
||||
Two format details that will mislead anyone re-running the probe:
|
||||
Picard writes `ARTISTS` **slash-joined into one TXXX frame** on mp3 and
|
||||
as **true repeated Vorbis keys** on flac, so a probe splitting only on
|
||||
NUL undercounts mp3 to zero.
|
||||
|
||||
## The shape
|
||||
|
||||
MusicBrainz models a credit as ordered parts, and the credit *string*
|
||||
is derived from them — `artist_credit.name` is a cached render, nothing
|
||||
more. Each participant is `(position, artist, name, join_phrase)`,
|
||||
where `artist` is the MBID (canonical, what you navigate to) and `name`
|
||||
is the credited spelling (what you display).
|
||||
|
||||
**Join phrases are assembly instructions, not disassembly
|
||||
instructions.** Rendering is a concatenation, never a search:
|
||||
|
||||
```
|
||||
for each (position, artist_mbid, credited_name, join_phrase):
|
||||
emit link(credited_name -> artist_mbid)
|
||||
emit text(join_phrase)
|
||||
```
|
||||
|
||||
The link positions are known **by construction**. This is load-bearing:
|
||||
if we instead located each `credited_name` inside the stored
|
||||
`artist_credit` text, we would reintroduce the mismatch above — the
|
||||
stored string may have come from the tags while the parts come from the
|
||||
catalog, and those **disagree for ~1 in 3 multi-artist files** (61 of
|
||||
90 sampled credits rendered exactly equal to the tag string).
|
||||
Divergences seen: `'Skrillex feat. Swae Lee'` tagged vs
|
||||
`'Skrillex & Swae Lee'` in MB; `'STRFKR'` vs `'Starfucker'`;
|
||||
`'Zedd feat. Hayley Williams'` vs `'... of Paramore'`. Either MB was
|
||||
edited after tagging or Picard versions differ; either way the search
|
||||
would miss or match the wrong span.
|
||||
|
||||
So `audio_files.artist_credit` stops being the source of truth and
|
||||
becomes the **fallback**, used only where there are no parts.
|
||||
|
||||
## Where the data comes from
|
||||
|
||||
The catalog carries the decomposition; no user ever makes a
|
||||
per-recording call. Two sources were ruled out first, both cheaply:
|
||||
|
||||
- **The canonical dump — which is what CI already pulls
|
||||
(`dumpimport.go:84-85`) — does not have it.**
|
||||
`canonical_musicbrainz_data.csv` gives `artist_mbids` (ordered list)
|
||||
and `artist_credit_name`, but that last column is the *rendered*
|
||||
string. Splitting it on CI needs the as-credited names, so CI would
|
||||
fail exactly the way a local parse does.
|
||||
- **The JSON dumps do not cover the catalog.**
|
||||
`json-dumps/recording.tar.xz` is 31 MB / 368 MB uncompressed and
|
||||
holds **153,691 recordings**, not ~35M. Measured against the test
|
||||
library's 24,885 recording MBIDs: **0.00% overlap, zero rows**. It is
|
||||
some other subset and is not usable.
|
||||
|
||||
That leaves the core dump, **`mbdump.tar.bz2`** (7.1 GB compressed at
|
||||
the 20260815 export), from
|
||||
`https://data.metabrainz.org/pub/musicbrainz/data/fullexport/`. Four
|
||||
members are needed:
|
||||
|
||||
| member | why | approx rows |
|
||||
| --- | --- | --- |
|
||||
| `mbdump/artist_credit_name` | `(artist_credit, position, artist, name, join_phrase)` — the payload | ~4M |
|
||||
| `mbdump/artist` | `id -> gid`, since the above references artist *row ids* | ~2.6M |
|
||||
| `mbdump/recording` | `gid -> artist_credit`, to key credits by recording MBID | ~35M |
|
||||
| `mbdump/release_group` | same, for album credits | ~2M |
|
||||
|
||||
### Coverage is not a concern
|
||||
|
||||
Of 24,885 distinct recording MBIDs in the test library, **24,808
|
||||
(99.7%)** already have an `explore_index` recording row, measured
|
||||
against a database at 2,052,200 rows — i.e. shipped-artifact coverage,
|
||||
not a local build's. The popularity filter does not strand the long
|
||||
tail here.
|
||||
|
||||
## Status
|
||||
|
||||
- **Phase 1 — done.** `backend/explore/dumpcredits.go` +
|
||||
`dumpcreditswrite.go`, wired into `dumpimport.go`'s `run` behind its
|
||||
own `credits_import_done` marker.
|
||||
- **Phase 2 — done.** `cmd/indexexport` writes the two tables;
|
||||
`artifactimport.go` reads them behind `artifactHasCredits()`.
|
||||
- **Phase 4 — done, and it does not need Phase 3.** `explore.GetCredits`
|
||||
reads the catalog tables keyed on the *recording* MBID, which both
|
||||
sides of the app already carry — a catalog row has one and so does a
|
||||
local file (`library.Track.RecordingMBID`). So one binding serves the
|
||||
Explore pages and the library's own lists, and all ten artist-link
|
||||
call sites render credits today without a local table.
|
||||
- **Phase 3 (`file_artists`) — not started, and now an
|
||||
offline-resilience task rather than a prerequisite.** The table is
|
||||
deliberately *not* declared yet: nothing writes or reads it, and a
|
||||
schema file plus a datamap note describing behaviour that does not
|
||||
exist is a claim the code cannot back. Its remaining
|
||||
value is that credits currently vanish when the catalog is absent or
|
||||
still downloading, which is precisely the `no-index` state
|
||||
`ShelfPage.State` exists to describe. Materialising into
|
||||
`file_artists` is what makes a library stand on its own.
|
||||
|
||||
**Nothing renders yet in practice**, because no published artifact
|
||||
carries credit tables — every credit falls back to its single link
|
||||
until an index build with Phase 1 runs and is exported.
|
||||
|
||||
**Column layouts are verified against the real 20260815 export**, not
|
||||
taken from the schema docs — `artist(id, gid, …)`,
|
||||
`artist_credit(id, name, artist_count, …)`,
|
||||
`artist_credit_name(credit, position, artist, name, join_phrase)` and
|
||||
`recording(id, gid, name, artist_credit, …)` were each read out of the
|
||||
dump. `release_group` shares `recording`'s first four columns and is
|
||||
the one layout still taken on trust; `ErrDumpShape` turns a wrong guess
|
||||
into a loud failure rather than a quietly wrong catalog.
|
||||
|
||||
**Still unrun: the ingest against the real 7.1 GB dump.** Everything is
|
||||
covered by tests over a synthetic tar, which cannot catch a surprise in
|
||||
the other ~35M rows.
|
||||
|
||||
### Phase 1 — Ingest credits on CI
|
||||
|
||||
New dump stage in `cmd/indexbuild`, behind the `indexbuild` tag with
|
||||
the rest of `dumpimport.go`'s stages.
|
||||
|
||||
**Constraint from `b98840e`:** `cmd/indexbuild` is built
|
||||
`CGO_ENABLED=0` in a plain `golang` container and must not reach the
|
||||
Wails `application` package — `TestIndexToolsDoNotImportWails` walks
|
||||
`go list -deps -tags indexbuild`. Nothing here should need it, but a
|
||||
new `ServiceStartup` hook on a package this imports is how it comes
|
||||
back. Go's `compress/bzip2` is pure Go and decompress-only, which is
|
||||
all this needs.
|
||||
|
||||
**Measured, 20260815 export.** Tar members are **alphabetical**, and
|
||||
that is favourable: `artist` (435 MB), `artist_credit` (414 MB) and
|
||||
`artist_credit_name` (237 MB) all fall inside the first ~900 MB
|
||||
compressed, while `recording` and `release_group` come later. So the
|
||||
maps are complete before the rows that consume them arrive, and no
|
||||
recording data is ever buffered.
|
||||
|
||||
Pure-Go `compress/bzip2` decompresses at **26 MB/s uncompressed /
|
||||
8.7 MB/s compressed** (measured on a 250 MB prefix, 3.01x ratio) —
|
||||
**~13.7 min** for the whole file single-threaded, and less because the
|
||||
stream can stop after `release_group` rather than reading the
|
||||
`series`/`tag`/`track`/`url`/`work` tail. The 2 MB/s origin throttle
|
||||
dominates, as it already does for every other dump here.
|
||||
|
||||
Do not, however, *depend* on the ordering: assert it and fall back to
|
||||
buffering if a future export reorders, rather than silently emitting
|
||||
nothing.
|
||||
|
||||
- `artist` -> `map[int32]uuid16` (~2.6M x ~20 B = ~60 MB)
|
||||
- `artist_credit_name` -> `map[int32][]creditPart` (~4M x ~40 B =
|
||||
~200 MB)
|
||||
- `recording` / `release_group` -> emit `gid -> credit_id` **only for
|
||||
MBIDs already in `explore_index`** (the kept set is ~1.4M x 16 B =
|
||||
~22 MB), which is what keeps 35M rows from being held
|
||||
|
||||
Peak ~300 MB, one sequential pass.
|
||||
|
||||
**Only multi-artist credits are stored.** A single-artist credit is
|
||||
`(name, "")` and is already fully described by `explore_index`'s
|
||||
`artist_name` / `artist_mbid`; storing it would triple the table for
|
||||
nothing. Post-filter after loading, once the row count per credit is
|
||||
known.
|
||||
|
||||
New tables (and `datamap` entries, or `TestCatalogCoversSchema` fails
|
||||
the build — both are `Cache`, matching `explore_index`):
|
||||
|
||||
```
|
||||
artist_credit_part(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||
```
|
||||
|
||||
with `explore_index.artist_credit_id` as the link. Credits are
|
||||
**shared** — an album's twelve tracks by one artist share one credit
|
||||
row — which is the opposite of 013's local verdict, and correctly so:
|
||||
1:1 in a local library, genuinely many-to-one at 2M-row catalog scale.
|
||||
|
||||
### Phase 2 — Ship them in the artifact
|
||||
|
||||
`cmd/indexexport` currently creates exactly two tables in the artifact
|
||||
(`explore_index`, `artifact_meta`, at `cmd/indexexport/*.go:147,170`),
|
||||
so this is a structural addition, not a column.
|
||||
|
||||
Estimated size: ~13% of 1.4M recordings, deduplicated by shared credit,
|
||||
at ~2.3 parts each — order 400k rows, ~18 MB uncompressed. Against a
|
||||
~0.6 GB install that is acceptable; it must be measured rather than
|
||||
assumed before merge.
|
||||
|
||||
`artifactimport.go` must read it **only if present**, on the writer
|
||||
handle where `core` is attached — the `artifactHasTotals()` /
|
||||
`artifactStoresText()` pattern (`artifactimport.go:145-175`), one step
|
||||
up from a column to a table. An artifact published before this exists
|
||||
is still a perfectly good catalog and must import as one that declines
|
||||
to answer. Adding this to the importer's SELECT list without the probe
|
||||
is how every already-published artifact starts failing.
|
||||
|
||||
`artifactCatalogColumns` gains `artist_credit_id`; it is kept in sync
|
||||
with the exporter by `TestArtifactColumnsMatchExporter`.
|
||||
|
||||
### Phase 3 — Materialize locally
|
||||
|
||||
```
|
||||
file_artists(audio_file_id, position, artist_id, credited_name, join_phrase)
|
||||
```
|
||||
|
||||
`credited_name` is stored **per row**, not looked up from
|
||||
`artists.name` — that is the Snoop-Doggy-Dogg distinction, and it is
|
||||
the whole point.
|
||||
|
||||
Filled at scan/import time by joining `audio_files.recording_mbid`
|
||||
against the catalog. **Materialized rather than resolved live**,
|
||||
because the catalog is a downloaded artifact that can be absent or
|
||||
still arriving — that is why `ShelfPage.State` has a `no-index` value —
|
||||
and a library whose track rows lose their artists when the catalog is
|
||||
missing is worse than today.
|
||||
|
||||
That implies a backfill for the case where the catalog arrives *after*
|
||||
the library was scanned. It registers with `jobs` (progress, cancel)
|
||||
like every other long pass, and takes a **distinct kind** from
|
||||
`index-build`, since `job-controls.ts` keys its "you will discard hours
|
||||
of downloading" confirmation on that kind.
|
||||
|
||||
`artists` gains rows for guests who own no files. **This changes what
|
||||
the artists grid shows** and is an open question below.
|
||||
|
||||
### Phase 4 — Render
|
||||
|
||||
`utils/explore-link.ts` gains a credit-rendering entry point taking
|
||||
ordered parts and returning a `TemplateResult`. Every row and detail
|
||||
view already renders artist names through it, so they inherit
|
||||
multi-artist links without individually knowing credits exist — the
|
||||
property that made centralising it worthwhile.
|
||||
|
||||
Its existing fallback philosophy already covers the no-parts case: "a
|
||||
list where some rows are clickable and others silently are not reads as
|
||||
a bug, not as a statement about metadata." Where there are no parts
|
||||
(no recording MBID, or no catalog row — ~4% of the test library) render
|
||||
today's behaviour: the flat `artist_credit` string with one link to the
|
||||
primary artist. **Do not split the string there.** There is genuinely
|
||||
no information to split on, and that is the one place the temptation
|
||||
returns.
|
||||
|
||||
`primaryArtist()` stays exactly as it is. It remains the fallback and
|
||||
is still what `artist_id` means.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Catalog credit vs tagged credit, when they disagree** (~1 in 3
|
||||
multi-artist files). Rendering the catalog's decomposition is what
|
||||
makes names navigable; preserving the file's is what makes the app
|
||||
reflect the user's files. Leaning toward: render the catalog
|
||||
decomposition, keep `artist_credit` as the fallback string. Wants a
|
||||
deliberate decision, not an accident.
|
||||
2. **Do guest artists appear in the artists grid?** Phase 3 creates
|
||||
`artists` rows for people who own no files. The grid currently means
|
||||
"artists in your library" and joins `audio_files`. A guest on one
|
||||
track is arguably in the library and arguably not. Whichever way,
|
||||
the ownership question stays "is there a file" — that rule does not
|
||||
bend.
|
||||
3. **`release_group` credits** are ingested in the same pass for
|
||||
nearly nothing, but album-artist rendering is a separate surface.
|
||||
Ship the data in phase 1, render in a follow-up rather than widening
|
||||
phase 4.
|
||||
4. **Our own `tagwriter`** does not write `ARTISTS` or multiple
|
||||
`MUSICBRAINZ_ARTISTID` frames, so autotagging a folder degrades the
|
||||
very field this rests on — the same shape as the existing
|
||||
track-totals note. Out of scope here; worth recording.
|
||||
|
||||
## Verification
|
||||
|
||||
- Coverage: re-run the library probe and assert `file_artists` is
|
||||
populated for ~13% of files, not ~0.9%.
|
||||
- `TestCatalogCoversSchema` / `TestLifetimesMatchSchema` for the new
|
||||
tables.
|
||||
- `TestIndexToolsDoNotImportWails` still passes with the new stage.
|
||||
- An artifact **without** the credits table imports cleanly (the
|
||||
`artifactHasTotals` regression shape).
|
||||
- Round-trip: a known multi-artist recording renders each name as a
|
||||
separate link with the correct join phrases between them.
|
||||
@@ -0,0 +1,412 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **Completed.** Sections A, B1, B2 and B4 shipped. B3, writing tags on the device, is now **#87**; the device-found UI faults are #51–#72, sequenced by #73.
|
||||
|
||||
> **Status: all of section A is done.** A1–A3 landed with "let the app
|
||||
> reach the user's music"; A4 (MediaSession, transport notification,
|
||||
> audio focus) landed with "survive the screen locking". The direction
|
||||
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
|
||||
> plus an in-app folder browser, which keeps the path-keyed model
|
||||
> intact. B1/B2 remain, both awaiting a decision rather than work. The
|
||||
> sections below are kept as written, because they are the argument the
|
||||
> decision rests on — see "What is left" at the end for the current
|
||||
> state.
|
||||
|
||||
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
|
||||
versioned, and publishes from CI. This is the assessment of what stands
|
||||
between that and an Android app worth installing.
|
||||
|
||||
**The headline: parity is the wrong target, and choosing it would be
|
||||
the expensive mistake.** Four of the blockers below are not porting work
|
||||
— they are the Android platform declining to support the model this app
|
||||
is built on. The decision to make first is in "The fork in the road" at
|
||||
the end; everything before it is evidence for that decision.
|
||||
|
||||
Severity is what the app *does* today, verified against the source and
|
||||
the generated manifest, not guessed.
|
||||
|
||||
## A. It cannot work at all until these are fixed
|
||||
|
||||
### A1. The app can read no music. (deepest)
|
||||
|
||||
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
|
||||
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
|
||||
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
|
||||
**There is no storage or media permission of any kind.** At
|
||||
`targetSdk 35` that means the app can see its own private directory and
|
||||
nothing else.
|
||||
|
||||
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
|
||||
grants access through **MediaStore**, not through the filesystem. This
|
||||
app's entire model is absolute paths: `audio_files.file_path` is the
|
||||
primary key of ownership, `AddLibrary(path)` takes a directory, the
|
||||
scanner walks it with `os.ReadDir`, and every one of
|
||||
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
|
||||
paths to the player. Scoped storage does not offer a stable directory
|
||||
to walk.
|
||||
|
||||
The honest options are three, and they are not close in cost:
|
||||
|
||||
- **MediaStore as the library source.** Query the content resolver,
|
||||
keep MediaStore IDs (or content URIs) beside or instead of paths, and
|
||||
open audio through a `ContentResolver` file descriptor. This is the
|
||||
Android-native answer and it touches the schema, the scanner, the
|
||||
player's file opening and every path-keyed query.
|
||||
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
|
||||
effectively barred from Google Play except for genuine file managers.
|
||||
Viable *only* because we distribute through Obtainium — which is a
|
||||
real point in its favour here, and worth stating plainly rather than
|
||||
dismissing.
|
||||
- **App-private storage only**, i.e. the user copies music into the
|
||||
app's sandbox. Trivial to build, and nobody wants it.
|
||||
|
||||
### A2. The first-run flow cannot complete.
|
||||
|
||||
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
|
||||
`frontendutil.DirectoryPicker` → `app.Dialog.OpenFile().
|
||||
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
|
||||
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
|
||||
paths"**. So the one action the wizard exists to perform fails, and
|
||||
`<first-run-wizard>` intercepts all pointer events until a library
|
||||
exists — so the app is not merely empty, it is inert.
|
||||
|
||||
Whatever A1 resolves to decides this: a MediaStore library needs no
|
||||
picker at all, and a SAF tree needs the picker to return a URI the
|
||||
backend can use.
|
||||
|
||||
### A3. MPRIS is compiled into the Android build.
|
||||
|
||||
`mpris_linux.go` is `//go:build linux`, and **`android` implies
|
||||
`linux`** (documented, and the reason it is in the APK). It will look
|
||||
for a session bus that does not exist. It needs `//go:build linux &&
|
||||
!android`, and its Android counterpart is A4.
|
||||
|
||||
This one is cheap and should be done regardless — it is a two-character
|
||||
build-tag change plus whatever `mediacontrols.New` returns instead.
|
||||
|
||||
### A4. Playback will be killed the moment the screen locks.
|
||||
|
||||
The scaffold's `WailsForegroundService` is typed **`dataSync`**
|
||||
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
|
||||
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
|
||||
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
|
||||
`MediaSession` for lock-screen and notification transport controls,
|
||||
plus **audio focus** — pause on a phone call, duck for a notification,
|
||||
pause on headphone unplug. None of that exists today. `oto` will happily
|
||||
keep writing to a stream nobody can hear.
|
||||
|
||||
This is the difference between "an app that plays audio" and "a music
|
||||
player", and it is Java-side work in the scaffold plus a Go-side bridge.
|
||||
|
||||
## B. It works, but wrongly
|
||||
|
||||
### B1. The x86_64 half of the APK cannot run on any Android.
|
||||
|
||||
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
|
||||
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
|
||||
takes `SIGSYS` the first time it touches the database. arm64 is
|
||||
structurally unaffected (no `lstat` syscall exists; it routes through
|
||||
`fstatat`).
|
||||
|
||||
So ~31 MB of the artifact is dead weight on *every* Android device,
|
||||
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
|
||||
(smaller APK, no emulator target — which does not work anyway), or
|
||||
carry it against a future modernc fix. **Dropping it is the honest
|
||||
default**; it is also the only item in this plan that is a five-minute
|
||||
change.
|
||||
|
||||
### B2. The UI is a desktop shell.
|
||||
|
||||
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
|
||||
header subtitle wraps the title out of its bar. A phone is ~360–430 CSS
|
||||
px wide. The sidebar collapses to icons below 900px, which is a
|
||||
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
|
||||
on hover (the marquee's `hover` mode, tooltips), right-click context
|
||||
menus, a keyboard shortcut layer with its own overlay and settings page,
|
||||
multi-select with ctrl/shift, and a resizable-column track list. None of
|
||||
those are gestures.
|
||||
|
||||
This is not a stylesheet pass. It is a second front end for the views
|
||||
worth having on a phone, sharing the stores and bindings — which the
|
||||
architecture supports, since a view is already a lazily-loaded chunk
|
||||
behind `VIEW_LOADERS`.
|
||||
|
||||
### B3. Tag writing cannot reach the user's files.
|
||||
|
||||
`tagwriter` rewrites tags in place, and autotag's whole purpose is
|
||||
applying them to a folder. Under scoped storage that is impossible
|
||||
outside the sandbox without a SAF write grant per tree. If A1 lands on
|
||||
MediaStore, in-place tag writing needs `MediaStore` write requests and
|
||||
user confirmation per file on Android 11+.
|
||||
|
||||
Autotagging is arguably a desktop-only feature and saying so is a
|
||||
legitimate answer.
|
||||
|
||||
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
|
||||
|
||||
It works — but with no awareness of a metered connection and no
|
||||
accounting for a device where that is a meaningful fraction of free
|
||||
space. At minimum it needs to be opt-in on mobile and to refuse a
|
||||
metered network by default. `Android.NetworkJSON()` reports
|
||||
`{connected,type}`, so the signal is available.
|
||||
|
||||
## C. Inert, and fine
|
||||
|
||||
Window geometry, menus and the system tray are documented no-ops on
|
||||
mobile. The keyboard shortcut layer is harmless but its Settings page
|
||||
is dead weight. `profiling` is already compiled out of production
|
||||
builds. These cost nothing and need no work.
|
||||
|
||||
## D. Unknown until it runs on a device
|
||||
|
||||
**Nothing in section A or B has been observed on Android**, because the
|
||||
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
|
||||
images on an x86_64 host. Everything above is read from the source, the
|
||||
generated manifest and Wails' own documentation. The first real device
|
||||
run will find things this list does not have, and the most likely
|
||||
places are audio latency and buffering under `oto`/oboe, and SQLite
|
||||
behaviour on app-private storage.
|
||||
|
||||
## The fork in the road
|
||||
|
||||
The four blockers in section A are all the same question wearing
|
||||
different clothes: **is the Android app a librarian, or a player?**
|
||||
|
||||
YellowJacket on the desktop is a *librarian*. It scans folders,
|
||||
deduplicates covers, detects duplicate tracks, reconciles against
|
||||
MusicBrainz, rewrites tags on disk, and manages downloads. That model
|
||||
rests on owning a filesystem, which is precisely what Android declines
|
||||
to give.
|
||||
|
||||
Three coherent products, and only the first is "parity":
|
||||
|
||||
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
|
||||
(Obtainium-only distribution, which we already have), a phone UI for
|
||||
every view, and media-session playback. Largest scope by far; the
|
||||
result is an app almost nobody has asked for on a phone.
|
||||
2. **A player for music already on the phone.** MediaStore as the
|
||||
source, no scanner, no autotag, no downloads; the library, queue,
|
||||
playlists, favourites and Explore-as-browsing all still make sense.
|
||||
This is a genuinely good Android app and it is *not* parity — it is
|
||||
a subset with a different data source.
|
||||
3. **A companion to the desktop app.** The phone browses and controls
|
||||
the desktop's library over the network, or syncs a subset. Smallest
|
||||
Android surface, and it leans on the thing that already works.
|
||||
|
||||
**Option 2 is the recommendation** if the goal is an app people use;
|
||||
option 3 if the goal is the least work for the most value. Option 1 is
|
||||
the only one that answers "feature parity" literally, and it is the one
|
||||
worth arguing hardest against.
|
||||
|
||||
> **Decided:** option 1's *data model* (the librarian keeps its
|
||||
> filesystem and its scanner — A1 shipped that) with option 2's
|
||||
> *surface*. The phone is a player over the library this app already
|
||||
> builds; it does not get every view. The list is below.
|
||||
|
||||
## The phone gets a subset (decided)
|
||||
|
||||
B2 is not a stylesheet pass and not a second front end either. A view
|
||||
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
|
||||
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
|
||||
so the phone build is **a different loader table and a different
|
||||
chrome**, over the same stores.
|
||||
|
||||
**In**, because each is something a person does with a phone in their
|
||||
hand:
|
||||
|
||||
- **Home** — the shelves are already a phone-shaped surface.
|
||||
- **Library browse** — albums, artists, genres. The grids are already
|
||||
virtualized and card-shaped.
|
||||
- **Now playing** — which on a phone is a *view*, not a 4em bar.
|
||||
- **The queue.**
|
||||
- **Search** — the header box, scoped as it already is.
|
||||
- **Playlists**, including smart ones, as lists to play rather than to
|
||||
edit.
|
||||
|
||||
**Out**, and each for a reason rather than by omission:
|
||||
|
||||
- **Autotag** — the review UI is a wide table and the action rewrites
|
||||
files on disk; B3 has not been verified even as *possible* yet.
|
||||
- **Downloads** — two tab panels of client configuration.
|
||||
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
|
||||
the last thing to earn a phone's storage.
|
||||
- **Settings** — not the page. The phone needs a handful of settings
|
||||
(theme, the library folder, playback) and not the 93 controls the
|
||||
desktop page carries.
|
||||
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
|
||||
has no keyboard and no resizable columns, and the jobs indicator is
|
||||
enough.
|
||||
|
||||
What the shell has to lose, from the audit at the top of this section:
|
||||
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
|
||||
bar over the five things above), hover as a route to anything,
|
||||
right-click as the only route to a context menu (long-press is the
|
||||
gesture), and ctrl/shift multi-select.
|
||||
|
||||
One rule for the work: **no view forks.** A phone layout that copies a
|
||||
view's template is two templates to fix every bug in. Where a view
|
||||
cannot serve both, the split belongs at the chunk boundary that already
|
||||
exists.
|
||||
|
||||
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
|
||||
inside the drawer means reusing its `data-testid`s too, and a second
|
||||
copy standing by in the DOM broke 30 specs that had nothing to do with
|
||||
the phone. The rule holds — a second list of destinations would be
|
||||
worse — but a shared component must be rendered only when it is wanted,
|
||||
and the guard belongs in a test that names the reason.
|
||||
|
||||
## What is worth doing regardless of that decision
|
||||
|
||||
Cheap, independently useful, and each unblocks measurement:
|
||||
|
||||
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
|
||||
why. Five minutes.
|
||||
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
|
||||
Android build stops carrying a D-Bus client. Small.
|
||||
3. **A device smoke run**, which needs someone's phone and the published
|
||||
APK. Everything in D depends on it, and it is the single highest
|
||||
information-per-minute action available.
|
||||
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
|
||||
— the picker's error already routes through `describeError`, but the
|
||||
wizard still blocks pointer events, so an Android user sees a dead
|
||||
screen rather than a sentence. Even under option 3 this is the right
|
||||
behaviour.
|
||||
|
||||
|
||||
## What is left (updated after A4)
|
||||
|
||||
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
|
||||
beside the MPRIS one, and the Java half is
|
||||
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
|
||||
transport notification and audio focus. It needed no new JNI and no new
|
||||
Gradle dependency — `application.Android.StartForegroundService(json)`
|
||||
going out, `WailsBridge.emitEvent` → the application event bus coming
|
||||
back, and the platform `android.media.session` API rather than
|
||||
androidx.media, which minSdk 21 makes available anyway.
|
||||
|
||||
Four decisions in it are worth keeping:
|
||||
|
||||
- **Ducking is a player concept, not a volume change.**
|
||||
`Player.SetDuck` re-applies the *user's* level with an attenuation
|
||||
offset, so `getUserVolume` still reports what the user chose and
|
||||
nothing is persisted or emitted. A duck that wrote through to the
|
||||
volume would let one notification tone permanently turn the music
|
||||
down.
|
||||
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
|
||||
the app itself and sends no `CAN_DUCK` focus change, so asking to be
|
||||
told instead (`setWillPauseWhenDucked`) would mean pausing for every
|
||||
notification tone, and doing both would attenuate twice.
|
||||
- **An unchanged payload is not an event here either.** Every push
|
||||
crosses JNI and re-delivers an Intent, and the player pushes state on
|
||||
several paths that can agree.
|
||||
- **After the first start, updates use `startService`.** From Android
|
||||
12 an app in the background may not *start* a foreground service, but
|
||||
it may keep delivering intents to one it already has — which is every
|
||||
track change with the screen off.
|
||||
|
||||
The contract with Java — the payload keys, the state words, the command
|
||||
names — is in `androidpayload.go`, deliberately *without* the `android`
|
||||
build tag, so `go test` exercises it on every platform. Everything left
|
||||
in `android.go` is untested by construction: it compiles only under a
|
||||
cross-compiler and runs only on a phone.
|
||||
|
||||
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
|
||||
places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||
(or Go still compiles a library Gradle then discards) and the
|
||||
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
|
||||
anchor is what stops it also matching the fat APK's line. Adding the
|
||||
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||
|
||||
**B2, the desktop shell.** Scope decided (below); **all four phases are
|
||||
done.**
|
||||
|
||||
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
|
||||
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||
exactly — measured, from 652px in a 360px viewport before.
|
||||
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
|
||||
and volume went. A detail view, so Back pops the nav stack; it
|
||||
composes the real transport components rather than copying them; and
|
||||
it hides the bottom bar while it is up, so it carries its own queue
|
||||
button.
|
||||
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
|
||||
listener, installed once from `index.ts`, which turns a 500 ms
|
||||
stationary touch into a synthetic `contextmenu` at the touch point.
|
||||
Every menu in the app opens from that event, so all six components
|
||||
gained the gesture without one of them changing — which is the same
|
||||
argument `ContextMenuController` rests on, one layer lower. The
|
||||
details that are not obvious are in `NOTES.md` (2026-08-17); the one
|
||||
worth repeating is that ours is told from the browser's own
|
||||
long-press event by **identity**, not `isTrusted`, because a test
|
||||
cannot dispatch a trusted event and that path would otherwise be the
|
||||
only uncovered one.
|
||||
|
||||
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
|
||||
artist) plus the duration, and drops the column headers and the resize
|
||||
handles — a column set rather than a second row template, so the row
|
||||
and everything delegated on it is unchanged. Verified at the device's
|
||||
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
|
||||
overflow. The device also found the bug in it, which no browser
|
||||
viewport would have: saved *desktop* column widths reached the phone
|
||||
through an id-keyed store and gave the duration column 55% of the row.
|
||||
|
||||
**B2 and B4 are complete.** B4 is `backend/explore/netpolicy.go`: the
|
||||
catalog download is skipped on a cellular connection unless
|
||||
`AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search
|
||||
Index section. The policy and the JSON parsing are in `explore` (tested
|
||||
on every platform) and only the platform call is injected from `app.go`,
|
||||
because `cmd/indexbuild` imports `explore` and must not link Wails. Two
|
||||
things the plan got slightly wrong: the portable API is
|
||||
`application.Mobile.NetworkJSON()` rather than `Android`'s, and it
|
||||
reports no metered flag — so cellular is the signal and a metered Wi-Fi
|
||||
cannot be seen.
|
||||
|
||||
What is left in this plan is B3 (tag writing, which needs a device) and
|
||||
the standing question of the Light Phone's Chrome 113 — which so far has
|
||||
cost nothing: menus, dialogs and long-press all work on it.
|
||||
|
||||
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||
with all-files access, `tagwriter` can write in place.
|
||||
|
||||
### What the first device run answered (2026-08-17)
|
||||
|
||||
A4 **works**: playback survives the screen locking, and the transport
|
||||
notification appears with cover art — which also settles the service's
|
||||
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
|
||||
the lock-screen session in one observation. Everything below in "what
|
||||
none of section A answered" was written before this and is now answered
|
||||
except the OEM permission-flow variance.
|
||||
|
||||
It also found two faults no browser tier can see, both fixed and both
|
||||
awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||
|
||||
- **Back quit the app from any depth.** The scaffold asks
|
||||
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||
navigation is a history entry now, and `navStack` is gone rather than
|
||||
kept beside it.
|
||||
- **The transport was under the gesture bar** — or so the version
|
||||
number said. `applyWindowInsets()` in `MainActivity` is right and
|
||||
stays, but the phone is **Android 14**, where the system still insets
|
||||
the window: the fix is pre-emptive and the symptom has another cause.
|
||||
Still open, along with icons that do not appear at all. The phone's
|
||||
WebView is **Chrome 113**, which is the lead (no Popover API, no
|
||||
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
|
||||
how it gets asked.
|
||||
|
||||
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||
permission flow still need a device**, and so does confirming these two.
|
||||
|
||||
### What none of section A answered
|
||||
|
||||
Nothing here has been observed on a device. The permission flow in
|
||||
particular is the kind of thing that behaves differently across OEM
|
||||
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
|
||||
implemented inconsistently, which is why there is a fallback to the
|
||||
global list, and neither path has been exercised.
|
||||
|
||||
A4 adds its own list of things only a device can answer, and they are
|
||||
the likely first failures: whether the notification appears at all
|
||||
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
|
||||
user who declines gets a service with an invisible notification),
|
||||
whether audio focus arrives while `oto`/oboe holds the output, whether
|
||||
the lock screen picks up the session, and whether cover art decoded
|
||||
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
|
||||
@@ -0,0 +1,87 @@
|
||||
# 017 — Releases that happen by themselves
|
||||
|
||||
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
|
||||
Commits since the last tag, cuts the tag and the Gitea release whose body
|
||||
is the generated changelog, and the four publishing workflows build that
|
||||
tag and attach their artifacts. Nothing is released by hand.
|
||||
|
||||
## What it looks like now
|
||||
|
||||
`release.yml` on push to `main` → semantic-release → tag → four `v*`
|
||||
workflows in parallel (serialised in practice by the capacity-1 runner):
|
||||
|
||||
| workflow | publishes | attaches |
|
||||
| --- | --- | --- |
|
||||
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
|
||||
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
|
||||
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
|
||||
| `homebrew-formula` | the public tap | — (builds from source) |
|
||||
|
||||
Verified on the real thing: all five green, three assets on the release,
|
||||
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
|
||||
|
||||
## The five decisions, and what they cost
|
||||
|
||||
1. **semantic-release, not a shell script.** The first draft of this plan
|
||||
proposed hand-rolling it and the argument did not survive checking:
|
||||
`@semantic-release/exec` is first-party and current, and the
|
||||
Gitea-shaped part is one `curl`. What I would have hand-rolled —
|
||||
commit parsing, semver ordering, note rendering — is the part with the
|
||||
edge cases and none of it is Gitea-shaped.
|
||||
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
|
||||
before it was checked: last published 2022, `got@10`, and no peer
|
||||
dependency on semantic-release at all.
|
||||
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
|
||||
commit-back is rejected by the pre-receive hook — and would be
|
||||
rejected *after* the tag was pushed, leaving a tagged release the run
|
||||
reports as failed. The release page is the changelog;
|
||||
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
|
||||
signpost.
|
||||
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
|
||||
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
|
||||
once. Documented in `packaging/homebrew/README.md` and
|
||||
`docs/android-release.md`.
|
||||
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
|
||||
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
|
||||
because no build of it has ever been *run*.
|
||||
|
||||
## Four things that only showed up by running it
|
||||
|
||||
- **`conventional-changelog-conventionalcommits@10` renders empty
|
||||
notes.** Silently: right version, right tag, every step green, and a
|
||||
release body that is a bare `## 0.0.1 (date)` heading with nothing
|
||||
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
|
||||
the reason beside both. **Check the rendered notes, never the exit
|
||||
code.**
|
||||
- **semantic-release core dry-run-pushes to the release branch** as a
|
||||
permission check, independently of any plugin. `PACKAGE_TOKEN` had
|
||||
package-write and repo-*read* — enough to clone, not enough for this —
|
||||
and it failed with a flat `403 Forbidden` that reads exactly like
|
||||
branch protection. It is not: a `--dry-run` push never reaches the
|
||||
pre-receive hook, which a one-line experiment settled. The token needed
|
||||
`write:repository`.
|
||||
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
|
||||
commit itself it leaves nothing between the floor and HEAD, and
|
||||
semantic-release correctly reports there is nothing to release. The
|
||||
first run did exactly that and cut nothing.
|
||||
- **A tag-triggered workflow runs from the tagged commit's tree.**
|
||||
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
|
||||
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
|
||||
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
|
||||
general: a guard added today does not protect a tag pointing at
|
||||
yesterday.
|
||||
|
||||
## Two mechanisms confirmed, having been assumptions
|
||||
|
||||
- **A tag pushed with a user PAT does start the `v*` workflows**; one
|
||||
pushed with the Actions token does not (go-gitea#33123). Both halves
|
||||
are load-bearing and both were observed: the floor seed triggered
|
||||
nothing, and the release tag triggered all four.
|
||||
- **Tags are not protected** on this repo, only `main` — which is what
|
||||
lets semantic-release tag at all.
|
||||
|
||||
## Left behind deliberately
|
||||
|
||||
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
|
||||
four publishers skip it by name.
|
||||
@@ -0,0 +1,327 @@
|
||||
# 018 — Supported sizes, and what the queue panel is
|
||||
|
||||
**Issue:** #24 (`Area/Shell-Nav`, `Priority/High`, `Reviewed/Confirmed`)
|
||||
**Unblocks:** #55 (queue as a screen) — a real Gitea dependency
|
||||
**Relates:** #69 (page-header overflow), #12 (mini-player), #51 (small-screen umbrella)
|
||||
**Status:** complete — #24 shipped as PR #132, and the matrix's last
|
||||
unkept promise closed with #69.
|
||||
|
||||
#73 puts this first in Phase 2 and hangs the rest of the phase off it,
|
||||
so the decision has to be written down and arguable before any CSS
|
||||
moves. This document is the decision. Everything below the matrix is
|
||||
either a measurement or an argument for one of the four choices #24
|
||||
asks for.
|
||||
|
||||
---
|
||||
|
||||
## What is actually wrong, measured
|
||||
|
||||
Against the running app (`make dev-headless SEED=default`, Chromium),
|
||||
Playlists, sweeping the viewport with the queue open and closed. The
|
||||
number that matters is how much of the page header survives.
|
||||
|
||||
| viewport | sidebar | queue | main panel | header needs | actions clipped |
|
||||
|---|---|---|---|---|---|
|
||||
| 1280×800 | 200 | open 321 | 759 | 759 | — |
|
||||
| 1000×700 | 200 | open 321 | 479 | 747 | New Playlist, New Smart Playlist |
|
||||
| **900×600** | 200 | open 321 | **379** | 747 | **all three** |
|
||||
| 800×600 | 56 | open 321 | 423 | 747 | all three |
|
||||
| 700×600 | 56 | open 321 | 323 | 747 | all three |
|
||||
| 390×780 | — | open 321 | **69** | 747 | all three |
|
||||
| 320×600 | — | open 321 | **0** | 747 | all three |
|
||||
| 900×600 | 200 | closed | 700 | 747 | New Smart Playlist |
|
||||
| **800×600** | 56 | closed | 744 | 747 | **New Smart Playlist (158/162px)** |
|
||||
| 320×600 | — | closed | 320 | 747 | all three |
|
||||
|
||||
Five things in that table are not in the issue.
|
||||
|
||||
**The header clips at the supported minimum with the queue closed.**
|
||||
At 800×600 — the size `backend/config/window.go` enforces and the only
|
||||
size this app *promises* — "New Smart Playlist" loses 4px of its 162.
|
||||
#24 reads as a queue-panel bug; the queue makes it dramatic, but the
|
||||
header overflows on its own at the minimum window.
|
||||
|
||||
**900×600 is worse than 800×600, because the sidebar expands at 900.**
|
||||
`AUTO_COLLAPSE_VIEWPORT` collapses the sidebar to icons *below* 900, so
|
||||
at 899px the main panel is 843px and at 900px it is 700px. The worst
|
||||
desktop case is therefore not the minimum window; it is the pixel
|
||||
immediately above the collapse. Anything that tests "the minimum" and
|
||||
stops has not tested the worst case, which is what
|
||||
`layout-overflow.spec.ts` does today.
|
||||
|
||||
**At phone widths the queue is not a drawer, it is an amputation.**
|
||||
`queue-panel`'s host is `flex-shrink: 0; width: 0`, going to
|
||||
`width: var(--queue-width, 320px)` under `[open]` — it is *in the flow*
|
||||
of `.content-area`, so it takes its width from the main panel rather
|
||||
than covering it. At 390px that leaves 69px of the page; at 320px it
|
||||
leaves **0px**, and the app is not degraded but gone. This is the
|
||||
measurement #55 needs and did not have.
|
||||
|
||||
**Only Playlists overflows.** Sweeping all ten primary views at 900×600
|
||||
and at 390×780, every other header reports `scrollWidth ==
|
||||
clientWidth`, and Albums at 390px renders title, count and sort
|
||||
legibly (checked on a screenshot, not just the number). #69 is
|
||||
therefore one view's action set — three text buttons totalling 390px —
|
||||
and not a systemic header failure, though the *rule* still belongs in
|
||||
`page-header`.
|
||||
|
||||
**Both reasons in `MinWidth`'s comment are stale.** It says the floor is
|
||||
800×600 because "below ~780 the header's subtitle wraps" and "below
|
||||
~600 tall the eleven sidebar items no longer fit". The subtitle is
|
||||
`display: none` below 900 (index.css), and the sidebar host is
|
||||
`overflow-y: auto` — at 600×460 its `scrollHeight` is 434 against a
|
||||
332px client, and Settings is reachable after scrolling. Neither
|
||||
mechanism can happen any more. That does not mean the floor should
|
||||
move; it means its stated reason no longer supports it, which is worse
|
||||
than either answer.
|
||||
|
||||
*(Care needed: my first probe for the sidebar scroller searched
|
||||
`shadowRoot.querySelectorAll('*')` and reported "items are
|
||||
unreachable", because the scroller is the **host** and a host is not in
|
||||
its own shadow root. The claim in CLAUDE.md is correct.)*
|
||||
|
||||
---
|
||||
|
||||
## Decision 1 — the supported size matrix
|
||||
|
||||
Three bands. Two of them already exist and are already argued; what is
|
||||
new is that they are written down as a *promise*, and that the queue is
|
||||
part of it.
|
||||
|
||||
| band | width | navigation | queue | promise |
|
||||
|---|---|---|---|---|
|
||||
| **Phone** | < 600 | `bottom-nav` + drawer | overlay, full width | reflows; nothing needs sideways scrolling; fits 320px |
|
||||
| **Compact** | 600 – 899 | icon sidebar | overlay + scrim | nothing is clipped or unreachable at any width in the band |
|
||||
| **Desktop** | ≥ 900 | labelled sidebar | inline where it fits (see decision 2), else overlay | as Compact |
|
||||
|
||||
And one promise across all three: **no action is ever unreachable.**
|
||||
That is the sentence #69 asks for and it is the one the matrix exists
|
||||
to make checkable.
|
||||
|
||||
**400% zoom** keeps the meaning it already has: WCAG 1.4.10 names 320px
|
||||
as the reflow target, the phone band covers it, and
|
||||
`layout-overflow.spec.ts` already asserts a 320px viewport needs no
|
||||
sideways scrolling. What changes is that the *queue* must be part of
|
||||
that assertion — it is not today, and with the queue open at 320px the
|
||||
main panel is 0px wide, which no current test can see.
|
||||
|
||||
**The window minimum stays 800×600**, and its comment gets the real
|
||||
reason. The old mechanisms are gone, but the floor is still where the
|
||||
Compact band's chrome stops being comfortable, and lowering it would
|
||||
mean promising the desktop layout at sizes where only the phone layout
|
||||
works. The interesting consequence is decision 4.
|
||||
|
||||
---
|
||||
|
||||
## Decision 2 — the queue is an overlay when it cannot afford to be a column
|
||||
|
||||
**The rule.** The queue panel renders inline — in the flow, as today —
|
||||
only while
|
||||
|
||||
```
|
||||
viewport − sidebar − queueWidth ≥ 480
|
||||
```
|
||||
|
||||
and as an overlay with a scrim otherwise.
|
||||
|
||||
**Why it cannot be a media query**, which is the load-bearing half:
|
||||
the queue's width is *user state*. It is drag-resizable between 200 and
|
||||
500px and persisted (`--queue-width`, `MIN_WIDTH`/`MAX_WIDTH` in
|
||||
`queue-panel.ts`). A breakpoint at a fixed viewport width silently
|
||||
assumes the default 320, and is wrong by 180px for a user who has
|
||||
dragged the panel wide — in the direction that hurts, since a wider
|
||||
queue is exactly when the content can least afford it. So the mode is
|
||||
computed from the measured widths and published as an attribute, the
|
||||
way `data-active-view` already is, and the CSS keys off that.
|
||||
|
||||
**Why 480, honestly.** There is no cliff to derive it from. The track
|
||||
list rescales its columns continuously — at main widths from 900 down
|
||||
to 544 its `--grid-cols` shrink from 213px to 124px with
|
||||
`rowOverflow=0` throughout — and the album grid steps 3 columns to 2
|
||||
somewhere between 564 and 644 without breaking. So this is a judgement,
|
||||
anchored on two things: it keeps the *default* window (1100 wide, main
|
||||
= 580) inline, because the inline queue is a desktop affordance people
|
||||
choose and turning it into an overlay for the common case would be a
|
||||
regression in feel; and it puts every case measured as broken —
|
||||
900×600 at main=379, and every phone width — on the overlay side.
|
||||
1024×768 lands at main=504 and stays inline.
|
||||
|
||||
**The scrim is the other half of the issue's complaint** ("make the
|
||||
queue obviously an overlay *over* the content so it reads as something
|
||||
to close"). An overlay queue gets a scrim, closes on scrim click and on
|
||||
Escape, and returns focus to `#queue-button`.
|
||||
|
||||
**What must not change**: #55's Direction is explicit — one component,
|
||||
two mount points, do not fork it. The overlay is a *presentation* of
|
||||
the same `queue-panel`, so the roving tab stop, Alt+Arrow reorder, drag
|
||||
reorder, selection semantics and the `virtualizer.requestUpdate()` on
|
||||
selection and current-track change all come along untouched. This
|
||||
decision deliberately stops short of #55's detail-view mount, but it is
|
||||
the shape that makes it possible, and it unblocks it.
|
||||
|
||||
---
|
||||
|
||||
## Decision 3 — #69 is its own PR, and here is the finding that decides it
|
||||
|
||||
`page-header` **cannot collapse its own actions**, and that is not an
|
||||
effort estimate but a fact about the API. Actions arrive through
|
||||
`<slot name="actions">` as arbitrary light-DOM markup — Playlists slots
|
||||
a `<div class="header-actions">` of three `<button>`s with click
|
||||
handlers, drag handlers and a conditional class. A component cannot
|
||||
move another component's light-DOM children into a dropdown and keep
|
||||
their behaviour; there is nothing generic to render as a menu item.
|
||||
|
||||
So the overflow rule needs an *actions API* — hosts declaring
|
||||
`{icon, label, handler, priority}` data that `page-header` can render
|
||||
either as buttons or as menu items — which is a change to all three
|
||||
hosts that slot actions, not a rule added in one place. That is a
|
||||
different piece of work from this one, it is independently verifiable,
|
||||
and the desktop half of #69's symptom is removed by decision 2 anyway
|
||||
(the queue stops eating the header's width).
|
||||
|
||||
It therefore stays #69, gets the finding above recorded on it, and
|
||||
follows immediately after this. What *this* plan owes it is the
|
||||
promise in the matrix — no action unreachable at any supported size —
|
||||
and the measurement that the only offender today is Playlists.
|
||||
|
||||
**And the promise is not kept yet, which is the honest version of a
|
||||
claim this document made in its first draft.** "Decision 2 removes the
|
||||
desktop half of #69's symptom" was too strong. Measured after phase 2,
|
||||
at 900×600 on Playlists:
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| queue open | main 379px, **all three** actions clipped | main 700px, **one** clipped |
|
||||
| queue closed | main 700px, one clipped | unchanged |
|
||||
|
||||
So the queue's *contribution* is gone — open and closed are now
|
||||
identical, which is the whole of what this decision owed — and the
|
||||
residual "New Smart Playlist: 114/162px" is the header overflowing on
|
||||
its own, at a size the queue never touched. #69 is still a live defect
|
||||
at a supported size, and the matrix's promise is what will close it.
|
||||
|
||||
---
|
||||
|
||||
## Decision 4 — a very small window becomes the phone layout, not the mini-player
|
||||
|
||||
#24 asks whether a very small window should switch to the mini-player
|
||||
(#12) "or simply refuse to go there". Both options in the question are
|
||||
worse than the one the codebase already has.
|
||||
|
||||
**#12 is a second window, not a mode.** Its findings say so: v3
|
||||
supports multiple windows, `AlwaysOnTop` is a window *option*, and the
|
||||
frontend would need an entry branch mounting only the mini-player root
|
||||
for a second window loading the same bundle. Turning the main window
|
||||
into a mini-player at some width conflates the two: it would throw away
|
||||
the user's navigation state on a resize, and it puts the MPRIS question
|
||||
(#12's own open question — media controls are process-level and must
|
||||
not be per-window) on a code path that a drag can trigger by accident.
|
||||
|
||||
**And "refuses" is unnecessary, because the reflow already exists.**
|
||||
The phone band is real, tested, and reached by width alone — a desktop
|
||||
window narrowed below 600px already gets `bottom-nav` and the phone
|
||||
shell. That is a better answer than refusing: it is strictly more
|
||||
usable than a hard minimum, it costs nothing new, and it is the same
|
||||
code Android runs, so it stays exercised.
|
||||
|
||||
So: the main window reflows and never becomes a mini-player; #12 stays
|
||||
a separate always-on-top window and is not blocked by, or coupled to,
|
||||
this decision. The window minimum stays 800×600 for the reason in
|
||||
decision 1 — but the phone band is what happens below it, not a
|
||||
refusal, which is why the minimum is a comfort floor rather than a
|
||||
correctness one.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
1. **This document**, linked from #24, with the matrix reported on the
|
||||
issue and #55 told whether it is unblocked. *(no code)* — **done**
|
||||
2. **The queue's overlay mode** — computed mode attribute, scrim,
|
||||
Escape and scrim-click close, focus return. The inline path is
|
||||
unchanged above the threshold. — **done**
|
||||
3. **The window minimum's comment** — replace both stale reasons with
|
||||
the measured ones. No value change. — **done**
|
||||
4. **Verification**, below. Including the specs that must change
|
||||
because they assert the old behaviour. — **done**
|
||||
|
||||
#69 follows as its own branch; #55 became unblocked at phase 2.
|
||||
|
||||
## What landed, measured
|
||||
|
||||
Main panel width with the queue open, before and after:
|
||||
|
||||
| viewport | before | after | mode |
|
||||
|---|---|---|---|
|
||||
| 1280×800 | 759 | 759 | inline |
|
||||
| 1100×720 (default window) | 579 | 579 | inline |
|
||||
| 1024×768 | 503 | 503 | inline |
|
||||
| 900×600 | **379** | **700** | overlay |
|
||||
| 800×600 | 423 | 744 | overlay |
|
||||
| 390×780 | **69** | **390** | overlay |
|
||||
| 320×600 | **0** | **320** | overlay |
|
||||
|
||||
The scrim is perceptible but subtle on a dark ramp, which is worth
|
||||
knowing before someone "fixes" it: sampled from the screenshots at
|
||||
900×600, the main panel's background goes 33,37,41 → 18,20,23 and a
|
||||
row's text 242 → 133. It covers the **content area only** — not the
|
||||
sidebar or the transport — on purpose: the queue is not modal, and
|
||||
leaving the navigation live means the scrim reads as "this is over the
|
||||
content" (which is what #24 asked for) without pretending the rest of
|
||||
the app is unavailable.
|
||||
|
||||
## What #69 did with the promise, and one thing this plan got wrong
|
||||
|
||||
#69 landed on its own branch as decision 3 said it would, and the
|
||||
matrix's *no action is ever unreachable at any supported size* is now
|
||||
kept rather than promised. Measured on Playlists, actions clipped:
|
||||
|
||||
| viewport | before #24 | after #24 | after #69 |
|
||||
|---|---|---|---|
|
||||
| 900×600, queue open | all three | one (114/162px) | none |
|
||||
| 900×600, queue closed | one | one | none |
|
||||
| 800×600, queue closed | one (158/162px) | one | none |
|
||||
| 390×780 | all three | all three | none |
|
||||
| 320×600 | all three | all three | none |
|
||||
|
||||
The shape was the one decision 3 predicted — an actions API first, an
|
||||
overflow rule second — and all three hosts that slot actions migrated.
|
||||
|
||||
**What this document got wrong is smaller and worth keeping.** Decision
|
||||
1 says the header's minimum is a *comfort* floor and that only the
|
||||
queue and the actions compete for the header's width. They are not the
|
||||
only two: every child of that flex row was `flex-shrink: 0`, so
|
||||
whatever came last lost, and the actions come last. At 320px the sort
|
||||
control alone is 172px of the header — so with every action already
|
||||
collapsed into the menu, the *menu button* was 76px off the right edge.
|
||||
The promise was still broken with nothing left to collapse.
|
||||
|
||||
That is why #69 also had to decide what gives way: the title (which the
|
||||
navigation also states) and, below 600px, the word "Sort:" (which the
|
||||
direction arrow implies). Neither is an action, which is the rule the
|
||||
matrix actually encodes — **an action is a capability and everything
|
||||
else on that row is a label.**
|
||||
|
||||
## Verification, and what each tier cannot see
|
||||
|
||||
- `make ui-test` — the queue panel's mode logic is component-tier
|
||||
work and belongs there. It **cannot** see the shell: the threshold is
|
||||
computed from the sidebar and viewport, which do not exist in that
|
||||
tier.
|
||||
- `make e2e` — `layout-overflow.spec.ts` gains the queue-open case at
|
||||
every band (it has none today, which is why main=0px at 320px has
|
||||
never failed anything) and **gains 900×600**, since the minimum is
|
||||
not the worst case. `queue-toggle-state.spec.ts` and
|
||||
`phone-shell.spec.ts` both touch the panel and must be re-read before
|
||||
editing.
|
||||
- **Screenshots at every band, read by a human.** This is not optional
|
||||
here: `layout-overflow.spec.ts` asserts the *shell* needs no sideways
|
||||
scrolling and passes on a build whose album header clips its own
|
||||
buttons (measured this session at 390px; filed on #66). Clipping
|
||||
*inside* a component is invisible to it, and clipping is this issue.
|
||||
- `make ui-visual` **cannot help at all** — the component tier renders
|
||||
the token fallbacks, because the theme only reaches `:root` in the
|
||||
real app.
|
||||
- Accessible names via `page.getByRole(...)`, never a shadow-root
|
||||
query. A drawer with a scrim is exactly the shape that grows a
|
||||
nameless control, and this repo has shipped one three times.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Autotag (v1.3) — MusicBrainz Autotagger
|
||||
|
||||
> **Historical record.** Phases 008–010 shipped, and the scoring engine was subsequently overhauled (`recommend.go`, `rank.go`, `mixedbag.go`), which makes the 011/012 sections below stale in their details. What is actually left is **#90** (auto-accept and entry points) and **#91** (settings, and a way back from the dismissed file-write warning).
|
||||
|
||||
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008–012), each depending on the prior one.
|
||||
|
||||
| Phase | Title | Status |
|
||||
+214
-97
@@ -7,31 +7,34 @@
|
||||
* other events arrive from Go whenever they arrive. An assertion that
|
||||
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
||||
*
|
||||
* Three things it provides on `window.__yjEvents`:
|
||||
* Four things it provides on `window.__yjEvents`:
|
||||
*
|
||||
* record every backend -> frontend event, in order, with payloads
|
||||
* wait a promise that settles on a matching event (or rejects
|
||||
* with the list of events that *did* arrive, which is the
|
||||
* single most useful failure message this harness can give)
|
||||
* call a bound Go method that is guaranteed to settle: a binding
|
||||
* invoked with wrong argument types makes the backend log
|
||||
* "error parsing arguments" and never fire the callback, so
|
||||
* the in-page promise hangs forever. Timing out here fixes
|
||||
* that once instead of in every eval.
|
||||
* call a bound Go method, by name, over the runtime's own HTTP
|
||||
* endpoint — no dependence on the app's bundle
|
||||
* bindings every binding call the *app* made, which is what turns
|
||||
* "did that refetch the library" from an inference into a
|
||||
* fact (e2e/perf/measure.mjs labels and reads these)
|
||||
*
|
||||
* WHERE IT HOOKS. Not EventsOn. Every backend event enters the page
|
||||
* at exactly one place — wails' ipc_websocket.js does
|
||||
* WHERE IT HOOKS. Two places, and neither is `EventsOn`.
|
||||
*
|
||||
* case "n": window.wails.EventsNotify(message)
|
||||
* Inbound, `window._wails.dispatchWailsEvent`: v3's runtime assigns it
|
||||
* at module scope and it is the single point every backend event enters
|
||||
* the page through, so wrapping it captures all 46 whether or not the
|
||||
* app subscribes to them. The runtime does
|
||||
* `window._wails = window._wails || {}`, so this script creates that
|
||||
* object first and puts an accessor on the *property*, wrapping at
|
||||
* assignment time — v2 needed the accessor on `window` itself, because
|
||||
* there the whole object was replaced.
|
||||
*
|
||||
* and EventsNotify fans out to listeners from there. Wrapping that
|
||||
* single choke point captures all 46 events whether or not the app
|
||||
* subscribes to them, and needs one wrap rather than 46.
|
||||
*
|
||||
* `window.wails` does not exist yet when this script runs, so we install
|
||||
* an accessor on `window` and wrap at assignment time (wails' main.js
|
||||
* does a plain `window.wails = {...}`), then collapse the accessor back
|
||||
* to a data property so nothing downstream can tell.
|
||||
* Outbound, `fetch`: v3 routes every runtime call — binding calls, event
|
||||
* emits, window and dialog calls — through one POST to /wails/runtime.
|
||||
* There is no global to wrap the way v2's `window.runtime` could be, and
|
||||
* this is better anyway: it sees calls from any module, needs no walk of
|
||||
* an object graph, and cannot miss one made before the harness looked.
|
||||
*
|
||||
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
|
||||
* the next, so a recorder that re-registers double-counts. Tests call
|
||||
@@ -44,8 +47,27 @@
|
||||
|
||||
const LIMIT = 2000;
|
||||
|
||||
// Every bound service in this app lives under this Go module path,
|
||||
// so specs name a binding the short way — 'queue.Queue.GetState' —
|
||||
// and this is what makes that the same thing the backend calls
|
||||
// 'yellowjacket/backend/queue.Queue.GetState'.
|
||||
const FQN_PREFIX = "yellowjacket/backend/";
|
||||
|
||||
// The runtime's own object and method ids (objectNames in
|
||||
// @wailsio/runtime): 0 is Call, 3 is Events, and method 0 on each is
|
||||
// CallBinding and Emit respectively.
|
||||
const OBJECT_CALL = 0;
|
||||
const OBJECT_EVENTS = 3;
|
||||
|
||||
// Captured before the wrap below, and used for the harness's own
|
||||
// calls: `__yjEvents.call` is this file talking to the backend, not
|
||||
// the app, and counting it would make "did that action refetch the
|
||||
// library" answer for the question as well as the app.
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
|
||||
let seq = 0;
|
||||
const log = [];
|
||||
const bindings = [];
|
||||
const waiters = new Set();
|
||||
|
||||
const summarize = () => {
|
||||
@@ -56,6 +78,25 @@
|
||||
return counts;
|
||||
};
|
||||
|
||||
/*
|
||||
* `data` is recorded as the argument list Go emitted, which is the
|
||||
* shape every spec reads (`ev.data[0]`).
|
||||
*
|
||||
* v3's EventManager.Emit packs a variadic call into one field: no
|
||||
* arguments is null, one is the value itself, more than one is the
|
||||
* slice. Unpacking that back into a list is exact except for a
|
||||
* single argument that is itself an array, which is indistinguishable
|
||||
* from several arguments — an ambiguity v3 introduced and no
|
||||
* assertion here depends on, since nothing in backend/events emits
|
||||
* more than one value.
|
||||
*/
|
||||
const argsOf = (data) => {
|
||||
if (data === null || data === undefined) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(data) ? data : [data];
|
||||
};
|
||||
|
||||
const record = (name, data, dir) => {
|
||||
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
|
||||
log.push(entry);
|
||||
@@ -89,7 +130,7 @@
|
||||
};
|
||||
|
||||
const api = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
|
||||
/** Every recorded event, oldest first. */
|
||||
get log() {
|
||||
@@ -101,10 +142,30 @@
|
||||
return seq;
|
||||
},
|
||||
|
||||
/** Drop the buffer. Does NOT touch the recorder or waiters. */
|
||||
/**
|
||||
* Every binding call the app made, oldest first. Each is
|
||||
* { methodID, methodName, start, ms, bytes } — the id is what the
|
||||
* generated bindings send, and turning it back into a name is
|
||||
* e2e/perf/measure.mjs's job, which derives the map from
|
||||
* frontend/bindings/.
|
||||
*/
|
||||
get bindings() {
|
||||
return bindings.slice();
|
||||
},
|
||||
|
||||
/**
|
||||
* Read the size of every binding response. Off by default: it
|
||||
* costs a clone-and-read of each body, which only a measurement
|
||||
* wants to pay. With it off, `bytes` is the Content-Length when
|
||||
* the server sent one and -1 otherwise.
|
||||
*/
|
||||
measureBytes: false,
|
||||
|
||||
/** Drop the buffers. Does NOT touch the recorder or waiters. */
|
||||
reset() {
|
||||
const n = log.length;
|
||||
log.length = 0;
|
||||
bindings.length = 0;
|
||||
return n;
|
||||
},
|
||||
|
||||
@@ -177,13 +238,11 @@
|
||||
async ready(timeoutMs) {
|
||||
const deadline = Date.now() + (timeoutMs || 15000);
|
||||
for (;;) {
|
||||
if (window.go?.queue?.Queue?.GetState) {
|
||||
try {
|
||||
await api.call("queue.Queue.GetState", [], 2000);
|
||||
return true;
|
||||
} catch {
|
||||
/* backend not up yet */
|
||||
}
|
||||
try {
|
||||
await api.call("queue.Queue.GetState", [], 2000);
|
||||
return true;
|
||||
} catch {
|
||||
/* backend not up yet */
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error("__yjEvents.ready timed out");
|
||||
@@ -193,38 +252,66 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Call a bound Go method by dotted path, with a timeout.
|
||||
* Call a bound Go method by dotted path.
|
||||
*
|
||||
* await __yjEvents.call('player.Player.SetVolume', [42])
|
||||
*
|
||||
* A binding called with the wrong argument types never fires its
|
||||
* callback — the reason appears only in .dev/app.log. Without a
|
||||
* timeout the caller waits forever; with one it gets told where
|
||||
* to look.
|
||||
* This posts to the runtime's own endpoint rather than reaching
|
||||
* into the page for a binding function, because v3 has no
|
||||
* `window.go` and the generated bindings are ordinary bundled
|
||||
* modules an initScript cannot import. It calls *by name*, which
|
||||
* the backend resolves the same way it resolves the id the
|
||||
* bundle sends.
|
||||
*
|
||||
* v3 rejects a bad call rather than silently never firing its
|
||||
* callback the way v2 did — wrong argument types come back as a
|
||||
* TypeError naming the argument, an unknown method as a
|
||||
* ReferenceError. The timeout below is therefore a backstop for
|
||||
* a genuinely hung request, not the mechanism that makes a
|
||||
* mistake visible.
|
||||
*/
|
||||
call(path, args, timeoutMs) {
|
||||
const parts = String(path).split(".");
|
||||
let fn = window.go;
|
||||
for (const p of parts) {
|
||||
fn = fn?.[p];
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
return Promise.reject(
|
||||
new Error(`__yjEvents.call: no such binding: ${path}`),
|
||||
);
|
||||
}
|
||||
const request = nativeFetch("/wails/runtime", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-wails-client-id": window._wails?.clientId ?? "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
object: OBJECT_CALL,
|
||||
method: 0,
|
||||
args: {
|
||||
"call-id": `yj-${Math.random().toString(36).slice(2)}`,
|
||||
methodName: FQN_PREFIX + String(path),
|
||||
args: args || [],
|
||||
},
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
const type = res.headers.get("Content-Type") || "";
|
||||
const json = type.includes("application/json");
|
||||
|
||||
if (!res.ok) {
|
||||
const body = json ? await res.json() : { message: await res.text() };
|
||||
throw new Error(
|
||||
`__yjEvents.call(${path}) failed: ` +
|
||||
`${body.kind || "Error"}: ${body.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return json ? res.json() : res.text();
|
||||
});
|
||||
|
||||
return Promise.race([
|
||||
Promise.resolve(fn(...(args || []))),
|
||||
request,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`__yjEvents.call(${path}) did not settle in ` +
|
||||
`${timeoutMs || 10000}ms — almost always wrong ` +
|
||||
`argument types; check .dev/app.log for ` +
|
||||
`"error parsing arguments"`,
|
||||
`${timeoutMs || 10000}ms — the runtime endpoint ` +
|
||||
`hung, which is not how a bad argument fails; ` +
|
||||
`check .dev/app.log`,
|
||||
),
|
||||
),
|
||||
timeoutMs || 10000,
|
||||
@@ -241,62 +328,92 @@
|
||||
writable: false,
|
||||
});
|
||||
|
||||
// Wrap `obj[method]` once, routing every invocation through `tap`.
|
||||
const wrap = (obj, method, tap) => {
|
||||
const original = obj[method];
|
||||
if (typeof original !== "function" || original.__yjWrapped) {
|
||||
return;
|
||||
}
|
||||
const wrapped = function (...args) {
|
||||
try {
|
||||
tap(args);
|
||||
} catch {
|
||||
/* a broken recorder must never break the app */
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
wrapped.__yjWrapped = true;
|
||||
obj[method] = wrapped;
|
||||
};
|
||||
// ── Inbound ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The runtime keeps whatever `window._wails` already is, so creating
|
||||
// it here and defining an accessor on the one property we care about
|
||||
// means the wrap happens the moment the runtime module is evaluated.
|
||||
window._wails = window._wails || {};
|
||||
|
||||
// Install an accessor that wraps on first assignment, then collapses
|
||||
// back into an ordinary property.
|
||||
const hookOnAssign = (name, onAssign) => {
|
||||
let value;
|
||||
Object.defineProperty(window, name, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => value,
|
||||
set: (v) => {
|
||||
value = v;
|
||||
let dispatch;
|
||||
|
||||
Object.defineProperty(window._wails, "dispatchWailsEvent", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => dispatch,
|
||||
set: (fn) => {
|
||||
dispatch = function (event) {
|
||||
try {
|
||||
onAssign(v);
|
||||
record(event?.name, argsOf(event?.data), "in");
|
||||
} catch {
|
||||
/* ditto */
|
||||
/* a broken recorder must never break the app */
|
||||
}
|
||||
Object.defineProperty(window, name, {
|
||||
value: v,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
});
|
||||
},
|
||||
return fn.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Outbound ─────────────────────────────────────────────────────
|
||||
//
|
||||
// One POST per runtime call. Only two of the thirteen object ids
|
||||
// are interesting here; the rest (window, dialogs, clipboard) pass
|
||||
// through untouched and unrecorded.
|
||||
window.fetch = function (input, init) {
|
||||
let call = null;
|
||||
|
||||
try {
|
||||
// The runtime passes a **URL object**, not a string — it
|
||||
// builds `new URL(runtimeURL())` — and a URL has no `.url`,
|
||||
// only a Request does. Reading the wrong one matched
|
||||
// nothing and recorded no calls at all, which looks
|
||||
// identical to an app that made none.
|
||||
const url =
|
||||
input && typeof input === "object" && "url" in input
|
||||
? input.url
|
||||
: String(input ?? "");
|
||||
|
||||
if (
|
||||
url.includes("/wails/runtime") &&
|
||||
init?.method === "POST" &&
|
||||
typeof init.body === "string"
|
||||
) {
|
||||
const body = JSON.parse(init.body);
|
||||
|
||||
if (body.object === OBJECT_EVENTS && body.method === 0) {
|
||||
record(body.args?.name, argsOf(body.args?.data), "out");
|
||||
} else if (body.object === OBJECT_CALL && body.method === 0) {
|
||||
call = {
|
||||
methodID: body.args?.methodID ?? null,
|
||||
methodName: body.args?.methodName ?? null,
|
||||
start: performance.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ditto */
|
||||
}
|
||||
|
||||
const response = nativeFetch(input, init);
|
||||
|
||||
if (!call) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return response.then(async (res) => {
|
||||
try {
|
||||
call.ms = performance.now() - call.start;
|
||||
call.bytes = api.measureBytes
|
||||
? (await res.clone().text()).length
|
||||
: Number(res.headers.get("Content-Length") ?? -1);
|
||||
bindings.push(call);
|
||||
if (bindings.length > LIMIT) {
|
||||
bindings.splice(0, bindings.length - LIMIT);
|
||||
}
|
||||
} catch {
|
||||
/* ditto */
|
||||
}
|
||||
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
// Inbound: every backend -> frontend event.
|
||||
hookOnAssign("wails", (w) => {
|
||||
wrap(w, "EventsNotify", ([message]) => {
|
||||
const parsed = JSON.parse(message);
|
||||
record(parsed.name, parsed.data, "in");
|
||||
});
|
||||
});
|
||||
|
||||
// Outbound: events the frontend emits, so a flow that round-trips
|
||||
// through Go is legible from one buffer.
|
||||
hookOnAssign("runtime", (r) => {
|
||||
wrap(r, "EventsEmit", (args) => {
|
||||
record(args[0], args.slice(1), "out");
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
+63
-16
@@ -1,6 +1,31 @@
|
||||
# semantic-release configuration
|
||||
# Runs on main branch pushes to auto-determine version from conventional commits.
|
||||
# Creates a git tag + GitHub Release draft; a separate workflow builds binaries.
|
||||
# semantic-release configuration.
|
||||
#
|
||||
# Run by hand from .gitea/workflows/release.yml, which has no push
|
||||
# trigger: determine the version from the Conventional Commits since the
|
||||
# last tag, write the changelog, push the tag, and create the Gitea
|
||||
# release. A release is a shipment rather than a merge, and the commits
|
||||
# accumulate until someone says so -- this file needs to know nothing
|
||||
# about that, because reading everything since the last tag is what it
|
||||
# already did.
|
||||
#
|
||||
# `branches` is main and only main. A `prerelease: true` channel is the
|
||||
# obvious next edit here and is the one to think twice about: all four
|
||||
# publishing workflows trigger on `v*`, which matches `v0.4.0-beta.1`.
|
||||
# They carry a prerelease guard now, so the failure is a clean skip
|
||||
# rather than a beta in a public tap -- but they are four separate files
|
||||
# and this is the line that would turn them on.
|
||||
#
|
||||
# **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:
|
||||
- main
|
||||
|
||||
@@ -63,19 +88,41 @@ plugins:
|
||||
section: Build
|
||||
hidden: true
|
||||
|
||||
# Write CHANGELOG.md.
|
||||
# Render the notes to a file.
|
||||
#
|
||||
# **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"
|
||||
- changelogFile: CHANGELOG.md
|
||||
- changelogFile: .release-notes.md
|
||||
changelogTitle: "# Release notes"
|
||||
|
||||
# Commit the changelog back to the repo.
|
||||
- - "@semantic-release/git"
|
||||
- assets:
|
||||
- CHANGELOG.md
|
||||
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
# Create the Gitea release, whose body is that section.
|
||||
# `publish` runs after `prepare`, so the tag already exists by here.
|
||||
- - "@semantic-release/exec"
|
||||
- publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}"
|
||||
|
||||
# **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
|
||||
|
||||
+20
-371
@@ -1,377 +1,26 @@
|
||||
## [1.3.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.3...v1.3.0) (2026-03-20)
|
||||
# Changelog
|
||||
|
||||
### Features
|
||||
The changelog is the releases page:
|
||||
|
||||
* **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))
|
||||
<https://git.ljones.me/yonlu/yellowjacket/releases>
|
||||
|
||||
### Bug Fixes
|
||||
Every release there is generated from the Conventional Commits it
|
||||
contains, by `.gitea/workflows/release.yml`. Each one carries its notes
|
||||
as its body, grouped by change type, with a link to the commit behind
|
||||
every line.
|
||||
|
||||
* **09-05:** emit VolumeChanged event and persist state in ChangeVolume and MuteToggle ([bb3fd20](https://github.com/onion-4-dinner/yellowjacket/commit/bb3fd204f0895f357a14479b40754f397aae74c4))
|
||||
* **10-01:** move library_id index to migration 6 to fix existing DB startup ([75b2a34](https://github.com/onion-4-dinner/yellowjacket/commit/75b2a349ebd6fada5cbc92bfae9854cc2cd53c63))
|
||||
* **12-02:** claim orphaned tracks when adding library with matching path ([f60b6b5](https://github.com/onion-4-dinner/yellowjacket/commit/f60b6b525546ef77a3329fe92f03f336b7435a0e))
|
||||
* **12-02:** count failed saves as skipped so scan progress bar advances ([b36e472](https://github.com/onion-4-dinner/yellowjacket/commit/b36e472212957ff089f4f5d35f3978a754e23502))
|
||||
* **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))
|
||||
That workflow is **run by hand**, so a release holds everything merged
|
||||
since the last one rather than one PR's worth. It used to fire on every
|
||||
push to `main`, which made a version per merged PR (issue #115).
|
||||
|
||||
### Performance
|
||||
**This file is not generated and is not a copy of that.** `main` is a
|
||||
protected branch, so nothing pushes a changelog commit back to it — and a
|
||||
file that claimed to be a changelog while silently never updating would
|
||||
be worse than no file at all. `make release-dry` prints what a release
|
||||
run would cut right now, and the workflow's own `dry_run` input answers
|
||||
the same question from CI.
|
||||
|
||||
* **12-02:** increase scan batch size from 50 to 300 ([21ea71e](https://github.com/onion-4-dinner/yellowjacket/commit/21ea71e2575d76258bd81d89ab8ac883aa3bed36))
|
||||
* **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))
|
||||
History before `v0.0.1` is in `git log`. The versions before it were cut
|
||||
by hand and are not on the releases page; the entries this file used to
|
||||
hold were generated against a GitHub remote this project no longer has,
|
||||
and every link in them was dead.
|
||||
|
||||
@@ -7,17 +7,25 @@ LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'
|
||||
# point elsewhere (or unset it there to share the real user dirs).
|
||||
DEV_YJ_HOME ?= $(HOME)/.local/share/yellowjacket-dev
|
||||
|
||||
# `wails3 dev` and `wails3 task` run the scaffold's Taskfile tree, which
|
||||
# invokes `wails3` by bare name. The CLI is a vendored Go tool, so the
|
||||
# name only exists on PATH via this shim -- see scripts/toolbin/wails3.
|
||||
# Without it every supervisor target dies with
|
||||
# "/bin/sh: wails3: command not found" at its first sub-task.
|
||||
TOOLBIN := $(CURDIR)/scripts/toolbin
|
||||
|
||||
dev: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||
|
||||
dev-debug: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||
|
||||
# ── Headless harness (plan 005) ──────────────────────────────────────
|
||||
# The same dev server `make dev` runs, minus the blocking GTK window:
|
||||
# Xvfb gives it the display it insists on, and the script returns once
|
||||
# :34115 answers. This is the only entry point an agent can use, since
|
||||
# every other one blocks the terminal forever.
|
||||
# The same app `make dev` runs, minus the window: v3's `-tags server`
|
||||
# is a first-class headless mode that needs no display at all, so the
|
||||
# Xvfb this used to require is gone. The script returns once :34115
|
||||
# answers. This is the only entry point an agent can use, since every
|
||||
# other one blocks the terminal forever.
|
||||
dev-headless: ## Start the app headless in the background (SEED=<name> to seed)
|
||||
@./scripts/dev-headless.sh $(if $(SEED),--seed $(SEED),) $(HEADLESS_ARGS)
|
||||
|
||||
@@ -30,6 +38,64 @@ dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
|
||||
dev-logs: ## Tail the headless app log
|
||||
@tail -f .dev/app.log
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
|
||||
# android-tier.md for which of these to reach for and why a failure #
|
||||
# here looks like nothing at all. #
|
||||
# ---------------------------------------------------------------- #
|
||||
|
||||
# The NDK is pinned: r26d is what the pipeline is built and checked
|
||||
# against, and newer NDKs have broken Wails' Android build before.
|
||||
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
|
||||
# does not — hence the separate default.
|
||||
ANDROID_SDK ?= $(HOME)/Android/Sdk
|
||||
ANDROID_NDK ?= /opt/android-ndk
|
||||
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
|
||||
|
||||
# `package`, not `package:fat`: x86_64 Android cannot run this app at
|
||||
# all (modernc's raw lstat vs Android's seccomp -- see
|
||||
# android-tier.md), so the second ABI was ~31 MB that could not run
|
||||
# anywhere. app/build.gradle's abiFilters says the same thing to
|
||||
# Gradle; both have to agree or the .so is built and then dropped.
|
||||
android: build-frontend ## Build the arm64 APK into bin/
|
||||
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
|
||||
|
||||
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
|
||||
|
||||
android-emulator: ## Boot the emulator headless in the background and wait for it
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
|
||||
|
||||
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
|
||||
|
||||
android-install: ## Install bin/yellowjacket.apk onto the running emulator
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
|
||||
|
||||
android-launch: ## Force-stop, clear logcat, and start the app
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
|
||||
|
||||
android-logs: ## Tail logcat, filtered to the app's own tags
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
|
||||
|
||||
# The only tier that can see the platform is the one you can look at.
|
||||
android-screenshot: ## Grab the device screen (OUT=<path>)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
|
||||
|
||||
# The page's own answer, from the engine that is really rendering it.
|
||||
# Needs the debug build installed (it is a sibling id, so it does not
|
||||
# disturb the release app): see scripts/android-eval.mjs.
|
||||
android-inspect: ## Forward the device WebView's devtools socket
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
|
||||
|
||||
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
|
||||
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
|
||||
|
||||
# "Did it start" is the wrong question — a crash-looping app starts
|
||||
# several times a second. This asserts the *same pid* is still there.
|
||||
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
|
||||
|
||||
# Seeds are produced by *running the app* — driving the real AddLibrary
|
||||
# binding and waiting for the real scan — never by hand-writing a
|
||||
# config.toml and DB rows. A hand-built seed is a second description
|
||||
@@ -100,24 +166,30 @@ ui-visual-update: ## Re-record the screenshot baselines
|
||||
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
||||
@cd frontend && pnpm install && npx playwright install chromium
|
||||
|
||||
# frontend/wailsjs is generated by `wails`, NOT by `go generate`, so the
|
||||
# pre-commit codegen check does not cover it: a renamed Go struct field
|
||||
# currently surfaces at runtime, in a window. File modes are ignored
|
||||
# because `wails generate module` rewrites the runtime files as 755.
|
||||
bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings
|
||||
# Bindings are generated by `wails3`, NOT by `go generate`, so the
|
||||
# pre-commit codegen check does not cover them: a renamed Go struct
|
||||
# field would otherwise surface at runtime, inside a window.
|
||||
bindings-check: ## Fail if the generated bindings are stale
|
||||
@./scripts/bindings-check.sh
|
||||
|
||||
# Two CSS traps that report a long way from their cause, or not at all.
|
||||
# A backtick inside a comment in a css`` literal ends the literal, and
|
||||
# what you get back is a type error about CSSResult, or every test in
|
||||
# the suite failing to import. Four sessions, three plans. Instant.
|
||||
# the suite failing to import. Four sessions, three plans. And a nested
|
||||
# rule starting with an element name is dropped by the device's
|
||||
# Chrome 113 in silence -- no tier here runs an engine that can see it.
|
||||
# Instant.
|
||||
.PHONY: css-check
|
||||
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
||||
css-check: ## Fail on a css`` literal ended early by a backtick, or a nested rule needing an &
|
||||
@cd frontend && node scripts/check-css-literals.mjs
|
||||
@cd frontend && node scripts/check-css-nesting.mjs
|
||||
|
||||
# .pi/ documents commands, and a skill that documents a command wrongly
|
||||
# is worse than no skill: an agent runs it confidently. Every command
|
||||
# in there is a make target on purpose, so this is checkable.
|
||||
skill-check: ## Fail if .pi/ documents a make target that does not exist
|
||||
# .pi/ and CLAUDE.md document commands, and a doc that documents a
|
||||
# command wrongly is worse than no doc: an agent runs it confidently.
|
||||
# Every command in them is a make target on purpose, so this is
|
||||
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
|
||||
# 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
|
||||
|
||||
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
|
||||
@@ -125,17 +197,46 @@ skill-check: ## Fail if .pi/ documents a make target that does not exist
|
||||
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
||||
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
||||
|
||||
bindings: ## Regenerate frontend/wailsjs from the bound Go structs
|
||||
go tool wails generate module -tags webkit2_41
|
||||
@chmod 644 frontend/wailsjs/runtime/runtime.js \
|
||||
frontend/wailsjs/runtime/runtime.d.ts \
|
||||
frontend/wailsjs/runtime/package.json
|
||||
# What running the release workflow now would ship, without shipping it.
|
||||
# Reads the same .releaserc.yml CI does, so "why did that not cut a
|
||||
# version" is answerable locally instead of by pushing and watching.
|
||||
# Needs no credentials: --dry-run neither tags nor publishes.
|
||||
#
|
||||
# release.yml is dispatch-only, so this answers the question that
|
||||
# actually gets asked now -- what has accumulated since the last tag --
|
||||
# rather than what one merge would have done. The workflow's own
|
||||
# `dry_run` input is the same answer from the runner, against whatever
|
||||
# main points at rather than the working tree.
|
||||
#
|
||||
# The pins must stay identical to release.yml's, which is where the note
|
||||
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
||||
# release notes come out empty with everything green.
|
||||
release-dry: ## Print the version a release run would cut right now
|
||||
@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
|
||||
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
|
||||
# constant prefix, so a call site imports '@go/library/library.js'.
|
||||
#
|
||||
# No -f flag: the tag set is the default one, deliberately, because the
|
||||
# generator is a static analyser that sees only the configuration it is
|
||||
# told about and the one that matters is the one users run. See
|
||||
# scripts/bindings-check.sh for why the other two do not apply.
|
||||
bindings: ## Regenerate frontend/bindings from the bound Go services
|
||||
go tool wails3 generate bindings -clean=true -ts -i
|
||||
|
||||
.PHONY: dev-headless dev-headless-fresh dev-stop dev-logs \
|
||||
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
||||
perf perf-compare \
|
||||
ui-test ui-watch ui-visual ui-visual-update ui-setup \
|
||||
bindings bindings-check skill-check commit-check
|
||||
bindings bindings-check skill-check commit-check release-dry
|
||||
|
||||
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
|
||||
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
|
||||
@@ -158,7 +259,7 @@ fresh-install: setup generate clean
|
||||
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
||||
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
||||
esac; \
|
||||
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||
|
||||
# Named, persistent sandboxes: `make sandbox foo` runs dev against
|
||||
# $(FRESH_HOME_BASE)/yellowjacket-sandbox-foo, creating it on first use
|
||||
@@ -218,7 +319,7 @@ sandbox-%: setup generate clean
|
||||
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
||||
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
||||
esac; \
|
||||
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||
|
||||
sandboxes: ## List existing named sandboxes
|
||||
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
|
||||
@@ -228,10 +329,10 @@ sandboxes: ## List existing named sandboxes
|
||||
.PHONY: sandbox sandbox-rm sandboxes
|
||||
|
||||
build-dev: generate
|
||||
go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)"
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build DEV=true
|
||||
|
||||
build-prod: generate
|
||||
go tool wails build -tags webkit2_41 -clean -upx -ldflags "-s -w $(LDFLAGS)"
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build
|
||||
|
||||
build-frontend:
|
||||
cd frontend && pnpm install && pnpm build
|
||||
@@ -272,14 +373,16 @@ bulkdata-clean: ## Delete the bulk measurement library
|
||||
.PHONY: testdata testdata-force testdata-clean bulkdata bulkdata-clean
|
||||
|
||||
# The tag sets must match `make test` exactly, or lint is checking three
|
||||
# configurations that nothing builds. webkit2_41 is not optional: without
|
||||
# it wails resolves webkit2gtk-4.0, which Ubuntu 24.04 no longer ships, so
|
||||
# the `dev` pass (wails' own app_dev.go is dev-tagged and pulls in the 4.0
|
||||
# assetserver) fails to typecheck anywhere but Arch.
|
||||
# configurations that nothing builds. The webkit2_41 tag these all used
|
||||
# to carry is gone with v2: v3 builds against GTK4 + WebKitGTK 6.0 by
|
||||
# default, which both Arch and ubuntu:24.04 ship, so the default tag set
|
||||
# is the one that ships. (`-tags gtk3` still exists as an escape hatch
|
||||
# for a machine without webkitgtk-6.0; it is not what CI or releases
|
||||
# build.)
|
||||
lint:
|
||||
go tool golangci-lint run --build-tags webkit2_41
|
||||
go tool golangci-lint run --build-tags "webkit2_41 indexbuild"
|
||||
go tool golangci-lint run --build-tags "webkit2_41 dev"
|
||||
go tool golangci-lint run
|
||||
go tool golangci-lint run --build-tags indexbuild
|
||||
go tool golangci-lint run --build-tags dev
|
||||
|
||||
# Three passes: the app build, the `indexbuild` build that adds the
|
||||
# CI-only dump importer, and the `dev` build that adds profiling and
|
||||
@@ -287,12 +390,12 @@ lint:
|
||||
# exercise backend/explore/dump*.go, cmd/indexbuild or the harness
|
||||
# control surface at all.
|
||||
test: testdata
|
||||
go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
|
||||
go test -tags "webkit2_41 indexbuild" -race -count=1 -timeout 300s \
|
||||
go test -race -count=1 -timeout 120s ./...
|
||||
go test -tags indexbuild -race -count=1 -timeout 300s \
|
||||
./backend/explore/... ./cmd/...
|
||||
# backend/testctl only exists under the `dev` tag, so the pass above
|
||||
# does not compile it, let alone run it.
|
||||
go test -tags "webkit2_41 dev" -race -count=1 -timeout 120s \
|
||||
go test -tags dev -race -count=1 -timeout 120s \
|
||||
./backend/testctl/...
|
||||
|
||||
vulncheck:
|
||||
|
||||
@@ -78,16 +78,25 @@ YellowJacket is built with [Go](https://go.dev/) and a
|
||||
| Go | 1.25+ |
|
||||
| Node.js | 22+ |
|
||||
| pnpm | 10+ |
|
||||
| Wails CLI | v2 (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`) |
|
||||
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
|
||||
|
||||
On Linux, install the system libraries Wails needs:
|
||||
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
|
||||
to install globally; `make setup` fetches it with the rest of the tooling.
|
||||
|
||||
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
|
||||
WebKitGTK 6.0 by default:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libasound2-dev libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
|
||||
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
|
||||
```
|
||||
|
||||
macOS and Windows need no extra system packages. Run `wails doctor` to check your
|
||||
environment.
|
||||
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
|
||||
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
|
||||
release builds.
|
||||
|
||||
macOS and Windows need no extra system packages. Run `go tool wails3 doctor` to
|
||||
check your environment.
|
||||
|
||||
**Build**
|
||||
|
||||
@@ -97,5 +106,8 @@ make dev # run with hot-reload
|
||||
make build-prod # produce a release binary
|
||||
```
|
||||
|
||||
More detail for contributors lives in
|
||||
[`docs/dev/overview.md`](./docs/dev/overview.md) and [`CLAUDE.md`](./CLAUDE.md).
|
||||
More detail for contributors lives in [`CLAUDE.md`](./CLAUDE.md) — the
|
||||
architecture, the conventions and the reasons behind them. What is
|
||||
being worked on is [the issue
|
||||
tracker](https://git.ljones.me/yonlu/yellowjacket/issues); #73 is the
|
||||
roadmap.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
APP_NAME: "yellowjacket"
|
||||
BIN_DIR: "bin"
|
||||
PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "pnpm"}}'
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
# Target OS for build/package/run. Defaults to the host OS, and is overridden
|
||||
# by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The
|
||||
# tasks below dispatch to the matching platform Taskfile via this variable.
|
||||
GOOS: '{{.GOOS | default OS}}'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
android: ./build/android/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:build"
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:package"
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:run"
|
||||
|
||||
dev:
|
||||
summary: Runs the application in development mode
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
setup:docker:
|
||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||
cmds:
|
||||
- task: common:setup:docker
|
||||
|
||||
build:server:
|
||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||
cmds:
|
||||
- task: common:build:server
|
||||
|
||||
run:server:
|
||||
summary: Runs the application in server mode
|
||||
cmds:
|
||||
- task: common:run:server
|
||||
|
||||
build:docker:
|
||||
summary: Builds a Docker image for server mode deployment
|
||||
cmds:
|
||||
- task: common:build:docker
|
||||
|
||||
run:docker:
|
||||
summary: Builds and runs the Docker image
|
||||
cmds:
|
||||
- task: common:run:docker
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build android
|
||||
|
||||
// The write itself, and nothing else. Everything decidable off a phone
|
||||
// is in androidlog.go; see the package comment for why.
|
||||
|
||||
package androidlog
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -llog
|
||||
#include <stdlib.h>
|
||||
#include <android/log.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// The priorities in androidlog.go are android/log.h's own values, and
|
||||
// these are what says so. A constant expression that would be negative
|
||||
// does not compile as a uint, so a renumbered header fails the build
|
||||
// here rather than logging everything at the wrong severity -- which is
|
||||
// the failure that would otherwise be invisible, since logcat would
|
||||
// happily print whatever number it was handed.
|
||||
const (
|
||||
_ = uint(C.ANDROID_LOG_VERBOSE - PrioVerbose)
|
||||
_ = uint(PrioVerbose - C.ANDROID_LOG_VERBOSE)
|
||||
_ = uint(C.ANDROID_LOG_DEBUG - PrioDebug)
|
||||
_ = uint(PrioDebug - C.ANDROID_LOG_DEBUG)
|
||||
_ = uint(C.ANDROID_LOG_INFO - PrioInfo)
|
||||
_ = uint(PrioInfo - C.ANDROID_LOG_INFO)
|
||||
_ = uint(C.ANDROID_LOG_WARN - PrioWarn)
|
||||
_ = uint(PrioWarn - C.ANDROID_LOG_WARN)
|
||||
_ = uint(C.ANDROID_LOG_ERROR - PrioError)
|
||||
_ = uint(PrioError - C.ANDROID_LOG_ERROR)
|
||||
_ = uint(C.ANDROID_LOG_FATAL - PrioFatal)
|
||||
_ = uint(PrioFatal - C.ANDROID_LOG_FATAL)
|
||||
)
|
||||
|
||||
// New returns the handler main() installs on Android.
|
||||
func New(opts *slog.HandlerOptions) slog.Handler {
|
||||
return NewHandler(opts, write)
|
||||
}
|
||||
|
||||
// write hands one line to liblog.
|
||||
func write(prio int, tag, msg string) {
|
||||
cTag := C.CString(tag)
|
||||
defer C.free(unsafe.Pointer(cTag))
|
||||
|
||||
cMsg := C.CString(msg)
|
||||
defer C.free(unsafe.Pointer(cMsg))
|
||||
|
||||
C.__android_log_write(C.int(prio), cTag, cMsg)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Package androidlog routes slog to logcat.
|
||||
//
|
||||
// **An Android app's fd 1 and 2 go to /dev/null**, so every line this
|
||||
// app writes with slog is discarded on that platform -- including the
|
||||
// one naming the error it is about to os.Exit on. #52 is what that
|
||||
// cost: a process that vanished with no tombstone, no AndroidRuntime
|
||||
// stack and nothing in `logcat -b crash`, at Priority/Critical for
|
||||
// months, whose entire diagnosis was one slog.Error main.go was
|
||||
// already writing.
|
||||
//
|
||||
// The platform's own sink is __android_log_write, which is a handful
|
||||
// of cgo -- and cgo compiled by nothing `make lint` or `make test`
|
||||
// runs, since the only toolchain that builds the android tag is a
|
||||
// cross-compiler and the only thing that runs it is a phone. So the
|
||||
// split here is the one backend/mediacontrols/androidpayload.go makes,
|
||||
// pushed as far as it will go: **everything except the write itself is
|
||||
// in this file, untagged**. The priority mapping, the formatting, the
|
||||
// chunking and the handler's own attr and group bookkeeping are
|
||||
// ordinary Go that `go test` exercises on any platform; android.go is
|
||||
// fifteen lines that hand a string to liblog.
|
||||
package androidlog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Tag is what logcat labels these lines with.
|
||||
//
|
||||
// It is a constant of ours rather than the application id, because the
|
||||
// debug build carries `applicationIdSuffix ".dev"` so that it can be
|
||||
// installed beside the release app -- so a tag derived from the package
|
||||
// name is a *different* tag on the one build that can be inspected, and
|
||||
// the filter that is supposed to show these lines would hide them on
|
||||
// exactly the build used to look for them.
|
||||
const Tag = "yellowjacket"
|
||||
|
||||
// Android's priorities, from android/log.h. These are the values
|
||||
// __android_log_write takes; android.go asserts at compile time that
|
||||
// they still match the header, so a renumbered platform is a build
|
||||
// failure here rather than a warning silently logged as an error.
|
||||
const (
|
||||
PrioVerbose = 2
|
||||
PrioDebug = 3
|
||||
PrioInfo = 4
|
||||
PrioWarn = 5
|
||||
PrioError = 6
|
||||
PrioFatal = 7
|
||||
)
|
||||
|
||||
// maxPayload is how much of one line liblog will carry.
|
||||
//
|
||||
// The kernel logger's entry is 4068 bytes for the tag, the message and
|
||||
// their two NULs together, and what does not fit is **dropped without
|
||||
// comment** -- so a long line would be truncated in the middle of the
|
||||
// thing worth reading. 3500 leaves room for the tag and for the "(N/M)"
|
||||
// a continuation carries.
|
||||
const maxPayload = 3500
|
||||
|
||||
// WriteFunc is the platform sink: one already-formatted line, at one
|
||||
// priority, under one tag.
|
||||
//
|
||||
// It is a parameter rather than a package-level function so that the
|
||||
// handler can be driven by a test on a machine with no liblog at all.
|
||||
type WriteFunc func(prio int, tag, msg string)
|
||||
|
||||
// Priority maps a slog level onto an Android one.
|
||||
//
|
||||
// slog's levels are open -- a caller may define its own at any int --
|
||||
// so this is a banding rather than a lookup: anything below Info is
|
||||
// debug, anything at or above Error is error. A custom level between
|
||||
// two of the standard ones lands in the band beneath it, which is what
|
||||
// slog's own level naming does.
|
||||
func Priority(level slog.Level) int {
|
||||
switch {
|
||||
case level < slog.LevelDebug:
|
||||
return PrioVerbose
|
||||
case level < slog.LevelInfo:
|
||||
return PrioDebug
|
||||
case level < slog.LevelWarn:
|
||||
return PrioInfo
|
||||
case level < slog.LevelError:
|
||||
return PrioWarn
|
||||
default:
|
||||
return PrioError
|
||||
}
|
||||
}
|
||||
|
||||
// Handler formats records with slog's own TextHandler and hands each
|
||||
// line to a WriteFunc.
|
||||
//
|
||||
// It delegates the formatting rather than doing it, because WithAttrs
|
||||
// and WithGroup are the half of slog.Handler that is easy to get subtly
|
||||
// wrong -- and a logger whose groups are wrong is a logger nobody reads.
|
||||
// What it does own is what logcat needs and TextHandler does not know
|
||||
// about: the priority, and the fact that a line has a maximum length.
|
||||
type Handler struct {
|
||||
write WriteFunc
|
||||
|
||||
// mu guards buf, which the delegate writes into. slog.Handler is
|
||||
// documented as safe for concurrent use.
|
||||
mu *sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
delegate slog.Handler
|
||||
}
|
||||
|
||||
// NewHandler builds a handler over an arbitrary sink.
|
||||
//
|
||||
// The time and the level are dropped from the formatted line: logcat
|
||||
// stamps every entry with both, and repeating them costs a quarter of
|
||||
// the width of a phone-sized terminal to say the same thing twice.
|
||||
func NewHandler(opts *slog.HandlerOptions, write WriteFunc) *Handler {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
inner := &slog.HandlerOptions{}
|
||||
if opts != nil {
|
||||
*inner = *opts
|
||||
}
|
||||
|
||||
user := inner.ReplaceAttr
|
||||
inner.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||
if len(groups) == 0 && isBuiltin(a) {
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
return user(groups, a)
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
write: write,
|
||||
mu: &sync.Mutex{},
|
||||
buf: buf,
|
||||
delegate: slog.NewTextHandler(buf, inner),
|
||||
}
|
||||
}
|
||||
|
||||
// isBuiltin reports whether an attr is slog's own time or level,
|
||||
// rather than a caller's attribute that happens to share the name.
|
||||
//
|
||||
// ReplaceAttr cannot tell those apart by key. It is called with an
|
||||
// empty group path for the built-ins *and* for every top-level
|
||||
// attribute, so a key comparison alone silently eats a caller's own
|
||||
// "level" or "time" -- which is not hypothetical: the probe that
|
||||
// verified this package on the device logged one, and the attribute
|
||||
// vanished. The kinds are what separate them, because slog builds the
|
||||
// built-ins as slog.Any(LevelKey, r.Level) and slog.Time(TimeKey, ...)
|
||||
// and an attribute value of type slog.Level is not something a caller
|
||||
// passes by accident.
|
||||
func isBuiltin(a slog.Attr) bool {
|
||||
switch a.Key {
|
||||
case slog.TimeKey:
|
||||
return a.Value.Kind() == slog.KindTime
|
||||
case slog.LevelKey:
|
||||
_, ok := a.Value.Any().(slog.Level)
|
||||
|
||||
return ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the level is worth formatting.
|
||||
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
return h.delegate.Enabled(ctx, level)
|
||||
}
|
||||
|
||||
// Handle formats one record and writes it out, in as many entries as
|
||||
// its length demands.
|
||||
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.buf.Reset()
|
||||
|
||||
if err := h.delegate.Handle(ctx, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prio := Priority(rec.Level)
|
||||
for _, line := range Chunk(strings.TrimRight(h.buf.String(), "\n")) {
|
||||
h.write(prio, Tag, line)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAttrs returns a handler carrying the given attributes.
|
||||
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return h.derive(h.delegate.WithAttrs(attrs))
|
||||
}
|
||||
|
||||
// WithGroup returns a handler that qualifies subsequent attributes.
|
||||
func (h *Handler) WithGroup(name string) slog.Handler {
|
||||
return h.derive(h.delegate.WithGroup(name))
|
||||
}
|
||||
|
||||
// derive shares the buffer and its mutex with the parent.
|
||||
//
|
||||
// They must be shared rather than copied: the delegate returned by
|
||||
// WithAttrs writes into the *same* buffer this one does, so a second
|
||||
// mutex would guard nothing and two loggers derived from one would
|
||||
// interleave their bytes into a single line.
|
||||
func (h *Handler) derive(delegate slog.Handler) *Handler {
|
||||
return &Handler{
|
||||
write: h.write,
|
||||
mu: h.mu,
|
||||
buf: h.buf,
|
||||
delegate: delegate,
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk splits a formatted record into entries liblog will carry
|
||||
// whole.
|
||||
//
|
||||
// A record short enough to fit is returned as it is, which is nearly
|
||||
// every record; the numbering only appears where something was going
|
||||
// to be silently truncated anyway. It splits on bytes rather than runes
|
||||
// because the limit is a byte count -- a multi-byte rune straddling the
|
||||
// boundary is a mojibake character in a log line, against a lost one.
|
||||
func Chunk(msg string) []string {
|
||||
if len(msg) <= maxPayload {
|
||||
return []string{msg}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
for rest := msg; rest != ""; {
|
||||
n := min(maxPayload, len(rest))
|
||||
parts = append(parts, rest[:n])
|
||||
rest = rest[n:]
|
||||
}
|
||||
|
||||
numbered := make([]string, 0, len(parts))
|
||||
for i, p := range parts {
|
||||
numbered = append(
|
||||
numbered,
|
||||
"("+strconv.Itoa(i+1)+"/"+strconv.Itoa(len(parts))+") "+p,
|
||||
)
|
||||
}
|
||||
|
||||
return numbered
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package androidlog_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/androidlog"
|
||||
)
|
||||
|
||||
// entry is one call to the sink.
|
||||
type entry struct {
|
||||
prio int
|
||||
tag string
|
||||
msg string
|
||||
}
|
||||
|
||||
// recorder is the platform write, on a machine with no platform.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
entries []entry
|
||||
}
|
||||
|
||||
func (r *recorder) write(prio int, tag, msg string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.entries = append(r.entries, entry{prio: prio, tag: tag, msg: msg})
|
||||
}
|
||||
|
||||
func (r *recorder) only(t *testing.T) entry {
|
||||
t.Helper()
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if len(r.entries) != 1 {
|
||||
t.Fatalf("want exactly one entry, got %d: %v", len(r.entries), r.entries)
|
||||
}
|
||||
|
||||
return r.entries[0]
|
||||
}
|
||||
|
||||
func newLogger(r *recorder, level slog.Level) *slog.Logger {
|
||||
return slog.New(androidlog.NewHandler(
|
||||
&slog.HandlerOptions{Level: level},
|
||||
r.write,
|
||||
))
|
||||
}
|
||||
|
||||
// TestPriorityMapsEveryLevel pins the level banding.
|
||||
//
|
||||
// This is the one thing in #160 that a wrong answer hides rather than
|
||||
// breaks: logcat prints whatever priority it is handed, so an Error
|
||||
// filed as Info is a line that is present, correct and invisible to
|
||||
// every filter anyone would use to look for it.
|
||||
func TestPriorityMapsEveryLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
level slog.Level
|
||||
want int
|
||||
}{
|
||||
{"below debug is verbose", slog.LevelDebug - 1, androidlog.PrioVerbose},
|
||||
{"debug", slog.LevelDebug, androidlog.PrioDebug},
|
||||
{"info", slog.LevelInfo, androidlog.PrioInfo},
|
||||
{"warn", slog.LevelWarn, androidlog.PrioWarn},
|
||||
{"error", slog.LevelError, androidlog.PrioError},
|
||||
|
||||
// slog's levels are open, so a caller may sit between two of
|
||||
// the named ones. Each lands in the band beneath it, which is
|
||||
// what slog's own level naming does ("INFO+2").
|
||||
{"between info and warn", slog.LevelInfo + 2, androidlog.PrioInfo},
|
||||
{"between warn and error", slog.LevelWarn + 1, androidlog.PrioWarn},
|
||||
{"above error", slog.LevelError + 4, androidlog.PrioError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := androidlog.Priority(tt.level); got != tt.want {
|
||||
t.Errorf("Priority(%v) = %d, want %d", tt.level, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrioritiesAreTheHeadersValues pins the constants themselves.
|
||||
//
|
||||
// android.go asserts these against android/log.h at compile time, but
|
||||
// only a cross-compiler ever builds that file. This is the assertion
|
||||
// that runs in CI, and the numbers are written out longhand on purpose
|
||||
// -- comparing a constant to itself would pass on any renumbering.
|
||||
func TestPrioritiesAreTheHeadersValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{"verbose", androidlog.PrioVerbose, 2},
|
||||
{"debug", androidlog.PrioDebug, 3},
|
||||
{"info", androidlog.PrioInfo, 4},
|
||||
{"warn", androidlog.PrioWarn, 5},
|
||||
{"error", androidlog.PrioError, 6},
|
||||
{"fatal", androidlog.PrioFatal, 7},
|
||||
} {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s priority = %d, want %d", tt.name, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordReachesTheSink is the whole point of the package: a line
|
||||
// written with slog arrives, under the app's tag, at the right
|
||||
// priority.
|
||||
func TestRecordReachesTheSink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
newLogger(rec, slog.LevelInfo).Error("application error", "err", "boom")
|
||||
|
||||
got := rec.only(t)
|
||||
|
||||
if got.prio != androidlog.PrioError {
|
||||
t.Errorf("priority = %d, want %d", got.prio, androidlog.PrioError)
|
||||
}
|
||||
|
||||
if got.tag != androidlog.Tag {
|
||||
t.Errorf("tag = %q, want %q", got.tag, androidlog.Tag)
|
||||
}
|
||||
|
||||
if !strings.Contains(got.msg, "application error") {
|
||||
t.Errorf("message %q does not carry the message", got.msg)
|
||||
}
|
||||
|
||||
if !strings.Contains(got.msg, `err=boom`) {
|
||||
t.Errorf("message %q does not carry the attribute", got.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheTagIsNotTheApplicationID guards the trap the tag exists to
|
||||
// avoid.
|
||||
//
|
||||
// The debug build carries `applicationIdSuffix ".dev"`, so it is
|
||||
// installed as app.yellowjacket.dev -- and it is the *only* build whose
|
||||
// WebView can be inspected, so it is the build anyone debugging this
|
||||
// app is running. A tag derived from the application id therefore
|
||||
// differs between the build being looked at and the build the filter
|
||||
// was written for, which is the failure this whole issue is about
|
||||
// wearing a different hat.
|
||||
func TestTheTagIsNotTheApplicationID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if strings.Contains(androidlog.Tag, ".") {
|
||||
t.Errorf(
|
||||
"tag %q looks like an application id; it must be stable "+
|
||||
"across the debug suffix",
|
||||
androidlog.Tag,
|
||||
)
|
||||
}
|
||||
|
||||
// Logcat's tag field is 23 bytes. A longer one is truncated, and a
|
||||
// truncated tag matches no filter.
|
||||
if len(androidlog.Tag) > 23 {
|
||||
t.Errorf("tag %q is %d bytes, over logcat's 23", androidlog.Tag, len(androidlog.Tag))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimeAndLevelAreDropped checks the formatting decision.
|
||||
//
|
||||
// logcat stamps every entry with a timestamp and a priority letter, so
|
||||
// carrying slog's own is the same information twice on a 424px screen.
|
||||
func TestTimeAndLevelAreDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
newLogger(rec, slog.LevelInfo).Warn("scan finished", "files", 1577)
|
||||
|
||||
got := rec.only(t).msg
|
||||
|
||||
if strings.Contains(got, "time=") {
|
||||
t.Errorf("message %q still carries a timestamp", got)
|
||||
}
|
||||
|
||||
if strings.Contains(got, "level=") {
|
||||
t.Errorf("message %q still carries a level", got)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "files=1577") {
|
||||
t.Errorf("message %q lost its attributes with them", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestACallersOwnLevelAttrSurvives is a regression, and it was found on
|
||||
// the phone rather than here.
|
||||
//
|
||||
// Dropping slog's built-in time and level by key alone also drops a
|
||||
// caller's attribute of the same name, because ReplaceAttr sees an
|
||||
// empty group path for both. The probe that verified this package on
|
||||
// the device wrote slog.Info("...", "level", "info") and logcat showed
|
||||
// the message with no attributes at all.
|
||||
func TestACallersOwnLevelAttrSurvives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
newLogger(rec, slog.LevelInfo).Info("probe", "level", "info", "time", "soon")
|
||||
|
||||
got := rec.only(t).msg
|
||||
|
||||
for _, want := range []string{"level=info", "time=soon"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("message %q lost the caller's %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// And slog's own are still gone: the built-in level renders as a
|
||||
// bare word like INFO, never as the caller's value.
|
||||
if strings.Contains(got, "level=INFO") {
|
||||
t.Errorf("message %q carries slog's own level", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLevelIsHonoured checks that Enabled reaches the delegate.
|
||||
func TestLevelIsHonoured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
log := newLogger(rec, slog.LevelWarn)
|
||||
|
||||
log.Info("not this one")
|
||||
log.Warn("this one")
|
||||
|
||||
if got := rec.only(t).msg; !strings.Contains(got, "this one") {
|
||||
t.Errorf("wrong record survived: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupsAndAttrsSurvive covers the half of slog.Handler this
|
||||
// delegates rather than implements -- the reason it delegates at all.
|
||||
func TestGroupsAndAttrsSurvive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
log := newLogger(rec, slog.LevelInfo).
|
||||
With("component", "player").
|
||||
WithGroup("track")
|
||||
|
||||
log.Info("loaded", "path", "/sdcard/Music/a.flac")
|
||||
|
||||
got := rec.only(t).msg
|
||||
|
||||
for _, want := range []string{
|
||||
"component=player",
|
||||
"track.path=/sdcard/Music/a.flac",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("message %q is missing %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedHandlersDoNotInterleave is why derive shares the buffer's
|
||||
// mutex rather than taking a new one.
|
||||
//
|
||||
// Two loggers derived from one write into the same buffer, so a second
|
||||
// mutex would guard nothing and a concurrent pair would splice each
|
||||
// other's bytes into a single line -- which reads as corrupted logs
|
||||
// under load and as nothing at all in a test that logs once.
|
||||
func TestDerivedHandlersDoNotInterleave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := &recorder{}
|
||||
base := newLogger(rec, slog.LevelInfo)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
log := base.With("worker", i).WithGroup("g")
|
||||
for range 50 {
|
||||
log.Info("tick", "n", i)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
|
||||
if len(rec.entries) != 8*50 {
|
||||
t.Fatalf("got %d entries, want %d", len(rec.entries), 8*50)
|
||||
}
|
||||
|
||||
for _, e := range rec.entries {
|
||||
if strings.Count(e.msg, "msg=tick") != 1 {
|
||||
t.Fatalf("interleaved line: %q", e.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkLeavesShortLinesAlone is the common case: no numbering
|
||||
// appears on a record that was never going to be truncated.
|
||||
func TestChunkLeavesShortLinesAlone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := androidlog.Chunk("msg=short")
|
||||
|
||||
if len(got) != 1 || got[0] != "msg=short" {
|
||||
t.Errorf("Chunk(short) = %q, want the input unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunkSplitsWhatWouldBeTruncated covers the case liblog drops
|
||||
// silently.
|
||||
func TestChunkSplitsWhatWouldBeTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const n = 9000
|
||||
|
||||
long := strings.Repeat("x", n)
|
||||
parts := androidlog.Chunk(long)
|
||||
|
||||
if len(parts) < 2 {
|
||||
t.Fatalf("a %d-byte line was not split", n)
|
||||
}
|
||||
|
||||
var payload strings.Builder
|
||||
|
||||
for i, p := range parts {
|
||||
if len(p) > 4000 {
|
||||
t.Errorf("part %d is %d bytes, over liblog's entry", i, len(p))
|
||||
}
|
||||
|
||||
_, rest, found := strings.Cut(p, ") ")
|
||||
if !found {
|
||||
t.Fatalf("part %d carries no (n/m) marker: %q", i, p)
|
||||
}
|
||||
|
||||
payload.WriteString(rest)
|
||||
}
|
||||
|
||||
if payload.String() != long {
|
||||
t.Errorf("the parts do not reassemble into the input")
|
||||
}
|
||||
}
|
||||
+184
-83
@@ -10,9 +10,10 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/assets"
|
||||
"yellowjacket/backend/autotagservice"
|
||||
@@ -39,7 +40,9 @@ import (
|
||||
|
||||
// YellowJacketApp is the main application struct for Wails.
|
||||
type YellowJacketApp struct {
|
||||
FEBindings []any
|
||||
// Services is what v3 binds to the frontend. Each entry's
|
||||
// ServiceStartup runs before the app-level wiring in OnStartup.
|
||||
Services []application.Service
|
||||
FrontendUtil *frontendutil.FrontendUtil
|
||||
|
||||
logger *slog.Logger
|
||||
@@ -61,6 +64,12 @@ type YellowJacketApp struct {
|
||||
appContext context.Context
|
||||
appConfig *config.Config
|
||||
startupErr error
|
||||
|
||||
// quitAsking guards the one quit-confirmation dialog; quitConfirmed
|
||||
// records that the user already answered "quit anyway", so the
|
||||
// Quit() issued from that callback is not questioned again.
|
||||
quitAsking atomic.Bool
|
||||
quitConfirmed atomic.Bool
|
||||
}
|
||||
|
||||
// NewYellowJacketApp creates and initializes the application.
|
||||
@@ -182,6 +191,34 @@ func NewYellowJacketApp(
|
||||
yjApp.library.SetJobRegistry(yjApp.jobs)
|
||||
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
||||
|
||||
// Whether this connection is one to spend ~0.6 GB of catalog on
|
||||
// (plan 016 B4). The probe is injected from here because `explore` is
|
||||
// imported by `cmd/indexbuild`, which must not link Wails: naming
|
||||
// `application` there is what `TestIndexToolsDoNotImportWails`
|
||||
// forbids.
|
||||
//
|
||||
// `application.Mobile`, not `application.Android`: the latter exists
|
||||
// only under the `android` build tag, while `Mobile` is the portable
|
||||
// name whose desktop implementation is a stub returning "" — which
|
||||
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
|
||||
// one; this is the same call by the name every build has.
|
||||
yjApp.explore.SetNetworkPolicy(
|
||||
func() explore.Network {
|
||||
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
|
||||
},
|
||||
yjApp.appConfig.GetAllowMeteredCatalogDownload,
|
||||
)
|
||||
|
||||
// Let the release prefetch skip albums the user already owns in
|
||||
// full — those open with no catalog call at all, so warming their
|
||||
// tracklists spends the most expensive request in the app on
|
||||
// nothing. Injected because neither package imports the other.
|
||||
yjApp.explore.SetAlbumComplete(func(albumID int64) bool {
|
||||
c, err := yjApp.library.GetAlbumCompleteness(albumID)
|
||||
|
||||
return err == nil && c.Known && c.Complete
|
||||
})
|
||||
|
||||
// create autotag service (depends on explore + tagWriter)
|
||||
yjApp.autotag = autotagservice.NewService(
|
||||
yjApp.logger.WithGroup("autotag"),
|
||||
@@ -201,28 +238,42 @@ func NewYellowJacketApp(
|
||||
)
|
||||
}
|
||||
|
||||
yjApp.FEBindings = []any{
|
||||
yjApp.FrontendUtil,
|
||||
yjApp.appConfig,
|
||||
yjApp.library,
|
||||
yjApp.playlist,
|
||||
yjApp.queue,
|
||||
yjApp.player,
|
||||
yjApp.tagWriter,
|
||||
yjApp.explore,
|
||||
yjApp.autotag,
|
||||
jobs.NewService(yjApp.jobs),
|
||||
home.NewService(
|
||||
// application.NewService is generic over a concrete pointer type —
|
||||
// the static analyser that generates bindings reads these calls, so
|
||||
// a []any of the same values would generate nothing.
|
||||
yjApp.Services = []application.Service{
|
||||
application.NewService(yjApp.FrontendUtil),
|
||||
application.NewService(yjApp.appConfig),
|
||||
application.NewService(yjApp.library),
|
||||
application.NewService(yjApp.playlist),
|
||||
application.NewService(yjApp.queue),
|
||||
application.NewService(yjApp.player),
|
||||
application.NewService(yjApp.tagWriter),
|
||||
application.NewService(yjApp.explore),
|
||||
application.NewService(yjApp.autotag),
|
||||
application.NewService(jobs.NewService(yjApp.jobs)),
|
||||
application.NewService(home.NewService(
|
||||
yjApp.logger.WithGroup("home"),
|
||||
yjApp.database,
|
||||
yjApp.library,
|
||||
),
|
||||
)),
|
||||
}
|
||||
|
||||
if yjApp.downloadSvc != nil {
|
||||
yjApp.FEBindings = append(yjApp.FEBindings, yjApp.downloadSvc)
|
||||
yjApp.Services = append(
|
||||
yjApp.Services, application.NewService(yjApp.downloadSvc),
|
||||
)
|
||||
}
|
||||
|
||||
// Last, deliberately: services start in registration order, so this
|
||||
// runs once every service above has taken its context. See
|
||||
// startup.go for why the wiring is a service rather than an
|
||||
// application-event hook.
|
||||
yjApp.Services = append(
|
||||
yjApp.Services,
|
||||
application.NewService(&startupService{app: yjApp}),
|
||||
)
|
||||
|
||||
return yjApp, nil
|
||||
}
|
||||
|
||||
@@ -319,19 +370,18 @@ func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
|
||||
return yj.appConfig.Window
|
||||
}
|
||||
|
||||
// OnStartup initializes components that require the Wails runtime context.
|
||||
// OnStartup wires the services to each other once the runtime exists.
|
||||
//
|
||||
// It is no longer where each service *gets* the context: every bound
|
||||
// service implements v3's ServiceStartup, which the runtime calls
|
||||
// before this runs. What is left here is the cross-service wiring —
|
||||
// hooks, adapters and the callbacks that make one package drive
|
||||
// another — which has no home inside any single service.
|
||||
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
|
||||
|
||||
// initialize anything that needs to use the wails runtime AFTER its been initialized
|
||||
// you CANNOT use the wails runtime during this function
|
||||
yj.appContext = ctx
|
||||
|
||||
// Set context for components that need Wails runtime for events
|
||||
yj.appConfig.SetContext(ctx)
|
||||
yj.FrontendUtil.SetContext(ctx)
|
||||
yj.library.SetContext(ctx)
|
||||
yj.playlist.SetContext(ctx)
|
||||
yj.playlist.EnsureDefaultPlaylist()
|
||||
// Recover playlists that lost tracks from a pre-fix FullRescan.
|
||||
go yj.playlist.RepopulateFromM3U()
|
||||
@@ -348,14 +398,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
yj.player.SetContext(ctx)
|
||||
yj.tagWriter.SetContext(ctx)
|
||||
yj.explore.SetContext(ctx)
|
||||
yj.autotag.SetContext(ctx)
|
||||
// The job registry is not a bound service — it is wrapped by
|
||||
// jobs.NewService for that — so it still takes the context by hand.
|
||||
yj.jobs.SetContext(ctx)
|
||||
|
||||
if yj.downloadSvc != nil {
|
||||
yj.downloadSvc.SetContext(ctx)
|
||||
yj.initDownloadRuntime(ctx)
|
||||
}
|
||||
|
||||
@@ -365,9 +412,12 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.library.RestorePausedScans()
|
||||
yj.explore.AdoptPausedIndexBuild()
|
||||
|
||||
// Wire queue (created in NewYellowJacketApp for Wails binding)
|
||||
yj.queue.SetContext(ctx)
|
||||
yj.queue.SetPlayer(yj.player)
|
||||
yj.queue.SetFallbackSource(&queueFallbackAdapter{
|
||||
config: yj.appConfig,
|
||||
playlist: yj.playlist,
|
||||
explore: yj.explore,
|
||||
})
|
||||
yj.queue.RestoreState()
|
||||
|
||||
// Wire cross-cutting rescan hooks so the library can
|
||||
@@ -407,6 +457,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
// no-op once every owned artist is covered.
|
||||
yj.explore.BackfillLibraryDiscographies()
|
||||
|
||||
// Resolve any release-group MBIDs the scan could only find a
|
||||
// release-level tag for (see updateMBIDs). Same shape as the
|
||||
// discography backfill above: background, bounded, resumable.
|
||||
yj.explore.BackfillReleaseGroupMBIDs()
|
||||
|
||||
// Start (or resume) the dump-based index build. Skips
|
||||
// itself once the one-time import has completed, so this
|
||||
// is cheap on every startup.
|
||||
@@ -447,20 +502,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
// Register playback finished handler to drive queue auto-advance.
|
||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||
|
||||
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
|
||||
// Initialize OS media controls (MPRIS on desktop Linux, a
|
||||
// MediaSession on Android, no-op elsewhere). The callbacks are the
|
||||
// same on every platform; only what delivers them differs.
|
||||
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
|
||||
|
||||
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
||||
OnPlay: yj.queue.Play,
|
||||
OnPause: func() {
|
||||
if err := yj.player.Pause(); err != nil {
|
||||
yj.logger.Warn("MPRIS Pause failed", "err", err)
|
||||
yj.logger.Warn("Media controls Pause failed", "err", err)
|
||||
}
|
||||
},
|
||||
OnPlayPause: func() {
|
||||
if yj.player.IsPlaying() {
|
||||
if err := yj.player.Pause(); err != nil {
|
||||
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
|
||||
yj.logger.Warn(
|
||||
"Media controls PlayPause(pause) failed", "err", err,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
yj.queue.Play()
|
||||
@@ -468,14 +527,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
},
|
||||
OnStop: func() {
|
||||
if err := yj.player.Pause(); err != nil {
|
||||
yj.logger.Warn("MPRIS Stop failed", "err", err)
|
||||
yj.logger.Warn("Media controls Stop failed", "err", err)
|
||||
}
|
||||
},
|
||||
OnNext: yj.queue.Next,
|
||||
OnPrevious: yj.queue.Previous,
|
||||
OnSeek: func(positionSec int) {
|
||||
if err := yj.player.Seek(positionSec); err != nil {
|
||||
yj.logger.Warn("MPRIS Seek failed", "err", err)
|
||||
yj.logger.Warn("Media controls Seek failed", "err", err)
|
||||
}
|
||||
},
|
||||
OnVolume: func(vol float64) {
|
||||
@@ -485,6 +544,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
),
|
||||
)
|
||||
},
|
||||
OnDuck: yj.player.SetDuck,
|
||||
}); err != nil {
|
||||
yj.logger.Error(
|
||||
"Failed to initialize media controls",
|
||||
@@ -495,37 +555,32 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.player.SetMediaControls(yj.mediaControls)
|
||||
}
|
||||
|
||||
// OnBeforeClose captures window state while the window is still alive,
|
||||
// and asks first when quitting would abandon a job that is writing to
|
||||
// the user's files.
|
||||
//
|
||||
// Returning true keeps the window open. Quitting mid-apply cancels the
|
||||
// service context and leaves a folder half-retagged with nothing
|
||||
// recording where it stopped (errors.p4), which is the one case worth
|
||||
// interrupting a quit for.
|
||||
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
if yj.confirmQuitDuringWrites(ctx) {
|
||||
return true
|
||||
// SaveWindowState captures the window's size while the window is still
|
||||
// alive. It is registered on the WindowClosing event, because at
|
||||
// shutdown there is no window left to measure.
|
||||
func (yj *YellowJacketApp) SaveWindowState(window application.Window) {
|
||||
if window == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w, h := wailsruntime.WindowGetSize(ctx)
|
||||
w, h := window.Size()
|
||||
|
||||
// Guard against a bogus size clobbering a good saved one. During
|
||||
// teardown / hot-reload the runtime can report a zero or below-
|
||||
// minimum size; persisting that would shrink the window to the
|
||||
// minimum on next launch. Keep the previously-saved size instead.
|
||||
if w < config.MinWidth || h < config.MinHeight {
|
||||
yj.logger.Warn("OnBeforeClose: ignoring bogus window size",
|
||||
yj.logger.Warn("window close: ignoring bogus window size",
|
||||
"width", w,
|
||||
"height", h,
|
||||
"kept_width", yj.appConfig.Window.Width,
|
||||
"kept_height", yj.appConfig.Window.Height,
|
||||
)
|
||||
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
yj.logger.Info("OnBeforeClose: saving window state",
|
||||
yj.logger.Info("window close: saving window state",
|
||||
"width", w,
|
||||
"height", h,
|
||||
"accentColor", yj.appConfig.Theme.AccentColor,
|
||||
@@ -541,39 +596,69 @@ func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldQuit answers v3's quit veto: false keeps the app running.
|
||||
//
|
||||
// Quitting mid-apply cancels the service context and leaves a folder
|
||||
// half-retagged with nothing recording where it stopped (errors.p4),
|
||||
// which is the one case worth interrupting a quit for.
|
||||
//
|
||||
// The shape differs from v2's OnBeforeClose because v3's dialog is
|
||||
// asynchronous — Show() returns immediately and the answer arrives on
|
||||
// a button callback — so this cannot ask and answer in one call. It
|
||||
// vetoes the quit, asks, and quits again from the callback if the user
|
||||
// says so. quitConfirmed is what stops that second Quit() coming
|
||||
// straight back here and asking a second time.
|
||||
func (yj *YellowJacketApp) ShouldQuit() bool {
|
||||
if yj.quitConfirmed.Load() {
|
||||
return true
|
||||
}
|
||||
|
||||
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
|
||||
return true
|
||||
}
|
||||
|
||||
// A dialog already up must not spawn another on every close attempt.
|
||||
if !yj.quitAsking.CompareAndSwap(false, true) {
|
||||
return false
|
||||
}
|
||||
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
// No runtime to ask through: never trap the user in the app.
|
||||
return true
|
||||
}
|
||||
|
||||
dialog := app.Dialog.Question()
|
||||
dialog.SetTitle("Tags are still being written")
|
||||
dialog.SetMessage(
|
||||
"YellowJacket is rewriting tags on your files. " +
|
||||
"Quitting now leaves that folder holding a mix of old and " +
|
||||
"new tags.\n\nQuit anyway?",
|
||||
)
|
||||
|
||||
quit := dialog.AddButton("Quit anyway")
|
||||
quit.OnClick(func() {
|
||||
yj.quitConfirmed.Store(true)
|
||||
yj.quitAsking.Store(false)
|
||||
app.Quit()
|
||||
})
|
||||
|
||||
stay := dialog.AddButton("Keep writing")
|
||||
stay.OnClick(func() { yj.quitAsking.Store(false) })
|
||||
stay.SetAsDefault()
|
||||
stay.SetAsCancel()
|
||||
|
||||
dialog.Show()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// confirmQuitDuringWrites returns true when the user chose to stay.
|
||||
// A dialog that cannot be shown is not allowed to trap anyone in the
|
||||
// app, so any error here quits.
|
||||
func (yj *YellowJacketApp) confirmQuitDuringWrites(ctx context.Context) bool {
|
||||
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
|
||||
return false
|
||||
}
|
||||
|
||||
answer, err := wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
|
||||
Type: wailsruntime.QuestionDialog,
|
||||
Title: "Tags are still being written",
|
||||
Message: "YellowJacket is rewriting tags on your files. " +
|
||||
"Quitting now leaves that folder holding a mix of old and " +
|
||||
"new tags.\n\nQuit anyway?",
|
||||
Buttons: []string{"Quit anyway", "Keep writing"},
|
||||
DefaultButton: "Keep writing",
|
||||
CancelButton: "Keep writing",
|
||||
})
|
||||
if err != nil {
|
||||
yj.logger.Warn("could not ask about quitting mid-write", "err", err)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return answer == "Keep writing" || answer == "No"
|
||||
}
|
||||
|
||||
// OnShutdown saves player state and cleans up resources before the application exits.
|
||||
func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
||||
// OnShutdown saves player state and cleans up resources before the
|
||||
// application exits. v3 passes no context — the app is going away, so
|
||||
// there is nothing left to scope work to.
|
||||
func (yj *YellowJacketApp) OnShutdown() {
|
||||
if yj.player != nil {
|
||||
yj.player.SaveState()
|
||||
}
|
||||
@@ -592,10 +677,17 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
||||
// driven by the frontend: once its stores have registered their event
|
||||
// listeners, index.ts calls Player.EmitCurrentState() and
|
||||
// Queue.EmitCurrentState() via Wails bindings.
|
||||
func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
||||
func (yj *YellowJacketApp) OnDomReady(_ context.Context) {
|
||||
if yj.startupErr != nil {
|
||||
yj.logger.Error("startup error", "err", yj.startupErr.Error())
|
||||
wailsruntime.Quit(ctx)
|
||||
|
||||
// A startup failure is not a mid-write quit, so go straight out
|
||||
// rather than through the ShouldQuit question.
|
||||
yj.quitConfirmed.Store(true)
|
||||
|
||||
if app := application.Get(); app != nil {
|
||||
app.Quit()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -635,6 +727,9 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
||||
// discography (e.g. a prior run was capped or interrupted).
|
||||
// Cheap no-op once every owned artist is covered.
|
||||
yj.explore.BackfillLibraryDiscographies()
|
||||
|
||||
// Same continuation for release-group MBID resolution.
|
||||
yj.explore.BackfillReleaseGroupMBIDs()
|
||||
}
|
||||
|
||||
// Kick off the autotag prefetch worker so any unscored
|
||||
@@ -684,7 +779,13 @@ func (yj *YellowJacketApp) startJanitor() {
|
||||
yj.database, coversDir, library.CoverArtFileSet,
|
||||
))
|
||||
yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
|
||||
yj.database, filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||
yj.database,
|
||||
filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||
explore.ArtistImageDir,
|
||||
))
|
||||
yj.janitor.Register(maintenance.StrayArtistImageFilesJob(
|
||||
filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||
explore.ArtistImageKeepNames(),
|
||||
))
|
||||
yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
|
||||
filepath.Join(dataDir, explore.CoverArtCacheDirName),
|
||||
|
||||
@@ -3,15 +3,22 @@ package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// distRoot is where the frontend build lands inside the embedded FS.
|
||||
// v2 knew this prefix itself; v3 takes an fs.FS rooted at the assets,
|
||||
// so the sub-FS is taken here.
|
||||
const distRoot = "frontend/dist"
|
||||
|
||||
// Handler serves frontend assets with custom route support.
|
||||
type Handler struct {
|
||||
Options *assetserver.Options
|
||||
Options application.AssetOptions
|
||||
logger *slog.Logger
|
||||
frontendDistAssets embed.FS
|
||||
serveMux *http.ServeMux
|
||||
@@ -25,8 +32,16 @@ func NewAssetHandler(logger *slog.Logger, frontendDistAssets embed.FS) (*Handler
|
||||
frontendDistAssets: frontendDistAssets,
|
||||
serveMux: http.NewServeMux(),
|
||||
}
|
||||
handler.Options = &assetserver.Options{
|
||||
Assets: handler.frontendDistAssets,
|
||||
|
||||
dist, err := fs.Sub(frontendDistAssets, distRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not open %s in the embedded assets: %w", distRoot, err,
|
||||
)
|
||||
}
|
||||
|
||||
handler.Options = application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(dist),
|
||||
Middleware: handler.Middleware,
|
||||
}
|
||||
|
||||
|
||||
+59
-30
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/tagtotals"
|
||||
)
|
||||
|
||||
// TagChanges mirrors tagwriter.TagChanges — redefined here so the
|
||||
@@ -28,6 +29,8 @@ const (
|
||||
FieldYear = "year"
|
||||
FieldTrackNumber = "track_number"
|
||||
FieldDiscNumber = "disc_number"
|
||||
FieldTotalTracks = "total_tracks"
|
||||
FieldTotalDiscs = "total_discs"
|
||||
FieldCoverArt = "cover_art"
|
||||
)
|
||||
|
||||
@@ -328,43 +331,42 @@ func (a *Applier) Apply(
|
||||
func (a *Applier) syncDBMBIDs(
|
||||
ctx context.Context, tr TrackApply, cand Candidate,
|
||||
) error {
|
||||
// Look up recording row via audio_file.
|
||||
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get audio_file: %w", err)
|
||||
}
|
||||
|
||||
if tr.CandidateTrack.MBID != "" {
|
||||
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
|
||||
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
||||
ID: af.RecordingID,
|
||||
if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
|
||||
RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
||||
ID: tr.Local.AudioFileID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set recording mbid: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cand.ReleaseGroupMBID != "" {
|
||||
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
|
||||
if err == nil && rgID > 0 {
|
||||
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
|
||||
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
||||
ID: rgID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set release group mbid: %w", err)
|
||||
}
|
||||
if cand.ReleaseGroupMBID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stamp the release-group's original-release year too —
|
||||
// this is what the tracklist / smart-playlist year rule
|
||||
// surfaces by default once the user accepts a candidate.
|
||||
if year := parseYear(cand.OriginalDate); year > 0 {
|
||||
if err := a.q.SetReleaseGroupOriginalYear(
|
||||
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
|
||||
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
||||
ID: rgID,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("set release group original year: %w", err)
|
||||
}
|
||||
// The album is reached through the file rather than through two
|
||||
// join tables; SetFileAlbumMBID takes the file id and does the
|
||||
// lookup in one statement.
|
||||
if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
|
||||
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
||||
ID: tr.Local.AudioFileID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set album mbid: %w", err)
|
||||
}
|
||||
|
||||
// Stamp the album's original-release year too - this is what the
|
||||
// tracklist and the smart-playlist year rule surface by default
|
||||
// once the user accepts a candidate.
|
||||
if year := parseYear(cand.OriginalDate); year > 0 {
|
||||
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
|
||||
if err == nil && af.AlbumID.Valid {
|
||||
if err := a.q.SetAlbumOriginalYear(
|
||||
ctx, sqlcgen.SetAlbumOriginalYearParams{
|
||||
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
||||
ID: af.AlbumID.Int64,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("set album original year: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,5 +421,32 @@ func buildChanges(
|
||||
changes[FieldDiscNumber] = track.DiscNumber
|
||||
}
|
||||
|
||||
// The totals are what says "2 of 10" rather than a bare tick, and
|
||||
// dropping them here is what made autotagging an album *erase* the
|
||||
// evidence: the release becomes MBID-matched while the field
|
||||
// GetAlbumCompleteness reads stays absent.
|
||||
//
|
||||
// They are written unconditionally where the candidate has a
|
||||
// tracklist, not only when they differ from the local value, because
|
||||
// the common case is a file that declares no total at all -- which
|
||||
// compares equal to nothing and would be skipped by a diff guard.
|
||||
if tracks, discs := tagtotals.For(
|
||||
candidatePositions(cand), track.DiscNumber,
|
||||
); tracks > 0 {
|
||||
changes[FieldTotalTracks] = tracks
|
||||
changes[FieldTotalDiscs] = discs
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// candidatePositions is the candidate's tracklist as bare positions.
|
||||
func candidatePositions(cand Candidate) []tagtotals.Position {
|
||||
out := make([]tagtotals.Position, 0, len(cand.Tracks))
|
||||
|
||||
for _, t := range cand.Tracks {
|
||||
out = append(out, tagtotals.Position{Disc: t.DiscNumber, Track: t.Position})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package autotag_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -87,51 +86,22 @@ func seedAudioFiles(
|
||||
q := db.Queries
|
||||
ctx := db.Ctx
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: "Test Album",
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert rg: %v", err)
|
||||
}
|
||||
|
||||
out := make([]sqlcgen.AudioFile, 0, len(paths))
|
||||
|
||||
for i, p := range paths {
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: p,
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
||||
id := database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: p,
|
||||
Title: p,
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
TrackNumber: int64(i + 1),
|
||||
LengthMs: 100000,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("link rg recording: %v", err)
|
||||
}
|
||||
|
||||
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: p,
|
||||
LengthMilliseconds: 100000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: p,
|
||||
LibraryID: 0,
|
||||
GroupKey: groupKey,
|
||||
TagStatus: "untagged",
|
||||
})
|
||||
af, err := q.GetAudioFile(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
t.Fatalf("read seeded audio file: %v", err)
|
||||
}
|
||||
|
||||
out = append(out, af)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package autotag
|
||||
|
||||
import "testing"
|
||||
|
||||
// Autotagging an album used to *erase* the evidence that says "2 of 10":
|
||||
// the release became MBID-matched while the totals the files declared
|
||||
// went unwritten, so the album page showed a plain tick. These pin the
|
||||
// two halves of the fix that are easy to get wrong silently.
|
||||
func TestBuildChanges_Totals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
twoDiscs := Candidate{
|
||||
Tracks: []CandidateTrack{
|
||||
{DiscNumber: 1, Position: 1},
|
||||
{DiscNumber: 1, Position: 2},
|
||||
{DiscNumber: 2, Position: 1},
|
||||
{DiscNumber: 2, Position: 2},
|
||||
{DiscNumber: 2, Position: 3},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cand Candidate
|
||||
local LocalTrack
|
||||
track CandidateTrack
|
||||
wantTracks any
|
||||
wantDiscs any
|
||||
}{
|
||||
{
|
||||
// The common case, and the one a diff guard would skip: the
|
||||
// file declares no total at all, so the total "has not
|
||||
// changed" and would never be written.
|
||||
name: "a file with no total gets one",
|
||||
cand: Candidate{Tracks: []CandidateTrack{
|
||||
{Position: 1}, {Position: 2}, {Position: 3},
|
||||
}},
|
||||
local: LocalTrack{TrackNumber: 1},
|
||||
track: CandidateTrack{Position: 1},
|
||||
wantTracks: 3,
|
||||
wantDiscs: 1,
|
||||
},
|
||||
{
|
||||
// 5 here would be the release's track count. Summed once
|
||||
// per disc by GetAlbumCompleteness that claims a ten-track
|
||||
// expectation for a five-track album, which no library can
|
||||
// ever satisfy.
|
||||
name: "a multi-disc release totals the track's own disc",
|
||||
cand: twoDiscs,
|
||||
local: LocalTrack{},
|
||||
track: CandidateTrack{DiscNumber: 2, Position: 1},
|
||||
wantTracks: 3,
|
||||
wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
name: "the other disc gets its own total",
|
||||
cand: twoDiscs,
|
||||
local: LocalTrack{},
|
||||
track: CandidateTrack{DiscNumber: 1, Position: 1},
|
||||
wantTracks: 2,
|
||||
wantDiscs: 2,
|
||||
},
|
||||
{
|
||||
// A candidate with no tracklist knows nothing, and writing
|
||||
// a zero would claim it did.
|
||||
name: "a candidate with no tracklist writes no total",
|
||||
cand: Candidate{},
|
||||
local: LocalTrack{},
|
||||
track: CandidateTrack{Position: 1},
|
||||
wantTracks: nil,
|
||||
wantDiscs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
changes := buildChanges(tc.local, tc.cand, tc.track)
|
||||
|
||||
if got := changes[FieldTotalTracks]; got != tc.wantTracks {
|
||||
t.Errorf("%s: got %v, want %v", FieldTotalTracks, got, tc.wantTracks)
|
||||
}
|
||||
|
||||
if got := changes[FieldTotalDiscs]; got != tc.wantDiscs {
|
||||
t.Errorf("%s: got %v, want %v", FieldTotalDiscs, got, tc.wantDiscs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,13 @@ func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
|
||||
// genuine multi-disc release still separates correctly, since its
|
||||
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
|
||||
// number.
|
||||
//
|
||||
// This is the single-file fallback used where a whole directory's
|
||||
// disc tags aren't available (e.g. maybeRebindTaggingGroup, which
|
||||
// rebinds one changed file at a time). Where a directory's full set
|
||||
// of raw disc numbers IS available, prefer ResolveDirectoryDiscNumbers
|
||||
// instead — a hardcoded "1" is the wrong guess for an untagged track
|
||||
// sitting alongside siblings that all agree on disc 2.
|
||||
func normalizeDiscNumber(discNumber int) int {
|
||||
if discNumber <= 0 {
|
||||
return 1
|
||||
@@ -100,3 +107,55 @@ func normalizeDiscNumber(discNumber int) int {
|
||||
|
||||
return discNumber
|
||||
}
|
||||
|
||||
// ResolveDirectoryDiscNumbers returns, for one directory's files, the
|
||||
// disc number each should use when computing its GroupKey.
|
||||
//
|
||||
// normalizeDiscNumber's fixed "fold untagged to disc 1" is only a
|
||||
// safe guess when the caller has no other evidence. Given the whole
|
||||
// directory's raw disc tags at once, a better guess is available: if
|
||||
// every file that DOES carry an explicit disc number agrees on the
|
||||
// same value, an untagged sibling is almost certainly the same disc
|
||||
// — a partially re-tagged rip, not a stray track from a different
|
||||
// one — so it folds to that value instead of a hardcoded 1. If the
|
||||
// directory's explicit disc numbers disagree, it's a genuine
|
||||
// multi-disc release with no per-disc subfolders, and there's no
|
||||
// single disc to guess for the untagged ones, so they fall back to
|
||||
// normalizeDiscNumber's default.
|
||||
//
|
||||
// rawDiscNumbers must be in the same order as the files they belong
|
||||
// to; the returned slice mirrors that order 1:1.
|
||||
func ResolveDirectoryDiscNumbers(rawDiscNumbers []int) []int {
|
||||
consensus := 0
|
||||
ambiguous := false
|
||||
|
||||
for _, d := range rawDiscNumbers {
|
||||
if d <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case consensus == 0:
|
||||
consensus = d
|
||||
case consensus != d:
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
fallback := 1
|
||||
if consensus > 0 && !ambiguous {
|
||||
fallback = consensus
|
||||
}
|
||||
|
||||
out := make([]int, len(rawDiscNumbers))
|
||||
|
||||
for i, d := range rawDiscNumbers {
|
||||
if d <= 0 {
|
||||
out[i] = fallback
|
||||
} else {
|
||||
out[i] = d
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -111,6 +111,72 @@ func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_UntaggedFoldsToConsensus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A folder that's really disc 2, partially re-tagged: untagged
|
||||
// tracks should join disc 2, not fall back to a hardcoded disc 1.
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{2, 0, 2, 0})
|
||||
want := []int{2, 2, 2, 2}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_AllUntaggedFallsBackToOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{0, 0, 0})
|
||||
want := []int{1, 1, 1}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_GenuineMultiDiscKeepsExplicitValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Explicit disagreement (disc 1 and disc 2 both present, no
|
||||
// subfolders) means there's no single disc to guess for the
|
||||
// untagged track — it falls back to normalizeDiscNumber's default
|
||||
// rather than being assigned to either disc.
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{1, 1, 2, 2, 0})
|
||||
want := []int{1, 1, 2, 2, 1}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_PreservesExplicitValuesEvenWhenUnanimous(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Every file already agrees on disc 3 — nothing to resolve, but
|
||||
// the explicit values must pass through unchanged.
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{3, 3, 3})
|
||||
want := []int{3, 3, 3}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func equalInts(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup(
|
||||
}
|
||||
|
||||
// ResolveLocal returns candidate releases sourced from the local
|
||||
// DB's release_groups rows (filtered to those carrying an MBID)
|
||||
// DB's albums (filtered to those carrying an MBID)
|
||||
// whose normalized name matches the tagging item's album name.
|
||||
// No network calls. Candidates carry all tracks flat; caller runs
|
||||
// AlignTracks on each to produce per-track alignments.
|
||||
@@ -67,7 +67,7 @@ func (r *LocalResolver) ResolveLocal(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
|
||||
rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list local candidates: %w", err)
|
||||
}
|
||||
@@ -84,12 +84,12 @@ func (r *LocalResolver) ResolveLocal(
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := byID[row.ReleaseGroupID]; !ok {
|
||||
byID[row.ReleaseGroupID] = localCandidate(row)
|
||||
if _, ok := byID[row.AlbumID]; !ok {
|
||||
byID[row.AlbumID] = localCandidate(row)
|
||||
}
|
||||
|
||||
tracksByID[row.ReleaseGroupID] = append(
|
||||
tracksByID[row.ReleaseGroupID],
|
||||
tracksByID[row.AlbumID] = append(
|
||||
tracksByID[row.AlbumID],
|
||||
CandidateTrack{
|
||||
Position: int(row.TrackNumber),
|
||||
DiscNumber: int(row.DiscNumber),
|
||||
@@ -113,15 +113,15 @@ func (r *LocalResolver) ResolveLocal(
|
||||
// localCandidate converts one sqlc row (minus track-level fields)
|
||||
// into a Candidate shell. Track fields and alignments are filled
|
||||
// in by the caller.
|
||||
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
|
||||
func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate {
|
||||
date := ""
|
||||
if row.Year > 0 {
|
||||
date = fmt.Sprintf("%04d", row.Year)
|
||||
}
|
||||
|
||||
mbid := ""
|
||||
if row.ReleaseGroupMbid.Valid {
|
||||
mbid = row.ReleaseGroupMbid.String
|
||||
if row.AlbumMbid.Valid {
|
||||
mbid = row.AlbumMbid.String
|
||||
}
|
||||
|
||||
return &Candidate{
|
||||
|
||||
+84
-78
@@ -75,51 +75,93 @@ func trackAlbumTags(tracks []LocalTrack) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// TrackCluster is a set of local tracks sharing a non-empty (album,
|
||||
// album-artist) tag pair — a candidate sub-album hiding inside a
|
||||
// mixed-bag folder.
|
||||
// TrackCluster is a set of local tracks whose album (and album-artist)
|
||||
// tags are close enough to describe the same release — a candidate
|
||||
// sub-album hiding inside a mixed-bag folder.
|
||||
type TrackCluster struct {
|
||||
AlbumName string
|
||||
AlbumArtist string
|
||||
Tracks []LocalTrack
|
||||
}
|
||||
|
||||
// ClusterByAlbumArtist groups tracks by normalized (album tag,
|
||||
// album-artist tag) and returns the clusters with at least
|
||||
// clusterMinSize members, in first-seen order (the caller typically
|
||||
// passes tracks already ordered by disc/track/path, so this stays
|
||||
// deterministic run to run). Tracks with no album tag, or whose
|
||||
// cluster never reaches clusterMinSize, are omitted — they belong in
|
||||
// the leftover folder, not a synthetic group of their own.
|
||||
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
||||
type key struct{ album, artist string }
|
||||
// clusterFuzzyThreshold is the maximum stringDist between a track's
|
||||
// album tag (and, separately, its album-artist tag) and the tags that
|
||||
// started a cluster for the two to be considered the same album.
|
||||
// Tight enough to keep genuinely different albums by the same artist
|
||||
// apart, loose enough to absorb the kind of typo, dropped diacritic,
|
||||
// or stray whitespace that exact Normalize()-equality clustering used
|
||||
// to split into separate clusters — the same distance function
|
||||
// candidate scoring already uses to decide two titles describe the
|
||||
// same release (rank.go's albumTitleFit/artistCreditFit), applied to
|
||||
// the same question here: do these two tags name the same thing.
|
||||
const clusterFuzzyThreshold = 0.15
|
||||
|
||||
index := make(map[key]int, 4) //nolint:mnd
|
||||
// clusterTracks groups tracks into candidate sub-albums: a track
|
||||
// joins the first existing cluster whose founding track's album tag
|
||||
// is within clusterFuzzyThreshold (in stringDist terms), and whose
|
||||
// album-artist tag either also matches or is empty on either side —
|
||||
// same "empty means unknown, not a mismatch" contract as
|
||||
// artistCreditFit — or else it starts a new cluster. Tracks with no
|
||||
// album tag are left unassigned (memberOf entry -1).
|
||||
//
|
||||
// Comparing only against the cluster's founding track, not a running
|
||||
// centroid or every member, keeps this O(tracks × clusters) and
|
||||
// deterministic in first-seen order — the order ClusterByAlbumArtist
|
||||
// and SplitPlan's callers already depend on (they pass tracks ordered
|
||||
// by disc/track/path).
|
||||
func clusterTracks(tracks []LocalTrack) (clusters []TrackCluster, memberOf []int) {
|
||||
type rep struct{ album, artist string }
|
||||
|
||||
var clusters []TrackCluster
|
||||
var reps []rep
|
||||
|
||||
for _, t := range tracks {
|
||||
album := Normalize(t.AlbumTag)
|
||||
if album == "" {
|
||||
continue
|
||||
}
|
||||
memberOf = make([]int, len(tracks))
|
||||
|
||||
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
|
||||
|
||||
if i, ok := index[k]; ok {
|
||||
clusters[i].Tracks = append(clusters[i].Tracks, t)
|
||||
for i, t := range tracks {
|
||||
if Normalize(t.AlbumTag) == "" {
|
||||
memberOf[i] = -1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
index[k] = len(clusters)
|
||||
clusters = append(clusters, TrackCluster{
|
||||
AlbumName: t.AlbumTag,
|
||||
AlbumArtist: t.AlbumArtistTag,
|
||||
Tracks: []LocalTrack{t},
|
||||
})
|
||||
joined := -1
|
||||
|
||||
for ci, r := range reps {
|
||||
artistMatches := t.AlbumArtistTag == "" || r.artist == "" ||
|
||||
stringDist(t.AlbumArtistTag, r.artist) <= clusterFuzzyThreshold
|
||||
|
||||
if artistMatches && stringDist(t.AlbumTag, r.album) <= clusterFuzzyThreshold {
|
||||
joined = ci
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if joined < 0 {
|
||||
joined = len(clusters)
|
||||
|
||||
reps = append(reps, rep{album: t.AlbumTag, artist: t.AlbumArtistTag})
|
||||
clusters = append(clusters, TrackCluster{
|
||||
AlbumName: t.AlbumTag,
|
||||
AlbumArtist: t.AlbumArtistTag,
|
||||
})
|
||||
}
|
||||
|
||||
clusters[joined].Tracks = append(clusters[joined].Tracks, t)
|
||||
memberOf[i] = joined
|
||||
}
|
||||
|
||||
return clusters, memberOf
|
||||
}
|
||||
|
||||
// ClusterByAlbumArtist groups tracks by album/album-artist tag
|
||||
// similarity (see clusterTracks) and returns the clusters with at
|
||||
// least clusterMinSize members, in first-seen order. Tracks with no
|
||||
// album tag, or whose cluster never reaches clusterMinSize, are
|
||||
// omitted — they belong in the leftover folder, not a synthetic group
|
||||
// of their own.
|
||||
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
||||
clusters, _ := clusterTracks(tracks)
|
||||
|
||||
out := clusters[:0]
|
||||
|
||||
for _, c := range clusters {
|
||||
@@ -134,55 +176,19 @@ func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
||||
// SplitPlan returns the full set of synthetic groups a mixed-bag
|
||||
// folder should be torn into: ClusterByAlbumArtist's tag-matched
|
||||
// sub-albums, plus a one-track cluster for every track that didn't
|
||||
// share an (album, album-artist) pair with anything else in the
|
||||
// folder. Unlike ClusterByAlbumArtist alone — which leaves
|
||||
// unclustered tracks behind in the parent group, where they'd still
|
||||
// get folded into whatever partial-album match the scorer finds for
|
||||
// the rest of the pile — this guarantees every track leaves the
|
||||
// parent, so a folder of entirely unrelated singles (no two tracks
|
||||
// share an album tag) still gets torn apart instead of being scored
|
||||
// as one bogus album with a pile of "extra" tracks. Each singleton's
|
||||
// evidence-scaled score (rank.go) keeps it appropriately humble on
|
||||
// its own — it just no longer drags an unrelated release's score
|
||||
// down, or gets dragged down by one.
|
||||
// end up sharing a cluster with anything else in the folder. Unlike
|
||||
// ClusterByAlbumArtist alone — which leaves unclustered tracks behind
|
||||
// in the parent group, where they'd still get folded into whatever
|
||||
// partial-album match the scorer finds for the rest of the pile —
|
||||
// this guarantees every track leaves the parent, so a folder of
|
||||
// entirely unrelated singles (no two tracks share an album tag) still
|
||||
// gets torn apart instead of being scored as one bogus album with a
|
||||
// pile of "extra" tracks. Each singleton's evidence-scaled score
|
||||
// (rank.go) keeps it appropriately humble on its own — it just no
|
||||
// longer drags an unrelated release's score down, or gets dragged
|
||||
// down by one.
|
||||
func SplitPlan(tracks []LocalTrack) []TrackCluster {
|
||||
type key struct{ album, artist string }
|
||||
|
||||
index := make(map[key]int, 4) //nolint:mnd
|
||||
|
||||
var clusters []TrackCluster
|
||||
|
||||
// memberOf[i] is 1+the cluster index track i was assigned to (by
|
||||
// album/artist tag match), or 0 if it never matched anything.
|
||||
// Tracked by slice position rather than any LocalTrack field —
|
||||
// AudioFileID/FilePath are frequently zero-valued in this
|
||||
// package's own tests and would collide, wrongly treating
|
||||
// distinct untagged tracks as duplicates of one another.
|
||||
memberOf := make([]int, len(tracks))
|
||||
|
||||
for i, t := range tracks {
|
||||
album := Normalize(t.AlbumTag)
|
||||
if album == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
|
||||
|
||||
if ci, ok := index[k]; ok {
|
||||
clusters[ci].Tracks = append(clusters[ci].Tracks, t)
|
||||
memberOf[i] = ci + 1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
index[k] = len(clusters)
|
||||
memberOf[i] = len(clusters) + 1
|
||||
clusters = append(clusters, TrackCluster{
|
||||
AlbumName: t.AlbumTag,
|
||||
AlbumArtist: t.AlbumArtistTag,
|
||||
Tracks: []LocalTrack{t},
|
||||
})
|
||||
}
|
||||
clusters, memberOf := clusterTracks(tracks)
|
||||
|
||||
// Clusters that never reached clusterMinSize don't survive as a
|
||||
// group; their sole member falls through to the singleton pass
|
||||
@@ -198,7 +204,7 @@ func SplitPlan(tracks []LocalTrack) []TrackCluster {
|
||||
}
|
||||
|
||||
for i, t := range tracks {
|
||||
if ci := memberOf[i] - 1; ci >= 0 {
|
||||
if ci := memberOf[i]; ci >= 0 {
|
||||
if _, ok := keptIndex[ci]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -140,6 +140,84 @@ func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_TypoVariantsMergeIntoOneCluster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A dropped diacritic and a stray trailing space are the kind of
|
||||
// noise exact Normalize()-equality clustering used to treat as
|
||||
// two different albums, splitting one real album across clusters
|
||||
// even though a candidate search on either would land on the same
|
||||
// release. Fuzzy clustering absorbs both into one cluster.
|
||||
tracks := []LocalTrack{
|
||||
{
|
||||
Title: "Song A",
|
||||
Artist: "Sigur Ros",
|
||||
AlbumTag: "Agaetis Byrjun",
|
||||
AlbumArtistTag: "Sigur Ros",
|
||||
},
|
||||
{
|
||||
Title: "Song B",
|
||||
Artist: "Sigur Ros",
|
||||
AlbumTag: "Ágætis byrjun",
|
||||
AlbumArtistTag: "Sigur Ros",
|
||||
},
|
||||
{
|
||||
Title: "Song C",
|
||||
Artist: "Sigur Ros",
|
||||
AlbumTag: "Agaetis Byrjun ",
|
||||
AlbumArtistTag: "Sigur Ros",
|
||||
},
|
||||
}
|
||||
|
||||
clusters := ClusterByAlbumArtist(tracks)
|
||||
|
||||
if len(clusters) != 1 {
|
||||
t.Fatalf(
|
||||
"expected typo variants to merge into 1 cluster, got %d: %+v",
|
||||
len(clusters),
|
||||
clusters,
|
||||
)
|
||||
}
|
||||
|
||||
if len(clusters[0].Tracks) != 3 { //nolint:mnd
|
||||
t.Fatalf("expected all 3 tracks in the merged cluster, got %d", len(clusters[0].Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_DifferentAlbumsBySameArtistStaySeparate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Fuzzy clustering must not blur genuinely different albums by
|
||||
// the same artist into one cluster just because they share an
|
||||
// artist tag — the threshold has to stay tight enough for this.
|
||||
tracks := []LocalTrack{
|
||||
{
|
||||
Title: "Song A",
|
||||
Artist: "Radiohead",
|
||||
AlbumTag: "OK Computer",
|
||||
AlbumArtistTag: "Radiohead",
|
||||
},
|
||||
{
|
||||
Title: "Song B",
|
||||
Artist: "Radiohead",
|
||||
AlbumTag: "OK Computer",
|
||||
AlbumArtistTag: "Radiohead",
|
||||
},
|
||||
{Title: "Song C", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
|
||||
{Title: "Song D", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
|
||||
}
|
||||
|
||||
clusters := ClusterByAlbumArtist(tracks)
|
||||
|
||||
if len(clusters) != 2 { //nolint:mnd
|
||||
t.Fatalf(
|
||||
"expected OK Computer and Kid A to stay separate, got %d clusters: %+v",
|
||||
len(clusters),
|
||||
clusters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -17,6 +17,34 @@ const (
|
||||
RecommendationStrong Recommendation = "strong"
|
||||
)
|
||||
|
||||
// ConfidentTier is the tier at which this package considers a match
|
||||
// good enough to act on without being asked to look.
|
||||
//
|
||||
// It exists as a name rather than as `== RecommendationStrong` at
|
||||
// each call site because two features read it and they must not
|
||||
// disagree about what "high confidence" means: the album page tells
|
||||
// the user unprompted that the autotagger has a match (#28), and
|
||||
// strict auto-accept will rewrite the files without asking (#90).
|
||||
// A page that says "we are sure" about something the auto-accept
|
||||
// pass would decline is the app contradicting itself.
|
||||
//
|
||||
// What the two do *not* share is everything else. Surfacing a match
|
||||
// is a suggestion with a confirm dialog behind it; auto-accept is an
|
||||
// irreversible on-disk rewrite, and #90 gates it on further
|
||||
// conditions this tier cannot express — exact track count, every
|
||||
// title matching, lengths within a couple of seconds, no cover
|
||||
// replacement, no MBID conflict. So this is the floor both stand on,
|
||||
// not the whole of either test.
|
||||
const ConfidentTier = RecommendationStrong
|
||||
|
||||
// Confident reports whether a tier clears ConfidentTier.
|
||||
//
|
||||
// A comparison rather than an equality, so adding a tier above
|
||||
// "strong" later does not silently stop qualifying.
|
||||
func Confident(r Recommendation) bool {
|
||||
return recommendationRank(r) >= recommendationRank(ConfidentTier)
|
||||
}
|
||||
|
||||
const (
|
||||
// Absolute score tiers.
|
||||
strongScoreThresh = 0.90
|
||||
|
||||
@@ -168,3 +168,34 @@ func TestRecommend_LocalCandidatesWithoutRGMBIDCompareByTitle(t *testing.T) {
|
||||
t.Errorf("different-title rival: Recommend = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The tier both features stand on is one name, checked here rather
|
||||
// than assumed at two call sites.
|
||||
//
|
||||
// #28 renders "we have a match for this album" on the album page and
|
||||
// #90 will rewrite files without asking; a page that claims confidence
|
||||
// the auto-accept pass would decline is the app contradicting itself.
|
||||
// What they do not share is everything else — auto-accept adds gates
|
||||
// this tier cannot express — so this pins the floor, not the whole of
|
||||
// either test.
|
||||
func TestConfidentIsTheOneSharedFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if ConfidentTier != RecommendationStrong {
|
||||
t.Errorf("ConfidentTier = %q, want strong", ConfidentTier)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
rec Recommendation
|
||||
want bool
|
||||
}{
|
||||
{RecommendationNone, false},
|
||||
{RecommendationLow, false},
|
||||
{RecommendationMedium, false},
|
||||
{RecommendationStrong, true},
|
||||
} {
|
||||
if got := Confident(tc.rec); got != tc.want {
|
||||
t.Errorf("Confident(%q) = %v, want %v", tc.rec, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ package autotag_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// seedAlbum drops a minimal release_group + recordings + audio_files
|
||||
@@ -33,70 +31,19 @@ type seededTrack struct {
|
||||
func seed(t *testing.T, db *database.DB, album seededAlbum) {
|
||||
t.Helper()
|
||||
|
||||
ctx := db.Ctx
|
||||
q := db.Queries
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert ac: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: album.albumName,
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert rg: %v", err)
|
||||
}
|
||||
|
||||
if album.releaseMBID != "" {
|
||||
if _, err := db.ExecContext(
|
||||
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
|
||||
album.releaseMBID, rg.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("set rg mbid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tr := range album.tracks {
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: tr.title,
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: tr.filePath,
|
||||
Title: tr.title,
|
||||
Artist: "Test Artist",
|
||||
Album: album.albumName,
|
||||
AlbumMBID: album.releaseMBID,
|
||||
RecordingMBID: tr.recordingMBID,
|
||||
TrackNumber: int64(tr.trackNumber),
|
||||
LengthMs: tr.lengthMillis,
|
||||
LibraryID: album.libraryID,
|
||||
GroupKey: album.groupKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
if tr.recordingMBID != "" {
|
||||
if _, err := db.ExecContext(
|
||||
`UPDATE recordings SET mbid = ? WHERE id = ?`,
|
||||
tr.recordingMBID, rec.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("set recording mbid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("link rg recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: tr.filePath,
|
||||
LengthMilliseconds: tr.lengthMillis,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: tr.filePath,
|
||||
LibraryID: album.libraryID,
|
||||
GroupKey: album.groupKey,
|
||||
TagStatus: "untagged",
|
||||
}); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
)
|
||||
|
||||
// AlbumMatchView is "the autotagger already has a confident match for
|
||||
// the album you are looking at".
|
||||
//
|
||||
// It is deliberately not a score. The album page renders a suggestion,
|
||||
// and a suggestion has to be actionable: which release, what it is
|
||||
// called, and whether acting on it here would do the whole album or
|
||||
// only part of it.
|
||||
type AlbumMatchView struct {
|
||||
// GroupKey is the tagging group the actions operate on.
|
||||
GroupKey string `json:"groupKey"`
|
||||
|
||||
// Recommendation is the tier, as a string, for a caller that
|
||||
// wants to render the strength rather than trust the filter.
|
||||
Recommendation string `json:"recommendation"`
|
||||
|
||||
// Score is the top candidate's raw score, 0..1.
|
||||
Score float64 `json:"score"`
|
||||
|
||||
// ReleaseMBID is the release Apply would write.
|
||||
ReleaseMBID string `json:"releaseMbid"`
|
||||
|
||||
// Title and ArtistCredit name that release, so the banner can say
|
||||
// what it is offering rather than "a match".
|
||||
Title string `json:"title"`
|
||||
ArtistCredit string `json:"artistCredit"`
|
||||
|
||||
// TrackCount is the group's local track count.
|
||||
TrackCount int64 `json:"trackCount"`
|
||||
|
||||
// GroupCount is how many tagging groups this album spans.
|
||||
//
|
||||
// More than one means a multi-disc album (one group per disc), and
|
||||
// it is the reason this is a field rather than an implementation
|
||||
// detail: applying "the album" from a single button would retag
|
||||
// one disc of three and leave the folder holding a mix of old and
|
||||
// new tags. The caller offers review instead.
|
||||
GroupCount int `json:"groupCount"`
|
||||
}
|
||||
|
||||
// MatchForAlbum answers "does the autotagger have something confident
|
||||
// to say about this album", for the album detail page.
|
||||
//
|
||||
// Three things about it are load-bearing.
|
||||
//
|
||||
// **It costs no MusicBrainz request.** Everything it needs is already
|
||||
// on disk: `tagging_items` carries the top score and release from the
|
||||
// background prefetch, and `tagging_candidates` durably holds the
|
||||
// scored list. The rate limiters here are shared with every page the
|
||||
// user can open, so a lookup that fires on page load must not join
|
||||
// that queue — which also means this returns nothing for a folder
|
||||
// nobody has scored yet, rather than scoring it now. That is the
|
||||
// right trade: the prefetch will get to it, and a page that silently
|
||||
// spends a minute of somebody's MusicBrainz budget to draw a banner
|
||||
// is worse than a page that says nothing.
|
||||
//
|
||||
// **The tier is computed, not read.** `tagging_items.score` is the raw
|
||||
// number and `Recommend` is what turns it into a claim — capping it
|
||||
// for an ambiguous runner-up, an incomplete alignment or a folder too
|
||||
// small to corroborate itself. Filtering on the raw score would
|
||||
// promise confidence the scorer had explicitly withheld.
|
||||
//
|
||||
// **Nothing is said about an album the user has already answered
|
||||
// for.** Only a `pending` group qualifies: `confirmed` covers both a
|
||||
// finished apply and an explicit "leave as is", and `skipped` is the
|
||||
// user saying not now. Re-offering either is nagging, and "leave as
|
||||
// is" would be actively wrong to argue with.
|
||||
func (s *Service) MatchForAlbum(albumID int64) (*AlbumMatchView, error) {
|
||||
if albumID <= 0 {
|
||||
return nil, nil //nolint:nilnil // "no album" is not an error.
|
||||
}
|
||||
|
||||
rows, err := s.db.Queries.GetTaggingItemsForAlbum(
|
||||
s.ctx, sql.NullInt64{Int64: albumID, Valid: true},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tagging items for album: %w", err)
|
||||
}
|
||||
|
||||
pending := rows[:0:0]
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Status == "pending" {
|
||||
pending = append(pending, row)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return nil, nil //nolint:nilnil // nothing to say is not an error.
|
||||
}
|
||||
|
||||
// Rows arrive best-score-first, so the first pending one is the
|
||||
// group worth describing. On a multi-disc album that is one disc
|
||||
// of several and GroupCount says so.
|
||||
best := pending[0]
|
||||
|
||||
cands := s.lookupCachedCandidates(best.GroupKey)
|
||||
if len(cands) == 0 {
|
||||
return nil, nil //nolint:nilnil // not scored yet; see the doc comment.
|
||||
}
|
||||
|
||||
locals, err := s.scorer.LocalTracksForGroup(s.ctx, best.GroupKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local tracks for group: %w", err)
|
||||
}
|
||||
|
||||
group := autotag.Group{
|
||||
AlbumName: best.AlbumName,
|
||||
AlbumArtist: best.AlbumArtist,
|
||||
Tracks: locals,
|
||||
Synthetic: best.Synthetic != 0,
|
||||
}
|
||||
|
||||
rec := autotag.Recommend(group, cands)
|
||||
if !autotag.Confident(rec) {
|
||||
return nil, nil //nolint:nilnil // not confident enough to interrupt.
|
||||
}
|
||||
|
||||
top := cands[0]
|
||||
|
||||
// The release the banner names must be the release Apply would
|
||||
// write. Apply with an empty MBID takes the top cached candidate,
|
||||
// which is what this reads — but it is passed explicitly anyway,
|
||||
// so a rescore between the page rendering and the user clicking
|
||||
// cannot swap the album out from under a button they have already
|
||||
// read.
|
||||
return &AlbumMatchView{
|
||||
GroupKey: best.GroupKey,
|
||||
Recommendation: string(rec),
|
||||
Score: top.Score,
|
||||
ReleaseMBID: top.ReleaseMBID,
|
||||
Title: top.Title,
|
||||
ArtistCredit: top.ArtistCredit,
|
||||
TrackCount: best.TrackCount,
|
||||
GroupCount: len(pending),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// seedAlbumGroup writes one album's files, its tagging item and the
|
||||
// durable candidate blob the prefetch would have left behind.
|
||||
//
|
||||
// The candidate list is what a real one looks like in the two ways
|
||||
// that decide the tier: a per-track alignment for every local track,
|
||||
// and a runner-up far enough away not to count as ambiguity.
|
||||
func seedAlbumGroup(
|
||||
t *testing.T,
|
||||
db *database.DB,
|
||||
groupKey string,
|
||||
tracks int,
|
||||
status string,
|
||||
score float64,
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
for i := 1; i <= tracks; i++ {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePathFor(groupKey, i),
|
||||
Title: titleFor(i),
|
||||
Artist: "Tideline",
|
||||
Album: "Glass Harbour",
|
||||
AlbumArtist: "Tideline",
|
||||
TrackNumber: int64(i),
|
||||
LengthMs: 200000,
|
||||
LibraryID: 0,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items
|
||||
(group_key, library_id, track_count, album_name, album_artist,
|
||||
disc_number, status, score, best_match_release_mbid)
|
||||
VALUES (?, 0, ?, 'Glass Harbour', 'Tideline', 0, ?, ?, 'rel-1')
|
||||
`, groupKey, tracks, status, score); err != nil {
|
||||
t.Fatalf("insert tagging item: %v", err)
|
||||
}
|
||||
|
||||
var albumID int64
|
||||
if err := db.QueryRowWriter(
|
||||
`SELECT album_id FROM audio_files WHERE group_key = ? LIMIT 1`, groupKey,
|
||||
).Scan(&albumID); err != nil {
|
||||
t.Fatalf("read album id: %v", err)
|
||||
}
|
||||
|
||||
return albumID
|
||||
}
|
||||
|
||||
func filePathFor(groupKey string, n int) string {
|
||||
return "/music/" + groupKey + "/0" + string(rune('0'+n)) + ".mp3"
|
||||
}
|
||||
|
||||
func titleFor(n int) string {
|
||||
return "Track " + string(rune('0'+n))
|
||||
}
|
||||
|
||||
// storeCandidates writes the durable blob GetCandidates would have
|
||||
// cached, with `top` as the winning score.
|
||||
func storeCandidates(
|
||||
t *testing.T, db *database.DB, groupKey string, tracks int, top float64,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
aligns := make([]autotag.TrackAlignment, 0, tracks)
|
||||
for i := range tracks {
|
||||
aligns = append(aligns, autotag.TrackAlignment{
|
||||
Status: autotag.AlignmentMatched,
|
||||
LocalIndex: i,
|
||||
})
|
||||
}
|
||||
|
||||
cands := []autotag.Candidate{
|
||||
{
|
||||
ReleaseMBID: "rel-1",
|
||||
ReleaseGroupMBID: "rg-1",
|
||||
Title: "Glass Harbour",
|
||||
ArtistCredit: "Tideline",
|
||||
TrackCount: tracks,
|
||||
Alignments: aligns,
|
||||
Score: top,
|
||||
},
|
||||
{
|
||||
ReleaseMBID: "rel-2",
|
||||
ReleaseGroupMBID: "rg-2",
|
||||
Title: "Something Else",
|
||||
ArtistCredit: "Another Band",
|
||||
TrackCount: tracks,
|
||||
Score: 0.40,
|
||||
},
|
||||
}
|
||||
|
||||
blob, err := json.Marshal(cands)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal candidates: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
`INSERT INTO tagging_candidates (group_key, candidates) VALUES (?, ?)`,
|
||||
groupKey, string(blob),
|
||||
); err != nil {
|
||||
t.Fatalf("insert candidates: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A confident match is what the album page exists to surface.
|
||||
func TestMatchForAlbumSurfacesAConfidentMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-1", 8, "pending", 0.95)
|
||||
storeCandidates(t, db, "grp-1", 8, 0.95)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("no match returned for a strong candidate")
|
||||
}
|
||||
|
||||
if got.Recommendation != string(autotag.RecommendationStrong) {
|
||||
t.Errorf("recommendation = %q, want strong", got.Recommendation)
|
||||
}
|
||||
|
||||
// The release named is the release Apply would write — the page
|
||||
// must not offer one album and tag another.
|
||||
if got.ReleaseMBID != "rel-1" || got.Title != "Glass Harbour" {
|
||||
t.Errorf("named %q/%q, want rel-1/Glass Harbour", got.ReleaseMBID, got.Title)
|
||||
}
|
||||
|
||||
if got.GroupCount != 1 {
|
||||
t.Errorf("groupCount = %d, want 1", got.GroupCount)
|
||||
}
|
||||
}
|
||||
|
||||
// The tier is computed from the candidates, not read off the raw
|
||||
// score — a high number the scorer would have capped must not reach
|
||||
// the page as confidence it withheld.
|
||||
func TestMatchForAlbumDoesNotTrustTheStoredScore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
// Two tracks: below the evidence floor, so `Recommend` caps this
|
||||
// at medium however well it scores.
|
||||
albumID := seedAlbumGroup(t, db, "grp-2", 2, "pending", 0.99)
|
||||
storeCandidates(t, db, "grp-2", 2, 0.99)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v for a two-track folder, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A weak match is not worth interrupting for.
|
||||
func TestMatchForAlbumStaysQuietBelowTheTier(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-3", 8, "pending", 0.60)
|
||||
storeCandidates(t, db, "grp-3", 8, 0.60)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v for a 0.60 match, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An album the user has already answered for is not re-offered.
|
||||
//
|
||||
// `confirmed` covers both a finished apply and an explicit "leave as
|
||||
// is", and arguing with the second would be actively wrong.
|
||||
func TestMatchForAlbumRespectsAnAnswerAlreadyGiven(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, status := range []string{"confirmed", "skipped", "matched"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-"+status, 8, status, 0.95)
|
||||
storeCandidates(t, db, "grp-"+status, 8, 0.95)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v for a %s group, want nothing", got, status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A folder nobody has scored yet says nothing, rather than scoring it
|
||||
// now: the MusicBrainz limiter is shared with every page the user can
|
||||
// open, and this runs on page load.
|
||||
func TestMatchForAlbumMakesNoNetworkCallForAnUnscoredFolder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
// No storeCandidates: the prefetch has not reached this folder.
|
||||
albumID := seedAlbumGroup(t, db, "grp-4", 8, "pending", 0.95)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("surfaced %+v with no cached candidates, want nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-disc album is several groups, and the count is what stops
|
||||
// the page offering one button that would retag one disc of two.
|
||||
func TestMatchForAlbumCountsEveryGroupOfTheAlbum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
albumID := seedAlbumGroup(t, db, "grp-d1", 8, "pending", 0.95)
|
||||
storeCandidates(t, db, "grp-d1", 8, 0.95)
|
||||
|
||||
// Disc two: same album row, its own folder and tagging group.
|
||||
for i := 1; i <= 6; i++ {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePathFor("grp-d2", i),
|
||||
Title: titleFor(i),
|
||||
Artist: "Tideline",
|
||||
Album: "Glass Harbour",
|
||||
AlbumArtist: "Tideline",
|
||||
TrackNumber: int64(i),
|
||||
DiscNumber: 2,
|
||||
LengthMs: 200000,
|
||||
LibraryID: 0,
|
||||
GroupKey: "grp-d2",
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items
|
||||
(group_key, library_id, track_count, album_name, album_artist,
|
||||
disc_number, status, score)
|
||||
VALUES ('grp-d2', 0, 6, 'Glass Harbour', 'Tideline', 2, 'pending', 0.93)
|
||||
`); err != nil {
|
||||
t.Fatalf("insert disc two: %v", err)
|
||||
}
|
||||
|
||||
storeCandidates(t, db, "grp-d2", 6, 0.93)
|
||||
|
||||
got, err := svc.MatchForAlbum(albumID)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum: %v", err)
|
||||
}
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("no match returned")
|
||||
}
|
||||
|
||||
if got.GroupCount != 2 {
|
||||
t.Errorf("groupCount = %d, want 2", got.GroupCount)
|
||||
}
|
||||
|
||||
// Best-first: the 0.95 disc is the one described.
|
||||
if got.GroupKey != "grp-d1" {
|
||||
t.Errorf("described %q, want the higher-scoring grp-d1", got.GroupKey)
|
||||
}
|
||||
}
|
||||
|
||||
// An album with no local files at all — a pure catalog page — is not
|
||||
// a question this can answer.
|
||||
func TestMatchForAlbumSaysNothingWithoutAnAlbum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
svc := newTestService(t, db)
|
||||
|
||||
for _, id := range []int64{0, -1, 4242} {
|
||||
got, err := svc.MatchForAlbum(id)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchForAlbum(%d): %v", id, err)
|
||||
}
|
||||
|
||||
if got != nil {
|
||||
t.Errorf("MatchForAlbum(%d) = %+v, want nil", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ const applyJobPrefix = "autotag:"
|
||||
// registry gets progress, cancel and the global indicator for free; the
|
||||
// three subsystems that lacked them were the three that were not
|
||||
// registered.
|
||||
//
|
||||
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
||||
s.mu.Lock()
|
||||
s.jobsReg = reg
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
|
||||
// twAdapter passes the diff map through unchanged, so autotag's field
|
||||
// constants and tagwriter's are the same keys written down twice --
|
||||
// deliberately, to keep autotag out of the write pipeline's import
|
||||
// graph. A key that drifts does not fail to compile and does not fail
|
||||
// to write: the writer simply finds no entry under the name it looks
|
||||
// for, and the field is silently dropped. That is what this pins, and
|
||||
// this package is the one place that imports both.
|
||||
func TestAutotagAndTagwriterAgreeOnFieldNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pairs := map[string][2]string{
|
||||
"title": {autotag.FieldTitle, tagwriter.FieldTitle},
|
||||
"artist": {autotag.FieldArtist, tagwriter.FieldArtist},
|
||||
"album": {autotag.FieldAlbum, tagwriter.FieldAlbum},
|
||||
"album artist": {autotag.FieldAlbumArtist, tagwriter.FieldAlbumArtist},
|
||||
"year": {autotag.FieldYear, tagwriter.FieldYear},
|
||||
"track number": {autotag.FieldTrackNumber, tagwriter.FieldTrackNumber},
|
||||
"disc number": {autotag.FieldDiscNumber, tagwriter.FieldDiscNumber},
|
||||
"total tracks": {autotag.FieldTotalTracks, tagwriter.FieldTotalTracks},
|
||||
"total discs": {autotag.FieldTotalDiscs, tagwriter.FieldTotalDiscs},
|
||||
"cover art": {autotag.FieldCoverArt, tagwriter.FieldCoverArt},
|
||||
}
|
||||
|
||||
for name, pair := range pairs {
|
||||
if pair[0] != pair[1] {
|
||||
t.Errorf("%s: autotag says %q, tagwriter says %q", name, pair[0], pair[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
@@ -199,13 +201,20 @@ func NewService(
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext stores the Wails runtime context (called from
|
||||
// OnStartup).
|
||||
func (s *Service) SetContext(ctx context.Context) {
|
||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||
// replaces v2's SetContext, which had to be called by hand from
|
||||
// OnStartup and was exported, so it was also bound to the frontend.
|
||||
func (s *Service) ServiceStartup(
|
||||
ctx context.Context,
|
||||
_ application.ServiceOptions,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.ctx = ctx
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitEvent emits a Wails runtime event under the service lock, which
|
||||
|
||||
@@ -3,12 +3,12 @@ package autotagservice
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// newTestService builds a Service with just enough wired up for
|
||||
@@ -36,62 +36,18 @@ func newTestService(t *testing.T, db *database.DB) *Service {
|
||||
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||
t.Helper()
|
||||
|
||||
ctx := db.Ctx
|
||||
q := db.Queries
|
||||
|
||||
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
|
||||
ac, err := q.UpsertArtistCredit(ctx, artist)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: title,
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePath,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
AlbumArtist: albumArtist,
|
||||
TrackNumber: int64(trackNum),
|
||||
LengthMs: 200000,
|
||||
LibraryID: libraryID,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
if album != "" {
|
||||
albumArtistAC, err := q.UpsertArtistCredit(ctx, albumArtist)
|
||||
if err != nil {
|
||||
t.Fatalf("upsert album artist credit: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: album,
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: albumArtistAC.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert release group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(
|
||||
ctx,
|
||||
sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("link release group recording: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: filePath,
|
||||
LengthMilliseconds: 200000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: filePath,
|
||||
LibraryID: libraryID,
|
||||
GroupKey: groupKey,
|
||||
TagStatus: "untagged",
|
||||
}); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1)
|
||||
@@ -113,58 +69,22 @@ func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryI
|
||||
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||
t.Helper()
|
||||
|
||||
ctx := db.Ctx
|
||||
q := db.Queries
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "The Beatles")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: "Abbey Road",
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert release group: %v", err)
|
||||
}
|
||||
|
||||
titles := []string{"Come Together", "Something", "Maxwell's Silver Hammer", "Oh! Darling"}
|
||||
for i, title := range titles {
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: title,
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
||||
for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1),
|
||||
Title: title,
|
||||
Artist: "The Beatles",
|
||||
Album: "Abbey Road",
|
||||
TrackNumber: int64(i + 1),
|
||||
LengthMs: 200000,
|
||||
LibraryID: libraryID,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("link release group recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: groupKey + "/" + title + ".mp3",
|
||||
LengthMilliseconds: 200000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: title + ".mp3",
|
||||
LibraryID: libraryID,
|
||||
GroupKey: groupKey,
|
||||
TagStatus: "untagged",
|
||||
}); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
VALUES (?, ?, 4, 'Abbey Road', 'The Beatles', 0, 'pending')
|
||||
VALUES (?, ?, 3, 'Abbey Road', 'The Beatles', 0, 'pending')
|
||||
`, groupKey, libraryID); err != nil {
|
||||
t.Fatalf("insert tagging item: %v", err)
|
||||
}
|
||||
@@ -293,20 +213,11 @@ func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) {
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
if _, err := db.Queries.CreateAudioFileWithGroupKey(
|
||||
db.Ctx,
|
||||
sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: "/coherent/01.mp3",
|
||||
FileTypeID: 0,
|
||||
RecordingID: mustCreateRecording(t, db, "Track"),
|
||||
Basename: "01.mp3",
|
||||
LibraryID: 0,
|
||||
GroupKey: "g-coherent",
|
||||
TagStatus: "untagged",
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: "/coherent/01.mp3",
|
||||
Title: "Track",
|
||||
GroupKey: "g-coherent",
|
||||
})
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
@@ -328,20 +239,11 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// A real, live folder — must survive.
|
||||
if _, err := db.Queries.CreateAudioFileWithGroupKey(
|
||||
db.Ctx,
|
||||
sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: "/live/01.mp3",
|
||||
FileTypeID: 0,
|
||||
RecordingID: mustCreateRecording(t, db, "Track"),
|
||||
Basename: "01.mp3",
|
||||
LibraryID: 0,
|
||||
GroupKey: "g-live",
|
||||
TagStatus: "untagged",
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: "/live/01.mp3",
|
||||
Title: "Track",
|
||||
GroupKey: "g-live",
|
||||
})
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
@@ -385,22 +287,3 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
|
||||
t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustCreateRecording(t *testing.T, db *database.DB, title string) int64 {
|
||||
t.Helper()
|
||||
|
||||
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
rec, err := db.Queries.CreateRecordingFull(db.Ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: title,
|
||||
ArtistCreditID: ac.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
return rec.ID
|
||||
}
|
||||
|
||||
+283
-4
@@ -10,6 +10,7 @@ import (
|
||||
"path"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/download"
|
||||
"yellowjacket/backend/events"
|
||||
@@ -35,6 +36,7 @@ type Config struct {
|
||||
loaded bool // true once Load() succeeds
|
||||
Library *library.Config `toml:"Library"`
|
||||
Theme *theme.Config `toml:"Theme"`
|
||||
General *GeneralConfig `toml:"General"`
|
||||
Window *WindowConfig `toml:"Window"`
|
||||
TrackList *tracklist.Config `toml:"TrackList"`
|
||||
Favorites *favorites.Config `toml:"Favorites"`
|
||||
@@ -84,6 +86,12 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.General != nil {
|
||||
if err := c.General.Validate(); err != nil {
|
||||
configErrs = errors.Join(configErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.TrackList != nil {
|
||||
if err := c.TrackList.Validate(); err != nil {
|
||||
configErrs = errors.Join(configErrs, err)
|
||||
@@ -240,6 +248,12 @@ func (c *Config) applyDefaults() {
|
||||
|
||||
c.Theme.ApplyDefaults()
|
||||
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
}
|
||||
|
||||
c.General.ApplyDefaults()
|
||||
|
||||
if c.TrackList == nil {
|
||||
c.TrackList = &tracklist.Config{}
|
||||
}
|
||||
@@ -267,9 +281,17 @@ func (c *Config) applyDefaults() {
|
||||
c.Downloads.ApplyDefaults()
|
||||
}
|
||||
|
||||
// SetContext sets the Wails runtime context for event emission.
|
||||
func (c *Config) SetContext(ctx context.Context) {
|
||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||
// replaces v2's SetContext, which had to be called by hand from
|
||||
// OnStartup and was exported, so it was also bound to the frontend.
|
||||
func (c *Config) ServiceStartup(
|
||||
ctx context.Context,
|
||||
_ application.ServiceOptions,
|
||||
) error {
|
||||
c.ctx = ctx
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLibraryDirectory returns the currently configured library directory path.
|
||||
@@ -389,9 +411,10 @@ func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error
|
||||
formats = append(formats, string(f))
|
||||
}
|
||||
|
||||
c.Downloads.MinFileSizeMB = prefs.MinSizeMB
|
||||
c.Downloads.MinKbps = prefs.MinKbps
|
||||
c.Downloads.MaxKbps = prefs.MaxKbps
|
||||
c.Downloads.PreferredKbps = prefs.PreferredKbps
|
||||
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
|
||||
c.Downloads.PreferredFileSizeMB = prefs.PreferredSizeMB
|
||||
c.Downloads.AllowedFormats = formats
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
@@ -505,6 +528,262 @@ func (c *Config) emitThemeChanged() {
|
||||
)
|
||||
}
|
||||
|
||||
// GetDefaultPage returns the view the app opens to on launch.
|
||||
func (c *Config) GetDefaultPage() string {
|
||||
if c.General == nil {
|
||||
return string(DefaultDefaultPage)
|
||||
}
|
||||
|
||||
return string(c.General.DefaultPage)
|
||||
}
|
||||
|
||||
// SetDefaultPage validates and saves a new launch page.
|
||||
func (c *Config) SetDefaultPage(page string) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.DefaultPage = View(page)
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid default page: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"DefaultPage": string(c.General.DefaultPage),
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"default page updated",
|
||||
"page", page,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQueueFallback returns what plays, if anything, once the queue
|
||||
// runs out.
|
||||
func (c *Config) GetQueueFallback() string {
|
||||
if c.General == nil {
|
||||
return string(DefaultQueueFallback)
|
||||
}
|
||||
|
||||
return string(c.General.QueueFallback)
|
||||
}
|
||||
|
||||
// SetQueueFallback validates and saves a new queue-fallback mode.
|
||||
func (c *Config) SetQueueFallback(mode string) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.QueueFallback = QueueFallback(mode)
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid queue fallback: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"QueueFallback": string(c.General.QueueFallback),
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"queue fallback updated",
|
||||
"mode", mode,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
|
||||
// catalog may be fetched on a metered connection.
|
||||
func (c *Config) GetAllowMeteredCatalogDownload() bool {
|
||||
if c.General == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.General.AllowMeteredCatalogDownload
|
||||
}
|
||||
|
||||
// SetAllowMeteredCatalogDownload saves the metered-download permission.
|
||||
//
|
||||
// There is nothing to validate and nothing to restart: the policy is
|
||||
// read at the moment a download would start, so turning it on takes
|
||||
// effect on the next attempt rather than needing this launch to be over.
|
||||
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.AllowMeteredCatalogDownload = allow
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"AllowMeteredCatalogDownload": allow,
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"metered catalog download permission updated",
|
||||
"allow", allow,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPopupVolume reports whether the bottom bar's volume control is a
|
||||
// click-to-open popup rather than an inline slider (#42).
|
||||
func (c *Config) GetPopupVolume() bool {
|
||||
if c.General == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.General.PopupVolume
|
||||
}
|
||||
|
||||
// SetPopupVolume saves the volume control's presentation.
|
||||
//
|
||||
// Nothing to validate: both values are legal at every width, and the
|
||||
// frontend additionally stands the inline slider down below the phone
|
||||
// breakpoint whatever this says, because that is about room rather than
|
||||
// about preference.
|
||||
func (c *Config) SetPopupVolume(popup bool) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.PopupVolume = popup
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"PopupVolume": popup,
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info("volume control presentation updated", "popup", popup)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetViewVisibility reports which primary views the sidebar should
|
||||
// show, answered for every known view rather than only the ones the
|
||||
// config mentions -- so the frontend filters on a value and never has
|
||||
// to hold a second copy of the defaults.
|
||||
func (c *Config) GetViewVisibility() map[string]bool {
|
||||
if c.General == nil {
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
return general.ResolvedViewVisibility()
|
||||
}
|
||||
|
||||
return c.General.ResolvedViewVisibility()
|
||||
}
|
||||
|
||||
// SetViewVisible shows or hides one primary view.
|
||||
//
|
||||
// Two refusals, both about a state the user cannot get out of from the
|
||||
// UI they would be left with: Settings is never hideable, and the
|
||||
// launch page is never hideable while it is the launch page (change it
|
||||
// first). Hiding a view does not make it unreachable -- `navigate`
|
||||
// still resolves it, which detail views depend on -- it only takes the
|
||||
// nav item away.
|
||||
func (c *Config) SetViewVisible(view string, visible bool) error {
|
||||
spec, known := LookupView(view)
|
||||
if !known {
|
||||
return fmt.Errorf("%w: %q", errUnknownView, view)
|
||||
}
|
||||
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
if !visible {
|
||||
if !spec.Hideable {
|
||||
return fmt.Errorf("%w: %q", errViewNotHideable, view)
|
||||
}
|
||||
|
||||
if spec.ID == c.General.DefaultPage {
|
||||
return fmt.Errorf("%w: %q", errViewIsLaunchPage, view)
|
||||
}
|
||||
}
|
||||
|
||||
if c.General.ViewVisibility == nil {
|
||||
c.General.ViewVisibility = make(map[string]bool, len(Views))
|
||||
}
|
||||
|
||||
c.General.ViewVisibility[view] = visible
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid view visibility: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf("could not save config: %w", err)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"ViewVisibility": c.General.ResolvedViewVisibility(),
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"view visibility updated",
|
||||
"view", view,
|
||||
"visible", visible,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTrackListColumns returns the configured track-list columns.
|
||||
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
||||
if c.TrackList == nil {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
@@ -28,7 +30,10 @@ func setupRecordedConfig(t *testing.T) (*Config, *events.Recorder) {
|
||||
}
|
||||
|
||||
rec := events.NewRecorder()
|
||||
conf.SetContext(events.WithSink(context.Background(), rec))
|
||||
_ = conf.ServiceStartup(
|
||||
events.WithSink(context.Background(), rec),
|
||||
application.ServiceOptions{},
|
||||
)
|
||||
|
||||
return conf, rec
|
||||
}
|
||||
@@ -183,3 +188,43 @@ func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmit_PopupVolumeRoundTripsAndDefaultsToInline pins both halves of
|
||||
// #42's storage decision.
|
||||
//
|
||||
// The **default** is the load-bearing one: inline is what a fresh
|
||||
// install and an existing `config.toml` with no such key must both
|
||||
// produce, which is why the field names the popup rather than the
|
||||
// inline slider. A flag spelled the other way round would default to
|
||||
// false, hand every existing install the popup this issue exists to
|
||||
// stop being the only option, and need a migration to say otherwise.
|
||||
func TestEmit_PopupVolumeRoundTripsAndDefaultsToInline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if conf.GetPopupVolume() {
|
||||
t.Error("a config with no PopupVolume key wants the popup, want inline")
|
||||
}
|
||||
|
||||
if err := conf.SetPopupVolume(true); err != nil {
|
||||
t.Fatalf("SetPopupVolume: %v", err)
|
||||
}
|
||||
|
||||
if !conf.GetPopupVolume() {
|
||||
t.Error("GetPopupVolume = false after setting it true")
|
||||
}
|
||||
|
||||
data := payloadMap(t, rec, events.GeneralConfigChanged)
|
||||
if data["PopupVolume"] != true {
|
||||
t.Errorf("PopupVolume = %v, want true", data["PopupVolume"])
|
||||
}
|
||||
|
||||
if err := conf.SetPopupVolume(false); err != nil {
|
||||
t.Fatalf("SetPopupVolume(false): %v", err)
|
||||
}
|
||||
|
||||
if conf.GetPopupVolume() {
|
||||
t.Error("GetPopupVolume = true after setting it false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DefaultDefaultPage is the launch page for a fresh install.
|
||||
const DefaultDefaultPage = ViewHome
|
||||
|
||||
var (
|
||||
errUnknownDefaultPage = errors.New("unknown default page")
|
||||
errViewCannotLaunch = errors.New("view cannot be the launch page")
|
||||
)
|
||||
|
||||
// QueueFallback identifies what plays, if anything, once the queue
|
||||
// runs out with nothing left to auto-advance to.
|
||||
type QueueFallback string
|
||||
|
||||
// Valid QueueFallback values.
|
||||
const (
|
||||
QueueFallbackStop QueueFallback = "stop"
|
||||
QueueFallbackFavorites QueueFallback = "favorites"
|
||||
QueueFallbackDynamicMix QueueFallback = "dynamicMix"
|
||||
)
|
||||
|
||||
// DefaultQueueFallback is the fallback behavior for a fresh install.
|
||||
const DefaultQueueFallback = QueueFallbackFavorites
|
||||
|
||||
var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
||||
|
||||
// GeneralConfig holds general application preferences that don't
|
||||
// belong to a more specific subsystem.
|
||||
type GeneralConfig struct {
|
||||
DefaultPage View `toml:"DefaultPage"`
|
||||
QueueFallback QueueFallback `toml:"QueueFallback"`
|
||||
// ViewVisibility says which sidebar destinations are shown, keyed by
|
||||
// view id.
|
||||
//
|
||||
// **An absent key means that view's own default** (`Views`), and that
|
||||
// is the whole reason this is a map rather than a `HiddenViews
|
||||
// []string` or a struct of booleans. A list's zero value is "hide
|
||||
// nothing", which cannot express Autotag being off by default without
|
||||
// a migration; a struct field for a view that later stops existing is
|
||||
// stored garbage somebody has to deprecate. Here a view added later
|
||||
// gets its own default rather than being invisible or forcibly
|
||||
// visible, an unknown key is dropped on load, and no install needs
|
||||
// migrating in either direction. Same polarity rule as
|
||||
// AllowMeteredCatalogDownload: the zero value is the intended answer.
|
||||
ViewVisibility map[string]bool `toml:"ViewVisibility"`
|
||||
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
|
||||
// be fetched on a connection the platform calls cellular. It defaults
|
||||
// to false, which is the whole point: the zero value is the safe one,
|
||||
// so an existing config with no such key refuses by default rather
|
||||
// than needing a migration to become careful.
|
||||
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
|
||||
// PopupVolume draws the bottom bar's volume as a click-to-open popup
|
||||
// instead of a slider that is always there (#42).
|
||||
//
|
||||
// The polarity is the rule this file already states twice: **the
|
||||
// zero value is the intended answer**. Inline is the new default, so
|
||||
// the flag has to name the *other* choice — an `InlineVolume bool`
|
||||
// would default to false and give every existing install the popup
|
||||
// this issue exists to stop being the only option, and would need a
|
||||
// migration to say otherwise.
|
||||
PopupVolume bool `toml:"PopupVolume"`
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||
//
|
||||
// A launch page naming a *retired* view is treated as a zero value
|
||||
// rather than as an error, because the alternative is an app that will
|
||||
// not start for anyone who had that page selected when it was removed.
|
||||
// An unknown-but-not-retired name still fails Validate: that is a typo,
|
||||
// and telling someone about it is the useful answer.
|
||||
func (c *GeneralConfig) ApplyDefaults() {
|
||||
if _, retired := RetiredViews[c.DefaultPage]; retired {
|
||||
c.DefaultPage = ""
|
||||
}
|
||||
|
||||
if c.DefaultPage == "" {
|
||||
c.DefaultPage = DefaultDefaultPage
|
||||
}
|
||||
|
||||
if c.QueueFallback == "" {
|
||||
c.QueueFallback = DefaultQueueFallback
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks that all values are well-formed.
|
||||
func (c *GeneralConfig) Validate() error {
|
||||
c.ApplyDefaults()
|
||||
|
||||
spec, known := LookupView(string(c.DefaultPage))
|
||||
if !known {
|
||||
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
|
||||
}
|
||||
|
||||
if !spec.CanLaunch {
|
||||
return fmt.Errorf("%w: %q", errViewCannotLaunch, c.DefaultPage)
|
||||
}
|
||||
|
||||
c.normalizeViewVisibility()
|
||||
|
||||
switch c.QueueFallback {
|
||||
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
|
||||
// Valid.
|
||||
default:
|
||||
return fmt.Errorf("%w: %q", errUnknownQueueFallback, c.QueueFallback)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeViewVisibility drops what the stored map may not say, and
|
||||
// repairs the one invariant the shell depends on.
|
||||
//
|
||||
// Three things are dropped or forced, and all three are reachable only
|
||||
// from a hand-edited config or from a version that knew different
|
||||
// views: an unknown id (a view removed since, e.g. when #27 folds Jobs
|
||||
// into Settings) says nothing to anybody; a view that is not Hideable
|
||||
// cannot be false; and **the launch page is always visible**, because
|
||||
// otherwise an install lands on a page with no nav item pointing at it.
|
||||
//
|
||||
// That last one is a *repair* here and an *error* at the setter
|
||||
// (SetViewVisible), deliberately. On load there is nobody to tell and
|
||||
// the honest reading of "my launch page is Autotag" is that this user
|
||||
// wants Autotag, so it is un-hidden rather than the launch page being
|
||||
// silently reset to something they did not choose. At the setter the
|
||||
// user is right there and can act, so it refuses and says why.
|
||||
func (c *GeneralConfig) normalizeViewVisibility() {
|
||||
for id := range c.ViewVisibility {
|
||||
spec, known := LookupView(id)
|
||||
if !known || !spec.Hideable {
|
||||
delete(c.ViewVisibility, id)
|
||||
}
|
||||
}
|
||||
|
||||
if visible, ok := c.ViewVisibility[string(c.DefaultPage)]; ok && !visible {
|
||||
c.ViewVisibility[string(c.DefaultPage)] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvedViewVisibility answers for every known view, so no caller has
|
||||
// to know the defaults -- the frontend included, which is why the
|
||||
// binding returns this rather than the stored map.
|
||||
func (c *GeneralConfig) ResolvedViewVisibility() map[string]bool {
|
||||
resolved := make(map[string]bool, len(Views))
|
||||
|
||||
for _, v := range Views {
|
||||
visible := v.VisibleByDefault
|
||||
|
||||
if stored, ok := c.ViewVisibility[string(v.ID)]; ok && v.Hideable {
|
||||
visible = stored
|
||||
}
|
||||
|
||||
resolved[string(v.ID)] = visible
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package config
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
errUnknownView = errors.New("unknown view")
|
||||
errViewNotHideable = errors.New("view cannot be hidden")
|
||||
errViewIsLaunchPage = errors.New("view is the launch page")
|
||||
)
|
||||
|
||||
// View identifies one of the shell's primary destinations -- the
|
||||
// things the sidebar lists and `index.ts` knows as `VIEW_TAGS`.
|
||||
type View string
|
||||
|
||||
// The primary views, in no particular order: the sidebar owns the order
|
||||
// it draws them in, because that is presentation.
|
||||
const (
|
||||
ViewHome View = "home"
|
||||
ViewPlaylists View = "playlists"
|
||||
ViewArtists View = "artists"
|
||||
ViewGenres View = "genres"
|
||||
ViewAlbums View = "albums"
|
||||
ViewTracks View = "tracks"
|
||||
ViewExplore View = "explore"
|
||||
ViewDownloads View = "downloads"
|
||||
ViewAutotag View = "autotag"
|
||||
ViewSettings View = "settings"
|
||||
)
|
||||
|
||||
// RetiredViews are destinations that used to exist and no longer do.
|
||||
//
|
||||
// A *visibility* entry for a removed view needs no such list: it is a
|
||||
// key in a map, and an unknown key is dropped on load. A `DefaultPage`
|
||||
// is a **value**, and an unknown one fails validation -- which on the
|
||||
// load path means the app refuses to start rather than a setting being
|
||||
// ignored. So the one shape that cannot be retired for free is named
|
||||
// here and reset to the default instead.
|
||||
//
|
||||
// `jobs` was folded into Settings by #27: library scans under
|
||||
// Libraries, index work under Search Index, downloads under the
|
||||
// download clients, and the autotag apply into the Autotag view.
|
||||
var RetiredViews = map[View]struct{}{
|
||||
"jobs": {},
|
||||
}
|
||||
|
||||
// ViewSpec is what the backend knows about a destination. The label and
|
||||
// the icon are deliberately absent: those are presentation, they live
|
||||
// beside the rest of the app's icon vocabulary in
|
||||
// `frontend/src/utils/icon-language.ts`, and a Go copy of them would be
|
||||
// a second thing to keep in step for nothing.
|
||||
type ViewSpec struct {
|
||||
// ID is the view name the frontend navigates by.
|
||||
ID View
|
||||
// VisibleByDefault is what an install gets when the config says
|
||||
// nothing about this view -- which is every install until somebody
|
||||
// changes it, and every view added after this one shipped.
|
||||
VisibleByDefault bool
|
||||
// Hideable is false for Settings alone. It is a property of the
|
||||
// view rather than a check in the setter because `config.toml` is
|
||||
// hand-editable, and an app that can be locked out of its own
|
||||
// Settings by a typo is a support problem nobody can debug
|
||||
// remotely.
|
||||
Hideable bool
|
||||
// CanLaunch reports whether the view may be the launch page.
|
||||
// Settings is the only one that may not, which is the shape the
|
||||
// DefaultPage enum already had.
|
||||
CanLaunch bool
|
||||
}
|
||||
|
||||
// Views is the one list of primary destinations, in the order Settings
|
||||
// offers them.
|
||||
//
|
||||
// It is the single source for three things that used to be written down
|
||||
// separately: which views exist, which of them may be the launch page
|
||||
// (`DefaultPage`'s validation reads it), and what an unconfigured
|
||||
// install shows.
|
||||
//
|
||||
// Autotag is the one view hidden by default: it rewrites tags on disk,
|
||||
// which is not what most libraries want on day one, and #25 asks for it
|
||||
// to be turned on deliberately.
|
||||
var Views = []ViewSpec{
|
||||
{ID: ViewHome, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewPlaylists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewArtists, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewGenres, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewAlbums, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewTracks, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewExplore, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewDownloads, VisibleByDefault: true, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewAutotag, VisibleByDefault: false, Hideable: true, CanLaunch: true},
|
||||
{ID: ViewSettings, VisibleByDefault: true, Hideable: false, CanLaunch: false},
|
||||
}
|
||||
|
||||
// LookupView returns the spec for a view id.
|
||||
func LookupView(id string) (ViewSpec, bool) {
|
||||
for _, v := range Views {
|
||||
if string(v.ID) == id {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
|
||||
return ViewSpec{}, false
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newViewTestConfig builds a Config backed by a temp file, which is all
|
||||
// SetViewVisible needs: it saves and emits, and the emit is a no-op
|
||||
// without a running app.
|
||||
func newViewTestConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
|
||||
c := &Config{
|
||||
logger: slog.Default(),
|
||||
filePath: filepath.Join(t.TempDir(), "config.toml"),
|
||||
}
|
||||
|
||||
// Load a file that is not there: that is what marks the config
|
||||
// loaded, without which Save refuses on the *second* write.
|
||||
if err := c.Load(); err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// A view the config says nothing about takes its own default, which is
|
||||
// what makes this need no migration in either direction: an existing
|
||||
// install gets Autotag hidden without a key, and a view added later
|
||||
// gets its own answer rather than the list's.
|
||||
func TestViewVisibilityDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
resolved := general.ResolvedViewVisibility()
|
||||
|
||||
if len(resolved) != len(Views) {
|
||||
t.Fatalf("resolved %d views, want %d", len(resolved), len(Views))
|
||||
}
|
||||
|
||||
if resolved[string(ViewAutotag)] {
|
||||
t.Error("autotag should be hidden by default")
|
||||
}
|
||||
|
||||
for _, v := range Views {
|
||||
if v.ID == ViewAutotag {
|
||||
continue
|
||||
}
|
||||
|
||||
if !resolved[string(v.ID)] {
|
||||
t.Errorf("%s should be visible by default", v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stored answer wins over the default, in both directions -- turning
|
||||
// Autotag on is the whole user-facing point.
|
||||
func TestViewVisibilityStoredWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{
|
||||
ViewVisibility: map[string]bool{
|
||||
string(ViewAutotag): true,
|
||||
string(ViewExplore): false,
|
||||
},
|
||||
}
|
||||
general.ApplyDefaults()
|
||||
|
||||
resolved := general.ResolvedViewVisibility()
|
||||
|
||||
if !resolved[string(ViewAutotag)] {
|
||||
t.Error("autotag was switched on and should be visible")
|
||||
}
|
||||
|
||||
if resolved[string(ViewExplore)] {
|
||||
t.Error("explore was switched off and should be hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// A key for a view that no longer exists is discarded rather than
|
||||
// migrated. This is the property the #25-before-#27 ordering rests on:
|
||||
// when Jobs folds into Settings, `jobs = true` in somebody's config is
|
||||
// a key nothing asks about, not a cleanup task.
|
||||
func TestValidateDropsUnknownAndUnhideableViews(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{
|
||||
ViewVisibility: map[string]bool{
|
||||
"a-view-that-was-removed": true,
|
||||
string(ViewSettings): false,
|
||||
string(ViewAutotag): true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := general.ViewVisibility["a-view-that-was-removed"]; ok {
|
||||
t.Error("an unknown view id should be dropped on load")
|
||||
}
|
||||
|
||||
if _, ok := general.ViewVisibility[string(ViewSettings)]; ok {
|
||||
t.Error("settings is not hideable and should not be stored")
|
||||
}
|
||||
|
||||
if !general.ResolvedViewVisibility()[string(ViewSettings)] {
|
||||
t.Error("settings must resolve visible whatever the file said")
|
||||
}
|
||||
}
|
||||
|
||||
// On load there is nobody to tell, so a launch page hidden by a
|
||||
// hand-edited file is un-hidden rather than the launch page being
|
||||
// reset to something the user did not choose.
|
||||
func TestValidateRevealsAHiddenLaunchPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{
|
||||
DefaultPage: ViewAutotag,
|
||||
ViewVisibility: map[string]bool{
|
||||
string(ViewAutotag): false,
|
||||
},
|
||||
}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if !general.ResolvedViewVisibility()[string(ViewAutotag)] {
|
||||
t.Error("the launch page must be visible")
|
||||
}
|
||||
}
|
||||
|
||||
// A launch page naming a view that no longer exists resets to the
|
||||
// default instead of failing validation, which on the load path would
|
||||
// mean the app refusing to start for whoever had it selected.
|
||||
//
|
||||
// This is the one shape #25's storage decision does *not* make free: a
|
||||
// visibility entry is a key and an unknown key is dropped, but a launch
|
||||
// page is a value.
|
||||
func TestARetiredLaunchPageFallsBackToTheDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: "jobs"}
|
||||
|
||||
if err := general.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error: %v", err)
|
||||
}
|
||||
|
||||
if general.DefaultPage != DefaultDefaultPage {
|
||||
t.Errorf("DefaultPage = %q, want %q", general.DefaultPage, DefaultDefaultPage)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that is merely wrong is still an error: that is a typo, and
|
||||
// saying so is more useful than ignoring it.
|
||||
func TestAnUnknownLaunchPageIsStillAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: "nonsense"}
|
||||
|
||||
if err := general.Validate(); !errors.Is(err, errUnknownDefaultPage) {
|
||||
t.Fatalf("Validate() error = %v, want errUnknownDefaultPage", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A retired view is not a view, so nothing offers it and nothing
|
||||
// resolves it -- the visibility map included.
|
||||
func TestARetiredViewIsGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for id := range RetiredViews {
|
||||
if _, ok := LookupView(string(id)); ok {
|
||||
t.Errorf("%s is retired but still in Views", id)
|
||||
}
|
||||
|
||||
general := &GeneralConfig{}
|
||||
general.ApplyDefaults()
|
||||
|
||||
if _, ok := general.ResolvedViewVisibility()[string(id)]; ok {
|
||||
t.Errorf("%s is retired but still resolves a visibility", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings may not be the launch page, which is the shape the old
|
||||
// DefaultPage enum had and is now read off the same table.
|
||||
func TestValidateRejectsAnUnlaunchablePage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
general := &GeneralConfig{DefaultPage: ViewSettings}
|
||||
|
||||
err := general.Validate()
|
||||
if !errors.Is(err, errViewCannotLaunch) {
|
||||
t.Fatalf("Validate() error = %v, want errViewCannotLaunch", err)
|
||||
}
|
||||
}
|
||||
|
||||
// At the setter the user is present and can act, so the two states
|
||||
// they could not get out of are refused rather than repaired.
|
||||
func TestSetViewVisibleRefusals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
view string
|
||||
visible bool
|
||||
want error
|
||||
}{
|
||||
{"settings is never hideable", string(ViewSettings), false, errViewNotHideable},
|
||||
{"the launch page is not hideable", string(ViewHome), false, errViewIsLaunchPage},
|
||||
{"an unknown view is not a setting", "nonsense", false, errUnknownView},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newViewTestConfig(t)
|
||||
|
||||
err := c.SetViewVisible(tt.view, tt.visible)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Fatalf("SetViewVisible() error = %v, want %v", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Showing a view is never refused, including Settings and the launch
|
||||
// page -- there is no state to be stuck in.
|
||||
func TestSetViewVisibleShowsAnything(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := newViewTestConfig(t)
|
||||
|
||||
for _, v := range Views {
|
||||
if err := c.SetViewVisible(string(v.ID), true); err != nil {
|
||||
t.Fatalf("SetViewVisible(%q, true) error: %v", v.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !c.GetViewVisibility()[string(ViewAutotag)] {
|
||||
t.Error("autotag was switched on and should be visible")
|
||||
}
|
||||
}
|
||||
|
||||
// The stored map survives a save/load round trip, which is what a
|
||||
// map-valued TOML key is worth checking for.
|
||||
func TestViewVisibilityRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
|
||||
original := &Config{logger: slog.Default(), filePath: path}
|
||||
if err := original.Load(); err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
if err := original.SetViewVisible(string(ViewAutotag), true); err != nil {
|
||||
t.Fatalf("SetViewVisible() error: %v", err)
|
||||
}
|
||||
|
||||
if err := original.SetViewVisible(string(ViewExplore), false); err != nil {
|
||||
t.Fatalf("SetViewVisible() error: %v", err)
|
||||
}
|
||||
|
||||
loaded := &Config{logger: slog.Default(), filePath: path}
|
||||
if err := loaded.Load(); err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
|
||||
resolved := loaded.GetViewVisibility()
|
||||
|
||||
if !resolved[string(ViewAutotag)] {
|
||||
t.Error("autotag should have loaded as visible")
|
||||
}
|
||||
|
||||
if resolved[string(ViewExplore)] {
|
||||
t.Error("explore should have loaded as hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// Every view the shell can launch into is a view the sidebar can show,
|
||||
// or an install could land on a page with no nav item and no setting
|
||||
// pointing at it.
|
||||
func TestEveryLaunchableViewIsAView(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, v := range Views {
|
||||
if !v.CanLaunch {
|
||||
continue
|
||||
}
|
||||
|
||||
if !v.Hideable {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := LookupView(string(v.ID)); !ok {
|
||||
t.Errorf("%s is launchable but not a known view", v.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,31 @@ const (
|
||||
// enforces this at runtime; it is also the floor below which a
|
||||
// reported size is treated as bogus and not persisted.
|
||||
//
|
||||
// 800x600 is where the shell was measured to still work, rather
|
||||
// than a round number: below ~780 the header's subtitle wraps and
|
||||
// pushes the title out of the 4em top bar, and below ~600 tall the
|
||||
// eleven sidebar items no longer fit at once. The previous
|
||||
// 512x384 was aspirational — at 700x480 the sidebar overflowed
|
||||
// behind the player bar with no scroll and Settings and Jobs could
|
||||
// not be reached at all.
|
||||
// **Both reasons this comment used to give have expired**, and the
|
||||
// value is right for a third one. It said the floor was 800x600
|
||||
// because "below ~780 the header's subtitle wraps and pushes the
|
||||
// title out of the 4em top bar" and "below ~600 tall the eleven
|
||||
// sidebar items no longer fit at once". Neither mechanism can
|
||||
// happen now: the subtitle is display:none from 899px down
|
||||
// (index.css), and the sidebar host is overflow-y:auto — measured
|
||||
// at 600x460, its scrollHeight is 434 against a 332px client and
|
||||
// Settings is reachable after scrolling. A floor defended by two
|
||||
// mechanisms that no longer exist is a number nobody can argue
|
||||
// with, which is worse than either answer.
|
||||
//
|
||||
// It stays 800x600 because that is where the *desktop* chrome
|
||||
// stops being comfortable — the Compact band of plan 018's size
|
||||
// matrix (#24) — and not because the app breaks below it. It does
|
||||
// not: under 600px wide the phone layout takes over (bottom-nav,
|
||||
// no sidebar) and the shell fits 320px exactly, which is what
|
||||
// makes this a comfort floor rather than a correctness one, and
|
||||
// why a very small window reflows instead of becoming a
|
||||
// mini-player (#12 is a second always-on-top window, not a mode of
|
||||
// this one).
|
||||
//
|
||||
// The previous 512x384 was aspirational — at 700x480 the sidebar
|
||||
// overflowed behind the player bar with no scroll and Settings and
|
||||
// Jobs could not be reached at all.
|
||||
MinWidth = 800
|
||||
// MinHeight is the smallest allowed window height in pixels.
|
||||
MinHeight = 600
|
||||
|
||||
@@ -12,7 +12,15 @@ import (
|
||||
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
||||
const PathPrefix = "/covers/"
|
||||
|
||||
// URLs holds the resolved URL paths for all cover art size variants.
|
||||
// URLs holds the resolved URL paths for a cover's size variants.
|
||||
//
|
||||
// Original is the largest variant kept, which is the Large one: the
|
||||
// full-resolution image is no longer stored. It was 1,134 MB of a
|
||||
// 1.4 GB covers directory on a real 2,057-album library against 110 MB
|
||||
// for all three rendered tiers, and nothing rendered it - the grid caps
|
||||
// at 350 px and the largest tier is 400. The field keeps its name
|
||||
// because it is what a caller means by "the cover", and the bytes it
|
||||
// came from are still in the audio file if a bigger one is ever wanted.
|
||||
type URLs struct {
|
||||
Original string
|
||||
Small string
|
||||
@@ -36,25 +44,41 @@ func CoversDir() (string, error) {
|
||||
return filepath.Join(dataDir, dirName), nil
|
||||
}
|
||||
|
||||
// SizedFilename derives a sized-variant filename from an original cover art
|
||||
// filename and a size suffix.
|
||||
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
|
||||
func SizedFilename(originalFilename, suffix string) string {
|
||||
ext := filepath.Ext(originalFilename)
|
||||
name := strings.TrimSuffix(originalFilename, ext)
|
||||
// Suffixes are the size variants a cover is stored as, largest last.
|
||||
var Suffixes = []string{"_sm", "_md", "_lg"}
|
||||
|
||||
return name + suffix + ".jpg"
|
||||
// SizedFilename derives a sized-variant filename from a cover art
|
||||
// filename and a size suffix. The input may itself be a variant, so
|
||||
// its suffix is stripped first: SizedFilename("a1b2_lg.jpg", "_sm")
|
||||
// and SizedFilename("a1b2.jpg", "_sm") both return "a1b2_sm.jpg".
|
||||
func SizedFilename(filename, suffix string) string {
|
||||
return BaseName(filename) + suffix + ".jpg"
|
||||
}
|
||||
|
||||
// BaseName strips the extension and any size suffix from a cover art
|
||||
// filename, leaving the content hash that identifies the cover.
|
||||
func BaseName(filename string) string {
|
||||
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
for _, suffix := range Suffixes {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// ResolveURLs converts a cover art filesystem path into URL paths
|
||||
// for the original and all size variants (small, medium, large).
|
||||
func ResolveURLs(filesystemPath string) URLs {
|
||||
base := filepath.Base(filesystemPath)
|
||||
large := PathPrefix + SizedFilename(base, "_lg")
|
||||
|
||||
return URLs{
|
||||
Original: PathPrefix + base,
|
||||
Original: large,
|
||||
Small: PathPrefix + SizedFilename(base, "_sm"),
|
||||
Medium: PathPrefix + SizedFilename(base, "_md"),
|
||||
Large: PathPrefix + SizedFilename(base, "_lg"),
|
||||
Large: large,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
name string
|
||||
path string
|
||||
// Original is the largest kept variant: the full-resolution
|
||||
// image is not stored (see URLs).
|
||||
wantOrig string
|
||||
wantSm string
|
||||
wantMd string
|
||||
@@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) {
|
||||
{
|
||||
name: "absolute path",
|
||||
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
||||
wantOrig: "/covers/a1b2c3d4.jpg",
|
||||
wantOrig: "/covers/a1b2c3d4_lg.jpg",
|
||||
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
||||
wantMd: "/covers/a1b2c3d4_md.jpg",
|
||||
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
||||
@@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) {
|
||||
{
|
||||
name: "bare filename",
|
||||
path: "abcdef01.png",
|
||||
wantOrig: "/covers/abcdef01.png",
|
||||
wantOrig: "/covers/abcdef01_lg.jpg",
|
||||
wantSm: "/covers/abcdef01_sm.jpg",
|
||||
wantMd: "/covers/abcdef01_md.jpg",
|
||||
wantLg: "/covers/abcdef01_lg.jpg",
|
||||
|
||||
+69
-188
@@ -5,13 +5,10 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||
@@ -26,9 +23,6 @@ import (
|
||||
//go:embed sql/schemas/*.sql
|
||||
var schemas embed.FS
|
||||
|
||||
//go:embed sql/migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
// DB wraps the SQLite database connection and queries.
|
||||
//
|
||||
// Two handles back a single database file. db is the single-writer
|
||||
@@ -95,6 +89,14 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
||||
return nil, fmt.Errorf("could not apply PRAGMAs: %w", err)
|
||||
}
|
||||
|
||||
// Before the schema is applied, not after: applySchema is
|
||||
// CREATE ... IF NOT EXISTS, which no-ops against a table that
|
||||
// already exists in an older shape. Retiring the stale one first is
|
||||
// what turns that no-op into a create.
|
||||
if err := retireStaleTables(dbCtx, db, logger); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applySchema(dbCtx, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,7 +195,26 @@ var exploreIndexFTSTriggers = []string{
|
||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||
END`,
|
||||
`CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
|
||||
// Narrowed to the three columns the FTS table actually indexes, and
|
||||
// guarded on them having changed. An UPDATE that leaves all three
|
||||
// alone has nothing to re-index, and re-indexing it is not free: an
|
||||
// FTS5 delete has to find the old row's postings in a multi-million
|
||||
// row index, which is the ~31 rows/s figure below.
|
||||
//
|
||||
// This is not a micro-optimisation. Every writer here upserts, and
|
||||
// the merge rules keep existing values (`CASE WHEN excluded.title
|
||||
// != '' ...`), so the common write is a row arriving unchanged: the
|
||||
// discography backfill re-browsing a known artist, the incremental
|
||||
// dump refreshing popularity. Each of those used to pay a full
|
||||
// delete + insert against the FTS index while holding the single
|
||||
// writer connection — measured at 91% of the app's CPU, with the
|
||||
// play path queued behind it.
|
||||
`CREATE TRIGGER explore_index_au AFTER UPDATE OF title, artist_name, aliases
|
||||
ON explore_index
|
||||
WHEN old.title IS NOT new.title
|
||||
OR old.artist_name IS NOT new.artist_name
|
||||
OR old.aliases IS NOT new.aliases
|
||||
BEGIN
|
||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||
@@ -203,7 +224,17 @@ var exploreIndexFTSTriggers = []string{
|
||||
|
||||
// createExploreIndexFTSTriggers installs the sync triggers. Safe to
|
||||
// call on a database that already has them.
|
||||
//
|
||||
// It drops first rather than tolerating "already exists", because a
|
||||
// trigger is a definition and not a row: an install that already has
|
||||
// the old one would otherwise keep it forever, and these definitions
|
||||
// are exactly where this table's write cost is decided. Three DDL
|
||||
// statements against a table with no rows to rewrite, on open.
|
||||
func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
||||
if err := dropExploreIndexFTSTriggers(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stmt := range exploreIndexFTSTriggers {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil &&
|
||||
!strings.Contains(err.Error(), "already exists") {
|
||||
@@ -214,6 +245,23 @@ func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// exploreIndexFTSTriggerNames is what both the drop paths remove.
|
||||
var exploreIndexFTSTriggerNames = []string{
|
||||
"explore_index_ai", "explore_index_ad", "explore_index_au",
|
||||
}
|
||||
|
||||
func dropExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
||||
for _, name := range exploreIndexFTSTriggerNames {
|
||||
if _, err := db.ExecContext(
|
||||
ctx, "DROP TRIGGER IF EXISTS "+name,
|
||||
); err != nil {
|
||||
return fmt.Errorf("drop explore FTS trigger %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SuspendExploreIndexFTS drops the FTS sync triggers so a bulk load can
|
||||
// write explore_index without paying per-row FTS maintenance.
|
||||
//
|
||||
@@ -227,12 +275,8 @@ func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
||||
// Callers MUST pair this with ResumeExploreIndexFTS — while suspended,
|
||||
// explore_index_fts stops tracking the table and search goes stale.
|
||||
func (d *DB) SuspendExploreIndexFTS() error {
|
||||
for _, name := range []string{
|
||||
"explore_index_ai", "explore_index_ad", "explore_index_au",
|
||||
} {
|
||||
if _, err := d.db.ExecContext(d.Ctx, "DROP TRIGGER IF EXISTS "+name); err != nil {
|
||||
return fmt.Errorf("suspend explore FTS: drop %s: %w", name, err)
|
||||
}
|
||||
if err := dropExploreIndexFTSTriggers(d.Ctx, d.db); err != nil {
|
||||
return fmt.Errorf("suspend explore FTS: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -259,20 +303,19 @@ func (d *DB) ResumeExploreIndexFTS() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySchema creates the full schema on a fresh database and brings
|
||||
// an existing one up to date via sql/migrations.
|
||||
// applySchema creates the full schema.
|
||||
//
|
||||
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS,
|
||||
// so on a genuinely new database they create every table already at
|
||||
// its current, latest shape — that's the fast path new installs
|
||||
// take. A database that already has an older shape (e.g. a
|
||||
// tagging_items missing a column a later build added) needs the gap
|
||||
// closed, which IF NOT EXISTS can't do: it silently no-ops on a
|
||||
// table that already exists, columns and all. sql/migrations holds
|
||||
// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.)
|
||||
// for exactly that gap, tracked in schema_migrations so each applies
|
||||
// at most once — see applyMigrations for how a fresh database's
|
||||
// already-current tables tolerate replaying them anyway.
|
||||
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS and
|
||||
// declare the current, latest shape of every table — so running them
|
||||
// against a fresh database produces exactly that shape, and running
|
||||
// them against a database already at that shape does nothing. That is
|
||||
// the whole mechanism; there is no migration chain and no
|
||||
// schema_migrations table.
|
||||
//
|
||||
// There was one, and it was squashed (see .planning/plans/013): a chain
|
||||
// only earns its keep once real user databases exist in the wild, and
|
||||
// until then it is a second description of the schema that can drift
|
||||
// from the first — which this project has already been bitten by once.
|
||||
func applySchema(ctx context.Context, db *sql.DB) error {
|
||||
dirEntries, err := schemas.ReadDir("sql/schemas")
|
||||
if err != nil {
|
||||
@@ -302,171 +345,9 @@ func applySchema(ctx context.Context, db *sql.DB) error {
|
||||
return fmt.Errorf("could not create explore FTS triggers: %w", err)
|
||||
}
|
||||
|
||||
if err := applyMigrations(ctx, db); err != nil {
|
||||
return fmt.Errorf("could not apply migrations: %w", err)
|
||||
}
|
||||
|
||||
// The download subsystem's Want/Request rename reuses table names
|
||||
// (download_requests names a different table before and after), so
|
||||
// it cannot be a plain sql/migrations file the way an ADD COLUMN
|
||||
// migration can; see download_rename_migration.go for why.
|
||||
if err := migrateDownloadRename(ctx, db); err != nil {
|
||||
return fmt.Errorf("could not migrate download rename: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// schemaMigrationsTable tracks which sql/migrations files have run,
|
||||
// by their leading numeric prefix.
|
||||
const schemaMigrationsTable = `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`
|
||||
|
||||
// applyMigrations runs every sql/migrations file not yet recorded in
|
||||
// schema_migrations, in filename order (numeric prefix), one
|
||||
// statement at a time.
|
||||
//
|
||||
// Every migration runs on EVERY database, fresh or old — there is no
|
||||
// "skip on fresh install" branch. A fresh database's tables already
|
||||
// carry a migration's effect (sql/schemas declares the target shape
|
||||
// directly), so its statements are expected to sometimes be no-ops
|
||||
// there: "duplicate column name" from an ALTER TABLE ADD COLUMN is
|
||||
// tolerated and treated as "already applied", the same way
|
||||
// createExploreIndexFTSTriggers tolerates "already exists". Any
|
||||
// other error is fatal. This is deliberately simpler than detecting
|
||||
// "is this database fresh" — every migration converges both a fresh
|
||||
// and an upgraded database to the identical final schema (including
|
||||
// column order — ALTER TABLE ADD COLUMN always appends at the end,
|
||||
// so sql/schemas must declare a migrated column last too; see the
|
||||
// comment on tagging_items.sql and the regression test in
|
||||
// migrations_test.go).
|
||||
func applyMigrations(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, schemaMigrationsTable); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
dirEntries, err := migrations.ReadDir("sql/migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read migrations directory: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(dirEntries, func(i, j int) bool {
|
||||
return dirEntries[i].Name() < dirEntries[j].Name()
|
||||
})
|
||||
|
||||
for _, dirEntry := range dirEntries {
|
||||
if dirEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
version, err := migrationVersion(dirEntry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applied, err := migrationApplied(ctx, db, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := path.Join("sql/migrations", dirEntry.Name())
|
||||
|
||||
sqlContent, err := fs.ReadFile(migrations, filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
if err := execMigrationStatements(ctx, db, string(sqlContent)); err != nil {
|
||||
return fmt.Errorf("error executing migration %s: %w", dirEntry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, version,
|
||||
); err != nil {
|
||||
return fmt.Errorf("record migration %d applied: %w", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// execMigrationStatements runs a migration file one statement at a
|
||||
// time — NOT as one multi-statement Exec — so that one statement
|
||||
// being a tolerable no-op (ALTER TABLE ADD COLUMN on a fresh
|
||||
// database) doesn't abort the statements after it in the same file
|
||||
// (e.g. a trailing CREATE INDEX that a fresh database still needs,
|
||||
// since sql/schemas deliberately doesn't declare an index on a
|
||||
// migrated column — see the comment on tagging_items.sql).
|
||||
//
|
||||
// Splitting on ";" is safe for the simple ALTER/CREATE TABLE/CREATE
|
||||
// INDEX statements migrations are expected to contain; it is NOT
|
||||
// safe for statements embedding a literal semicolon (e.g. a CREATE
|
||||
// TRIGGER body) — write those with executeContext calls in Go
|
||||
// instead of a sql/migrations file, the same way the explore FTS
|
||||
// triggers already are.
|
||||
func execMigrationStatements(ctx context.Context, db *sql.DB, script string) error {
|
||||
for stmt := range strings.SplitSeq(script, ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
continue
|
||||
}
|
||||
|
||||
return fmt.Errorf("statement %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrationVersion extracts the leading integer prefix from a
|
||||
// migration filename, e.g. "0001_tagging_items_synthetic.sql" -> 1.
|
||||
func migrationVersion(filename string) (int, error) {
|
||||
prefix, _, ok := strings.Cut(filename, "_")
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
|
||||
}
|
||||
|
||||
version, err := strconv.Atoi(prefix)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
var errMigrationFilename = errors.New(
|
||||
"migration filename must start with a numeric prefix followed by '_' (e.g. 0001_description.sql)",
|
||||
)
|
||||
|
||||
func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) {
|
||||
var v int
|
||||
|
||||
err := db.QueryRowContext(
|
||||
ctx, `SELECT version FROM schema_migrations WHERE version = ?`, version,
|
||||
).Scan(&v)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return false, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("check migration %d: %w", version, err)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyPRAGMAs configures SQLite connection settings. Called by both
|
||||
// NewDB and NewTestDB to ensure identical behavior.
|
||||
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
|
||||
|
||||
@@ -312,31 +312,13 @@ func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
|
||||
|
||||
// Create prerequisite data: artist_credit, recording,
|
||||
// audio_file.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
||||
"VALUES (1, 'Test Song', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files "+
|
||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
||||
"recording_id, library_id) "+
|
||||
"VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)",
|
||||
libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/music/song.mp3",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
LengthMs: 180000,
|
||||
LibraryID: libID,
|
||||
})
|
||||
|
||||
// Create playlist.
|
||||
playlist, err := db.Queries.CreatePlaylist(
|
||||
@@ -442,39 +424,18 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
|
||||
|
||||
// Insert prerequisite recording.
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/track.mp3",
|
||||
Title: "Track",
|
||||
Artist: "Test",
|
||||
LibraryID: libID,
|
||||
})
|
||||
|
||||
// Insert audio file with invalid library_id - should fail FK.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
||||
"VALUES (1, 'Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
// Insert audio file with valid library_id — should succeed.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files "+
|
||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
||||
"recording_id, library_id) "+
|
||||
"VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)",
|
||||
libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file with valid library: %v", err)
|
||||
}
|
||||
|
||||
// Insert audio file with invalid library_id — should fail FK.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files " +
|
||||
"(id, file_path, length_milliseconds, file_type_id, " +
|
||||
"recording_id, library_id) " +
|
||||
"VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)",
|
||||
"(id, file_path, length_milliseconds, file_type_id, library_id) " +
|
||||
"VALUES (2, '/test/song2.mp3', 200000, 0, 999)",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error(
|
||||
@@ -483,16 +444,16 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// Count files by library.
|
||||
count, err := db.Queries.CountAudioFilesByLibrary(
|
||||
count, err := db.Queries.CountAudioFiles(
|
||||
db.Ctx, libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CountAudioFilesByLibrary: %v", err)
|
||||
t.Fatalf("CountAudioFiles: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf(
|
||||
"CountAudioFilesByLibrary = %d, want 1", count,
|
||||
"CountAudioFiles = %d, want 1", count,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -503,31 +464,13 @@ func TestTrackMetadataViewHasLibraryID(t *testing.T) {
|
||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
|
||||
|
||||
// Insert prerequisites.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
||||
"VALUES (1, 'View Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files "+
|
||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
||||
"recording_id, library_id) "+
|
||||
"VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)",
|
||||
libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/view.mp3",
|
||||
Title: "View Track",
|
||||
Artist: "View Artist",
|
||||
LengthMs: 200000,
|
||||
LibraryID: libID,
|
||||
})
|
||||
|
||||
// Query track_metadata VIEW and verify library_id is present
|
||||
// with the correct value.
|
||||
@@ -842,29 +785,13 @@ func TestPlayHistoryTable(t *testing.T) {
|
||||
|
||||
// Round-trip: insert a play_history row and verify play_count update.
|
||||
// First, set up test data. The test DB already has library id=0.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
`INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number)
|
||||
VALUES (1, 'Test Track', 1, 1, 1)`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
`INSERT INTO audio_files
|
||||
(id, file_path, length_milliseconds, file_type_id, recording_id, library_id)
|
||||
VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/play_history.mp3",
|
||||
Title: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
})
|
||||
|
||||
// Verify default play_count is 0.
|
||||
var playCount int64
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// migrateDownloadRename performs the download subsystem's table rename
|
||||
// for existing databases that still carry the old table names: the
|
||||
// durable "I asked for this" record moved from download_wants to
|
||||
// download_requests, and the one-shot search-and-grab attempt moved
|
||||
// from download_requests to download_downloads (see CLAUDE.md and
|
||||
// .planning/NOTES.md for the full Want->Request / Request->Download
|
||||
// rename).
|
||||
//
|
||||
// This cannot be a plain sql/migrations file the way an ADD COLUMN
|
||||
// migration is. That pattern's tolerance for "duplicate column name"
|
||||
// works because a fresh database's sql/schemas pass already produces
|
||||
// the identical target shape under the identical table name, so
|
||||
// replaying the ALTER TABLE against it is a safe no-op. Here the name
|
||||
// "download_requests" is reused for a different table before and after
|
||||
// the rename, so a fresh database's schema pass creates a real, empty,
|
||||
// correctly-shaped download_downloads AND a real, empty,
|
||||
// correctly-shaped (new) download_requests before this ever runs.
|
||||
// Blindly replaying "ALTER TABLE download_requests RENAME TO
|
||||
// download_downloads" against that fresh database would rename the new,
|
||||
// empty Request table into Download's place, destroying the fresh
|
||||
// install rather than no-opping. Gating on whether the OLD
|
||||
// download_wants table still exists — a name nothing creates or
|
||||
// references once this has run — is what tells an old database and a
|
||||
// fresh (or already migrated) one apart without executing anything
|
||||
// destructive on the fresh path.
|
||||
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
|
||||
var name string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||
).Scan(&name)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Nothing to migrate: either a fresh install (sql/schemas
|
||||
// already produced the target shape) or a database this has
|
||||
// already run against.
|
||||
case err != nil:
|
||||
return fmt.Errorf("check for download_wants table: %w", err)
|
||||
default:
|
||||
if err := runDownloadRename(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return ensureDownloadIndexes(ctx, db)
|
||||
}
|
||||
|
||||
// runDownloadRename performs the actual rename dance against a
|
||||
// database confirmed to still have the old download_wants table.
|
||||
func runDownloadRename(ctx context.Context, db *sql.DB) error {
|
||||
stmts := []string{
|
||||
// The schema pass already created an empty, correctly-shaped
|
||||
// download_downloads placeholder under this name (it never
|
||||
// existed under the old naming), which would otherwise collide
|
||||
// with the rename below.
|
||||
`DROP TABLE IF EXISTS download_downloads`,
|
||||
|
||||
// 1. Free the "download_requests" name: the old one-shot
|
||||
// attempt table becomes download_downloads.
|
||||
`ALTER TABLE download_requests RENAME TO download_downloads`,
|
||||
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
|
||||
|
||||
// 2. Claim the now-free "download_requests" name for the
|
||||
// durable-intent table.
|
||||
`ALTER TABLE download_wants RENAME TO download_requests`,
|
||||
|
||||
// 3. The transfer table's FK now points at download_downloads.
|
||||
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
|
||||
|
||||
// Named indexes survive a table/column rename attached to their
|
||||
// old name, so drop them here; ensureDownloadIndexes recreates
|
||||
// them under the names sql/schemas' comments describe.
|
||||
`DROP INDEX IF EXISTS idx_download_requests_created`,
|
||||
`DROP INDEX IF EXISTS idx_download_requests_state`,
|
||||
`DROP INDEX IF EXISTS idx_download_wants_due`,
|
||||
`DROP INDEX IF EXISTS idx_download_wants_entity`,
|
||||
`DROP INDEX IF EXISTS idx_download_wants_parent`,
|
||||
`DROP INDEX IF EXISTS idx_download_items_request`,
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin download rename migration: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("download rename migration %q: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit download rename migration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
|
||||
// omits inline for the renamed table/columns (see
|
||||
// migrateDownloadRename), under their final names. Safe to call
|
||||
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
|
||||
// the time this runs every column/table involved is guaranteed to be
|
||||
// in its final shape on both a fresh and a migrated database.
|
||||
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
||||
ON download_downloads(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
||||
ON download_downloads(state)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
||||
ON download_requests(next_try_at) WHERE state = 'wanted'`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
||||
ON download_requests(entity, state)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
||||
ON download_requests(parent_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_download_items_download
|
||||
ON download_items(download_id)`,
|
||||
}
|
||||
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("ensure download index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
|
||||
// are frozen snapshots of the download subsystem's tables exactly as
|
||||
// they read before the Want/Request rename (see
|
||||
// download_rename_migration.go) — i.e. what a real user's existing
|
||||
// database looks like today, before upgrading to a build that includes
|
||||
// this migration.
|
||||
const oldDownloadRequestsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS download_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
library_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
||||
release_mbid TEXT,
|
||||
release_group_mbid TEXT,
|
||||
recording_mbid TEXT,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
album TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
expected TEXT NOT NULL DEFAULT '[]',
|
||||
state TEXT NOT NULL DEFAULT 'searching'
|
||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||
'verifying', 'tagging', 'importing',
|
||||
'complete', 'cancelled', 'failed')),
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
||||
ON download_requests(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
||||
ON download_requests(state);
|
||||
`
|
||||
|
||||
const oldDownloadWantsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS download_wants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mbid TEXT NOT NULL,
|
||||
entity TEXT NOT NULL
|
||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
||||
library_id INTEGER NOT NULL,
|
||||
artist TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'future'
|
||||
CHECK(scope IN ('future', 'all')),
|
||||
secondary INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'wanted'
|
||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
||||
parent_id INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
last_tried_at DATETIME,
|
||||
next_try_at DATETIME,
|
||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mbid, library_id),
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
||||
ON download_wants(next_try_at)
|
||||
WHERE state = 'wanted';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
||||
ON download_wants(entity, state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
||||
ON download_wants(parent_id);
|
||||
`
|
||||
|
||||
const oldDownloadItemsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS download_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
request_id TEXT NOT NULL,
|
||||
provider_id INTEGER NOT NULL,
|
||||
transport_id INTEGER,
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
candidate TEXT NOT NULL DEFAULT '{}',
|
||||
state TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
||||
'verifying', 'tagging', 'importing',
|
||||
'complete', 'cancelled', 'failed')),
|
||||
staging_dir TEXT NOT NULL DEFAULT '',
|
||||
bytes_done INTEGER NOT NULL DEFAULT 0,
|
||||
bytes_total INTEGER NOT NULL DEFAULT 0,
|
||||
imported_paths TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
||||
ON download_items(state)
|
||||
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
||||
ON download_items(request_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
||||
ON download_items(state);
|
||||
`
|
||||
|
||||
// seedOldDownloadSchema builds the pre-rename download tables and
|
||||
// inserts one row of real data into each, standing in for a real
|
||||
// user's database at the moment it upgrades.
|
||||
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
for _, ddl := range []string{
|
||||
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
|
||||
} {
|
||||
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
|
||||
t.Fatalf("create old download schema: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed library: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO download_wants
|
||||
(id, mbid, entity, library_id, artist, title, state)
|
||||
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed download_wants: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO download_requests
|
||||
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
|
||||
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed download_requests: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
t.Context(),
|
||||
`INSERT INTO download_items
|
||||
(id, request_id, provider_id, state)
|
||||
VALUES ('item-1', 'dl-1', 1, 'complete')`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed download_items: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
|
||||
// brand-new database produces the target shape directly and that
|
||||
// migrateDownloadRename's gate (checking for the old download_wants
|
||||
// table) is a no-op there — the destructive path this test guards
|
||||
// against is exactly the one described in download_rename_migration.go:
|
||||
// blindly replaying the rename against a fresh database's already-
|
||||
// correct, empty download_requests/download_downloads tables.
|
||||
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openMemDB(t)
|
||||
|
||||
if err := applySchema(t.Context(), db); err != nil {
|
||||
t.Fatalf("apply schema (fresh): %v", err)
|
||||
}
|
||||
|
||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
||||
var name string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
||||
table,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
var stray string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||
).Scan(&stray)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
|
||||
}
|
||||
|
||||
// Both auto-download guardrail indexes sql/schemas deliberately
|
||||
// omits (see ensureDownloadIndexes) must still exist.
|
||||
for _, idx := range []string{
|
||||
"idx_download_requests_due",
|
||||
"idx_download_requests_entity",
|
||||
"idx_download_requests_parent",
|
||||
"idx_download_items_download",
|
||||
} {
|
||||
var name string
|
||||
|
||||
err := db.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
||||
idx,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRename_UpgradesExistingDatabase is the regression test
|
||||
// for the rename itself: an old-shaped database (download_wants +
|
||||
// old-style download_requests, both with real rows) must end up with
|
||||
// the same table names, column names, and data a fresh install would
|
||||
// have — nothing dropped, nothing silently emptied.
|
||||
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fresh := openMemDB(t)
|
||||
if err := applySchema(t.Context(), fresh); err != nil {
|
||||
t.Fatalf("apply schema (fresh): %v", err)
|
||||
}
|
||||
|
||||
upgraded := openMemDB(t)
|
||||
|
||||
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read libraries schema: %v", err)
|
||||
}
|
||||
|
||||
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
||||
t.Fatalf("create libraries table: %v", err)
|
||||
}
|
||||
|
||||
seedOldDownloadSchema(t, upgraded)
|
||||
|
||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||
t.Fatalf("apply schema (upgrade path): %v", err)
|
||||
}
|
||||
|
||||
// Column order must match a fresh install's, for the same reason
|
||||
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
|
||||
// sqlc's `SELECT *` binds positionally.
|
||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
||||
freshCols := tableColumns(t, fresh, table)
|
||||
upgradedCols := tableColumns(t, upgraded, table)
|
||||
|
||||
if len(freshCols) != len(upgradedCols) {
|
||||
t.Fatalf(
|
||||
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
|
||||
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
||||
)
|
||||
}
|
||||
|
||||
for i := range freshCols {
|
||||
if freshCols[i] != upgradedCols[i] {
|
||||
t.Errorf(
|
||||
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
|
||||
table,
|
||||
i,
|
||||
freshCols[i],
|
||||
upgradedCols[i],
|
||||
freshCols,
|
||||
upgradedCols,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The seeded rows survived the rename under their new names.
|
||||
var (
|
||||
requestMBID string
|
||||
requestEntity string
|
||||
)
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
|
||||
).Scan(&requestMBID, &requestEntity)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded request row missing after rename: %v", err)
|
||||
}
|
||||
|
||||
if requestMBID != "artist-mbid" || requestEntity != "artist" {
|
||||
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
|
||||
}
|
||||
|
||||
var (
|
||||
downloadRequestID sql.NullInt64
|
||||
downloadAlbum string
|
||||
)
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
|
||||
).Scan(&downloadRequestID, &downloadAlbum)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded download row missing after rename: %v", err)
|
||||
}
|
||||
|
||||
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
|
||||
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
|
||||
}
|
||||
|
||||
if downloadAlbum != "OK Computer" {
|
||||
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
|
||||
}
|
||||
|
||||
var itemDownloadID string
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
|
||||
).Scan(&itemDownloadID)
|
||||
if err != nil {
|
||||
t.Fatalf("seeded item row missing after rename: %v", err)
|
||||
}
|
||||
|
||||
if itemDownloadID != "dl-1" {
|
||||
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
|
||||
}
|
||||
|
||||
// The old table is gone, not just emptied.
|
||||
var stray string
|
||||
|
||||
err = upgraded.QueryRowContext(
|
||||
t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
||||
).Scan(&stray)
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
|
||||
}
|
||||
|
||||
// Running the whole thing again (as a second app startup would) is
|
||||
// a no-op: the gate sees no download_wants table and does nothing
|
||||
// further, so this must not error or duplicate anything.
|
||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||
t.Fatalf("apply schema a second time: %v", err)
|
||||
}
|
||||
|
||||
var count int
|
||||
|
||||
if err := upgraded.QueryRowContext(
|
||||
t.Context(), `SELECT COUNT(*) FROM download_requests`,
|
||||
).Scan(&count); err != nil {
|
||||
t.Fatalf("count download_requests: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedExploreRow inserts one explore_index row.
|
||||
//
|
||||
// The catalog stores an MBID as 16 raw bytes and an entity type as a
|
||||
// code (see backend/explore/mbid.go), and the column says so, so the
|
||||
// label these tests use as an id is hashed into something the table
|
||||
// will accept. What they actually assert on is the FTS text.
|
||||
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
|
||||
t.Helper()
|
||||
|
||||
sum := sha256.Sum256([]byte(mbid))
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
||||
VALUES ('recording', ?, ?, ?, '')
|
||||
`, mbid, title, artist); err != nil {
|
||||
VALUES (3 /* recording */, ?, ?, ?, x'')
|
||||
`, sum[:16], title, artist); err != nil {
|
||||
t.Fatalf("seed %s: %v", mbid, err)
|
||||
}
|
||||
}
|
||||
@@ -157,3 +166,163 @@ func TestExploreFTSSuspendIsIdempotent(t *testing.T) {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ftsSegmentCount reports how much the FTS index itself has been
|
||||
// written to. Every delete + insert the update trigger performs
|
||||
// appends to the shadow content table, so this is the observable that
|
||||
// tells "the trigger re-indexed the row" from "the trigger declined
|
||||
// to". Search results cannot: a no-op re-index leaves the same
|
||||
// matches behind.
|
||||
func ftsSegmentCount(t *testing.T, db *DB) int {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.QueryContext("SELECT COUNT(*) FROM explore_index_fts_data")
|
||||
if err != nil {
|
||||
t.Fatalf("fts data count: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
n := 0
|
||||
|
||||
if rows.Next() {
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatalf("scan fts data count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// The common write in this schema is an upsert whose merge rules keep
|
||||
// every existing value — the discography backfill re-browsing a known
|
||||
// artist, the incremental dump refreshing popularity. Re-indexing
|
||||
// those cost an FTS5 delete against a multi-million row index while
|
||||
// holding the single writer connection, which is what starved the
|
||||
// playback path. An update that leaves title, artist_name and aliases
|
||||
// alone must not touch the FTS index at all.
|
||||
func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedExploreRow(t, db, "mbid-1", "Unchanged Title", "Steady Artist")
|
||||
|
||||
before := ftsSegmentCount(t, db)
|
||||
|
||||
// A popularity refresh: an FTS column is not named at all.
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET popularity = 42 WHERE title = ?",
|
||||
"Unchanged Title",
|
||||
); err != nil {
|
||||
t.Fatalf("popularity update: %v", err)
|
||||
}
|
||||
|
||||
// An upsert-shaped write that re-states the text identically, which
|
||||
// is what the merge rules produce for a row that has not changed.
|
||||
if _, err := db.ExecContext(`
|
||||
UPDATE explore_index
|
||||
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
|
||||
WHERE title = ?
|
||||
`, "Unchanged Title"); err != nil {
|
||||
t.Fatalf("no-op text update: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsSegmentCount(t, db); got != before {
|
||||
t.Errorf(
|
||||
"FTS index written by an update that changed no text: %d rows, want %d",
|
||||
got, before,
|
||||
)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Unchanged"); got != 1 {
|
||||
t.Errorf("matches after unchanged updates = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same guard: a real rename still re-indexes,
|
||||
// old term gone and new term found.
|
||||
func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?",
|
||||
"Original Title",
|
||||
); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Original"); got != 0 {
|
||||
t.Errorf("matches for the old title = %d, want 0", got)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Corrected"); got != 1 {
|
||||
t.Errorf("matches for the new title = %d, want 1", got)
|
||||
}
|
||||
|
||||
// The same for the other two indexed columns.
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?",
|
||||
"Corrected Title",
|
||||
); err != nil {
|
||||
t.Fatalf("artist rename: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Renamed"); got != 1 {
|
||||
t.Errorf("matches for the new artist = %d, want 1", got)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "AKA"); got != 1 {
|
||||
t.Errorf("matches for the new alias = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An existing install already carries the previous, unguarded trigger,
|
||||
// and a create that tolerated "already exists" would leave it there
|
||||
// forever — so the definition has to be replaced on open, not merely
|
||||
// offered.
|
||||
func TestExploreFTSTriggersAreReplacedOnOpen(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
// The shape that shipped before: fires on every UPDATE.
|
||||
if _, err := db.ExecContext(`
|
||||
CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
|
||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||
VALUES (new.id, new.title, new.artist_name, new.aliases);
|
||||
END
|
||||
`); err != nil {
|
||||
t.Fatalf("install old trigger: %v", err)
|
||||
}
|
||||
|
||||
if err := createExploreIndexFTSTriggers(db.Ctx, db.db); err != nil {
|
||||
t.Fatalf("recreate triggers: %v", err)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'explore_index_au'",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("read trigger sql: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
definition := ""
|
||||
|
||||
if rows.Next() {
|
||||
if err := rows.Scan(&definition); err != nil {
|
||||
t.Fatalf("scan trigger sql: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(definition, "UPDATE OF") ||
|
||||
!strings.Contains(definition, "WHEN") {
|
||||
t.Errorf("explore_index_au was not replaced; definition is:\n%s", definition)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-111
@@ -1,15 +1,26 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// toNullString treats an empty string as NULL.
|
||||
func toNullString(v string) sql.NullString {
|
||||
if v == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
|
||||
return sql.NullString{String: v, Valid: true}
|
||||
}
|
||||
|
||||
// LyricsHit is a single result from a lyric-fragment search: the
|
||||
// matched recording plus enough metadata to render and play it.
|
||||
// matched file plus enough metadata to render and play it.
|
||||
type LyricsHit struct {
|
||||
RecordingID int64
|
||||
AudioFileID int64
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
@@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Map the matched recording (lyrics_index.rowid == recordings.id)
|
||||
// to a representative playable file via the lowest audio_files id,
|
||||
// then to the track_metadata VIEW for display fields.
|
||||
// lyrics_index.rowid is the audio file's id, so the hit is already
|
||||
// a playable file - it used to be a recording id, which then had to
|
||||
// be mapped back to "some file of that recording" by a grouped
|
||||
// subquery.
|
||||
//
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
tm.id,
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.album
|
||||
FROM lyrics_index li
|
||||
JOIN recordings r ON r.id = li.rowid
|
||||
JOIN (
|
||||
SELECT recording_id, MIN(id) AS af_id
|
||||
FROM audio_files
|
||||
GROUP BY recording_id
|
||||
) af ON af.recording_id = r.id
|
||||
JOIN track_metadata tm ON tm.id = af.af_id
|
||||
JOIN track_metadata tm ON tm.id = li.rowid
|
||||
WHERE lyrics_index MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
@@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
for rows.Next() {
|
||||
var h LyricsHit
|
||||
if err := rows.Scan(
|
||||
&h.RecordingID,
|
||||
&h.AudioFileID,
|
||||
&h.FilePath,
|
||||
&h.LengthMilliseconds,
|
||||
&h.Title,
|
||||
@@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRecordingLyrics returns the stored lyrics for a recording, or
|
||||
// an empty string if none are stored.
|
||||
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
|
||||
// GetLyrics returns the stored lyrics for a file, or "" if none.
|
||||
func (d *DB) GetLyrics(audioFileID int64) (string, error) {
|
||||
var lyrics string
|
||||
|
||||
err := d.db.QueryRowContext(d.Ctx,
|
||||
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
|
||||
recordingID,
|
||||
err := d.reader().QueryRowContext(d.Ctx,
|
||||
"SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||
).Scan(&lyrics)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read recording lyrics: %w", err)
|
||||
return "", fmt.Errorf("could not read lyrics: %w", err)
|
||||
}
|
||||
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
|
||||
// lyrics_index in sync (delete + reinsert the single row). Used by
|
||||
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
|
||||
// string clears both the column and the index entry.
|
||||
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"UPDATE recordings SET lyrics = ? WHERE id = ?",
|
||||
lyrics, recordingID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not update recording lyrics: %w", err)
|
||||
// SetLyrics writes lyrics for a file and keeps the FTS index in sync.
|
||||
//
|
||||
// `source` says where they came from, which is the question the old
|
||||
// column could not answer: lyrics read from a USLT frame are rebuilt
|
||||
// free by any rescan, and lyrics fetched from LRCLIB are network
|
||||
// traffic nobody wants to repeat. Passing an empty string clears both
|
||||
// the row and the index entry.
|
||||
func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error {
|
||||
if strings.TrimSpace(lyrics) == "" {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not delete lyrics: %w", err)
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(audioFileID, "")
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(recordingID, lyrics)
|
||||
if _, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO lyrics (audio_file_id, text, source, recording_mbid)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(audio_file_id) DO UPDATE SET
|
||||
text = excluded.text,
|
||||
source = excluded.source,
|
||||
recording_mbid = COALESCE(excluded.recording_mbid, lyrics.recording_mbid),
|
||||
fetched_at = CURRENT_TIMESTAMP
|
||||
`, audioFileID, lyrics, source, toNullString(recordingMBID)); err != nil {
|
||||
return fmt.Errorf("could not write lyrics: %w", err)
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(audioFileID, lyrics)
|
||||
}
|
||||
|
||||
// upsertLyricsIndex refreshes a single recording's entry in the
|
||||
// contentless lyrics_index. contentless_delete=1 makes the DELETE
|
||||
// valid; an empty lyrics string leaves the row deleted.
|
||||
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
||||
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||
// lyrics string leaves the row deleted.
|
||||
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
|
||||
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
||||
}
|
||||
@@ -141,7 +168,7 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
||||
recordingID, lyrics,
|
||||
audioFileID, lyrics,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not insert lyrics_index row: %w", err)
|
||||
}
|
||||
@@ -149,22 +176,16 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
|
||||
// current recordings table. Cheap for a personal library and safe to
|
||||
// run after every scan.
|
||||
// RebuildLyricsIndex repopulates lyrics_index from the lyrics table.
|
||||
func (d *DB) RebuildLyricsIndex() error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics_index",
|
||||
); err != nil {
|
||||
if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil {
|
||||
return fmt.Errorf("could not clear lyrics_index: %w", err)
|
||||
}
|
||||
|
||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input.
|
||||
// SAFETY: FTS5 virtual table INSERT. Values sourced from lyrics; no user input.
|
||||
if _, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO lyrics_index(rowid, lyrics)
|
||||
SELECT id, lyrics
|
||||
FROM recordings
|
||||
WHERE lyrics IS NOT NULL AND lyrics != ''
|
||||
SELECT audio_file_id, text FROM lyrics WHERE text != ''
|
||||
`); err != nil {
|
||||
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
||||
}
|
||||
@@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordingsMissingLyrics returns recordings that have no stored
|
||||
// lyrics but do carry the artist/title/duration needed to look them
|
||||
// up from an external provider. Used by the LRCLIB backfill. The
|
||||
// limit bounds each batch so the backfill can be run incrementally.
|
||||
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
// LyricsCandidate identifies a file that needs its lyrics fetched and
|
||||
// carries the fields an external provider matches on.
|
||||
type LyricsCandidate struct {
|
||||
AudioFileID int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
RecordingMBID string
|
||||
LengthMilliseconds int64
|
||||
}
|
||||
|
||||
// FilesMissingLyrics returns files with no stored lyrics that carry
|
||||
// the artist/title/duration needed to look them up. Used by the
|
||||
// LRCLIB backfill; the limit bounds each batch.
|
||||
func (d *DB) FilesMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, ''),
|
||||
MIN(af.length_milliseconds)
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
WHERE (r.lyrics IS NULL OR r.lyrics = '')
|
||||
AND r.name IS NOT NULL AND r.name != ''
|
||||
AND ac.text IS NOT NULL AND ac.text != ''
|
||||
GROUP BY r.id
|
||||
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||
tm.recording_mbid, tm.length_milliseconds
|
||||
FROM track_metadata tm
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id)
|
||||
AND tm.title != '' AND tm.artist_name != ''
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err)
|
||||
return nil, fmt.Errorf("could not query files missing lyrics: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
@@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
for rows.Next() {
|
||||
var c LyricsCandidate
|
||||
if err := rows.Scan(
|
||||
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds,
|
||||
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
|
||||
}
|
||||
@@ -229,44 +247,23 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LyricsCandidate identifies a recording that needs its lyrics fetched
|
||||
// and carries the fields an external provider matches on.
|
||||
type LyricsCandidate struct {
|
||||
RecordingID int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
LengthMilliseconds int64
|
||||
}
|
||||
|
||||
// RecordingLyricLookup returns the provider-match fields (artist,
|
||||
// title, album, duration) for a single recording, so lyrics can be
|
||||
// fetched on demand. Returns nil if the recording has no audio file
|
||||
// or no artist/title to match on.
|
||||
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
|
||||
// FileLyricLookup returns the provider-match fields for one file, so
|
||||
// lyrics can be fetched on demand. Returns nil if the file has no
|
||||
// artist/title to match on.
|
||||
func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) {
|
||||
var c LyricsCandidate
|
||||
|
||||
err := d.db.QueryRowContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, ''),
|
||||
COALESCE(MIN(af.length_milliseconds), 0)
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
WHERE r.id = ?
|
||||
GROUP BY r.id
|
||||
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
|
||||
err := d.reader().QueryRowContext(d.Ctx, `
|
||||
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||
tm.recording_mbid, tm.length_milliseconds
|
||||
FROM track_metadata tm
|
||||
WHERE tm.id = ?
|
||||
`, audioFileID).Scan(
|
||||
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err)
|
||||
return nil, fmt.Errorf("could not look up file for lyrics: %w", err)
|
||||
}
|
||||
|
||||
if c.Title == "" || c.Artist == "" {
|
||||
|
||||
@@ -4,59 +4,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
|
||||
// recording → audio_file → release_group link) for one track with the
|
||||
// given lyrics, so lyric-search tests have realistic joins.
|
||||
// seedLyricsTrack inserts one file with the given lyrics, so lyric
|
||||
// searches have something realistic to join against. It used to
|
||||
// insert a four-row FK chain by hand.
|
||||
func seedLyricsTrack(
|
||||
t *testing.T,
|
||||
db *DB,
|
||||
id int64,
|
||||
title, artist, album, lyrics string,
|
||||
lenMs int64,
|
||||
) {
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
|
||||
); err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
fileID := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/music/track" + itoa(id) + ".mp3",
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
LengthMs: lenMs,
|
||||
})
|
||||
|
||||
if lyrics != "" {
|
||||
if err := db.SetLyrics(fileID, lyrics, "tag", ""); err != nil {
|
||||
t.Fatalf("seed lyrics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release_group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
|
||||
id, title, id, nullableLyrics(lyrics),
|
||||
); err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
|
||||
); err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
|
||||
id, id,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release_group_recordings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nullableLyrics(l string) any {
|
||||
if l == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return l
|
||||
return fileID
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
@@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) {
|
||||
}
|
||||
|
||||
h := hits[0]
|
||||
if h.RecordingID != 1 {
|
||||
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
|
||||
if h.AudioFileID != 1 {
|
||||
t.Errorf("RecordingID = %d, want 1", h.AudioFileID)
|
||||
}
|
||||
|
||||
if h.Title != "The Sound of Silence" {
|
||||
@@ -191,11 +165,11 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
||||
|
||||
// Backfill lyrics — should update both the column and the FTS index.
|
||||
const lyrics = "Yesterday all my troubles seemed so far away"
|
||||
if err := db.SetRecordingLyrics(1, lyrics); err != nil {
|
||||
if err := db.SetLyrics(1, lyrics, "lrclib", ""); err != nil {
|
||||
t.Fatalf("SetRecordingLyrics: %v", err)
|
||||
}
|
||||
|
||||
stored, err := db.GetRecordingLyrics(1)
|
||||
stored, err := db.GetLyrics(1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordingLyrics: %v", err)
|
||||
}
|
||||
@@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(hits) != 1 || hits[0].RecordingID != 1 {
|
||||
if len(hits) != 1 || hits[0].AudioFileID != 1 {
|
||||
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -222,7 +196,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
|
||||
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
||||
|
||||
missing, err := db.RecordingsMissingLyrics(50)
|
||||
missing, err := db.FilesMissingLyrics(50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
||||
}
|
||||
@@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
}
|
||||
|
||||
c := missing[0]
|
||||
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
||||
if c.AudioFileID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
||||
t.Errorf("unexpected candidate: %+v", c)
|
||||
}
|
||||
|
||||
@@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
}
|
||||
|
||||
// Single-recording lookup mirrors the batch fields.
|
||||
one, err := db.RecordingLyricLookup(2)
|
||||
one, err := db.FileLyricLookup(2)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordingLyricLookup: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// oldTaggingItemsDDL is a frozen snapshot of tagging_items exactly as
|
||||
// it read before sql/migrations/0001_tagging_items_synthetic.sql —
|
||||
// i.e. what a real user's existing database looks like today, before
|
||||
// upgrading to a build that includes that migration.
|
||||
const oldTaggingItemsDDL = `
|
||||
CREATE TABLE IF NOT EXISTS tagging_items (
|
||||
group_key TEXT PRIMARY KEY,
|
||||
library_id INTEGER NOT NULL,
|
||||
track_count INTEGER NOT NULL DEFAULT 0,
|
||||
album_name TEXT NOT NULL DEFAULT '',
|
||||
album_artist TEXT NOT NULL DEFAULT '',
|
||||
disc_number INTEGER NOT NULL DEFAULT 0,
|
||||
best_match_release_mbid TEXT,
|
||||
score REAL,
|
||||
last_checked_at DATETIME,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
|
||||
cleared_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
|
||||
ON tagging_items(library_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
|
||||
ON tagging_items(library_id) WHERE status = 'pending';
|
||||
`
|
||||
|
||||
// tableColumns returns the column names of a table in on-disk
|
||||
// (positional) order, via PRAGMA table_info — the order sqlc's
|
||||
// generated `SELECT *` scans bind to positionally.
|
||||
func tableColumns(t *testing.T, db *sql.DB, table string) []string {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.QueryContext(t.Context(), "PRAGMA table_info("+table+")")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var cols []string
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notnull int
|
||||
dfltValue sql.NullString
|
||||
primaryKey int
|
||||
)
|
||||
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &primaryKey); err != nil {
|
||||
t.Fatalf("scan table_info row: %v", err)
|
||||
}
|
||||
|
||||
cols = append(cols, name)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("iterate table_info: %v", err)
|
||||
}
|
||||
|
||||
return cols
|
||||
}
|
||||
|
||||
func openMemDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", ":memory:?_busy_timeout=5000&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory db: %v", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if err := applyPRAGMAs(t.Context(), db); err != nil {
|
||||
t.Fatalf("apply pragmas: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// TestMigrations_ColumnOrderMatchesFreshInstall is the regression
|
||||
// test for the exact failure mode that got the old 48-step migration
|
||||
// chain torn out (see .planning/NOTES.md, "No migration chain"):
|
||||
// sql/schemas drifting from what migrations actually produce, so
|
||||
// sqlc-generated code silently reads the wrong thing.
|
||||
//
|
||||
// A fresh install takes tagging_items straight from sql/schemas
|
||||
// (CREATE TABLE, columns in file order). An existing database takes
|
||||
// it from sql/schemas (the base shape, unchanged since the table
|
||||
// already existed) plus sql/migrations/0001 (`ALTER TABLE ADD
|
||||
// COLUMN`, which SQLite always appends at the END of the column
|
||||
// list, regardless of where the column sits in the CREATE TABLE
|
||||
// statement). If sql/schemas ever declares a migrated column
|
||||
// somewhere other than last, the two paths produce tables with the
|
||||
// SAME columns in a DIFFERENT order — invisible until a `SELECT *`
|
||||
// (e.g. GetTaggingItem) silently binds a value to the wrong field.
|
||||
func TestMigrations_ColumnOrderMatchesFreshInstall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fresh := openMemDB(t)
|
||||
if err := applySchema(t.Context(), fresh); err != nil {
|
||||
t.Fatalf("apply schema (fresh): %v", err)
|
||||
}
|
||||
|
||||
upgraded := openMemDB(t)
|
||||
|
||||
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("read libraries schema: %v", err)
|
||||
}
|
||||
|
||||
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
||||
t.Fatalf("create libraries table: %v", err)
|
||||
}
|
||||
|
||||
if _, err := upgraded.ExecContext(t.Context(), oldTaggingItemsDDL); err != nil {
|
||||
t.Fatalf("create pre-migration tagging_items: %v", err)
|
||||
}
|
||||
|
||||
// sql/schemas no-ops on the pre-existing tagging_items (IF NOT
|
||||
// EXISTS), then sql/migrations/0001's ALTER TABLE statements
|
||||
// actually add the missing columns for real this time.
|
||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
||||
t.Fatalf("apply schema (upgrade path): %v", err)
|
||||
}
|
||||
|
||||
freshCols := tableColumns(t, fresh, "tagging_items")
|
||||
upgradedCols := tableColumns(t, upgraded, "tagging_items")
|
||||
|
||||
if len(freshCols) != len(upgradedCols) {
|
||||
t.Fatalf(
|
||||
"column count mismatch: fresh install has %d (%v), upgraded has %d (%v)",
|
||||
len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
||||
)
|
||||
}
|
||||
|
||||
for i := range freshCols {
|
||||
if freshCols[i] != upgradedCols[i] {
|
||||
t.Errorf(
|
||||
"column order mismatch at position %d: fresh install has %q, upgraded has %q\nfresh: %v\nupgraded: %v",
|
||||
i,
|
||||
freshCols[i],
|
||||
upgradedCols[i],
|
||||
freshCols,
|
||||
upgradedCols,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrations_FreshDatabaseStillRecordsAndGetsIndex confirms a
|
||||
// brand-new database runs migration 0001 (tolerating "duplicate
|
||||
// column name" from its ALTER TABLE statements, since sql/schemas
|
||||
// already declared those columns), records it applied, AND still
|
||||
// gets the trailing CREATE INDEX statement sql/schemas deliberately
|
||||
// omits for migrated columns.
|
||||
func TestMigrations_FreshDatabaseStillRecordsAndGetsIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fresh := openMemDB(t)
|
||||
if err := applySchema(t.Context(), fresh); err != nil {
|
||||
t.Fatalf("apply schema: %v", err)
|
||||
}
|
||||
|
||||
var version int
|
||||
|
||||
err := fresh.QueryRowContext(
|
||||
t.Context(), "SELECT version FROM schema_migrations WHERE version = 1",
|
||||
).Scan(&version)
|
||||
if err != nil {
|
||||
t.Fatalf("expected migration 1 to be recorded as applied on a fresh db: %v", err)
|
||||
}
|
||||
|
||||
var indexName string
|
||||
|
||||
err = fresh.QueryRowContext(
|
||||
t.Context(),
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_tagging_items_parent_group_key'",
|
||||
).Scan(&indexName)
|
||||
if err != nil {
|
||||
t.Fatalf("expected idx_tagging_items_parent_group_key to exist on a fresh db: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOneRowPerTrackForAMultiArtistCredit pins what is left of the
|
||||
// multi-artist problem, which is now much smaller than it was.
|
||||
//
|
||||
// It used to be possible for one file to produce several rows: an
|
||||
// artist credit was a row in its own table linking *many* artists, so
|
||||
// any query that joined artist_credit_artist to read the artist MBID
|
||||
// returned the same track once per credited artist. The playlist, the
|
||||
// queue, the library list and the phantom resolver all did, and all
|
||||
// showed collaborations twice. Nine queries carried a
|
||||
// first-credited-artist subquery to work around it.
|
||||
//
|
||||
// The join is gone: a file carries its credit as text and points at one
|
||||
// primary artist, so the fan-out has nothing to fan out from. What is
|
||||
// still worth pinning is that the credit text survives intact - a
|
||||
// collaboration must still *read* as one - and that the file resolves
|
||||
// to exactly one row wherever it is asked for.
|
||||
func TestOneRowPerTrackForAMultiArtistCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
id := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/lib/collab.mp3",
|
||||
Title: "Collab Song",
|
||||
Artist: "A feat. B",
|
||||
ArtistMBID: "mbid-a",
|
||||
Album: "An Album",
|
||||
LengthMs: 200000,
|
||||
})
|
||||
|
||||
t.Run("one row in the view", func(t *testing.T) {
|
||||
var n int
|
||||
if err := db.QueryRowWriter(
|
||||
`SELECT COUNT(*) FROM track_metadata WHERE id = ?`, id,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
|
||||
if n != 1 {
|
||||
t.Errorf("track_metadata rows = %d, want 1", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the credit is preserved and the artist resolved", func(t *testing.T) {
|
||||
rows, err := db.Queries.GetTracks(db.Ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get tracks: %v", err)
|
||||
}
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("tracks = %d, want 1", len(rows))
|
||||
}
|
||||
|
||||
if rows[0].ArtistName != "A feat. B" {
|
||||
t.Errorf("artist credit = %q, want %q", rows[0].ArtistName, "A feat. B")
|
||||
}
|
||||
|
||||
if rows[0].ArtistMbid != "mbid-a" {
|
||||
t.Errorf("artist mbid = %q, want %q", rows[0].ArtistMbid, "mbid-a")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one row per album track", func(t *testing.T) {
|
||||
var albumID int64
|
||||
if err := db.QueryRowWriter(
|
||||
`SELECT album_id FROM audio_files WHERE id = ?`, id,
|
||||
).Scan(&albumID); err != nil {
|
||||
t.Fatalf("album id: %v", err)
|
||||
}
|
||||
|
||||
rows, err := db.Queries.GetTracks(db.Ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("album tracks: %v", err)
|
||||
}
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Errorf("album tracks = %d, want 1", len(rows))
|
||||
}
|
||||
})
|
||||
}
|
||||
+55
-176
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// SearchRow holds a single result from an FTS5 or basename search.
|
||||
@@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchTrackRow holds a full track result from an FTS5 search,
|
||||
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
|
||||
type SearchTrackRow struct {
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
ArtistName string
|
||||
TrackNumber sql.NullInt64
|
||||
DiscNumber sql.NullInt64
|
||||
Album string
|
||||
Genre string
|
||||
Year int64
|
||||
Composer string
|
||||
FileType string
|
||||
SampleRate int64
|
||||
BitDepth int64
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
// trackMetadataColumns is the column list of the track_metadata view,
|
||||
// in the order sqlc generates TrackMetadatum's fields. The FTS
|
||||
// searches below cannot be sqlc queries (MATCH is not in its grammar),
|
||||
// so this is the one place the view's shape is written out by hand.
|
||||
const trackMetadataColumns = `
|
||||
tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name,
|
||||
tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year,
|
||||
tm.release_year, tm.composer, tm.file_type, tm.sample_rate,
|
||||
tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id,
|
||||
tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid,
|
||||
tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id`
|
||||
|
||||
// scanTrackMetadata reads track_metadata rows into the generated row
|
||||
// type, so an FTS hit and an ordinary query produce the same Track.
|
||||
func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) {
|
||||
var out []sqlcgen.TrackMetadatum
|
||||
|
||||
for rows.Next() {
|
||||
var r sqlcgen.TrackMetadatum
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.FilePath, &r.LengthMilliseconds, &r.Title, &r.ArtistName,
|
||||
&r.TrackNumber, &r.DiscNumber, &r.Album, &r.Genre, &r.Year,
|
||||
&r.ReleaseYear, &r.Composer, &r.FileType, &r.SampleRate,
|
||||
&r.BitDepth, &r.Channels, &r.Bitrate, &r.FileSize, &r.LibraryID,
|
||||
&r.PlayCount, &r.LastPlayed, &r.CoverArtPath, &r.ArtistMbid,
|
||||
&r.ReleaseGroupMbid, &r.RecordingMbid, &r.AlbumID, &r.ArtistID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan track metadata: %w", err)
|
||||
}
|
||||
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate track metadata: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchFTSTracks performs a full-text search and returns full track
|
||||
// metadata for each match. Unlike SearchFTS (which returns only 5
|
||||
// columns), this includes all 16 fields needed for library.Track.
|
||||
// SearchFTSTracks performs a full-text search and returns whole tracks.
|
||||
//
|
||||
// A library id of 0 means every library. There were two of these, one
|
||||
// per case, each with its own copy of a sixteen-column projection that
|
||||
// silently dropped the MBIDs and the play count - which is why the
|
||||
// caller used to pass zeros for them.
|
||||
func (d *DB) SearchFTSTracks(
|
||||
query string, limit int,
|
||||
) ([]SearchTrackRow, error) {
|
||||
query string, libraryID int64, limit int,
|
||||
) ([]sqlcgen.TrackMetadatum, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ftsQuery := buildFTSQuery(query)
|
||||
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.track_number,
|
||||
tm.disc_number,
|
||||
tm.album,
|
||||
tm.genre,
|
||||
tm.year,
|
||||
tm.composer,
|
||||
tm.file_type,
|
||||
tm.sample_rate,
|
||||
tm.bit_depth,
|
||||
tm.channels,
|
||||
tm.bitrate,
|
||||
tm.file_size
|
||||
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||
SELECT`+trackMetadataColumns+`
|
||||
FROM search_index si
|
||||
JOIN track_metadata tm ON tm.id = si.rowid
|
||||
WHERE search_index MATCH ?
|
||||
AND (? = 0 OR tm.library_id = ?)
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, limit)
|
||||
`, buildFTSQuery(query), libraryID, libraryID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS track search failed: %w", err,
|
||||
)
|
||||
return nil, fmt.Errorf("FTS track search failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []SearchTrackRow
|
||||
|
||||
for rows.Next() {
|
||||
var r SearchTrackRow
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.FilePath,
|
||||
&r.LengthMilliseconds,
|
||||
&r.Title,
|
||||
&r.ArtistName,
|
||||
&r.TrackNumber,
|
||||
&r.DiscNumber,
|
||||
&r.Album,
|
||||
&r.Genre,
|
||||
&r.Year,
|
||||
&r.Composer,
|
||||
&r.FileType,
|
||||
&r.SampleRate,
|
||||
&r.BitDepth,
|
||||
&r.Channels,
|
||||
&r.Bitrate,
|
||||
&r.FileSize,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not scan search track row: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"search track row iteration error: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
return scanTrackMetadata(rows)
|
||||
}
|
||||
|
||||
// SearchFTSTracksByLibrary performs a full-text search scoped to a
|
||||
// specific library and returns full track metadata for each match.
|
||||
func (d *DB) SearchFTSTracksByLibrary(
|
||||
query string, limit int, libraryID int64,
|
||||
) ([]SearchTrackRow, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ftsQuery := buildFTSQuery(query)
|
||||
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.track_number,
|
||||
tm.disc_number,
|
||||
tm.album,
|
||||
tm.genre,
|
||||
tm.year,
|
||||
tm.composer,
|
||||
tm.file_type,
|
||||
tm.sample_rate,
|
||||
tm.bit_depth,
|
||||
tm.channels,
|
||||
tm.bitrate,
|
||||
tm.file_size
|
||||
FROM search_index si
|
||||
JOIN track_metadata tm ON tm.id = si.rowid
|
||||
WHERE search_index MATCH ? AND tm.library_id = ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, libraryID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS library track search failed: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []SearchTrackRow
|
||||
|
||||
for rows.Next() {
|
||||
var r SearchTrackRow
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.FilePath,
|
||||
&r.LengthMilliseconds,
|
||||
&r.Title,
|
||||
&r.ArtistName,
|
||||
&r.TrackNumber,
|
||||
&r.DiscNumber,
|
||||
&r.Album,
|
||||
&r.Genre,
|
||||
&r.Year,
|
||||
&r.Composer,
|
||||
&r.FileType,
|
||||
&r.SampleRate,
|
||||
&r.BitDepth,
|
||||
&r.Channels,
|
||||
&r.Bitrate,
|
||||
&r.FileSize,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not scan library search track row: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"library search track row iteration error: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// scanSearchRows reads all rows from a query result into a slice.
|
||||
func scanSearchRows(
|
||||
rows interface {
|
||||
Next() bool
|
||||
|
||||
+76
-232
@@ -3,6 +3,8 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// seedSearchData inserts ~7 tracks with the full FK chain required for
|
||||
@@ -84,128 +86,44 @@ func seedSearchData(t *testing.T, db *DB) {
|
||||
},
|
||||
}
|
||||
|
||||
// Build unique sets.
|
||||
artistMap := map[string]int64{}
|
||||
albumMap := map[string]int64{}
|
||||
|
||||
var artistID, albumID int64
|
||||
|
||||
for _, tr := range tracks {
|
||||
if _, ok := artistMap[tr.artist]; !ok {
|
||||
artistID++
|
||||
artistMap[tr.artist] = artistID
|
||||
}
|
||||
|
||||
if _, ok := albumMap[tr.album]; !ok {
|
||||
albumID++
|
||||
albumMap[tr.album] = albumID
|
||||
}
|
||||
}
|
||||
|
||||
// Insert artist_credit rows.
|
||||
for text, id := range artistMap {
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (?, ?)",
|
||||
id, text,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit %q: %v", text, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert release_groups.
|
||||
for name, id := range albumMap {
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO release_groups (id, name) VALUES (?, ?)",
|
||||
id, name,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert genres + recording_genres.
|
||||
genreMap := map[string]int64{}
|
||||
|
||||
var genreID int64
|
||||
|
||||
for _, tr := range tracks {
|
||||
if tr.genre == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := genreMap[tr.genre]; !ok {
|
||||
genreID++
|
||||
genreMap[tr.genre] = genreID
|
||||
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO genres (id, name) VALUES (?, ?)",
|
||||
genreID, tr.genre,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert genre %q: %v", tr.genre, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tr := range tracks {
|
||||
acID := artistMap[tr.artist]
|
||||
rgID := albumMap[tr.album]
|
||||
|
||||
// Insert recording.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, "+
|
||||
"track_number, disc_number, year, genre, composer) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tr.id, tr.title, acID, tr.trackNum, tr.discNum,
|
||||
tr.year, tr.genre, tr.composer,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err)
|
||||
}
|
||||
|
||||
// Insert audio_files.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, "+
|
||||
"length_milliseconds, file_type_id, recording_id, "+
|
||||
"sample_rate, bit_depth, channels, bitrate, file_size) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id,
|
||||
tr.sr, tr.bd, tr.ch, tr.br, tr.fsize,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file %d: %v", tr.id, err)
|
||||
}
|
||||
|
||||
// Link recording to release_group.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO release_group_recordings "+
|
||||
"(release_group_id, recording_id, track_number, disc_number) "+
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
rgID, tr.id, tr.trackNum, tr.discNum,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err)
|
||||
}
|
||||
|
||||
// Insert search_index entry (rowid must match audio_files.id).
|
||||
if err := db.InsertSearchIndex(
|
||||
tr.id, tr.filePath, tr.title, tr.artist, tr.album,
|
||||
); err != nil {
|
||||
t.Fatalf("insert search_index for %d: %v", tr.id, err)
|
||||
}
|
||||
|
||||
// Insert recording_genres link.
|
||||
var genres []string
|
||||
if tr.genre != "" {
|
||||
gID := genreMap[tr.genre]
|
||||
genres = []string{tr.genre}
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
|
||||
tr.id, gID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err)
|
||||
}
|
||||
var trackNum, discNum int64
|
||||
if tr.trackNum != nil {
|
||||
trackNum = *tr.trackNum
|
||||
}
|
||||
|
||||
if tr.discNum != nil {
|
||||
discNum = *tr.discNum
|
||||
}
|
||||
|
||||
id := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: tr.filePath,
|
||||
Title: tr.title,
|
||||
Artist: tr.artist,
|
||||
Album: tr.album,
|
||||
Genres: genres,
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
Year: tr.year,
|
||||
LengthMs: tr.lenMs,
|
||||
})
|
||||
|
||||
// The fixtures assert on audio properties and the composer,
|
||||
// which InsertTestTrack does not carry - they are not part of
|
||||
// what a seeder should have to know about a track.
|
||||
if _, err := db.ExecContext(
|
||||
`UPDATE audio_files
|
||||
SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
|
||||
channels = ?, bitrate = ?, file_size = ?, composer = ?
|
||||
WHERE id = ?`,
|
||||
tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
|
||||
); err != nil {
|
||||
t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,7 +471,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
seedSearchData(t, db)
|
||||
|
||||
results, err := db.SearchFTSTracks("queen", 10)
|
||||
results, err := db.SearchFTSTracks("queen", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchFTSTracks: %v", err)
|
||||
}
|
||||
@@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
||||
}
|
||||
|
||||
// Find the Bohemian Rhapsody result and verify all 16 fields.
|
||||
var br *SearchTrackRow
|
||||
var br *sqlcgen.TrackMetadatum
|
||||
|
||||
for i, r := range results {
|
||||
if r.Title == "Bohemian Rhapsody" {
|
||||
@@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Set up minimal FK chain for a single track.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/track.mp3",
|
||||
Title: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
LengthMs: 180000,
|
||||
})
|
||||
|
||||
// Insert into search index.
|
||||
if err := db.InsertSearchIndex(
|
||||
@@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) {
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Seed the full entity graph WITHOUT inserting into search_index.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group_recordings: %v", err)
|
||||
}
|
||||
// Seed the file WITHOUT putting it in search_index.
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/rebuild/track.mp3",
|
||||
Title: "Rebuild Track",
|
||||
Artist: "Rebuild Artist",
|
||||
Album: "Rebuild Album",
|
||||
LengthMs: 200000,
|
||||
SkipSearchIndex: true,
|
||||
})
|
||||
|
||||
// Search should return nothing before rebuild.
|
||||
results, err := db.SearchFTS("Rebuild", 10)
|
||||
@@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Set up minimal FK chain for a single track at rowid 100.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (100, 'Old Title', 100)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) " +
|
||||
"VALUES (100, '/test/update_cycle.mp3', 200000, 0, 100)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
id := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/update_cycle.mp3",
|
||||
Title: "Old Title",
|
||||
Artist: "Old Artist",
|
||||
LengthMs: 200000,
|
||||
SkipSearchIndex: true,
|
||||
})
|
||||
|
||||
// 1. Insert with old metadata.
|
||||
if err := db.InsertSearchIndex(
|
||||
100, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
|
||||
id, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
|
||||
); err != nil {
|
||||
t.Fatalf("InsertSearchIndex (old): %v", err)
|
||||
}
|
||||
|
||||
// Verify search for "Old Title" returns rowid 100.
|
||||
// Verify search for "Old Title" finds it.
|
||||
results, err := db.SearchFTS("Old Title", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchFTS(Old Title): %v", err)
|
||||
@@ -926,9 +790,9 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
||||
t.Fatal("SearchFTS(Old Title): got 0 results after insert")
|
||||
}
|
||||
|
||||
// 2. Delete rowid 100.
|
||||
if err := db.DeleteSearchIndex(100); err != nil {
|
||||
t.Fatalf("DeleteSearchIndex(100): %v", err)
|
||||
// 2. Delete the row.
|
||||
if err := db.DeleteSearchIndex(id); err != nil {
|
||||
t.Fatalf("DeleteSearchIndex(%d): %v", id, err)
|
||||
}
|
||||
|
||||
// Verify "Old Title" no longer found.
|
||||
@@ -944,25 +808,17 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Update the recording name in the DB to simulate tag edit.
|
||||
// 3. Update the file's title in the DB to simulate a tag edit.
|
||||
_, err = db.ExecContext(
|
||||
"UPDATE recordings SET name = 'New Title' WHERE id = 100",
|
||||
"UPDATE audio_files SET title = 'New Title' WHERE file_path = '/test/update_cycle.mp3'",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("update recording: %v", err)
|
||||
t.Fatalf("update title: %v", err)
|
||||
}
|
||||
|
||||
// Also add a new artist_credit for the new artist.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (101, 'New Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert new artist_credit: %v", err)
|
||||
}
|
||||
|
||||
// 4. Re-insert rowid 100 with new metadata.
|
||||
// 4. Re-insert the row with new metadata.
|
||||
if err := db.InsertSearchIndex(
|
||||
100, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
|
||||
id, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
|
||||
); err != nil {
|
||||
t.Fatalf("InsertSearchIndex (new): %v", err)
|
||||
}
|
||||
@@ -1079,25 +935,13 @@ func TestSearchIndexSchema(t *testing.T) {
|
||||
t.Fatalf("insert artist: %v", err)
|
||||
}
|
||||
|
||||
// The credit tables this used to assert a UNIQUE constraint on are
|
||||
// gone; a file names its artist directly, and artists are unique by
|
||||
// name, which is asserted below.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("first insert artist_credit_artist: %v", err)
|
||||
}
|
||||
|
||||
// Duplicate insert should fail with UNIQUE constraint.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
|
||||
"INSERT INTO artists (id, name) VALUES (2, 'Test')",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("duplicate artist_credit_artist insert should fail, got nil error")
|
||||
t.Error("duplicate artist name should fail, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Adds SplitMixedFolder's synthetic-group bookkeeping to an
|
||||
-- existing tagging_items table. A fresh database never runs this
|
||||
-- file: sql/schemas/tagging_items.sql already declares these
|
||||
-- columns, so applySchema's isFreshDatabase check stamps this
|
||||
-- version as applied without executing it.
|
||||
ALTER TABLE tagging_items ADD COLUMN synthetic INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tagging_items ADD COLUMN parent_group_key TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_parent_group_key
|
||||
ON tagging_items(parent_group_key) WHERE parent_group_key != '';
|
||||
@@ -1,24 +0,0 @@
|
||||
-- Repairs tagging_items rows left behind by a library-scan bug: the
|
||||
-- rescan's orphan-cleanup phase deleted audio_files rows for files
|
||||
-- removed from disk without decrementing/clearing their tagging
|
||||
-- group, so a folder whose contents were fully replaced kept a
|
||||
-- phantom entry (stale track_count, no matching audio_files) in the
|
||||
-- autotag queue forever. The library scan code no longer has this
|
||||
-- gap, but a database written before the fix still carries the
|
||||
-- damage — this is a one-time repair, not ongoing bookkeeping.
|
||||
--
|
||||
-- Drop groups with no audio_files left at all.
|
||||
DELETE FROM tagging_items
|
||||
WHERE group_key NOT IN (
|
||||
SELECT DISTINCT group_key FROM audio_files WHERE group_key != ''
|
||||
);
|
||||
|
||||
-- Reconcile track_count for groups that are still alive but drifted
|
||||
-- (some, not all, of their tracks were removed without decrementing).
|
||||
UPDATE tagging_items
|
||||
SET track_count = (
|
||||
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
|
||||
)
|
||||
WHERE track_count != (
|
||||
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
|
||||
);
|
||||
@@ -0,0 +1,184 @@
|
||||
-- Queries over albums (formerly release_groups).
|
||||
--
|
||||
-- The two-copy pattern is gone here too: one query answers both the
|
||||
-- whole-library and the single-library case. The `fallback_ac`
|
||||
-- subquery every album read used to carry -- "if the album has no album
|
||||
-- artist credit, borrow one from any of its recordings" -- is gone with
|
||||
-- it, because the album carries its own credit text now.
|
||||
|
||||
-- name: UpsertAlbum :one
|
||||
INSERT INTO albums (name, artist_credit, artist_id, year, cover_art_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name, artist_credit) DO UPDATE SET
|
||||
artist_id = COALESCE(excluded.artist_id, albums.artist_id),
|
||||
year = COALESCE(excluded.year, albums.year),
|
||||
cover_art_id = COALESCE(excluded.cover_art_id, albums.cover_art_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAlbum :one
|
||||
SELECT * FROM albums WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: SetAlbumMBID :exec
|
||||
UPDATE albums SET mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: SetAlbumOriginalYear :exec
|
||||
UPDATE albums SET original_year = ? WHERE id = ?;
|
||||
|
||||
-- name: SetAlbumCoverArt :exec
|
||||
UPDATE albums SET cover_art_id = ? WHERE id = ?;
|
||||
|
||||
-- name: SetAlbumPendingReleaseMBID :exec
|
||||
UPDATE albums SET pending_release_mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: ResolveAlbumPendingReleaseMBID :exec
|
||||
-- Clears the pending marker once the release-group MBID it stood in for
|
||||
-- has been resolved. Guarded so a real MBID is never overwritten.
|
||||
UPDATE albums
|
||||
SET mbid = ?, pending_release_mbid = NULL
|
||||
WHERE id = ? AND (mbid IS NULL OR mbid = '');
|
||||
|
||||
-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||
SELECT id, pending_release_mbid FROM albums
|
||||
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||
AND (mbid IS NULL OR mbid = '')
|
||||
LIMIT ?;
|
||||
|
||||
-- name: DeleteAlbum :exec
|
||||
DELETE FROM albums WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllAlbums :exec
|
||||
DELETE FROM albums;
|
||||
|
||||
-- name: GetEmptyAlbumIDs :many
|
||||
-- Albums with no file left behind them. Under the old schema this was
|
||||
-- one of three orphan sweeps that had to run by hand and did not;
|
||||
-- audio_files is the only thing that can leave an album empty now, so
|
||||
-- this is the whole of it.
|
||||
SELECT id FROM albums al
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
|
||||
);
|
||||
|
||||
-- name: GetAlbums :many
|
||||
SELECT
|
||||
al.id,
|
||||
al.name,
|
||||
COALESCE(al.original_year, al.year) AS year,
|
||||
COALESCE(al.year, 0) AS release_year,
|
||||
al.mbid,
|
||||
al.artist_credit AS artist_name,
|
||||
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path
|
||||
FROM albums al
|
||||
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.album_id = al.id
|
||||
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY al.name;
|
||||
|
||||
-- name: GetAlbumsByArtistName :many
|
||||
SELECT
|
||||
al.id,
|
||||
al.name,
|
||||
COALESCE(al.original_year, al.year) AS year,
|
||||
COALESCE(al.year, 0) AS release_year,
|
||||
al.mbid,
|
||||
al.artist_credit AS artist_name,
|
||||
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path
|
||||
FROM albums al
|
||||
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||
WHERE (al.artist_credit = sqlc.arg(artist) OR ar.name = sqlc.arg(artist))
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.album_id = al.id
|
||||
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY year, al.name;
|
||||
|
||||
-- name: GetAlbumCompleteness :one
|
||||
-- "Do I have all of this album", answered from the tags on disk.
|
||||
--
|
||||
-- The expectation is a **sum over discs**, not one number: totals are
|
||||
-- declared per disc ("5/12" on disc 2 means 12 tracks on disc 2), so a
|
||||
-- multi-disc album's expectation is the sum of each disc's declared
|
||||
-- total. A disc whose files declared nothing leaves the whole album
|
||||
-- unknowable rather than being covered by the discs that did -- which is
|
||||
-- what `known` reports.
|
||||
--
|
||||
-- Owned counts DISTINCT track numbers: this app detects duplicates, and
|
||||
-- counting two files of track 3 twice would report a short album as
|
||||
-- complete.
|
||||
SELECT
|
||||
-- Distinct (disc, track) pairs: this app detects duplicates, and
|
||||
-- counting two files of track 3 twice would report a short album as
|
||||
-- complete. A file with no track number falls back to its own id,
|
||||
-- because three untagged files are three tracks, not one.
|
||||
CAST(COUNT(DISTINCT CAST(COALESCE(a.disc_number, 1) AS TEXT) || ':' ||
|
||||
COALESCE(CAST(a.track_number AS TEXT), 'f' || a.id)
|
||||
) AS INTEGER) AS owned,
|
||||
CAST(COALESCE((
|
||||
SELECT SUM(per_disc.total)
|
||||
FROM (
|
||||
SELECT MAX(b.total_tracks) AS total
|
||||
FROM audio_files b
|
||||
WHERE b.album_id = sqlc.arg(album_id) AND b.total_tracks IS NOT NULL
|
||||
GROUP BY COALESCE(b.disc_number, 1)
|
||||
) per_disc
|
||||
), 0) AS INTEGER) AS expected,
|
||||
CAST((
|
||||
SELECT COUNT(*) = 0 FROM audio_files c
|
||||
WHERE c.album_id = sqlc.arg(album_id) AND c.total_tracks IS NULL
|
||||
) AS INTEGER) AS known
|
||||
FROM audio_files a
|
||||
WHERE a.album_id = sqlc.arg(album_id);
|
||||
|
||||
-- name: GetAlbumsCompleteness :many
|
||||
-- The same question as GetAlbumCompleteness, asked of a screenful of
|
||||
-- albums at once.
|
||||
--
|
||||
-- A card grid cannot afford one query per card, and the answer it wants
|
||||
-- is the one thing a badge cannot guess: an album held 9 tracks of 12
|
||||
-- must show the count, never a bare tick. So this is one query for the
|
||||
-- whole grid, asked only of the cards that have a local album id.
|
||||
--
|
||||
-- It is two grouping levels rather than the single-album form's
|
||||
-- correlated subqueries, because a correlated subquery in the FROM
|
||||
-- clause is not something SQLite will reliably do -- and because the
|
||||
-- slice may only be spelled once, or sqlc expands it twice with
|
||||
-- independently numbered placeholders.
|
||||
--
|
||||
-- The per-disc level is where the meaning is, and it is the same
|
||||
-- meaning as the single-album query. `owned` counts DISTINCT track
|
||||
-- numbers within a disc (this app detects duplicates, and counting two
|
||||
-- files of track 3 twice would report a short album as complete), with
|
||||
-- a file that declares no track number falling back to its own id
|
||||
-- because three untagged files are three tracks and not one.
|
||||
-- `expected` takes each disc's declared total and sums over discs,
|
||||
-- since a total is declared per disc and a release total written on
|
||||
-- every file of a two-disc album would double its expectation. A disc
|
||||
-- whose files declared nothing contributes a NULL that SUM ignores,
|
||||
-- and `known` is what says the album is therefore unanswerable.
|
||||
WITH per_disc AS (
|
||||
SELECT
|
||||
album_id AS album_id,
|
||||
COUNT(DISTINCT COALESCE(CAST(track_number AS TEXT), 'f' || id))
|
||||
AS owned_on_disc,
|
||||
MAX(total_tracks) AS disc_total,
|
||||
SUM(CASE WHEN total_tracks IS NULL THEN 1 ELSE 0 END)
|
||||
AS discs_without_a_total
|
||||
FROM audio_files
|
||||
WHERE album_id IN (sqlc.slice('album_ids'))
|
||||
GROUP BY album_id, COALESCE(disc_number, 1)
|
||||
)
|
||||
SELECT
|
||||
CAST(album_id AS INTEGER) AS album_id,
|
||||
CAST(SUM(owned_on_disc) AS INTEGER) AS owned,
|
||||
CAST(COALESCE(SUM(disc_total), 0) AS INTEGER) AS expected,
|
||||
CAST(SUM(discs_without_a_total) = 0 AS INTEGER) AS known
|
||||
FROM per_disc
|
||||
GROUP BY album_id;
|
||||
@@ -1,42 +0,0 @@
|
||||
-- name: CreateArtistCredit :one
|
||||
INSERT INTO artist_credit (text) VALUES (?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetArtistCredit :one
|
||||
SELECT * FROM artist_credit
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetArtistCreditByText :one
|
||||
SELECT * FROM artist_credit
|
||||
WHERE text = ? LIMIT 1;
|
||||
|
||||
-- name: UpsertArtistCredit :one
|
||||
INSERT INTO artist_credit (text) VALUES (?)
|
||||
ON CONFLICT(text) DO UPDATE SET text = excluded.text
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateArtistCredit :exec
|
||||
UPDATE artist_credit
|
||||
SET text = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteArtistCredit :exec
|
||||
DELETE FROM artist_credit
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllArtistCredits :exec
|
||||
DELETE FROM artist_credit;
|
||||
|
||||
-- name: CountArtistCreditReferences :one
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) +
|
||||
(SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1)
|
||||
AS total;
|
||||
|
||||
-- name: GetOrphanedArtistCreditIDs :many
|
||||
-- Artist credits no longer used by any recording or release group - run
|
||||
-- after orphaned recordings/release groups are deleted, so a credit
|
||||
-- that only existed for now-removed tracks is cleaned up too.
|
||||
SELECT ac.id FROM artist_credit ac
|
||||
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id);
|
||||
@@ -1,24 +0,0 @@
|
||||
-- name: CreateArtistCreditArtist :one
|
||||
INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetArtistCreditArtist :one
|
||||
SELECT * FROM artist_credit_artist
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: UpdateArtistCreditArtist :exec
|
||||
UPDATE artist_credit_artist
|
||||
SET artist_id = ?, credit_id = ?
|
||||
WHERE id =?;
|
||||
|
||||
-- name: DeleteArtistCreditArtist :exec
|
||||
DELETE FROM artist_credit_artist
|
||||
WHERE id =?;
|
||||
|
||||
-- name: DeleteAllArtistCreditArtists :exec
|
||||
DELETE FROM artist_credit_artist;
|
||||
|
||||
-- name: DeleteArtistCreditArtistByCredit :exec
|
||||
DELETE FROM artist_credit_artist
|
||||
WHERE credit_id = ?;
|
||||
|
||||
@@ -1,67 +1,55 @@
|
||||
-- name: CreateArtist :one
|
||||
INSERT INTO artists (name) VALUES (?)
|
||||
-- Queries over artists.
|
||||
--
|
||||
-- An artist row is reachable two ways: as a file's primary artist
|
||||
-- (audio_files.artist_id) and as an album's artist (albums.artist_id).
|
||||
-- Both used to route through artist_credit + artist_credit_artist,
|
||||
-- which is how "which album artists are in library 2" came to be a
|
||||
-- five-join subquery inside a three-join query.
|
||||
|
||||
-- name: UpsertArtist :one
|
||||
INSERT INTO artists (name, mbid) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
mbid = COALESCE(excluded.mbid, artists.mbid)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetArtist :one
|
||||
SELECT * FROM artists
|
||||
WHERE id = ? LIMIT 1;
|
||||
SELECT * FROM artists WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetArtistByName :one
|
||||
SELECT * FROM artists
|
||||
WHERE name = ? LIMIT 1;
|
||||
SELECT * FROM artists WHERE name = ? LIMIT 1;
|
||||
|
||||
-- name: UpsertArtist :one
|
||||
INSERT INTO artists (name) VALUES (?)
|
||||
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateArtist :exec
|
||||
UPDATE artists
|
||||
SET name = ?
|
||||
WHERE id = ?;
|
||||
-- name: SetArtistMBID :exec
|
||||
UPDATE artists SET mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: DeleteArtist :exec
|
||||
DELETE FROM artists
|
||||
WHERE id = ?;
|
||||
DELETE FROM artists WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllArtists :exec
|
||||
DELETE FROM artists;
|
||||
|
||||
-- name: GetUnreferencedArtistIDs :many
|
||||
-- Artists no file and no album points at any more.
|
||||
SELECT id FROM artists a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id);
|
||||
|
||||
-- name: GetAllArtists :many
|
||||
SELECT * FROM artists
|
||||
ORDER BY name;
|
||||
SELECT * FROM artists ORDER BY name;
|
||||
|
||||
-- name: GetAlbumArtists :many
|
||||
SELECT DISTINCT a.id, a.name, a.mbid
|
||||
FROM artists a
|
||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
||||
ORDER BY a.name;
|
||||
|
||||
-- name: GetOrphanedArtistIDs :many
|
||||
-- Artists no longer credited on any recording or release group - left
|
||||
-- behind when a scan's orphan cleanup removes the audio_files that used
|
||||
-- to justify them, since deleting an audio_files row doesn't cascade.
|
||||
SELECT a.id FROM artists a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
|
||||
);
|
||||
|
||||
-- name: GetAlbumArtistsByLibrary :many
|
||||
SELECT DISTINCT a.id, a.name, a.mbid
|
||||
FROM artists a
|
||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
||||
WHERE a.id IN (
|
||||
SELECT DISTINCT aca2.artist_id
|
||||
FROM artist_credit_artist aca2
|
||||
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
|
||||
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
|
||||
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
|
||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
||||
WHERE af2.library_id = ?
|
||||
JOIN albums al ON al.artist_id = a.id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.album_id = al.id
|
||||
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY a.name;
|
||||
|
||||
-- name: GetArtistByFilePath :one
|
||||
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
|
||||
FROM audio_files af
|
||||
LEFT JOIN artists a ON a.id = af.artist_id
|
||||
WHERE af.file_path = ?
|
||||
LIMIT 1;
|
||||
|
||||
@@ -1,39 +1,60 @@
|
||||
-- Queries over audio_files and the track_metadata view above it.
|
||||
--
|
||||
-- Every query that returns "a track" selects from `track_metadata`,
|
||||
-- which is the one place the projection is defined. The scoped and
|
||||
-- unscoped variants that used to be written twice are one query now:
|
||||
-- library_id 0 means "every library", and `(:id = 0 OR library_id = :id)`
|
||||
-- costs nothing measurable (23 ms vs 21 ms over 26k rows) because these
|
||||
-- queries scan either way.
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Writes
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: CreateAudioFile :one
|
||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateAudioFileWithGroupKey :one
|
||||
INSERT INTO audio_files (
|
||||
file_path, length_milliseconds, file_type_id, recording_id,
|
||||
sample_rate, bit_depth, channels, bitrate, file_size, basename,
|
||||
library_id, group_key, tag_status, modified_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
file_path, library_id, file_type_id,
|
||||
length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size,
|
||||
title, artist_credit, artist_id, album_id,
|
||||
track_number, disc_number, total_tracks, year, composer, comment,
|
||||
recording_mbid, basename, group_key, modified_at, tag_status
|
||||
) VALUES (
|
||||
?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAudioFileGroupKey :one
|
||||
SELECT group_key FROM audio_files
|
||||
WHERE id = ? LIMIT 1;
|
||||
-- name: UpdateAudioFileTags :exec
|
||||
-- A rescan of a file whose mtime moved: the tags are re-read and
|
||||
-- written over the same row. Under the old schema this created a
|
||||
-- *new* recording and repointed the file at it, abandoning the old one
|
||||
-- -- which is where 812 orphaned rows and every phantom "you own this"
|
||||
-- came from. There is nothing to orphan now.
|
||||
UPDATE audio_files
|
||||
SET title = ?, artist_credit = ?, artist_id = ?, album_id = ?,
|
||||
track_number = ?, disc_number = ?, total_tracks = ?, year = ?,
|
||||
composer = ?, comment = ?, recording_mbid = ?,
|
||||
sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?,
|
||||
file_size = ?, length_milliseconds = ?, modified_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetAudioFileGroupKey :exec
|
||||
UPDATE audio_files SET group_key = ? WHERE id = ?;
|
||||
|
||||
-- name: GetAudioFile :one
|
||||
SELECT * FROM audio_files
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetAudioFileByPath :one
|
||||
SELECT * FROM audio_files
|
||||
WHERE file_path = ? LIMIT 1;
|
||||
|
||||
-- name: UpdateAudioFile :exec
|
||||
UPDATE audio_files
|
||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: UpdateAudioFileRecording :exec
|
||||
-- name: PromoteAudioFileTagStatusIfUntagged :exec
|
||||
-- A rescan re-reads the tags of a file whose mtime moved, so a file
|
||||
-- another tagger stamped with MBIDs since import arrives here still
|
||||
-- carrying the 'untagged' status it was created with (only the insert
|
||||
-- path sets it). Promote it the same way saveAudioFile does.
|
||||
-- Guarded on 'untagged' so it cannot overwrite a deliberate
|
||||
-- 'user_skipped_permanent', and so a file losing its MBIDs is left
|
||||
-- alone -- demotion is the scan's judgement, not this statement's.
|
||||
UPDATE audio_files
|
||||
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ?
|
||||
WHERE id = ?;
|
||||
SET tag_status = 'user_confirmed'
|
||||
WHERE id = ? AND tag_status = 'untagged';
|
||||
|
||||
-- name: UpdateAudioFileStat :exec
|
||||
-- Records the on-disk mtime/size without re-reading tags. Used to
|
||||
@@ -43,307 +64,146 @@ UPDATE audio_files
|
||||
SET modified_at = ?, file_size = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetAudioFileRecordingMBID :exec
|
||||
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: DeleteAudioFile :exec
|
||||
DELETE FROM audio_files WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllAudioFiles :exec
|
||||
DELETE FROM audio_files;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Reads: the file row itself
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: GetAudioFile :one
|
||||
SELECT * FROM audio_files WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetAudioFileByPath :one
|
||||
SELECT * FROM audio_files WHERE file_path = ? LIMIT 1;
|
||||
|
||||
-- name: GetAudioFileGroupKey :one
|
||||
SELECT group_key FROM audio_files WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetAllAudioFilePaths :many
|
||||
SELECT id, file_path FROM audio_files;
|
||||
|
||||
-- name: GetAudioFilesByPaths :many
|
||||
SELECT id, library_id, file_path, group_key FROM audio_files
|
||||
WHERE file_path IN (sqlc.slice('paths'));
|
||||
|
||||
-- name: GetRandomAudioFilePath :one
|
||||
SELECT file_path FROM audio_files ORDER BY RANDOM() LIMIT 1;
|
||||
|
||||
-- name: CountAudioFiles :one
|
||||
SELECT COUNT(*) AS count FROM audio_files
|
||||
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
|
||||
|
||||
-- name: GetLibraryMaxModifiedAt :one
|
||||
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
|
||||
-- the library is empty or no row has a baseline yet.
|
||||
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
||||
WHERE library_id = ?;
|
||||
|
||||
-- name: DeleteAudioFile :exec
|
||||
DELETE FROM audio_files
|
||||
WHERE id = ?;
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Reads: tracks
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: CountAudioFiles :one
|
||||
SELECT count(*) FROM audio_files;
|
||||
-- name: GetTracks :many
|
||||
SELECT * FROM track_metadata
|
||||
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
|
||||
|
||||
-- name: GetRandomAudioFilePath :one
|
||||
SELECT file_path FROM audio_files
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 1;
|
||||
-- name: GetTrackByPath :one
|
||||
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
|
||||
|
||||
-- name: GetAllAudioFiles :many
|
||||
SELECT * FROM audio_files;
|
||||
-- name: GetTracksByAlbum :many
|
||||
SELECT * FROM track_metadata
|
||||
WHERE album_id = sqlc.arg(album_id)
|
||||
AND library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id)
|
||||
ORDER BY disc_number, track_number;
|
||||
|
||||
-- name: GetAllAudioFilePaths :many
|
||||
SELECT id, file_path FROM audio_files;
|
||||
|
||||
-- name: GetAudioFilesNeedingMetadata :many
|
||||
SELECT * FROM audio_files
|
||||
WHERE recording_id = 0;
|
||||
|
||||
-- name: GetAllAudioFilesWithArtist :many
|
||||
SELECT
|
||||
af.id,
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
af.file_type_id,
|
||||
af.recording_id,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
COALESCE(r.name, '') AS title
|
||||
FROM audio_files af
|
||||
JOIN recordings r ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id;
|
||||
|
||||
-- name: GetTrackMetadataByPath :one
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
WHERE af.file_path = ?
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetAllTracksWithFullMetadata :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size,
|
||||
af.play_count,
|
||||
af.last_played,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM audio_files af
|
||||
JOIN recordings r ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
|
||||
|
||||
-- name: SearchAudioFilesByBasename :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
WHERE af.basename = ?
|
||||
LIMIT ?;
|
||||
-- name: GetTracksByGenre :many
|
||||
SELECT tm.* FROM track_metadata tm
|
||||
JOIN file_genres fg ON fg.audio_file_id = tm.id
|
||||
JOIN genres g ON g.id = fg.genre_id
|
||||
WHERE g.name = sqlc.arg(genre)
|
||||
AND tm.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), tm.library_id);
|
||||
|
||||
-- name: LookupTrackMetaByPaths :many
|
||||
SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid
|
||||
SELECT id, file_path, title, artist_name, album, cover_art_path,
|
||||
artist_mbid, release_group_mbid, recording_mbid
|
||||
FROM track_metadata
|
||||
WHERE file_path IN (sqlc.slice('paths'));
|
||||
|
||||
-- name: GetAudioFilesByLibrary :many
|
||||
SELECT * FROM audio_files WHERE library_id = ?;
|
||||
-- name: SearchTracksByBasename :many
|
||||
SELECT id, file_path, length_milliseconds, title, artist_name, album
|
||||
FROM track_metadata
|
||||
WHERE file_path IN (
|
||||
SELECT file_path FROM audio_files WHERE basename = sqlc.arg(basename)
|
||||
)
|
||||
LIMIT sqlc.arg(lim);
|
||||
|
||||
-- name: CountAudioFilesByLibrary :one
|
||||
SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?;
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Reads: file paths, grouped by whatever the caller asked about
|
||||
-- ---------------------------------------------------------------------
|
||||
-- These answer "what can I play" and they all ask audio_files, because
|
||||
-- that is the only table whose rows are files. Grouped rather than
|
||||
-- flattened because the caller owns the order.
|
||||
|
||||
-- name: DeleteAllAudioFiles :exec
|
||||
DELETE FROM audio_files;
|
||||
|
||||
-- name: GetAllTracksWithFullMetadataByLibrary :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size,
|
||||
af.play_count,
|
||||
af.last_played,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM audio_files af
|
||||
JOIN recordings r ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE af.library_id = ?;
|
||||
|
||||
-- name: GetAudioFilesByReleaseGroup :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
rgr.track_number,
|
||||
rgr.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE rgr.release_group_id = ?
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
-- name: GetAudioFilesByReleaseGroupByLibrary :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
rgr.track_number,
|
||||
rgr.disc_number,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE rgr.release_group_id = ? AND af.library_id = ?
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
-- "Play this artist" and "play these albums" wanted file paths and asked
|
||||
-- for whole track rows to get them, one round trip per album (perf.m2).
|
||||
-- These answer the same question in one query and carry only what the
|
||||
-- caller uses; the release group id comes back so the caller can keep
|
||||
-- its own album ordering.
|
||||
|
||||
-- name: GetFilePathsByReleaseGroups :many
|
||||
SELECT rgr.release_group_id, af.file_path
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
-- name: GetFilePathsByReleaseGroupsByLibrary :many
|
||||
SELECT rgr.release_group_id, af.file_path
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings r ON rgr.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
||||
AND af.library_id = ?
|
||||
ORDER BY rgr.disc_number, rgr.track_number;
|
||||
|
||||
-- Same shape again, keyed on recording MBID, for the catalog side.
|
||||
-- An Explore album page knows which of its tracks the user owns only
|
||||
-- as a set of recording MBIDs -- that is exactly how the backend
|
||||
-- decides `inLibrary` (markReleasesInLibrary -> CheckMBIDs) -- and
|
||||
-- MBTrack.LocalID is declared but never written by anything, so there
|
||||
-- is no id to ask by. Grouped by MBID because a recording can have
|
||||
-- more than one file (the duplicate fixtures are precisely that) and
|
||||
-- because the caller owns the order: the tracklist's, not the
|
||||
-- database's.
|
||||
-- name: GetFilePathsByAlbums :many
|
||||
-- The library filter is applied in Go rather than here: sqlc numbers a
|
||||
-- named parameter (?2) but expands a slice into N placeholders, so the
|
||||
-- two together bind the wrong values - GetFilePathsByAlbums([1,2], 0)
|
||||
-- read album id 2 as the library id. Returning library_id and
|
||||
-- filtering the (small) result is the version that cannot be wrong.
|
||||
SELECT album_id, library_id, file_path FROM audio_files
|
||||
WHERE album_id IN (sqlc.slice('album_ids'))
|
||||
ORDER BY disc_number, track_number;
|
||||
|
||||
-- name: GetFilePathsByRecordingMBIDs :many
|
||||
SELECT r.mbid AS recording_mbid, af.file_path
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE r.mbid IN (sqlc.slice('mbids'))
|
||||
ORDER BY af.file_path;
|
||||
-- The ownership question in its only honest form: which of these
|
||||
-- catalog recordings has a *file* behind it. Asked of audio_files, so
|
||||
-- a metadata row with no file cannot answer yes.
|
||||
SELECT recording_mbid, library_id, file_path FROM audio_files
|
||||
WHERE recording_mbid IN (sqlc.slice('mbids'))
|
||||
ORDER BY file_path;
|
||||
|
||||
-- name: GetFilePathsByRecordingMBIDsByLibrary :many
|
||||
SELECT r.mbid AS recording_mbid, af.file_path
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE r.mbid IN (sqlc.slice('mbids'))
|
||||
AND af.library_id = ?
|
||||
ORDER BY af.file_path;
|
||||
-- name: GetFilePathsByGenres :many
|
||||
SELECT g.name AS genre, af.library_id, af.file_path
|
||||
FROM audio_files af
|
||||
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||
JOIN genres g ON g.id = fg.genre_id
|
||||
WHERE g.name IN (sqlc.slice('genres'))
|
||||
ORDER BY af.disc_number, af.track_number;
|
||||
|
||||
-- name: GetAudioFilesByPaths :many
|
||||
SELECT id, library_id, file_path, group_key FROM audio_files
|
||||
WHERE file_path IN (sqlc.slice('paths'));
|
||||
-- name: GetFilePathsByArtistMBID :many
|
||||
SELECT DISTINCT af.file_path
|
||||
FROM audio_files af
|
||||
JOIN artists a ON a.id = af.artist_id
|
||||
WHERE a.mbid = ?;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Ownership, asked in bulk
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- name: OwnedRecordingMBIDs :many
|
||||
-- Which of these recording MBIDs are actually in the library. This is
|
||||
-- what marks a catalog tracklist owned; it used to be
|
||||
-- `SELECT mbid FROM recordings`, which answered yes for 129 tracks in a
|
||||
-- real library that had no file at all.
|
||||
SELECT DISTINCT recording_mbid FROM audio_files
|
||||
WHERE recording_mbid IN (sqlc.slice('mbids'));
|
||||
|
||||
-- name: OwnedAlbumMBIDs :many
|
||||
SELECT DISTINCT al.mbid FROM albums al
|
||||
JOIN audio_files af ON af.album_id = al.id
|
||||
WHERE al.mbid IN (sqlc.slice('mbids'));
|
||||
|
||||
-- name: OwnedArtistMBIDs :many
|
||||
SELECT DISTINCT a.mbid FROM artists a
|
||||
JOIN audio_files af ON af.artist_id = a.id
|
||||
WHERE a.mbid IN (sqlc.slice('mbids'));
|
||||
|
||||
-- name: GetAudioFilesInLibrary :many
|
||||
SELECT * FROM audio_files WHERE library_id = ?;
|
||||
|
||||
@@ -1,150 +1,50 @@
|
||||
-- Queries over genres and file_genres.
|
||||
--
|
||||
-- The track-returning ones live in audio_files.sql with the rest of the
|
||||
-- track_metadata reads; what is left here is the genre list itself and
|
||||
-- the link table's writes.
|
||||
|
||||
-- name: UpsertGenre :one
|
||||
INSERT INTO genres (name) VALUES (?)
|
||||
ON CONFLICT(name) DO UPDATE SET name = name
|
||||
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateRecordingGenre :exec
|
||||
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
|
||||
VALUES (?, ?);
|
||||
-- name: LinkFileGenre :exec
|
||||
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?);
|
||||
|
||||
-- name: DeleteRecordingGenres :exec
|
||||
DELETE FROM recording_genres
|
||||
WHERE recording_id = ?;
|
||||
-- name: DeleteFileGenres :exec
|
||||
DELETE FROM file_genres WHERE audio_file_id = ?;
|
||||
|
||||
-- name: GetGenresByRecordingID :many
|
||||
SELECT g.*
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
WHERE rg.recording_id = ?;
|
||||
-- name: GetGenreNamesByFile :many
|
||||
SELECT g.name FROM genres g
|
||||
JOIN file_genres fg ON fg.genre_id = g.id
|
||||
WHERE fg.audio_file_id = ?;
|
||||
|
||||
-- name: DeleteAllRecordingGenres :exec
|
||||
DELETE FROM recording_genres;
|
||||
|
||||
-- name: DeleteAllGenres :exec
|
||||
DELETE FROM genres;
|
||||
|
||||
-- name: GetTracksByGenre :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rlg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
||||
FROM recording_genres rg2
|
||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
||||
WHERE rg2.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE g.name = ?
|
||||
ORDER BY r.name;
|
||||
|
||||
-- name: GetTracksByGenreByLibrary :many
|
||||
SELECT
|
||||
af.file_path,
|
||||
af.length_milliseconds,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
r.track_number,
|
||||
r.disc_number,
|
||||
COALESCE(rlg.name, '') AS album,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
||||
FROM recording_genres rg2
|
||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
||||
WHERE rg2.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(r.year, 0) AS year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
af.channels,
|
||||
af.bitrate,
|
||||
af.file_size
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id,
|
||||
MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
||||
WHERE g.name = ? AND af.library_id = ?
|
||||
ORDER BY r.name;
|
||||
|
||||
-- name: CountGenreReferences :one
|
||||
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?;
|
||||
-- name: GetGenreNamesByFilePaths :many
|
||||
-- Genres for many files at once. The mix builder asked this one file
|
||||
-- at a time, inside two nested loops -- twelve thousand single-row
|
||||
-- queries to assemble one mix.
|
||||
SELECT af.file_path, g.name
|
||||
FROM audio_files af
|
||||
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||
JOIN genres g ON g.id = fg.genre_id
|
||||
WHERE af.file_path IN (sqlc.slice('paths'));
|
||||
|
||||
-- name: DeleteGenre :exec
|
||||
DELETE FROM genres WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllGenres :exec
|
||||
DELETE FROM genres;
|
||||
|
||||
-- name: GetUnusedGenreIDs :many
|
||||
SELECT id FROM genres g
|
||||
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id);
|
||||
|
||||
-- name: GetAllGenresWithCounts :many
|
||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
||||
SELECT g.name, COUNT(fg.audio_file_id) AS track_count
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN file_genres fg ON fg.genre_id = g.id
|
||||
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||
WHERE af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name;
|
||||
|
||||
-- name: GetAllGenresWithCountsByLibrary :many
|
||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE af.library_id = ?
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name;
|
||||
|
||||
-- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
||||
-- one query instead of one per genre, and file paths instead of whole
|
||||
-- track rows, which was 6 MB over the IPC for five genres.
|
||||
|
||||
-- name: GetFilePathsByGenres :many
|
||||
SELECT g.name AS genre_name, af.file_path
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE g.name IN (sqlc.slice('genre_names'))
|
||||
ORDER BY r.name;
|
||||
|
||||
-- name: GetFilePathsByGenresByLibrary :many
|
||||
SELECT g.name AS genre_name, af.file_path
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
JOIN recordings r ON rg.recording_id = r.id
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE g.name IN (sqlc.slice('genre_names'))
|
||||
AND af.library_id = ?
|
||||
ORDER BY r.name;
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
--
|
||||
-- Every one of these returns album ids and nothing else. The display
|
||||
-- columns (cover art, artist credit, year) already have exactly one
|
||||
-- correct expression of them, in GetAllAlbumsWithDetails, and a second
|
||||
-- correct expression of them, in GetAlbums, and a second
|
||||
-- copy per shelf would be six more places for that to drift. The home
|
||||
-- service joins the ids back to that one album list in Go.
|
||||
|
||||
-- name: HomeRecentlyPlayedAlbums :many
|
||||
-- Albums with the most recent play, newest first.
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
WHERE af.last_played IS NOT NULL
|
||||
GROUP BY rg.id
|
||||
ORDER BY MAX(af.last_played) DESC
|
||||
@@ -22,9 +21,8 @@ LIMIT ?;
|
||||
-- stands in for one: it is monotonic and assigned at import, which is
|
||||
-- the same ordering an added_at column would give.
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
GROUP BY rg.id
|
||||
ORDER BY MAX(af.id) DESC
|
||||
LIMIT ?;
|
||||
@@ -32,9 +30,8 @@ LIMIT ?;
|
||||
-- name: HomeMostPlayedAlbums :many
|
||||
-- Albums by total plays across their tracks.
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
GROUP BY rg.id
|
||||
HAVING SUM(af.play_count) > 0
|
||||
ORDER BY SUM(af.play_count) DESC
|
||||
@@ -45,9 +42,8 @@ LIMIT ?;
|
||||
-- shelf is a different suggestion each time rather than the same
|
||||
-- alphabetical head of the list forever.
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
GROUP BY rg.id
|
||||
HAVING SUM(af.play_count) = 0
|
||||
ORDER BY RANDOM()
|
||||
@@ -56,9 +52,8 @@ LIMIT ?;
|
||||
-- name: HomeStaleAlbums :many
|
||||
-- Played before, but not for a long while.
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
WHERE af.last_played IS NOT NULL
|
||||
GROUP BY rg.id
|
||||
HAVING MAX(af.last_played) < datetime('now', ?)
|
||||
@@ -67,9 +62,8 @@ LIMIT ?;
|
||||
|
||||
-- name: HomeRandomAlbums :many
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
GROUP BY rg.id
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?;
|
||||
@@ -78,10 +72,10 @@ LIMIT ?;
|
||||
-- A random sample of albums carrying a genre, so the same genre shelf
|
||||
-- is not the same ten albums every time the page opens.
|
||||
SELECT rg.id AS album_id
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
|
||||
JOIN genres g ON g.id = rgen.genre_id
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||
JOIN genres g ON g.id = fg.genre_id
|
||||
WHERE g.name = ?
|
||||
GROUP BY rg.id
|
||||
ORDER BY RANDOM()
|
||||
@@ -93,10 +87,10 @@ LIMIT ?;
|
||||
-- album carries is a shelf about that one album.
|
||||
SELECT
|
||||
g.name AS genre,
|
||||
COUNT(DISTINCT rgr.release_group_id) AS album_count
|
||||
COUNT(DISTINCT af.album_id) AS album_count
|
||||
FROM genres g
|
||||
JOIN recording_genres rgen ON rgen.genre_id = g.id
|
||||
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
|
||||
JOIN file_genres fg ON fg.genre_id = g.id
|
||||
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||
GROUP BY g.id
|
||||
HAVING album_count >= 3
|
||||
ORDER BY album_count DESC
|
||||
@@ -106,14 +100,12 @@ LIMIT ?;
|
||||
-- Artists by total plays, as the album-artist credit text the album
|
||||
-- list already displays.
|
||||
SELECT
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
rg.artist_credit AS artist_name,
|
||||
SUM(af.play_count) AS plays
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
||||
WHERE ac.text <> ''
|
||||
GROUP BY ac.text
|
||||
FROM albums rg
|
||||
JOIN audio_files af ON af.album_id = rg.id
|
||||
WHERE rg.artist_credit <> ''
|
||||
GROUP BY rg.artist_credit
|
||||
HAVING plays > 0
|
||||
ORDER BY plays DESC
|
||||
LIMIT ?;
|
||||
|
||||
@@ -47,29 +47,18 @@ SELECT
|
||||
pt.playlist_id,
|
||||
pt.audio_file_id,
|
||||
pt.position,
|
||||
COALESCE(af.file_path, '') AS file_path,
|
||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||
COALESCE(tm.file_path, '') AS file_path,
|
||||
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||
FROM playlist_tracks pt
|
||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
|
||||
WHERE pt.playlist_id = ?
|
||||
ORDER BY pt.position;
|
||||
|
||||
@@ -79,29 +68,18 @@ SELECT
|
||||
pt.playlist_id,
|
||||
pt.audio_file_id,
|
||||
pt.position,
|
||||
COALESCE(af.file_path, '') AS file_path,
|
||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||
COALESCE(tm.file_path, '') AS file_path,
|
||||
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||
FROM playlist_tracks pt
|
||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
|
||||
ORDER BY pt.playlist_id, pt.position;
|
||||
|
||||
-- name: DeleteAllPlaylistTracks :exec
|
||||
@@ -132,27 +110,8 @@ WHERE playlist_id = ? AND audio_file_id = (
|
||||
);
|
||||
|
||||
-- name: GetTrackPhantomMetadata :one
|
||||
SELECT
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
af.length_milliseconds AS duration_ms,
|
||||
CAST(COALESCE(
|
||||
(SELECT GROUP_CONCAT(g.name, '||')
|
||||
FROM recording_genres rg_sub
|
||||
JOIN genres g ON rg_sub.genre_id = g.id
|
||||
WHERE rg_sub.recording_id = r.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
WHERE af.id = ?;
|
||||
-- The display fields a playlist row keeps after its file goes away.
|
||||
SELECT title, artist_name AS artist, album,
|
||||
length_milliseconds AS duration_ms, genre, cover_art_path
|
||||
FROM track_metadata
|
||||
WHERE id = ?;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
-- name: GetQueueState :one
|
||||
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
|
||||
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
|
||||
FROM queue WHERE id = 1;
|
||||
|
||||
-- name: UpdateQueueState :exec
|
||||
UPDATE queue
|
||||
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
|
||||
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: UpdateQueuePosition :exec
|
||||
@@ -13,27 +13,12 @@ SET current_position = ?
|
||||
WHERE id = 1;
|
||||
|
||||
-- name: GetQueueTracks :many
|
||||
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist,
|
||||
COALESCE(rg.name, '') AS album,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||
COALESCE(a.mbid, '') AS artist_mbid,
|
||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
||||
COALESCE(r.mbid, '') AS recording_mbid
|
||||
-- The queue's rows, joined to the one track projection.
|
||||
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
|
||||
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
|
||||
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
|
||||
FROM queue_tracks qt
|
||||
JOIN audio_files af ON qt.audio_file_id = af.id
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON r.id = rgr.recording_id
|
||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
JOIN track_metadata tm ON tm.id = qt.audio_file_id
|
||||
ORDER BY qt.position;
|
||||
|
||||
-- name: GetQueueTrackCount :one
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
-- name: CreateRecording :one
|
||||
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateRecordingFull :one
|
||||
INSERT INTO recordings (
|
||||
name, artist_credit_id, track_number, disc_number,
|
||||
year, genre, composer, lyrics, comment
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRecording :one
|
||||
SELECT * FROM recordings
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: UpdateRecording :exec
|
||||
UPDATE recordings
|
||||
SET name = ?, artist_credit_id = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: UpdateRecordingFull :exec
|
||||
UPDATE recordings
|
||||
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
|
||||
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteRecording :exec
|
||||
DELETE FROM recordings
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllRecordings :exec
|
||||
DELETE FROM recordings;
|
||||
|
||||
-- name: GetAllRecordings :many
|
||||
SELECT * FROM recordings
|
||||
ORDER BY name;
|
||||
|
||||
-- name: CountRecordingsByArtistCredit :one
|
||||
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?;
|
||||
|
||||
-- name: GetOrphanedRecordingIDs :many
|
||||
-- Recordings no longer backed by any audio_files row - left behind
|
||||
-- when a scan's orphan cleanup deletes the file that used to own them,
|
||||
-- since deleting audio_files doesn't cascade to recordings.
|
||||
SELECT r.id FROM recordings r
|
||||
LEFT JOIN audio_files af ON af.recording_id = r.id
|
||||
WHERE af.id IS NULL;
|
||||
@@ -1,32 +0,0 @@
|
||||
-- name: CreateReleaseGroupRecording :one
|
||||
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetReleaseGroupRecording :one
|
||||
SELECT * FROM release_group_recordings
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetReleaseGroupRecordings :many
|
||||
SELECT * FROM release_group_recordings
|
||||
WHERE release_group_id = ?
|
||||
ORDER BY disc_number, track_number;
|
||||
|
||||
-- name: GetRecordingReleaseGroups :many
|
||||
SELECT * FROM release_group_recordings
|
||||
WHERE recording_id = ?;
|
||||
|
||||
-- name: DeleteReleaseGroupRecording :exec
|
||||
DELETE FROM release_group_recordings
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteReleaseGroupRecordingByFK :exec
|
||||
DELETE FROM release_group_recordings
|
||||
WHERE release_group_id = ? AND recording_id = ?;
|
||||
|
||||
-- name: DeleteAllReleaseGroupRecordings :exec
|
||||
DELETE FROM release_group_recordings;
|
||||
|
||||
-- name: DeleteReleaseGroupRecordingsByRecording :exec
|
||||
DELETE FROM release_group_recordings
|
||||
WHERE recording_id = ?;
|
||||
@@ -1,216 +0,0 @@
|
||||
-- name: CreateReleaseGroup :one
|
||||
INSERT INTO release_groups (name) VALUES (?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateReleaseGroupFull :one
|
||||
INSERT INTO release_groups (
|
||||
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetReleaseGroup :one
|
||||
SELECT * FROM release_groups
|
||||
WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: GetReleaseGroupByNameAndArtist :one
|
||||
SELECT * FROM release_groups
|
||||
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1;
|
||||
|
||||
-- name: UpsertReleaseGroup :one
|
||||
INSERT INTO release_groups (name, album_artist_credit_id, year)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
|
||||
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
|
||||
year = COALESCE(excluded.year, release_groups.year)
|
||||
RETURNING *;
|
||||
|
||||
-- name: SetReleaseGroupOriginalYear :exec
|
||||
-- Set the release group's original-release-year (release-group's
|
||||
-- first-release-date from MusicBrainz). Called from autotag apply
|
||||
-- when the user confirms a candidate; the file-tag year stays in
|
||||
-- the year column.
|
||||
UPDATE release_groups SET original_year = ? WHERE id = ?;
|
||||
|
||||
-- name: UpdateReleaseGroup :exec
|
||||
UPDATE release_groups
|
||||
SET name = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: UpdateReleaseGroupCoverArt :exec
|
||||
UPDATE release_groups
|
||||
SET cover_art_id = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteReleaseGroup :exec
|
||||
DELETE FROM release_groups
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllReleaseGroups :exec
|
||||
DELETE FROM release_groups;
|
||||
|
||||
-- name: GetAllReleaseGroups :many
|
||||
SELECT * FROM release_groups
|
||||
ORDER BY name;
|
||||
|
||||
-- name: GetAllAlbumsWithDetails :many
|
||||
SELECT
|
||||
rg.id,
|
||||
rg.name,
|
||||
-- year prefers original release year (MB first-release-date)
|
||||
-- over the file-tag year so the UI surfaces the album's
|
||||
-- original year by default. release_year keeps the file-tag
|
||||
-- year accessible.
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
rg.mbid,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN (
|
||||
SELECT rgr.release_group_id, ac2.text
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
||||
GROUP BY rgr.release_group_id
|
||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||
ORDER BY rg.name;
|
||||
|
||||
-- name: GetAllAlbumsWithDetailsByLibrary :many
|
||||
SELECT
|
||||
rg.id,
|
||||
rg.name,
|
||||
-- year prefers original release year (MB first-release-date)
|
||||
-- over the file-tag year so the UI surfaces the album's
|
||||
-- original year by default. release_year keeps the file-tag
|
||||
-- year accessible.
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
rg.mbid,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN (
|
||||
SELECT rgr.release_group_id, ac2.text
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
||||
GROUP BY rgr.release_group_id
|
||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||
WHERE rg.id IN (
|
||||
SELECT DISTINCT rgr2.release_group_id
|
||||
FROM release_group_recordings rgr2
|
||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
||||
WHERE af2.library_id = ?
|
||||
)
|
||||
ORDER BY rg.name;
|
||||
|
||||
-- name: GetAlbumsByArtist :many
|
||||
SELECT
|
||||
rg.id,
|
||||
rg.name,
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN (
|
||||
SELECT rgr.release_group_id, ac2.text
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
||||
GROUP BY rgr.release_group_id
|
||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||
WHERE aca.artist_id = ?
|
||||
ORDER BY rg.name;
|
||||
|
||||
-- name: CountReleaseGroupRecordings :one
|
||||
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
|
||||
|
||||
-- name: GetOrphanedReleaseGroupIDs :many
|
||||
-- Release groups with no recordings left in them - run after orphaned
|
||||
-- recordings (and their release_group_recordings rows) are deleted, so
|
||||
-- a release group whose last owned track was removed is cleaned up too.
|
||||
SELECT rg.id FROM release_groups rg
|
||||
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
WHERE rgr.id IS NULL;
|
||||
|
||||
-- name: GetAlbumsByArtistByLibrary :many
|
||||
SELECT
|
||||
rg.id,
|
||||
rg.name,
|
||||
COALESCE(rg.original_year, rg.year) AS year,
|
||||
COALESCE(rg.year, 0) AS release_year,
|
||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
||||
-- primary (first-credited) album artist's MBID, for linking the
|
||||
-- artist name to its detail page. Empty when the album has no
|
||||
-- MB-tagged album-artist credit.
|
||||
CAST(COALESCE((
|
||||
SELECT a.mbid
|
||||
FROM artist_credit_artist aca_p
|
||||
JOIN artists a ON a.id = aca_p.artist_id
|
||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
||||
ORDER BY aca_p.id
|
||||
LIMIT 1
|
||||
), '') AS TEXT) as artist_mbid,
|
||||
COALESCE(ca.file_path, '') as cover_art_path
|
||||
FROM release_groups rg
|
||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||
LEFT JOIN (
|
||||
SELECT rgr.release_group_id, ac2.text
|
||||
FROM release_group_recordings rgr
|
||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
||||
GROUP BY rgr.release_group_id
|
||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
||||
WHERE aca.artist_id = ?
|
||||
AND rg.id IN (
|
||||
SELECT DISTINCT rgr2.release_group_id
|
||||
FROM release_group_recordings rgr2
|
||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
||||
WHERE af2.library_id = ?
|
||||
)
|
||||
ORDER BY rg.name;
|
||||
@@ -10,7 +10,27 @@ ON CONFLICT(group_key) DO UPDATE SET
|
||||
WHEN tagging_items.album_name = '' THEN excluded.album_name
|
||||
ELSE tagging_items.album_name
|
||||
END,
|
||||
-- Tracks real consensus, not first-write-wins: stays set only
|
||||
-- while every track that has contributed a non-empty value agrees.
|
||||
-- A later track with a *different* non-empty value clears it back
|
||||
-- to '' and latches album_artist_conflict, since a single
|
||||
-- disagreeing tag means the folder no longer has one authoritative
|
||||
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
|
||||
-- value here as trusted, so leaving a stale first-seen value in
|
||||
-- place would let one track's tag silently blind mixed-bag
|
||||
-- detection for the whole folder. The latch (rather than just
|
||||
-- clearing the text column) stops a later track from coincidentally
|
||||
-- repeating an already-disputed value and resurrecting trust in it.
|
||||
album_artist_conflict = CASE
|
||||
WHEN tagging_items.album_artist_conflict = 1 THEN 1
|
||||
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
|
||||
AND tagging_items.album_artist != excluded.album_artist THEN 1
|
||||
ELSE 0
|
||||
END,
|
||||
album_artist = CASE
|
||||
WHEN tagging_items.album_artist_conflict = 1 THEN ''
|
||||
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
|
||||
AND tagging_items.album_artist != excluded.album_artist THEN ''
|
||||
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
|
||||
ELSE tagging_items.album_artist
|
||||
END;
|
||||
@@ -67,10 +87,7 @@ LIMIT 1;
|
||||
SELECT ti.group_key
|
||||
FROM tagging_items ti
|
||||
JOIN audio_files af ON af.group_key = ti.group_key
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
LEFT JOIN albums rg ON rg.id = af.album_id
|
||||
WHERE ti.synthetic = 0
|
||||
AND ti.track_count >= 4
|
||||
AND (
|
||||
@@ -78,13 +95,23 @@ WHERE ti.synthetic = 0
|
||||
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
||||
)
|
||||
GROUP BY ti.group_key
|
||||
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
|
||||
HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1
|
||||
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
|
||||
|
||||
-- name: CountPendingTaggingItems :one
|
||||
SELECT COUNT(*) FROM tagging_items
|
||||
WHERE status = 'pending'
|
||||
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
|
||||
-- "Needs tagging" is a question about the files, not about the row:
|
||||
-- every scanned folder gets a tagging_items row (see
|
||||
-- UpsertTaggingItemOnTrackAdd), including one whose files all arrived
|
||||
-- carrying a recording MBID. Without the EXISTS a fully MB-tagged
|
||||
-- library reports its entire album count as pending work. See the
|
||||
-- same predicate on the three list queries below.
|
||||
SELECT COUNT(*) FROM tagging_items ti
|
||||
WHERE ti.status = 'pending'
|
||||
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||
);
|
||||
|
||||
-- name: ListPendingTaggingItemsAlphabetical :many
|
||||
SELECT
|
||||
@@ -105,6 +132,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
||||
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
||||
AND ti.cleared_at IS NULL
|
||||
-- Actionable rows must have something to act on: see
|
||||
-- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are
|
||||
-- exempt because they are history, not work -- an applied folder is
|
||||
-- fully tagged by definition and would otherwise vanish from the
|
||||
-- sidebar's Completed section the instant it succeeded.
|
||||
AND (
|
||||
ti.status IN ('confirmed', 'skipped')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||
)
|
||||
)
|
||||
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
||||
LIMIT @row_limit OFFSET @row_offset;
|
||||
|
||||
@@ -130,6 +169,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
||||
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
||||
AND ti.cleared_at IS NULL
|
||||
-- See ListPendingTaggingItemsAlphabetical.
|
||||
AND (
|
||||
ti.status IN ('confirmed', 'skipped')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||
)
|
||||
)
|
||||
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
||||
LIMIT @row_limit OFFSET @row_offset;
|
||||
|
||||
@@ -184,61 +231,53 @@ ORDER BY ti.created_at DESC, ti.group_key
|
||||
LIMIT @row_limit OFFSET @row_offset;
|
||||
|
||||
-- name: ListAudioFilesInTaggingGroup :many
|
||||
-- album_name/album_artist are the PER-TRACK tags (via each track's
|
||||
-- own release_group link), not the folder-level tagging_items
|
||||
-- values. SplitMixedFolder clusters on these to find sub-albums
|
||||
-- hiding inside a folder full of unrelated tracks.
|
||||
-- album_name/album_artist are the PER-TRACK tags (each file's own
|
||||
-- album link), not the folder-level tagging_items values.
|
||||
-- SplitMixedFolder clusters on these to find sub-albums hiding inside
|
||||
-- a folder full of unrelated tracks.
|
||||
SELECT
|
||||
af.id,
|
||||
af.file_path,
|
||||
af.basename,
|
||||
af.length_milliseconds,
|
||||
af.tag_status,
|
||||
COALESCE(r.track_number, 0) AS track_number,
|
||||
COALESCE(r.disc_number, 0) AS disc_number,
|
||||
COALESCE(r.name, '') AS title,
|
||||
COALESCE(ac.text, '') AS artist_name,
|
||||
COALESCE(r.mbid, '') AS recording_mbid,
|
||||
COALESCE(rg.name, '') AS album_name,
|
||||
COALESCE(rgac.text, '') AS album_artist
|
||||
COALESCE(af.track_number, 0) AS track_number,
|
||||
COALESCE(af.disc_number, 0) AS disc_number,
|
||||
af.title,
|
||||
af.artist_credit AS artist_name,
|
||||
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||
COALESCE(al.name, '') AS album_name,
|
||||
COALESCE(al.artist_credit, '') AS album_artist
|
||||
FROM audio_files af
|
||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
|
||||
LEFT JOIN albums al ON al.id = af.album_id
|
||||
WHERE af.group_key = ?
|
||||
ORDER BY COALESCE(r.disc_number, 0),
|
||||
COALESCE(r.track_number, 0),
|
||||
ORDER BY COALESCE(af.disc_number, 0),
|
||||
COALESCE(af.track_number, 0),
|
||||
af.file_path;
|
||||
|
||||
-- name: ListLocalReleaseGroupCandidates :many
|
||||
-- Returns one row per (release_group, track) combination for any
|
||||
-- local release_group that has an MBID. Callers group these in Go
|
||||
-- and filter by normalized album-name match. Joined case-insensitive
|
||||
-- on name to pre-filter cheaply; Go does the real normalization.
|
||||
-- name: ListLocalAlbumCandidates :many
|
||||
-- One row per (album, track) for any local album carrying an MBID.
|
||||
-- Callers group these in Go and filter by normalized album-name match;
|
||||
-- the join is case-insensitive on name to pre-filter cheaply.
|
||||
SELECT
|
||||
rg.id AS release_group_id,
|
||||
rg.mbid AS release_group_mbid,
|
||||
rg.name AS album_name,
|
||||
COALESCE(rg.year, 0) AS year,
|
||||
COALESCE(ac.text, '') AS artist_credit,
|
||||
COALESCE(rgr.track_number, 0) AS track_number,
|
||||
COALESCE(rgr.disc_number, 0) AS disc_number,
|
||||
COALESCE(r.name, '') AS track_title,
|
||||
COALESCE(r.mbid, '') AS recording_mbid,
|
||||
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
|
||||
FROM release_groups rg
|
||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
||||
JOIN recordings r ON r.id = rgr.recording_id
|
||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
||||
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
|
||||
WHERE rg.mbid IS NOT NULL
|
||||
AND rg.mbid != ''
|
||||
AND r.mbid IS NOT NULL
|
||||
AND r.mbid != ''
|
||||
AND rg.name = ? COLLATE NOCASE
|
||||
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
|
||||
al.id AS album_id,
|
||||
al.mbid AS album_mbid,
|
||||
al.name AS album_name,
|
||||
COALESCE(al.year, 0) AS year,
|
||||
al.artist_credit,
|
||||
COALESCE(af.track_number, 0) AS track_number,
|
||||
COALESCE(af.disc_number, 0) AS disc_number,
|
||||
af.title AS track_title,
|
||||
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||
af.length_milliseconds
|
||||
FROM albums al
|
||||
JOIN audio_files af ON af.album_id = al.id
|
||||
WHERE al.mbid IS NOT NULL
|
||||
AND al.mbid != ''
|
||||
AND af.recording_mbid IS NOT NULL
|
||||
AND af.recording_mbid != ''
|
||||
AND al.name = ? COLLATE NOCASE
|
||||
ORDER BY al.id, af.disc_number, af.track_number;
|
||||
|
||||
-- name: SetTaggingItemBestMatch :exec
|
||||
UPDATE tagging_items
|
||||
@@ -264,17 +303,16 @@ WHERE group_key = ?;
|
||||
-- name: SetAudioFileTagStatus :exec
|
||||
UPDATE audio_files SET tag_status = ? WHERE id = ?;
|
||||
|
||||
-- name: SetRecordingMBID :exec
|
||||
UPDATE recordings SET mbid = ? WHERE id = ?;
|
||||
-- name: SetFileRecordingMBID :exec
|
||||
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: SetReleaseGroupMBID :exec
|
||||
UPDATE release_groups SET mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: GetRecordingReleaseGroupID :one
|
||||
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
|
||||
FROM release_group_recordings rgr
|
||||
WHERE rgr.recording_id = ?
|
||||
LIMIT 1;
|
||||
-- name: SetFileAlbumMBID :exec
|
||||
-- The album MBID for the album a file belongs to. Keyed by file
|
||||
-- because that is what the autotag apply path holds; under the old
|
||||
-- schema it had to look the release group up through two join tables
|
||||
-- first (GetRecordingReleaseGroupID), which is gone.
|
||||
UPDATE albums SET mbid = ?
|
||||
WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?);
|
||||
|
||||
-- name: GetNextPendingTaggingItem :one
|
||||
SELECT
|
||||
@@ -295,5 +333,44 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
||||
WHERE ti.status = 'pending'
|
||||
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||
AND ti.group_key > @after_group_key
|
||||
-- See CountPendingTaggingItems: the cursor must not stop on a
|
||||
-- folder the list query no longer shows, or "next" walks folders
|
||||
-- that are not in the sidebar.
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||
)
|
||||
ORDER BY ti.group_key
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetTaggingItemsForAlbum :many
|
||||
-- Every tagging group holding a file of this album.
|
||||
--
|
||||
-- The join is `audio_files.group_key`, not a key derived from the
|
||||
-- album's folder path: a group carved out of a mixed-bag folder by
|
||||
-- SplitMixedFolder is keyed on its tags rather than on a directory,
|
||||
-- so a path-derived key finds nothing for exactly the messiest
|
||||
-- libraries this is meant to help.
|
||||
--
|
||||
-- Usually one row. A multi-disc album is one group per disc, which
|
||||
-- the caller has to know about rather than average over -- applying
|
||||
-- to "the album" would silently retag one disc of three.
|
||||
SELECT
|
||||
ti.group_key,
|
||||
ti.status,
|
||||
ti.score,
|
||||
ti.best_match_release_mbid,
|
||||
ti.track_count,
|
||||
ti.album_name,
|
||||
ti.album_artist,
|
||||
ti.synthetic
|
||||
FROM tagging_items ti
|
||||
WHERE ti.group_key IN (
|
||||
SELECT DISTINCT af.group_key
|
||||
FROM audio_files af
|
||||
WHERE af.album_id = sqlc.arg(album_id) AND af.group_key != ''
|
||||
)
|
||||
AND ti.cleared_at IS NULL
|
||||
-- Best first, with an unscored group last rather than first: NULL
|
||||
-- sorts low in SQLite and DESC would put it at the top.
|
||||
ORDER BY ti.score IS NULL, ti.score DESC, ti.group_key;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- One row per album in the library.
|
||||
--
|
||||
-- This is `release_groups` renamed, and the rename is the point: a
|
||||
-- release group is a *MusicBrainz* concept and the catalog still has
|
||||
-- them (`explore_index.entity_type = 'release_group'`). What this
|
||||
-- table holds is the local thing — the album some files on disk belong
|
||||
-- to — which may or may not have a catalog counterpart. Calling both
|
||||
-- of them "release group" is most of why "is this album mine" was a
|
||||
-- question three different subsystems answered three different ways.
|
||||
--
|
||||
-- `artist_credit` is the album artist as tagged ("Various Artists",
|
||||
-- "A & B"); `artist_id` is the primary artist it resolves to. Album
|
||||
-- identity is (name, artist_credit), which is what the old
|
||||
-- UNIQUE(name, album_artist_credit_id) meant with a join in the way.
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
artist_credit TEXT NOT NULL DEFAULT '',
|
||||
artist_id INTEGER,
|
||||
mbid TEXT,
|
||||
-- year is the tagged year of the copy on disk; original_year is
|
||||
-- MusicBrainz's first-release date when known. For a 2010 remaster
|
||||
-- of a 1973 album: original_year 1973, year 2010.
|
||||
year INTEGER,
|
||||
original_year INTEGER,
|
||||
cover_art_id INTEGER,
|
||||
-- Set when the files carried a release MBID but no release-group
|
||||
-- MBID; a background pass resolves it and clears this.
|
||||
pending_release_mbid TEXT,
|
||||
|
||||
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
|
||||
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
||||
UNIQUE(name, artist_credit)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_id
|
||||
ON albums(artist_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_cover_art_id
|
||||
ON albums(cover_art_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_mbid
|
||||
ON albums(mbid) WHERE mbid IS NOT NULL;
|
||||
@@ -1,4 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS artist_credit (
|
||||
id INTEGER PRIMARY KEY,
|
||||
text TEXT NOT NULL UNIQUE
|
||||
);
|
||||
@@ -1,16 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS artist_credit_artist (
|
||||
id integer PRIMARY KEY,
|
||||
artist_id int NOT NULL,
|
||||
credit_id int NOT NULL,
|
||||
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
||||
FOREIGN KEY(credit_id) REFERENCES artist_credit(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id
|
||||
ON artist_credit_artist(artist_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id
|
||||
ON artist_credit_artist(credit_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_credit_artist_unique
|
||||
ON artist_credit_artist(artist_id, credit_id);
|
||||
@@ -0,0 +1,56 @@
|
||||
-- The decomposition of a multi-artist credit, from the MusicBrainz
|
||||
-- dump. One row per credited artist, in credit order.
|
||||
--
|
||||
-- A credit is ordered parts, and the credit *string* is derived from
|
||||
-- them -- MusicBrainz's own `artist_credit.name` is a cached render and
|
||||
-- nothing more. Rendering is a concatenation:
|
||||
--
|
||||
-- for each part in position order:
|
||||
-- emit link(credited_name -> artist_mbid)
|
||||
-- emit text(join_phrase)
|
||||
--
|
||||
-- so the link boundaries are known by construction. That is the whole
|
||||
-- reason this table exists, and it is why nothing may reconstruct a
|
||||
-- credit by *searching* for a name inside a credit string: the stored
|
||||
-- string may have come from a file's tags while the parts come from the
|
||||
-- catalog, and measured on a real library those disagree for about one
|
||||
-- in three multi-artist credits ("Skrillex feat. Swae Lee" tagged
|
||||
-- against "Skrillex & Swae Lee" upstream). A search would miss, or
|
||||
-- match the wrong span.
|
||||
--
|
||||
-- `credited_name` is the name *as credited*, which is not the artist's
|
||||
-- canonical name: MusicBrainz credits "Snoop Dogg" on a track by the
|
||||
-- artist whose name is "Snoop Doggy Dogg". It is stored per row rather
|
||||
-- than joined from an artist table for exactly that reason.
|
||||
--
|
||||
-- Only *multi-artist* credits are stored. A single-artist credit is
|
||||
-- (name, "") and is already fully described by explore_index's
|
||||
-- artist_name and artist_mbid; storing those would roughly triple the
|
||||
-- table to say nothing new.
|
||||
--
|
||||
-- Credits are shared: an album's twelve tracks by one artist reference
|
||||
-- one credit_id. That is the opposite of the local library's verdict
|
||||
-- in plan 013, and correctly so -- credit sharing is 1:1 in one
|
||||
-- person's files and genuinely many-to-one across a 2M-row catalog.
|
||||
--
|
||||
-- MBIDs are the same 16 raw bytes explore_index stores, for the same
|
||||
-- size reason and with the same CHECK, so a stringly write fails at the
|
||||
-- insert that made it rather than reading back as no rows at all. See
|
||||
-- backend/explore/mbid.go.
|
||||
CREATE TABLE IF NOT EXISTS artist_credit_part (
|
||||
credit_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
artist_mbid BLOB NOT NULL CHECK(length(artist_mbid) = 16),
|
||||
|
||||
-- The name as credited on this release, which may differ from the
|
||||
-- artist's canonical name. Display uses this; navigation uses the
|
||||
-- MBID above.
|
||||
credited_name TEXT NOT NULL,
|
||||
|
||||
-- The literal connector that follows this part -- " feat. ", " & ",
|
||||
-- ", ", or "" on the last part. Rendered as plain text between two
|
||||
-- links.
|
||||
join_phrase TEXT NOT NULL DEFAULT '',
|
||||
|
||||
PRIMARY KEY (credit_id, position)
|
||||
) WITHOUT ROWID;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user