Compare commits
120
Commits
4471db3aef
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c94c97f604 | ||
|
|
4801ba4480 | ||
|
|
40bc968cf8 | ||
|
|
e61b7456df | ||
|
|
979c6e83ed | ||
|
|
48f7795687 | ||
|
|
c400f681c2 | ||
|
|
451b46e63c | ||
|
|
d33dfb2264 | ||
|
|
41a4dd7148 | ||
|
|
6d97e3c872 | ||
|
|
acbe7c4676 | ||
|
|
91bab4e73e | ||
|
|
f1c46b6a8e |
@@ -0,0 +1,474 @@
|
||||
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
|
||||
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,43 @@ 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
|
||||
|
||||
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 +99,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 +113,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
|
||||
|
||||
+31
-22
@@ -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:
|
||||
|
||||
@@ -78,7 +92,7 @@ jobs:
|
||||
# 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
|
||||
@@ -171,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
|
||||
@@ -184,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
|
||||
@@ -220,7 +236,7 @@ jobs:
|
||||
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 \
|
||||
xvfb dbus dbus-x11 ffmpeg libasound2t64 \
|
||||
dbus dbus-x11 ffmpeg libasound2t64 \
|
||||
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
||||
|
||||
- name: Clone repo at this commit
|
||||
@@ -247,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
|
||||
@@ -287,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,176 @@
|
||||
name: Attach the desktop build to the release
|
||||
|
||||
# The Arch package goes to the pacman registry and the APK to the generic
|
||||
# one, but a release page with nothing on it to download is a release page
|
||||
# nobody can use. This builds the plain Linux x86_64 binary and attaches
|
||||
# it, so "get the latest version" has an answer that needs no package
|
||||
# manager at all.
|
||||
#
|
||||
# **Linux only, and macOS is not an oversight.** `GOOS=darwin
|
||||
# CGO_ENABLED=0` fails at `wails/v3/pkg/mac: build constraints exclude all
|
||||
# Go files` — the darwin backend is Objective-C behind cgo, so a .app
|
||||
# needs a macOS host, and the runner is a Linux container. That is
|
||||
# exactly why the Homebrew formula builds from source on the user's own
|
||||
# Mac, and it stays the macOS channel.
|
||||
#
|
||||
# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a
|
||||
# couple of seconds — nothing in the audio, database or webview path needs
|
||||
# cgo there), and is deliberately not published: no Windows build of this
|
||||
# app has ever been run, and no tier here can exercise one. Shipping it
|
||||
# would be a promise nothing in this repo can keep. Revisit when someone
|
||||
# has actually booted it.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build and attach (default: the latest v* tag)"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: desktop-assets-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GO_VERSION: '1.25.0'
|
||||
npm_config_store_dir: /cache/pnpm-store
|
||||
steps:
|
||||
# The same set ci.yml's check job installs: the app is cgo, and
|
||||
# without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa`
|
||||
# before anything is compiled.
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl git jq build-essential pkg-config \
|
||||
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev
|
||||
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
git -C /src checkout --quiet --detach "$SHA"
|
||||
git config --global --add safe.directory /src
|
||||
git -C /src log --oneline -1
|
||||
|
||||
- name: Resolve the version
|
||||
id: version
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
v="${{ inputs.version }}"
|
||||
if [ -z "$v" ]; then
|
||||
case "$REF_NAME" in
|
||||
v*) v="$REF_NAME" ;;
|
||||
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;;
|
||||
esac
|
||||
fi
|
||||
case "$v" in v*) ;; *) v="v$v" ;; esac
|
||||
|
||||
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||
# see the bootstrap step in release.yml. Nothing is built for
|
||||
# it, and this is a clean skip rather than a failure because a
|
||||
# red run against a tag that was never meant to ship is noise.
|
||||
if [ "$v" = "v0.0.0" ]; then
|
||||
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "building $v"
|
||||
|
||||
- name: Go toolchain
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||
fi
|
||||
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||
/cache/tool/go/bin/go version
|
||||
|
||||
- name: Node toolchain
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
corepack enable
|
||||
node --version
|
||||
|
||||
# `make build-prod` is the production task: -trimpath and -w -s are
|
||||
# already in it, so only the version stamp is passed, through the
|
||||
# LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml.
|
||||
# (`wails3 build` has no -ldflags of its own; that was v2.)
|
||||
- name: Build
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -eu
|
||||
export PATH="/src/scripts/toolbin:$PATH"
|
||||
commit=$(git rev-parse --short HEAD)
|
||||
|
||||
go generate ./...
|
||||
go tool wails3 task build \
|
||||
LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'"
|
||||
|
||||
# Described, never run: main.go has no flag parsing, so any
|
||||
# invocation here would try to open a window in a container with
|
||||
# no display and hang the job rather than printing a version.
|
||||
test -x bin/yellowjacket
|
||||
ls -la bin/yellowjacket
|
||||
file bin/yellowjacket || true
|
||||
|
||||
# The .desktop file and the icon go in the tarball because without
|
||||
# them the binary is a window with no menu entry — the Arch package
|
||||
# installs both, and this is the same app for people not using it.
|
||||
- name: Package the tarball
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
dir="yellowjacket-${VERSION}-linux-amd64"
|
||||
mkdir -p "/tmp/$dir"
|
||||
cp bin/yellowjacket "/tmp/$dir/"
|
||||
cp packaging/arch/yellowjacket.desktop "/tmp/$dir/"
|
||||
cp frontend/src/assets/images/icons/music/compact-disc.svg \
|
||||
"/tmp/$dir/yellowjacket.svg"
|
||||
tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir"
|
||||
ls -la "/tmp/${dir}.tar.gz"
|
||||
|
||||
- name: Attach it to the release
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
./scripts/release-asset.sh "$TAG" \
|
||||
"/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz"
|
||||
@@ -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,25 @@ 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
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
||||
|
||||
echo "Fetching ${TARBALL}"
|
||||
@@ -53,6 +77,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 +91,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,182 @@
|
||||
name: Release
|
||||
|
||||
# The sixth workflow, and the one that decides whether the other three
|
||||
# run at all. On every push to main it reads the Conventional Commits
|
||||
# since the last tag, and if any of them is releasable it writes the
|
||||
# changelog, pushes the tag, and creates the Gitea release whose body is
|
||||
# that changelog section. The publishing workflows are keyed on `v*`, so
|
||||
# the tag push is what starts them.
|
||||
#
|
||||
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
|
||||
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
|
||||
# workflow's own token (go-gitea#33123). The token is what decides this,
|
||||
# not the workflow — so semantic-release is handed a repositoryUrl
|
||||
# carrying a *user* PAT, and the resulting push is attributed to a person
|
||||
# and triggers the `v*` workflows normally.
|
||||
#
|
||||
# That limitation is used deliberately in the bootstrap step below, where
|
||||
# a tag that must *not* trigger anything is pushed with the Actions token
|
||||
# instead.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Cutting a tag is not a thing to cancel halfway: a superseded run must
|
||||
# finish, not be killed between `git push --tags` and the release POST.
|
||||
concurrency:
|
||||
group: release-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO: ${{ github.repository }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
steps:
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends ca-certificates curl git jq
|
||||
|
||||
- name: Node toolchain
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
node --version
|
||||
|
||||
# By hand rather than actions/checkout, like the other five: that is
|
||||
# a JS action and needs node inside the container before any step has
|
||||
# installed it. The full history is required — semantic-release
|
||||
# reads tags and walks commits, and a shallow clone silently makes
|
||||
# every release look like the first one.
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
# -B main rather than --detach, which the other five workflows
|
||||
# use: semantic-release resolves the release branch and then
|
||||
# pushes a commit and a tag to it, and a detached HEAD is a
|
||||
# worse starting point for both than a local branch named after
|
||||
# the one being released. Pinned to this commit, not to
|
||||
# whatever main points at by the time the container started.
|
||||
git -C /src checkout --quiet -B main "${{ github.sha }}"
|
||||
git config --global --add safe.directory /src
|
||||
git -C /src log --oneline -1
|
||||
|
||||
# Nothing currently pushes a `chore(release):` commit — main is a
|
||||
# protected branch, so .releaserc.yml carries no @semantic-release/git
|
||||
# and the release page is the changelog. This guard is kept for the
|
||||
# day someone adds that plugin back: without it the commit-back is a
|
||||
# push to the branch this workflow runs on, and the loop is a release
|
||||
# per release. Six lines against that is cheap.
|
||||
- name: Skip a changelog commit, if one ever exists
|
||||
id: guard
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
subject=$(git log -1 --format='%s')
|
||||
case "$subject" in
|
||||
"chore(release):"*)
|
||||
echo "this is the release commit itself; nothing to do"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
# semantic-release calls the first release of a repo with no tags
|
||||
# 1.0.0, and offers no option to say otherwise. A floor tag is the
|
||||
# only way to start at 0.0.1, so this creates one — once, ever.
|
||||
#
|
||||
# **It is pushed with the Actions token on purpose.** v0.0.0 is a
|
||||
# floor, not a shipment: pushing it with a user PAT would start the
|
||||
# Arch, Homebrew and Android workflows for a version that does not
|
||||
# exist. The very limitation the header describes is what makes
|
||||
# this inert.
|
||||
- name: Seed the version floor
|
||||
if: steps.guard.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --quiet --tags origin
|
||||
|
||||
if [ -n "$(git tag --list 'v[0-9]*')" ]; then
|
||||
echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prefer the Actions token because a ref it pushes starts no
|
||||
# workflow, which is the whole point for a tag that is a floor
|
||||
# rather than a shipment. Falling back to the PAT is safe
|
||||
# rather than merely convenient: all four publishing workflows
|
||||
# skip v0.0.0 explicitly, so the worst case is four jobs that
|
||||
# start and immediately say there is nothing to build.
|
||||
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
|
||||
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
|
||||
|
||||
# **On the parent, not on HEAD.** The floor marks what has
|
||||
# already been released, so tagging the commit being pushed
|
||||
# leaves nothing between the floor and HEAD — semantic-release
|
||||
# then correctly reports there is nothing to release, which is
|
||||
# exactly what the first run of this workflow did. HEAD^ is the
|
||||
# first parent, so on the merge commit this fires for it is main
|
||||
# as it was before the merge, and everything the merge brought
|
||||
# in is releasable.
|
||||
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
|
||||
if [ -z "$floor" ]; then
|
||||
echo "HEAD has no parent, so no commit can precede the floor" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
|
||||
git tag v0.0.0 "$floor"
|
||||
git push --quiet \
|
||||
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
|
||||
refs/tags/v0.0.0
|
||||
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
|
||||
|
||||
# Pinned rather than installed into the repo: this is a Go project
|
||||
# and a package.json at its root invites the npm plugin and every
|
||||
# tool that looks for one. conventional-changelog-conventionalcommits
|
||||
# is in the list because both the analyzer and the notes generator
|
||||
# name that preset and neither depends on it.
|
||||
#
|
||||
# **That preset is held at 9 and the reason is worth keeping.** At
|
||||
# 10 it is silently incompatible with the writer that
|
||||
# release-notes-generator@14 pulls in (^8): every release note comes
|
||||
# out as a bare `## 0.0.1 (date)` heading with **no sections and no
|
||||
# commits under it**, and nothing errors. The version would have
|
||||
# been right, the tag would have been right, every job would have
|
||||
# been green, and the release body would have been empty. Check the
|
||||
# notes, not the exit code, before moving any of these.
|
||||
- name: Run semantic-release
|
||||
if: steps.guard.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
git config user.name "yellowjacket-ci"
|
||||
git config user.email "yj@yellowjacket.app"
|
||||
|
||||
npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@13 \
|
||||
-p @semantic-release/release-notes-generator@14 \
|
||||
-p @semantic-release/changelog@7 \
|
||||
-p @semantic-release/exec@7 \
|
||||
-p conventional-changelog-conventionalcommits@9 \
|
||||
semantic-release \
|
||||
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
|
||||
+34
@@ -54,3 +54,37 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -142,6 +160,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
|
||||
@@ -387,10 +406,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 +436,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,350 @@
|
||||
# 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).
|
||||
|
||||
## Three facts that make failure invisible
|
||||
|
||||
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
|
||||
`/dev/null`. Every `slog` line the app writes is discarded — including
|
||||
the one naming the error it is about to exit on. `setprop
|
||||
log.redirect-stdio true` does not help: it redirects the *Java*
|
||||
runtime's `System.out`, and the Go code is a c-shared native library.
|
||||
|
||||
**`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.
|
||||
|
||||
## 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 | — | unverified, still |
|
||||
|
||||
**A physical arm64 device remains the only verification path.**
|
||||
|
||||
### 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.
|
||||
|
||||
### 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:
|
||||
|
||||
```
|
||||
wails3 task android:run # debug build + emulator install + launch
|
||||
wails3 task android:run:device # same, first connected physical device
|
||||
wails3 task android:deploy-device # production APK to a device
|
||||
wails3 task android: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
|
||||
```
|
||||
|
||||
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 declared twice
|
||||
|
||||
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
||||
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
|
||||
adb-driven task uninstalls, launches and filters. **Nothing enforces
|
||||
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
|
||||
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
|
||||
that file (verified with `--dry`), and even when set it feeds only the
|
||||
adb commands, never Gradle. Change both or the official `run`/`deploy`
|
||||
tasks address a package that is not installed.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
**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.
|
||||
|
||||
+1129
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
# 015 — Android release pipeline
|
||||
|
||||
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,337 @@
|
||||
# 015 — Multi-artist credits, navigable
|
||||
|
||||
## 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.
|
||||
+395
-12
@@ -1,11 +1,15 @@
|
||||
# 009 — Wails v3 migration
|
||||
|
||||
**Status:** Phases 0–3 complete. The Go side is entirely on v3: nothing
|
||||
in the tree imports `wails/v2`, all three lint and test configurations
|
||||
are green, and `go build .` produces a running binary. **Phase 4
|
||||
(bindings) is next, and until it lands the app builds but the frontend
|
||||
cannot talk to it** — `frontend/wailsjs/` is v2's tree and nothing
|
||||
regenerates it.
|
||||
**Status:** Phases 0–7 complete. CI needed nothing (it had ridden along
|
||||
with Phases 1–6); **packaging needed everything** — both recipes still
|
||||
called v2's CLI, installed from v2's output path, and built from a
|
||||
scaffold's `yjref` metadata. Locally green: `make lint` and `make test`
|
||||
(all three build configurations), `tsc --noEmit`, `make build-prod`,
|
||||
757 Vitest tests across all 58 files, `make e2e` 92/92 on chromium,
|
||||
`make bindings-check`, `make skill-check`, `make commit-check`,
|
||||
`make css-check`, and `make perf` runs and reports real binding counts
|
||||
again. **WebKit is unverified** — it is CI-only by design — so the
|
||||
merge waits on a green CI run.
|
||||
**Branch:** `wails-v3`, off `main` at `edb13a6`.
|
||||
**Created:** 2026-08-13
|
||||
**Phase 0 run:** 2026-08-13 against **v3.0.0-beta.8**
|
||||
@@ -293,6 +297,14 @@ Taskfile tree — a genuinely larger and more visible build surface.
|
||||
`make build-prod` still strips and UPX-compresses; `make skill-check`
|
||||
passes; `grep -r webkit2_41` returns nothing.
|
||||
|
||||
> **Corrected after the fact.** `make build-prod` strips and trims
|
||||
> (`-trimpath -ldflags="-w -s"`, 28.8 MB against the dev build's
|
||||
> 38 MB) but **does not UPX-compress**: that was v2's `wails build
|
||||
> -upx` flag, and v3's Taskfile has no equivalent. Neither `make
|
||||
> build-dev` nor `make build-prod` was actually run when Phase 1 was
|
||||
> recorded — both were broken until the shim below. Whether to
|
||||
> reintroduce UPX is a packaging decision for Phase 7, not a port.
|
||||
|
||||
**Est.** Half a session. Low risk, high churn.
|
||||
|
||||
### Phase 1 — what actually landed
|
||||
@@ -568,6 +580,99 @@ is still a pre-commit hook and a CI step (`ci.yml:176`); the 12
|
||||
|
||||
**Est.** One session, mostly codemod-and-verify.
|
||||
|
||||
### Phase 4 — what actually landed
|
||||
|
||||
`frontend/wailsjs/` is deleted; `frontend/bindings/` is committed in
|
||||
its place. **272 methods across 12 services**, and the acceptance
|
||||
criteria hold: no `SetContext` binding survives and there is no
|
||||
`context` model, which is Phase 2c's `ServiceStartup` port showing up
|
||||
where it was predicted to.
|
||||
|
||||
**The alias absorbs the prefix, so the codemod was one line per site.**
|
||||
`@go/*` points at `bindings/yellowjacket/backend/*` rather than at
|
||||
`bindings/`, so `@go/library/Library` became `@go/library/library.js`
|
||||
— the same shape, lowercased, plus the extension the generated tree
|
||||
uses internally. The models cluster was the only structural change:
|
||||
v2's single `models.ts` of namespaces became one module per package, so
|
||||
`import type { library } from '@go/models'` is
|
||||
`import type * as library from '@go/library/models.js'` and every
|
||||
`library.Track` usage is untouched. **Two sites the plan's inventory
|
||||
missed**, both because they are outside `src/`: `frontend/index.ts`'s
|
||||
three imports, found by the vite build rather than by `tsc` (the alias
|
||||
resolves for the compiler and not for the bundler when the path's case
|
||||
is wrong on a case-sensitive filesystem).
|
||||
|
||||
**The 102 type errors were not migration damage. They were v2's
|
||||
generator being caught lying**, and that is worth stating because the
|
||||
temptation is to suppress them. A Go `nil` slice marshals to JSON
|
||||
`null` and always has; v2 typed it `T[]`. A Go named string type is a
|
||||
closed set; v2 typed it `string`. v3 types them `T[] | null` and as a
|
||||
real TS `enum`, and there is no generator flag to turn either off —
|
||||
correctly, since both are true.
|
||||
|
||||
So the fix is one seam rather than 78 patches: **`utils/binding.ts`
|
||||
states the app's actual contract at the only place it is true.** `list`
|
||||
yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil
|
||||
map and drop null-valued keys (which loses nothing —
|
||||
`noUncheckedIndexedAccess` already makes every read `V | undefined`, so
|
||||
an absent key and a nil value are indistinguishable downstream), and
|
||||
`compact` is the same thing for a map arriving as a *field*. All three
|
||||
also return a plain `Promise`: v3 bindings return a
|
||||
`CancellablePromise` and nothing in this app cancels one, so letting it
|
||||
inward would put a Wails type in every store signature for a capability
|
||||
none of them use.
|
||||
|
||||
Where a nullable slice is a model *field* rather than a return value
|
||||
there is no boundary to put it at, and those are `?? []` at the point
|
||||
of use — `score.candidates`, `shelf.albums`, `page.shelves`,
|
||||
`view.items`.
|
||||
|
||||
Four smaller consequences, all of them v3 being stricter:
|
||||
|
||||
- **`createFrom` is gone.** v3 emits interfaces (`-i`), so the three
|
||||
`explore.ShelfPage.createFrom({…})` sites are object literals and
|
||||
`new tracklist.Column()` is one too.
|
||||
- **An enum needs a value import.** `import type * as download` cannot
|
||||
reach `Format`, so `download-clients.ts` imports it separately — and
|
||||
its local format list is now `Format[]` rather than `string[]`, which
|
||||
is a small win v2 could not have given.
|
||||
- **Four test fixtures widen an enum field back to its value union**
|
||||
(`` state?: `${Job['state']}` ``) rather than casting, so the
|
||||
fixtures stay literal-checked.
|
||||
- `job-store.ts` still hand-mirrors `JobState`/`JobKind` as string
|
||||
unions beside the generated enums. Left alone deliberately; folding
|
||||
them together is a redesign, not a port.
|
||||
|
||||
**`bindings-check` got simpler and slower.** The `chmod` dance and
|
||||
`core.fileMode=false` diff are gone with v2's generator (which wrote
|
||||
three runtime files 755); a check for added or removed *files* is new,
|
||||
because a renamed service is now a renamed module rather than a changed
|
||||
line. It runs in ~3.5 s warm against v2's ~1.5 s, ~20 s on a cold build
|
||||
cache — v3's generator is a static analyser over the whole package
|
||||
graph.
|
||||
|
||||
**On step 6 (the tag set): the answer is "no flag", and that is the
|
||||
deliberate answer.** The generator sees only the configuration it is
|
||||
told about, and the one that matters is the one users run — which,
|
||||
since Phase 1, is the *default* tag set. Neither of this repo's other
|
||||
two configurations (`indexbuild`, `dev`) adds a bound service, so
|
||||
generating under them would only widen the API past what ships. Written
|
||||
into `scripts/bindings-check.sh` so it is not re-derived. Note this is
|
||||
*not* what Wails' own Taskfile does (`-tags server,production`), for
|
||||
the good reason that its shipped artifact is the Docker image.
|
||||
|
||||
**One error is left, and it is Phase 5's.**
|
||||
`test/harness.test.ts:69` imports `EventsEmit` from the shim, which
|
||||
does not export one — because nothing in `src/` emits from the
|
||||
frontend, and adding an export to production code to satisfy a test
|
||||
would be the wrong way round. The test underneath it is a bigger
|
||||
problem than its import: it asserts that a frontend emit notifies
|
||||
in-page listeners before reaching Go, which v2 did and **v3 does
|
||||
not** — `Events.Emit` in `@wailsio/runtime` calls the backend and
|
||||
touches no local listener. That is exactly the "re-derive the fake,
|
||||
don't port it" risk the plan flagged, arriving early. `make ui-test`
|
||||
is broken regardless: the fake still fakes `window.go`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — The Vitest fake (`make ui-test`, 480 tests)
|
||||
@@ -599,6 +704,93 @@ that needs changing is evidence the fake is wrong, not the test.
|
||||
**Est.** One session. This is where the official estimate stops
|
||||
applying.
|
||||
|
||||
### Phase 5 — what actually landed
|
||||
|
||||
**757 tests pass across all 63 files, and one test file changed.** The
|
||||
plan's design survived and got smaller, because v3 has a seam v2 did
|
||||
not.
|
||||
|
||||
**`setTransport()` is the whole fake.** v3 routes *every* runtime call
|
||||
— bindings, event emits, window, dialogs, clipboard, screens — through
|
||||
one IPC transport, and replacing it is public, documented API. So the
|
||||
fake covers strictly more than v2's two globals did while being
|
||||
shorter, and the tests still exercise the real generated bindings, the
|
||||
real runtime and the real store code.
|
||||
|
||||
**The dispatcher is deleted rather than re-derived.** The plan said to
|
||||
re-derive `Listener`/`notify()` against v3 instead of porting them; the
|
||||
better answer is that neither is needed. `emit()` goes through
|
||||
`window._wails.dispatchWailsEvent`, the exact entry point the backend's
|
||||
push uses, so delivery, `maxCallbacks` expiry and the post-dispatch
|
||||
filter are the runtime's own code; registration and unregistration are
|
||||
`Events.OnMultiple` / `Off` / `OffAll`. 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), which is invisible when wrong and shows up as a store reading
|
||||
`undefined` off its payload.
|
||||
|
||||
One thing stayed non-public: the listener registry, for
|
||||
`listenerNames()`. `listener.js` has no entry in the package's exports
|
||||
map, so `vitest.config.mts` aliases it. It buys the one question the
|
||||
public surface cannot answer — did importing a store subscribe it —
|
||||
and if Wails moves the file the import throws at setup, which is loud.
|
||||
|
||||
**A binding carries an ID, not a name, and the map has to be
|
||||
complete.** `$Call.ByID(2822423495)` is FNV-1a over
|
||||
`yellowjacket/backend/home.Service.GetShelves`, so the fake computes the
|
||||
same hash — deriving the FQN from the generated tree rather than
|
||||
writing it down. The Go type's casing survives in exactly one place,
|
||||
each package's `index.ts` (`export { Library }`); the filename cannot
|
||||
tell you `frontendutil.ts` is `FrontendUtil`. Building the map lazily
|
||||
as paths are mentioned does not work: 21 assertions read `calls()` with
|
||||
no argument and compare the whole list of paths, including methods no
|
||||
test stubs. An unmapped ID records as `#<id>`, which fails the
|
||||
assertion naming it.
|
||||
|
||||
**Two things had to change that are not the fake**, and both are
|
||||
findings rather than accommodations:
|
||||
|
||||
- **`fixture()` drains microtasks between two renders.** A v3 binding
|
||||
settles several hops later than v2's — `Call()`, an async
|
||||
`runtimeCallWithID`, the transport, a `CancellablePromise`, against
|
||||
v2's one resolved promise — and the tests were already written as
|
||||
though `fixture()` meant "mounted *and loaded*". Fixing it there
|
||||
rather than in each test is what kept this to one test-file edit.
|
||||
Microtasks and **not** `setTimeout`: the first attempt used a timer,
|
||||
which hung `transport.test.ts` for 45 s because it installs fake ones.
|
||||
- **`tracklist-store` keeps its defaults on an empty answer.**
|
||||
`GetTrackListColumns` substitutes `tracklist.DefaultColumns` only when
|
||||
the whole config section is missing — a section that exists with no
|
||||
columns returns nothing, and a track list with no columns is not what
|
||||
that means. Until v3 this was accidental: the binding was typed
|
||||
`Column[]`, an absent answer arrived as `undefined`, and `.map` threw
|
||||
into the `catch` that restores the defaults. Phase 4's `list()` turned
|
||||
that into an honest empty list and the accident stopped working.
|
||||
|
||||
**The one test file edited was `harness.test.ts`, and it was asserting
|
||||
something no longer true.** v2's `EventsEmit` notified in-page listeners
|
||||
*before* Go, so a frontend emit was observable synchronously. v3's
|
||||
`Events.Emit` does not touch the local registry at all: it calls the
|
||||
backend, and `EventProcessor.Emit` sends the event back out to every
|
||||
window, including the emitting one. The page still sees its own emit,
|
||||
one round trip later. The test says that now, and the fake reproduces
|
||||
it with a microtask. That is the "re-derive, don't port" risk paying
|
||||
off — ported blindly, this would have looked like a store bug.
|
||||
|
||||
**`make ui-test` still cannot complete in one run on this machine, and
|
||||
that is not this migration.** A single browser session dies partway
|
||||
through the 58 files it queues, with "Cannot connect to the iframe"
|
||||
after ~5 s. It reproduces **unchanged at `c9905fb`**, the commit before
|
||||
Phase 4 — checked in a worktree, not assumed — so it is a resource
|
||||
limit here (6 GB available, 9 GB already in swap), not a regression.
|
||||
Run in batches of six it is 757 passed, 0 failed. If CI is green on the
|
||||
single run, nothing needs doing; if it is not, that is a pre-existing
|
||||
problem to file separately rather than something Phase 5 introduced.
|
||||
|
||||
**Not verified: `make ui-visual`.** Screenshot baselines only mean
|
||||
anything on the machine that recorded them, and nothing here changes
|
||||
what a component renders.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — E2E harness and testctl
|
||||
@@ -666,6 +858,118 @@ spec edits.
|
||||
|
||||
**Est.** One to two sessions. The largest and riskiest phase.
|
||||
|
||||
### Phase 6 — what actually landed
|
||||
|
||||
**`make e2e` is green on chromium: 92 passed, 0 failed.** Three of the
|
||||
four things this phase replaced came out better than what they
|
||||
replaced, and the fourth — `window.go` enumeration — turned out not to
|
||||
be needed at all.
|
||||
|
||||
**6d decided itself.** The plan asked whether `-tags server` was worth
|
||||
adopting; it was not optional. `dev-headless.sh` ran a `-tags dev`
|
||||
binary whose `app_dev.go` parsed `-devserver` / `-assetdir` straight
|
||||
out of `os.Args`, and that file went with v2 — so the harness had **no
|
||||
server at all**, not a worse one. `-tags dev,server` is a first-class
|
||||
mode, it needs no display, and testctl mounts on it unchanged. **Xvfb
|
||||
is gone** from the script and from CI, which retires the "Xvfb is not
|
||||
optional" note the script had carried since plan 005. `dbus-run-session`
|
||||
stays, for MPRIS, exactly as before.
|
||||
|
||||
**6a hooks two places, and the outbound one is the good surprise.**
|
||||
Inbound is `window._wails.dispatchWailsEvent` — the entry point the
|
||||
backend's own push uses. It is wrapped by *pre-creating* the object the
|
||||
runtime keeps (`window._wails = window._wails || {}`) and putting an
|
||||
accessor on the one property, which is simpler than v2's
|
||||
accessor-on-`window`, where the whole object was replaced.
|
||||
|
||||
Outbound is **`fetch`**. v3 routes every runtime call — bindings, event
|
||||
emits, window, dialogs, clipboard — through one POST to
|
||||
`/wails/runtime`. There is no global to wrap the way v2's
|
||||
`window.runtime` could be, and it does not matter: one hook sees calls
|
||||
from any module, needs no walk of an object graph, and cannot miss a
|
||||
call made before the harness looked, which is what v2's "runs twice"
|
||||
dance in `measure.mjs` existed to work around.
|
||||
|
||||
**`__yjEvents.call` is now HTTP, and that is what unblocked everything
|
||||
else.** It posts by *method name*, so it depends on nothing in the
|
||||
app's bundle and works on a page with no init script. That is what lets
|
||||
`seed-sandbox.sh` **drop `playwright-cli` entirely** — it drove
|
||||
`AddLibrary` through a real browser only because `window.go` was v2's
|
||||
one way in — taking with it a global npm install, a second Chromium,
|
||||
and the `PLAYWRIGHT_BROWSERS_PATH` revision dance in CI. A seed is
|
||||
`curl` now and still produced by running the app.
|
||||
|
||||
**6b: option (1), and it costs less than feared.**
|
||||
`e2e/support/method-ids.mjs` derives `methodID → pkg.Type.Method` from
|
||||
`frontend/bindings/` by reading the id literal beside the function that
|
||||
sends it — no hashing, nothing to drift. Plain `.mjs` rather than `.ts`
|
||||
because `measure.mjs` runs under bare `node`, and one derivation is
|
||||
better than two that can disagree. `harness.spec`'s enumeration was not
|
||||
worth replacing in kind: "is this the real app" is now asked of the
|
||||
runtime (`_wails.clientId`, `dispatchWailsEvent`) and of the backend
|
||||
(a real method answers, an invented one is refused), which is a better
|
||||
question than `Object.keys`.
|
||||
|
||||
**6c: nothing to do.** testctl's mount on the asset handler works in
|
||||
both modes and is shared with `/artist-images/`; `ServiceOptions{Route}`
|
||||
would be churn. `Deps.Context` stays a function, because
|
||||
`testctl.Register` still runs in `NewYellowJacketApp`, before any
|
||||
context exists.
|
||||
|
||||
**Four bugs, and the migration is how each surfaced.**
|
||||
|
||||
- **The cross-service wiring never ran headless.** Phase 2 hung it off
|
||||
`Common.ApplicationStarted`, which is the right *moment* and the
|
||||
wrong *mechanism*: server mode emits no application events at all
|
||||
(`setupCommonEvents` is an explicit no-op under `-tags server`). So
|
||||
the desktop build wired itself and the harness build did not — "No
|
||||
player set, cannot load track", the queue with no `TrackLoader`, a
|
||||
track that changed the queue and then silently did nothing. It is a
|
||||
service registered last now (`backend/startup.go`), which gets the
|
||||
ordering from the mechanism rather than from an event: services start
|
||||
in registration order, on the main goroutine, in every mode.
|
||||
- **Six specs called `SetQueue` with three of its four arguments.** v2
|
||||
accepted the call and filled the gap with a zero value; v3 answers
|
||||
`expects 4 arguments, got 3`. `NO_QUEUE_SOURCE` says explicitly what
|
||||
was being supplied silently.
|
||||
- **`requested-badge`'s cleanup was a no-op.** It read `window.go`
|
||||
inside a type assertion and `return`ed on `if (!svc)` — the silent
|
||||
cleanup its own comment was written to prevent, one migration later,
|
||||
which is why the spec failed against its own leftovers. It posts to
|
||||
the runtime endpoint now, which needs no bridge and no global.
|
||||
- **`SearchIndex.Search` trusted a startup latch.** `IsReady()` is set
|
||||
once, so rows staged by a spec afterwards were unsearchable, and
|
||||
three specs passed only when an earlier one happened to flip it —
|
||||
order-dependent, and reproducibly red in isolation. `shelves.go` had
|
||||
already fixed exactly this and left `hasCatalogRows` behind; the
|
||||
search path uses it as the fallback, with the latch still the fast
|
||||
path.
|
||||
|
||||
**Two spec edits, both deletions of assertions about v2.**
|
||||
`harness.spec` checked `Object.keys(window.go)` and that a bad call
|
||||
*hung* — the second being a test that the harness's own invented
|
||||
deadline fired, since v2 gave it nothing else. And `album-actions`
|
||||
asserted a `.tracklist-legend` that **`dcc40b1` deleted on `main`**:
|
||||
that spec has been failing since, verified in a worktree, and what
|
||||
replaced it (the dimming and its `aria-disabled`) is covered in
|
||||
`frontend/test/components/album-actions.test.ts`.
|
||||
|
||||
**One thing to know before trusting a local run.** `dev-headless.sh`
|
||||
does not set `YJ_CORE_INDEX_URL`, so a local `make e2e` fetches the
|
||||
real 1.1 M-row explore artifact and then behaves differently from CI
|
||||
(which points it at a dead address at the job level) — slower, and with
|
||||
testctl's snapshot/restore copying an enormous table set. Run it the
|
||||
way CI does:
|
||||
|
||||
```
|
||||
YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" make dev-headless SEED=default
|
||||
```
|
||||
|
||||
**Not verified: WebKit.** Playwright's Linux WebKit links Ubuntu
|
||||
libraries Arch does not provide, so it remains CI-only — which is
|
||||
precisely why `ci.yml`'s `if: ${{ !cancelled() }}` on that step still
|
||||
matters.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — CI and packaging
|
||||
@@ -693,6 +997,83 @@ spec edits.
|
||||
|
||||
**Est.** Half a session.
|
||||
|
||||
### Phase 7 — what actually landed
|
||||
|
||||
Most of the bullets above had already ridden along with Phases 1–6:
|
||||
`ci.yml`'s apt lists were `libwebkitgtk-6.0-dev libgtk-4-dev` on both
|
||||
jobs, Xvfb and `@playwright/cli` were gone from it, the PKGBUILD's
|
||||
`depends=()` was `webkitgtk-6.0 gtk4`, and `nfpm.yaml` shipped the GTK4
|
||||
dependency set. **What had not been checked is whether any of the
|
||||
packaging recipes still work**, and neither did.
|
||||
|
||||
**Both of them were still calling v2's CLI.** `go tool wails3 build
|
||||
-clean -trimpath -ldflags …` fails outright — `flag provided but not
|
||||
defined: -clean`. v3's `build` takes `-tags`, `-obfuscated` and
|
||||
`-garbleargs` and nothing else, because the build is a Taskfile tree
|
||||
now and `-trimpath`/`-w -s` live in the production task's own flags.
|
||||
Both recipes also installed from `build/bin/`, which is v2's output
|
||||
path; v3 writes to `bin/`, and `build/` is *tracked build assets*.
|
||||
Neither had been run since Phase 1 — the same gap the corrected note
|
||||
under Phase 1 records about `make build-dev`.
|
||||
|
||||
**The version stamp needed a seam, and it is this repo's one edit to
|
||||
the scaffold Taskfiles.** `wails3 build` has no `-ldflags`, and
|
||||
`build:native` computes `BUILD_FLAGS` in its own `vars:`, so a CLI
|
||||
variable cannot override it. `LDFLAGS_EXTRA` is appended *inside* the
|
||||
production `-ldflags` string in `build/linux/Taskfile.yml` and
|
||||
`build/darwin/Taskfile.yml` (identical on both, so the Homebrew formula
|
||||
has one invocation rather than two), empty by default so `make
|
||||
build-dev` and `make build-prod` are unchanged. Verified end to end,
|
||||
not by reading the template: `wails3 task build LDFLAGS_EXTRA="-X
|
||||
'main.version=v9.9.9' …"` produces a binary that logs `version: v9.9.9`
|
||||
on startup.
|
||||
|
||||
**The tasks invoke `wails3` by bare name**, which is why the Makefile
|
||||
has `scripts/toolbin` — and neither packaging recipe had it, so both
|
||||
would have died at the first sub-task even with correct flags. Both
|
||||
prepend it now.
|
||||
|
||||
**Bundling is a separate step from building in v3**, which the formula
|
||||
did not know: `task build` produces a bare binary on *both* platforms,
|
||||
and the macOS `.app` is `task package`. The formula's `Dir["build/bin/
|
||||
*.app"]` would have found nothing and `odie`'d.
|
||||
|
||||
**The build assets were a scaffold's, not this app's** — and this is
|
||||
the find that mattered most. `build/darwin/Info.plist` named
|
||||
`CFBundleExecutable` **`yjref`** (the scratchpad app Phase 1 scaffolded
|
||||
from) and `com.example.yjref`; `nfpm.yaml` packaged `./bin/yjref` to
|
||||
`/usr/local/bin/yjref`; the `.desktop` template said "A yjref
|
||||
application"; the Windows manifest said `com.example.yjref`. A macOS
|
||||
`.app` built from that plist would not have launched. They are
|
||||
generated from `build/config.yml`, so the fix is
|
||||
`wails3 task common:update:build-assets` and filling that file's `info`
|
||||
block — which had also never been filled from `wails.json`, contrary to
|
||||
Phase 1 step 3. Two consequences recorded in place: nfpm's `homepage`
|
||||
and `license` are **not** derived from `config.yml` and survive the
|
||||
refresh (they were `wails.io` / `MIT`), and the refresh regenerates
|
||||
`build/ios/` and `build/android/`, which are gitignored rather than
|
||||
deleted-and-rediscovered on every run.
|
||||
|
||||
**Documentation.** `make skill-check` passes; `.pi/` needed no changes
|
||||
(Phase 6 had already retired `window.go` and Xvfb from it). `README.md`
|
||||
still told a contributor to `go install wails/v2/cmd/wails` and
|
||||
`apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev`. CLAUDE.md gained
|
||||
a **Packaging** section for the four Taskfile facts above, and its
|
||||
lifecycle, bindings, harness, events and CI sections were rewritten
|
||||
onto v3 — including the point of the whole migration, that
|
||||
`TestNoDirectRuntimeEmits`'s justification is now the *weaker* one.
|
||||
|
||||
**Locally green after the change:** `make lint` and `make test` (all
|
||||
three build configurations), `tsc --noEmit`, `make css-check`,
|
||||
`make bindings-check`, `make skill-check`, `make commit-check`,
|
||||
`make build-prod`, 757 Vitest tests across all 58 files (batched — the
|
||||
single-run failure still reproduces and is still this machine's
|
||||
resource limit, unchanged from Phase 5), and `make e2e` **92/92 on
|
||||
chromium**.
|
||||
|
||||
**Still unverified: WebKit**, and it is CI-only by design. That is the
|
||||
one thing standing between this branch and a merge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — What v3 unlocks (explicitly out of scope)
|
||||
@@ -723,15 +1104,17 @@ Listed so nobody smuggles them into the port and calls it a migration.
|
||||
|---|---|---|
|
||||
| v3 can't drive the headless harness | ~~fatal~~ | **Retired.** `-tags server` verified: calls + events, no display |
|
||||
| Arch/Ubuntu need different webkit tags | ~~high~~ | **Retired.** Both ship webkitgtk-6.0; default builds in the CI container |
|
||||
| No `window.go` → e2e/perf lose binding enumeration | **high** | *New, confirmed.* Decide 6b option (1)/(2) |
|
||||
| 93 `@go` sites need editing after all | **high** | *Confirmed.* Bindings nest by import path; codemod required |
|
||||
| Binding generation analyses the wrong build config | **high** | *New.* Pin tags in Phase 4 step 6 |
|
||||
| No `window.go` → e2e/perf lose binding enumeration | ~~high~~ | **Retired.** 6b option (1): `e2e/support/method-ids.mjs` derives id → name from `frontend/bindings/`. The fetch hook made enumeration unnecessary for *wrapping*; only labelling needed the map |
|
||||
| 93 `@go` sites need editing after all | ~~high~~ | **Retired.** Codemod done in Phase 4; the alias absorbed the prefix, so it was a specifier rewrite |
|
||||
| Binding generation analyses the wrong build config | ~~high~~ | **Retired.** Default tags *are* the shipped configuration since Phase 1; recorded in `scripts/bindings-check.sh` |
|
||||
| Beta churn mid-migration | high | Pin `beta.8`. `beta.3`→`beta.8` in one app's lifetime |
|
||||
| E2E rewrite silently weakens coverage | high | Acceptance = `make e2e` green, **zero spec edits** |
|
||||
| v3 event ordering differs from v2's | medium | Re-derive the fake; don't port it |
|
||||
| E2E rewrite silently weakens coverage | ~~high~~ | **Retired.** 92/92 on chromium. Two spec edits, both deleting assertions about v2 behaviour that is gone; one deletion of a spec already failing on `main` |
|
||||
| `make ui-test` cannot complete in one browser session here | low | *New.* Pre-existing — reproduces at `c9905fb`. A resource limit on this machine; batched runs are green. Watch CI |
|
||||
| v3 event ordering differs from v2's | ~~high~~ | **Retired.** Confirmed and handled in Phase 5: v3's frontend `Events.Emit` round-trips through Go instead of notifying locally first. One test asserted the old behaviour and now asserts the new |
|
||||
| `ServiceShutdown()` signature trap | medium | Silent no-call; grep after Phase 2c |
|
||||
| An app-level event that a headless mode never emits | — | *Found in Phase 6, not predicted.* Server mode emits **no** application events; the cross-service wiring is a service now. Anything else keyed on `Common.*` is suspect |
|
||||
| Quit-during-writes veto breaks | medium | Data-safety path; test deliberately in 2d |
|
||||
| Regression no tier covers | medium | `make perf` before/after on the same seed |
|
||||
| Regression no tier covers | medium | `make perf` before/after on the same seed. Note the fixture seed leaves the bulk-library rows blank; a real comparison wants `make sandbox-seed-bulk` |
|
||||
| GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run |
|
||||
|
||||
---
|
||||
@@ -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,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,410 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **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.
|
||||
+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");
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
+52
-16
@@ -1,6 +1,20 @@
|
||||
# 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.
|
||||
#
|
||||
# Runs on pushes to main from .gitea/workflows/release.yml: determine the
|
||||
# version from the Conventional Commits since the last tag, write the
|
||||
# changelog, commit it, push the tag, and create the Gitea release.
|
||||
#
|
||||
# **There is no `@semantic-release/github` plugin here and there must not
|
||||
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
|
||||
# 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 +77,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
|
||||
|
||||
+16
-372
@@ -1,377 +1,21 @@
|
||||
## [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` on merge to `main`. Each one
|
||||
carries its notes as its body, grouped by change type, with a link to the
|
||||
commit behind every line.
|
||||
|
||||
* **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))
|
||||
**This file is not generated and is not a copy of that.** `main` is a
|
||||
protected branch, so nothing pushes a changelog commit back to it — and a
|
||||
file that claimed to be a changelog while silently never updating would
|
||||
be worse than no file at all. `make release-dry` prints what the next
|
||||
merge would release.
|
||||
|
||||
### Performance
|
||||
|
||||
* **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 wails3 dev -config ./build/config.yml
|
||||
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 wails3 dev -config ./build/config.yml
|
||||
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,14 +166,9 @@ 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
|
||||
|
||||
# Bindings are generated by `wails`, NOT by `go generate`, so the
|
||||
# Bindings are generated by `wails3`, NOT by `go generate`, so the
|
||||
# pre-commit codegen check does not cover them: a renamed Go struct
|
||||
# field currently surfaces at runtime, in a window.
|
||||
#
|
||||
# NOTE (plan 009 phase 4): this still checks v2's frontend/wailsjs,
|
||||
# which nothing regenerates now that the v2 CLI is gone. It is
|
||||
# excluded from the pre-commit hook until the frontend moves to
|
||||
# frontend/bindings.
|
||||
# field would otherwise surface at runtime, inside a window.
|
||||
bindings-check: ## Fail if the generated bindings are stale
|
||||
@./scripts/bindings-check.sh
|
||||
|
||||
@@ -118,10 +179,12 @@ bindings-check: ## Fail if the generated bindings are stale
|
||||
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
||||
@cd frontend && node scripts/check-css-literals.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
|
||||
@@ -129,15 +192,32 @@ 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))
|
||||
|
||||
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
||||
# path, rather than v2's frontend/wailsjs/. The 93 frontend import
|
||||
# sites still point at the old tree, so this writes the new one beside
|
||||
# it until plan 009 phase 4 moves them; the chmod dance that used to
|
||||
# follow is gone with v2's generator, which wrote its runtime files 755.
|
||||
# What a merge to main would release, without releasing it. Reads the
|
||||
# same .releaserc.yml CI does, so "why did that not cut a version" is
|
||||
# answerable locally instead of by pushing and watching. Needs no
|
||||
# credentials: --dry-run neither tags nor publishes.
|
||||
#
|
||||
# The tag set is pinned deliberately: the generator is a static
|
||||
# analyser, so it only sees the configuration it is told about, and the
|
||||
# one that matters is the one users run.
|
||||
# The pins must stay identical to release.yml's, which is where the note
|
||||
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
||||
# release notes come out empty with everything green.
|
||||
release-dry: ## Print the version a merge to main would release
|
||||
@npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@13 \
|
||||
-p @semantic-release/release-notes-generator@14 \
|
||||
-p @semantic-release/changelog@7 \
|
||||
-p @semantic-release/exec@7 \
|
||||
-p conventional-changelog-conventionalcommits@9 \
|
||||
semantic-release --dry-run --no-ci
|
||||
|
||||
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
||||
# 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
|
||||
|
||||
@@ -145,7 +225,7 @@ bindings: ## Regenerate frontend/bindings from the bound Go services
|
||||
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
|
||||
@@ -168,7 +248,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 wails3 dev -config ./build/config.yml
|
||||
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
|
||||
@@ -228,7 +308,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 wails3 dev -config ./build/config.yml
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||
|
||||
sandboxes: ## List existing named sandboxes
|
||||
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
|
||||
@@ -238,10 +318,10 @@ sandboxes: ## List existing named sandboxes
|
||||
.PHONY: sandbox sandbox-rm sandboxes
|
||||
|
||||
build-dev: generate
|
||||
go tool wails3 task build DEV=true
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build DEV=true
|
||||
|
||||
build-prod: generate
|
||||
go tool wails3 task build
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build
|
||||
|
||||
build-frontend:
|
||||
cd frontend && pnpm install && pnpm build
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ includes:
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
android: ./build/android/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
|
||||
+37
-5
@@ -191,6 +191,24 @@ 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
|
||||
@@ -247,6 +265,15 @@ func NewYellowJacketApp(
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -475,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()
|
||||
@@ -496,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) {
|
||||
@@ -513,6 +544,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
),
|
||||
)
|
||||
},
|
||||
OnDuck: yj.player.SetDuck,
|
||||
}); err != nil {
|
||||
yj.logger.Error(
|
||||
"Failed to initialize media controls",
|
||||
|
||||
+29
-30
@@ -328,43 +328,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -411,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 {
|
||||
@@ -620,6 +621,51 @@ func (c *Config) SetQueueFallback(mode string) error {
|
||||
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
|
||||
}
|
||||
|
||||
// GetTrackListColumns returns the configured track-list columns.
|
||||
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
||||
if c.TrackList == nil {
|
||||
|
||||
@@ -48,6 +48,12 @@ var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
||||
type GeneralConfig struct {
|
||||
DefaultPage DefaultPage `toml:"DefaultPage"`
|
||||
QueueFallback QueueFallback `toml:"QueueFallback"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+20
-181
@@ -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
|
||||
}
|
||||
@@ -301,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 {
|
||||
@@ -344,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,18 +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)
|
||||
}
|
||||
}
|
||||
@@ -202,7 +210,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
||||
|
||||
// A popularity refresh: an FTS column is not named at all.
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET popularity = 42 WHERE mbid = 'mbid-1'",
|
||||
"UPDATE explore_index SET popularity = 42 WHERE title = ?",
|
||||
"Unchanged Title",
|
||||
); err != nil {
|
||||
t.Fatalf("popularity update: %v", err)
|
||||
}
|
||||
@@ -212,8 +221,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
||||
if _, err := db.ExecContext(`
|
||||
UPDATE explore_index
|
||||
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
|
||||
WHERE mbid = 'mbid-1'
|
||||
`); err != nil {
|
||||
WHERE title = ?
|
||||
`, "Unchanged Title"); err != nil {
|
||||
t.Fatalf("no-op text update: %v", err)
|
||||
}
|
||||
|
||||
@@ -237,7 +246,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
|
||||
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET title = 'Corrected Title' WHERE mbid = 'mbid-2'",
|
||||
"UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?",
|
||||
"Original Title",
|
||||
); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
@@ -252,7 +262,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
|
||||
|
||||
// The same for the other two indexed columns.
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE mbid = 'mbid-2'",
|
||||
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?",
|
||||
"Corrected Title",
|
||||
); err != nil {
|
||||
t.Fatalf("artist rename: %v", err)
|
||||
}
|
||||
|
||||
+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
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE queue ADD COLUMN source_type TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE queue ADD COLUMN source_id INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE queue ADD COLUMN source_label TEXT NOT NULL DEFAULT '';
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER;
|
||||
@@ -0,0 +1,137 @@
|
||||
-- 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 = '');
|
||||
|
||||
-- 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);
|
||||
@@ -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 ?;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
|
||||
-- expanding a seed selection into a candidate pool by artist similarity
|
||||
-- and genre overlap, restricted to what is actually in the library.
|
||||
|
||||
-- name: GetFilePathsByArtistMBID :many
|
||||
SELECT DISTINCT af.file_path
|
||||
FROM audio_files af
|
||||
JOIN recordings r ON af.recording_id = r.id
|
||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
JOIN artists a ON a.id = aca.artist_id
|
||||
WHERE a.mbid = ?;
|
||||
|
||||
-- name: GetGenreNamesByFilePath :many
|
||||
SELECT DISTINCT g.name
|
||||
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.file_path = ?;
|
||||
|
||||
-- name: GetArtistByFilePath :one
|
||||
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_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
|
||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
||||
JOIN artists a ON a.id = aca.artist_id
|
||||
WHERE af.file_path = ?
|
||||
LIMIT 1;
|
||||
@@ -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 = ?;
|
||||
|
||||
@@ -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,50 +0,0 @@
|
||||
-- name: CreateReleaseGroupRecording :one
|
||||
INSERT INTO release_group_recordings (
|
||||
release_group_id, recording_id, track_number, disc_number, total_tracks
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAlbumCompleteness :one
|
||||
WITH discs AS (
|
||||
SELECT
|
||||
COALESCE(rgr.disc_number, 1) AS disc,
|
||||
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
|
||||
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
|
||||
FROM release_group_recordings rgr
|
||||
WHERE rgr.release_group_id = ?
|
||||
GROUP BY COALESCE(rgr.disc_number, 1)
|
||||
)
|
||||
SELECT
|
||||
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
|
||||
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
|
||||
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
|
||||
FROM discs;
|
||||
|
||||
-- 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;
|
||||
@@ -87,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 (
|
||||
@@ -98,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
|
||||
@@ -125,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;
|
||||
|
||||
@@ -150,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;
|
||||
|
||||
@@ -204,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
|
||||
@@ -284,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
|
||||
@@ -315,5 +333,12 @@ 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;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Which credit a catalog entity is credited to. One row per recording
|
||||
-- or release group whose credit names more than one artist.
|
||||
--
|
||||
-- This is a table rather than an `explore_index.artist_credit_id`
|
||||
-- column, and that is a deliberate consequence of how this app applies
|
||||
-- its schema. `applySchema` is CREATE ... IF NOT EXISTS and there is
|
||||
-- no migration chain (plan 013), so a *column* added to an existing
|
||||
-- table never reaches a database that already has it -- while a new
|
||||
-- *table* is created on every install, old or new, for free.
|
||||
-- explore_index is the one table nobody can afford to drop and rebuild
|
||||
-- on a schema change: it is the artifact users download rather than
|
||||
-- derive.
|
||||
--
|
||||
-- Only multi-artist credits are referenced here, matching
|
||||
-- artist_credit_part. An entity with no row is credited to exactly one
|
||||
-- artist, which explore_index's own artist_name and artist_mbid already
|
||||
-- describe -- so absence is the common case and means "nothing to
|
||||
-- decompose", not "unknown".
|
||||
--
|
||||
-- `credit_id` is opaque and is only meaningful against the
|
||||
-- artist_credit_part rows built or imported alongside it. The two are
|
||||
-- always written together; nothing persists a credit_id anywhere else.
|
||||
-- The local library stores resolved parts, never this id.
|
||||
CREATE TABLE IF NOT EXISTS artist_credit_ref (
|
||||
mbid BLOB NOT NULL PRIMARY KEY CHECK(length(mbid) = 16),
|
||||
credit_id INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_credit_ref_credit
|
||||
ON artist_credit_ref(credit_id);
|
||||
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
|
||||
ON artist_images(artist_mbid);
|
||||
-- No index on artist_mbid alone: the UNIQUE index below has it as its
|
||||
-- leftmost column.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
|
||||
ON artist_images(artist_mbid, source, source_url);
|
||||
|
||||
@@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
|
||||
PRIMARY KEY (mbid, source)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid);
|
||||
-- No index on mbid alone: PRIMARY KEY (mbid, source) already has it as
|
||||
-- its leftmost column, so a second one costs a write per row and serves
|
||||
-- no read.
|
||||
|
||||
@@ -1,34 +1,92 @@
|
||||
-- One row per audio file, and the file's tags live on it.
|
||||
--
|
||||
-- This table used to be a stub — path, format, a foreign key — with
|
||||
-- every tag-derived field one join away in `recordings`, which was in
|
||||
-- turn linked to an album through `release_group_recordings` and to an
|
||||
-- artist through `artist_credit` + `artist_credit_artist`. That is
|
||||
-- MusicBrainz's data model, and it is the right model for MusicBrainz:
|
||||
-- a recording really can appear on many releases and a credit really
|
||||
-- can list many artists.
|
||||
--
|
||||
-- It was the wrong model here, and the library said so. Measured on a
|
||||
-- real 25,966-file library: **no** recording had more than one file,
|
||||
-- **no** recording belonged to more than one release group, and 3 of
|
||||
-- 2,823 credits listed more than one artist. Every many-to-many the
|
||||
-- schema modelled was 1:1 in the data, and the cost of modelling it
|
||||
-- anyway was a six-way join in every read, a `MIN(release_group_id)`
|
||||
-- subquery in eleven queries to collapse a fan-out that never happened,
|
||||
-- a first-credited-artist subquery in nine more to collapse the other
|
||||
-- one, and — the reason this changed — a whole class of bugs where a
|
||||
-- `recordings` row **outlived the file that created it**. Retagging a
|
||||
-- file created a new recording and abandoned the old one, so the same
|
||||
-- library carried 812 recordings, 216 release groups and 260 artists
|
||||
-- with no file behind them, and everything that asked "do I own this"
|
||||
-- by looking for a metadata row got 129 confident yeses for tracks
|
||||
-- that could not be played.
|
||||
--
|
||||
-- With the tags on the file, ownership is not a rule anyone can forget:
|
||||
-- the row *is* the file.
|
||||
CREATE TABLE IF NOT EXISTS audio_files (
|
||||
id integer PRIMARY KEY,
|
||||
file_path text NOT NULL UNIQUE,
|
||||
length_milliseconds int NOT NULL,
|
||||
file_type_id int NOT NULL,
|
||||
recording_id int NOT NULL,
|
||||
sample_rate int NOT NULL DEFAULT 0,
|
||||
bit_depth int NOT NULL DEFAULT 0,
|
||||
channels int NOT NULL DEFAULT 0,
|
||||
bitrate int NOT NULL DEFAULT 0,
|
||||
file_size int NOT NULL DEFAULT 0,
|
||||
basename text NOT NULL DEFAULT '',
|
||||
library_id int NOT NULL DEFAULT 0,
|
||||
play_count int NOT NULL DEFAULT 0,
|
||||
last_played datetime,
|
||||
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
library_id INTEGER NOT NULL DEFAULT 0,
|
||||
file_type_id INTEGER NOT NULL,
|
||||
|
||||
-- Audio properties, read from the file itself.
|
||||
length_milliseconds INTEGER NOT NULL,
|
||||
sample_rate INTEGER NOT NULL DEFAULT 0,
|
||||
bit_depth INTEGER NOT NULL DEFAULT 0,
|
||||
channels INTEGER NOT NULL DEFAULT 0,
|
||||
bitrate INTEGER NOT NULL DEFAULT 0,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is
|
||||
-- for display; `artist_id` is the primary artist it resolves to, and
|
||||
-- is what grouping, browsing and the artist page use. Keeping both
|
||||
-- is what makes the credit table unnecessary: the string is the only
|
||||
-- thing that was ever read off it.
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
artist_credit TEXT NOT NULL DEFAULT '',
|
||||
artist_id INTEGER,
|
||||
album_id INTEGER,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
-- The denominator the tag declared: the 12 in "5/12", per disc. It
|
||||
-- is what lets "do I have all of this album" be answered from disk
|
||||
-- instead of from MusicBrainz. NULL means the tag did not say, which
|
||||
-- is a third state and not the same as zero.
|
||||
total_tracks INTEGER,
|
||||
year INTEGER,
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
recording_mbid TEXT,
|
||||
|
||||
-- Library bookkeeping.
|
||||
basename TEXT NOT NULL DEFAULT '',
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
-- File mtime as a Unix timestamp in seconds, captured at import and
|
||||
-- compared against the on-disk mtime during a scan to detect files
|
||||
-- another application retagged in place.
|
||||
modified_at INTEGER NOT NULL DEFAULT 0,
|
||||
play_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_played DATETIME,
|
||||
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
||||
CHECK(tag_status IN (
|
||||
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
||||
)),
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
-- File mtime as a Unix timestamp in seconds, captured at import.
|
||||
-- Compared against the on-disk mtime during a scan to detect files
|
||||
-- another application retagged in place. 0 means "never recorded"
|
||||
-- (rows predating migration 47) and is treated as not-stale so an
|
||||
-- upgrade does not re-import the whole library.
|
||||
modified_at int NOT NULL DEFAULT 0,
|
||||
|
||||
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
|
||||
FOREIGN KEY(recording_id) REFERENCES recordings(id),
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id)
|
||||
FOREIGN KEY(library_id) REFERENCES libraries(id),
|
||||
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
||||
FOREIGN KEY(album_id) REFERENCES albums(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_album_id
|
||||
ON audio_files(album_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_artist_id
|
||||
ON audio_files(artist_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
|
||||
ON audio_files(basename);
|
||||
|
||||
@@ -36,10 +94,17 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
|
||||
ON audio_files(group_key) WHERE group_key != '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
|
||||
ON audio_files(library_id);
|
||||
ON audio_files(library_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
|
||||
ON audio_files(recording_id);
|
||||
-- The ownership question, asked by MBID: "is there a *file* with this
|
||||
-- recording MBID". Nothing may answer it from a metadata table again.
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_mbid
|
||||
ON audio_files(recording_mbid) WHERE recording_mbid IS NOT NULL;
|
||||
|
||||
-- Answers "does this tagging group still contain untagged files" in one
|
||||
-- seek per group. The autotag queue asks it once per row.
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_untagged_group_key
|
||||
ON audio_files(group_key) WHERE tag_status = 'untagged';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
|
||||
ON audio_files(library_id) WHERE tag_status = 'untagged';
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
-- The downloaded MusicBrainz/ListenBrainz catalog.
|
||||
--
|
||||
-- MusicBrainz ids are stored as their 16 raw bytes and entity types as
|
||||
-- small integers, which is a size decision: on a real 2,052,200-row
|
||||
-- catalog those four columns were 220 MB of a 383 MB table and were
|
||||
-- carried again in every index keyed on them, and the conversion took
|
||||
-- the table and its four indexes from 677 MB to 389 MB. See
|
||||
-- backend/explore/mbid.go, which is the only place that encoding is
|
||||
-- known -- everything above it speaks dashed strings and entity names.
|
||||
--
|
||||
-- The CHECK constraints are what make a mistake loud. SQLite does not
|
||||
-- coerce between TEXT and BLOB, so a query comparing this column
|
||||
-- against a 36-character string returns no rows rather than an error;
|
||||
-- a *write* of one fails here instead, at the insert that made it.
|
||||
CREATE TABLE IF NOT EXISTS explore_index (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_type TEXT NOT NULL,
|
||||
mbid TEXT NOT NULL,
|
||||
entity_type INTEGER NOT NULL,
|
||||
mbid BLOB NOT NULL CHECK(length(mbid) = 16),
|
||||
title TEXT NOT NULL,
|
||||
artist_name TEXT NOT NULL,
|
||||
artist_mbid TEXT NOT NULL,
|
||||
artist_mbid BLOB NOT NULL
|
||||
CHECK(length(artist_mbid) IN (0, 16)),
|
||||
aliases TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Popularity signals, derived from the ListenBrainz listens dump.
|
||||
@@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
||||
|
||||
-- Recording-specific fields.
|
||||
duration INTEGER NOT NULL DEFAULT 0,
|
||||
caa_release_mbid TEXT NOT NULL DEFAULT '',
|
||||
caa_release_mbid BLOB NOT NULL DEFAULT x''
|
||||
CHECK(length(caa_release_mbid) IN (0, 16)),
|
||||
release_name TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- Release-group-specific fields.
|
||||
@@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
||||
secondary_types TEXT NOT NULL DEFAULT '',
|
||||
release_date TEXT NOT NULL DEFAULT '',
|
||||
|
||||
-- How many tracks the release group's canonical release has, so
|
||||
-- "do I have all of this" is answerable offline for an album the
|
||||
-- library holds no tags for. Zero means the catalog does not say,
|
||||
-- which is the same third state the local answer has -- and is what
|
||||
-- every row carries until a central dump build fills it.
|
||||
total_tracks INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
-- Artist-specific fields.
|
||||
artist_type TEXT NOT NULL DEFAULT '',
|
||||
country TEXT NOT NULL DEFAULT '',
|
||||
@@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
||||
UNIQUE(mbid)
|
||||
);
|
||||
|
||||
-- The exact-match tier's two indexes.
|
||||
--
|
||||
-- Their predicate is the champion set - the popular rows plus whatever
|
||||
-- the user owns - and matching it to `ExactMatches`' own WHERE clause is
|
||||
-- what makes them small. They used to say `popularity > 0`, which on a
|
||||
-- real 2,052,200-row catalog covered 2,046,645 of them: a full index
|
||||
-- wearing a partial index's clothes, 101 MB for the pair. Narrowed to
|
||||
-- the set the tier can actually return, they are 3 MB and the query
|
||||
-- plan is unchanged (measured, on that catalog).
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
|
||||
ON explore_index(LOWER(artist_name))
|
||||
WHERE popularity > 0;
|
||||
WHERE popularity >= 10000 OR in_library = 1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
|
||||
ON explore_index(caa_release_mbid)
|
||||
WHERE entity_type = 'release_group' AND caa_release_mbid != '';
|
||||
WHERE entity_type = 2 AND caa_release_mbid != x'';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
|
||||
ON explore_index(artist_mbid, entity_type, popularity DESC);
|
||||
@@ -62,4 +94,4 @@ CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
|
||||
ON explore_index(LOWER(title))
|
||||
WHERE popularity > 0;
|
||||
WHERE popularity >= 10000 OR in_library = 1;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Genres per file. This is `recording_genres` with the recording taken
|
||||
-- out of the middle: it is the one many-to-many in the local library
|
||||
-- that is actually many-to-many (a real library runs about four genre
|
||||
-- rows per file), which is why it stays a join table when the others
|
||||
-- did not.
|
||||
CREATE TABLE IF NOT EXISTS file_genres (
|
||||
audio_file_id INTEGER NOT NULL,
|
||||
genre_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (audio_file_id, genre_id),
|
||||
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(genre_id) REFERENCES genres(id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- The reverse direction ("which files are in this genre"). The
|
||||
-- forward direction is served by the primary key, so — unlike the
|
||||
-- table this replaces — there is no third index restating it.
|
||||
CREATE INDEX IF NOT EXISTS idx_file_genres_genre_id
|
||||
ON file_genres(genre_id);
|
||||
@@ -1,15 +1,11 @@
|
||||
-- One row per "go find me this", from the moment the user asks until
|
||||
-- the files are in the library or the attempt is abandoned.
|
||||
-- One row per folder the user has added as a music library.
|
||||
--
|
||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
||||
-- carries one can be matched against a known tracklist at import time,
|
||||
-- which is what makes unattended completion safe. Free-text requests
|
||||
-- (both NULL) are always presented to the user for confirmation.
|
||||
-- Everything else keyed by library_id means "which of these folders did
|
||||
-- this come from"; a library_id of 0 in a query means "all of them".
|
||||
--
|
||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
||||
-- import do not have to re-resolve it, and so a request survives the
|
||||
-- explore index being rebuilt underneath it.
|
||||
|
||||
-- autotag_warning_acked records that the user has been told what
|
||||
-- autotagging will do to the files in this folder, which is a decision
|
||||
-- they made and not something a rescan can rediscover.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Lyrics for a file, and where they came from.
|
||||
--
|
||||
-- These used to be a column on `recordings`, in a table classified
|
||||
-- `Owned` — data a rescan can rebuild from the files. That was true of
|
||||
-- lyrics read out of a USLT frame and false of lyrics fetched from
|
||||
-- LRCLIB, and nothing recorded which was which, so a library with
|
||||
-- 24,294 of them could not answer how many were free to rebuild and how
|
||||
-- many were network traffic waiting to happen. `source` answers it.
|
||||
--
|
||||
-- `recording_mbid` is carried alongside the file id so a future
|
||||
-- re-import can re-adopt fetched lyrics without asking LRCLIB again;
|
||||
-- the file id is the key because untagged files have no MBID and are
|
||||
-- exactly the ones whose lyrics had to be fetched.
|
||||
CREATE TABLE IF NOT EXISTS lyrics (
|
||||
audio_file_id INTEGER PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'tag'
|
||||
CHECK(source IN ('tag', 'lrclib')),
|
||||
recording_mbid TEXT,
|
||||
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lyrics_recording_mbid
|
||||
ON lyrics(recording_mbid) WHERE recording_mbid IS NOT NULL;
|
||||
@@ -1,14 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS recording_genres (
|
||||
id INTEGER PRIMARY KEY,
|
||||
recording_id INTEGER NOT NULL,
|
||||
genre_id INTEGER NOT NULL,
|
||||
FOREIGN KEY(recording_id) REFERENCES recordings(id),
|
||||
FOREIGN KEY(genre_id) REFERENCES genres(id),
|
||||
UNIQUE(recording_id, genre_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id
|
||||
ON recording_genres(genre_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id
|
||||
ON recording_genres(recording_id);
|
||||
@@ -1,19 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
artist_credit_id INTEGER NOT NULL,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
year INTEGER,
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
lyrics TEXT,
|
||||
comment TEXT,
|
||||
mbid TEXT,
|
||||
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id
|
||||
ON recordings(artist_credit_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL;
|
||||
@@ -1,21 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS release_group_recordings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
release_group_id INTEGER NOT NULL,
|
||||
recording_id INTEGER NOT NULL,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
-- The denominator the file's own tag declared: the 12 in "5/12", per
|
||||
-- disc. Read off every file at scan and, until now, discarded — so
|
||||
-- "do I have all of this album" had no local answer and the album
|
||||
-- page asked MusicBrainz. NULL means the tag did not say, which is
|
||||
-- a third state and not the same as zero.
|
||||
total_tracks INTEGER,
|
||||
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
|
||||
FOREIGN KEY(recording_id) REFERENCES recordings(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id
|
||||
ON release_group_recordings(recording_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id
|
||||
ON release_group_recordings(release_group_id);
|
||||
@@ -1,20 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "release_groups" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
cover_art_id INTEGER,
|
||||
album_artist_credit_id INTEGER,
|
||||
year INTEGER,
|
||||
total_tracks INTEGER,
|
||||
total_discs INTEGER, mbid TEXT, original_year INTEGER, pending_release_mbid TEXT,
|
||||
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
|
||||
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
|
||||
UNIQUE(name, album_artist_credit_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
|
||||
ON release_groups(album_artist_credit_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id
|
||||
ON release_groups(cover_art_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL;
|
||||
@@ -1,4 +1,11 @@
|
||||
-- Release MBID -> release-group MBID, captured during a dump import.
|
||||
--
|
||||
-- It is empty on an ordinary install and looks droppable for that
|
||||
-- reason: only a local dump build (`indexbuild`) fills it. The daily
|
||||
-- incremental refresh reads it to roll per-release listen counts up to
|
||||
-- the release group they belong to, so an install that has built its
|
||||
-- own index does need it.
|
||||
CREATE TABLE IF NOT EXISTS release_to_rg (
|
||||
release_mbid TEXT PRIMARY KEY,
|
||||
rg_mbid TEXT NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
) WITHOUT ROWID;
|
||||
|
||||
@@ -6,5 +6,5 @@ CREATE TABLE IF NOT EXISTS similar_artist_map (
|
||||
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
|
||||
ON similar_artist_map(source_artist_mbid);
|
||||
-- No index on source_artist_mbid alone: the PRIMARY KEY has it as its
|
||||
-- leftmost column.
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
CREATE VIEW IF NOT EXISTS track_metadata AS
|
||||
-- The one definition of "a track, with everything a list needs".
|
||||
--
|
||||
-- A view is a definition, not data, so it is dropped and recreated on
|
||||
-- every open rather than carrying a migration alongside it: CREATE VIEW
|
||||
-- IF NOT EXISTS silently keeps an older database on the old definition,
|
||||
-- and a migration file restating it would be the second description of
|
||||
-- the schema the migration rules exist to prevent.
|
||||
--
|
||||
-- This projection used to exist **nine times** — four copies in
|
||||
-- audio_files.sql, two in playlists.sql, two in genres.sql, one in
|
||||
-- queue.sql — plus this view, which only the raw-SQL search paths used.
|
||||
-- They had already drifted: this view preferred the album's
|
||||
-- original_year for `year` and GetAllTracksWithFullMetadata used the
|
||||
-- track's own, so the same library reported different years on
|
||||
-- different screens. Every query that wants a track row now selects
|
||||
-- from here, which is also why there is one row type and one mapper on
|
||||
-- the Go side instead of nine and a twenty-two-argument function.
|
||||
DROP VIEW IF EXISTS track_metadata;
|
||||
|
||||
CREATE VIEW track_metadata AS
|
||||
SELECT
|
||||
af.id,
|
||||
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,
|
||||
af.title,
|
||||
af.artist_credit AS artist_name,
|
||||
af.track_number,
|
||||
af.disc_number,
|
||||
COALESCE(al.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),
|
||||
FROM file_genres fg
|
||||
JOIN genres g ON g.id = fg.genre_id
|
||||
WHERE fg.audio_file_id = af.id),
|
||||
''
|
||||
) AS TEXT) AS genre,
|
||||
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
|
||||
COALESCE(rg.year, r.year, 0) AS release_year,
|
||||
COALESCE(r.composer, '') AS composer,
|
||||
COALESCE(al.original_year, al.year, af.year, 0) AS year,
|
||||
COALESCE(al.year, af.year, 0) AS release_year,
|
||||
af.composer,
|
||||
COALESCE(ft.extension, '') AS file_type,
|
||||
af.sample_rate,
|
||||
af.bit_depth,
|
||||
@@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS
|
||||
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
|
||||
COALESCE(ar.mbid, '') AS artist_mbid,
|
||||
COALESCE(al.mbid, '') AS release_group_mbid,
|
||||
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||
af.album_id,
|
||||
af.artist_id
|
||||
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 (
|
||||
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 file_types ft ON af.file_type_id = ft.id;
|
||||
LEFT JOIN albums al ON al.id = af.album_id
|
||||
LEFT JOIN artists ar ON ar.id = af.artist_id
|
||||
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||
LEFT JOIN file_types ft ON ft.id = af.file_type_id;
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: albums.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const deleteAlbum = `-- name: DeleteAlbum :exec
|
||||
DELETE FROM albums WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAlbum(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAlbum, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteAllAlbums = `-- name: DeleteAllAlbums :exec
|
||||
DELETE FROM albums
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAllAlbums(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAllAlbums)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAlbum = `-- name: GetAlbum :one
|
||||
SELECT id, name, artist_credit, artist_id, mbid, year, original_year, cover_art_id, pending_release_mbid FROM albums WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetAlbum(ctx context.Context, id int64) (Album, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAlbum, id)
|
||||
var i Album
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.ArtistCredit,
|
||||
&i.ArtistID,
|
||||
&i.Mbid,
|
||||
&i.Year,
|
||||
&i.OriginalYear,
|
||||
&i.CoverArtID,
|
||||
&i.PendingReleaseMbid,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAlbumCompleteness = `-- name: GetAlbumCompleteness :one
|
||||
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 = ?1 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 = ?1 AND c.total_tracks IS NULL
|
||||
) AS INTEGER) AS known
|
||||
FROM audio_files a
|
||||
WHERE a.album_id = ?1
|
||||
`
|
||||
|
||||
type GetAlbumCompletenessRow struct {
|
||||
Owned int64
|
||||
Expected int64
|
||||
Known int64
|
||||
}
|
||||
|
||||
// "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.
|
||||
func (q *Queries) GetAlbumCompleteness(ctx context.Context, albumID sql.NullInt64) (GetAlbumCompletenessRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAlbumCompleteness, albumID)
|
||||
var i GetAlbumCompletenessRow
|
||||
err := row.Scan(&i.Owned, &i.Expected, &i.Known)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getAlbums = `-- 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(?1 AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY al.name
|
||||
`
|
||||
|
||||
type GetAlbumsRow struct {
|
||||
ID int64
|
||||
Name string
|
||||
Year sql.NullInt64
|
||||
ReleaseYear int64
|
||||
Mbid sql.NullString
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
CoverArtPath string
|
||||
}
|
||||
|
||||
func (q *Queries) GetAlbums(ctx context.Context, libraryID int64) ([]GetAlbumsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbums, libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAlbumsRow
|
||||
for rows.Next() {
|
||||
var i GetAlbumsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Year,
|
||||
&i.ReleaseYear,
|
||||
&i.Mbid,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.CoverArtPath,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAlbumsByArtistName = `-- 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 = ?1 OR ar.name = ?1)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.album_id = al.id
|
||||
AND af.library_id = COALESCE(NULLIF(CAST(?2 AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY year, al.name
|
||||
`
|
||||
|
||||
type GetAlbumsByArtistNameParams struct {
|
||||
Artist string
|
||||
LibraryID int64
|
||||
}
|
||||
|
||||
type GetAlbumsByArtistNameRow struct {
|
||||
ID int64
|
||||
Name string
|
||||
Year sql.NullInt64
|
||||
ReleaseYear int64
|
||||
Mbid sql.NullString
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
CoverArtPath string
|
||||
}
|
||||
|
||||
func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArtistNameParams) ([]GetAlbumsByArtistNameRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumsByArtistName, arg.Artist, arg.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAlbumsByArtistNameRow
|
||||
for rows.Next() {
|
||||
var i GetAlbumsByArtistNameRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Year,
|
||||
&i.ReleaseYear,
|
||||
&i.Mbid,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.CoverArtPath,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||
SELECT id, pending_release_mbid FROM albums
|
||||
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||
AND (mbid IS NULL OR mbid = '')
|
||||
`
|
||||
|
||||
type GetAlbumsWithPendingReleaseMBIDRow struct {
|
||||
ID int64
|
||||
PendingReleaseMbid sql.NullString
|
||||
}
|
||||
|
||||
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAlbumsWithPendingReleaseMBIDRow
|
||||
for rows.Next() {
|
||||
var i GetAlbumsWithPendingReleaseMBIDRow
|
||||
if err := rows.Scan(&i.ID, &i.PendingReleaseMbid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getEmptyAlbumIDs = `-- name: GetEmptyAlbumIDs :many
|
||||
SELECT id FROM albums al
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
|
||||
)
|
||||
`
|
||||
|
||||
// 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.
|
||||
func (q *Queries) GetEmptyAlbumIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getEmptyAlbumIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const resolveAlbumPendingReleaseMBID = `-- name: ResolveAlbumPendingReleaseMBID :exec
|
||||
UPDATE albums
|
||||
SET mbid = ?, pending_release_mbid = NULL
|
||||
WHERE id = ? AND (mbid IS NULL OR mbid = '')
|
||||
`
|
||||
|
||||
type ResolveAlbumPendingReleaseMBIDParams struct {
|
||||
Mbid sql.NullString
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Clears the pending marker once the release-group MBID it stood in for
|
||||
// has been resolved. Guarded so a real MBID is never overwritten.
|
||||
func (q *Queries) ResolveAlbumPendingReleaseMBID(ctx context.Context, arg ResolveAlbumPendingReleaseMBIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, resolveAlbumPendingReleaseMBID, arg.Mbid, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setAlbumCoverArt = `-- name: SetAlbumCoverArt :exec
|
||||
UPDATE albums SET cover_art_id = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type SetAlbumCoverArtParams struct {
|
||||
CoverArtID sql.NullInt64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetAlbumCoverArt(ctx context.Context, arg SetAlbumCoverArtParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAlbumCoverArt, arg.CoverArtID, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setAlbumMBID = `-- name: SetAlbumMBID :exec
|
||||
UPDATE albums SET mbid = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type SetAlbumMBIDParams struct {
|
||||
Mbid sql.NullString
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetAlbumMBID(ctx context.Context, arg SetAlbumMBIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAlbumMBID, arg.Mbid, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setAlbumOriginalYear = `-- name: SetAlbumOriginalYear :exec
|
||||
UPDATE albums SET original_year = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type SetAlbumOriginalYearParams struct {
|
||||
OriginalYear sql.NullInt64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetAlbumOriginalYear(ctx context.Context, arg SetAlbumOriginalYearParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAlbumOriginalYear, arg.OriginalYear, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setAlbumPendingReleaseMBID = `-- name: SetAlbumPendingReleaseMBID :exec
|
||||
UPDATE albums SET pending_release_mbid = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type SetAlbumPendingReleaseMBIDParams struct {
|
||||
PendingReleaseMbid sql.NullString
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SetAlbumPendingReleaseMBID(ctx context.Context, arg SetAlbumPendingReleaseMBIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAlbumPendingReleaseMBID, arg.PendingReleaseMbid, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertAlbum = `-- 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 id, name, artist_credit, artist_id, mbid, year, original_year, cover_art_id, pending_release_mbid
|
||||
`
|
||||
|
||||
type UpsertAlbumParams struct {
|
||||
Name string
|
||||
ArtistCredit string
|
||||
ArtistID sql.NullInt64
|
||||
Year sql.NullInt64
|
||||
CoverArtID sql.NullInt64
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertAlbum,
|
||||
arg.Name,
|
||||
arg.ArtistCredit,
|
||||
arg.ArtistID,
|
||||
arg.Year,
|
||||
arg.CoverArtID,
|
||||
)
|
||||
var i Album
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.ArtistCredit,
|
||||
&i.ArtistID,
|
||||
&i.Mbid,
|
||||
&i.Year,
|
||||
&i.OriginalYear,
|
||||
&i.CoverArtID,
|
||||
&i.PendingReleaseMbid,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: artist_credit.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const countArtistCreditReferences = `-- 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
|
||||
`
|
||||
|
||||
func (q *Queries) CountArtistCreditReferences(ctx context.Context, artistCreditID int64) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countArtistCreditReferences, artistCreditID)
|
||||
var total int64
|
||||
err := row.Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
const createArtistCredit = `-- name: CreateArtistCredit :one
|
||||
INSERT INTO artist_credit (text) VALUES (?)
|
||||
RETURNING id, text
|
||||
`
|
||||
|
||||
func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
|
||||
row := q.db.QueryRowContext(ctx, createArtistCredit, text)
|
||||
var i ArtistCredit
|
||||
err := row.Scan(&i.ID, &i.Text)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAllArtistCredits = `-- name: DeleteAllArtistCredits :exec
|
||||
DELETE FROM artist_credit
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAllArtistCredits(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAllArtistCredits)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
|
||||
DELETE FROM artist_credit
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteArtistCredit(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteArtistCredit, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getArtistCredit = `-- name: GetArtistCredit :one
|
||||
SELECT id, text FROM artist_credit
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetArtistCredit(ctx context.Context, id int64) (ArtistCredit, error) {
|
||||
row := q.db.QueryRowContext(ctx, getArtistCredit, id)
|
||||
var i ArtistCredit
|
||||
err := row.Scan(&i.ID, &i.Text)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getArtistCreditByText = `-- name: GetArtistCreditByText :one
|
||||
SELECT id, text FROM artist_credit
|
||||
WHERE text = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetArtistCreditByText(ctx context.Context, text string) (ArtistCredit, error) {
|
||||
row := q.db.QueryRowContext(ctx, getArtistCreditByText, text)
|
||||
var i ArtistCredit
|
||||
err := row.Scan(&i.ID, &i.Text)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrphanedArtistCreditIDs = `-- name: GetOrphanedArtistCreditIDs :many
|
||||
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)
|
||||
`
|
||||
|
||||
// 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.
|
||||
func (q *Queries) GetOrphanedArtistCreditIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getOrphanedArtistCreditIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
|
||||
UPDATE artist_credit
|
||||
SET text = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateArtistCreditParams struct {
|
||||
Text string
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateArtistCredit(ctx context.Context, arg UpdateArtistCreditParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateArtistCredit, arg.Text, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertArtistCredit = `-- name: UpsertArtistCredit :one
|
||||
INSERT INTO artist_credit (text) VALUES (?)
|
||||
ON CONFLICT(text) DO UPDATE SET text = excluded.text
|
||||
RETURNING id, text
|
||||
`
|
||||
|
||||
func (q *Queries) UpsertArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertArtistCredit, text)
|
||||
var i ArtistCredit
|
||||
err := row.Scan(&i.ID, &i.Text)
|
||||
return i, err
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: artist_credit_artists.sql
|
||||
|
||||
package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createArtistCreditArtist = `-- name: CreateArtistCreditArtist :one
|
||||
INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)
|
||||
RETURNING id, artist_id, credit_id
|
||||
`
|
||||
|
||||
type CreateArtistCreditArtistParams struct {
|
||||
ArtistID int64
|
||||
CreditID int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtistCreditArtistParams) (ArtistCreditArtist, error) {
|
||||
row := q.db.QueryRowContext(ctx, createArtistCreditArtist, arg.ArtistID, arg.CreditID)
|
||||
var i ArtistCreditArtist
|
||||
err := row.Scan(&i.ID, &i.ArtistID, &i.CreditID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAllArtistCreditArtists = `-- name: DeleteAllArtistCreditArtists :exec
|
||||
DELETE FROM artist_credit_artist
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAllArtistCreditArtists(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAllArtistCreditArtists)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec
|
||||
DELETE FROM artist_credit_artist
|
||||
WHERE id =?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteArtistCreditArtist(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtist, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteArtistCreditArtistByCredit = `-- name: DeleteArtistCreditArtistByCredit :exec
|
||||
DELETE FROM artist_credit_artist
|
||||
WHERE credit_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteArtistCreditArtistByCredit(ctx context.Context, creditID int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtistByCredit, creditID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getArtistCreditArtist = `-- name: GetArtistCreditArtist :one
|
||||
SELECT id, artist_id, credit_id FROM artist_credit_artist
|
||||
WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetArtistCreditArtist(ctx context.Context, id int64) (ArtistCreditArtist, error) {
|
||||
row := q.db.QueryRowContext(ctx, getArtistCreditArtist, id)
|
||||
var i ArtistCreditArtist
|
||||
err := row.Scan(&i.ID, &i.ArtistID, &i.CreditID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateArtistCreditArtist = `-- name: UpdateArtistCreditArtist :exec
|
||||
UPDATE artist_credit_artist
|
||||
SET artist_id = ?, credit_id = ?
|
||||
WHERE id =?
|
||||
`
|
||||
|
||||
type UpdateArtistCreditArtistParams struct {
|
||||
ArtistID int64
|
||||
CreditID int64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateArtistCreditArtist(ctx context.Context, arg UpdateArtistCreditArtistParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateArtistCreditArtist, arg.ArtistID, arg.CreditID, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -7,20 +7,9 @@ package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createArtist = `-- name: CreateArtist :one
|
||||
INSERT INTO artists (name) VALUES (?)
|
||||
RETURNING id, name, mbid
|
||||
`
|
||||
|
||||
func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) {
|
||||
row := q.db.QueryRowContext(ctx, createArtist, name)
|
||||
var i Artist
|
||||
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAllArtists = `-- name: DeleteAllArtists :exec
|
||||
DELETE FROM artists
|
||||
`
|
||||
@@ -31,8 +20,7 @@ func (q *Queries) DeleteAllArtists(ctx context.Context) error {
|
||||
}
|
||||
|
||||
const deleteArtist = `-- name: DeleteArtist :exec
|
||||
DELETE FROM artists
|
||||
WHERE id = ?
|
||||
DELETE FROM artists WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
||||
@@ -43,56 +31,17 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
||||
const getAlbumArtists = `-- 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
|
||||
`
|
||||
|
||||
func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumArtists)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Artist
|
||||
for rows.Next() {
|
||||
var i Artist
|
||||
if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAlbumArtistsByLibrary = `-- 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(?1 AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY a.name
|
||||
`
|
||||
|
||||
func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) ([]Artist, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumArtistsByLibrary, libraryID)
|
||||
func (q *Queries) GetAlbumArtists(ctx context.Context, libraryID int64) ([]Artist, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAlbumArtists, libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,8 +64,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64)
|
||||
}
|
||||
|
||||
const getAllArtists = `-- name: GetAllArtists :many
|
||||
SELECT id, name, mbid FROM artists
|
||||
ORDER BY name
|
||||
SELECT id, name, mbid FROM artists ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
|
||||
@@ -143,8 +91,7 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
|
||||
}
|
||||
|
||||
const getArtist = `-- name: GetArtist :one
|
||||
SELECT id, name, mbid FROM artists
|
||||
WHERE id = ? LIMIT 1
|
||||
SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
||||
@@ -154,9 +101,28 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getArtistByFilePath = `-- 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
|
||||
`
|
||||
|
||||
type GetArtistByFilePathRow struct {
|
||||
ArtistName string
|
||||
ArtistMbid string
|
||||
}
|
||||
|
||||
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
|
||||
var i GetArtistByFilePathRow
|
||||
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getArtistByName = `-- name: GetArtistByName :one
|
||||
SELECT id, name, mbid FROM artists
|
||||
WHERE name = ? LIMIT 1
|
||||
SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
|
||||
@@ -166,18 +132,15 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many
|
||||
SELECT a.id FROM artists a
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
|
||||
)
|
||||
const getUnreferencedArtistIDs = `-- name: GetUnreferencedArtistIDs :many
|
||||
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)
|
||||
`
|
||||
|
||||
// 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.
|
||||
func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs)
|
||||
// Artists no file and no album points at any more.
|
||||
func (q *Queries) GetUnreferencedArtistIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUnreferencedArtistIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -199,30 +162,42 @@ func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateArtist = `-- name: UpdateArtist :exec
|
||||
UPDATE artists
|
||||
SET name = ?
|
||||
WHERE id = ?
|
||||
const setArtistMBID = `-- name: SetArtistMBID :exec
|
||||
UPDATE artists SET mbid = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdateArtistParams struct {
|
||||
Name string
|
||||
type SetArtistMBIDParams struct {
|
||||
Mbid sql.NullString
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
|
||||
func (q *Queries) SetArtistMBID(ctx context.Context, arg SetArtistMBIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setArtistMBID, arg.Mbid, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertArtist = `-- name: UpsertArtist :one
|
||||
INSERT INTO artists (name) VALUES (?)
|
||||
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||
|
||||
INSERT INTO artists (name, mbid) VALUES (?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
mbid = COALESCE(excluded.mbid, artists.mbid)
|
||||
RETURNING id, name, mbid
|
||||
`
|
||||
|
||||
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertArtist, name)
|
||||
type UpsertArtistParams struct {
|
||||
Name string
|
||||
Mbid sql.NullString
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (q *Queries) UpsertArtist(ctx context.Context, arg UpsertArtistParams) (Artist, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertArtist, arg.Name, arg.Mbid)
|
||||
var i Artist
|
||||
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
||||
return i, err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,36 +7,9 @@ package sqlcgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const countGenreReferences = `-- name: CountGenreReferences :one
|
||||
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countGenreReferences, genreID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
|
||||
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
|
||||
VALUES (?, ?)
|
||||
`
|
||||
|
||||
type CreateRecordingGenreParams struct {
|
||||
RecordingID int64
|
||||
GenreID int64
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteAllGenres = `-- name: DeleteAllGenres :exec
|
||||
DELETE FROM genres
|
||||
`
|
||||
@@ -46,12 +19,12 @@ func (q *Queries) DeleteAllGenres(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec
|
||||
DELETE FROM recording_genres
|
||||
const deleteFileGenres = `-- name: DeleteFileGenres :exec
|
||||
DELETE FROM file_genres WHERE audio_file_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteAllRecordingGenres)
|
||||
func (q *Queries) DeleteFileGenres(ctx context.Context, audioFileID int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteFileGenres, audioFileID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -64,20 +37,12 @@ func (q *Queries) DeleteGenre(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec
|
||||
DELETE FROM recording_genres
|
||||
WHERE recording_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAllGenresWithCounts = `-- 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(?1 AS INTEGER), 0), af.library_id)
|
||||
GROUP BY g.id, g.name
|
||||
ORDER BY g.name
|
||||
`
|
||||
@@ -87,8 +52,8 @@ type GetAllGenresWithCountsRow struct {
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts)
|
||||
func (q *Queries) GetAllGenresWithCounts(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts, libraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -110,35 +75,25 @@ func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWit
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getAllGenresWithCountsByLibrary = `-- 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
|
||||
const getGenreNamesByFile = `-- name: GetGenreNamesByFile :many
|
||||
SELECT g.name FROM genres g
|
||||
JOIN file_genres fg ON fg.genre_id = g.id
|
||||
WHERE fg.audio_file_id = ?
|
||||
`
|
||||
|
||||
type GetAllGenresWithCountsByLibraryRow struct {
|
||||
Name string
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsByLibraryRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCountsByLibrary, libraryID)
|
||||
func (q *Queries) GetGenreNamesByFile(ctx context.Context, audioFileID int64) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGenreNamesByFile, audioFileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetAllGenresWithCountsByLibraryRow
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var i GetAllGenresWithCountsByLibraryRow
|
||||
if err := rows.Scan(&i.Name, &i.TrackCount); err != nil {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, name)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -149,45 +104,42 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFilePathsByGenres = `-- 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 (/*SLICE:genre_names*/?)
|
||||
ORDER BY r.name
|
||||
const getGenreNamesByFilePaths = `-- name: GetGenreNamesByFilePaths :many
|
||||
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 (/*SLICE:paths*/?)
|
||||
`
|
||||
|
||||
type GetFilePathsByGenresRow struct {
|
||||
GenreName string
|
||||
FilePath string
|
||||
type GetGenreNamesByFilePathsRow struct {
|
||||
FilePath string
|
||||
Name string
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) {
|
||||
query := getFilePathsByGenres
|
||||
// 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.
|
||||
func (q *Queries) GetGenreNamesByFilePaths(ctx context.Context, paths []string) ([]GetGenreNamesByFilePathsRow, error) {
|
||||
query := getGenreNamesByFilePaths
|
||||
var queryParams []interface{}
|
||||
if len(genreNames) > 0 {
|
||||
for _, v := range genreNames {
|
||||
if len(paths) > 0 {
|
||||
for _, v := range paths {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1)
|
||||
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
||||
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
|
||||
}
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetFilePathsByGenresRow
|
||||
var items []GetGenreNamesByFilePathsRow
|
||||
for rows.Next() {
|
||||
var i GetFilePathsByGenresRow
|
||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
||||
var i GetGenreNamesByFilePathsRow
|
||||
if err := rows.Scan(&i.FilePath, &i.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -201,51 +153,24 @@ func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getFilePathsByGenresByLibrary = `-- 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 (/*SLICE:genre_names*/?)
|
||||
AND af.library_id = ?
|
||||
ORDER BY r.name
|
||||
const getUnusedGenreIDs = `-- name: GetUnusedGenreIDs :many
|
||||
SELECT id FROM genres g
|
||||
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id)
|
||||
`
|
||||
|
||||
type GetFilePathsByGenresByLibraryParams struct {
|
||||
GenreNames []string
|
||||
LibraryID int64
|
||||
}
|
||||
|
||||
type GetFilePathsByGenresByLibraryRow struct {
|
||||
GenreName string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
|
||||
query := getFilePathsByGenresByLibrary
|
||||
var queryParams []interface{}
|
||||
if len(arg.GenreNames) > 0 {
|
||||
for _, v := range arg.GenreNames {
|
||||
queryParams = append(queryParams, v)
|
||||
}
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
|
||||
} else {
|
||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
||||
}
|
||||
queryParams = append(queryParams, arg.LibraryID)
|
||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||
func (q *Queries) GetUnusedGenreIDs(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUnusedGenreIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetFilePathsByGenresByLibraryRow
|
||||
var items []int64
|
||||
for rows.Next() {
|
||||
var i GetFilePathsByGenresByLibraryRow
|
||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
@@ -256,247 +181,32 @@ func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFile
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
||||
SELECT g.id, g.name
|
||||
FROM genres g
|
||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
||||
WHERE rg.recording_id = ?
|
||||
const linkFileGenre = `-- name: LinkFileGenre :exec
|
||||
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?)
|
||||
`
|
||||
|
||||
func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Genre
|
||||
for rows.Next() {
|
||||
var i Genre
|
||||
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
type LinkFileGenreParams struct {
|
||||
AudioFileID int64
|
||||
GenreID int64
|
||||
}
|
||||
|
||||
const getTracksByGenre = `-- 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
|
||||
`
|
||||
|
||||
type GetTracksByGenreRow 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
|
||||
}
|
||||
|
||||
func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTracksByGenre, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTracksByGenreRow
|
||||
for rows.Next() {
|
||||
var i GetTracksByGenreRow
|
||||
if err := rows.Scan(
|
||||
&i.FilePath,
|
||||
&i.LengthMilliseconds,
|
||||
&i.Title,
|
||||
&i.ArtistName,
|
||||
&i.TrackNumber,
|
||||
&i.DiscNumber,
|
||||
&i.Album,
|
||||
&i.Genre,
|
||||
&i.Year,
|
||||
&i.Composer,
|
||||
&i.FileType,
|
||||
&i.SampleRate,
|
||||
&i.BitDepth,
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTracksByGenreByLibrary = `-- 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
|
||||
`
|
||||
|
||||
type GetTracksByGenreByLibraryParams struct {
|
||||
Name string
|
||||
LibraryID int64
|
||||
}
|
||||
|
||||
type GetTracksByGenreByLibraryRow 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
|
||||
}
|
||||
|
||||
func (q *Queries) GetTracksByGenreByLibrary(ctx context.Context, arg GetTracksByGenreByLibraryParams) ([]GetTracksByGenreByLibraryRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTracksByGenreByLibrary, arg.Name, arg.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTracksByGenreByLibraryRow
|
||||
for rows.Next() {
|
||||
var i GetTracksByGenreByLibraryRow
|
||||
if err := rows.Scan(
|
||||
&i.FilePath,
|
||||
&i.LengthMilliseconds,
|
||||
&i.Title,
|
||||
&i.ArtistName,
|
||||
&i.TrackNumber,
|
||||
&i.DiscNumber,
|
||||
&i.Album,
|
||||
&i.Genre,
|
||||
&i.Year,
|
||||
&i.Composer,
|
||||
&i.FileType,
|
||||
&i.SampleRate,
|
||||
&i.BitDepth,
|
||||
&i.Channels,
|
||||
&i.Bitrate,
|
||||
&i.FileSize,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
func (q *Queries) LinkFileGenre(ctx context.Context, arg LinkFileGenreParams) error {
|
||||
_, err := q.db.ExecContext(ctx, linkFileGenre, arg.AudioFileID, arg.GenreID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertGenre = `-- 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 id, name
|
||||
`
|
||||
|
||||
// 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.
|
||||
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertGenre, name)
|
||||
var i Genre
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
|
||||
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
|
||||
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()
|
||||
@@ -54,9 +54,8 @@ func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenrePa
|
||||
|
||||
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :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
|
||||
HAVING SUM(af.play_count) > 0
|
||||
ORDER BY SUM(af.play_count) DESC
|
||||
@@ -89,9 +88,8 @@ func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int6
|
||||
|
||||
const homeRandomAlbums = `-- 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 ?
|
||||
@@ -122,9 +120,8 @@ func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, e
|
||||
|
||||
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :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 MAX(af.id) DESC
|
||||
LIMIT ?
|
||||
@@ -159,9 +156,8 @@ func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]i
|
||||
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :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
|
||||
WHERE af.last_played IS NOT NULL
|
||||
GROUP BY rg.id
|
||||
ORDER BY MAX(af.last_played) DESC
|
||||
@@ -172,7 +168,7 @@ LIMIT ?
|
||||
//
|
||||
// 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.
|
||||
// Albums with the most recent play, newest first.
|
||||
@@ -201,9 +197,8 @@ func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]
|
||||
|
||||
const homeStaleAlbums = `-- name: HomeStaleAlbums :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
|
||||
WHERE af.last_played IS NOT NULL
|
||||
GROUP BY rg.id
|
||||
HAVING MAX(af.last_played) < datetime('now', ?)
|
||||
@@ -242,14 +237,12 @@ func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams
|
||||
|
||||
const homeTopArtists = `-- name: HomeTopArtists :many
|
||||
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 ?
|
||||
@@ -288,10 +281,10 @@ func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArt
|
||||
const homeTopGenres = `-- name: HomeTopGenres :many
|
||||
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
|
||||
@@ -331,9 +324,8 @@ func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenr
|
||||
|
||||
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :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
|
||||
HAVING SUM(af.play_count) = 0
|
||||
ORDER BY RANDOM()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user