Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85768dc489 | ||
|
|
1a221a40d3 | ||
|
|
0821deb877 | ||
|
|
31ada14111 | ||
|
|
20139394f3 | ||
|
|
eb139cf872 | ||
|
|
ae82fd2233 | ||
|
|
3c3197df4b | ||
|
|
e16bd245bd | ||
|
|
887a9324b4 | ||
|
|
fcb484ead5 | ||
|
|
48de41cd69 | ||
|
|
66a6ee63ab | ||
|
|
10660c8168 | ||
|
|
441b67daaa | ||
|
|
026f26bdf6 | ||
|
|
73dc80bdc9 | ||
|
|
760021ea5a | ||
|
|
63ec068add | ||
|
|
a2ff0aed4c | ||
|
|
12e75ee24c | ||
|
|
792e87298b | ||
|
|
266e7032dd | ||
|
|
d6b48fb3ac | ||
|
|
3bf27e3fd5 | ||
|
|
185eb1b125 | ||
|
|
b3556d825c | ||
|
|
bf4f352117 | ||
|
|
1062b7c0bc | ||
|
|
48abecb830 | ||
|
|
e1c07438e9 | ||
|
|
6e563f3846 | ||
|
|
186f6a5839 | ||
|
|
590a0d86dd | ||
|
|
36af7090d9 | ||
|
|
3e142f8c35 | ||
|
|
3d375adab1 | ||
|
|
e3d492e130 | ||
|
|
e6f30b6e43 | ||
|
|
351798fd66 | ||
|
|
40984f6086 | ||
|
|
786d9c6110 | ||
|
|
0019310ca4 | ||
|
|
1940cb548f | ||
|
|
37e3373db9 | ||
|
|
8d5d8af297 | ||
|
|
9ce79ee416 | ||
|
|
b3a0814f24 | ||
|
|
2c576fa1e8 | ||
|
|
544dbdb4db | ||
|
|
087eb77875 | ||
|
|
6fb7b5ea11 | ||
|
|
369810e06b | ||
|
|
e51cb13662 | ||
|
|
3d65da0529 | ||
|
|
52cbef27c4 | ||
|
|
c03c0b8ec4 | ||
|
|
8c48105ca3 | ||
|
|
1c4d6ca9a1 | ||
|
|
6bf832a4ba | ||
|
|
4f8257ef72 | ||
|
|
b505959934 | ||
|
|
d0250a2133 | ||
|
|
de2b324e20 | ||
|
|
2c78b58207 | ||
|
|
a9852c18a0 | ||
|
|
409bfd5e89 | ||
|
|
0eeef6048e | ||
|
|
4fc0cdeab7 | ||
|
|
eb059a3d71 | ||
|
|
dcabec8b1d | ||
|
|
b3737d30af | ||
|
|
0bfa2136be | ||
|
|
b1cdef8769 | ||
|
|
d661836347 | ||
|
|
28eecf0a97 | ||
|
|
e8690476bd | ||
|
|
7e0be8fa30 | ||
|
|
1b05dde382 | ||
|
|
29299d17da | ||
|
|
57fbbdf0d2 | ||
|
|
df2e9ea777 | ||
|
|
c99c8efa11 | ||
|
|
904786b941 | ||
|
|
b6651310ea | ||
|
|
da38b865fc | ||
|
|
ced537ecf2 | ||
|
|
e14a34fccf | ||
|
|
78576b8da9 | ||
|
|
01706c6053 | ||
|
|
f7dc76c955 | ||
|
|
ed975019dc | ||
|
|
0c7f34ab90 | ||
|
|
a7a33527c4 | ||
|
|
0c6ca72cf1 | ||
|
|
68468e5378 | ||
|
|
6fbb62730d | ||
|
|
48b37f6301 | ||
|
|
66182f82cd | ||
|
|
18aba34c08 | ||
|
|
b98840ee37 | ||
|
|
dd17a4d8eb | ||
|
|
e7748f1fd5 | ||
|
|
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,6 +32,7 @@ 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:
|
||||
@@ -26,8 +42,9 @@ jobs:
|
||||
# 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 \
|
||||
base-devel git go nodejs pnpm curl sudo jq \
|
||||
webkitgtk-6.0 gtk4 alsa-lib
|
||||
|
||||
- name: Create unprivileged build user
|
||||
@@ -36,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
|
||||
@@ -54,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
|
||||
@@ -67,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
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
@@ -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"
|
||||
+24
-6
@@ -63,10 +63,28 @@ bin/
|
||||
# packaging tasks that depend on it. A derived file with one source.
|
||||
build/linux/yellowjacket.desktop
|
||||
|
||||
# `wails3 task common:update:build-assets` regenerates the mobile trees
|
||||
# whether or not anything asks for them. This is a desktop player and
|
||||
# cannot target iOS/Android, so their includes: entries are dropped from
|
||||
# Taskfile.yml and the trees themselves are not carried — ignored rather
|
||||
# than deleted-and-rediscovered on every asset refresh.
|
||||
# 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/
|
||||
build/android/
|
||||
|
||||
# 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
|
||||
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
# Work log
|
||||
|
||||
Temporal memory: what happened and what's next. Structure lives in
|
||||
`CLAUDE.md`, operational instructions in `.pi/skills/yellowjacket-dev/`,
|
||||
measured discoveries in `.planning/NOTES.md`. Don't duplicate those here.
|
||||
|
||||
## Current state
|
||||
|
||||
Plan 005 (agent development harness) is **complete — all seven
|
||||
phases**. Everything from phase 1 onward is still **uncommitted**: one
|
||||
large but coherent working-tree diff, nothing pushed.
|
||||
|
||||
All four tiers verified green from a cold, cleaned state:
|
||||
`make ui-test` 313 passed, `make lint` 0 issues × 3 configurations,
|
||||
`make test` green × 3 passes, `make e2e` 19 passed. Both CI jobs
|
||||
verified green in a bare `ubuntu:24.04` container, including 19/19 on
|
||||
WebKit.
|
||||
|
||||
**Committed and pushed** as `5ca6cad` (the harness) + `ccacd67` (a CI
|
||||
fix), and **green on the real runner**: job `check` ~4 min, job `e2e`
|
||||
~3 min with 19/19 chromium *and* 19/19 webkit. One commit rather than
|
||||
seven because the working tree was the end state, not per-phase
|
||||
snapshots — `Makefile`, `CLAUDE.md` and `lefthook.yml` are touched by
|
||||
nearly every phase, so a split would have been fabricated history.
|
||||
|
||||
Still unverified, because no run has failed yet: the
|
||||
`actions/upload-artifact` step (`continue-on-error`, so it cannot mask
|
||||
a real failure) and whether pnpm honours `npm_config_store_dir` for
|
||||
store caching. Worth checking the next time a spec legitimately fails.
|
||||
|
||||
- [ ] `gitea_ci`'s `job_logs` returns 404 on Gitea 1.27.1 — the endpoint
|
||||
is not exposed. Logs come from the VPS instead: `zstdcat` the file
|
||||
under `gitea/actions_log/<owner>/<repo>/<xx>/<task_id>.log.zst`,
|
||||
and note `zstdcat` is not in the gitea container, so
|
||||
`docker cp` it out first. Job status is `action_run_job.status`
|
||||
(1 success, 2 failure, 4 skipped, 5 waiting, 6 running).
|
||||
Probably belongs in the `gitea` skill, not here.
|
||||
|
||||
Open items deliberately not fixed: WAV tags are write-only
|
||||
(`TestWAVTagsAreNotReadableYet`), `themeStore.loadFromBackend`'s failure
|
||||
handler cannot recover, `backend/playlist` has no CRUD suite.
|
||||
|
||||
## Log
|
||||
|
||||
### 2026-08-11 — cold skill run, then phase 7 (CI)
|
||||
|
||||
- **Followed the skill cold first**, as the last session asked. It
|
||||
works: app up from a wiped `.dev/`, an undocumented flow driven
|
||||
(queue panel + shuffle, asserted on `QueueModeChanged`), stopped —
|
||||
~1 minute, no dead ends. One real config bug: `outputDir` in
|
||||
`.playwright/cli.config.json` resolves against **cwd**, not the
|
||||
config file's directory (only `initScript` does that), so snapshots
|
||||
were landing above the repo and a *stale* one from the previous
|
||||
session answered `ls -t` instead. That cost a DOM walk to disprove a
|
||||
regression that did not exist. Four smaller doc gaps fixed
|
||||
(`sandbox-seed` already runs `testdata`; `ui-setup`/`e2e-setup` were
|
||||
undocumented prerequisites; `snapshot` prints a path; `dev-stop`
|
||||
leaves the browser open), plus `dev-headless.sh`'s own banner, which
|
||||
was suggesting the bare `window.go` call its next paragraph warns
|
||||
against.
|
||||
- **Built both CI jobs as container scripts before writing any YAML**,
|
||||
then transcribed the YAML back out and re-ran it to prove the
|
||||
transcription. Push-and-see is a bad loop on a self-hosted runner.
|
||||
- **It found a real bug immediately**: `make lint` omitted
|
||||
`webkit2_41` on all three passes, so it was linting configurations
|
||||
nothing builds. Invisible on Arch (which still ships
|
||||
`webkit2gtk-4.0.pc`), fatal on Ubuntu 24.04. Tag sets now match
|
||||
`make test`.
|
||||
- **Both open decisions settled by measurement**: ALSA `null` PCM for
|
||||
audio (no daemon; the elapsed clock really advances), dead-address
|
||||
stub for the explore artifact (and setting it for the *app* run, not
|
||||
just seeding, is worth 8x on suite wall clock). **WebKit is a
|
||||
required step** — it had never been run anywhere, so one throwaway
|
||||
container run replaced a coin flip with 19/19 at +11 s.
|
||||
|
||||
### 2026-08-10 — phase 6, pi affordances
|
||||
|
||||
- Added `.pi/skills/yellowjacket-dev/` as a directory rather than a flat
|
||||
file: only the description is always in context, so `SKILL.md` stays
|
||||
short enough that reading it whole is never a decision, and the deeper
|
||||
material sits in `references/{harness,fixtures,ui-tier,schema-change}.md`.
|
||||
- Settled the CLAUDE.md-vs-skill split **grammatically, not topically**,
|
||||
because a topical split is what rots — every new fact gets two
|
||||
plausible homes. Three docs, three tenses: NOTES.md is past
|
||||
(measured, dated, append-only), CLAUDE.md is present (what the system
|
||||
is), the skill is imperative (what to run). A new paragraph's tense
|
||||
decides where it goes.
|
||||
- The five gotchas (binding timeouts, first-run wizard, `pkill -f`,
|
||||
seeds-by-running, WebKit-is-CI-only) went **inline in SKILL.md**, not
|
||||
into a reference: you need them before the failure, not after.
|
||||
- Trimmed CLAUDE.md's "Fixtures and the headless harness" section by
|
||||
about half — the command sequences and gotchas it was carrying are now
|
||||
the skill's, and leaving both would have created exactly the duplicate
|
||||
description this repo has a standing rule against.
|
||||
- Added `make skill-check` / `scripts/skill-check.sh` + a pre-commit
|
||||
hook: every command in `.pi/**/*.md` must be a real `make` target, so
|
||||
the Makefile stays the source of truth for invocation and a renamed
|
||||
target fails a commit instead of misleading an agent later. Verified
|
||||
it fails (it caught its own not-yet-created target) and passes.
|
||||
- Added the `/e2e` prompt template: promoting a hand-driven
|
||||
`playwright-cli` session into a spec is a transcription with four
|
||||
fixed substitutions (refs → testids, sleeps → `waitForEvent`, raw
|
||||
`window.go` → `callBinding`, short fixture → `LONG_TRACK`), plus three
|
||||
runs — pass, pass again, pass after a DB restore — because the usual
|
||||
failure is a spec depending on state the hand-driving left behind.
|
||||
- One shell trap: under `set -euo pipefail`, `x="$(make -pqRr | …)"`
|
||||
fails the whole assignment, because `make -q` exits non-zero when a
|
||||
target is out of date and `pipefail` propagates it.
|
||||
|
||||
### Earlier
|
||||
|
||||
Phases 1–5 of plan 005: fixture generator and manifest, headless launch
|
||||
and seeds, the event bridge + `data-testid` pass + `backend/testctl` +
|
||||
`e2e/`, the Vitest component tier + `make bindings-check`, and the
|
||||
`events.Emit` wrapper with its in-process service-event tests. Recaps
|
||||
and the five "verified end to end" blocks are in
|
||||
`.planning/plans/active/005-agent-development-harness.md`; the lessons
|
||||
are in `.planning/NOTES.md`.
|
||||
@@ -96,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
|
||||
@@ -151,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
|
||||
@@ -426,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.
|
||||
|
||||
@@ -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 ./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.
|
||||
|
||||
+1129
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ reviews. Nothing was changed.
|
||||
|
||||
Findings below are numbered `H-n` (hands-on) and cross-reference the
|
||||
static reports where they overlap. The reconciliation plan built from
|
||||
all four files is `.planning/plans/pending/007-ui-reconciliation.md`.
|
||||
all four files is `.planning/plans/completed/007-ui-reconciliation.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
# 012 — What we ask the network for, and what we already had
|
||||
|
||||
> **Completed.** Findings 1, 2 and 4 shipped. Finding 3 — the bound-but-uncalled methods — is now **#86**.
|
||||
|
||||
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
|
||||
configs), `tsc` and 752 Vitest tests pass; **not driven against the
|
||||
real app**, so the numbers below are read off the code, not measured.
|
||||
@@ -0,0 +1,639 @@
|
||||
# 013 — The database audit
|
||||
|
||||
**Status:** **complete** (2026-08-16). R1–R10 landed, the album page
|
||||
that prompted the audit with them, and the one part of R5 that ships
|
||||
*in the artifact* — a per-release-group track denominator — landed as
|
||||
plan 014.
|
||||
The audit below is unchanged from when it was written — the measurements
|
||||
describe the *old* shape and are the reason for the new one.
|
||||
**Branch:** none
|
||||
**Created:** 2026-08-15
|
||||
**Supersedes:** the four-part album-page fix sketched in conversation
|
||||
(it survives, reduced, as R1 and R3 below)
|
||||
**Related:** 010 (owned albums offline), 011 (owned artists'
|
||||
discography), 012 (API call audit), 002 (data lifecycle)
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
Every number here is measured against the **real 25,966-track library**
|
||||
at `~/.local/share/yellowjacket/yj.db` (copied read-only), not against
|
||||
a fixture and not inferred from the code. Where a claim rests on a
|
||||
capability rather than a count — "sqlc can do X" — it was executed, not
|
||||
assumed.
|
||||
|
||||
The brief: *efficiency and simplicity — the minimum required to achieve
|
||||
our featureset*, with fewer lines and a smaller database as evidence
|
||||
rather than as the goal. Two named sources of confusion to resolve:
|
||||
**local versus remote** versions of a thing, **files versus tracks**,
|
||||
and **indexed versus live** lookups. One added constraint: **avoid
|
||||
hitting APIs by storing intelligently, without a ridiculous base
|
||||
install.**
|
||||
|
||||
---
|
||||
|
||||
## The measurements
|
||||
|
||||
### The database is 1.00 GB, and 78% of it is one table
|
||||
|
||||
| object | size | rows |
|
||||
|---|---|---|
|
||||
| `explore_index` | 383 MB | 2,052,200 |
|
||||
| its five indexes + `UNIQUE(mbid)` | 395 MB | — |
|
||||
| its two FTS tables | 85 MB | 2,052,200 + 96,451 |
|
||||
| `recordings` | 38 MB (27 MB of it lyrics) | 26,778 |
|
||||
| `lyrics_index` | 18 MB | 24,294 |
|
||||
| `artist_metadata` | 12 MB | 7,673 |
|
||||
| `http_cache` | 9 MB | 2,930 |
|
||||
| `audio_files` | 5 MB | 25,966 |
|
||||
| everything else | < 10 MB | — |
|
||||
|
||||
The local library — the part that is *the user's* — is about 50 MB.
|
||||
The catalog and its indexes are 780 MB.
|
||||
|
||||
### Inside `explore_index`, half the bytes are three text columns
|
||||
|
||||
| column | bytes | note |
|
||||
|---|---|---|
|
||||
| `mbid` | 70 MB | 36-char text; 16 bytes as a blob |
|
||||
| `artist_mbid` | 70 MB | same, and it is a foreign key in disguise |
|
||||
| `caa_release_mbid` | 62 MB | same |
|
||||
| `entity_type` | 18 MB | three distinct values, stored as words |
|
||||
| `title` / `artist_name` / `release_name` | 74 MB | real data |
|
||||
|
||||
Five columns are declared, shipped in the artifact, selected in every
|
||||
query, and **empty**: `aliases` (0 rows), `sort_name` (0),
|
||||
`disambiguation` (0), `country` (69 rows of 2.05 M), `artist_type`
|
||||
(72). `aliases` is additionally a column in *both* FTS tables, so the
|
||||
tokenizer indexes nothing, twice.
|
||||
|
||||
### Two 50 MB indexes have a `WHERE` clause that excludes 0.3% of rows
|
||||
|
||||
`idx_explore_title_lower` (53 MB) and `idx_explore_artist_lower`
|
||||
(48 MB) are `WHERE popularity > 0`. 2,046,645 of 2,052,200 rows satisfy
|
||||
that. They are full indexes wearing a partial index's clothes, and they
|
||||
exist to serve one exact-match tier (`ExactMatches`,
|
||||
`searchindex.go:1298`) that the champion FTS — 96,451 rows, 2 MB —
|
||||
already covers the popular half of.
|
||||
|
||||
### The local library models many-to-many relationships that are all 1:1
|
||||
|
||||
| claim | measured |
|
||||
|---|---|
|
||||
| recordings with more than one file | **0** |
|
||||
| recordings in more than one release group | **0** |
|
||||
| artist credits with more than one artist | **3** of 2,823 |
|
||||
| files sharing a recording | **0** |
|
||||
|
||||
`recordings` (26,778) is one row per file. `release_group_recordings`
|
||||
(26,778) is one row per file. `artist_credit` (2,823) and
|
||||
`artist_credit_artist` (2,826) differ by three.
|
||||
|
||||
### …and it leaks rows that outlive the files
|
||||
|
||||
| orphan | count |
|
||||
|---|---|
|
||||
| `recordings` with no `audio_files` row | **812** (218 carry MBIDs) |
|
||||
| `release_groups` with no file underneath | **216** |
|
||||
| `artists` credited on no file | **260** |
|
||||
| `explore_index` rows flagged **`in_library` with no file behind them** | **129** recordings, 2 release groups, 1 artist |
|
||||
|
||||
That last row is the bug reported today, in the user's own data.
|
||||
|
||||
### The query surface
|
||||
|
||||
| surface | count |
|
||||
|---|---|
|
||||
| sqlc queries | 235 (7,850 generated Go lines) |
|
||||
| raw SQL call sites outside sqlc | 188 |
|
||||
| bound IPC methods | 272 |
|
||||
| `X` / `XByLibrary` query twins | 14 (8 of them exposed as separate bindings) |
|
||||
| copies of the "one row per file with its metadata" projection | **9**, plus the view that already defines it |
|
||||
|
||||
`mapTrackRow` takes **22 positional arguments** and is called from 9
|
||||
places, because each duplicated query generates its own row struct.
|
||||
|
||||
### The data directory is 8.5 GB — the database is the small part
|
||||
|
||||
| path | size | of which |
|
||||
|---|---|---|
|
||||
| `artist-images/` | 5.4 GB | **4,125 MB is candidate images no code path reads**; 1,222 MB is primaries + tiers for **5,770 artists** in a library with **1,301** |
|
||||
| `covers/` | 1.4 GB | **1,134 MB is originals**; all three rendered tiers together are 110 MB |
|
||||
| `ffmpeg/` | 283 MB | bundled binary |
|
||||
| `yj.db` | 1.0 GB | above |
|
||||
| `yj.db.bak` + `.bak.20260309` | 452 MB | nothing deletes these |
|
||||
| art caches (`cover-art-cache`, `artist-image-cache`) | 81 MB | catalog art, fine |
|
||||
|
||||
The 4.1 GB of unreachable artist candidates is the bug `CLAUDE.md`
|
||||
records as fixed; this install still carries it, so **the janitor jobs
|
||||
have never run here**. Worth confirming they run at all before
|
||||
declaring that one closed.
|
||||
|
||||
---
|
||||
|
||||
## The diagnosis
|
||||
|
||||
Everything below is downstream of one thing.
|
||||
|
||||
**There are three different notions of "a track" in this app, and the
|
||||
code keeps asking the wrong one.**
|
||||
|
||||
1. **A file** — a row in `audio_files`. The only thing that is
|
||||
unambiguously *yours*: it has a path, it plays.
|
||||
2. **A local entity** — a row in `recordings` / `release_groups` /
|
||||
`artists`. Created by a scan *from* a file, but with an independent
|
||||
lifetime: nothing deletes it when the file goes, and retagging a
|
||||
file **creates a new one and abandons the old**
|
||||
(`library.go:1722` repoints `audio_files.recording_id` at a fresh
|
||||
recording; `pruneOrphanedMetadata` only runs on the scan's
|
||||
*deleted-file* branch, `library.go:982`). This is where the 812
|
||||
orphans come from — and autotagging is the machine that makes them.
|
||||
3. **A catalog entity** — a row in `explore_index`, downloaded, global,
|
||||
identical for every user.
|
||||
|
||||
"Is this mine" is asked of **(2)** almost everywhere, and answered by
|
||||
**(1)** whenever the user actually does something:
|
||||
|
||||
- `LibraryMBIDIndex.CheckMBIDs` (`librarymbid.go:64`) is literally
|
||||
`SELECT mbid FROM recordings WHERE mbid IN (…)`. It sets `inLibrary`
|
||||
on every catalog tracklist.
|
||||
- `pruneStaleLocalCrossReferences` (`searchindex.go:2480`) clears
|
||||
`explore_index.in_library` when the **`recordings` row** disappears —
|
||||
not when the file does. Hence 129 phantom "you own this" rows.
|
||||
- `albumLibraryStatus()` in `explore-album-details.ts` ORs four claims
|
||||
of decreasing confidence, none of which is "a file exists".
|
||||
- But `GetFilePathsByRecordingMBIDs`, which every *action* goes
|
||||
through, joins `audio_files`. It is the only one that tells the
|
||||
truth.
|
||||
|
||||
So a retagged file leaves behind a recording carrying the **old** MBID;
|
||||
the catalog matches that MBID; the row renders owned, undimmed, with a
|
||||
Play button; and every action on it fails with "could not be found in
|
||||
your library" — on a fully-tagged library. The user's instinct that the
|
||||
check is fragile is correct, and the fragility is not the live lookup.
|
||||
**The live lookup is the only part that is right.**
|
||||
|
||||
The same confusion explains "files vs tracks" and "local vs remote":
|
||||
tables (2) exist to be a local mirror of the catalog's shape, so a
|
||||
"track" is sometimes a file, sometimes a mirror row, sometimes a
|
||||
catalog row, and the three are joined by MBID — a key that **two of the
|
||||
three can lack or lie about**.
|
||||
|
||||
---
|
||||
|
||||
## Findings and recommendations
|
||||
|
||||
### R1 — Ownership is "a file exists". Say it once, in SQL.
|
||||
|
||||
*Cheap, immediate, and it fixes the reported bug.*
|
||||
|
||||
- `CheckMBIDs`' `recordings` and `release_groups` branches gain a join
|
||||
to `audio_files`. (`artists` too, via credit.)
|
||||
- `pruneStaleLocalCrossReferences` tests for a file, not for a local
|
||||
row.
|
||||
- `pruneOrphanedMetadata` runs after the retag path as well as the
|
||||
delete path — or, better, is deleted along with the tables that need
|
||||
it (R2).
|
||||
- One-shot cleanup of the 812/216/260 existing orphans at open.
|
||||
|
||||
**Effect:** 129 lying rows in this library become honest; the class
|
||||
cannot recur while (2) exists.
|
||||
|
||||
### R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one
|
||||
|
||||
*The big one. It is what makes R1 structural rather than a patch.*
|
||||
|
||||
The local model imitates MusicBrainz's normalization — `artist_credit`
|
||||
is an MB concept — for a dataset in which **every relationship it
|
||||
models is 1:1** (measured above). The cost of that imitation:
|
||||
|
||||
- 5 tables (`recordings`, `release_group_recordings`, `artist_credit`,
|
||||
`artist_credit_artist`, `release_to_rg` — the last has **0 rows** and
|
||||
no schema-file writer) and ~12 indexes.
|
||||
- A 6-way join in every read, including a `MIN(release_group_id)`
|
||||
subquery repeated in **11 places** to undo a many-to-many that never
|
||||
happens, and a "first credited artist" subquery in **9** to undo
|
||||
another (the row-multiplication bug class documented at length in
|
||||
`CLAUDE.md`, which serves 3 rows).
|
||||
- An orphan-cleanup subsystem (`GetOrphaned*IDs` ×3, `Count*References`
|
||||
×2, `pruneOrphanedMetadata`) that exists only because these rows can
|
||||
outlive their file — and which does not actually work (812 orphans).
|
||||
- The entire phantom-ownership class above.
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```
|
||||
audio_files id, path, library_id, …, title, track_no, disc_no, year,
|
||||
composer, comment, artist_credit TEXT, artist_id→artists,
|
||||
album_id→albums, recording_mbid, modified_at, …
|
||||
albums id, name, artist_id, mbid, year, original_year,
|
||||
cover_art_id, total_tracks… (genuinely many files→1)
|
||||
artists id, name, mbid (genuinely many→1)
|
||||
genres + file_genres (genuinely many↔many:
|
||||
107k rows / 26k files)
|
||||
```
|
||||
|
||||
`artist_credit` survives as **text on the file** (display: "A feat.
|
||||
B") plus `artist_id` (the primary artist, for grouping) — which is
|
||||
everything the UI does with it today, minus the join that multiplies
|
||||
rows.
|
||||
|
||||
**Effect:** a row exists iff a file exists, so R1 becomes a foreign key
|
||||
rather than a rule anyone can forget. Removes 5 tables, ~12 indexes,
|
||||
~30 sqlc queries, the orphan subsystem, both repeated subqueries, and
|
||||
the `AUTOMATIC COVERING INDEX` SQLite builds on every library load.
|
||||
Estimated −1,500 to −2,500 lines across `backend/library`,
|
||||
`backend/database/sql/*` and `sqlcgen`.
|
||||
|
||||
**Cost:** one real migration of user data (not an `ADD COLUMN`), and it
|
||||
touches autotag, tagwriter, playlist matching and the explore xref.
|
||||
This is the item to sequence carefully; everything else is independent
|
||||
of it.
|
||||
|
||||
### R3 — One projection, one row type, one mapper
|
||||
|
||||
`track_metadata` (the view) already *is* the canonical "one row per
|
||||
file" definition, and **only the raw-SQL search paths use it**
|
||||
(`search.go`, `lyrics_search.go`). Every sqlc query re-implements it —
|
||||
9 copies, which have already drifted: the view prefers
|
||||
`rg.original_year` for `year`, `GetAllTracksWithFullMetadata` uses
|
||||
`r.year`. The same library shows a different year depending on which
|
||||
screen you are on.
|
||||
|
||||
**Verified, not assumed:** sqlc generates cleanly against the view —
|
||||
`SELECT * FROM track_metadata WHERE …` yields one `TrackMetadatum`
|
||||
struct with correct types (run during this audit).
|
||||
|
||||
And the 14 `X`/`XByLibrary` twins collapse into one query each:
|
||||
|
||||
```sql
|
||||
WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
|
||||
OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))
|
||||
```
|
||||
|
||||
**Measured cost of the collapse: none.** Scoped-with-OR 23 ms, scoped
|
||||
direct 21 ms, unscoped 145 ms over the full 26k rows.
|
||||
|
||||
**Effect:** −14 queries, −8 bindings, −8 frontend branches, 9 row
|
||||
structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly −2,000
|
||||
generated lines and −300 hand-written ones, and the year inconsistency
|
||||
cannot exist.
|
||||
|
||||
### R4 — Put `explore_index` on a diet (~200 MB, no feature loss)
|
||||
|
||||
| change | saved |
|
||||
|---|---|
|
||||
| `mbid`, `artist_mbid`, `caa_release_mbid` as 16-byte blobs | ~110 MB in the table |
|
||||
| …and the same keys in `UNIQUE(mbid)` (99 MB) and `idx_explore_index_artist_mbid` (131 MB) | ~70–100 MB |
|
||||
| `entity_type` → INTEGER | 18 MB + index |
|
||||
| drop `aliases`, `sort_name`, `disambiguation` (0 rows); reconsider `country`/`artist_type` (69/72 rows) | small bytes, real clarity — and one fewer empty FTS column |
|
||||
| make the two `LOWER()` indexes' partial predicate *mean* something (`popularity >= championPopThreshold OR in_library`), or retire the tier onto the champion FTS | up to 101 MB |
|
||||
|
||||
Better still for `artist_mbid`: it is a foreign key spelled as text.
|
||||
An integer reference to the artist row is 8 bytes instead of 36 and
|
||||
makes the 131 MB index a fraction of its size.
|
||||
|
||||
**Also worth separating:** `in_library`, `local_*_id`, `is_similar` and
|
||||
`discog_fetched` are *personalization* stored inside the *shipped
|
||||
catalog* table, which is why the artifact import has to merge by
|
||||
explicit column list and why `artist_enrichment` had to become its own
|
||||
table for exactly this reason. Measured: `in_library` and
|
||||
`local_*_id IS NOT NULL` agree on **every one of 2,052,200 rows** —
|
||||
they are the same fact stored twice. A `library_xref(mbid, kind,
|
||||
local_id)` side table would make the catalog table purely the artifact
|
||||
and delete the merge-by-column-list rule.
|
||||
|
||||
### R5 — Ask the network less, without a bigger install
|
||||
|
||||
Present state (from `musicbrainz.go:17-27`): search 24 h, **entity 7
|
||||
days**, releases 90 days. MusicBrainz entity data changes on the order
|
||||
of *never* for the fields we read, and 251 of 2,930 cache rows are
|
||||
already expired on this install — so a fully-populated artist page
|
||||
re-fetches itself weekly, forever.
|
||||
|
||||
- **Raise `cacheTTLEntity` to a year** (or drop expiry and revalidate
|
||||
in the background). Cost: bytes already stored. Benefit: the
|
||||
steady-state network cost of browsing your own library goes to
|
||||
roughly zero.
|
||||
- **Ship a per-release-group `total_tracks` in the artifact.** 010
|
||||
correctly rejects shipping *tracklists* (the per-artist track budget
|
||||
would truncate them, and "Play 7 of 9" for a twelve-track album is a
|
||||
confident lie). But the **denominator** is one small integer per
|
||||
release group — 400,677 rows, ~2 bytes — and it is exactly what
|
||||
`albumLibraryStatus`/`ownership()` needs to say complete /
|
||||
incomplete / unknown for a catalog album with no local tags. Tiny,
|
||||
honest, and it does not depend on coverage.
|
||||
- **Keep 010's per-user backfill** for the tracklists themselves; this
|
||||
does not replace it, it shrinks what it has to cover.
|
||||
- `http_cache` has no size bound and no vacuum beyond expiry. Give it a
|
||||
ceiling.
|
||||
|
||||
### R6 — The 5.3 GB on disk that no feature needs
|
||||
|
||||
- **4,125 MB of artist candidate images** that nothing reads (the
|
||||
documented bug — but the janitors have not run on this install;
|
||||
verify they run at all).
|
||||
- Artist images exist for **5,770 artists** in a **1,301-artist**
|
||||
library. Fetching art for artists you do not own is the same
|
||||
"prefetch everything" instinct as the discography backfill 011
|
||||
corrected.
|
||||
- **1,134 MB of cover originals** versus 110 MB for all three rendered
|
||||
tiers. Nothing renders the original; and it is re-derivable from the
|
||||
audio file itself, which is on disk by definition. Keep `_lg` as the
|
||||
largest and drop originals — that is 1.1 GB with no visible change.
|
||||
- `yj.db.bak` (394 MB) and `yj.db.bak.20260309` (58 MB) accumulate with
|
||||
nothing to clean them.
|
||||
|
||||
This is the largest single win available and it does not touch the
|
||||
schema.
|
||||
|
||||
### R7 — Redundant indexes and dead columns
|
||||
|
||||
Five indexes are prefixes of an existing UNIQUE/PK and can be dropped
|
||||
outright (they cost write time on every insert):
|
||||
|
||||
`idx_recording_genres_recording_id` ⊂ `UNIQUE(recording_id, genre_id)` ·
|
||||
`idx_similar_artist_map_source` ⊂ `PK(source, similar)` ·
|
||||
`idx_artist_credit_artist_artist_id` ⊂ `UNIQUE(artist_id, credit_id)` ·
|
||||
`idx_artist_metadata_mbid` ⊂ `PK(mbid, source)` ·
|
||||
`idx_artist_images_mbid` ⊂ `UNIQUE(artist_mbid, source, source_url)`.
|
||||
|
||||
Dead data:
|
||||
|
||||
- **`recordings.genre`** — populated on 25,619 rows at every scan and
|
||||
**read by nothing**. Every genre read goes through
|
||||
`recording_genres` + `genres`. Write-only column.
|
||||
- **`release_groups.total_tracks` / `total_discs`** — 0 rows populated;
|
||||
the feature that needed them put the number on
|
||||
`release_group_recordings` instead.
|
||||
- **`release_to_rg`** — 0 rows, no writer in any schema file.
|
||||
- `libraries.sql` carries a doc comment about `download_requests`,
|
||||
pasted from another file. Small, but it is the kind of drift the
|
||||
two-file schema rule exists to catch.
|
||||
|
||||
### R8 — One genuine N+1
|
||||
|
||||
`mixCandidates` (`explore/mix.go:181`) issues
|
||||
`GetGenreNamesByFilePath` **per candidate path**, inside a loop over
|
||||
similar artists, inside a loop over seed artists. Twenty seeds × twenty
|
||||
similar × thirty paths is 12,000 single-row queries for one mix. It is
|
||||
one query with an `IN` clause, or one query for the whole weighted set.
|
||||
(`mixSeedProfile` above it is the same shape, bounded by seed size.)
|
||||
|
||||
Nothing else in the tree matches this pattern — a scan of every query
|
||||
issued inside a loop turned up 72 candidates and this is the only real
|
||||
one.
|
||||
|
||||
### R9 — The IPC surface has internals in it
|
||||
|
||||
Bound and reachable from the frontend today: `AcquirePipelineLock`,
|
||||
`ReleasePipelineLock`, `SetJobRegistry`, `SetScanHooks`,
|
||||
`SetRescanHooks`, `SetRemovalHooks`, `MusicBrainz`, `CAALimiter`,
|
||||
`PopulateLocalCrossReferences`. v3's generator binds every exported
|
||||
method; these want to be unexported or moved off the service type.
|
||||
Free lines, and one less way to wedge the app from a console.
|
||||
|
||||
### R10 — The test DB is not the shape production runs
|
||||
|
||||
`NewTestDB` shares one in-memory connection and leaves `readDB` nil, so
|
||||
`reader()` returns the writer. That is why the read-pool write bug
|
||||
(documented in `CLAUDE.md`) reached a user, and why
|
||||
`TestNoWritesOnTheReadPool` had to be a tree-walk instead of a test.
|
||||
Giving the test DB two handles over one shared in-memory file would let
|
||||
that be an ordinary test.
|
||||
|
||||
---
|
||||
|
||||
## What I recommend leaving alone
|
||||
|
||||
- **The download subsystem** (requests / downloads / items). Three
|
||||
tables, clean lifetimes, well argued in the schema comments. The
|
||||
`download_wants` table in this install is the pre-rename name; the
|
||||
rename migration will clear it on next launch.
|
||||
- **The champion FTS.** 96k rows, 2 MB, a real latency tier.
|
||||
- **The dual write/read handle**, WAL, and the persist-writer queues.
|
||||
These are recent, measured, and correct.
|
||||
- **File paths as the frontend's identity for a track.** Integer ids
|
||||
would be cheaper over IPC, but `CLAUDE.md`'s argument (an index goes
|
||||
stale on re-sort/refilter, a path does not) is right, and the cost is
|
||||
bounded.
|
||||
- **Storing lyrics locally** (27 MB + 18 MB index for 24k tracks). That
|
||||
is the API-avoidance trade working exactly as intended.
|
||||
|
||||
---
|
||||
|
||||
## What landed (2026-08-15 / 16)
|
||||
|
||||
### The third pass: the album page, which is where the report came from
|
||||
|
||||
The audit started from a user report — a fully-tagged library saying
|
||||
"not in your library", on hover rather than on click — and R1 fixed the
|
||||
half of that which lives in SQL. The other half was the page: ownership
|
||||
was four claims OR'd into a tick, and the context menu asked the backend
|
||||
per row, as the menu opened.
|
||||
|
||||
`explore-album-details` now resolves the displayed tracklist's file
|
||||
paths **once**, from `updated()`, into one `filePaths` map that the
|
||||
badge, the Play count, the dimmed rows and every menu item read. The
|
||||
synthesised local tracks carry their own `FilePath`, so a library album
|
||||
costs no lookup at all; a catalog tracklist costs one batched
|
||||
`GetFilePathsByRecordingMBIDs`. `catalogScope()` no longer returns
|
||||
`'library'` here — that was the second complaint in the same report, and
|
||||
the artist page keeps it because a library-only *artist* really is
|
||||
missing sections.
|
||||
|
||||
Two bugs fell out of doing it this way, and neither is the one that was
|
||||
reported:
|
||||
|
||||
- The render loop. Guarding the lookup on `filePaths` (answered) rather
|
||||
than on `askedFor` (asked) re-requests every *unowned* MBID forever,
|
||||
because an unowned MBID never lands in the map.
|
||||
- "No release data available" over a tracklist held in memory.
|
||||
`loadLocalTracks` rebuilt the version list only when catalog releases
|
||||
existed, but the "Your Library" entry is synthesised *from* the local
|
||||
tracks — so the no-releases case was the one case it skipped. Nothing
|
||||
caught it because the old ownership check answered from the local
|
||||
album id and never needed the tracklist to exist.
|
||||
|
||||
### The second pass: R5–R10
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| the two exact-match indexes | 101 MB | **3 MB** (predicate narrowed to the champion set; plan unchanged, measured) |
|
||||
| cover art on disk | original + 3 tiers | **3 tiers** — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it |
|
||||
| browsed artist art | 90-day expiry, no ceiling | expiry **plus a 256 MB budget**, oldest evicted first; owned artists never in it |
|
||||
| MusicBrainz entity TTL | 7 days | **1 year**, with a 128 MB ceiling on the response cache |
|
||||
| redundant indexes | 5 | **0** (3 dropped here, 2 went with their tables) |
|
||||
| internal methods on the IPC surface | 24 | **0** (`//wails:ignore`; 272 → 248 bound methods) |
|
||||
| test DB | one handle, `readDB` nil | **two handles**, the shape production runs |
|
||||
|
||||
The catalog line is R4, finished the day after: MBIDs stored as 16 raw
|
||||
bytes and entity types as codes, measured by converting the real
|
||||
2,052,200-row catalog through the shipped schema. It needed no artifact
|
||||
rebuild — the importer asks the artifact which encoding it carries and
|
||||
converts the older text form on the way in. Plan 014 has the detail.
|
||||
|
||||
Two of those repaid immediately. Giving the test database its own
|
||||
read pool **caught three tests writing through it** on the first run —
|
||||
the exact bug class that reached a user as "attempt to write a readonly
|
||||
database" and that `TestNoWritesOnTheReadPool` had to walk the source
|
||||
tree to find. And the artist-image sweep's own test turned out to seed
|
||||
an `artists` row with no file and call it owned: the phantom this whole
|
||||
audit is about, sitting in the fixture of the test that guards it.
|
||||
|
||||
**One finding in this audit was wrong.** `aliases`, `sort_name`,
|
||||
`disambiguation`, `country` and `artist_type` are not dead columns. They
|
||||
are empty on that install because the artist-enrichment pass had barely
|
||||
run (which is finding 011's subject), but `indexOneArtist` writes all
|
||||
five, and `aliases` is an FTS column that makes an artist findable by
|
||||
alias. They stay.
|
||||
|
||||
### The first pass: R2, carrying R1 and R3
|
||||
|
||||
R2 shipped with R1 and R3 inside it, because the collapse made them
|
||||
free rather than separate work. No migration: fresh installs only, by
|
||||
the user's decision, so `sql/migrations/` went with it.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| local tables | 9 | 5 (`audio_files`, `albums`, `artists`, `genres`, `file_genres`) |
|
||||
| sqlc queries | 235 | 185 |
|
||||
| generated Go | 7,850 | 6,023 |
|
||||
| bound IPC methods | 272 | 264 |
|
||||
| copies of the track projection | 9 + the view | the view |
|
||||
| `X`/`XByLibrary` query twins | 14 | 0 |
|
||||
| migration files + runner | 7 + ~120 lines | 0 |
|
||||
| **net** | | **−5,070 lines** across 122 files |
|
||||
|
||||
Gone: `recordings`, `release_group_recordings`, `artist_credit`,
|
||||
`artist_credit_artist`, `pruneOrphanedMetadata`'s four sweeps,
|
||||
`RemoveLibrary`'s eight, `mapTrackRow`'s 22 positional arguments, and
|
||||
340 lines of `tagwriter/dbsync.go` that existed to relink and then
|
||||
un-orphan those tables.
|
||||
|
||||
Ownership is now a file in every one of the places that used to ask a
|
||||
metadata table: `CheckMBIDs`, `collectLibraryEntities`,
|
||||
`pruneStaleLocalCrossReferences` and `GetFilePathsByRecordingMBIDs`.
|
||||
|
||||
Three things found on the way, each written down where it can be hit
|
||||
again (`CLAUDE.md`, `references/schema-change.md`):
|
||||
|
||||
- **sqlc's parameter rewriter is byte-offset based**, so one em dash in
|
||||
a *query* comment corrupts generation into `SELECid`.
|
||||
- **`sqlc.slice` and `sqlc.arg` do not compose** — slice expansion
|
||||
renumbers, so `GetFilePathsByAlbums([1,2], 0)` read album id 2 as the
|
||||
library id. Caught by a test, not by a type.
|
||||
- **`release_to_rg` looked dead and was not**: 0 rows on any ordinary
|
||||
install, because only a local `indexbuild` fills it, and the daily
|
||||
incremental refresh reads it. Restored.
|
||||
|
||||
Verified: `make lint` (3 configurations), `go test ./...` plus the
|
||||
`indexbuild` and `dev` tag passes, `tsc --noEmit`, `make ui-test`
|
||||
(768), and a new end-to-end test that scans the real fixture library
|
||||
and asserts no row outlives its file
|
||||
(`TestScan_FixtureLibraryLeavesNothingBehind`).
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
**Revised 2026-08-15, after the compatibility constraint was lifted:**
|
||||
breaking changes are acceptable and the schema may be squashed. That
|
||||
inverts the order — R2 was last only because of the migration, and it
|
||||
*subsumes* R1 (ownership becomes a foreign key) and reshapes R3 (the
|
||||
projection is defined over the new tables). Doing R1 and R3 against the
|
||||
old shape first would be work thrown away.
|
||||
|
||||
1. **R2** — the schema collapse, with the rebuild below. It carries R1
|
||||
and R3 with it.
|
||||
2. **R6** — reclaim the 5.3 GB on disk; confirm the janitors run.
|
||||
3. **R7 / R9 / R8 / R10** — the small correctness and hygiene items.
|
||||
4. **R4** — the `explore_index` diet. Artifact rebuild + format bump.
|
||||
5. **R5** — cache TTLs (trivial) and the shipped denominator (rides
|
||||
along with R4's artifact change).
|
||||
|
||||
### "Break everything" has a floor, and it is not the schema
|
||||
|
||||
Reshaping tables freely is fine. **Dropping the database is not**, and
|
||||
the numbers say so — a wipe-and-rescan would destroy:
|
||||
|
||||
| | count | why a rescan does not restore it |
|
||||
|---|---|---|
|
||||
| files marked `user_confirmed` | **25,014** | the user's autotag review decisions |
|
||||
| reviewed tagging folders (`confirmed`/`skipped`) | **2,109** | ditto, plus every `skipped` becomes pending again |
|
||||
| rows in `recordings.lyrics` | **24,294** | an unknown share came from **LRCLIB**, not from tags — re-fetching them is precisely the API traffic we are trying to avoid |
|
||||
| playlists / playlist tracks | 22 / 1,917 | `Authored`; nothing else has them |
|
||||
|
||||
So the change ships as a **one-shot in-place rebuild**: create the new
|
||||
tables, `INSERT … SELECT` across, drop the old ones, in a single
|
||||
transaction at open. Seconds on 26k rows, ~40 lines of SQL, no
|
||||
migration *chain* and no rollback path — which is the freedom that was
|
||||
actually being asked for. `sql/migrations/` gets squashed into
|
||||
`sql/schemas/` at the same time (`NOTES.md` already blesses this
|
||||
pre-1.0).
|
||||
|
||||
### Two tables are classified as one Kind and hold another
|
||||
|
||||
`backend/datamap` already encodes what is safe to lose (`Owned` and
|
||||
`Derived` rebuild from the files; `Cache` is expensive; `Authored` is
|
||||
irreplaceable). The audit found two places where the *column* disagrees
|
||||
with the *table's* entry, which is exactly why a wipe looked cheaper
|
||||
than it is:
|
||||
|
||||
- **`audio_files.tag_status`** — the table is `Owned` (a projection of
|
||||
the files), but `user_confirmed` / `user_skipped_permanent` are
|
||||
**`Authored`**: a decision the user made that exists nowhere else.
|
||||
- **`recordings.lyrics`** — the table is `Owned`, but lyrics fetched by
|
||||
the LRCLIB backfill are **`Cache`**, and nothing records which of the
|
||||
24,294 rows came from a tag and which from the network.
|
||||
|
||||
The new schema fixes both by construction: lyrics move to their own
|
||||
MBID-keyed table with a `source` column (so they survive any rebuild of
|
||||
the owned tables, and the provenance question becomes answerable), and
|
||||
`tag_status`' authored values are carried across explicitly rather than
|
||||
recomputed.
|
||||
|
||||
**Expected outcome if all of it lands:** database ~1.0 GB → ~0.75 GB,
|
||||
data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go
|
||||
7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters —
|
||||
one definition of "this is mine" that a file either satisfies or does
|
||||
not.
|
||||
|
||||
## The open questions, answered
|
||||
|
||||
1. **R2's migration** — the user's call, and it was "just assume this
|
||||
new version will only be installed by a new user". So there is no
|
||||
in-place rebuild and no chain: `sql/schemas/` is the whole
|
||||
description. An existing `YJ_HOME` does not open (its `audio_files`
|
||||
has `recording_id` and none of the tag columns, and
|
||||
`CREATE TABLE IF NOT EXISTS` cannot add them) — delete and rescan,
|
||||
and rebuild any seed with `make sandbox-seed`.
|
||||
2. **R4's artifact format** — no break was needed. The importer asks
|
||||
the artifact what it carries rather than trusting a version, so the
|
||||
published text-form artifact still imports. Plan 014 has it.
|
||||
3. **Yes, the janitors run.** `Runner.Start` calls `RunDue` immediately
|
||||
and `lastRun` is in-memory, so every launch runs everything due.
|
||||
The 4.1 GB survived because `OrphanedArtistImagesJob` joined a bare
|
||||
MBID onto a *sharded* directory — deleting the rows and leaving the
|
||||
files, which is worse than not running — and because
|
||||
`StrayArtistImageFilesJob` did not exist. Both are fixed; it was a
|
||||
bug report, not a cleanup.
|
||||
|
||||
## Measured on the finished refactor
|
||||
|
||||
| | expected | actual |
|
||||
|---|---|---|
|
||||
| sqlc queries | ~180 | **185** |
|
||||
| generated Go | ~5,000 | **6,024** |
|
||||
| bound methods | ~255 | **248** |
|
||||
| `explore_index` + indexes | — | **780 MB → 405 MB** |
|
||||
|
||||
## The one recommendation not taken
|
||||
|
||||
R4's "better still" for `artist_mbid`: an integer reference to the
|
||||
artist row (8 bytes) rather than the 16 raw bytes it now stores. It is
|
||||
a further ~30 MB on `idx_explore_index_artist_mbid`, and the reason to
|
||||
stop short is that the *artifact* carries MBIDs and not local ids, so
|
||||
the import would have to resolve every row against a table it is in the
|
||||
middle of filling. Worth its own argument, not a footnote to this one.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 014 — The catalog's compact encoding, and the denominator it owed
|
||||
|
||||
**Status:** **complete** (2026-08-16). The encoding landed first; the
|
||||
per-release-group `total_tracks` denominator landed with the album page
|
||||
that spends it.
|
||||
**Branch:** none
|
||||
**Created:** 2026-08-16
|
||||
**Depends on:** nothing
|
||||
**Related:** 013 (the database audit, which measured all of this), 010
|
||||
(owned albums offline), 001 (ship core index)
|
||||
|
||||
---
|
||||
|
||||
## The encoding
|
||||
|
||||
Measured on the real 2,052,200-row catalog, converting it through the
|
||||
shipped schema (not a projection):
|
||||
|
||||
| object | before | after |
|
||||
|---|---|---|
|
||||
| `explore_index` | 383 MB | **242 MB** |
|
||||
| `idx_explore_index_artist_mbid` | 131 MB | **65 MB** |
|
||||
| `UNIQUE(mbid)` | 99 MB | **54 MB** |
|
||||
| `idx_explore_index_entity_pop` | 47 MB | **28 MB** |
|
||||
| `idx_explore_caa_release` | 17 MB | **11 MB** |
|
||||
| the two `LOWER()` indexes | 101 MB | **3 MB** (013) |
|
||||
| **total** | **780 MB** | **405 MB** |
|
||||
|
||||
Every row converted with the `CHECK` constraints live, which is also a
|
||||
result: no MBID in a real 2 M-row catalog is malformed.
|
||||
|
||||
**No format bump, and no rebuilt artifact needed.** The importer asks
|
||||
the artifact what encoding it carries (`typeof(mbid)`) and converts on
|
||||
the way in if it is the old text form, so the artifact already
|
||||
published keeps working and the exporter switches whenever CI next
|
||||
runs. That is strictly better than the version negotiation this plan
|
||||
originally proposed.
|
||||
|
||||
The silent-failure risk the plan was written around was handled by
|
||||
making the failure loud instead of by avoiding the change: a `CHECK` on
|
||||
the column turns a stringly write into an error at the insert, the
|
||||
22-column projection became one constant and one scanner instead of
|
||||
four copies, and `TestStoredEncodingRoundTrips` sweeps every read path
|
||||
in the package. It found one real bug on its first run — the artifact
|
||||
probe was asking the read pool, where the attached artifact does not
|
||||
exist.
|
||||
|
||||
## The denominator
|
||||
|
||||
`total_tracks` on `explore_index`, ~2 bytes across 400,677 release
|
||||
groups. It makes "do I have all of this" answerable offline for an
|
||||
album whose **files declared no total**, which is a great deal of any
|
||||
untagged library and the one thing `GetAlbumCompleteness` cannot answer
|
||||
from tags. 010 rightly rejected shipping whole tracklists — the
|
||||
per-artist track budget truncates them, and a truncated tracklist is a
|
||||
confident lie about which tracks exist. A denominator has no such
|
||||
problem, and the album page spends it as one: the numerator stays
|
||||
local (distinct track numbers on disk), only the denominator is
|
||||
borrowed, and only where the tags have none.
|
||||
|
||||
Four things about it are load-bearing.
|
||||
|
||||
**It is counted before the popularity filter.** `cmd/indexbuild` counts
|
||||
the canonical dump's rows per kept release, which is that release's
|
||||
track count because the dump carries one row per recording per
|
||||
canonical release. Counting the *kept* recordings instead would say
|
||||
"9" about a twelve-track album whose other three nobody has played —
|
||||
worse than saying nothing, and the same class of lie as the truncated
|
||||
tracklist. `TestDumpImportEndToEnd` has an unplayed track on a fixture
|
||||
album for exactly this: three tracks in the total, two indexed as
|
||||
recordings.
|
||||
|
||||
**Zero means "the catalog does not say"**, which is the same third
|
||||
state the local answer already has. An album neither side can total
|
||||
wears no ring rather than a wrong one.
|
||||
|
||||
**Adding a column to the importer's SELECT is how you break every
|
||||
artifact already published.** `artifactHasTotals()` asks the attached
|
||||
artifact whether the column exists, the same way and on the same handle
|
||||
as `artifactStoresText()`, and selects a literal `0` when it does not.
|
||||
Verified by forcing the probe true: the older shape then fails with
|
||||
`no such column: total_tracks`, which is what a shipped build would
|
||||
have done to a file nobody can re-cut retroactively.
|
||||
|
||||
**A test seeder that binds the upsert's parameters by hand is not
|
||||
"breaking where the app breaks".** Three of them did, on the argument
|
||||
that a schema change should fail the tests in the same place — and what
|
||||
it actually produced was `missing argument with index 25`, three files
|
||||
at a time, for a column none of them cares about. They go through
|
||||
`upsertBatch` now, which is the one writer, and keep the property they
|
||||
wanted: a field written to the wrong column still fails there.
|
||||
|
||||
## Done when
|
||||
|
||||
- [x] `GetAlbumCompleteness`'s gap is answerable for a catalog album the
|
||||
library has no tags for, with no network call.
|
||||
- [x] The artifact grows by less than a megabyte (~800 kB at 400,677
|
||||
release groups).
|
||||
- [x] An artifact published before the column still imports.
|
||||
@@ -0,0 +1,385 @@
|
||||
# 015 — Android release pipeline
|
||||
|
||||
> **Completed.** The pipeline ships a signed APK from CI on every `v*` tag; `docs/android-release.md` is its operating document.
|
||||
|
||||
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||
generic package registry so Obtainium can poll a plain URL.
|
||||
|
||||
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
|
||||
`android:` job has been through the failure modes already. Most of what
|
||||
follows is a transcription of that job onto this repo's conventions;
|
||||
where it differs, the difference is argued.
|
||||
|
||||
## What this is not
|
||||
|
||||
**This ships a pipeline, not a usable Android music player.** The
|
||||
success criterion is a signed, installable APK that launches — not an
|
||||
app anyone would want. Explicitly out of scope, and each is real:
|
||||
|
||||
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
|
||||
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
|
||||
linux` file is in the build and MPRIS will look for a session bus that
|
||||
does not exist. It compiles; it will error at runtime.
|
||||
- `backend/system` resolves XDG paths. Android has no XDG.
|
||||
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
|
||||
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
|
||||
measured minimum, a transport bar. None of that is a phone layout.
|
||||
- The library scanner walks a filesystem Android does not grant.
|
||||
|
||||
Those are the *next* plan, if there is one. Conflating them with this one
|
||||
is how a build pipeline takes six weeks.
|
||||
|
||||
## Phase 0 — the gate [DONE 2026-08-16]
|
||||
|
||||
**Passed, further than asked.** No source changes were needed; a full
|
||||
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
|
||||
the environment and four non-obvious findings are in
|
||||
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
|
||||
library in the release APK's phone ABI, fixed here.
|
||||
|
||||
**It also installs and launches on an emulator, and then exits.** One
|
||||
line stops it: `backend/system/buildUserDirPath` switches on
|
||||
`runtime.GOOS` and Android takes the `default:` branch returning
|
||||
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
|
||||
after the JNI bridge comes up. That is the *first* thing that stops it,
|
||||
not the only one — see the "not this" section above, all of which is
|
||||
still true and still out of scope.
|
||||
|
||||
The emulator tier that found it is now part of the harness:
|
||||
`scripts/android-emulator.sh`, the `make android-*` targets, and
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
|
||||
because the failure is invisible in all three places anyone would look
|
||||
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
|
||||
the app fast enough that `pidof` always answers — so the tier's
|
||||
assertion is "same pid after N seconds", not "it started".
|
||||
|
||||
Original phase 0 text follows, kept because its reasoning is what the
|
||||
later phases rest on.
|
||||
|
||||
|
||||
Everything downstream is wasted if the c-shared link fails. Establish it
|
||||
by hand, locally, before writing a line of YAML.
|
||||
|
||||
Already established, by probe rather than by assumption:
|
||||
|
||||
```
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
|
||||
```
|
||||
|
||||
compiles the entire tree. Exactly two packages fail, and both fail only
|
||||
because their Android implementation is cgo:
|
||||
|
||||
- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe**
|
||||
C++ backend. Oto supports Android natively; there is no Java audio
|
||||
glue to write.
|
||||
- `wails/v3/pkg/application` — `mobile_features_android.go` needs the
|
||||
JNI bridge.
|
||||
|
||||
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
|
||||
every `backend/` package are clean. **No source changes are known to be
|
||||
required**, which is the single most surprising finding here and the
|
||||
reason this plan is worth doing at all.
|
||||
|
||||
What Phase 0 must actually verify:
|
||||
|
||||
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
|
||||
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
|
||||
this build.
|
||||
2. Generate the scaffolding (Phase 1) and run
|
||||
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
|
||||
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
|
||||
exists and is an ARM64 shared object.
|
||||
4. Repeat for `amd64` (the emulator ABI).
|
||||
|
||||
**If the link fails, stop and re-plan.** The likely culprits, in order:
|
||||
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
|
||||
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
|
||||
combined with the generated `main_android.gen.go` overlay.
|
||||
|
||||
Deliverable: a note in `.planning/NOTES.md` recording the exact command
|
||||
and the NDK version that produced a `.so`, or the reason it cannot.
|
||||
|
||||
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
|
||||
|
||||
Done as a side-effect of phase 0, which could not run without it. One
|
||||
correction to the text below: **step 1 is wrong.** `update
|
||||
build-assets` does not generate the android tree (NOTES.md explains);
|
||||
it was generated with `generate build-assets` into a scratch dir and
|
||||
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
|
||||
were done as written.
|
||||
|
||||
|
||||
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
|
||||
entry was dropped from `Taskfile.yml` during plan 009. That was correct
|
||||
when nothing could target Android and is what has to be undone.
|
||||
|
||||
1. `wails3 task common:update:build-assets` — beta.8 embeds
|
||||
`internal/commands/build_assets/android/`, so this generates the tree.
|
||||
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
|
||||
to a comment so the asymmetry is explained rather than looking like an
|
||||
oversight.
|
||||
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
|
||||
`includes:`.
|
||||
4. **Gitignore the tree's own output**, or the repo grows a few hundred
|
||||
Gradle intermediates. ljos has exactly this problem — its
|
||||
`app/build/android/app/build/**` is committed. Ignore:
|
||||
- `build/android/app/build/`
|
||||
- `build/android/app/src/main/jniLibs/`
|
||||
- `build/android/overlay.json` and `build/android/gen/`
|
||||
5. `make build-prod` and `make test` still pass — the new include must
|
||||
not perturb the desktop path.
|
||||
|
||||
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
|
||||
section already says `build/`'s platform metadata is regenerated from
|
||||
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
|
||||
by hand. Extend that paragraph to name `build/android/app/build.gradle`
|
||||
specifically, because the loss is silent and the symptom (a debug-signed
|
||||
APK) appears months later as a failed update.
|
||||
|
||||
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
|
||||
|
||||
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
|
||||
beta.3: the release signing config already exists** and reads the four
|
||||
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
|
||||
phase was identity and versioning only. Verified end to end:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| package | `app.yellowjacket` (was `com.wails.app`) |
|
||||
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
|
||||
| label | `YellowJacket` |
|
||||
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
|
||||
| ABIs | arm64-v8a + x86_64, both production-stripped |
|
||||
|
||||
Installs and launches under the new identity. Still exits on the known
|
||||
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
|
||||
|
||||
Two things this phase learned that the text below did not know:
|
||||
|
||||
- **The identity has to be declared twice.** `applicationId` in
|
||||
`app/build.gradle` is what Gradle installs; `APP_ID` in
|
||||
`build/android/Taskfile.yml` is what every adb-driven task targets.
|
||||
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
|
||||
nothing in beta.8, verified with `--dry`. Both are set, each
|
||||
commented pointing at the other.
|
||||
- **The launcher activity is not under the applicationId.** It stays
|
||||
`com.wails.app.MainActivity` (the scaffold's Java package), so
|
||||
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
|
||||
the wrong package and fails. `scripts/android-emulator.sh` carries the
|
||||
fully-qualified name and a comment saying why.
|
||||
|
||||
The `keytool` PKCS12 note below was confirmed verbatim: given a
|
||||
`-keypass` differing from `-storepass` it prints "Different store and
|
||||
key passwords not supported for PKCS12 KeyStores. Ignoring
|
||||
user-specified -keypass value."
|
||||
|
||||
Original phase 2 text follows.
|
||||
|
||||
|
||||
Edit `build/android/app/build.gradle`, following ljos's, whose comments
|
||||
are worth reading before writing this:
|
||||
|
||||
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
|
||||
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
|
||||
Java package, not the app identity).
|
||||
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
|
||||
— **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
|
||||
parentheses to `versionCode` first, so the cast reads as
|
||||
`versionCode("1") as Integer`, which sets a String and then casts the
|
||||
setter's null return; Gradle fails the whole project with "Value is
|
||||
null" at that line.
|
||||
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
|
||||
- `abiFilters 'arm64-v8a', 'x86_64'`.
|
||||
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
|
||||
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
|
||||
back to the debug keystore only when no keystore is supplied.
|
||||
|
||||
**Android orders releases by an integer and refuses anything not greater
|
||||
than what is installed.** A hardcoded `versionCode 1` means the first
|
||||
install is the last: every later build is rejected as a downgrade and the
|
||||
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
|
||||
and patch stay under 100.
|
||||
|
||||
**Signing is not optional past the first install.** Android refuses to
|
||||
update an app whose signing key changed, and the debug keystore differs
|
||||
between every machine and every runner — so an unsigned CI build is a
|
||||
decision to reinstall by hand forever. The job must **refuse to build**
|
||||
without the keystore rather than quietly produce an APK that can never be
|
||||
updated.
|
||||
|
||||
There is **one password and two required secrets**. keytool has defaulted
|
||||
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
|
||||
cannot hold a separate key password — given `-keypass` it warns and
|
||||
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
|
||||
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
|
||||
cannot exist is how someone sets a wrong value and debugs Gradle at
|
||||
midnight.
|
||||
|
||||
Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task
|
||||
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
|
||||
documented target that does not exist, so document it only once it does.
|
||||
|
||||
## Phase 3 — the workflow [DONE 2026-08-16]
|
||||
|
||||
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
|
||||
the operating document its error messages point at (phase 4's
|
||||
documentation half; the secrets themselves still have to be created by
|
||||
hand — see the table there).
|
||||
|
||||
Three departures from the text below, all argued in the file:
|
||||
|
||||
- **No `continue-on-error`.** The plan inherited it from ljos, where
|
||||
the Android job shares a pipeline with a server deploy that must
|
||||
never go red over a phone build. Here it is standalone and can
|
||||
neither delay nor redden anything, so a release step that fails
|
||||
silently is strictly worse than one that fails visibly.
|
||||
- **No cached `wails3` binary.** The plan budgeted for ljos's
|
||||
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
|
||||
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
|
||||
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
|
||||
WebKit *dev* headers are still installed, because `go tool wails3`
|
||||
links them.
|
||||
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
|
||||
~700 MB a run.
|
||||
|
||||
Four publish-gates were added and each was checked against a real APK:
|
||||
both ABIs present, `versionCode` equal to the one derived from the tag,
|
||||
a non-empty artifact, and **not signed with the debug key** — verified
|
||||
by pointing the check at a deliberately debug-signed build, which it
|
||||
refused.
|
||||
|
||||
Rehearsed locally with the exact CI invocation
|
||||
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
|
||||
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
|
||||
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
|
||||
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
|
||||
|
||||
Original phase 3 text follows.
|
||||
|
||||
|
||||
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
|
||||
`ci.yml` runs on every branch push and is the workflow that gates; the
|
||||
runner is capacity 1, and a 45-minute Android build in it would put every
|
||||
push behind an SDK download.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
This is where the baseline genuinely diverges. ljos computes its version
|
||||
in CI (`scripts/next-version.sh`) and gates the Android job on
|
||||
`needs.release.outputs.version != ''`, with an `always()` whose absence
|
||||
would silently kill the manual path. **This repo has no release
|
||||
automation** — tags are pushed by hand and `homebrew-formula.yml` already
|
||||
keys on `v*`. So there is no `needs:`, no `always()`, and no status
|
||||
function to get wrong: the tag *is* the version, and a dispatch falls
|
||||
back to `git describe --tags --abbrev=0`.
|
||||
|
||||
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
|
||||
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
|
||||
needing node before any step has installed it):
|
||||
|
||||
```yaml
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||
```
|
||||
|
||||
The SDK path must be inside the runner's `valid_volumes` allowlist —
|
||||
a directory outside it makes the job **fail to start**, not silently skip
|
||||
the mount. `/cache/tool` is already allowed and already holds the Go
|
||||
toolchain `ci.yml` downloads.
|
||||
|
||||
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
|
||||
tag's other three workflows must not go red over a phone build, and a
|
||||
backstop because a wedged SDK download must not hold the only runner slot
|
||||
for hours.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
|
||||
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
|
||||
Android cross-build, which uses oboe.
|
||||
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
|
||||
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
|
||||
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
|
||||
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
|
||||
sdkmanager is itself idempotent but still spends minutes verifying,
|
||||
which is why the explicit directory guards are there. ~3 GB and most of
|
||||
the job's wall clock on the first run; a directory listing after.
|
||||
4. **wails3.** Cheaper here than in ljos, which pins
|
||||
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
|
||||
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
|
||||
already pinned by `go.mod` and there is nothing to drift. It still
|
||||
*links* GTK and WebKit, so cache the built binary in
|
||||
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
|
||||
ljos's finding that **caching the binary alone turned a slow job into
|
||||
a broken one**: `wails3` is dynamically linked, so the runtime
|
||||
packages are needed even on a cache hit. Here they are already in
|
||||
step 1.
|
||||
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
|
||||
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
|
||||
`frontend/dist`, so nothing Go-side typechecks without it.
|
||||
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
|
||||
unset, with the sentence explaining why (Phase 2). Decide the absolute
|
||||
path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}`
|
||||
evaluates to an empty string in Gitea's expression context**, which
|
||||
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
|
||||
fifty-five seconds into a Gradle run.
|
||||
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
|
||||
opens with `keytool -list` *before* Gradle does (Gradle only notices at
|
||||
`:app:validateSigningRelease`, a minute in, and reports it as a missing
|
||||
file), then `make android`.
|
||||
8. **Verify the signature.** `apksigner verify --print-certs`, and print
|
||||
the SHA-256 with the note that a change to it breaks every future
|
||||
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
|
||||
`head -1` exits early, the producer takes SIGPIPE, and the step fails
|
||||
with 141 *after* printing a perfectly good APK. Use `find … -print
|
||||
-quit` and a captured variable.
|
||||
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
|
||||
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
|
||||
credential pair `arch-package.yml` already uses, not ljos's
|
||||
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
|
||||
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
|
||||
Gitea refuses to overwrite, so delete `latest` first. The generic
|
||||
registry is readable **without credentials**, which is what lets
|
||||
Obtainium poll a plain URL with no token and no public source mirror.
|
||||
|
||||
## Phase 4 — secrets and documentation
|
||||
|
||||
Secrets to create on the repo (all under Settings → Actions → Secrets):
|
||||
|
||||
| Secret | Required | Note |
|
||||
|---|---|---|
|
||||
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
|
||||
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
|
||||
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
|
||||
|
||||
Write the keytool command, the Obtainium URL and the signing-key warning
|
||||
into a docs page — this is the part of ljos's setup that lives in
|
||||
`docs/clients.md` and is referenced from the workflow's error messages,
|
||||
so the messages have somewhere to point.
|
||||
|
||||
Then extend CLAUDE.md's CI section: it currently says "four workflows,
|
||||
three of them package and publish; only `ci.yml` gates". That becomes
|
||||
five, with the same sentence still true.
|
||||
|
||||
## Order and stopping points
|
||||
|
||||
Phase 0 gates everything. Phases 1–2 are one commit's worth of work and
|
||||
are verifiable locally without CI. Phase 3 is the only part that needs a
|
||||
runner, and its first run will be slow and will probably fail once on
|
||||
something in the SDK step — budget for that rather than treating it as a
|
||||
setback.
|
||||
|
||||
**Stop after Phase 0 if the c-shared link does not work.** Every later
|
||||
phase is scaffolding for a build that does not exist, and the honest
|
||||
outcome is a NOTES.md entry saying which package cannot cross-compile and
|
||||
what it would take.
|
||||
@@ -0,0 +1,339 @@
|
||||
# 015 — Multi-artist credits, navigable
|
||||
|
||||
> **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it.
|
||||
|
||||
## The problem
|
||||
|
||||
A track credited to more than one artist has exactly one navigable
|
||||
artist in this app, and the others are punctuation.
|
||||
|
||||
`audio_files` carries `artist_credit` (the credit as tagged, for
|
||||
display) and `artist_id` (one artist, for grouping and browsing).
|
||||
`primaryArtist()` (`backend/library/artistcredit.go:53`) resolves that
|
||||
one artist by *string-parsing* the credit: it strips a " feat. "
|
||||
clause, and deliberately does not split on `&`, `x`, `with` or `,`
|
||||
because those appear inside real artist names. So "Lana Del Rey ft.
|
||||
Sean Lennon" stores Lana Del Rey and discards Sean Lennon entirely,
|
||||
and "Alina Baraz & Galimatias" stores one artist whose name is the
|
||||
whole credit.
|
||||
|
||||
### What the measurement says
|
||||
|
||||
Measured 2026-08-16 against a real 26,069-file library (19,840 mp3,
|
||||
6,229 flac; 57 unreadable, m4a/ogg not examined), plus an 80+80
|
||||
MusicBrainz `inc=artist-credits` sample.
|
||||
|
||||
- **13%** of a random sample of the library's recordings have more
|
||||
than one credited artist in MusicBrainz (10 of 79 resolved).
|
||||
Extrapolates to ~3,250 of the 24,989 files carrying a recording
|
||||
MBID.
|
||||
- **0.86%** of files (224) carry any structured multi-artist signal in
|
||||
their own tags. mp3 carries **zero** files with multiple
|
||||
`MUSICBRAINZ_ARTISTID` values across 19,840 files; flac has 87.
|
||||
- **1,286** files say "feat." in `ARTIST`; **1,159 of them (90%)**
|
||||
have nothing structured behind it. A sample of 80 such files was
|
||||
multi-artist in MB **80 of 80 times**.
|
||||
|
||||
CLAUDE.md currently justifies plan 013's removal of `artist_credit` /
|
||||
`artist_credit_artist` with "3 credits of 2,823 listed more than one
|
||||
artist". That figure measured **our own writer**, not the library:
|
||||
`cachedLinkArtist` was called exactly once per credit
|
||||
(`e7748f1^:backend/library/library.go:1842`), so a collaboration could
|
||||
never have been recorded, and the three were resolution collisions on
|
||||
shared credit text. Dropping the join table was still correct — it only
|
||||
ever held one row, so it was pure join cost — but the stated evidence
|
||||
does not support "multi-artist is rare". Correcting that claim is part
|
||||
of this plan.
|
||||
|
||||
### Why the tags cannot answer it
|
||||
|
||||
Deriving the decomposition locally, with no network, works **79% of the
|
||||
time** (169 of 215 files with a multi-value `ARTISTS` tag: mp3 69/105,
|
||||
flac 100/110), and the failures are systematic rather than random:
|
||||
|
||||
```
|
||||
ARTIST = '2Pac feat. Snoop Dogg, Nate Dogg, Hussein Fatal & Yaki Kadafi'
|
||||
ARTISTS = ['2Pac', 'Snoop Doggy Dogg', 'Nate Dogg', 'Fatal', 'Yaki Kadafi']
|
||||
```
|
||||
|
||||
`ARTISTS` holds **canonical** artist names; `ARTIST` holds
|
||||
**as-credited** names. Locating one inside the other fails on
|
||||
"Snoop Doggy Dogg" vs "Snoop Dogg", on "Fatal" vs "Hussein Fatal", and
|
||||
on Unicode (`Michel'le` vs `Michel’le`, `K-Ci` vs `K‐Ci` — U+2010, not
|
||||
a hyphen). That distinction is precisely what a join phrase encodes,
|
||||
and it is why this cannot be a tag-parsing feature.
|
||||
|
||||
Two format details that will mislead anyone re-running the probe:
|
||||
Picard writes `ARTISTS` **slash-joined into one TXXX frame** on mp3 and
|
||||
as **true repeated Vorbis keys** on flac, so a probe splitting only on
|
||||
NUL undercounts mp3 to zero.
|
||||
|
||||
## The shape
|
||||
|
||||
MusicBrainz models a credit as ordered parts, and the credit *string*
|
||||
is derived from them — `artist_credit.name` is a cached render, nothing
|
||||
more. Each participant is `(position, artist, name, join_phrase)`,
|
||||
where `artist` is the MBID (canonical, what you navigate to) and `name`
|
||||
is the credited spelling (what you display).
|
||||
|
||||
**Join phrases are assembly instructions, not disassembly
|
||||
instructions.** Rendering is a concatenation, never a search:
|
||||
|
||||
```
|
||||
for each (position, artist_mbid, credited_name, join_phrase):
|
||||
emit link(credited_name -> artist_mbid)
|
||||
emit text(join_phrase)
|
||||
```
|
||||
|
||||
The link positions are known **by construction**. This is load-bearing:
|
||||
if we instead located each `credited_name` inside the stored
|
||||
`artist_credit` text, we would reintroduce the mismatch above — the
|
||||
stored string may have come from the tags while the parts come from the
|
||||
catalog, and those **disagree for ~1 in 3 multi-artist files** (61 of
|
||||
90 sampled credits rendered exactly equal to the tag string).
|
||||
Divergences seen: `'Skrillex feat. Swae Lee'` tagged vs
|
||||
`'Skrillex & Swae Lee'` in MB; `'STRFKR'` vs `'Starfucker'`;
|
||||
`'Zedd feat. Hayley Williams'` vs `'... of Paramore'`. Either MB was
|
||||
edited after tagging or Picard versions differ; either way the search
|
||||
would miss or match the wrong span.
|
||||
|
||||
So `audio_files.artist_credit` stops being the source of truth and
|
||||
becomes the **fallback**, used only where there are no parts.
|
||||
|
||||
## Where the data comes from
|
||||
|
||||
The catalog carries the decomposition; no user ever makes a
|
||||
per-recording call. Two sources were ruled out first, both cheaply:
|
||||
|
||||
- **The canonical dump — which is what CI already pulls
|
||||
(`dumpimport.go:84-85`) — does not have it.**
|
||||
`canonical_musicbrainz_data.csv` gives `artist_mbids` (ordered list)
|
||||
and `artist_credit_name`, but that last column is the *rendered*
|
||||
string. Splitting it on CI needs the as-credited names, so CI would
|
||||
fail exactly the way a local parse does.
|
||||
- **The JSON dumps do not cover the catalog.**
|
||||
`json-dumps/recording.tar.xz` is 31 MB / 368 MB uncompressed and
|
||||
holds **153,691 recordings**, not ~35M. Measured against the test
|
||||
library's 24,885 recording MBIDs: **0.00% overlap, zero rows**. It is
|
||||
some other subset and is not usable.
|
||||
|
||||
That leaves the core dump, **`mbdump.tar.bz2`** (7.1 GB compressed at
|
||||
the 20260815 export), from
|
||||
`https://data.metabrainz.org/pub/musicbrainz/data/fullexport/`. Four
|
||||
members are needed:
|
||||
|
||||
| member | why | approx rows |
|
||||
| --- | --- | --- |
|
||||
| `mbdump/artist_credit_name` | `(artist_credit, position, artist, name, join_phrase)` — the payload | ~4M |
|
||||
| `mbdump/artist` | `id -> gid`, since the above references artist *row ids* | ~2.6M |
|
||||
| `mbdump/recording` | `gid -> artist_credit`, to key credits by recording MBID | ~35M |
|
||||
| `mbdump/release_group` | same, for album credits | ~2M |
|
||||
|
||||
### Coverage is not a concern
|
||||
|
||||
Of 24,885 distinct recording MBIDs in the test library, **24,808
|
||||
(99.7%)** already have an `explore_index` recording row, measured
|
||||
against a database at 2,052,200 rows — i.e. shipped-artifact coverage,
|
||||
not a local build's. The popularity filter does not strand the long
|
||||
tail here.
|
||||
|
||||
## Status
|
||||
|
||||
- **Phase 1 — done.** `backend/explore/dumpcredits.go` +
|
||||
`dumpcreditswrite.go`, wired into `dumpimport.go`'s `run` behind its
|
||||
own `credits_import_done` marker.
|
||||
- **Phase 2 — done.** `cmd/indexexport` writes the two tables;
|
||||
`artifactimport.go` reads them behind `artifactHasCredits()`.
|
||||
- **Phase 4 — done, and it does not need Phase 3.** `explore.GetCredits`
|
||||
reads the catalog tables keyed on the *recording* MBID, which both
|
||||
sides of the app already carry — a catalog row has one and so does a
|
||||
local file (`library.Track.RecordingMBID`). So one binding serves the
|
||||
Explore pages and the library's own lists, and all ten artist-link
|
||||
call sites render credits today without a local table.
|
||||
- **Phase 3 (`file_artists`) — not started, and now an
|
||||
offline-resilience task rather than a prerequisite.** The table is
|
||||
deliberately *not* declared yet: nothing writes or reads it, and a
|
||||
schema file plus a datamap note describing behaviour that does not
|
||||
exist is a claim the code cannot back. Its remaining
|
||||
value is that credits currently vanish when the catalog is absent or
|
||||
still downloading, which is precisely the `no-index` state
|
||||
`ShelfPage.State` exists to describe. Materialising into
|
||||
`file_artists` is what makes a library stand on its own.
|
||||
|
||||
**Nothing renders yet in practice**, because no published artifact
|
||||
carries credit tables — every credit falls back to its single link
|
||||
until an index build with Phase 1 runs and is exported.
|
||||
|
||||
**Column layouts are verified against the real 20260815 export**, not
|
||||
taken from the schema docs — `artist(id, gid, …)`,
|
||||
`artist_credit(id, name, artist_count, …)`,
|
||||
`artist_credit_name(credit, position, artist, name, join_phrase)` and
|
||||
`recording(id, gid, name, artist_credit, …)` were each read out of the
|
||||
dump. `release_group` shares `recording`'s first four columns and is
|
||||
the one layout still taken on trust; `ErrDumpShape` turns a wrong guess
|
||||
into a loud failure rather than a quietly wrong catalog.
|
||||
|
||||
**Still unrun: the ingest against the real 7.1 GB dump.** Everything is
|
||||
covered by tests over a synthetic tar, which cannot catch a surprise in
|
||||
the other ~35M rows.
|
||||
|
||||
### Phase 1 — Ingest credits on CI
|
||||
|
||||
New dump stage in `cmd/indexbuild`, behind the `indexbuild` tag with
|
||||
the rest of `dumpimport.go`'s stages.
|
||||
|
||||
**Constraint from `b98840e`:** `cmd/indexbuild` is built
|
||||
`CGO_ENABLED=0` in a plain `golang` container and must not reach the
|
||||
Wails `application` package — `TestIndexToolsDoNotImportWails` walks
|
||||
`go list -deps -tags indexbuild`. Nothing here should need it, but a
|
||||
new `ServiceStartup` hook on a package this imports is how it comes
|
||||
back. Go's `compress/bzip2` is pure Go and decompress-only, which is
|
||||
all this needs.
|
||||
|
||||
**Measured, 20260815 export.** Tar members are **alphabetical**, and
|
||||
that is favourable: `artist` (435 MB), `artist_credit` (414 MB) and
|
||||
`artist_credit_name` (237 MB) all fall inside the first ~900 MB
|
||||
compressed, while `recording` and `release_group` come later. So the
|
||||
maps are complete before the rows that consume them arrive, and no
|
||||
recording data is ever buffered.
|
||||
|
||||
Pure-Go `compress/bzip2` decompresses at **26 MB/s uncompressed /
|
||||
8.7 MB/s compressed** (measured on a 250 MB prefix, 3.01x ratio) —
|
||||
**~13.7 min** for the whole file single-threaded, and less because the
|
||||
stream can stop after `release_group` rather than reading the
|
||||
`series`/`tag`/`track`/`url`/`work` tail. The 2 MB/s origin throttle
|
||||
dominates, as it already does for every other dump here.
|
||||
|
||||
Do not, however, *depend* on the ordering: assert it and fall back to
|
||||
buffering if a future export reorders, rather than silently emitting
|
||||
nothing.
|
||||
|
||||
- `artist` -> `map[int32]uuid16` (~2.6M x ~20 B = ~60 MB)
|
||||
- `artist_credit_name` -> `map[int32][]creditPart` (~4M x ~40 B =
|
||||
~200 MB)
|
||||
- `recording` / `release_group` -> emit `gid -> credit_id` **only for
|
||||
MBIDs already in `explore_index`** (the kept set is ~1.4M x 16 B =
|
||||
~22 MB), which is what keeps 35M rows from being held
|
||||
|
||||
Peak ~300 MB, one sequential pass.
|
||||
|
||||
**Only multi-artist credits are stored.** A single-artist credit is
|
||||
`(name, "")` and is already fully described by `explore_index`'s
|
||||
`artist_name` / `artist_mbid`; storing it would triple the table for
|
||||
nothing. Post-filter after loading, once the row count per credit is
|
||||
known.
|
||||
|
||||
New tables (and `datamap` entries, or `TestCatalogCoversSchema` fails
|
||||
the build — both are `Cache`, matching `explore_index`):
|
||||
|
||||
```
|
||||
artist_credit_part(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||
```
|
||||
|
||||
with `explore_index.artist_credit_id` as the link. Credits are
|
||||
**shared** — an album's twelve tracks by one artist share one credit
|
||||
row — which is the opposite of 013's local verdict, and correctly so:
|
||||
1:1 in a local library, genuinely many-to-one at 2M-row catalog scale.
|
||||
|
||||
### Phase 2 — Ship them in the artifact
|
||||
|
||||
`cmd/indexexport` currently creates exactly two tables in the artifact
|
||||
(`explore_index`, `artifact_meta`, at `cmd/indexexport/*.go:147,170`),
|
||||
so this is a structural addition, not a column.
|
||||
|
||||
Estimated size: ~13% of 1.4M recordings, deduplicated by shared credit,
|
||||
at ~2.3 parts each — order 400k rows, ~18 MB uncompressed. Against a
|
||||
~0.6 GB install that is acceptable; it must be measured rather than
|
||||
assumed before merge.
|
||||
|
||||
`artifactimport.go` must read it **only if present**, on the writer
|
||||
handle where `core` is attached — the `artifactHasTotals()` /
|
||||
`artifactStoresText()` pattern (`artifactimport.go:145-175`), one step
|
||||
up from a column to a table. An artifact published before this exists
|
||||
is still a perfectly good catalog and must import as one that declines
|
||||
to answer. Adding this to the importer's SELECT list without the probe
|
||||
is how every already-published artifact starts failing.
|
||||
|
||||
`artifactCatalogColumns` gains `artist_credit_id`; it is kept in sync
|
||||
with the exporter by `TestArtifactColumnsMatchExporter`.
|
||||
|
||||
### Phase 3 — Materialize locally
|
||||
|
||||
```
|
||||
file_artists(audio_file_id, position, artist_id, credited_name, join_phrase)
|
||||
```
|
||||
|
||||
`credited_name` is stored **per row**, not looked up from
|
||||
`artists.name` — that is the Snoop-Doggy-Dogg distinction, and it is
|
||||
the whole point.
|
||||
|
||||
Filled at scan/import time by joining `audio_files.recording_mbid`
|
||||
against the catalog. **Materialized rather than resolved live**,
|
||||
because the catalog is a downloaded artifact that can be absent or
|
||||
still arriving — that is why `ShelfPage.State` has a `no-index` value —
|
||||
and a library whose track rows lose their artists when the catalog is
|
||||
missing is worse than today.
|
||||
|
||||
That implies a backfill for the case where the catalog arrives *after*
|
||||
the library was scanned. It registers with `jobs` (progress, cancel)
|
||||
like every other long pass, and takes a **distinct kind** from
|
||||
`index-build`, since `job-controls.ts` keys its "you will discard hours
|
||||
of downloading" confirmation on that kind.
|
||||
|
||||
`artists` gains rows for guests who own no files. **This changes what
|
||||
the artists grid shows** and is an open question below.
|
||||
|
||||
### Phase 4 — Render
|
||||
|
||||
`utils/explore-link.ts` gains a credit-rendering entry point taking
|
||||
ordered parts and returning a `TemplateResult`. Every row and detail
|
||||
view already renders artist names through it, so they inherit
|
||||
multi-artist links without individually knowing credits exist — the
|
||||
property that made centralising it worthwhile.
|
||||
|
||||
Its existing fallback philosophy already covers the no-parts case: "a
|
||||
list where some rows are clickable and others silently are not reads as
|
||||
a bug, not as a statement about metadata." Where there are no parts
|
||||
(no recording MBID, or no catalog row — ~4% of the test library) render
|
||||
today's behaviour: the flat `artist_credit` string with one link to the
|
||||
primary artist. **Do not split the string there.** There is genuinely
|
||||
no information to split on, and that is the one place the temptation
|
||||
returns.
|
||||
|
||||
`primaryArtist()` stays exactly as it is. It remains the fallback and
|
||||
is still what `artist_id` means.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Catalog credit vs tagged credit, when they disagree** (~1 in 3
|
||||
multi-artist files). Rendering the catalog's decomposition is what
|
||||
makes names navigable; preserving the file's is what makes the app
|
||||
reflect the user's files. Leaning toward: render the catalog
|
||||
decomposition, keep `artist_credit` as the fallback string. Wants a
|
||||
deliberate decision, not an accident.
|
||||
2. **Do guest artists appear in the artists grid?** Phase 3 creates
|
||||
`artists` rows for people who own no files. The grid currently means
|
||||
"artists in your library" and joins `audio_files`. A guest on one
|
||||
track is arguably in the library and arguably not. Whichever way,
|
||||
the ownership question stays "is there a file" — that rule does not
|
||||
bend.
|
||||
3. **`release_group` credits** are ingested in the same pass for
|
||||
nearly nothing, but album-artist rendering is a separate surface.
|
||||
Ship the data in phase 1, render in a follow-up rather than widening
|
||||
phase 4.
|
||||
4. **Our own `tagwriter`** does not write `ARTISTS` or multiple
|
||||
`MUSICBRAINZ_ARTISTID` frames, so autotagging a folder degrades the
|
||||
very field this rests on — the same shape as the existing
|
||||
track-totals note. Out of scope here; worth recording.
|
||||
|
||||
## Verification
|
||||
|
||||
- Coverage: re-run the library probe and assert `file_artists` is
|
||||
populated for ~13% of files, not ~0.9%.
|
||||
- `TestCatalogCoversSchema` / `TestLifetimesMatchSchema` for the new
|
||||
tables.
|
||||
- `TestIndexToolsDoNotImportWails` still passes with the new stage.
|
||||
- An artifact **without** the credits table imports cleanly (the
|
||||
`artifactHasTotals` regression shape).
|
||||
- Round-trip: a known multi-artist recording renders each name as a
|
||||
separate link with the correct join phrases between them.
|
||||
@@ -0,0 +1,412 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **Completed.** Sections A, B1, B2 and B4 shipped. B3, writing tags on the device, is now **#87**; the device-found UI faults are #51–#72, sequenced by #73.
|
||||
|
||||
> **Status: all of section A is done.** A1–A3 landed with "let the app
|
||||
> reach the user's music"; A4 (MediaSession, transport notification,
|
||||
> audio focus) landed with "survive the screen locking". The direction
|
||||
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
|
||||
> plus an in-app folder browser, which keeps the path-keyed model
|
||||
> intact. B1/B2 remain, both awaiting a decision rather than work. The
|
||||
> sections below are kept as written, because they are the argument the
|
||||
> decision rests on — see "What is left" at the end for the current
|
||||
> state.
|
||||
|
||||
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
|
||||
versioned, and publishes from CI. This is the assessment of what stands
|
||||
between that and an Android app worth installing.
|
||||
|
||||
**The headline: parity is the wrong target, and choosing it would be
|
||||
the expensive mistake.** Four of the blockers below are not porting work
|
||||
— they are the Android platform declining to support the model this app
|
||||
is built on. The decision to make first is in "The fork in the road" at
|
||||
the end; everything before it is evidence for that decision.
|
||||
|
||||
Severity is what the app *does* today, verified against the source and
|
||||
the generated manifest, not guessed.
|
||||
|
||||
## A. It cannot work at all until these are fixed
|
||||
|
||||
### A1. The app can read no music. (deepest)
|
||||
|
||||
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
|
||||
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
|
||||
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
|
||||
**There is no storage or media permission of any kind.** At
|
||||
`targetSdk 35` that means the app can see its own private directory and
|
||||
nothing else.
|
||||
|
||||
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
|
||||
grants access through **MediaStore**, not through the filesystem. This
|
||||
app's entire model is absolute paths: `audio_files.file_path` is the
|
||||
primary key of ownership, `AddLibrary(path)` takes a directory, the
|
||||
scanner walks it with `os.ReadDir`, and every one of
|
||||
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
|
||||
paths to the player. Scoped storage does not offer a stable directory
|
||||
to walk.
|
||||
|
||||
The honest options are three, and they are not close in cost:
|
||||
|
||||
- **MediaStore as the library source.** Query the content resolver,
|
||||
keep MediaStore IDs (or content URIs) beside or instead of paths, and
|
||||
open audio through a `ContentResolver` file descriptor. This is the
|
||||
Android-native answer and it touches the schema, the scanner, the
|
||||
player's file opening and every path-keyed query.
|
||||
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
|
||||
effectively barred from Google Play except for genuine file managers.
|
||||
Viable *only* because we distribute through Obtainium — which is a
|
||||
real point in its favour here, and worth stating plainly rather than
|
||||
dismissing.
|
||||
- **App-private storage only**, i.e. the user copies music into the
|
||||
app's sandbox. Trivial to build, and nobody wants it.
|
||||
|
||||
### A2. The first-run flow cannot complete.
|
||||
|
||||
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
|
||||
`frontendutil.DirectoryPicker` → `app.Dialog.OpenFile().
|
||||
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
|
||||
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
|
||||
paths"**. So the one action the wizard exists to perform fails, and
|
||||
`<first-run-wizard>` intercepts all pointer events until a library
|
||||
exists — so the app is not merely empty, it is inert.
|
||||
|
||||
Whatever A1 resolves to decides this: a MediaStore library needs no
|
||||
picker at all, and a SAF tree needs the picker to return a URI the
|
||||
backend can use.
|
||||
|
||||
### A3. MPRIS is compiled into the Android build.
|
||||
|
||||
`mpris_linux.go` is `//go:build linux`, and **`android` implies
|
||||
`linux`** (documented, and the reason it is in the APK). It will look
|
||||
for a session bus that does not exist. It needs `//go:build linux &&
|
||||
!android`, and its Android counterpart is A4.
|
||||
|
||||
This one is cheap and should be done regardless — it is a two-character
|
||||
build-tag change plus whatever `mediacontrols.New` returns instead.
|
||||
|
||||
### A4. Playback will be killed the moment the screen locks.
|
||||
|
||||
The scaffold's `WailsForegroundService` is typed **`dataSync`**
|
||||
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
|
||||
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
|
||||
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
|
||||
`MediaSession` for lock-screen and notification transport controls,
|
||||
plus **audio focus** — pause on a phone call, duck for a notification,
|
||||
pause on headphone unplug. None of that exists today. `oto` will happily
|
||||
keep writing to a stream nobody can hear.
|
||||
|
||||
This is the difference between "an app that plays audio" and "a music
|
||||
player", and it is Java-side work in the scaffold plus a Go-side bridge.
|
||||
|
||||
## B. It works, but wrongly
|
||||
|
||||
### B1. The x86_64 half of the APK cannot run on any Android.
|
||||
|
||||
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
|
||||
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
|
||||
takes `SIGSYS` the first time it touches the database. arm64 is
|
||||
structurally unaffected (no `lstat` syscall exists; it routes through
|
||||
`fstatat`).
|
||||
|
||||
So ~31 MB of the artifact is dead weight on *every* Android device,
|
||||
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
|
||||
(smaller APK, no emulator target — which does not work anyway), or
|
||||
carry it against a future modernc fix. **Dropping it is the honest
|
||||
default**; it is also the only item in this plan that is a five-minute
|
||||
change.
|
||||
|
||||
### B2. The UI is a desktop shell.
|
||||
|
||||
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
|
||||
header subtitle wraps the title out of its bar. A phone is ~360–430 CSS
|
||||
px wide. The sidebar collapses to icons below 900px, which is a
|
||||
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
|
||||
on hover (the marquee's `hover` mode, tooltips), right-click context
|
||||
menus, a keyboard shortcut layer with its own overlay and settings page,
|
||||
multi-select with ctrl/shift, and a resizable-column track list. None of
|
||||
those are gestures.
|
||||
|
||||
This is not a stylesheet pass. It is a second front end for the views
|
||||
worth having on a phone, sharing the stores and bindings — which the
|
||||
architecture supports, since a view is already a lazily-loaded chunk
|
||||
behind `VIEW_LOADERS`.
|
||||
|
||||
### B3. Tag writing cannot reach the user's files.
|
||||
|
||||
`tagwriter` rewrites tags in place, and autotag's whole purpose is
|
||||
applying them to a folder. Under scoped storage that is impossible
|
||||
outside the sandbox without a SAF write grant per tree. If A1 lands on
|
||||
MediaStore, in-place tag writing needs `MediaStore` write requests and
|
||||
user confirmation per file on Android 11+.
|
||||
|
||||
Autotagging is arguably a desktop-only feature and saying so is a
|
||||
legitimate answer.
|
||||
|
||||
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
|
||||
|
||||
It works — but with no awareness of a metered connection and no
|
||||
accounting for a device where that is a meaningful fraction of free
|
||||
space. At minimum it needs to be opt-in on mobile and to refuse a
|
||||
metered network by default. `Android.NetworkJSON()` reports
|
||||
`{connected,type}`, so the signal is available.
|
||||
|
||||
## C. Inert, and fine
|
||||
|
||||
Window geometry, menus and the system tray are documented no-ops on
|
||||
mobile. The keyboard shortcut layer is harmless but its Settings page
|
||||
is dead weight. `profiling` is already compiled out of production
|
||||
builds. These cost nothing and need no work.
|
||||
|
||||
## D. Unknown until it runs on a device
|
||||
|
||||
**Nothing in section A or B has been observed on Android**, because the
|
||||
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
|
||||
images on an x86_64 host. Everything above is read from the source, the
|
||||
generated manifest and Wails' own documentation. The first real device
|
||||
run will find things this list does not have, and the most likely
|
||||
places are audio latency and buffering under `oto`/oboe, and SQLite
|
||||
behaviour on app-private storage.
|
||||
|
||||
## The fork in the road
|
||||
|
||||
The four blockers in section A are all the same question wearing
|
||||
different clothes: **is the Android app a librarian, or a player?**
|
||||
|
||||
YellowJacket on the desktop is a *librarian*. It scans folders,
|
||||
deduplicates covers, detects duplicate tracks, reconciles against
|
||||
MusicBrainz, rewrites tags on disk, and manages downloads. That model
|
||||
rests on owning a filesystem, which is precisely what Android declines
|
||||
to give.
|
||||
|
||||
Three coherent products, and only the first is "parity":
|
||||
|
||||
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
|
||||
(Obtainium-only distribution, which we already have), a phone UI for
|
||||
every view, and media-session playback. Largest scope by far; the
|
||||
result is an app almost nobody has asked for on a phone.
|
||||
2. **A player for music already on the phone.** MediaStore as the
|
||||
source, no scanner, no autotag, no downloads; the library, queue,
|
||||
playlists, favourites and Explore-as-browsing all still make sense.
|
||||
This is a genuinely good Android app and it is *not* parity — it is
|
||||
a subset with a different data source.
|
||||
3. **A companion to the desktop app.** The phone browses and controls
|
||||
the desktop's library over the network, or syncs a subset. Smallest
|
||||
Android surface, and it leans on the thing that already works.
|
||||
|
||||
**Option 2 is the recommendation** if the goal is an app people use;
|
||||
option 3 if the goal is the least work for the most value. Option 1 is
|
||||
the only one that answers "feature parity" literally, and it is the one
|
||||
worth arguing hardest against.
|
||||
|
||||
> **Decided:** option 1's *data model* (the librarian keeps its
|
||||
> filesystem and its scanner — A1 shipped that) with option 2's
|
||||
> *surface*. The phone is a player over the library this app already
|
||||
> builds; it does not get every view. The list is below.
|
||||
|
||||
## The phone gets a subset (decided)
|
||||
|
||||
B2 is not a stylesheet pass and not a second front end either. A view
|
||||
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
|
||||
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
|
||||
so the phone build is **a different loader table and a different
|
||||
chrome**, over the same stores.
|
||||
|
||||
**In**, because each is something a person does with a phone in their
|
||||
hand:
|
||||
|
||||
- **Home** — the shelves are already a phone-shaped surface.
|
||||
- **Library browse** — albums, artists, genres. The grids are already
|
||||
virtualized and card-shaped.
|
||||
- **Now playing** — which on a phone is a *view*, not a 4em bar.
|
||||
- **The queue.**
|
||||
- **Search** — the header box, scoped as it already is.
|
||||
- **Playlists**, including smart ones, as lists to play rather than to
|
||||
edit.
|
||||
|
||||
**Out**, and each for a reason rather than by omission:
|
||||
|
||||
- **Autotag** — the review UI is a wide table and the action rewrites
|
||||
files on disk; B3 has not been verified even as *possible* yet.
|
||||
- **Downloads** — two tab panels of client configuration.
|
||||
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
|
||||
the last thing to earn a phone's storage.
|
||||
- **Settings** — not the page. The phone needs a handful of settings
|
||||
(theme, the library folder, playback) and not the 93 controls the
|
||||
desktop page carries.
|
||||
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
|
||||
has no keyboard and no resizable columns, and the jobs indicator is
|
||||
enough.
|
||||
|
||||
What the shell has to lose, from the audit at the top of this section:
|
||||
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
|
||||
bar over the five things above), hover as a route to anything,
|
||||
right-click as the only route to a context menu (long-press is the
|
||||
gesture), and ctrl/shift multi-select.
|
||||
|
||||
One rule for the work: **no view forks.** A phone layout that copies a
|
||||
view's template is two templates to fix every bug in. Where a view
|
||||
cannot serve both, the split belongs at the chunk boundary that already
|
||||
exists.
|
||||
|
||||
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
|
||||
inside the drawer means reusing its `data-testid`s too, and a second
|
||||
copy standing by in the DOM broke 30 specs that had nothing to do with
|
||||
the phone. The rule holds — a second list of destinations would be
|
||||
worse — but a shared component must be rendered only when it is wanted,
|
||||
and the guard belongs in a test that names the reason.
|
||||
|
||||
## What is worth doing regardless of that decision
|
||||
|
||||
Cheap, independently useful, and each unblocks measurement:
|
||||
|
||||
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
|
||||
why. Five minutes.
|
||||
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
|
||||
Android build stops carrying a D-Bus client. Small.
|
||||
3. **A device smoke run**, which needs someone's phone and the published
|
||||
APK. Everything in D depends on it, and it is the single highest
|
||||
information-per-minute action available.
|
||||
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
|
||||
— the picker's error already routes through `describeError`, but the
|
||||
wizard still blocks pointer events, so an Android user sees a dead
|
||||
screen rather than a sentence. Even under option 3 this is the right
|
||||
behaviour.
|
||||
|
||||
|
||||
## What is left (updated after A4)
|
||||
|
||||
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
|
||||
beside the MPRIS one, and the Java half is
|
||||
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
|
||||
transport notification and audio focus. It needed no new JNI and no new
|
||||
Gradle dependency — `application.Android.StartForegroundService(json)`
|
||||
going out, `WailsBridge.emitEvent` → the application event bus coming
|
||||
back, and the platform `android.media.session` API rather than
|
||||
androidx.media, which minSdk 21 makes available anyway.
|
||||
|
||||
Four decisions in it are worth keeping:
|
||||
|
||||
- **Ducking is a player concept, not a volume change.**
|
||||
`Player.SetDuck` re-applies the *user's* level with an attenuation
|
||||
offset, so `getUserVolume` still reports what the user chose and
|
||||
nothing is persisted or emitted. A duck that wrote through to the
|
||||
volume would let one notification tone permanently turn the music
|
||||
down.
|
||||
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
|
||||
the app itself and sends no `CAN_DUCK` focus change, so asking to be
|
||||
told instead (`setWillPauseWhenDucked`) would mean pausing for every
|
||||
notification tone, and doing both would attenuate twice.
|
||||
- **An unchanged payload is not an event here either.** Every push
|
||||
crosses JNI and re-delivers an Intent, and the player pushes state on
|
||||
several paths that can agree.
|
||||
- **After the first start, updates use `startService`.** From Android
|
||||
12 an app in the background may not *start* a foreground service, but
|
||||
it may keep delivering intents to one it already has — which is every
|
||||
track change with the screen off.
|
||||
|
||||
The contract with Java — the payload keys, the state words, the command
|
||||
names — is in `androidpayload.go`, deliberately *without* the `android`
|
||||
build tag, so `go test` exercises it on every platform. Everything left
|
||||
in `android.go` is untested by construction: it compiles only under a
|
||||
cross-compiler and runs only on a phone.
|
||||
|
||||
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
|
||||
places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||
(or Go still compiles a library Gradle then discards) and the
|
||||
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
|
||||
anchor is what stops it also matching the fat APK's line. Adding the
|
||||
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||
|
||||
**B2, the desktop shell.** Scope decided (below); **all four phases are
|
||||
done.**
|
||||
|
||||
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
|
||||
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||
exactly — measured, from 652px in a 360px viewport before.
|
||||
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
|
||||
and volume went. A detail view, so Back pops the nav stack; it
|
||||
composes the real transport components rather than copying them; and
|
||||
it hides the bottom bar while it is up, so it carries its own queue
|
||||
button.
|
||||
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
|
||||
listener, installed once from `index.ts`, which turns a 500 ms
|
||||
stationary touch into a synthetic `contextmenu` at the touch point.
|
||||
Every menu in the app opens from that event, so all six components
|
||||
gained the gesture without one of them changing — which is the same
|
||||
argument `ContextMenuController` rests on, one layer lower. The
|
||||
details that are not obvious are in `NOTES.md` (2026-08-17); the one
|
||||
worth repeating is that ours is told from the browser's own
|
||||
long-press event by **identity**, not `isTrusted`, because a test
|
||||
cannot dispatch a trusted event and that path would otherwise be the
|
||||
only uncovered one.
|
||||
|
||||
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
|
||||
artist) plus the duration, and drops the column headers and the resize
|
||||
handles — a column set rather than a second row template, so the row
|
||||
and everything delegated on it is unchanged. Verified at the device's
|
||||
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
|
||||
overflow. The device also found the bug in it, which no browser
|
||||
viewport would have: saved *desktop* column widths reached the phone
|
||||
through an id-keyed store and gave the duration column 55% of the row.
|
||||
|
||||
**B2 and B4 are complete.** B4 is `backend/explore/netpolicy.go`: the
|
||||
catalog download is skipped on a cellular connection unless
|
||||
`AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search
|
||||
Index section. The policy and the JSON parsing are in `explore` (tested
|
||||
on every platform) and only the platform call is injected from `app.go`,
|
||||
because `cmd/indexbuild` imports `explore` and must not link Wails. Two
|
||||
things the plan got slightly wrong: the portable API is
|
||||
`application.Mobile.NetworkJSON()` rather than `Android`'s, and it
|
||||
reports no metered flag — so cellular is the signal and a metered Wi-Fi
|
||||
cannot be seen.
|
||||
|
||||
What is left in this plan is B3 (tag writing, which needs a device) and
|
||||
the standing question of the Light Phone's Chrome 113 — which so far has
|
||||
cost nothing: menus, dialogs and long-press all work on it.
|
||||
|
||||
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||
with all-files access, `tagwriter` can write in place.
|
||||
|
||||
### What the first device run answered (2026-08-17)
|
||||
|
||||
A4 **works**: playback survives the screen locking, and the transport
|
||||
notification appears with cover art — which also settles the service's
|
||||
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
|
||||
the lock-screen session in one observation. Everything below in "what
|
||||
none of section A answered" was written before this and is now answered
|
||||
except the OEM permission-flow variance.
|
||||
|
||||
It also found two faults no browser tier can see, both fixed and both
|
||||
awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||
|
||||
- **Back quit the app from any depth.** The scaffold asks
|
||||
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||
navigation is a history entry now, and `navStack` is gone rather than
|
||||
kept beside it.
|
||||
- **The transport was under the gesture bar** — or so the version
|
||||
number said. `applyWindowInsets()` in `MainActivity` is right and
|
||||
stays, but the phone is **Android 14**, where the system still insets
|
||||
the window: the fix is pre-emptive and the symptom has another cause.
|
||||
Still open, along with icons that do not appear at all. The phone's
|
||||
WebView is **Chrome 113**, which is the lead (no Popover API, no
|
||||
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
|
||||
how it gets asked.
|
||||
|
||||
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||
permission flow still need a device**, and so does confirming these two.
|
||||
|
||||
### What none of section A answered
|
||||
|
||||
Nothing here has been observed on a device. The permission flow in
|
||||
particular is the kind of thing that behaves differently across OEM
|
||||
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
|
||||
implemented inconsistently, which is why there is a fallback to the
|
||||
global list, and neither path has been exercised.
|
||||
|
||||
A4 adds its own list of things only a device can answer, and they are
|
||||
the likely first failures: whether the notification appears at all
|
||||
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
|
||||
user who declines gets a service with an invisible notification),
|
||||
whether audio focus arrives while `oto`/oboe holds the output, whether
|
||||
the lock screen picks up the session, and whether cover art decoded
|
||||
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
|
||||
@@ -0,0 +1,87 @@
|
||||
# 017 — Releases that happen by themselves
|
||||
|
||||
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
|
||||
Commits since the last tag, cuts the tag and the Gitea release whose body
|
||||
is the generated changelog, and the four publishing workflows build that
|
||||
tag and attach their artifacts. Nothing is released by hand.
|
||||
|
||||
## What it looks like now
|
||||
|
||||
`release.yml` on push to `main` → semantic-release → tag → four `v*`
|
||||
workflows in parallel (serialised in practice by the capacity-1 runner):
|
||||
|
||||
| workflow | publishes | attaches |
|
||||
| --- | --- | --- |
|
||||
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
|
||||
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
|
||||
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
|
||||
| `homebrew-formula` | the public tap | — (builds from source) |
|
||||
|
||||
Verified on the real thing: all five green, three assets on the release,
|
||||
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
|
||||
|
||||
## The five decisions, and what they cost
|
||||
|
||||
1. **semantic-release, not a shell script.** The first draft of this plan
|
||||
proposed hand-rolling it and the argument did not survive checking:
|
||||
`@semantic-release/exec` is first-party and current, and the
|
||||
Gitea-shaped part is one `curl`. What I would have hand-rolled —
|
||||
commit parsing, semver ordering, note rendering — is the part with the
|
||||
edge cases and none of it is Gitea-shaped.
|
||||
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
|
||||
before it was checked: last published 2022, `got@10`, and no peer
|
||||
dependency on semantic-release at all.
|
||||
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
|
||||
commit-back is rejected by the pre-receive hook — and would be
|
||||
rejected *after* the tag was pushed, leaving a tagged release the run
|
||||
reports as failed. The release page is the changelog;
|
||||
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
|
||||
signpost.
|
||||
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
|
||||
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
|
||||
once. Documented in `packaging/homebrew/README.md` and
|
||||
`docs/android-release.md`.
|
||||
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
|
||||
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
|
||||
because no build of it has ever been *run*.
|
||||
|
||||
## Four things that only showed up by running it
|
||||
|
||||
- **`conventional-changelog-conventionalcommits@10` renders empty
|
||||
notes.** Silently: right version, right tag, every step green, and a
|
||||
release body that is a bare `## 0.0.1 (date)` heading with nothing
|
||||
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
|
||||
the reason beside both. **Check the rendered notes, never the exit
|
||||
code.**
|
||||
- **semantic-release core dry-run-pushes to the release branch** as a
|
||||
permission check, independently of any plugin. `PACKAGE_TOKEN` had
|
||||
package-write and repo-*read* — enough to clone, not enough for this —
|
||||
and it failed with a flat `403 Forbidden` that reads exactly like
|
||||
branch protection. It is not: a `--dry-run` push never reaches the
|
||||
pre-receive hook, which a one-line experiment settled. The token needed
|
||||
`write:repository`.
|
||||
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
|
||||
commit itself it leaves nothing between the floor and HEAD, and
|
||||
semantic-release correctly reports there is nothing to release. The
|
||||
first run did exactly that and cut nothing.
|
||||
- **A tag-triggered workflow runs from the tagged commit's tree.**
|
||||
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
|
||||
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
|
||||
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
|
||||
general: a guard added today does not protect a tag pointing at
|
||||
yesterday.
|
||||
|
||||
## Two mechanisms confirmed, having been assumptions
|
||||
|
||||
- **A tag pushed with a user PAT does start the `v*` workflows**; one
|
||||
pushed with the Actions token does not (go-gitea#33123). Both halves
|
||||
are load-bearing and both were observed: the floor seed triggered
|
||||
nothing, and the release tag triggered all four.
|
||||
- **Tags are not protected** on this repo, only `main` — which is what
|
||||
lets semantic-release tag at all.
|
||||
|
||||
## Left behind deliberately
|
||||
|
||||
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
|
||||
four publishers skip it by name.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Autotag (v1.3) — MusicBrainz Autotagger
|
||||
|
||||
> **Historical record.** Phases 008–010 shipped, and the scoring engine was subsequently overhauled (`recommend.go`, `rank.go`, `mixedbag.go`), which makes the 011/012 sections below stale in their details. What is actually left is **#90** (auto-accept and entry points) and **#91** (settings, and a way back from the dismissed file-write warning).
|
||||
|
||||
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008–012), each depending on the prior one.
|
||||
|
||||
| Phase | Title | Status |
|
||||
@@ -1,195 +0,0 @@
|
||||
# 010 — Owned albums, offline
|
||||
|
||||
**Status:** not started — and **much smaller than when it was written**
|
||||
**Branch:** none yet
|
||||
**Created:** 2026-08-13
|
||||
**Depends on:** nothing
|
||||
**Related:** the `AlbumReleasesFailed` fix that prompted it, and the
|
||||
tag-derived completeness that landed after it (same session)
|
||||
|
||||
---
|
||||
|
||||
## What already shipped, and what it leaves
|
||||
|
||||
The common case is solved without this plan. `GetAlbumCompleteness`
|
||||
reads the "5/12" denominator off the files' own tags — persisted to
|
||||
`release_group_recordings.total_tracks`, having been extracted at every
|
||||
scan since forever and discarded — and an album that is **MBID-matched
|
||||
and complete** now opens with **no catalog call at all**. Identity from
|
||||
the MBID, tracklist from the tags; those were the two things the browse
|
||||
was being spent on.
|
||||
|
||||
So the set this plan still has to serve is not "albums you own a track
|
||||
of". It is:
|
||||
|
||||
- albums that are genuinely **incomplete** (the catalog is the only way
|
||||
to say *which* tracks are missing — tags give the count, not the
|
||||
names), and
|
||||
- albums whose tags **never declared a total**, where completeness is
|
||||
unknowable locally and the catalog is the only source.
|
||||
|
||||
On a well-tagged library that is a small minority, which changes the
|
||||
economics below considerably: the run is shorter, and the rate limiter
|
||||
contention that dominates this design is proportionally less severe.
|
||||
Re-measure before building — the answer may now be "the prefetch is
|
||||
enough".
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Opening an album detail page for an album **you already own** hits
|
||||
MusicBrainz. Every time it is not in the response cache, which for most
|
||||
of a library is every time, because nothing warms that cache except a
|
||||
capped prefetch on the artist page.
|
||||
|
||||
The user's framing: *this is a classic example of an album we should
|
||||
have had locally.*
|
||||
|
||||
## Why we do not have it, despite the discography backfill
|
||||
|
||||
`BackfillLibraryDiscographies` / `EnsureArtistDiscography`
|
||||
(`backend/explore/searchindex.go:301`, `:397`) do less than the name
|
||||
suggests. Per artist, `indexOneArtist` fetches:
|
||||
|
||||
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
|
||||
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
|
||||
|
||||
and writes them as **flat `explore_index` rows**. There is no release
|
||||
group → tracklist relation anywhere in the index, and no release-level
|
||||
rows at all. `explore_index` recordings carry `caa_release_mbid` and
|
||||
`release_name`, which name the release used for cover art — not a
|
||||
tracklist.
|
||||
|
||||
So "we have full discographies for library artists" means *we know
|
||||
which albums the artist made, offline*. It has never meant we know
|
||||
what is on any of them.
|
||||
|
||||
The only store of release-level catalog data in the app is `http_cache`
|
||||
under `mb:browse:releases:<rg>` (90-day TTL, `musicbrainz.go:27`),
|
||||
populated **only** by a live `BrowseReleases` with
|
||||
`Includes: ["recordings", "media"]` at `MaxLimit` — the most expensive
|
||||
call the app makes to MusicBrainz. It is warmed by exactly one thing:
|
||||
`PrefetchReleases` (`explore.go:746`), capped at 8, called only when an
|
||||
artist page renders.
|
||||
|
||||
An album opened from the library grid therefore always browses live.
|
||||
|
||||
## What to build
|
||||
|
||||
**A post-scan backfill that warms the release cache for release groups
|
||||
that are owned but not known-complete** — bounded, resumable, and
|
||||
shaped exactly like `BackfillLibraryDiscographies`, which is the proven
|
||||
pattern for this in the codebase.
|
||||
|
||||
The scoping rule is the user's and it is the right one: not "every
|
||||
album by every artist in the library" (50 release groups per artist,
|
||||
mostly never opened) but albums with owned tracks — narrowed further,
|
||||
now, to the ones a local answer cannot already cover. The query gains
|
||||
one clause: skip release groups whose `GetAlbumCompleteness` reports
|
||||
`complete`.
|
||||
|
||||
Sketch:
|
||||
|
||||
1. A query for release groups with ≥1 owned track and no warm release
|
||||
cache entry. `release_groups.mbid` is the key; the owned-track join
|
||||
is `audio_files → recordings → release_group_recordings`, the same
|
||||
shape `unenrichedLibraryArtistMBIDs` already uses one table over.
|
||||
2. Order by owned-track count descending, so the albums the user has
|
||||
most of are warmed first — same reasoning as the discography
|
||||
backfill's ordering, same benefit if a run is cut short.
|
||||
3. Run through `releasesSF`, so it never double-fetches a release group
|
||||
an interactive open is already handling.
|
||||
4. Bound a run (`discogBackfillMaxPerRun` has a value to copy) and make
|
||||
it resumable: the resume marker is the response cache itself —
|
||||
`BrowseReleasesCached` already answers "is this one done", so unlike
|
||||
the discography path this needs **no new flag column**.
|
||||
5. Trigger it where `BackfillLibraryDiscographies` is triggered, and
|
||||
register it with `jobs` so it has progress, pause and cancel like
|
||||
every other long-running operation.
|
||||
|
||||
### The rate limiter is the whole design constraint
|
||||
|
||||
> **Update (2026-08-13): the priority half is built, and the sentence
|
||||
> below is wrong on a detail.** `e.mb` runs on `mbSearchLimiter`
|
||||
> (`NewRateLimiterBurst(3, 1)`); the 1 req/s `NewRateLimiter()` cited
|
||||
> here is the *artist image* limiter. Both are shared and both were
|
||||
> FIFO. `RateLimiter.WithBackgroundLane` + `WithBackgroundPriority(ctx)`
|
||||
> now make a marked caller yield to interactive work and pace at 1/s,
|
||||
> and `jobs.KindCatalogEnrich` + `startBackfillJob` give the existing
|
||||
> backfills progress and cancel. **"Do not start until the priority
|
||||
> question has an answer" is satisfied** — mark this backfill's context
|
||||
> and register it the way `BackfillLibraryDiscographies` now is.
|
||||
> `PrefetchReleases`' cap of 8 is still unrevisited.
|
||||
|
||||
One shared `NewRateLimiter()` at 1 req/s (`explore.go:84`) serves this,
|
||||
`PrefetchReleases`, and every interactive browse. A backfill over a
|
||||
few thousand owned albums is *hours* of wall clock at that rate — which
|
||||
is fine for a background job, and not fine if it starves the album page
|
||||
the user is looking at right now.
|
||||
|
||||
That is the real work in this plan, and it is not the query:
|
||||
|
||||
- Interactive browses need to **jump the queue**. Today they cannot;
|
||||
there is one limiter and it is FIFO.
|
||||
- `PrefetchReleases`' cap of 8 was sized when nothing else competed for
|
||||
the limiter. Revisit it in the same change.
|
||||
- The 60 s fallback the `AlbumReleasesFailed` fix installed is sized
|
||||
for today's contention. If a backfill can queue behind it, that
|
||||
number is wrong again — which is an argument for priority, not for a
|
||||
bigger number.
|
||||
|
||||
Do not start the query until the priority question has an answer.
|
||||
|
||||
## The alternative that was considered and rejected
|
||||
|
||||
**Project release-group tracklists in the dump build and ship them in
|
||||
the artifact.** The data is there: `canonical_musicbrainz_data.csv`
|
||||
carries `release_mbid` *and* `recording_mbid`
|
||||
(`dumpcatalog.go:520`), and `release_to_rg` already maps release →
|
||||
release group. It is derivable from bytes the index build already
|
||||
streams, with no new API surface at all, and it would work offline on
|
||||
first launch with no per-user backfill.
|
||||
|
||||
It is rejected **for this plan** because the artifact is built
|
||||
centrally and is byte-identical for every user, so "albums the user
|
||||
owns a track of" cannot be a filter on it. Shipping tracklists for the
|
||||
whole catalog means per-recording rows against a ~900 MB artifact
|
||||
budget (~426 B/row measured), and gating on a popularity floor means it
|
||||
is absent for exactly the obscure albums a local backfill would have
|
||||
covered.
|
||||
|
||||
Worse than absent, in fact — and this is the argument that actually
|
||||
kills it. The floor is not one number over artists; it is a **per
|
||||
artist track budget** (`dumpcatalog.go:58-89`): 50 tracks for a tier-A
|
||||
artist, 25 for tier B, 12 for tier C. A projected tracklist would
|
||||
therefore be *whichever* of an album's tracks survived that budget,
|
||||
with nothing marking the rest as absent — so the album page would count
|
||||
owned against a truncated denominator and render "Play 7 of 9" for a
|
||||
twelve-track album. That is a confident lie, where the honest states
|
||||
this plan's alternative produces (complete / incomplete / unknown) are
|
||||
at worst silent.
|
||||
|
||||
Note that `markLibraryArtists` (`dumpcatalog.go:246`) already grants
|
||||
every library artist full coverage — 500 tracks, 100 release groups —
|
||||
by reading the local library, so the per-user tailoring this option
|
||||
supposedly cannot have does exist in code. It is a no-op in the CI
|
||||
build (empty library), and reaching it means a **local** dump build:
|
||||
the ~205 GB, half-a-day download the entire artifact design exists to
|
||||
avoid. Whoever finds that function next should read this paragraph
|
||||
before getting excited about it.
|
||||
|
||||
Worth revisiting if the artifact ever gains per-user tailoring, or if a
|
||||
measurement shows the row count is smaller than feared. Note it also
|
||||
yields the *canonical* tracklist rather than MusicBrainz's full version
|
||||
list, so the versions dropdown would still browse live when opened.
|
||||
|
||||
## Done when
|
||||
|
||||
- Opening an owned album that has never been opened before renders its
|
||||
catalog tracklist with no network call, after one backfill run.
|
||||
- An interactive browse issued while the backfill is running is not
|
||||
delayed by it.
|
||||
- The backfill appears in the jobs indicator, and can be paused and
|
||||
cancelled there.
|
||||
- A second run after a completed one does approximately nothing.
|
||||
+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.
|
||||
|
||||
@@ -38,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
|
||||
@@ -121,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
|
||||
@@ -132,6 +192,24 @@ 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))
|
||||
|
||||
# What a merge to main would release, without releasing it. Reads the
|
||||
# same .releaserc.yml CI does, so "why did that not cut a version" is
|
||||
# answerable locally instead of by pushing and watching. Needs no
|
||||
# credentials: --dry-run neither tags nor publishes.
|
||||
#
|
||||
# The pins must stay identical to release.yml's, which is where the note
|
||||
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
||||
# release notes come out empty with everything green.
|
||||
release-dry: ## Print the version a merge to main would release
|
||||
@npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@13 \
|
||||
-p @semantic-release/release-notes-generator@14 \
|
||||
-p @semantic-release/changelog@7 \
|
||||
-p @semantic-release/exec@7 \
|
||||
-p conventional-changelog-conventionalcommits@9 \
|
||||
semantic-release --dry-run --no-ci
|
||||
|
||||
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
||||
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
|
||||
# constant prefix, so a call site imports '@go/library/library.js'.
|
||||
@@ -147,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
|
||||
|
||||
@@ -106,5 +106,8 @@ make dev # run with hot-reload
|
||||
make build-prod # produce a release binary
|
||||
```
|
||||
|
||||
More detail for contributors lives in
|
||||
[`docs/dev/overview.md`](./docs/dev/overview.md) and [`CLAUDE.md`](./CLAUDE.md).
|
||||
More detail for contributors lives in [`CLAUDE.md`](./CLAUDE.md) — the
|
||||
architecture, the conventions and the reasons behind them. What is
|
||||
being worked on is [the issue
|
||||
tracker](https://git.ljones.me/yonlu/yellowjacket/issues); #73 is the
|
||||
roadmap.
|
||||
|
||||
@@ -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:
|
||||
|
||||
+28
-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
|
||||
@@ -484,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()
|
||||
@@ -505,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) {
|
||||
@@ -522,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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user