Compare commits
20
Commits
c94c97f604
...
dd17a4d8eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd17a4d8eb | ||
|
|
e7748f1fd5 | ||
|
|
1128881e8d | ||
|
|
cad3d1339b | ||
|
|
453d5df0da | ||
|
|
84963e38bd | ||
|
|
deb3f3da7e | ||
|
|
60779c41c3 | ||
|
|
a4ada725a2 | ||
|
|
04114eabae | ||
|
|
162c68769f | ||
|
|
c9905fbcff | ||
|
|
4471db3aef | ||
|
|
f47b2db308 | ||
|
|
e7873bded3 | ||
|
|
edb13a6f39 | ||
|
|
20fbf28f2a | ||
|
|
878cf4b561 | ||
|
|
dc890d1fcc | ||
|
|
dcc40b1781 |
@@ -22,9 +22,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install build dependencies
|
- name: Install build dependencies
|
||||||
run: |
|
run: |
|
||||||
|
# Wails v3 resolves GTK4 + WebKitGTK 6.0 by default; webkit2gtk-4.1 +
|
||||||
|
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
|
||||||
|
# These must match the PKGBUILD's depends=() — makepkg installs
|
||||||
|
# nothing itself, so a mismatch fails at link time, not at check time.
|
||||||
pacman -Syu --noconfirm --needed \
|
pacman -Syu --noconfirm --needed \
|
||||||
base-devel git go nodejs pnpm curl sudo \
|
base-devel git go nodejs pnpm curl sudo \
|
||||||
webkit2gtk-4.1 gtk3 alsa-lib
|
webkitgtk-6.0 gtk4 alsa-lib
|
||||||
|
|
||||||
- name: Create unprivileged build user
|
- name: Create unprivileged build user
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+19
-22
@@ -66,12 +66,14 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
# libwebkit2gtk-4.1-dev and libasound2-dev are not optional:
|
# libwebkitgtk-6.0-dev and libasound2-dev are not optional:
|
||||||
# the app is cgo, and without alsa.pc oto/v3 fails at
|
# the app is cgo, and without alsa.pc oto/v3 fails at
|
||||||
# `pkg-config --cflags -- alsa` before anything is compiled.
|
# `pkg-config --cflags -- alsa` before anything is compiled.
|
||||||
|
# ubuntu:24.04 ships webkitgtk-6.0, which is what wails v3
|
||||||
|
# builds against by default.
|
||||||
apt-get install -y -qq --no-install-recommends \
|
apt-get install -y -qq --no-install-recommends \
|
||||||
ca-certificates curl git jq build-essential pkg-config \
|
ca-certificates curl git jq build-essential pkg-config \
|
||||||
libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev ffmpeg
|
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev ffmpeg
|
||||||
|
|
||||||
# Cloned by hand rather than with actions/checkout: that is a JS
|
# Cloned by hand rather than with actions/checkout: that is a JS
|
||||||
# action and needs node inside the job container before any step
|
# action and needs node inside the job container before any step
|
||||||
@@ -169,7 +171,7 @@ jobs:
|
|||||||
working-directory: /src
|
working-directory: /src
|
||||||
run: make ui-test
|
run: make ui-test
|
||||||
|
|
||||||
# frontend/wailsjs is generated by `wails`, not by `go generate`,
|
# frontend/bindings is generated by `wails3`, not by `go generate`,
|
||||||
# so the codegen pre-commit hook does not cover it.
|
# so the codegen pre-commit hook does not cover it.
|
||||||
- name: Bindings are current
|
- name: Bindings are current
|
||||||
working-directory: /src
|
working-directory: /src
|
||||||
@@ -182,7 +184,9 @@ jobs:
|
|||||||
run: make skill-check
|
run: make skill-check
|
||||||
|
|
||||||
# ---------------------------------------------------------------- #
|
# ---------------------------------------------------------------- #
|
||||||
# Job 2: the real app, under a virtual display. #
|
# Job 2: the real app, headless. v3's `-tags server` needs no #
|
||||||
|
# display, so the Xvfb this job used to wrap everything in is gone. #
|
||||||
|
# `dbus-run-session` stays, for MPRIS. #
|
||||||
# ---------------------------------------------------------------- #
|
# ---------------------------------------------------------------- #
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -217,8 +221,8 @@ jobs:
|
|||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq --no-install-recommends \
|
apt-get install -y -qq --no-install-recommends \
|
||||||
ca-certificates curl git jq build-essential pkg-config \
|
ca-certificates curl git jq build-essential pkg-config \
|
||||||
libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev \
|
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||||
xvfb dbus dbus-x11 ffmpeg libasound2t64 \
|
dbus dbus-x11 ffmpeg libasound2t64 \
|
||||||
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
||||||
|
|
||||||
- name: Clone repo at this commit
|
- name: Clone repo at this commit
|
||||||
@@ -245,24 +249,17 @@ jobs:
|
|||||||
apt-get install -y -qq --no-install-recommends nodejs
|
apt-get install -y -qq --no-install-recommends nodejs
|
||||||
corepack enable
|
corepack enable
|
||||||
|
|
||||||
# scripts/seed-sandbox.sh drives the real AddLibrary binding
|
# @playwright/cli is gone with v2. seed-sandbox.sh drove the real
|
||||||
# through playwright-cli, so the CLI has to be on PATH.
|
# AddLibrary binding through a browser because `window.go` was the
|
||||||
- name: Playwright CLI
|
# only way in; v3 answers the same call over HTTP, so the seed is
|
||||||
run: npm install -g @playwright/cli
|
# curl now and needs no CLI, no second Chromium and no shared
|
||||||
|
# PLAYWRIGHT_BROWSERS_PATH revision dance.
|
||||||
- name: Browsers
|
- name: Browsers
|
||||||
working-directory: /src/e2e
|
working-directory: /src/e2e
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
# PLAYWRIGHT_BROWSERS_PATH unifies the *location*, not the
|
|
||||||
# *revisions*: @playwright/cli bundles its own playwright-core
|
|
||||||
# pinned to a different Chromium build than @playwright/test,
|
|
||||||
# so each installs its own into the shared directory. Drop
|
|
||||||
# either line and the other fails with "Browser chromium is
|
|
||||||
# not installed; expected executable at ...".
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
npx playwright install --with-deps chromium webkit
|
npx playwright install --with-deps chromium webkit
|
||||||
playwright-cli install-browser chromium
|
|
||||||
|
|
||||||
# oto/v3 talks to libasound directly, and a container has no
|
# oto/v3 talks to libasound directly, and a container has no
|
||||||
# PulseAudio socket to fall back on — so it needs a default device
|
# PulseAudio socket to fall back on — so it needs a default device
|
||||||
@@ -285,10 +282,10 @@ jobs:
|
|||||||
#
|
#
|
||||||
# PulseAudio's null sink is timer-scheduled and does pace — the
|
# PulseAudio's null sink is timer-scheduled and does pace — the
|
||||||
# 0.76 s over is the buffer draining, not a rate error; 12 s of
|
# 0.76 s over is the buffer draining, not a rate error; 12 s of
|
||||||
# audio takes 13.5 s. Verified under the private session bus and
|
# audio takes 13.5 s. Verified under the private session bus
|
||||||
# Xvfb that dev-headless.sh runs the app in. It needs no system
|
# dev-headless.sh runs the app in. It needs no system D-Bus and
|
||||||
# D-Bus and no kernel module, which is why it is reachable from a
|
# no kernel module, which is why it is reachable from a container
|
||||||
# container at all.
|
# at all.
|
||||||
- name: Real-time audio sink
|
- name: Real-time audio sink
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
+19
-2
@@ -1,6 +1,6 @@
|
|||||||
frontend/dist
|
frontend/dist
|
||||||
node_modules
|
node_modules
|
||||||
build
|
build/bin/
|
||||||
test_data
|
test_data
|
||||||
test.db
|
test.db
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Thumbs.db
|
|||||||
node_modules/
|
node_modules/
|
||||||
.next/
|
.next/
|
||||||
dist/
|
dist/
|
||||||
build/
|
# build/ holds v3 build assets and is tracked; only its output is not.
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
@@ -53,3 +53,20 @@ vendor/
|
|||||||
coverage/
|
coverage/
|
||||||
.cache/
|
.cache/
|
||||||
tmp/
|
tmp/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Task's checksum cache, written by every `wails3 task` run.
|
||||||
|
.task/
|
||||||
|
|
||||||
|
# Generated by build/linux/Taskfile.yml's generate:dotdesktop from
|
||||||
|
# build/config.yml on every build, and consumed by the deb/rpm/AppImage
|
||||||
|
# packaging tasks that depend on it. A derived file with one source.
|
||||||
|
build/linux/yellowjacket.desktop
|
||||||
|
|
||||||
|
# `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.
|
||||||
|
build/ios/
|
||||||
|
build/android/
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ here has disappeared.
|
|||||||
Fifteen things cost a cycle each the first time. They are here, not in a
|
Fifteen things cost a cycle each the first time. They are here, not in a
|
||||||
reference, because you need them *before* the failure, not after.
|
reference, because you need them *before* the failure, not after.
|
||||||
|
|
||||||
- **Time out every binding call.** A bound Go method called with wrong
|
- **Call a binding through the bridge.** `window.go` does not exist
|
||||||
argument types makes the backend log `error parsing arguments` and
|
under Wails v3 — the bindings are bundled modules, not a global — so
|
||||||
**never fire the callback**, so the promise hangs forever. Use
|
use `window.__yjEvents.call(path, args, ms)` (browser) or
|
||||||
`window.__yjEvents.call(path, args, ms)` (browser) or `callBinding`
|
`callBinding` (specs). Both post to the runtime's own endpoint by
|
||||||
(specs), never a bare `window.go.…`. When one hangs anyway,
|
method name, so they work on any page, including one with no init
|
||||||
`make dev-logs` — `.dev/app.log` is the only place the reason appears.
|
script.
|
||||||
|
|
||||||
|
A bad call now *rejects*, and says why: a wrong type comes back as a
|
||||||
|
TypeError naming the argument, a wrong count as
|
||||||
|
`expects 4 arguments, got 3`, an unknown method as a ReferenceError.
|
||||||
|
Under v2 the backend logged `error parsing arguments` and never fired
|
||||||
|
the callback, so `.dev/app.log` was the only place the reason
|
||||||
|
appeared and the timeout was the only thing that made the mistake
|
||||||
|
visible. The timeout is still there, but now it means a genuinely
|
||||||
|
hung request.
|
||||||
- **Nothing is clickable on a fresh `YJ_HOME`.** `<first-run-wizard>`
|
- **Nothing is clickable on a fresh `YJ_HOME`.** `<first-run-wizard>`
|
||||||
intercepts all pointer events until a library exists, and the click
|
intercepts all pointer events until a library exists, and the click
|
||||||
fails with a Playwright interception error that reads like a selector
|
fails with a Playwright interception error that reads like a selector
|
||||||
@@ -387,10 +396,10 @@ never get the shell back.
|
|||||||
when iterating on a single package:
|
when iterating on a single package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -tags webkit2_41 ./backend/player/ # the app build
|
go test ./backend/player/ # the app build
|
||||||
go test -tags webkit2_41 -run TestName ./backend/player/
|
go test -run TestName ./backend/player/
|
||||||
go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/... # dump importer
|
go test -tags indexbuild ./backend/explore/... ./cmd/... # dump importer
|
||||||
go test -tags "webkit2_41 dev" ./backend/testctl/... # control surface
|
go test -tags dev ./backend/testctl/... # control surface
|
||||||
```
|
```
|
||||||
|
|
||||||
Forgetting the tag gives a build error that looks like a missing
|
Forgetting the tag gives a build error that looks like a missing
|
||||||
|
|||||||
@@ -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
|
address on purpose, so no seed depends on what the explore artifact
|
||||||
server happened to be serving.
|
server happened to be serving.
|
||||||
|
|
||||||
Rebuild a seed after any schema change, or the restored database is
|
Rebuild a seed after any schema change. Nothing migrates a restored
|
||||||
migrated on open in a way the seed's author never saw.
|
database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed
|
||||||
|
keeps its old columns, the app starts, and the first query dies on
|
||||||
|
`no such column`.
|
||||||
|
|
||||||
|
**Restoring the seed does not disable the artifact fetch — only
|
||||||
|
*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone,
|
||||||
|
so on a developer machine the restored app immediately downloads and
|
||||||
|
imports the real ~1.1M-row catalog, through the one writer connection,
|
||||||
|
while whatever you started it for is running. A full `make e2e` against
|
||||||
|
that reported **14 failures** that were all contention; the same suite
|
||||||
|
against the same seed with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default
|
||||||
|
```
|
||||||
|
|
||||||
|
is the configuration CI runs (`ci.yml` sets exactly that address) and is
|
||||||
|
what to use before believing a failure. The tell is in `.dev/app.log` —
|
||||||
|
an import logging progress — and in how the failures look: timeouts
|
||||||
|
spread across unrelated specs rather than one surface being wrong.
|
||||||
|
|||||||
@@ -74,9 +74,14 @@ behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
|
|||||||
- **`snapshot` writes a file, it does not print the tree.** The
|
- **`snapshot` writes a file, it does not print the tree.** The
|
||||||
command prints a path under `outputDir`; read that. Only the tail
|
command prints a path under `outputDir`; read that. Only the tail
|
||||||
is echoed.
|
is echoed.
|
||||||
- **Three separate browser caches.** `playwright-cli`, `@playwright/test`
|
- **Two separate browser caches.** `@playwright/test`
|
||||||
(`make e2e-setup`) and the Vitest provider (`make ui-setup`) each
|
(`make e2e-setup`) and the Vitest provider (`make ui-setup`) each
|
||||||
download their own Chromium. One working is no guarantee for the next.
|
download their own Chromium. One working is no guarantee for the
|
||||||
|
other. There used to be a third: `playwright-cli` was a *required*
|
||||||
|
dependency because `scripts/seed-sandbox.sh` drove `AddLibrary`
|
||||||
|
through a real page, `window.go` being v2's only way in. v3 answers
|
||||||
|
the same call over HTTP, so the seed is `curl` now and the CLI is
|
||||||
|
only an exploratory convenience.
|
||||||
- **`getByRole('button', { name })` matches substrings.** "Play" also
|
- **`getByRole('button', { name })` matches substrings.** "Play" also
|
||||||
matches "Add queue to playlist"; transport controls need
|
matches "Add queue to playlist"; transport controls need
|
||||||
`exact: true`.
|
`exact: true`.
|
||||||
|
|||||||
@@ -1,66 +1,76 @@
|
|||||||
# Changing the database schema
|
# Changing the database schema
|
||||||
|
|
||||||
The reasoning — why there are two files, what the old 48-step migration
|
The reasoning — why the local library is shaped like files rather than
|
||||||
chain got wrong, and when squashing is legitimate — is in `CLAUDE.md`
|
like MusicBrainz, and what the metadata tables cost before they went —
|
||||||
under *Backend packages → database*. Read it once. This is the
|
is in `CLAUDE.md` under *Backend packages → database*. Read it once.
|
||||||
checklist.
|
This is the checklist.
|
||||||
|
|
||||||
**A brand-new table needs one file, not two.** The rule below is about
|
**There is one description of the schema and no migration chain.**
|
||||||
a *column added to a table that already exists*. `applySchema` runs
|
`sql/schemas/*.sql` declares the current shape; `applySchema` runs every
|
||||||
every file in `sql/schemas/` on every open, so a
|
file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent.
|
||||||
`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a
|
`sql/migrations/`, `applyMigrations` and `schema_migrations` were
|
||||||
migration for it would be a second description of the same table — the
|
squashed away with plan 013. So:
|
||||||
thing the third rule forbids. Its indexes go in the schema file too,
|
|
||||||
because the column and the index arrive together.
|
**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
|
A new table has a second gate: **`backend/datamap`**. Add an entry
|
||||||
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
|
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
|
||||||
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
|
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
|
||||||
wants an explicit exemption with a note, because authored data is what
|
wants an explicit exemption with a note, because authored data is what a
|
||||||
a user cannot get back.
|
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
|
**A stale one fails at the first query, not at open**, which is worth
|
||||||
EXISTS`, the literal target shape, what sqlc reads and what a fresh
|
knowing before you read the error. `applySchema` is
|
||||||
install gets verbatim. Add the new column **last** in the
|
`CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns
|
||||||
`CREATE TABLE`.
|
and gains nothing; the app then starts fine and dies on
|
||||||
2. **`backend/database/sql/migrations/NNNN_description.sql`** — the
|
`no such column: title`. Every tier that does not *run the app* — unit
|
||||||
`ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an
|
tests, `make ui-test`, `tsc` — is green while this is true, because
|
||||||
existing database to the same shape. Schema files are a no-op against
|
they build their database from the current schema. `make e2e` and
|
||||||
a table that already exists, so without this an upgrade never gets
|
`make dev` are the two that will tell you, and only after the seed has
|
||||||
the column.
|
been rebuilt.
|
||||||
|
|
||||||
Then:
|
## The four ways this goes wrong
|
||||||
|
|
||||||
```bash
|
- **A query file must be ASCII.** sqlc's parameter rewriter works on
|
||||||
make generate # sqlc + templ
|
byte offsets, so a single non-ASCII character in a *query* comment
|
||||||
go test -tags webkit2_41 ./backend/database/ # migration + column-order tests
|
(an em dash, a curly quote) shifts every placeholder and generates
|
||||||
make test
|
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
|
## Where things go
|
||||||
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.
|
|
||||||
|
|
||||||
New queries go in `backend/database/sql/queries/`; generated Go lands in
|
New queries go in `backend/database/sql/queries/`; generated Go lands in
|
||||||
`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests
|
`backend/database/sql/sqlcgen/`, which is never edited by hand. Anything
|
||||||
use `database.NewTestDB(t)`, built by the same `applySchema` production
|
returning a track selects from the `track_metadata` view rather than
|
||||||
uses, so the two cannot diverge.
|
re-joining — that is why there is one row type and one mapper.
|
||||||
|
|
||||||
|
Tests use `database.NewTestDB(t)`, built by the same `applySchema`
|
||||||
|
production uses, and seed rows with `database.InsertTestTrack(t, db,
|
||||||
|
database.TestTrack{...})` rather than assembling inserts by hand.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# The component and store tier (`make ui-test`)
|
# The component and store tier (`make ui-test`)
|
||||||
|
|
||||||
313 tests in a real Chromium in ~2 s with no Wails, no backend, no
|
757 tests in a real Chromium with no Wails, no backend, no seeded
|
||||||
seeded library and no virtual display. This is the cheapest coverage
|
library and no virtual display. This is the cheapest coverage available
|
||||||
available and where the bulk of UI regression belongs.
|
and where the bulk of UI regression belongs.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make ui-setup # once: the Vitest provider's own Chromium
|
make ui-setup # once: the Vitest provider's own Chromium
|
||||||
@@ -15,12 +15,20 @@ make ui-test UI_ARGS='store/queue' # filter
|
|||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
`frontend/wailsjs/` is a pure passthrough — every binding is
|
Wails v3 routes every runtime call — bindings, event emits, window,
|
||||||
`window.go[svc][Type][Method](args)`, every runtime call is
|
dialogs, clipboard — through one IPC transport, and `setTransport()` is
|
||||||
`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts`
|
a public seam for replacing it. So
|
||||||
replaces **those two globals and nothing else**, and the tests then
|
`frontend/test/support/wails-fake.ts` replaces **that and nothing
|
||||||
exercise the *real* generated bindings and the *real* store code. No
|
else**, and the tests then exercise the *real* generated bindings, the
|
||||||
module mocking, and no second description of the Wails layer.
|
*real* runtime and the *real* store code. No module mocking, and no
|
||||||
|
second description of the Wails layer.
|
||||||
|
|
||||||
|
A binding call carries a *method ID* (an FNV-1a hash of the Go method's
|
||||||
|
fully-qualified name), not a name, so the fake derives the ID → path
|
||||||
|
map from the generated tree at setup: each package's `index.ts`
|
||||||
|
re-exports its service under the Go type's real name, which is the one
|
||||||
|
place that casing survives. A path that never maps records as `#<id>`
|
||||||
|
and fails the assertion naming it.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
emit(Events.QueueChanged, payload); // push a backend event
|
emit(Events.QueueChanged, payload); // push a backend event
|
||||||
@@ -31,11 +39,19 @@ lastArgs('queue.Queue.SetQueue');
|
|||||||
const el = await fixture('now-playing'); // mount; shadow()/text() query it
|
const el = await fixture('now-playing'); // mount; shadow()/text() query it
|
||||||
```
|
```
|
||||||
|
|
||||||
The dispatcher mirrors wails' own `desktop/events.js`, including
|
Delivery is not mirrored — `emit()` goes through the runtime's own
|
||||||
`maxCallbacks` expiry and the fact that a frontend `EventsEmit`
|
`window._wails.dispatchWailsEvent`, which is the entry point the
|
||||||
notifies local listeners *before* Go.
|
backend's push uses, so listener expiry and ordering are the runtime's
|
||||||
|
real code. What *is* mirrored is one line of Go: how
|
||||||
|
`EventManager.Emit` packs variadic data into an event's single `data`
|
||||||
|
field (none is null, one is the value, more is the slice).
|
||||||
|
|
||||||
## Four things that will cost you time
|
A frontend `Events.Emit` no longer notifies local listeners before Go —
|
||||||
|
v3 calls the backend, which sends the event back out to every window.
|
||||||
|
The page still sees its own emit, one round trip later rather than
|
||||||
|
synchronously.
|
||||||
|
|
||||||
|
## Five things that will cost you time
|
||||||
|
|
||||||
- **Store singletons are constructed at module import**, before any test
|
- **Store singletons are constructed at module import**, before any test
|
||||||
can stub. `test/setup.ts` therefore carries import-time defaults for
|
can stub. `test/setup.ts` therefore carries import-time defaults for
|
||||||
@@ -54,6 +70,13 @@ notifies local listeners *before* Go.
|
|||||||
- **`@lit-labs/virtualizer` never produces two identical frames**, so
|
- **`@lit-labs/virtualizer` never produces two identical frames**, so
|
||||||
`toMatchScreenshot` on `<queue-panel>` fails with "could not capture a
|
`toMatchScreenshot` on `<queue-panel>` fails with "could not capture a
|
||||||
stable screenshot" rather than a diff. Assert on its rows instead.
|
stable screenshot" rather than a diff. Assert on its rows instead.
|
||||||
|
- **A v3 binding settles several microtasks after a v2 one did** — it
|
||||||
|
goes through `Call()`, an async `runtimeCallWithID`, the transport and
|
||||||
|
a `CancellablePromise`, where v2's `window.go` proxy resolved one
|
||||||
|
promise. `fixture()` drains microtasks between two renders so a
|
||||||
|
component that loads in `firstUpdated` is loaded when it returns.
|
||||||
|
Microtasks and not a timer, deliberately: a timer hangs forever under
|
||||||
|
the suites that install fake ones.
|
||||||
|
|
||||||
Visual baselines are font-hinting and compositing sensitive, which is
|
Visual baselines are font-hinting and compositing sensitive, which is
|
||||||
why they are opt-in: they only mean anything on the machine that
|
why they are opt-in: they only mean anything on the machine that
|
||||||
@@ -61,14 +84,15 @@ recorded them.
|
|||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`,
|
`frontend/bindings/` is generated by `wails3`, **not** by `go generate`,
|
||||||
so the pre-commit codegen check does not cover it — a renamed Go bound
|
so the pre-commit codegen check does not cover it — a renamed Go bound
|
||||||
method first shows up at runtime, as a call that never settles.
|
method first shows up at runtime, as a call that never settles.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make bindings-check # ~1.5 s, also a pre-commit hook
|
make bindings-check # ~3.5 s warm, also a pre-commit hook
|
||||||
make bindings # regenerate for real
|
make bindings # regenerate for real
|
||||||
```
|
```
|
||||||
|
|
||||||
The generator rewrites `wailsjs/runtime/*` as mode 755 every run; that
|
No build tags are passed: the generator is a static analyser that sees
|
||||||
is churn, not drift, and the check ignores it.
|
only the configuration it is told about, and the one that matters is
|
||||||
|
the one users run, which is the default tag set.
|
||||||
|
|||||||
@@ -157,6 +157,34 @@ would be two copies free to drift.
|
|||||||
Bulk loads must suspend them: measured on a real import, assembly runs at
|
Bulk loads must suspend them: measured on a real import, assembly runs at
|
||||||
~31 rows/s with the triggers attached and ~4,700 rows/s without.
|
~31 rows/s with the triggers attached and ~4,700 rows/s without.
|
||||||
|
|
||||||
|
They are now **dropped and recreated** on every open rather than created
|
||||||
|
with "already exists" tolerated, because a trigger is a definition: an
|
||||||
|
existing install would otherwise keep the first one it ever got, and
|
||||||
|
these definitions are where this table's write cost is decided.
|
||||||
|
`explore_index_au` is scoped to `UPDATE OF title, artist_name, aliases`
|
||||||
|
with a `WHEN` guard on them having changed, so the common write — an
|
||||||
|
upsert whose merge rules keep every existing value — re-indexes nothing.
|
||||||
|
|
||||||
|
## The writer stall that broke playback is only half explained (2026-08-14)
|
||||||
|
|
||||||
|
The user's report — play an album, the track changes, the transport
|
||||||
|
stays paused, nothing appears in the queue, the play button does
|
||||||
|
nothing — was diagnosed on the running app: 91% of its CPU was
|
||||||
|
`BackfillLibraryDiscographies` → `upsertBatch`, with four of its six
|
||||||
|
workers parked in `sql.(*DB).conn` waiting for the single write
|
||||||
|
connection, while the play path did its writes inline under `q.mu` and
|
||||||
|
`p.mu`. The locking half is fixed and tested (see CLAUDE.md, "Playing a
|
||||||
|
track does not wait for the database to hear about it").
|
||||||
|
|
||||||
|
**What is not explained is why those upserts were so expensive.**
|
||||||
|
|
||||||
|
> **Recovered note, 2026-08-14.** The rest of this section was lost to a
|
||||||
|
> mishandled `git stash --keep-index` before it was ever committed; what
|
||||||
|
> survives above is verbatim, and the two arguments that followed
|
||||||
|
> "Two things argue against the obvious answer" are gone. Re-derive them
|
||||||
|
> from a profile before acting on this — do not treat the question as
|
||||||
|
> answered.
|
||||||
|
|
||||||
## Explore "library only" toggle was removed (2026-08-06)
|
## Explore "library only" toggle was removed (2026-08-06)
|
||||||
|
|
||||||
The Explore UI used to have a "library only" mode toggle
|
The Explore UI used to have a "library only" mode toggle
|
||||||
@@ -2326,3 +2354,43 @@ Five more things worth keeping:
|
|||||||
child does not prove the icon inside it is `pointer-events: none`.
|
child does not prove the icon inside it is `pointer-events: none`.
|
||||||
Both were checked with a real mouse (`mousemove`/`mousedown`/
|
Both were checked with a real mouse (`mousemove`/`mousedown`/
|
||||||
`mouseup`) and a real Tab/Enter before either was believed.
|
`mouseup`) and a real Tab/Enter before either was believed.
|
||||||
|
|
||||||
|
## A queue of work has to ask whether there is work
|
||||||
|
|
||||||
|
Reported as "the autotag page has every album in it, even the MB-tagged
|
||||||
|
ones". Both halves of that were true and they were two different bugs,
|
||||||
|
which is why the first answer found (the pending list) accounted for
|
||||||
|
nine rows out of 2172.
|
||||||
|
|
||||||
|
**Every scanned folder gets a `tagging_items` row.**
|
||||||
|
`UpsertTaggingItemOnTrackAdd` runs per track with `status = 'pending'`
|
||||||
|
and no condition, so the table is a row per album folder, not a queue.
|
||||||
|
`saveAudioFile` *does* record the answer — `audio_files.tag_status` is
|
||||||
|
`user_confirmed` on import for any file carrying a recording MBID, and
|
||||||
|
`idx_audio_files_tag_status_untagged` was declared for the filter — but
|
||||||
|
no query in the app read the column. Pending therefore meant "has a
|
||||||
|
row". The four queue queries ask the files now
|
||||||
|
(`EXISTS … tag_status = 'untagged'`), which matters most where it is
|
||||||
|
least visible: `startPrefetch` was scoring every album in a tagged
|
||||||
|
library against MusicBrainz.
|
||||||
|
|
||||||
|
**The 2094 were the Completed section, and nobody completed them.** A
|
||||||
|
backfill stamped `status = 'confirmed'` on every folder whose files
|
||||||
|
already had MBIDs, and the sidebar keeps confirmed rows deliberately —
|
||||||
|
so the review page's history became a list of the library. They are
|
||||||
|
distinguishable without new state: every status flip the app performs
|
||||||
|
goes through `SetTaggingItemStatus` or `SetTaggingItemBestMatch` and
|
||||||
|
both stamp `last_checked_at`, so *confirmed with no `last_checked_at`,
|
||||||
|
no score and no best match* is the backfill's row and not the user's.
|
||||||
|
All 2094 were that shape; the two the app had actually applied were
|
||||||
|
not. Migration 0007 sets `cleared_at` on them — the column exists for
|
||||||
|
exactly this, and it keeps the row so a rescan cannot reset review
|
||||||
|
state.
|
||||||
|
|
||||||
|
Two smaller things fell out. The reviewed states are **exempt** from
|
||||||
|
the untagged-files predicate, or an applied folder would vanish from
|
||||||
|
Completed the instant it succeeded — the section is history, not work.
|
||||||
|
And `tag_status` was only ever written by the *insert* path, so a file
|
||||||
|
another tagger stamped after import kept `untagged` for ever and its
|
||||||
|
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
||||||
|
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
|||||||
|
# 011 — An owned artist's discography, whole and offline
|
||||||
|
|
||||||
|
**Status:** built, **not yet verified against a real library**. Lint,
|
||||||
|
the three Go test configurations, `tsc` and the Vitest suite all pass;
|
||||||
|
what has *not* happened is a run against a seeded library with real
|
||||||
|
MusicBrainz traffic, which is the only thing that can show the pass
|
||||||
|
completing an artist end to end. Do that before moving this to
|
||||||
|
`completed/`.
|
||||||
|
**Branch:** main
|
||||||
|
**Created:** 2026-08-13
|
||||||
|
**Depends on:** nothing
|
||||||
|
**Related:** 010 (owned albums, offline) — the same rate limiter, the
|
||||||
|
next layer down. 010 warms *tracklists*; this warms the *list of
|
||||||
|
albums*. Read 010's "the rate limiter is the whole design constraint"
|
||||||
|
section before building either.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
`BackfillLibraryDiscographies` sounds like it does this and does not.
|
||||||
|
Per owned artist, `indexOneArtist` (`searchindex.go:1910`) fetches from
|
||||||
|
ListenBrainz:
|
||||||
|
|
||||||
|
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
|
||||||
|
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
|
||||||
|
|
||||||
|
and both drop anything under `indexMinPopularity` (50 listens). So what
|
||||||
|
an owned artist's page shows offline is **their fifty most-listened
|
||||||
|
release groups**, not their discography. For an artist with a long tail
|
||||||
|
— early EPs, live albums, splits, anything regional — the missing rows
|
||||||
|
are precisely the ones a user who owns that artist is most likely to be
|
||||||
|
looking for.
|
||||||
|
|
||||||
|
**It is also untyped.** LB's `top-release-groups-for-artist` returns no
|
||||||
|
secondary types, so the first view of every backfilled artist has no
|
||||||
|
EP / Live / Compilation / Soundtrack distinction — the discography
|
||||||
|
renders as one undifferentiated list.
|
||||||
|
|
||||||
|
MusicBrainz's browse-by-artist has both the full list and the types,
|
||||||
|
and `BrowseReleaseGroups` (`explore.go:589`) already knows it: on
|
||||||
|
finding no secondary types on any indexed row it fires the browse **in
|
||||||
|
a goroutine, for next time**, and `AddFromCache` writes the result into
|
||||||
|
the index. So the fix is not new machinery. It is running that call
|
||||||
|
deliberately, once per owned artist, at scan time instead of
|
||||||
|
accidentally, on view, one artist at a time.
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Extend the existing post-scan pass — it is already bounded, resumable,
|
||||||
|
idempotent and ordered by owned-track count, which is the shape this
|
||||||
|
needs and the proven one in this codebase.
|
||||||
|
|
||||||
|
Per unenriched owned artist, in addition to today's LB fetches:
|
||||||
|
|
||||||
|
1. **`BrowseReleaseGroups`, paged to exhaustion.** `musicbrainz.go:318`
|
||||||
|
issues a single `Paginator{Limit: MaxLimit}` with no offset loop, so
|
||||||
|
a prolific artist is silently truncated at 100 release groups. Page
|
||||||
|
until a short response. This is the one change that makes the word
|
||||||
|
*full* honest, and it is a change to a function the interactive path
|
||||||
|
also calls — which is a win, not a risk.
|
||||||
|
2. **`SimilarArtists`.** `similar_artist_map` is not in the shipped
|
||||||
|
artifact and is filled lazily on view (`explore.go:905`), so it is
|
||||||
|
empty for every artist nobody has opened. It is one LB labs call and
|
||||||
|
already persists; folding it in here costs a request and removes the
|
||||||
|
page's last routine network dependency.
|
||||||
|
|
||||||
|
Deliberately **not** in scope: cover art for non-owned release groups.
|
||||||
|
It is roughly *RGs per artist* fetches rather than one — an order of
|
||||||
|
magnitude more requests than everything else here combined — and a
|
||||||
|
missing thumbnail degrades to a placeholder, where a missing release
|
||||||
|
group degrades to a page that is quietly wrong. Covers stay lazy.
|
||||||
|
|
||||||
|
## Four things that bite
|
||||||
|
|
||||||
|
**`discog_fetched` is one boolean and would now cover three fetches
|
||||||
|
with different failure modes.** Today it is set only if an LB fetch
|
||||||
|
returned rows (`indexOneArtist:1962`), which is the right rule for one
|
||||||
|
call and useless for three — an MB failure would either permanently
|
||||||
|
claim the artist as done or force the LB fetches to repeat. Track the
|
||||||
|
facets separately. Prefer **a new table keyed by artist MBID** over new
|
||||||
|
`explore_index` columns: `artifactimport.go:95` enumerates the columns
|
||||||
|
the artifact merge preserves, so a flag column added there is a second
|
||||||
|
place to remember, and forgetting it silently wipes every mark on the
|
||||||
|
next artifact update. A new table is also the single-file schema case
|
||||||
|
(`CREATE TABLE IF NOT EXISTS`, no migration) and needs a `datamap`
|
||||||
|
entry — `Cache` / `Swept`, since it is re-derivable.
|
||||||
|
|
||||||
|
**The `hasSecondaryTypes` heuristic re-fires forever for an artist who
|
||||||
|
has none.** An artist whose discography is entirely plain albums writes
|
||||||
|
`secondary_types = ''` on every row, so the "we must be missing them"
|
||||||
|
test is true on every visit and browses again (cheaply — 7-day
|
||||||
|
`cacheTTLEntity` — but forever). An explicit per-artist "browsed at"
|
||||||
|
mark retires the heuristic, which is a second reason for the table
|
||||||
|
above.
|
||||||
|
|
||||||
|
**Popularity is safe, and only because of the upsert rule.**
|
||||||
|
`AddFromCache` writes `Popularity: 0` for every browsed release group;
|
||||||
|
`upsertIndexConflictSQL:2180` is "highest wins", so it cannot clobber
|
||||||
|
the LB figures. The consequence is one to state rather than fix:
|
||||||
|
`TopReleaseGroupsByArtist` orders by popularity descending, so the deep
|
||||||
|
cuts this plan adds sort below the top fifty. That is the correct
|
||||||
|
order.
|
||||||
|
|
||||||
|
**The MB limiter is shared — and the priority work this needed is
|
||||||
|
done.** ~~One `NewRateLimiter()` at 1 req/s serves this,
|
||||||
|
`PrefetchReleases`, and every interactive browse~~ — 010 says that and
|
||||||
|
it is wrong on the detail: `e.mb` runs on `mbSearchLimiter`,
|
||||||
|
`NewRateLimiterBurst(3, 1)`, while the 1/s `NewRateLimiter()` at
|
||||||
|
`explore.go:84` is the *artist image* limiter. Both were shared with
|
||||||
|
background work and both are FIFO, which was the real problem.
|
||||||
|
|
||||||
|
Shipped ahead of this plan (same session it was written):
|
||||||
|
|
||||||
|
- `RateLimiter.WithBackgroundLane(perSecond)` plus
|
||||||
|
`WithBackgroundPriority(ctx)` — a marked caller yields entirely while
|
||||||
|
any interactive wait is outstanding, and is paced at MB's own 1/s
|
||||||
|
rather than the interactive burst rate. The marker is a context value
|
||||||
|
so a backfill and a detail page can call the same
|
||||||
|
`MusicBrainzClient` method and be treated differently.
|
||||||
|
- Both existing backfills mark their context, including the artist
|
||||||
|
image resolution (`GetArtistImage` takes a `ctx` now for no reason
|
||||||
|
other than carrying that marking).
|
||||||
|
- `jobs.KindCatalogEnrich` and `startBackfillJob` — both backfills are
|
||||||
|
registered, cancellable, and show progress. No job is registered
|
||||||
|
when there is nothing to do, which is every launch once the library
|
||||||
|
is covered.
|
||||||
|
|
||||||
|
So this plan inherits the lane: mark the new fetches background and add
|
||||||
|
them to the existing job's progress. What it must **not** do is treat
|
||||||
|
"a backfill is now polite" as licence to widen it without measuring —
|
||||||
|
the yield gate protects latency, not the origin's patience.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- An owned artist's page, opened for the first time after a scan,
|
||||||
|
renders their complete typed discography with no network call —
|
||||||
|
including release groups under the popularity floor and beyond the
|
||||||
|
first 100.
|
||||||
|
- Similar artists render offline for an owned artist nobody has opened.
|
||||||
|
- An interactive browse issued while the backfill runs is not delayed
|
||||||
|
by it.
|
||||||
|
- The backfill appears in the jobs indicator and can be paused and
|
||||||
|
cancelled.
|
||||||
|
- A second run after a completed one does approximately nothing, and an
|
||||||
|
artifact update does not undo a completed one.
|
||||||
@@ -0,0 +1,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,195 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 012 — What we ask the network for, and what we already had
|
||||||
|
|
||||||
|
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
|
||||||
|
configs), `tsc` and 752 Vitest tests pass; **not driven against the
|
||||||
|
real app**, so the numbers below are read off the code, not measured.
|
||||||
|
|
||||||
|
One claim in the audit was wrong and is corrected in finding 3:
|
||||||
|
`CheckLibraryMBIDs` is *not* dead — `downloadcatalog.go:152` calls it.
|
||||||
|
It has no *frontend* caller, which is what was checked and not what was
|
||||||
|
written.
|
||||||
|
**Branch:** none yet
|
||||||
|
**Created:** 2026-08-13
|
||||||
|
**Related:** 010 (owned albums offline), 011 (owned artists' discography)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Every frontend call site that can reach the network, and the backend
|
||||||
|
method behind it. The question asked of each: *is there a local answer
|
||||||
|
first, and if we do go out, do we go out once for many things or many
|
||||||
|
times for one?*
|
||||||
|
|
||||||
|
## What is already right, and is the standard the rest is measured against
|
||||||
|
|
||||||
|
- **Every catalog read is index-first.** `LookupArtist`,
|
||||||
|
`LookupReleaseGroup`, `BrowseReleaseGroups`,
|
||||||
|
`TopRecordingsForArtist`, `TopReleaseGroupsForArtist`,
|
||||||
|
`SimilarArtists` and `ResolveReleaseGroupMBIDs` all answer from
|
||||||
|
`explore_index` / `similar_artist_map` and only fall through on a
|
||||||
|
miss — several kick a background fetch and return empty rather than
|
||||||
|
blocking, with a `*Ready` event to re-read.
|
||||||
|
- **Album art has the right shape:** seed from the library, one
|
||||||
|
`GetThumbnails` batch that is *cached-only by contract*, then
|
||||||
|
per-item `GetThumbnail` calls that stream in
|
||||||
|
(`explore-view.ts:1445`). Nothing waits on a batch of network
|
||||||
|
fetches.
|
||||||
|
- **Artist art has the right shape in exactly one place:**
|
||||||
|
`seedSimilarArtistImagesFromLibrary`
|
||||||
|
(`explore-artist-details.ts:1627`) — library store, then disk-only
|
||||||
|
`GetArtistImageCachedPath`, fired in parallel, zero network calls.
|
||||||
|
It is the model for finding 1.
|
||||||
|
|
||||||
|
## Finding 1 — Explore's artist images: no disk check, and serial
|
||||||
|
|
||||||
|
`explore-view.ts:1526-1546`. `loadArtistImages` seeds from
|
||||||
|
`libraryStore.cachedArtists` — i.e. **owned artists only**, which on a
|
||||||
|
catalog search is a small minority of results — and then, for every
|
||||||
|
remaining artist:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const url = await GetArtistImageURL(a.mbid); // in a for loop
|
||||||
|
```
|
||||||
|
|
||||||
|
Two faults, both fixed by patterns already in the codebase:
|
||||||
|
|
||||||
|
- **No cached-path pass.** `GetArtistImageCachedPath` and
|
||||||
|
`GetArtistImageCached` are disk-only and free, and neither is used
|
||||||
|
here. An artist whose portrait is already on disk from a previous
|
||||||
|
search still takes the resolution path.
|
||||||
|
- **`await` in a loop.** `GetArtistImageURL` is the *resolving* entry
|
||||||
|
point: on a miss it does MB artist-rels (on the 1/s artist-image
|
||||||
|
limiter) → Wikidata → Wikipedia → a Wikimedia image download. Serial
|
||||||
|
awaits mean 8 unresolved artists are 8 of those end to end, each
|
||||||
|
blocking the next, while the equivalent album-art path fires all of
|
||||||
|
them at once.
|
||||||
|
|
||||||
|
The same "resolver used where a cache check belongs" appears at
|
||||||
|
`top-results-row.ts:218` and `artist-details.ts:207` (both fire in
|
||||||
|
parallel, so only the first fault applies, and both are small-N).
|
||||||
|
|
||||||
|
**Fix:** disk-cached pass first, then network in parallel. A
|
||||||
|
`GetArtistImagesCached(mbids []string) map[string]string` mirroring
|
||||||
|
`GetThumbnails` would make it one IPC call instead of N — see finding 4
|
||||||
|
for why that is not `GetArtistImages`.
|
||||||
|
|
||||||
|
## Finding 2 — The artist page prefetches tracklists twice, or four times
|
||||||
|
|
||||||
|
`prefetchReleases` (`explore-artist-details.ts:1531`) is called from
|
||||||
|
**both** `fetchTopReleaseGroups` (:1467) and `fetchReleaseGroups`
|
||||||
|
(:1506), and `PrefetchReleases` fires up to **8** `BrowseReleases` per
|
||||||
|
call — the most expensive request the app makes (every version of a
|
||||||
|
release group, with `recordings` and `media`).
|
||||||
|
|
||||||
|
The top release groups are a subset of the discography, so the two
|
||||||
|
calls are asking about overlapping sets; the backend's
|
||||||
|
`BrowseReleasesCached` guard stops a *literal* repeat, which means the
|
||||||
|
second call spends its 8 slots on the next 8 uncached albums rather
|
||||||
|
than doing nothing. One page view is therefore up to 16 browses — and
|
||||||
|
on a cold artist, `ArtistDiscographyReady` re-runs both fetchers
|
||||||
|
(:945, :948), taking it to 32.
|
||||||
|
|
||||||
|
Worse, some of that is now provably wasted: since tag-derived
|
||||||
|
completeness landed (`dcc40b1`), **a complete, MBID-matched album opens
|
||||||
|
with no catalog call at all**, so warming its tracklist buys nothing.
|
||||||
|
|
||||||
|
**Fix, in order of value:**
|
||||||
|
|
||||||
|
1. Prefetch once, from the union of both lists, after both resolve.
|
||||||
|
2. Skip release groups that are owned and complete —
|
||||||
|
`GetAlbumCompleteness` already answers this locally.
|
||||||
|
3. Revisit the cap of 8 with the other two in place. Plan 010 flags
|
||||||
|
the same number from the other direction.
|
||||||
|
|
||||||
|
## Finding 3 — Batch helpers with no caller (one of which was live)
|
||||||
|
|
||||||
|
`CheckLibraryMBIDs`, `GetPopularityBatch` and `GetArtistImages` are
|
||||||
|
bound to the frontend and have **no call site in `frontend/src`**.
|
||||||
|
They are the batch shapes a future N+1 would want, and their existence
|
||||||
|
is presumably why the N+1s above were not noticed.
|
||||||
|
|
||||||
|
**`CheckLibraryMBIDs` is not dead** — `downloadcatalog.go:152` calls
|
||||||
|
it from Go, one MBID at a time. Deleting it broke the build, which is
|
||||||
|
how that was found; it is kept, with a comment saying who its consumer
|
||||||
|
is. Read "no frontend caller" as exactly that, and grep both languages
|
||||||
|
before removing a bound method.
|
||||||
|
|
||||||
|
Note `GetArtistImages` is not the helper finding 1 needs: it resolves
|
||||||
|
names through `libMBID.AllArtistMBIDs()`, so it only answers for
|
||||||
|
artists **in the library** — the exact set Explore's search results are
|
||||||
|
not. Either give it an MBID-keyed sibling or replace it.
|
||||||
|
|
||||||
|
Also bound with no caller, and worth a separate decision about whether
|
||||||
|
the feature is live at all: `GetTrackLyrics`, `GenerateMix`,
|
||||||
|
`GetArtistPlayCount`, `GetLibrarySimilarArtists`,
|
||||||
|
`GetCandidateThumbnail`.
|
||||||
|
|
||||||
|
## Finding 4 — One more background pass with no job and no priority
|
||||||
|
|
||||||
|
`BackfillLibraryLyrics` (`lyrics.go:129`) is a bare `go` call: bounded
|
||||||
|
by passes and per-track (LRCLIB has no batch endpoint, so per-track is
|
||||||
|
correct), but with no `jobs` registration and no
|
||||||
|
`WithBackgroundPriority` marking. It runs on its own limiter, so it
|
||||||
|
starves nothing today — but it is invisible and uncancellable, which is
|
||||||
|
the gap 011 just closed for the other two backfills.
|
||||||
|
|
||||||
|
## Not a finding, recorded so it is not re-audited
|
||||||
|
|
||||||
|
- `GetThumbnails` returning only cached entries is deliberate and
|
||||||
|
documented; the per-item follow-up is the streaming half, not an
|
||||||
|
N+1.
|
||||||
|
- `explore-artist-details` calling both `TopReleaseGroupsForArtist`
|
||||||
|
(50) and `BrowseReleaseGroups` (200) reads overlapping rows from the
|
||||||
|
index twice, but both are local queries feeding two different
|
||||||
|
sections. Not worth merging.
|
||||||
|
- The newest components (`home-view`, `catalog-scope-notice`,
|
||||||
|
`page-header`, the notification stack, `shortcuts-overlay`) make no
|
||||||
|
network calls at all. `home-view` is `GetShelves` + `GetAlbumTracks`,
|
||||||
|
both local.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- An Explore search with no owned artists in it makes zero artist-image
|
||||||
|
network calls for portraits already on disk, and resolves the rest
|
||||||
|
concurrently.
|
||||||
|
- Opening an artist page issues one prefetch pass, over albums that are
|
||||||
|
not already fully owned.
|
||||||
|
- The bound-but-uncalled batch helpers are either wired or removed.
|
||||||
+214
-97
@@ -7,31 +7,34 @@
|
|||||||
* other events arrive from Go whenever they arrive. An assertion that
|
* other events arrive from Go whenever they arrive. An assertion that
|
||||||
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
||||||
*
|
*
|
||||||
* Three things it provides on `window.__yjEvents`:
|
* Four things it provides on `window.__yjEvents`:
|
||||||
*
|
*
|
||||||
* record every backend -> frontend event, in order, with payloads
|
* record every backend -> frontend event, in order, with payloads
|
||||||
* wait a promise that settles on a matching event (or rejects
|
* wait a promise that settles on a matching event (or rejects
|
||||||
* with the list of events that *did* arrive, which is the
|
* with the list of events that *did* arrive, which is the
|
||||||
* single most useful failure message this harness can give)
|
* single most useful failure message this harness can give)
|
||||||
* call a bound Go method that is guaranteed to settle: a binding
|
* call a bound Go method, by name, over the runtime's own HTTP
|
||||||
* invoked with wrong argument types makes the backend log
|
* endpoint — no dependence on the app's bundle
|
||||||
* "error parsing arguments" and never fire the callback, so
|
* bindings every binding call the *app* made, which is what turns
|
||||||
* the in-page promise hangs forever. Timing out here fixes
|
* "did that refetch the library" from an inference into a
|
||||||
* that once instead of in every eval.
|
* fact (e2e/perf/measure.mjs labels and reads these)
|
||||||
*
|
*
|
||||||
* WHERE IT HOOKS. Not EventsOn. Every backend event enters the page
|
* WHERE IT HOOKS. Two places, and neither is `EventsOn`.
|
||||||
* at exactly one place — wails' ipc_websocket.js does
|
|
||||||
*
|
*
|
||||||
* case "n": window.wails.EventsNotify(message)
|
* Inbound, `window._wails.dispatchWailsEvent`: v3's runtime assigns it
|
||||||
|
* at module scope and it is the single point every backend event enters
|
||||||
|
* the page through, so wrapping it captures all 46 whether or not the
|
||||||
|
* app subscribes to them. The runtime does
|
||||||
|
* `window._wails = window._wails || {}`, so this script creates that
|
||||||
|
* object first and puts an accessor on the *property*, wrapping at
|
||||||
|
* assignment time — v2 needed the accessor on `window` itself, because
|
||||||
|
* there the whole object was replaced.
|
||||||
*
|
*
|
||||||
* and EventsNotify fans out to listeners from there. Wrapping that
|
* Outbound, `fetch`: v3 routes every runtime call — binding calls, event
|
||||||
* single choke point captures all 46 events whether or not the app
|
* emits, window and dialog calls — through one POST to /wails/runtime.
|
||||||
* subscribes to them, and needs one wrap rather than 46.
|
* There is no global to wrap the way v2's `window.runtime` could be, and
|
||||||
*
|
* this is better anyway: it sees calls from any module, needs no walk of
|
||||||
* `window.wails` does not exist yet when this script runs, so we install
|
* an object graph, and cannot miss one made before the harness looked.
|
||||||
* an accessor on `window` and wrap at assignment time (wails' main.js
|
|
||||||
* does a plain `window.wails = {...}`), then collapse the accessor back
|
|
||||||
* to a data property so nothing downstream can tell.
|
|
||||||
*
|
*
|
||||||
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
|
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
|
||||||
* the next, so a recorder that re-registers double-counts. Tests call
|
* the next, so a recorder that re-registers double-counts. Tests call
|
||||||
@@ -44,8 +47,27 @@
|
|||||||
|
|
||||||
const LIMIT = 2000;
|
const LIMIT = 2000;
|
||||||
|
|
||||||
|
// Every bound service in this app lives under this Go module path,
|
||||||
|
// so specs name a binding the short way — 'queue.Queue.GetState' —
|
||||||
|
// and this is what makes that the same thing the backend calls
|
||||||
|
// 'yellowjacket/backend/queue.Queue.GetState'.
|
||||||
|
const FQN_PREFIX = "yellowjacket/backend/";
|
||||||
|
|
||||||
|
// The runtime's own object and method ids (objectNames in
|
||||||
|
// @wailsio/runtime): 0 is Call, 3 is Events, and method 0 on each is
|
||||||
|
// CallBinding and Emit respectively.
|
||||||
|
const OBJECT_CALL = 0;
|
||||||
|
const OBJECT_EVENTS = 3;
|
||||||
|
|
||||||
|
// Captured before the wrap below, and used for the harness's own
|
||||||
|
// calls: `__yjEvents.call` is this file talking to the backend, not
|
||||||
|
// the app, and counting it would make "did that action refetch the
|
||||||
|
// library" answer for the question as well as the app.
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
const log = [];
|
const log = [];
|
||||||
|
const bindings = [];
|
||||||
const waiters = new Set();
|
const waiters = new Set();
|
||||||
|
|
||||||
const summarize = () => {
|
const summarize = () => {
|
||||||
@@ -56,6 +78,25 @@
|
|||||||
return counts;
|
return counts;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `data` is recorded as the argument list Go emitted, which is the
|
||||||
|
* shape every spec reads (`ev.data[0]`).
|
||||||
|
*
|
||||||
|
* v3's EventManager.Emit packs a variadic call into one field: no
|
||||||
|
* arguments is null, one is the value itself, more than one is the
|
||||||
|
* slice. Unpacking that back into a list is exact except for a
|
||||||
|
* single argument that is itself an array, which is indistinguishable
|
||||||
|
* from several arguments — an ambiguity v3 introduced and no
|
||||||
|
* assertion here depends on, since nothing in backend/events emits
|
||||||
|
* more than one value.
|
||||||
|
*/
|
||||||
|
const argsOf = (data) => {
|
||||||
|
if (data === null || data === undefined) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.isArray(data) ? data : [data];
|
||||||
|
};
|
||||||
|
|
||||||
const record = (name, data, dir) => {
|
const record = (name, data, dir) => {
|
||||||
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
|
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
|
||||||
log.push(entry);
|
log.push(entry);
|
||||||
@@ -89,7 +130,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
version: 1,
|
version: 2,
|
||||||
|
|
||||||
/** Every recorded event, oldest first. */
|
/** Every recorded event, oldest first. */
|
||||||
get log() {
|
get log() {
|
||||||
@@ -101,10 +142,30 @@
|
|||||||
return seq;
|
return seq;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Drop the buffer. Does NOT touch the recorder or waiters. */
|
/**
|
||||||
|
* Every binding call the app made, oldest first. Each is
|
||||||
|
* { methodID, methodName, start, ms, bytes } — the id is what the
|
||||||
|
* generated bindings send, and turning it back into a name is
|
||||||
|
* e2e/perf/measure.mjs's job, which derives the map from
|
||||||
|
* frontend/bindings/.
|
||||||
|
*/
|
||||||
|
get bindings() {
|
||||||
|
return bindings.slice();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the size of every binding response. Off by default: it
|
||||||
|
* costs a clone-and-read of each body, which only a measurement
|
||||||
|
* wants to pay. With it off, `bytes` is the Content-Length when
|
||||||
|
* the server sent one and -1 otherwise.
|
||||||
|
*/
|
||||||
|
measureBytes: false,
|
||||||
|
|
||||||
|
/** Drop the buffers. Does NOT touch the recorder or waiters. */
|
||||||
reset() {
|
reset() {
|
||||||
const n = log.length;
|
const n = log.length;
|
||||||
log.length = 0;
|
log.length = 0;
|
||||||
|
bindings.length = 0;
|
||||||
return n;
|
return n;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -177,13 +238,11 @@
|
|||||||
async ready(timeoutMs) {
|
async ready(timeoutMs) {
|
||||||
const deadline = Date.now() + (timeoutMs || 15000);
|
const deadline = Date.now() + (timeoutMs || 15000);
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (window.go?.queue?.Queue?.GetState) {
|
try {
|
||||||
try {
|
await api.call("queue.Queue.GetState", [], 2000);
|
||||||
await api.call("queue.Queue.GetState", [], 2000);
|
return true;
|
||||||
return true;
|
} catch {
|
||||||
} catch {
|
/* backend not up yet */
|
||||||
/* backend not up yet */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (Date.now() > deadline) {
|
if (Date.now() > deadline) {
|
||||||
throw new Error("__yjEvents.ready timed out");
|
throw new Error("__yjEvents.ready timed out");
|
||||||
@@ -193,38 +252,66 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call a bound Go method by dotted path, with a timeout.
|
* Call a bound Go method by dotted path.
|
||||||
*
|
*
|
||||||
* await __yjEvents.call('player.Player.SetVolume', [42])
|
* await __yjEvents.call('player.Player.SetVolume', [42])
|
||||||
*
|
*
|
||||||
* A binding called with the wrong argument types never fires its
|
* This posts to the runtime's own endpoint rather than reaching
|
||||||
* callback — the reason appears only in .dev/app.log. Without a
|
* into the page for a binding function, because v3 has no
|
||||||
* timeout the caller waits forever; with one it gets told where
|
* `window.go` and the generated bindings are ordinary bundled
|
||||||
* to look.
|
* modules an initScript cannot import. It calls *by name*, which
|
||||||
|
* the backend resolves the same way it resolves the id the
|
||||||
|
* bundle sends.
|
||||||
|
*
|
||||||
|
* v3 rejects a bad call rather than silently never firing its
|
||||||
|
* callback the way v2 did — wrong argument types come back as a
|
||||||
|
* TypeError naming the argument, an unknown method as a
|
||||||
|
* ReferenceError. The timeout below is therefore a backstop for
|
||||||
|
* a genuinely hung request, not the mechanism that makes a
|
||||||
|
* mistake visible.
|
||||||
*/
|
*/
|
||||||
call(path, args, timeoutMs) {
|
call(path, args, timeoutMs) {
|
||||||
const parts = String(path).split(".");
|
const request = nativeFetch("/wails/runtime", {
|
||||||
let fn = window.go;
|
method: "POST",
|
||||||
for (const p of parts) {
|
headers: {
|
||||||
fn = fn?.[p];
|
"Content-Type": "application/json",
|
||||||
}
|
"x-wails-client-id": window._wails?.clientId ?? "",
|
||||||
if (typeof fn !== "function") {
|
},
|
||||||
return Promise.reject(
|
body: JSON.stringify({
|
||||||
new Error(`__yjEvents.call: no such binding: ${path}`),
|
object: OBJECT_CALL,
|
||||||
);
|
method: 0,
|
||||||
}
|
args: {
|
||||||
|
"call-id": `yj-${Math.random().toString(36).slice(2)}`,
|
||||||
|
methodName: FQN_PREFIX + String(path),
|
||||||
|
args: args || [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).then(async (res) => {
|
||||||
|
const type = res.headers.get("Content-Type") || "";
|
||||||
|
const json = type.includes("application/json");
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = json ? await res.json() : { message: await res.text() };
|
||||||
|
throw new Error(
|
||||||
|
`__yjEvents.call(${path}) failed: ` +
|
||||||
|
`${body.kind || "Error"}: ${body.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json ? res.json() : res.text();
|
||||||
|
});
|
||||||
|
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
Promise.resolve(fn(...(args || []))),
|
request,
|
||||||
new Promise((_, reject) =>
|
new Promise((_, reject) =>
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() =>
|
() =>
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
new Error(
|
||||||
`__yjEvents.call(${path}) did not settle in ` +
|
`__yjEvents.call(${path}) did not settle in ` +
|
||||||
`${timeoutMs || 10000}ms — almost always wrong ` +
|
`${timeoutMs || 10000}ms — the runtime endpoint ` +
|
||||||
`argument types; check .dev/app.log for ` +
|
`hung, which is not how a bad argument fails; ` +
|
||||||
`"error parsing arguments"`,
|
`check .dev/app.log`,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
timeoutMs || 10000,
|
timeoutMs || 10000,
|
||||||
@@ -241,62 +328,92 @@
|
|||||||
writable: false,
|
writable: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrap `obj[method]` once, routing every invocation through `tap`.
|
// ── Inbound ──────────────────────────────────────────────────────
|
||||||
const wrap = (obj, method, tap) => {
|
//
|
||||||
const original = obj[method];
|
// The runtime keeps whatever `window._wails` already is, so creating
|
||||||
if (typeof original !== "function" || original.__yjWrapped) {
|
// it here and defining an accessor on the one property we care about
|
||||||
return;
|
// means the wrap happens the moment the runtime module is evaluated.
|
||||||
}
|
window._wails = window._wails || {};
|
||||||
const wrapped = function (...args) {
|
|
||||||
try {
|
|
||||||
tap(args);
|
|
||||||
} catch {
|
|
||||||
/* a broken recorder must never break the app */
|
|
||||||
}
|
|
||||||
return original.apply(this, args);
|
|
||||||
};
|
|
||||||
wrapped.__yjWrapped = true;
|
|
||||||
obj[method] = wrapped;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Install an accessor that wraps on first assignment, then collapses
|
let dispatch;
|
||||||
// back into an ordinary property.
|
|
||||||
const hookOnAssign = (name, onAssign) => {
|
Object.defineProperty(window._wails, "dispatchWailsEvent", {
|
||||||
let value;
|
configurable: true,
|
||||||
Object.defineProperty(window, name, {
|
enumerable: true,
|
||||||
configurable: true,
|
get: () => dispatch,
|
||||||
enumerable: true,
|
set: (fn) => {
|
||||||
get: () => value,
|
dispatch = function (event) {
|
||||||
set: (v) => {
|
|
||||||
value = v;
|
|
||||||
try {
|
try {
|
||||||
onAssign(v);
|
record(event?.name, argsOf(event?.data), "in");
|
||||||
} catch {
|
} catch {
|
||||||
/* ditto */
|
/* a broken recorder must never break the app */
|
||||||
}
|
}
|
||||||
Object.defineProperty(window, name, {
|
return fn.apply(this, arguments);
|
||||||
value: v,
|
};
|
||||||
configurable: true,
|
},
|
||||||
enumerable: true,
|
});
|
||||||
writable: true,
|
|
||||||
});
|
// ── Outbound ─────────────────────────────────────────────────────
|
||||||
},
|
//
|
||||||
|
// One POST per runtime call. Only two of the thirteen object ids
|
||||||
|
// are interesting here; the rest (window, dialogs, clipboard) pass
|
||||||
|
// through untouched and unrecorded.
|
||||||
|
window.fetch = function (input, init) {
|
||||||
|
let call = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The runtime passes a **URL object**, not a string — it
|
||||||
|
// builds `new URL(runtimeURL())` — and a URL has no `.url`,
|
||||||
|
// only a Request does. Reading the wrong one matched
|
||||||
|
// nothing and recorded no calls at all, which looks
|
||||||
|
// identical to an app that made none.
|
||||||
|
const url =
|
||||||
|
input && typeof input === "object" && "url" in input
|
||||||
|
? input.url
|
||||||
|
: String(input ?? "");
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.includes("/wails/runtime") &&
|
||||||
|
init?.method === "POST" &&
|
||||||
|
typeof init.body === "string"
|
||||||
|
) {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
|
||||||
|
if (body.object === OBJECT_EVENTS && body.method === 0) {
|
||||||
|
record(body.args?.name, argsOf(body.args?.data), "out");
|
||||||
|
} else if (body.object === OBJECT_CALL && body.method === 0) {
|
||||||
|
call = {
|
||||||
|
methodID: body.args?.methodID ?? null,
|
||||||
|
methodName: body.args?.methodName ?? null,
|
||||||
|
start: performance.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ditto */
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = nativeFetch(input, init);
|
||||||
|
|
||||||
|
if (!call) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.then(async (res) => {
|
||||||
|
try {
|
||||||
|
call.ms = performance.now() - call.start;
|
||||||
|
call.bytes = api.measureBytes
|
||||||
|
? (await res.clone().text()).length
|
||||||
|
: Number(res.headers.get("Content-Length") ?? -1);
|
||||||
|
bindings.push(call);
|
||||||
|
if (bindings.length > LIMIT) {
|
||||||
|
bindings.splice(0, bindings.length - LIMIT);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ditto */
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Inbound: every backend -> frontend event.
|
|
||||||
hookOnAssign("wails", (w) => {
|
|
||||||
wrap(w, "EventsNotify", ([message]) => {
|
|
||||||
const parsed = JSON.parse(message);
|
|
||||||
record(parsed.name, parsed.data, "in");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Outbound: events the frontend emits, so a flow that round-trips
|
|
||||||
// through Go is legible from one buffer.
|
|
||||||
hookOnAssign("runtime", (r) => {
|
|
||||||
wrap(r, "EventsEmit", (args) => {
|
|
||||||
record(args[0], args.slice(1), "out");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ make sandbox-seed-bulk # Same, from the bulk library (minutes; it is a real scan
|
|||||||
make perf LABEL=<n> # Measure a running app; writes .dev/perf/<n>.json
|
make perf LABEL=<n> # Measure a running app; writes .dev/perf/<n>.json
|
||||||
make perf-compare BEFORE=<a> AFTER=<b> # Print the before/after table
|
make perf-compare BEFORE=<a> AFTER=<b> # Print the before/after table
|
||||||
make build-dev # Debug build with symbols
|
make build-dev # Debug build with symbols
|
||||||
make build-prod # Production build (stripped, UPX-compressed)
|
make build-prod # Production build (stripped and trimmed; no UPX)
|
||||||
make generate # Run code generators (sqlc + templ via go generate)
|
make generate # Run code generators (sqlc + templ via go generate)
|
||||||
make e2e # Playwright smoke suite against a running dev-headless app
|
make e2e # Playwright smoke suite against a running dev-headless app
|
||||||
make e2e-setup # Install the e2e runner + its browser (once)
|
make e2e-setup # Install the e2e runner + its browser (once)
|
||||||
make ui-test # Vitest component/store suite in a real browser (no app)
|
make ui-test # Vitest component/store suite in a real browser (no app)
|
||||||
make ui-visual # Same, including toMatchScreenshot comparisons
|
make ui-visual # Same, including toMatchScreenshot comparisons
|
||||||
make ui-setup # Install the Vitest provider's own Chromium (once)
|
make ui-setup # Install the Vitest provider's own Chromium (once)
|
||||||
make bindings-check # Fail if frontend/wailsjs is stale vs the Go bindings
|
make bindings-check # Fail if frontend/bindings is stale vs the Go bindings
|
||||||
make skill-check # Fail if .pi/ documents a make target that doesn't exist
|
make skill-check # Fail if .pi/ documents a make target that doesn't exist
|
||||||
make commit-check # Fail if a commit subject is not a Conventional Commit
|
make commit-check # Fail if a commit subject is not a Conventional Commit
|
||||||
make lint # golangci-lint v2 (strict), all three build configurations
|
make lint # golangci-lint v2 (strict), all three build configurations
|
||||||
@@ -50,12 +50,15 @@ make setup # Install go tools, frontend deps, git hooks (lefthook)
|
|||||||
|
|
||||||
### Running tests
|
### Running tests
|
||||||
|
|
||||||
All Go test commands require the `-tags webkit2_41` build tag:
|
Go test commands need no build tag for the app configuration. The
|
||||||
|
`webkit2_41` tag every command here used to carry is gone with wails
|
||||||
|
v2: v3 builds against GTK4 + WebKitGTK 6.0 by default, which both Arch
|
||||||
|
and ubuntu:24.04 ship.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -tags webkit2_41 ./... # All tests
|
go test ./... # All tests
|
||||||
go test -tags webkit2_41 ./backend/player/ # Single package
|
go test ./backend/player/ # Single package
|
||||||
go test -tags webkit2_41 -run TestName ./backend/player/ # Single test
|
go test -run TestName ./backend/player/ # Single test
|
||||||
```
|
```
|
||||||
|
|
||||||
The central index builder is behind a second tag and is **not** covered
|
The central index builder is behind a second tag and is **not** covered
|
||||||
@@ -63,14 +66,14 @@ by the command above — `make test` runs both passes, but a manual run
|
|||||||
needs it spelled out:
|
needs it spelled out:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/...
|
go test -tags indexbuild ./backend/explore/... ./cmd/...
|
||||||
```
|
```
|
||||||
|
|
||||||
`backend/testctl` is behind a third tag and needs its own pass too
|
`backend/testctl` is behind a third tag and needs its own pass too
|
||||||
(`make test` runs all three):
|
(`make test` runs all three):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -tags "webkit2_41 dev" ./backend/testctl/...
|
go test -tags dev ./backend/testctl/...
|
||||||
```
|
```
|
||||||
|
|
||||||
Audio playback integration tests require `YELLOWJACKET_INTEGRATION=1`.
|
Audio playback integration tests require `YELLOWJACKET_INTEGRATION=1`.
|
||||||
@@ -84,9 +87,12 @@ through `internal/testfixtures`, selecting files by *case*
|
|||||||
path, and skip themselves when it has not been generated.
|
path, and skip themselves when it has not been generated.
|
||||||
|
|
||||||
The app itself can be run without a blocking window — `make
|
The app itself can be run without a blocking window — `make
|
||||||
dev-headless` — and driven with `playwright-cli` against the dev server
|
dev-headless` — and driven with `playwright-cli` against it on
|
||||||
on `:34115`, which is the real app with real bindings on `window.go`,
|
`:34115`. That is wails v3's first-class `-tags server` mode: the real
|
||||||
bridged to the same Go backend a desktop window would use.
|
app, the real bindings, the same Go backend a desktop window would use,
|
||||||
|
served over HTTP with **no display at all**. The Xvfb this used to
|
||||||
|
require is gone, from the script and from CI; `dbus-run-session` stays,
|
||||||
|
for MPRIS.
|
||||||
|
|
||||||
**The operational half of all this lives in the
|
**The operational half of all this lives in the
|
||||||
`yellowjacket-dev` skill** (`.pi/skills/yellowjacket-dev/`): which tier
|
`yellowjacket-dev` skill** (`.pi/skills/yellowjacket-dev/`): which tier
|
||||||
@@ -103,8 +109,16 @@ phase 3:
|
|||||||
backend event on `window.__yjEvents`. Half this app is push-driven,
|
backend event on `window.__yjEvents`. Half this app is push-driven,
|
||||||
so assertions **await an event, not a timeout**:
|
so assertions **await an event, not a timeout**:
|
||||||
`await window.__yjEvents.wait('LibraryScanComplete', {timeoutMs: 60000})`.
|
`await window.__yjEvents.wait('LibraryScanComplete', {timeoutMs: 60000})`.
|
||||||
It also provides `ready()` and `call('queue.Queue.GetState', [])`,
|
It also provides `ready()` and `call('queue.Queue.GetState', [])`.
|
||||||
which times out instead of hanging.
|
Both hook v3's own seams rather than an internal: inbound is
|
||||||
|
`window._wails.dispatchWailsEvent`, the entry point the backend's push
|
||||||
|
uses, and outbound is **`fetch`** — v3 routes every runtime call
|
||||||
|
through one POST to `/wails/runtime`, so one hook sees calls from any
|
||||||
|
module and cannot miss one made before the harness looked. `call()`
|
||||||
|
posts by method name, so it depends on nothing in the app's bundle and
|
||||||
|
works on a page with no init script — which is why `seed-sandbox.sh`
|
||||||
|
is `curl` now and needs no browser. It no longer races a timeout
|
||||||
|
either: v3 rejects bad arguments and unknown methods cleanly.
|
||||||
- **The dev-only control surface**, `backend/testctl`, mounted at
|
- **The dev-only control surface**, `backend/testctl`, mounted at
|
||||||
`/__test/` on the same port: `health`, `db/snapshot`, `db/restore`,
|
`/__test/` on the same port: `health`, `db/snapshot`, `db/restore`,
|
||||||
`emit` (force any backend event, which renders push-driven views
|
`emit` (force any backend event, which renders push-driven views
|
||||||
@@ -130,18 +144,29 @@ meaningless against the seed's one empty playlist, so it builds ten
|
|||||||
It wraps every bound Go method, so "did that refetch the library" is a
|
It wraps every bound Go method, so "did that refetch the library" is a
|
||||||
fact rather than an inference. It is not a spec and does not run in CI.
|
fact rather than an inference. It is not a spec and does not run in CI.
|
||||||
|
|
||||||
**The cheapest tier needs none of that.** `make ui-test` runs 672
|
**The cheapest tier needs none of that.** `make ui-test` runs 776
|
||||||
Vitest tests in a real Chromium in ~2 s with no Wails, no backend, no
|
Vitest tests in a real Chromium with no Wails, no backend, no seeded
|
||||||
seeded library and no virtual display, because `frontend/wailsjs/` is a
|
library and no virtual display, because **v3 routes every runtime call
|
||||||
pure passthrough to `window.go` / `window.runtime` and
|
— bindings, event emits, window, dialogs, clipboard — through one IPC
|
||||||
`frontend/test/support/wails-fake.ts` replaces just those two globals —
|
transport**, and `frontend/test/support/wails-fake.ts` replaces it via
|
||||||
so the tests exercise the real generated bindings and the real store
|
`setTransport()`, which is public documented API. So the fake covers
|
||||||
code.
|
strictly more than v2's two globals did while being shorter, and the
|
||||||
|
tests exercise the real generated bindings, the real runtime and the
|
||||||
|
real store code. A binding carries an **ID**, not a name
|
||||||
|
(`$Call.ByID(2822423495)` is FNV-1a over
|
||||||
|
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
|
||||||
|
that map from the generated tree rather than writing it down.
|
||||||
|
|
||||||
**`frontend/wailsjs/` is generated by `wails`, not `go generate`**, so
|
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
|
||||||
the pre-commit codegen check does not cover it. `make bindings-check`
|
the pre-commit codegen check does not cover it. `make bindings-check`
|
||||||
(~1.5 s, also a pre-commit hook) regenerates it and fails on a dirty
|
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
|
||||||
tree; `make bindings` regenerates it for real.
|
regenerates it and fails on a dirty tree; `make bindings` regenerates it
|
||||||
|
for real. It is slower than v2's because v3's generator is a **static
|
||||||
|
analyser** over the whole package graph rather than runtime reflection
|
||||||
|
— which is also why the tag set it runs under matters, and why it is
|
||||||
|
the *default* one: that is the configuration users run, and neither
|
||||||
|
`indexbuild` nor `dev` adds a bound service. See
|
||||||
|
`scripts/bindings-check.sh`.
|
||||||
|
|
||||||
**Seeds are produced by running the app**, never by hand-writing a
|
**Seeds are produced by running the app**, never by hand-writing a
|
||||||
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
|
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
|
||||||
@@ -151,7 +176,46 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
**Wails app lifecycle** (`main.go` → `backend/app.go`): `YellowJacketApp` is the root struct bound to Wails. Its methods are callable from the frontend. Lifecycle hooks: `OnStartup` (init audio), `OnDomReady` (start library scan), `OnBeforeClose` (save window state), `OnShutdown` (persist player/queue state).
|
**Wails app lifecycle** (`main.go` → `backend/app.go`): `main.go` is
|
||||||
|
`application.New(opts)` → `app.Window.NewWithOptions(…)` → `app.Run()`.
|
||||||
|
Each bound service takes its context from `ServiceStartup(ctx,
|
||||||
|
application.ServiceOptions{})` — v3 calls it on every service, in
|
||||||
|
registration order, on the main goroutine — and gives it back in
|
||||||
|
`ServiceShutdown()`. That context is **cancelled on app shutdown**,
|
||||||
|
which `SetContext` never was.
|
||||||
|
|
||||||
|
Three things about it are load-bearing.
|
||||||
|
|
||||||
|
**`ServiceShutdown()` takes no context.** A method with a
|
||||||
|
`context.Context` parameter does not satisfy the interface and is
|
||||||
|
**silently never called** — no error, no warning.
|
||||||
|
|
||||||
|
**The cross-service wiring is a service, not an event.** v3 has no
|
||||||
|
`OnStartup`/`OnDomReady` option, and the obvious replacement —
|
||||||
|
`app.Event.OnApplicationEvent(events.Common.ApplicationStarted, …)` —
|
||||||
|
is the right *moment* and the wrong *mechanism*: **server mode emits no
|
||||||
|
application events at all** (`setupCommonEvents` is an explicit no-op
|
||||||
|
under `-tags server`), so the desktop build wired itself and the
|
||||||
|
headless harness did not. `backend/startup.go` is registered last
|
||||||
|
instead, which takes the ordering from the mechanism rather than from
|
||||||
|
an event and therefore holds in every mode. Anything else keyed on
|
||||||
|
`Common.*` is suspect for the same reason.
|
||||||
|
|
||||||
|
**The quit veto is asynchronous now.** v2's `MessageDialog` blocked and
|
||||||
|
returned the button; v3's `Show()` returns immediately and the answer
|
||||||
|
arrives on a `Button.OnClick` callback, so `ShouldQuit` cannot ask and
|
||||||
|
answer in one call — it vetoes, shows the dialog, and calls
|
||||||
|
`app.Quit()` from the callback. `quitConfirmed` is what stops that
|
||||||
|
second `Quit()` asking again; `quitAsking` stops a second close attempt
|
||||||
|
stacking dialogs. Window state moved off that path entirely, onto a
|
||||||
|
`Common.WindowClosing` hook, because the size has to be read while the
|
||||||
|
window still exists and `OnShutdown` has neither a context nor a
|
||||||
|
window.
|
||||||
|
|
||||||
|
`internalServiceMethods` auto-excludes `ServiceStartup`,
|
||||||
|
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
|
||||||
|
shape **removed** 12 spurious bindings and the bogus `context` model
|
||||||
|
rather than renaming them.
|
||||||
|
|
||||||
**Backend packages** (under `backend/`):
|
**Backend packages** (under `backend/`):
|
||||||
- `player` — Audio playback via beep. `BufferedStreamer` provides a ring buffer for smooth seeking.
|
- `player` — Audio playback via beep. `BufferedStreamer` provides a ring buffer for smooth seeking.
|
||||||
@@ -159,45 +223,77 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
|
|||||||
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below.
|
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below.
|
||||||
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
|
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
|
||||||
|
|
||||||
**Schema changes need two things, not one.** `sql/schemas/*.sql` is
|
**The schema is one description, and there is no migration chain.**
|
||||||
`CREATE ... IF NOT EXISTS` and is what sqlc reads — it's the single
|
`sql/schemas/*.sql` is `CREATE ... IF NOT EXISTS`, declares the
|
||||||
source of truth for "what the schema looks like right now", and it's
|
current shape of every table, and is what sqlc reads and what an
|
||||||
what a fresh install gets verbatim. But it's a no-op against a
|
install gets verbatim. That is the whole mechanism: `sql/migrations/`,
|
||||||
database that already has the table, so an existing install needs a
|
`applyMigrations` and `schema_migrations` were squashed away with the
|
||||||
matching file in `sql/schemas/../migrations/` (e.g.
|
file-shaped rewrite (plan 013). A schema change is one edit to one
|
||||||
`NNNN_description.sql`, `ALTER TABLE ... ADD COLUMN ...` /
|
file plus `make generate`. Reintroducing a chain means reintroducing
|
||||||
`CREATE INDEX ...`) to actually reach that shape. Both run on every
|
the drift it caused before — `sql/schemas/` and the migrations
|
||||||
open, migrations after schema files, tracked in `schema_migrations`
|
disagreed, and sqlc generated against the stale one.
|
||||||
so each applies once; a migration's `ALTER TABLE ADD COLUMN` failing
|
|
||||||
with "duplicate column name" on an already-current database is
|
|
||||||
expected and tolerated, not an error.
|
|
||||||
|
|
||||||
A few things that bite if forgotten:
|
**The local library is shaped like files, not like MusicBrainz.**
|
||||||
- **Column order must match between the two paths.** `ALTER TABLE
|
`audio_files` carries its own tags — title, artist credit, track and
|
||||||
ADD COLUMN` always appends at the end, so a migrated column must
|
disc numbers, year, composer, the recording MBID — and points at two
|
||||||
also be declared *last* in the `CREATE TABLE` in `sql/schemas/`
|
shared rows: `albums` (many files to one) and `artists` (many albums
|
||||||
— otherwise a fresh install and an upgraded install disagree on
|
to one). `file_genres` is the one genuine many-to-many. That is the
|
||||||
column order, and a `SELECT *` query (sqlc binds those
|
entire local model.
|
||||||
positionally) silently reads the wrong field on one of them. See
|
|
||||||
`backend/database/migrations_test.go`'s
|
It used to be MusicBrainz's: `recordings`, `release_group_recordings`,
|
||||||
`TestMigrations_ColumnOrderMatchesFreshInstall`, which is the
|
`artist_credit` and `artist_credit_artist` sat between a file and its
|
||||||
regression test for exactly this.
|
own tags. Measured on a real 25,966-file library, **every**
|
||||||
- **Don't put an index on a migrated column in `sql/schemas/`.**
|
many-to-many that model expressed was 1:1 in the data — no recording
|
||||||
Schema files run before migrations, against a database that may
|
had two files, none belonged to two release groups, and 3 credits of
|
||||||
not have that column yet — the index's predicate would fail
|
2,823 listed more than one artist. What it cost was a six-way join in
|
||||||
(this is precisely the bug an earlier session shipped and a user
|
every read, a `MIN(release_group_id)` subquery in eleven queries and a
|
||||||
hit at `make sandbox`). Declare it in the migration file instead,
|
first-credited-artist subquery in nine to undo fan-outs that never
|
||||||
after the `ALTER TABLE` that adds the column.
|
happened, and a class of bugs where a metadata row **outlived the file
|
||||||
- This project **had** a 48-step migration chain before and tore it
|
that created it**: retagging a file created a new recording and
|
||||||
out (see `.planning/NOTES.md`, "No migration chain") because
|
abandoned the old one, so that library carried 812 orphaned
|
||||||
`sql/schemas/` had drifted from what the migrations actually
|
recordings, 216 release groups and 260 artists — and 129 catalog rows
|
||||||
produced and sqlc silently generated against the stale version.
|
that confidently claimed to be owned by files that no longer existed.
|
||||||
The design here avoids that by keeping `sql/schemas/` as the
|
|
||||||
literal target shape (not a hand-maintained description of it)
|
A few things that follow, and bite if forgotten:
|
||||||
and letting migrations replay tolerantly against it — but the
|
- **Ownership is a file.** "Do I own this" is asked of `audio_files`
|
||||||
same drift is possible again if a schema change ships without
|
and nothing else — `GetFilePathsByRecordingMBIDs`,
|
||||||
updating both files. Don't reintroduce a *second* description of
|
`LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
|
||||||
the schema anywhere else.
|
`pruneStaleLocalCrossReferences` all join it. A metadata row is not
|
||||||
|
ownership; that was the bug, and it is now structurally impossible
|
||||||
|
for a row to exist without its file.
|
||||||
|
- **The projection is defined once, in the `track_metadata` view.**
|
||||||
|
Every query that returns a track selects from it, which is why
|
||||||
|
there is one row type (`sqlcgen.TrackMetadatum`) and one mapper
|
||||||
|
(`trackFromRow`). It existed before and only the raw-SQL search
|
||||||
|
paths used it, so nine hand-rolled copies had already drifted: the
|
||||||
|
view preferred the album's original year and one copy used the
|
||||||
|
track's, and the same library reported different years on different
|
||||||
|
screens. The FTS searches cannot be sqlc queries (MATCH is not in
|
||||||
|
its grammar) and spell the column list out in `search.go` — that is
|
||||||
|
the one exception and it is one constant.
|
||||||
|
- **`library_id = 0` means every library.** Each list query used to
|
||||||
|
exist twice, scoped and unscoped, with a branch at every call site
|
||||||
|
and a separate binding for each. One query answers both, and the
|
||||||
|
scoped form costs nothing measurable (23 ms against 21 ms over 26k
|
||||||
|
rows).
|
||||||
|
- **A slice and a named parameter do not compose in sqlc.**
|
||||||
|
`sqlc.slice` expands to N placeholders but a named argument is
|
||||||
|
numbered independently, so `GetFilePathsByAlbums([1,2], 0)` read
|
||||||
|
album id 2 as the library id. Where a query needs both, return
|
||||||
|
`library_id` and filter in Go (`inLibrary`).
|
||||||
|
- **A cache without a ceiling is a leak with a schedule.** Every
|
||||||
|
store that grows with use declares a budget beside its retention
|
||||||
|
(`browsedArtBudget`, `httpCacheBudget`), because an age bound does
|
||||||
|
not bound anything a user can outrun in an afternoon.
|
||||||
|
- **A query file must be ASCII.** sqlc's parameter rewriter works on
|
||||||
|
byte offsets, so one non-ASCII character in a *query* comment
|
||||||
|
corrupts the generated Go into garbage like `SELECid`. Schema files
|
||||||
|
are not rewritten and may contain anything.
|
||||||
|
- **A view is dropped and recreated, not migrated.**
|
||||||
|
`CREATE VIEW IF NOT EXISTS` no-ops against a database holding the
|
||||||
|
old definition, so `track_metadata.sql` opens with
|
||||||
|
`DROP VIEW IF EXISTS`; a view holds no data, so rebuilding it on
|
||||||
|
every open costs nothing.
|
||||||
- **A write wearing a query's shape still needs the writer.**
|
- **A write wearing a query's shape still needs the writer.**
|
||||||
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
|
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
|
||||||
*query-only* read pool (a second `sql.DB` over the same file), so
|
*query-only* read pool (a second `sql.DB` over the same file), so
|
||||||
@@ -207,32 +303,24 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
|
|||||||
created at all. Use `ExecContext`, or `QueryRowWriter` when the
|
created at all. Use `ExecContext`, or `QueryRowWriter` when the
|
||||||
statement really does return a row. Nothing caught this because
|
statement really does return a row. Nothing caught this because
|
||||||
`NewTestDB` shares one in-memory connection and leaves `readDB`
|
`NewTestDB` shares one in-memory connection and leaves `readDB`
|
||||||
nil, so `reader()` returns the *writer* under test and the unit
|
nil, so `reader()` returns the *writer* under test.
|
||||||
tests exercised a handle the app does not have.
|
|
||||||
`TestNoWritesOnTheReadPool` walks the tree for it, in the same
|
`TestNoWritesOnTheReadPool` walks the tree for it, in the same
|
||||||
spirit as `TestNoDirectRuntimeEmits` and for the same reason — a
|
spirit as `TestNoDirectRuntimeEmits` and for the same reason — a
|
||||||
lint pass only sees one build configuration.
|
lint pass only sees one build configuration.
|
||||||
- **A new table needs one file, not two.** The two-file rule 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 — which is exactly what the third rule forbids.
|
|
||||||
`excluded_paths` is the worked example, index included, since the
|
|
||||||
column and its index arrive together.
|
|
||||||
- **A new table has to say what kind of data it holds.**
|
- **A new table has to say what kind of data it holds.**
|
||||||
`backend/datamap` is a catalogue of every table's Kind and
|
`backend/datamap` is a catalogue of every table's Kind and
|
||||||
Lifetime, and `TestCatalogCoversSchema` fails on a table missing
|
Lifetime, and `TestCatalogCoversSchema` fails on a table missing
|
||||||
from it. `TestAuthoredCascadesAreDeliberate` then makes an
|
from it. `TestAuthoredCascadesAreDeliberate` then makes an
|
||||||
*authored* table that cascades an explicit, argued exemption —
|
*authored* table that cascades an explicit, argued exemption —
|
||||||
authored data is what a user cannot get back.
|
authored data is what a user cannot get back. Two entries say
|
||||||
- **Squashing is fine pre-1.0.** While this hasn't shipped to real
|
**MIXED KIND** and mean it: `audio_files` is an owned projection
|
||||||
users, periodically folding `sql/migrations/` into `sql/schemas/`
|
except for `play_count`, `last_played` and `tag_status`, which are
|
||||||
and deleting the migration files (then wiping your own dev/sandbox
|
authored; `lyrics` carries a `source` column because a lyric read
|
||||||
DB) is a legitimate way to keep the migrations directory from
|
from a tag is free to rebuild and one fetched from LRCLIB is not.
|
||||||
accumulating dev-only churn — same effect as the old "just nuke
|
- **Test data has one seeder.** `database.InsertTestTrack` inserts a
|
||||||
it" workflow, opt-in instead of mandatory. Stop doing that once
|
file with its artist, album and genres. Twenty test files used to
|
||||||
real user databases exist in the wild.
|
carry their own, each assembling the old FK chain in a slightly
|
||||||
|
different order.
|
||||||
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
|
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
|
||||||
- `jobs` — The registry every long-running operation reports through:
|
- `jobs` — The registry every long-running operation reports through:
|
||||||
progress, pause/cancel, a global indicator and (for scans) a pause
|
progress, pause/cancel, a global indicator and (for scans) a pause
|
||||||
@@ -327,7 +415,226 @@ work happens **once, centrally**, and users download the result:
|
|||||||
(`dumpincremental.go`), and resolves artists outside the artifact's
|
(`dumpincremental.go`), and resolves artists outside the artifact's
|
||||||
coverage lazily on first view.
|
coverage lazily on first view.
|
||||||
|
|
||||||
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated in `frontend/wailsjs/` — don't edit by hand.
|
**The catalog stores ids as bytes, and that is a size decision.**
|
||||||
|
`explore_index` is 2,052,200 rows, and its MBIDs and entity types were
|
||||||
|
half of it: three 36-character text columns and one storing the words
|
||||||
|
"artist", "release_group", "recording" two million times. They are 16
|
||||||
|
raw bytes and a small integer now. Measured on a real catalog, the
|
||||||
|
table and its six indexes went **780 MB to 405 MB** — the largest
|
||||||
|
single saving available in this app, and the reason a fresh install is
|
||||||
|
~0.6 GB rather than ~1.0 GB.
|
||||||
|
|
||||||
|
`backend/explore/mbid.go` is the only place that encoding is known.
|
||||||
|
Everything above it speaks dashed strings and entity names —
|
||||||
|
`SearchIndexResult`, the bindings, the frontend — and `dbMBID` /
|
||||||
|
`dbEntityType` convert at the SQL boundary. That confinement is the
|
||||||
|
point: the alternative is blobs reaching code that has no use for them.
|
||||||
|
|
||||||
|
Four things about it are load-bearing, and they exist because of *how*
|
||||||
|
this fails when it fails: **SQLite does not coerce between TEXT and
|
||||||
|
BLOB**, so a query comparing the column against a 36-character string
|
||||||
|
returns no rows rather than an error, and a scan into a plain string
|
||||||
|
yields sixteen bytes of mojibake. Neither is visible except as a result
|
||||||
|
that is quietly empty.
|
||||||
|
|
||||||
|
- **The column checks itself.** `CHECK(length(mbid) = 16)` means a
|
||||||
|
stringly *write* fails at the insert that made it. It also caught
|
||||||
|
every fixture that had been using `"rh"` as an MBID; `testMBID()`
|
||||||
|
hashes a label into a real one so they stay readable.
|
||||||
|
- **The projection is one constant and one scanner.**
|
||||||
|
`indexRowColumns` / `scanIndexRow` replaced four copies of a 22-column
|
||||||
|
list and four matching `Scan` calls — four chances to decode wrongly.
|
||||||
|
`indexRowColumnsFor("i")` is the same list qualified, for the FTS join
|
||||||
|
where both sides have a `title`.
|
||||||
|
- **A query that names an entity type inline writes the code with the
|
||||||
|
name beside it** — `entity_type = 1 /* artist */`. Splicing a Go
|
||||||
|
constant in would keep them in step automatically but makes every such
|
||||||
|
query a concatenation; `TestEntityCodesAreStable` pins the mapping
|
||||||
|
instead, because it is a storage format and changing one is not a
|
||||||
|
refactor.
|
||||||
|
- **`TestStoredEncodingRoundTrips` sweeps every read path** — lookup,
|
||||||
|
top-N, exact match, FTS search, popularity batch, the CAA map — and
|
||||||
|
asserts each returns something with a dashed id. A missed conversion
|
||||||
|
site shows up there and essentially nowhere else.
|
||||||
|
|
||||||
|
**The artifact is read in either encoding.** A published artifact
|
||||||
|
carries whichever form the exporter that built it used, and there is one
|
||||||
|
already out there in the old text form. `artifactStoresText` asks the
|
||||||
|
artifact (`typeof(mbid)`) rather than trusting a version number, and
|
||||||
|
`artifactSelectColumns` converts on the way in — one `unhex` per row on
|
||||||
|
a once-a-month import, against requiring a rebuilt artifact before a new
|
||||||
|
build can read anything. That probe **must** run on the writer:
|
||||||
|
`core` is attached to that one connection, so `QueryContext` asks a
|
||||||
|
pool where the artifact does not exist, and the error would silently
|
||||||
|
select the conversion path for an artifact that needs none.
|
||||||
|
|
||||||
|
**Its shape is the pattern for every column added after the fact.**
|
||||||
|
`artifactHasTotals` is the same question about `total_tracks`, on the
|
||||||
|
same handle: an artifact built before a column existed is still a
|
||||||
|
perfectly good catalog, so it is *asked* and the missing column is
|
||||||
|
selected as a literal `0`. Adding the column to the importer's SELECT
|
||||||
|
list without that is how a published artifact — which nobody can re-cut
|
||||||
|
retroactively — starts failing with `no such column`.
|
||||||
|
|
||||||
|
**Background work yields, and says so in the context.** The post-scan
|
||||||
|
backfills share MusicBrainz's rate limiters with every page the user
|
||||||
|
can open, and both were FIFO — so a thousand-artist enrichment put an
|
||||||
|
album page behind an hour of queued work.
|
||||||
|
`RateLimiter.WithBackgroundLane(perSecond)` adds a second, slower lane
|
||||||
|
and `WithBackgroundPriority(ctx)` marks a caller as belonging to it: a
|
||||||
|
marked wait takes no token at all while any interactive wait is
|
||||||
|
outstanding, and is then paced at MB's own 1/s rather than the
|
||||||
|
interactive burst rate. It is a **context marker rather than a
|
||||||
|
parameter** because a backfill calls the same `MusicBrainzClient`
|
||||||
|
methods a detail page does — `GetArtistImage` takes a `ctx` for no
|
||||||
|
other reason than to carry it. One request of slippage is accepted and
|
||||||
|
documented at `waitBackground`: cancelling an already-granted
|
||||||
|
reservation is not something a token bucket can express, and the cost
|
||||||
|
is one request-time.
|
||||||
|
|
||||||
|
The other half is that a long backfill has to be **visible and
|
||||||
|
stoppable**: `jobs.KindCatalogEnrich` and `startBackfillJob`
|
||||||
|
(`backfilljob.go`) register both backfills with progress and cancel.
|
||||||
|
Two rules in it are load-bearing. The job is registered *after* the
|
||||||
|
work is counted, because these passes are a no-op on every launch once
|
||||||
|
the library is covered and an empty job in the indicator is noise. And
|
||||||
|
the kind is distinct from `index-build` rather than reused, because
|
||||||
|
`job-controls.ts` keys its "you will discard hours of downloading"
|
||||||
|
confirmation on that kind — wrong prompt for a pass that is resumable
|
||||||
|
per artist and free to stop.
|
||||||
|
|
||||||
|
**An owned artist's discography is fetched, not sampled.**
|
||||||
|
`BackfillLibraryDiscographies` is two fetches per owned artist, each
|
||||||
|
skipped by its own persistent mark, because they fail independently and
|
||||||
|
one boolean covering both either over-claims or forces repeats:
|
||||||
|
the ListenBrainz top release groups and recordings
|
||||||
|
(`explore_index.discog_fetched`) and the **full** MusicBrainz browse
|
||||||
|
(`artist_enrichment.browsed_at`).
|
||||||
|
|
||||||
|
**What it does not fetch is the point.** It ran for hours against a
|
||||||
|
900-artist library and marked nothing, because three of the four things
|
||||||
|
it did per artist were work nobody had asked for. Similar artists were
|
||||||
|
fetched for every owned artist, when the artist page already resolves
|
||||||
|
them on view through `SimilarArtists` → `ensureSimilarArtistsAsync` —
|
||||||
|
which is what stamps `similar_at` now. And `indexOneArtist` reached the
|
||||||
|
MB artist lookup it wants (`GetArtistDetails` reads that cache) by
|
||||||
|
calling `GetArtistImage`, which additionally queried fanart.tv,
|
||||||
|
TheAudioDB, Wikidata and Wikipedia and downloaded up to ten full-size
|
||||||
|
portraits; `EnsureArtistRels` is the lookup on its own. The corollary
|
||||||
|
is written into the query: **`similar_at` must not be one of the
|
||||||
|
conditions** in `unenrichedLibraryArtistMBIDs`, because testing a mark
|
||||||
|
this pass no longer sets makes every owned artist a candidate on every
|
||||||
|
run, forever.
|
||||||
|
|
||||||
|
The rest is that the pass was **serial across artists** while every
|
||||||
|
limiter that keeps us polite is per-host and idle — so one artist's
|
||||||
|
slowest upstream set the pace for the whole run. It runs
|
||||||
|
`discogBackfillWorkers` artists at once (concurrency here raises no
|
||||||
|
origin's request rate), each under `discogBackfillArtistTimeout`,
|
||||||
|
because the MB client retries a 503 five times honouring Retry-After
|
||||||
|
and one throttled artist could otherwise outlast a hundred healthy
|
||||||
|
ones. A timed-out artist goes unmarked and is retried next run, which
|
||||||
|
is what every other failure here already does. SQLite's writer pool is
|
||||||
|
`MaxOpenConns(1)`, so the workers queue at the Go level rather than
|
||||||
|
racing for the file.
|
||||||
|
|
||||||
|
Five things about the marks are load-bearing. **A mark records that the
|
||||||
|
upstream was asked, not that it answered with something.** ListenBrainz
|
||||||
|
returns 200 and `[]` for an artist it has no popularity data for — which
|
||||||
|
is most of a long-tail library, and the same is true of an artist whose
|
||||||
|
every row falls under `indexMinPopularity` — and keying
|
||||||
|
`discog_fetched` on "did rows come back" made those artists permanent
|
||||||
|
candidates: "Filling in artist details" re-ran for up to
|
||||||
|
`discogBackfillMaxPerRun` of them on **every launch**, forever, doing
|
||||||
|
the same two fetches to the same empty answer. So `indexOneArtist` sets
|
||||||
|
the mark when both fetches *succeeded* (`fetchTopReleaseGroups` and
|
||||||
|
`fetchTopRecordings` return an error for that reason), and only a real
|
||||||
|
failure — transport, non-2xx, unreadable body — leaves the artist for
|
||||||
|
the next run. `browseFullDiscography` already had this right: an artist
|
||||||
|
with genuinely no release groups is still marked browsed. The marks are **a table, not
|
||||||
|
more `explore_index` columns**, because `artifactimport.go` merges the
|
||||||
|
downloaded catalog by column list — a flag added there is a second
|
||||||
|
place to remember, and forgetting it silently wipes every mark on the
|
||||||
|
next catalog update. `discog_fetched` stays in `explore_index` for the
|
||||||
|
opposite reason: the artifact legitimately answers it for artists it
|
||||||
|
covers. **The artifact answering it is not "we have their
|
||||||
|
discography"** — its per-artist coverage is graded, so an artist can
|
||||||
|
arrive `discog_fetched = 1` and never have been browsed, which is why
|
||||||
|
the unenriched query ORs its conditions instead of testing the
|
||||||
|
first. **`BrowseReleaseGroupsAll` pages to exhaustion** where
|
||||||
|
`BrowseReleaseGroups` asks for `MaxLimit` once and takes what comes
|
||||||
|
back — a prolific artist was silently cut at 100 release groups, and a
|
||||||
|
hundred albums looks like a complete answer unless you count. And the
|
||||||
|
per-artist mark **replaced a heuristic that could never be satisfied**:
|
||||||
|
`BrowseReleaseGroups` used to re-browse whenever no indexed row carried
|
||||||
|
a secondary type, which is permanently true for an artist whose
|
||||||
|
releases are all plain albums.
|
||||||
|
|
||||||
|
**One artist portrait is downloaded; the rest are remembered as URLs.**
|
||||||
|
`resolveAllSources` asked five upstreams what images they had for an
|
||||||
|
artist and then downloaded **every** candidate, up to ten, full size,
|
||||||
|
serially — while nothing in the app has ever read anything but
|
||||||
|
`primary.jpg` and its three tiers. Measured on a real cache: 5.3 GB,
|
||||||
|
of which 4.1 GB was candidates no code path can reach, ~940 kB per
|
||||||
|
artist against the ~217 kB that is actually used.
|
||||||
|
|
||||||
|
It is split now. `resolveCandidates` does the metadata lookups and
|
||||||
|
returns an ordered list; `fetchPrimary` walks that list downloading
|
||||||
|
until one **succeeds**, makes that the primary, and records the rest
|
||||||
|
with an empty `file_path` — known, not fetched — so replacing a
|
||||||
|
portrait later is one download rather than five lookups again. Taking
|
||||||
|
the first that succeeds rather than the first outright is also a fix:
|
||||||
|
the old loop keyed `is_primary` on the index, so a failed candidate 0
|
||||||
|
left the artist with a stored image, no `primary.jpg`, and a `.miss`
|
||||||
|
marker claiming there was no artwork at all. The winning candidate is
|
||||||
|
no longer also written under its own name, since `setPrimary` writes
|
||||||
|
the same bytes to `primary.jpg`.
|
||||||
|
|
||||||
|
Two janitor jobs go with it, and the first is why the waste survived.
|
||||||
|
`OrphanedArtistImagesJob` joined the bare MBID onto the images
|
||||||
|
directory — but artist directories are **sharded** under a two-character
|
||||||
|
prefix, so it named a path that has never existed, `RemoveAll`
|
||||||
|
succeeded on it, and the job deleted the rows that were the only record
|
||||||
|
of the files it left behind. `explore.ArtistImageDir` is that layout's
|
||||||
|
one definition, passed in the way `OrphanedCoverFilesJob` takes
|
||||||
|
`expandVariants`, and the test lays its fixtures out with it — a test
|
||||||
|
that invents its own flat layout agrees with the bug.
|
||||||
|
`StrayArtistImageFilesJob` reclaims what earlier versions downloaded,
|
||||||
|
keeping only `explore.ArtistImageKeepNames()` and refusing an empty
|
||||||
|
keep set for the reason the covers sweep refuses an empty live set.
|
||||||
|
|
||||||
|
**An age is not a ceiling, and a cache needs one.** Art for an artist
|
||||||
|
the user owns is kept indefinitely; everything else aged out after 90
|
||||||
|
days and nothing counted it, so the same install held portraits for
|
||||||
|
**5,770 artists in a 1,301-artist library** — every artist page opened
|
||||||
|
in Explore fetches one, and a browsing afternoon is entirely inside the
|
||||||
|
retention window. `browsedArtBudget` (256 MB) is the second pass:
|
||||||
|
oldest browsed artist first, until what is left fits, with owned
|
||||||
|
artists outside the budget entirely. `httpCacheBudget` is the same rule
|
||||||
|
one cache over, and it is what makes the year-long entity TTL below
|
||||||
|
safe — once answers stop expiring, expiry stops being a bound.
|
||||||
|
|
||||||
|
**"Owned" is a file here too.** The sweep's live set used to be "there
|
||||||
|
is an `artists` row", which the file-shaped schema made meaningless;
|
||||||
|
it joins `audio_files` now, like every other ownership question. Its
|
||||||
|
test had seeded an artists row with no file and called it owned — the
|
||||||
|
exact phantom, in the fixture of the test that guards it.
|
||||||
|
|
||||||
|
**Only the tiers of a cover are stored.** `saveCoverArt` writes
|
||||||
|
`_sm`/`_md`/`_lg` and records the largest as `cover_art.file_path`;
|
||||||
|
`coverart.ResolveURLs` reports that one as `Original` too, because it
|
||||||
|
is the largest kept. The full-resolution image used to be written
|
||||||
|
beside them and was **1,134 MB of a 1.4 GB covers directory** against
|
||||||
|
110 MB for all three tiers — with nothing rendering it, since the grid
|
||||||
|
caps at 350 px and the largest tier is 400. The bytes are still in the
|
||||||
|
audio file, which is where they came from, so the repair pass that
|
||||||
|
regenerated tiers *from the stored original* went with it.
|
||||||
|
|
||||||
|
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated as TypeScript in `frontend/bindings/`, nested by Go import path — don't edit by hand. The `@go` alias absorbs the constant prefix, so a call site imports `@go/library/library.js`.
|
||||||
|
|
||||||
|
**One seam states what the generated types get wrong, rather than 78 patches.** v3's generator is honest where v2's lied: a Go `nil` slice marshals to JSON `null` and always has (v2 typed it `T[]`), and a Go named string type is a closed set (v2 typed it `string`). There is no flag to turn either off, correctly. So `utils/binding.ts` states the app's actual contract at the only place it is true — `list` yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil map and drop null-valued keys (which loses nothing: `noUncheckedIndexedAccess` already makes every read `V | undefined`), and `compact` is the same for a map arriving as a *field*. Where a nullable slice is a model field there is no boundary to put it at, and those are `?? []` at the point of use.
|
||||||
|
|
||||||
|
All three also return a **plain `Promise`**: v3 bindings return a `CancellablePromise` and nothing in this app cancels one, so letting it inward would put a Wails type in every store signature for a capability none of them use.
|
||||||
|
|
||||||
**A view is a chunk, and three components are not.** `index.ts` holds a
|
**A view is a chunk, and three components are not.** `index.ts` holds a
|
||||||
loader table (`VIEW_LOADERS`, `DETAIL_LOADERS`) and `await`s a view's
|
loader table (`VIEW_LOADERS`, `DETAIL_LOADERS`) and `await`s a view's
|
||||||
@@ -506,6 +813,29 @@ its equivalent predates this, carries selection semantics (shift-extend,
|
|||||||
ctrl-toggle) the other three do not have, and is pinned by its own
|
ctrl-toggle) the other three do not have, and is pinned by its own
|
||||||
tests.
|
tests.
|
||||||
|
|
||||||
|
**A shared panel computes its own name, and its items ask what the
|
||||||
|
target can do.** `explore-artist-details` had a menu for its top
|
||||||
|
*tracks* and none on the release cards, which are most of the page. It
|
||||||
|
has one now on both release shapes — the top section's
|
||||||
|
`LBTopReleaseGroup` and the discography's `MBReleaseGroup` — normalised
|
||||||
|
to a `ReleaseMenuTarget` at the moment the menu opens, so the union
|
||||||
|
does not reach five action handlers. `ctxMenuTarget` is a discriminated
|
||||||
|
union rather than one nullable field per kind **because the panel is
|
||||||
|
shared**: that is what keeps `aria-label` moving with the target, which
|
||||||
|
is the fault `cover-grid` shipped (every menu announced as "Album
|
||||||
|
actions").
|
||||||
|
|
||||||
|
Which items appear is decided by what the release can actually do, and
|
||||||
|
those are three different questions. Playback is gated on a **local
|
||||||
|
album id**, not on the badge's "owned": a release matched by MBID with
|
||||||
|
no local album behind it has nothing to queue, and `GetFilePathsBy‐
|
||||||
|
Albums` is keyed on the id for the reason the album page is — an owned
|
||||||
|
but untagged release has no recording MBIDs, so an MBID-keyed lookup
|
||||||
|
returns nothing while looking entirely correct. The request item needs
|
||||||
|
the opposite, a catalog MBID, so it is absent for a library-only
|
||||||
|
release (`local:<n>`, unwrapped the same way `navigateToAlbum` does) —
|
||||||
|
which is also the one case where wanting it makes no sense.
|
||||||
|
|
||||||
**Async surfaces say what they are doing.** `styles/sr-only.css.ts`
|
**Async surfaces say what they are doing.** `styles/sr-only.css.ts`
|
||||||
carries the visually-hidden class and the rule that comes with it: a
|
carries the visually-hidden class and the rule that comes with it: a
|
||||||
live region must be **in the DOM before the text it announces is**,
|
live region must be **in the DOM before the text it announces is**,
|
||||||
@@ -753,25 +1083,199 @@ when the whole release is owned, **Play 7 of 12** when some of it is,
|
|||||||
and **no play button at all** when none is, because a Play button that
|
and **no play button at all** when none is, because a Play button that
|
||||||
plays nothing (or seven tracks of forty) is worse than none.
|
plays nothing (or seven tracks of forty) is worse than none.
|
||||||
|
|
||||||
`albumLibraryStatus()` is deliberately *not* what decides that. It is
|
**The page asks one question, once, and it is "is there a file".**
|
||||||
four claims of decreasing confidence OR'd into one tick — a local album
|
Ownership used to be several claims of decreasing confidence OR'd
|
||||||
id, the backend's cross-reference, a cached MBID match, and finally
|
together — a local album id, the backend's cross-reference, a cached
|
||||||
*any single track* marked `inLibrary` — which is a fine answer to "is
|
MBID match, and finally *any single track* flagged `inLibrary` — none
|
||||||
any of this mine" and a useless basis for a button. `ownership()`
|
of which is "there is a file to play", which is why the tick could be
|
||||||
counts the displayed tracklist instead, whose `inLibrary` flags the
|
green on an album whose every action did nothing, and why the
|
||||||
backend sets per recording MBID.
|
tracklist's context menu asked the backend on **hover** whether the row
|
||||||
|
it was drawing was owned. `filePaths` is the one answer: a map from a
|
||||||
|
displayed track to its path, filled once from `updated()` by a single
|
||||||
|
batched `GetFilePathsByRecordingMBIDs`, and read by the badge, the Play
|
||||||
|
button's count, the dimmed rows and every menu item. `askedFor` is a
|
||||||
|
separate set from `filePaths` because the guard has to be *asked*, not
|
||||||
|
*answered* — an unowned MBID never lands in the map, so guarding on the
|
||||||
|
map re-requests it on every render, forever.
|
||||||
|
|
||||||
|
The other half is that the *displayed* tracklist is also one thing.
|
||||||
|
`buildVersionEntries` synthesises the "Your Library" entry from
|
||||||
|
`localTracks`, so an album the catalog cannot answer for is exactly the
|
||||||
|
case that needs the version list rebuilt — `loadLocalTracks` guarded
|
||||||
|
that rebuild on `releases.length > 0` and so rendered "No release data
|
||||||
|
available" over a tracklist it was holding in memory.
|
||||||
|
|
||||||
**And the key it plays by is not the key it looks owned by.** The local
|
**And the key it plays by is not the key it looks owned by.** The local
|
||||||
album id is used wherever there is one, because a library-only album
|
album id is used wherever there is one, because a library-only album
|
||||||
has *no* recording MBIDs — its tracks are synthesised from
|
has *no* recording MBIDs — its tracks are synthesised from
|
||||||
`GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed
|
`GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed
|
||||||
lookup on an untagged library resolves to nothing and Play queues
|
lookup on an untagged library resolves to nothing and Play queues
|
||||||
nothing while looking entirely correct.
|
nothing while looking entirely correct. Those synthesised tracks carry
|
||||||
`GetFilePathsByRecordingMBIDs` is the catalog-only fallback and the
|
their `FilePath` from the same rows, so they populate `filePaths`
|
||||||
third member of the `GetFilePathsBy…` family: one query, paths only,
|
directly and cost no lookup at all; the batched
|
||||||
grouped so the caller keeps the tracklist's order. It exists rather
|
`GetFilePathsByRecordingMBIDs` is what answers for a *catalog*
|
||||||
than a lookup by track id because **`MBTrack.LocalID` is declared and
|
tracklist. It is the third member of the `GetFilePathsBy…` family: one
|
||||||
nothing in the backend ever writes it**.
|
query, paths only, grouped so the caller keeps the tracklist's order.
|
||||||
|
It exists rather than a lookup by track id because **`MBTrack.LocalID`
|
||||||
|
is declared and nothing in the backend ever writes it**.
|
||||||
|
|
||||||
|
**How much of an album is here is a question the files can answer.**
|
||||||
|
`ownership()` above counts the *displayed* tracklist, which for a
|
||||||
|
library copy is a tautology — every local track has a file, so owned
|
||||||
|
always equals total and "do I have all of this" had no local answer.
|
||||||
|
The album page therefore asked MusicBrainz, and
|
||||||
|
`BrowseReleases` is the most expensive call the app makes: releases
|
||||||
|
plus every version's full tracklist, on a 1 req/s limiter shared with
|
||||||
|
`PrefetchReleases`, which fires up to eight when an artist page
|
||||||
|
renders.
|
||||||
|
|
||||||
|
The denominator was already on disk. `metadata` has read the "5/12"
|
||||||
|
totals off every file since forever (`m.Track()`, `m.Disc()`) and
|
||||||
|
discarded them; they persist to `audio_files.total_tracks` now, and
|
||||||
|
`GetAlbumCompleteness` sums them. **A complete, MBID-matched album
|
||||||
|
makes no catalog call at all** — identity from the MBID, tracklist from
|
||||||
|
the tags, which between them are what the browse was being spent on.
|
||||||
|
|
||||||
|
Three things about it are load-bearing. **Totals are declared per
|
||||||
|
disc**, so the expectation is a sum over discs and not one number, and
|
||||||
|
a disc whose files declared nothing leaves the whole album unknowable
|
||||||
|
rather than being covered by the discs that did. **Unknown is a third
|
||||||
|
state and must render as neither** — a great deal of any untagged
|
||||||
|
library has no total, and a ring drawn from its absence would mark most
|
||||||
|
of a library incomplete on no evidence; `Known` is what guards that,
|
||||||
|
and the badge falls back to the plain tick. And **complete is `>=`,
|
||||||
|
not `==`**, because bonus and hidden tracks routinely put a folder over
|
||||||
|
its declared total and that is a complete album, not a broken one.
|
||||||
|
Owned counts *distinct track numbers* for the same reason in reverse:
|
||||||
|
this app detects duplicates, and counting two files of track 3 twice
|
||||||
|
would report a short album as complete.
|
||||||
|
|
||||||
|
**Where the tags have no total, the catalog does.** `explore_index`
|
||||||
|
carries a per-release-group `total_tracks` — ~2 bytes across 400,677
|
||||||
|
rows, about 800 kB of artifact — and `completenessAnswer()` merges the
|
||||||
|
two: the numerator stays local (distinct track numbers on disk) and
|
||||||
|
only the denominator is borrowed, because a catalog total describes the
|
||||||
|
canonical release while the files' own total, where they declare one,
|
||||||
|
describes the release the user actually has. Zero still means "the
|
||||||
|
catalog does not say", so an album neither side can total keeps the
|
||||||
|
plain tick.
|
||||||
|
|
||||||
|
Two rules hold up the column itself. **It is counted before the
|
||||||
|
popularity filter**: `cmd/indexbuild` counts the canonical dump's rows
|
||||||
|
per kept release, and counting only the *kept recordings* would say "9"
|
||||||
|
about a twelve-track album whose other three nobody has played — the
|
||||||
|
same confident lie that kept whole tracklists out of the artifact.
|
||||||
|
And **adding a column to the importer's SELECT is how you break every
|
||||||
|
artifact already published**, so `artifactHasTotals()` asks the
|
||||||
|
attached artifact whether the column is there (on the writer, where
|
||||||
|
`core` is attached) and selects a literal `0` when it is not — the same
|
||||||
|
shape as the encoding probe beside it.
|
||||||
|
|
||||||
|
What neither side can give is *which* tracks are missing, only how many
|
||||||
|
— so an incomplete album still browses, and that is now the exception
|
||||||
|
rather than every album load. Two smaller consequences: existing databases
|
||||||
|
read "unknown" until a rescan repopulates the column (which degrades to
|
||||||
|
exactly the old behaviour, so nothing breaks), and our own `tagwriter`
|
||||||
|
writes track and disc *numbers* but not totals, so autotagging a folder
|
||||||
|
currently degrades the field this rests on.
|
||||||
|
|
||||||
|
**The absence is what gets marked, not the presence.** The tracklist
|
||||||
|
put a green tick against every owned track and a legend underneath
|
||||||
|
explaining the tick — a positive mark on the *common* case, so an album
|
||||||
|
you own outright wore a column of circles and a key for them. It is the
|
||||||
|
streaming-service treatment now: rows not in the library are **dimmed
|
||||||
|
in place**, and nothing marks the ones that are. Two things follow.
|
||||||
|
Dimming is a colour, so it cannot be the only signal — the row carries
|
||||||
|
`aria-disabled`, which is what reaches anyone not seeing it. And the
|
||||||
|
dimmed rows are why the `loading` banner could go: tracks arriving
|
||||||
|
dimmed reads as the album filling in, so a line of text about the
|
||||||
|
page's own plumbing earns nothing. `unavailable` survives because it is
|
||||||
|
not about plumbing — it says rows may be missing from the page
|
||||||
|
altogether, which nothing on screen can show. (`explore-artist-details`
|
||||||
|
still uses `loading`; it has no equivalent per-row signal.)
|
||||||
|
|
||||||
|
**A partly-owned album draws the release, not the part.** Once the tags
|
||||||
|
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
|
||||||
|
with three dimmed, rather than the nine on disk — the missing tracks
|
||||||
|
are the useful information and a tracklist trimmed to what is owned
|
||||||
|
cannot show them. It is guarded on `completeness.known` rather than on
|
||||||
|
"fewer tracks than the cluster", which would swap a catalog tracklist
|
||||||
|
in for every album whose tags simply never declared a total. A
|
||||||
|
side-effect worth knowing: this is what finally makes `ownership()`
|
||||||
|
say something true here, since counting the displayed tracklist of a
|
||||||
|
library-only entry could only ever produce "9 of 9".
|
||||||
|
|
||||||
|
**A dropdown is only a choice if the choices differ.** The version
|
||||||
|
selector tested `versionEntries.length`, but a release group routinely
|
||||||
|
has several releases — reissues, regional pressings, a remaster — whose
|
||||||
|
tracklists are identical, and the synthetic "Your Library" entry is
|
||||||
|
often a third name for the same one, so the control appeared with every
|
||||||
|
option showing the same rows. `distinctTracklistCount()` is the real
|
||||||
|
test. It deliberately does **not** use `fingerprint()`, which keys on
|
||||||
|
recording MBIDs alone: a library entry built from untagged files has
|
||||||
|
none, so every such tracklist fingerprints to the same run of empty
|
||||||
|
strings and compares equal to every other. It falls back to the title,
|
||||||
|
which is what lets a local copy be recognised as the same tracklist the
|
||||||
|
catalog is describing.
|
||||||
|
|
||||||
|
**Say which version you own, not that you own one.** A synthetic "Your
|
||||||
|
Library" entry used to stand in for the matching release, which hid the
|
||||||
|
thing worth knowing: you could see that you owned *a* version but not
|
||||||
|
*which*, while the real release — its date, country and release count —
|
||||||
|
sat underneath under a different name. The matching release carries
|
||||||
|
`inLibrary` and is marked (★ **and** the words "in your library", since
|
||||||
|
a `<select>` cannot be styled per option and a bare glyph is the
|
||||||
|
unexplained symbol the tracklist's green ticks were). The synthetic
|
||||||
|
survives only where there is nothing to mark: local files matching no
|
||||||
|
release, or the no-local-album overlap guess.
|
||||||
|
|
||||||
|
**A merged cluster shows the order the most releases agree on.**
|
||||||
|
`mergeNearDuplicateClusters` folds by track *set*, so a resequenced
|
||||||
|
pressing — same songs, different running order — merges correctly. But
|
||||||
|
the survivor was whichever release came first in the browse response,
|
||||||
|
which is meaningless ordering. On a real album one 2021 pressing
|
||||||
|
arrived ahead of eleven 2013 ones, so the cluster wore the 2021 running
|
||||||
|
order; the user's files then matched no cluster **fingerprint**, and
|
||||||
|
the page both called their copy unlinked to MusicBrainz and offered a
|
||||||
|
second "version" whose only difference was an ordering almost nothing
|
||||||
|
was pressed in. `withConsensusRepresentative` re-picks by how many
|
||||||
|
releases share each exact ordering, earliest date breaking the tie —
|
||||||
|
**and moves the cluster's fingerprint with it**, since that is what the
|
||||||
|
library match is tested against. Note the consequence for the version
|
||||||
|
list: a resequence is not a separate version, because the merge folds
|
||||||
|
it before any of this runs.
|
||||||
|
|
||||||
|
**A slow catalog fetch is not a failed one.** The same page used to
|
||||||
|
reach `unavailable` — "No catalog details for this album right now" —
|
||||||
|
from a **12-second timer**, which is the only signal it had, because
|
||||||
|
`ensureReleasesAsync` emitted `AlbumReleasesReady` on success and
|
||||||
|
nothing at all on failure. Against a browse queued behind eight
|
||||||
|
prefetches at 1 req/s, that reported healthy fetches as catalog
|
||||||
|
failures on correctly-matched albums. `AlbumReleasesFailed` is the
|
||||||
|
missing half; `catalogFailed` is the only route to `unavailable` now,
|
||||||
|
and the timer is a 60 s backstop for a genuine hang rather than the
|
||||||
|
verdict.
|
||||||
|
|
||||||
|
**Activating a row plays the list the row is in, from that row.** A
|
||||||
|
double-click — and Play on a single row's context menu — queues the
|
||||||
|
list as *displayed* with `startIndex` on that row, not a queue of one
|
||||||
|
track that stops when the song ends. The two playlist views and
|
||||||
|
`cover-grid`'s album dropdown always did this; the album page and
|
||||||
|
`track-list` did not, so playing anything from the two largest
|
||||||
|
tracklists in the app discarded the album around it. Three rules come
|
||||||
|
with it. **A menu asks how much is selected**: one row is a position
|
||||||
|
and means "from here", several rows are an explicit choice of *those*
|
||||||
|
tracks and become the queue on their own (which is also the only case
|
||||||
|
where `shuffleStart` still applies, since no one row was named as the
|
||||||
|
place to start). **The index is into the paths, not into the rows** —
|
||||||
|
`explore-album-details` queues `ownedFilePaths()` and its dimmed rows
|
||||||
|
are not in it, so an index taken from the tracklist starts an album
|
||||||
|
somewhere else entirely, or past its end. And **the index is looked up
|
||||||
|
when it is used, not remembered**: selection keys are file paths
|
||||||
|
because those survive a re-sort, a re-filter and a refetch, and an
|
||||||
|
index survives none of the three — `displayIndexOf` is that lookup,
|
||||||
|
against `cachedSortedTracks`, which is the only order the user can see
|
||||||
|
and therefore the only one they can mean.
|
||||||
|
|
||||||
**Ask for what the caller uses, once.** "Play this artist" resolved
|
**Ask for what the caller uses, once.** "Play this artist" resolved
|
||||||
file paths with one `GetAlbumTracks` per album, sequentially, and every
|
file paths with one `GetAlbumTracks` per album, sequentially, and every
|
||||||
@@ -954,6 +1458,38 @@ reader and is created lazily, so neither the invalidation nor — more
|
|||||||
expensively — the singleton's own construction warms a cache for a page
|
expensively — the singleton's own construction warms a cache for a page
|
||||||
that may never open.
|
that may never open.
|
||||||
|
|
||||||
|
**Playing a track does not wait for the database to hear about it.**
|
||||||
|
Every write in this app goes through one connection —
|
||||||
|
`database.DB` is `MaxOpenConns(1)`, because SQLite has one writer — and
|
||||||
|
a background pass can hold it for a long time. The player and the queue
|
||||||
|
used to write inline from paths that hold their own mutexes, so a
|
||||||
|
contended writer did not merely slow persistence down: `SetQueue`
|
||||||
|
blocked in `LoadFile`'s `saveState` and then in `persistState`, **while
|
||||||
|
holding `q.mu` and `p.mu`**. The user's report is the exact shape of
|
||||||
|
that — the track changed and the transport sat at paused (`LoadFile`
|
||||||
|
had emitted `TrackChanged` and `PlaybackStateChanged(paused)`, and
|
||||||
|
`p.Play()` is *after* the writes), nothing appeared in the queue
|
||||||
|
(`emitQueueChanged` is after them too), and the play button did nothing
|
||||||
|
because `Queue.Play` was waiting on the same held `q.mu`. It was
|
||||||
|
diagnosed by profiling the running app: 91% of its CPU was
|
||||||
|
`explore.BackfillLibraryDiscographies` → `upsertBatch`, with four more
|
||||||
|
of its six workers parked in `sql.(*DB).conn`.
|
||||||
|
|
||||||
|
So a write is **submitted, not performed**
|
||||||
|
(`queue/persistwriter.go`, `player/persistwriter.go`): jobs run in
|
||||||
|
submission order on one goroutine per component, each carrying its own
|
||||||
|
snapshot — which is what keeps "clear and rewrite the queue" and
|
||||||
|
"insert three tracks at 4" meaning what they meant when they were
|
||||||
|
called. Two rules come with it. A job **must not touch the component's
|
||||||
|
fields**: it holds no lock and the state has moved on, which is why
|
||||||
|
`persistTracks` clones. And `SaveState` — shutdown, and the tests —
|
||||||
|
still flushes and waits, because that is the one caller for which the
|
||||||
|
row has to exist on return.
|
||||||
|
|
||||||
|
The corollary for anything new: **a durability write is not a step in a
|
||||||
|
user action**. If a mutation path needs the database to have finished
|
||||||
|
before it returns, that is a claim worth arguing for, not a default.
|
||||||
|
|
||||||
**"Remove from library" removes the row and excludes the path, and
|
**"Remove from library" removes the row and excludes the path, and
|
||||||
never touches the file.** `RemoveFromLibrary(filePaths)` deletes the
|
never touches the file.** `RemoveFromLibrary(filePaths)` deletes the
|
||||||
`audio_files` rows the way the scan's own orphan cleanup does (tagging
|
`audio_files` rows the way the scan's own orphan cleanup does (tagging
|
||||||
@@ -1138,11 +1674,26 @@ on every change, or `scrollToIndex` from calling `scrollIntoView()` on
|
|||||||
something invisible.
|
something invisible.
|
||||||
|
|
||||||
Emit through **`events.Emit(ctx, name, data...)`**, never
|
Emit through **`events.Emit(ctx, name, data...)`**, never
|
||||||
`runtime.EventsEmit` — wails `log.Fatalf`s (unrecoverably) on any
|
`app.Event.Emit`. `TestNoDirectRuntimeEmits` fails the build on a
|
||||||
context that does not carry its runtime, which includes every
|
direct call anywhere outside `backend/events`.
|
||||||
`context.Background()`, so a direct call cannot run under test and can
|
|
||||||
kill the app from a background worker. `TestNoDirectRuntimeEmits` fails
|
**Its justification changed with v3 and is now the weaker one.** Under
|
||||||
the build on a direct call anywhere outside `backend/events`.
|
v2 this was a safety rule: `runtime.EventsEmit` `log.Fatalf`'d —
|
||||||
|
unrecoverably, taking the process down — on any context not carrying
|
||||||
|
the runtime, which includes every `context.Background()`, so a
|
||||||
|
background worker could kill the app by emitting. That is gone. v3's
|
||||||
|
emit takes **no context at all**, and `application.Get()` with no app
|
||||||
|
running returns `nil` rather than dying, so `Deliver` is
|
||||||
|
`if app == nil { return ErrNoRuntime }` where it used to probe the
|
||||||
|
**v2-private context key** `ctx.Value("events")`. What remains worth
|
||||||
|
pinning is narrower and still real: one emit path is what lets
|
||||||
|
`emitStatus` drop an unchanged payload for every caller at once.
|
||||||
|
|
||||||
|
**`events.Emit` keeps its `ctx` anyway, and it is now purely a test
|
||||||
|
seam.** Delivery does not go through it; `events.WithSink(ctx, rec)`
|
||||||
|
does, which is how a service is asserted on in-process
|
||||||
|
(`backend/queue/emit_test.go` is the model). Dropping the parameter
|
||||||
|
would have churned 45 call sites and every test for no gain.
|
||||||
|
|
||||||
That wrapper is what makes services testable in-process: install a
|
That wrapper is what makes services testable in-process: install a
|
||||||
recorder with `events.WithSink(ctx, rec)` and assert on the payload the
|
recorder with `events.WithSink(ctx, rec)` and assert on the payload the
|
||||||
@@ -1200,8 +1751,10 @@ Two jobs, both in an `ubuntu:24.04` container:
|
|||||||
`make lint` and `make test` (three build configurations each),
|
`make lint` and `make test` (three build configurations each),
|
||||||
`tsc --noEmit`, `make ui-test`, `make bindings-check`,
|
`tsc --noEmit`, `make ui-test`, `make bindings-check`,
|
||||||
`make skill-check`.
|
`make skill-check`.
|
||||||
- **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by
|
- **`e2e`** — under a private D-Bus and **no display at all**, since
|
||||||
running the app, `make dev-headless`, then the Playwright suite
|
v3's `-tags server` is a real headless mode: fixtures, a seed built by
|
||||||
|
running the app (`curl` against the runtime endpoint — no browser),
|
||||||
|
`make dev-headless`, then the Playwright suite
|
||||||
against **both** Chromium and WebKit. Playwright's Linux WebKit links
|
against **both** Chromium and WebKit. Playwright's Linux WebKit links
|
||||||
Ubuntu 24.04 libraries that Arch does not provide, so CI is the only
|
Ubuntu 24.04 libraries that Arch does not provide, so CI is the only
|
||||||
place it can run, and it is the closest available approximation of
|
place it can run, and it is the closest available approximation of
|
||||||
@@ -1232,8 +1785,51 @@ And
|
|||||||
`YJ_CORE_INDEX_URL` points at a dead address so no run fetches the real
|
`YJ_CORE_INDEX_URL` points at a dead address so no run fetches the real
|
||||||
explore artifact, matching what `scripts/seed-sandbox.sh` already does.
|
explore artifact, matching what `scripts/seed-sandbox.sh` already does.
|
||||||
|
|
||||||
**`make lint`'s tag sets must stay identical to `make test`'s.**
|
**`make lint`'s tag sets must stay identical to `make test`'s**, or
|
||||||
Without `webkit2_41` wails resolves `webkit2gtk-4.0`, which Arch still
|
lint is checking configurations nothing builds. There are three, and
|
||||||
ships and Ubuntu 24.04 does not — so a mismatch lints a configuration
|
the app's is now the *default* tag set: v3 resolves GTK4 +
|
||||||
that only builds on one developer's distro, and says nothing about what
|
WebKitGTK 6.0, which Arch and ubuntu:24.04 both ship, so the
|
||||||
ships.
|
`webkit2_41` tag that used to be mandatory everywhere is gone. A
|
||||||
|
machine without `webkitgtk-6.0` can still build with `-tags gtk3`, but
|
||||||
|
that is an escape hatch, not what CI or a release builds.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
**The Makefile is the front door and `Taskfile.yml` is an
|
||||||
|
implementation detail behind it.** `make dev`, `make build-dev`,
|
||||||
|
`make build-prod`, `make bindings` and `make e2e` all keep their names;
|
||||||
|
what changed underneath is that a build is now a Taskfile tree
|
||||||
|
(`Taskfile.yml` → `build/<platform>/Taskfile.yml`) rather than one
|
||||||
|
`wails build` invocation, and `wails3` is still a **vendored Go tool**
|
||||||
|
(`go tool wails3`), never a global install.
|
||||||
|
|
||||||
|
Four things about that tree bite anything outside the Makefile, and all
|
||||||
|
four bit the packaging recipes:
|
||||||
|
|
||||||
|
- **The tasks invoke `wails3` by bare name**, in 54 places across the
|
||||||
|
scaffold files. `scripts/toolbin/wails3` puts that name on PATH
|
||||||
|
pointing back at the vendored tool; without it a build dies at its
|
||||||
|
first sub-task with `wails3: command not found`. The Makefile
|
||||||
|
prepends it, and so must `packaging/arch/PKGBUILD` and the Homebrew
|
||||||
|
formula.
|
||||||
|
- **`wails3 build` has no `-ldflags`, `-trimpath` or `-clean`** — those
|
||||||
|
were v2's. `-trimpath` and `-w -s` are already in the production
|
||||||
|
task's own flags; the version stamp goes through **`LDFLAGS_EXTRA`**,
|
||||||
|
this repo's one edit to the scaffold Taskfiles (linux and darwin
|
||||||
|
alike), passed as `wails3 task build LDFLAGS_EXTRA="-X 'main.version=…'"`.
|
||||||
|
- **The output is `bin/`, not v2's `build/bin/`.** `build/` is *tracked
|
||||||
|
build assets* now.
|
||||||
|
- **Bundling is a separate step from building.** `task build` produces
|
||||||
|
a bare binary on every platform; the macOS `.app` is `task package`.
|
||||||
|
|
||||||
|
**`build/`'s platform metadata is generated from `build/config.yml`.**
|
||||||
|
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
|
||||||
|
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
|
||||||
|
one file — so a hand edit to any of them is lost on the next refresh,
|
||||||
|
and the two fields it does *not* own (nfpm's `homepage` and `license`)
|
||||||
|
say so in place. That refresh also regenerates `build/ios/` and
|
||||||
|
`build/android/`, which this repo does not carry: they are gitignored
|
||||||
|
rather than deleted-and-rediscovered, and their `includes:` entries
|
||||||
|
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
|
||||||
|
*metadata* version and is not what the app reports — `main.version` is
|
||||||
|
stamped at link time from the packaging recipe's git-derived version.
|
||||||
|
|||||||
@@ -7,17 +7,25 @@ LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'
|
|||||||
# point elsewhere (or unset it there to share the real user dirs).
|
# point elsewhere (or unset it there to share the real user dirs).
|
||||||
DEV_YJ_HOME ?= $(HOME)/.local/share/yellowjacket-dev
|
DEV_YJ_HOME ?= $(HOME)/.local/share/yellowjacket-dev
|
||||||
|
|
||||||
|
# `wails3 dev` and `wails3 task` run the scaffold's Taskfile tree, which
|
||||||
|
# invokes `wails3` by bare name. The CLI is a vendored Go tool, so the
|
||||||
|
# name only exists on PATH via this shim -- see scripts/toolbin/wails3.
|
||||||
|
# Without it every supervisor target dies with
|
||||||
|
# "/bin/sh: wails3: command not found" at its first sub-task.
|
||||||
|
TOOLBIN := $(CURDIR)/scripts/toolbin
|
||||||
|
|
||||||
dev: setup generate clean
|
dev: setup generate clean
|
||||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||||
|
|
||||||
dev-debug: setup generate clean
|
dev-debug: setup generate clean
|
||||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||||
|
|
||||||
# ── Headless harness (plan 005) ──────────────────────────────────────
|
# ── Headless harness (plan 005) ──────────────────────────────────────
|
||||||
# The same dev server `make dev` runs, minus the blocking GTK window:
|
# The same app `make dev` runs, minus the window: v3's `-tags server`
|
||||||
# Xvfb gives it the display it insists on, and the script returns once
|
# is a first-class headless mode that needs no display at all, so the
|
||||||
# :34115 answers. This is the only entry point an agent can use, since
|
# Xvfb this used to require is gone. The script returns once :34115
|
||||||
# every other one blocks the terminal forever.
|
# answers. This is the only entry point an agent can use, since every
|
||||||
|
# other one blocks the terminal forever.
|
||||||
dev-headless: ## Start the app headless in the background (SEED=<name> to seed)
|
dev-headless: ## Start the app headless in the background (SEED=<name> to seed)
|
||||||
@./scripts/dev-headless.sh $(if $(SEED),--seed $(SEED),) $(HEADLESS_ARGS)
|
@./scripts/dev-headless.sh $(if $(SEED),--seed $(SEED),) $(HEADLESS_ARGS)
|
||||||
|
|
||||||
@@ -100,11 +108,10 @@ ui-visual-update: ## Re-record the screenshot baselines
|
|||||||
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
||||||
@cd frontend && pnpm install && npx playwright install chromium
|
@cd frontend && pnpm install && npx playwright install chromium
|
||||||
|
|
||||||
# frontend/wailsjs is generated by `wails`, NOT by `go generate`, so the
|
# Bindings are generated by `wails3`, NOT by `go generate`, so the
|
||||||
# pre-commit codegen check does not cover it: a renamed Go struct field
|
# pre-commit codegen check does not cover them: a renamed Go struct
|
||||||
# currently surfaces at runtime, in a window. File modes are ignored
|
# field would otherwise surface at runtime, inside a window.
|
||||||
# because `wails generate module` rewrites the runtime files as 755.
|
bindings-check: ## Fail if the generated bindings are stale
|
||||||
bindings-check: ## Fail if frontend/wailsjs is stale against the Go bindings
|
|
||||||
@./scripts/bindings-check.sh
|
@./scripts/bindings-check.sh
|
||||||
|
|
||||||
# A backtick inside a comment in a css`` literal ends the literal, and
|
# A backtick inside a comment in a css`` literal ends the literal, and
|
||||||
@@ -125,11 +132,16 @@ 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
|
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
||||||
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
||||||
|
|
||||||
bindings: ## Regenerate frontend/wailsjs from the bound Go structs
|
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
||||||
go tool wails generate module -tags webkit2_41
|
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
|
||||||
@chmod 644 frontend/wailsjs/runtime/runtime.js \
|
# constant prefix, so a call site imports '@go/library/library.js'.
|
||||||
frontend/wailsjs/runtime/runtime.d.ts \
|
#
|
||||||
frontend/wailsjs/runtime/package.json
|
# No -f flag: the tag set is the default one, deliberately, because the
|
||||||
|
# generator is a static analyser that sees only the configuration it is
|
||||||
|
# told about and the one that matters is the one users run. See
|
||||||
|
# scripts/bindings-check.sh for why the other two do not apply.
|
||||||
|
bindings: ## Regenerate frontend/bindings from the bound Go services
|
||||||
|
go tool wails3 generate bindings -clean=true -ts -i
|
||||||
|
|
||||||
.PHONY: dev-headless dev-headless-fresh dev-stop dev-logs \
|
.PHONY: dev-headless dev-headless-fresh dev-stop dev-logs \
|
||||||
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
||||||
@@ -158,7 +170,7 @@ fresh-install: setup generate clean
|
|||||||
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
||||||
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
||||||
esac; \
|
esac; \
|
||||||
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||||
|
|
||||||
# Named, persistent sandboxes: `make sandbox foo` runs dev against
|
# Named, persistent sandboxes: `make sandbox foo` runs dev against
|
||||||
# $(FRESH_HOME_BASE)/yellowjacket-sandbox-foo, creating it on first use
|
# $(FRESH_HOME_BASE)/yellowjacket-sandbox-foo, creating it on first use
|
||||||
@@ -218,7 +230,7 @@ sandbox-%: setup generate clean
|
|||||||
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
|
||||||
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
|
||||||
esac; \
|
esac; \
|
||||||
go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||||
|
|
||||||
sandboxes: ## List existing named sandboxes
|
sandboxes: ## List existing named sandboxes
|
||||||
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
|
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
|
||||||
@@ -228,10 +240,10 @@ sandboxes: ## List existing named sandboxes
|
|||||||
.PHONY: sandbox sandbox-rm sandboxes
|
.PHONY: sandbox sandbox-rm sandboxes
|
||||||
|
|
||||||
build-dev: generate
|
build-dev: generate
|
||||||
go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)"
|
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build DEV=true
|
||||||
|
|
||||||
build-prod: generate
|
build-prod: generate
|
||||||
go tool wails build -tags webkit2_41 -clean -upx -ldflags "-s -w $(LDFLAGS)"
|
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
cd frontend && pnpm install && pnpm build
|
cd frontend && pnpm install && pnpm build
|
||||||
@@ -272,14 +284,16 @@ bulkdata-clean: ## Delete the bulk measurement library
|
|||||||
.PHONY: testdata testdata-force testdata-clean bulkdata bulkdata-clean
|
.PHONY: testdata testdata-force testdata-clean bulkdata bulkdata-clean
|
||||||
|
|
||||||
# The tag sets must match `make test` exactly, or lint is checking three
|
# The tag sets must match `make test` exactly, or lint is checking three
|
||||||
# configurations that nothing builds. webkit2_41 is not optional: without
|
# configurations that nothing builds. The webkit2_41 tag these all used
|
||||||
# it wails resolves webkit2gtk-4.0, which Ubuntu 24.04 no longer ships, so
|
# to carry is gone with v2: v3 builds against GTK4 + WebKitGTK 6.0 by
|
||||||
# the `dev` pass (wails' own app_dev.go is dev-tagged and pulls in the 4.0
|
# default, which both Arch and ubuntu:24.04 ship, so the default tag set
|
||||||
# assetserver) fails to typecheck anywhere but Arch.
|
# is the one that ships. (`-tags gtk3` still exists as an escape hatch
|
||||||
|
# for a machine without webkitgtk-6.0; it is not what CI or releases
|
||||||
|
# build.)
|
||||||
lint:
|
lint:
|
||||||
go tool golangci-lint run --build-tags webkit2_41
|
go tool golangci-lint run
|
||||||
go tool golangci-lint run --build-tags "webkit2_41 indexbuild"
|
go tool golangci-lint run --build-tags indexbuild
|
||||||
go tool golangci-lint run --build-tags "webkit2_41 dev"
|
go tool golangci-lint run --build-tags dev
|
||||||
|
|
||||||
# Three passes: the app build, the `indexbuild` build that adds the
|
# Three passes: the app build, the `indexbuild` build that adds the
|
||||||
# CI-only dump importer, and the `dev` build that adds profiling and
|
# CI-only dump importer, and the `dev` build that adds profiling and
|
||||||
@@ -287,12 +301,12 @@ lint:
|
|||||||
# exercise backend/explore/dump*.go, cmd/indexbuild or the harness
|
# exercise backend/explore/dump*.go, cmd/indexbuild or the harness
|
||||||
# control surface at all.
|
# control surface at all.
|
||||||
test: testdata
|
test: testdata
|
||||||
go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
|
go test -race -count=1 -timeout 120s ./...
|
||||||
go test -tags "webkit2_41 indexbuild" -race -count=1 -timeout 300s \
|
go test -tags indexbuild -race -count=1 -timeout 300s \
|
||||||
./backend/explore/... ./cmd/...
|
./backend/explore/... ./cmd/...
|
||||||
# backend/testctl only exists under the `dev` tag, so the pass above
|
# backend/testctl only exists under the `dev` tag, so the pass above
|
||||||
# does not compile it, let alone run it.
|
# does not compile it, let alone run it.
|
||||||
go test -tags "webkit2_41 dev" -race -count=1 -timeout 120s \
|
go test -tags dev -race -count=1 -timeout 120s \
|
||||||
./backend/testctl/...
|
./backend/testctl/...
|
||||||
|
|
||||||
vulncheck:
|
vulncheck:
|
||||||
|
|||||||
@@ -78,16 +78,25 @@ YellowJacket is built with [Go](https://go.dev/) and a
|
|||||||
| Go | 1.25+ |
|
| Go | 1.25+ |
|
||||||
| Node.js | 22+ |
|
| Node.js | 22+ |
|
||||||
| pnpm | 10+ |
|
| pnpm | 10+ |
|
||||||
| Wails CLI | v2 (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`) |
|
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
|
||||||
|
|
||||||
On Linux, install the system libraries Wails needs:
|
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
|
||||||
|
to install globally; `make setup` fetches it with the rest of the tooling.
|
||||||
|
|
||||||
|
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
|
||||||
|
WebKitGTK 6.0 by default:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt-get install libasound2-dev libgtk-3-dev libwebkit2gtk-4.1-dev
|
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
|
||||||
|
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
|
||||||
```
|
```
|
||||||
|
|
||||||
macOS and Windows need no extra system packages. Run `wails doctor` to check your
|
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
|
||||||
environment.
|
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
|
||||||
|
release builds.
|
||||||
|
|
||||||
|
macOS and Windows need no extra system packages. Run `go tool wails3 doctor` to
|
||||||
|
check your environment.
|
||||||
|
|
||||||
**Build**
|
**Build**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
APP_NAME: "yellowjacket"
|
||||||
|
BIN_DIR: "bin"
|
||||||
|
PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "pnpm"}}'
|
||||||
|
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||||
|
# Target OS for build/package/run. Defaults to the host OS, and is overridden
|
||||||
|
# by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The
|
||||||
|
# tasks below dispatch to the matching platform Taskfile via this variable.
|
||||||
|
GOOS: '{{.GOOS | default OS}}'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ./build/Taskfile.yml
|
||||||
|
windows: ./build/windows/Taskfile.yml
|
||||||
|
darwin: ./build/darwin/Taskfile.yml
|
||||||
|
linux: ./build/linux/Taskfile.yml
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{.GOOS}}:build"
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{.GOOS}}:package"
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Runs the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{.GOOS}}:run"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
summary: Runs the application in development mode
|
||||||
|
cmds:
|
||||||
|
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||||
|
|
||||||
|
setup:docker:
|
||||||
|
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||||
|
cmds:
|
||||||
|
- task: common:setup:docker
|
||||||
|
|
||||||
|
build:server:
|
||||||
|
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||||
|
cmds:
|
||||||
|
- task: common:build:server
|
||||||
|
|
||||||
|
run:server:
|
||||||
|
summary: Runs the application in server mode
|
||||||
|
cmds:
|
||||||
|
- task: common:run:server
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds a Docker image for server mode deployment
|
||||||
|
cmds:
|
||||||
|
- task: common:build:docker
|
||||||
|
|
||||||
|
run:docker:
|
||||||
|
summary: Builds and runs the Docker image
|
||||||
|
cmds:
|
||||||
|
- task: common:run:docker
|
||||||
+156
-78
@@ -10,9 +10,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
"yellowjacket/backend/assets"
|
"yellowjacket/backend/assets"
|
||||||
"yellowjacket/backend/autotagservice"
|
"yellowjacket/backend/autotagservice"
|
||||||
@@ -39,7 +40,9 @@ import (
|
|||||||
|
|
||||||
// YellowJacketApp is the main application struct for Wails.
|
// YellowJacketApp is the main application struct for Wails.
|
||||||
type YellowJacketApp struct {
|
type YellowJacketApp struct {
|
||||||
FEBindings []any
|
// Services is what v3 binds to the frontend. Each entry's
|
||||||
|
// ServiceStartup runs before the app-level wiring in OnStartup.
|
||||||
|
Services []application.Service
|
||||||
FrontendUtil *frontendutil.FrontendUtil
|
FrontendUtil *frontendutil.FrontendUtil
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@@ -61,6 +64,12 @@ type YellowJacketApp struct {
|
|||||||
appContext context.Context
|
appContext context.Context
|
||||||
appConfig *config.Config
|
appConfig *config.Config
|
||||||
startupErr error
|
startupErr error
|
||||||
|
|
||||||
|
// quitAsking guards the one quit-confirmation dialog; quitConfirmed
|
||||||
|
// records that the user already answered "quit anyway", so the
|
||||||
|
// Quit() issued from that callback is not questioned again.
|
||||||
|
quitAsking atomic.Bool
|
||||||
|
quitConfirmed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewYellowJacketApp creates and initializes the application.
|
// NewYellowJacketApp creates and initializes the application.
|
||||||
@@ -182,6 +191,16 @@ func NewYellowJacketApp(
|
|||||||
yjApp.library.SetJobRegistry(yjApp.jobs)
|
yjApp.library.SetJobRegistry(yjApp.jobs)
|
||||||
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
||||||
|
|
||||||
|
// Let the release prefetch skip albums the user already owns in
|
||||||
|
// full — those open with no catalog call at all, so warming their
|
||||||
|
// tracklists spends the most expensive request in the app on
|
||||||
|
// nothing. Injected because neither package imports the other.
|
||||||
|
yjApp.explore.SetAlbumComplete(func(albumID int64) bool {
|
||||||
|
c, err := yjApp.library.GetAlbumCompleteness(albumID)
|
||||||
|
|
||||||
|
return err == nil && c.Known && c.Complete
|
||||||
|
})
|
||||||
|
|
||||||
// create autotag service (depends on explore + tagWriter)
|
// create autotag service (depends on explore + tagWriter)
|
||||||
yjApp.autotag = autotagservice.NewService(
|
yjApp.autotag = autotagservice.NewService(
|
||||||
yjApp.logger.WithGroup("autotag"),
|
yjApp.logger.WithGroup("autotag"),
|
||||||
@@ -201,28 +220,42 @@ func NewYellowJacketApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
yjApp.FEBindings = []any{
|
// application.NewService is generic over a concrete pointer type —
|
||||||
yjApp.FrontendUtil,
|
// the static analyser that generates bindings reads these calls, so
|
||||||
yjApp.appConfig,
|
// a []any of the same values would generate nothing.
|
||||||
yjApp.library,
|
yjApp.Services = []application.Service{
|
||||||
yjApp.playlist,
|
application.NewService(yjApp.FrontendUtil),
|
||||||
yjApp.queue,
|
application.NewService(yjApp.appConfig),
|
||||||
yjApp.player,
|
application.NewService(yjApp.library),
|
||||||
yjApp.tagWriter,
|
application.NewService(yjApp.playlist),
|
||||||
yjApp.explore,
|
application.NewService(yjApp.queue),
|
||||||
yjApp.autotag,
|
application.NewService(yjApp.player),
|
||||||
jobs.NewService(yjApp.jobs),
|
application.NewService(yjApp.tagWriter),
|
||||||
home.NewService(
|
application.NewService(yjApp.explore),
|
||||||
|
application.NewService(yjApp.autotag),
|
||||||
|
application.NewService(jobs.NewService(yjApp.jobs)),
|
||||||
|
application.NewService(home.NewService(
|
||||||
yjApp.logger.WithGroup("home"),
|
yjApp.logger.WithGroup("home"),
|
||||||
yjApp.database,
|
yjApp.database,
|
||||||
yjApp.library,
|
yjApp.library,
|
||||||
),
|
)),
|
||||||
}
|
}
|
||||||
|
|
||||||
if yjApp.downloadSvc != nil {
|
if yjApp.downloadSvc != nil {
|
||||||
yjApp.FEBindings = append(yjApp.FEBindings, yjApp.downloadSvc)
|
yjApp.Services = append(
|
||||||
|
yjApp.Services, application.NewService(yjApp.downloadSvc),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last, deliberately: services start in registration order, so this
|
||||||
|
// runs once every service above has taken its context. See
|
||||||
|
// startup.go for why the wiring is a service rather than an
|
||||||
|
// application-event hook.
|
||||||
|
yjApp.Services = append(
|
||||||
|
yjApp.Services,
|
||||||
|
application.NewService(&startupService{app: yjApp}),
|
||||||
|
)
|
||||||
|
|
||||||
return yjApp, nil
|
return yjApp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,19 +352,18 @@ func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
|
|||||||
return yj.appConfig.Window
|
return yj.appConfig.Window
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup initializes components that require the Wails runtime context.
|
// OnStartup wires the services to each other once the runtime exists.
|
||||||
|
//
|
||||||
|
// It is no longer where each service *gets* the context: every bound
|
||||||
|
// service implements v3's ServiceStartup, which the runtime calls
|
||||||
|
// before this runs. What is left here is the cross-service wiring —
|
||||||
|
// hooks, adapters and the callbacks that make one package drive
|
||||||
|
// another — which has no home inside any single service.
|
||||||
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||||
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
|
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
|
||||||
|
|
||||||
// initialize anything that needs to use the wails runtime AFTER its been initialized
|
|
||||||
// you CANNOT use the wails runtime during this function
|
|
||||||
yj.appContext = ctx
|
yj.appContext = ctx
|
||||||
|
|
||||||
// Set context for components that need Wails runtime for events
|
|
||||||
yj.appConfig.SetContext(ctx)
|
|
||||||
yj.FrontendUtil.SetContext(ctx)
|
|
||||||
yj.library.SetContext(ctx)
|
|
||||||
yj.playlist.SetContext(ctx)
|
|
||||||
yj.playlist.EnsureDefaultPlaylist()
|
yj.playlist.EnsureDefaultPlaylist()
|
||||||
// Recover playlists that lost tracks from a pre-fix FullRescan.
|
// Recover playlists that lost tracks from a pre-fix FullRescan.
|
||||||
go yj.playlist.RepopulateFromM3U()
|
go yj.playlist.RepopulateFromM3U()
|
||||||
@@ -348,14 +380,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
yj.player.SetContext(ctx)
|
// The job registry is not a bound service — it is wrapped by
|
||||||
yj.tagWriter.SetContext(ctx)
|
// jobs.NewService for that — so it still takes the context by hand.
|
||||||
yj.explore.SetContext(ctx)
|
|
||||||
yj.autotag.SetContext(ctx)
|
|
||||||
yj.jobs.SetContext(ctx)
|
yj.jobs.SetContext(ctx)
|
||||||
|
|
||||||
if yj.downloadSvc != nil {
|
if yj.downloadSvc != nil {
|
||||||
yj.downloadSvc.SetContext(ctx)
|
|
||||||
yj.initDownloadRuntime(ctx)
|
yj.initDownloadRuntime(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,9 +394,12 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
yj.library.RestorePausedScans()
|
yj.library.RestorePausedScans()
|
||||||
yj.explore.AdoptPausedIndexBuild()
|
yj.explore.AdoptPausedIndexBuild()
|
||||||
|
|
||||||
// Wire queue (created in NewYellowJacketApp for Wails binding)
|
|
||||||
yj.queue.SetContext(ctx)
|
|
||||||
yj.queue.SetPlayer(yj.player)
|
yj.queue.SetPlayer(yj.player)
|
||||||
|
yj.queue.SetFallbackSource(&queueFallbackAdapter{
|
||||||
|
config: yj.appConfig,
|
||||||
|
playlist: yj.playlist,
|
||||||
|
explore: yj.explore,
|
||||||
|
})
|
||||||
yj.queue.RestoreState()
|
yj.queue.RestoreState()
|
||||||
|
|
||||||
// Wire cross-cutting rescan hooks so the library can
|
// Wire cross-cutting rescan hooks so the library can
|
||||||
@@ -407,6 +439,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
// no-op once every owned artist is covered.
|
// no-op once every owned artist is covered.
|
||||||
yj.explore.BackfillLibraryDiscographies()
|
yj.explore.BackfillLibraryDiscographies()
|
||||||
|
|
||||||
|
// Resolve any release-group MBIDs the scan could only find a
|
||||||
|
// release-level tag for (see updateMBIDs). Same shape as the
|
||||||
|
// discography backfill above: background, bounded, resumable.
|
||||||
|
yj.explore.BackfillReleaseGroupMBIDs()
|
||||||
|
|
||||||
// Start (or resume) the dump-based index build. Skips
|
// Start (or resume) the dump-based index build. Skips
|
||||||
// itself once the one-time import has completed, so this
|
// itself once the one-time import has completed, so this
|
||||||
// is cheap on every startup.
|
// is cheap on every startup.
|
||||||
@@ -495,37 +532,32 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
yj.player.SetMediaControls(yj.mediaControls)
|
yj.player.SetMediaControls(yj.mediaControls)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBeforeClose captures window state while the window is still alive,
|
// SaveWindowState captures the window's size while the window is still
|
||||||
// and asks first when quitting would abandon a job that is writing to
|
// alive. It is registered on the WindowClosing event, because at
|
||||||
// the user's files.
|
// shutdown there is no window left to measure.
|
||||||
//
|
func (yj *YellowJacketApp) SaveWindowState(window application.Window) {
|
||||||
// Returning true keeps the window open. Quitting mid-apply cancels the
|
if window == nil {
|
||||||
// service context and leaves a folder half-retagged with nothing
|
return
|
||||||
// recording where it stopped (errors.p4), which is the one case worth
|
|
||||||
// interrupting a quit for.
|
|
||||||
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
|
||||||
if yj.confirmQuitDuringWrites(ctx) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w, h := wailsruntime.WindowGetSize(ctx)
|
w, h := window.Size()
|
||||||
|
|
||||||
// Guard against a bogus size clobbering a good saved one. During
|
// Guard against a bogus size clobbering a good saved one. During
|
||||||
// teardown / hot-reload the runtime can report a zero or below-
|
// teardown / hot-reload the runtime can report a zero or below-
|
||||||
// minimum size; persisting that would shrink the window to the
|
// minimum size; persisting that would shrink the window to the
|
||||||
// minimum on next launch. Keep the previously-saved size instead.
|
// minimum on next launch. Keep the previously-saved size instead.
|
||||||
if w < config.MinWidth || h < config.MinHeight {
|
if w < config.MinWidth || h < config.MinHeight {
|
||||||
yj.logger.Warn("OnBeforeClose: ignoring bogus window size",
|
yj.logger.Warn("window close: ignoring bogus window size",
|
||||||
"width", w,
|
"width", w,
|
||||||
"height", h,
|
"height", h,
|
||||||
"kept_width", yj.appConfig.Window.Width,
|
"kept_width", yj.appConfig.Window.Width,
|
||||||
"kept_height", yj.appConfig.Window.Height,
|
"kept_height", yj.appConfig.Window.Height,
|
||||||
)
|
)
|
||||||
|
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
yj.logger.Info("OnBeforeClose: saving window state",
|
yj.logger.Info("window close: saving window state",
|
||||||
"width", w,
|
"width", w,
|
||||||
"height", h,
|
"height", h,
|
||||||
"accentColor", yj.appConfig.Theme.AccentColor,
|
"accentColor", yj.appConfig.Theme.AccentColor,
|
||||||
@@ -541,39 +573,69 @@ func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
|||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldQuit answers v3's quit veto: false keeps the app running.
|
||||||
|
//
|
||||||
|
// Quitting mid-apply cancels the service context and leaves a folder
|
||||||
|
// half-retagged with nothing recording where it stopped (errors.p4),
|
||||||
|
// which is the one case worth interrupting a quit for.
|
||||||
|
//
|
||||||
|
// The shape differs from v2's OnBeforeClose because v3's dialog is
|
||||||
|
// asynchronous — Show() returns immediately and the answer arrives on
|
||||||
|
// a button callback — so this cannot ask and answer in one call. It
|
||||||
|
// vetoes the quit, asks, and quits again from the callback if the user
|
||||||
|
// says so. quitConfirmed is what stops that second Quit() coming
|
||||||
|
// straight back here and asking a second time.
|
||||||
|
func (yj *YellowJacketApp) ShouldQuit() bool {
|
||||||
|
if yj.quitConfirmed.Load() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dialog already up must not spawn another on every close attempt.
|
||||||
|
if !yj.quitAsking.CompareAndSwap(false, true) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
app := application.Get()
|
||||||
|
if app == nil {
|
||||||
|
// No runtime to ask through: never trap the user in the app.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog := app.Dialog.Question()
|
||||||
|
dialog.SetTitle("Tags are still being written")
|
||||||
|
dialog.SetMessage(
|
||||||
|
"YellowJacket is rewriting tags on your files. " +
|
||||||
|
"Quitting now leaves that folder holding a mix of old and " +
|
||||||
|
"new tags.\n\nQuit anyway?",
|
||||||
|
)
|
||||||
|
|
||||||
|
quit := dialog.AddButton("Quit anyway")
|
||||||
|
quit.OnClick(func() {
|
||||||
|
yj.quitConfirmed.Store(true)
|
||||||
|
yj.quitAsking.Store(false)
|
||||||
|
app.Quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
stay := dialog.AddButton("Keep writing")
|
||||||
|
stay.OnClick(func() { yj.quitAsking.Store(false) })
|
||||||
|
stay.SetAsDefault()
|
||||||
|
stay.SetAsCancel()
|
||||||
|
|
||||||
|
dialog.Show()
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// confirmQuitDuringWrites returns true when the user chose to stay.
|
// OnShutdown saves player state and cleans up resources before the
|
||||||
// A dialog that cannot be shown is not allowed to trap anyone in the
|
// application exits. v3 passes no context — the app is going away, so
|
||||||
// app, so any error here quits.
|
// there is nothing left to scope work to.
|
||||||
func (yj *YellowJacketApp) confirmQuitDuringWrites(ctx context.Context) bool {
|
func (yj *YellowJacketApp) OnShutdown() {
|
||||||
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
answer, err := wailsruntime.MessageDialog(ctx, wailsruntime.MessageDialogOptions{
|
|
||||||
Type: wailsruntime.QuestionDialog,
|
|
||||||
Title: "Tags are still being written",
|
|
||||||
Message: "YellowJacket is rewriting tags on your files. " +
|
|
||||||
"Quitting now leaves that folder holding a mix of old and " +
|
|
||||||
"new tags.\n\nQuit anyway?",
|
|
||||||
Buttons: []string{"Quit anyway", "Keep writing"},
|
|
||||||
DefaultButton: "Keep writing",
|
|
||||||
CancelButton: "Keep writing",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
yj.logger.Warn("could not ask about quitting mid-write", "err", err)
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return answer == "Keep writing" || answer == "No"
|
|
||||||
}
|
|
||||||
|
|
||||||
// OnShutdown saves player state and cleans up resources before the application exits.
|
|
||||||
func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
|
||||||
if yj.player != nil {
|
if yj.player != nil {
|
||||||
yj.player.SaveState()
|
yj.player.SaveState()
|
||||||
}
|
}
|
||||||
@@ -592,10 +654,17 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
|||||||
// driven by the frontend: once its stores have registered their event
|
// driven by the frontend: once its stores have registered their event
|
||||||
// listeners, index.ts calls Player.EmitCurrentState() and
|
// listeners, index.ts calls Player.EmitCurrentState() and
|
||||||
// Queue.EmitCurrentState() via Wails bindings.
|
// Queue.EmitCurrentState() via Wails bindings.
|
||||||
func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
func (yj *YellowJacketApp) OnDomReady(_ context.Context) {
|
||||||
if yj.startupErr != nil {
|
if yj.startupErr != nil {
|
||||||
yj.logger.Error("startup error", "err", yj.startupErr.Error())
|
yj.logger.Error("startup error", "err", yj.startupErr.Error())
|
||||||
wailsruntime.Quit(ctx)
|
|
||||||
|
// A startup failure is not a mid-write quit, so go straight out
|
||||||
|
// rather than through the ShouldQuit question.
|
||||||
|
yj.quitConfirmed.Store(true)
|
||||||
|
|
||||||
|
if app := application.Get(); app != nil {
|
||||||
|
app.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -635,6 +704,9 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
|||||||
// discography (e.g. a prior run was capped or interrupted).
|
// discography (e.g. a prior run was capped or interrupted).
|
||||||
// Cheap no-op once every owned artist is covered.
|
// Cheap no-op once every owned artist is covered.
|
||||||
yj.explore.BackfillLibraryDiscographies()
|
yj.explore.BackfillLibraryDiscographies()
|
||||||
|
|
||||||
|
// Same continuation for release-group MBID resolution.
|
||||||
|
yj.explore.BackfillReleaseGroupMBIDs()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kick off the autotag prefetch worker so any unscored
|
// Kick off the autotag prefetch worker so any unscored
|
||||||
@@ -684,7 +756,13 @@ func (yj *YellowJacketApp) startJanitor() {
|
|||||||
yj.database, coversDir, library.CoverArtFileSet,
|
yj.database, coversDir, library.CoverArtFileSet,
|
||||||
))
|
))
|
||||||
yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
|
yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
|
||||||
yj.database, filepath.Join(dataDir, explore.ArtistImageDirName),
|
yj.database,
|
||||||
|
filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||||
|
explore.ArtistImageDir,
|
||||||
|
))
|
||||||
|
yj.janitor.Register(maintenance.StrayArtistImageFilesJob(
|
||||||
|
filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||||
|
explore.ArtistImageKeepNames(),
|
||||||
))
|
))
|
||||||
yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
|
yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
|
||||||
filepath.Join(dataDir, explore.CoverArtCacheDirName),
|
filepath.Join(dataDir, explore.CoverArtCacheDirName),
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ package assets
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// distRoot is where the frontend build lands inside the embedded FS.
|
||||||
|
// v2 knew this prefix itself; v3 takes an fs.FS rooted at the assets,
|
||||||
|
// so the sub-FS is taken here.
|
||||||
|
const distRoot = "frontend/dist"
|
||||||
|
|
||||||
// Handler serves frontend assets with custom route support.
|
// Handler serves frontend assets with custom route support.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
Options *assetserver.Options
|
Options application.AssetOptions
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
frontendDistAssets embed.FS
|
frontendDistAssets embed.FS
|
||||||
serveMux *http.ServeMux
|
serveMux *http.ServeMux
|
||||||
@@ -25,8 +32,16 @@ func NewAssetHandler(logger *slog.Logger, frontendDistAssets embed.FS) (*Handler
|
|||||||
frontendDistAssets: frontendDistAssets,
|
frontendDistAssets: frontendDistAssets,
|
||||||
serveMux: http.NewServeMux(),
|
serveMux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
handler.Options = &assetserver.Options{
|
|
||||||
Assets: handler.frontendDistAssets,
|
dist, err := fs.Sub(frontendDistAssets, distRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"could not open %s in the embedded assets: %w", distRoot, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.Options = application.AssetOptions{
|
||||||
|
Handler: application.AssetFileServerFS(dist),
|
||||||
Middleware: handler.Middleware,
|
Middleware: handler.Middleware,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-30
@@ -328,43 +328,42 @@ func (a *Applier) Apply(
|
|||||||
func (a *Applier) syncDBMBIDs(
|
func (a *Applier) syncDBMBIDs(
|
||||||
ctx context.Context, tr TrackApply, cand Candidate,
|
ctx context.Context, tr TrackApply, cand Candidate,
|
||||||
) error {
|
) 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 tr.CandidateTrack.MBID != "" {
|
||||||
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
|
if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
|
||||||
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
||||||
ID: af.RecordingID,
|
ID: tr.Local.AudioFileID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("set recording mbid: %w", err)
|
return fmt.Errorf("set recording mbid: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cand.ReleaseGroupMBID != "" {
|
if cand.ReleaseGroupMBID == "" {
|
||||||
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
|
return nil
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stamp the release-group's original-release year too —
|
// The album is reached through the file rather than through two
|
||||||
// this is what the tracklist / smart-playlist year rule
|
// join tables; SetFileAlbumMBID takes the file id and does the
|
||||||
// surfaces by default once the user accepts a candidate.
|
// lookup in one statement.
|
||||||
if year := parseYear(cand.OriginalDate); year > 0 {
|
if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
|
||||||
if err := a.q.SetReleaseGroupOriginalYear(
|
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
||||||
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
|
ID: tr.Local.AudioFileID,
|
||||||
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
}); err != nil {
|
||||||
ID: rgID,
|
return fmt.Errorf("set album mbid: %w", err)
|
||||||
},
|
}
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("set release group original year: %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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -87,51 +86,22 @@ func seedAudioFiles(
|
|||||||
q := db.Queries
|
q := db.Queries
|
||||||
ctx := db.Ctx
|
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))
|
out := make([]sqlcgen.AudioFile, 0, len(paths))
|
||||||
|
|
||||||
for i, p := range paths {
|
for i, p := range paths {
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
id := database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
Name: p,
|
FilePath: p,
|
||||||
ArtistCreditID: ac.ID,
|
Title: p,
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
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{
|
af, err := q.GetAudioFile(ctx, id)
|
||||||
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",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create audio file: %v", err)
|
t.Fatalf("read seeded audio file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, af)
|
out = append(out, af)
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
|
|||||||
// genuine multi-disc release still separates correctly, since its
|
// genuine multi-disc release still separates correctly, since its
|
||||||
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
|
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
|
||||||
// number.
|
// number.
|
||||||
|
//
|
||||||
|
// This is the single-file fallback used where a whole directory's
|
||||||
|
// disc tags aren't available (e.g. maybeRebindTaggingGroup, which
|
||||||
|
// rebinds one changed file at a time). Where a directory's full set
|
||||||
|
// of raw disc numbers IS available, prefer ResolveDirectoryDiscNumbers
|
||||||
|
// instead — a hardcoded "1" is the wrong guess for an untagged track
|
||||||
|
// sitting alongside siblings that all agree on disc 2.
|
||||||
func normalizeDiscNumber(discNumber int) int {
|
func normalizeDiscNumber(discNumber int) int {
|
||||||
if discNumber <= 0 {
|
if discNumber <= 0 {
|
||||||
return 1
|
return 1
|
||||||
@@ -100,3 +107,55 @@ func normalizeDiscNumber(discNumber int) int {
|
|||||||
|
|
||||||
return discNumber
|
return discNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveDirectoryDiscNumbers returns, for one directory's files, the
|
||||||
|
// disc number each should use when computing its GroupKey.
|
||||||
|
//
|
||||||
|
// normalizeDiscNumber's fixed "fold untagged to disc 1" is only a
|
||||||
|
// safe guess when the caller has no other evidence. Given the whole
|
||||||
|
// directory's raw disc tags at once, a better guess is available: if
|
||||||
|
// every file that DOES carry an explicit disc number agrees on the
|
||||||
|
// same value, an untagged sibling is almost certainly the same disc
|
||||||
|
// — a partially re-tagged rip, not a stray track from a different
|
||||||
|
// one — so it folds to that value instead of a hardcoded 1. If the
|
||||||
|
// directory's explicit disc numbers disagree, it's a genuine
|
||||||
|
// multi-disc release with no per-disc subfolders, and there's no
|
||||||
|
// single disc to guess for the untagged ones, so they fall back to
|
||||||
|
// normalizeDiscNumber's default.
|
||||||
|
//
|
||||||
|
// rawDiscNumbers must be in the same order as the files they belong
|
||||||
|
// to; the returned slice mirrors that order 1:1.
|
||||||
|
func ResolveDirectoryDiscNumbers(rawDiscNumbers []int) []int {
|
||||||
|
consensus := 0
|
||||||
|
ambiguous := false
|
||||||
|
|
||||||
|
for _, d := range rawDiscNumbers {
|
||||||
|
if d <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case consensus == 0:
|
||||||
|
consensus = d
|
||||||
|
case consensus != d:
|
||||||
|
ambiguous = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := 1
|
||||||
|
if consensus > 0 && !ambiguous {
|
||||||
|
fallback = consensus
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]int, len(rawDiscNumbers))
|
||||||
|
|
||||||
|
for i, d := range rawDiscNumbers {
|
||||||
|
if d <= 0 {
|
||||||
|
out[i] = fallback
|
||||||
|
} else {
|
||||||
|
out[i] = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,6 +111,72 @@ func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveDirectoryDiscNumbers_UntaggedFoldsToConsensus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A folder that's really disc 2, partially re-tagged: untagged
|
||||||
|
// tracks should join disc 2, not fall back to a hardcoded disc 1.
|
||||||
|
got := autotag.ResolveDirectoryDiscNumbers([]int{2, 0, 2, 0})
|
||||||
|
want := []int{2, 2, 2, 2}
|
||||||
|
|
||||||
|
if !equalInts(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDirectoryDiscNumbers_AllUntaggedFallsBackToOne(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := autotag.ResolveDirectoryDiscNumbers([]int{0, 0, 0})
|
||||||
|
want := []int{1, 1, 1}
|
||||||
|
|
||||||
|
if !equalInts(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDirectoryDiscNumbers_GenuineMultiDiscKeepsExplicitValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Explicit disagreement (disc 1 and disc 2 both present, no
|
||||||
|
// subfolders) means there's no single disc to guess for the
|
||||||
|
// untagged track — it falls back to normalizeDiscNumber's default
|
||||||
|
// rather than being assigned to either disc.
|
||||||
|
got := autotag.ResolveDirectoryDiscNumbers([]int{1, 1, 2, 2, 0})
|
||||||
|
want := []int{1, 1, 2, 2, 1}
|
||||||
|
|
||||||
|
if !equalInts(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveDirectoryDiscNumbers_PreservesExplicitValuesEvenWhenUnanimous(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Every file already agrees on disc 3 — nothing to resolve, but
|
||||||
|
// the explicit values must pass through unchanged.
|
||||||
|
got := autotag.ResolveDirectoryDiscNumbers([]int{3, 3, 3})
|
||||||
|
want := []int{3, 3, 3}
|
||||||
|
|
||||||
|
if !equalInts(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalInts(a, b []int) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
|
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveLocal returns candidate releases sourced from the local
|
// 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.
|
// whose normalized name matches the tagging item's album name.
|
||||||
// No network calls. Candidates carry all tracks flat; caller runs
|
// No network calls. Candidates carry all tracks flat; caller runs
|
||||||
// AlignTracks on each to produce per-track alignments.
|
// AlignTracks on each to produce per-track alignments.
|
||||||
@@ -67,7 +67,7 @@ func (r *LocalResolver) ResolveLocal(
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
|
rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list local candidates: %w", err)
|
return nil, fmt.Errorf("list local candidates: %w", err)
|
||||||
}
|
}
|
||||||
@@ -84,12 +84,12 @@ func (r *LocalResolver) ResolveLocal(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := byID[row.ReleaseGroupID]; !ok {
|
if _, ok := byID[row.AlbumID]; !ok {
|
||||||
byID[row.ReleaseGroupID] = localCandidate(row)
|
byID[row.AlbumID] = localCandidate(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
tracksByID[row.ReleaseGroupID] = append(
|
tracksByID[row.AlbumID] = append(
|
||||||
tracksByID[row.ReleaseGroupID],
|
tracksByID[row.AlbumID],
|
||||||
CandidateTrack{
|
CandidateTrack{
|
||||||
Position: int(row.TrackNumber),
|
Position: int(row.TrackNumber),
|
||||||
DiscNumber: int(row.DiscNumber),
|
DiscNumber: int(row.DiscNumber),
|
||||||
@@ -113,15 +113,15 @@ func (r *LocalResolver) ResolveLocal(
|
|||||||
// localCandidate converts one sqlc row (minus track-level fields)
|
// localCandidate converts one sqlc row (minus track-level fields)
|
||||||
// into a Candidate shell. Track fields and alignments are filled
|
// into a Candidate shell. Track fields and alignments are filled
|
||||||
// in by the caller.
|
// in by the caller.
|
||||||
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
|
func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate {
|
||||||
date := ""
|
date := ""
|
||||||
if row.Year > 0 {
|
if row.Year > 0 {
|
||||||
date = fmt.Sprintf("%04d", row.Year)
|
date = fmt.Sprintf("%04d", row.Year)
|
||||||
}
|
}
|
||||||
|
|
||||||
mbid := ""
|
mbid := ""
|
||||||
if row.ReleaseGroupMbid.Valid {
|
if row.AlbumMbid.Valid {
|
||||||
mbid = row.ReleaseGroupMbid.String
|
mbid = row.AlbumMbid.String
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Candidate{
|
return &Candidate{
|
||||||
|
|||||||
+84
-78
@@ -75,51 +75,93 @@ func trackAlbumTags(tracks []LocalTrack) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrackCluster is a set of local tracks sharing a non-empty (album,
|
// TrackCluster is a set of local tracks whose album (and album-artist)
|
||||||
// album-artist) tag pair — a candidate sub-album hiding inside a
|
// tags are close enough to describe the same release — a candidate
|
||||||
// mixed-bag folder.
|
// sub-album hiding inside a mixed-bag folder.
|
||||||
type TrackCluster struct {
|
type TrackCluster struct {
|
||||||
AlbumName string
|
AlbumName string
|
||||||
AlbumArtist string
|
AlbumArtist string
|
||||||
Tracks []LocalTrack
|
Tracks []LocalTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterByAlbumArtist groups tracks by normalized (album tag,
|
// clusterFuzzyThreshold is the maximum stringDist between a track's
|
||||||
// album-artist tag) and returns the clusters with at least
|
// album tag (and, separately, its album-artist tag) and the tags that
|
||||||
// clusterMinSize members, in first-seen order (the caller typically
|
// started a cluster for the two to be considered the same album.
|
||||||
// passes tracks already ordered by disc/track/path, so this stays
|
// Tight enough to keep genuinely different albums by the same artist
|
||||||
// deterministic run to run). Tracks with no album tag, or whose
|
// apart, loose enough to absorb the kind of typo, dropped diacritic,
|
||||||
// cluster never reaches clusterMinSize, are omitted — they belong in
|
// or stray whitespace that exact Normalize()-equality clustering used
|
||||||
// the leftover folder, not a synthetic group of their own.
|
// to split into separate clusters — the same distance function
|
||||||
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
// candidate scoring already uses to decide two titles describe the
|
||||||
type key struct{ album, artist string }
|
// same release (rank.go's albumTitleFit/artistCreditFit), applied to
|
||||||
|
// the same question here: do these two tags name the same thing.
|
||||||
|
const clusterFuzzyThreshold = 0.15
|
||||||
|
|
||||||
index := make(map[key]int, 4) //nolint:mnd
|
// clusterTracks groups tracks into candidate sub-albums: a track
|
||||||
|
// joins the first existing cluster whose founding track's album tag
|
||||||
|
// is within clusterFuzzyThreshold (in stringDist terms), and whose
|
||||||
|
// album-artist tag either also matches or is empty on either side —
|
||||||
|
// same "empty means unknown, not a mismatch" contract as
|
||||||
|
// artistCreditFit — or else it starts a new cluster. Tracks with no
|
||||||
|
// album tag are left unassigned (memberOf entry -1).
|
||||||
|
//
|
||||||
|
// Comparing only against the cluster's founding track, not a running
|
||||||
|
// centroid or every member, keeps this O(tracks × clusters) and
|
||||||
|
// deterministic in first-seen order — the order ClusterByAlbumArtist
|
||||||
|
// and SplitPlan's callers already depend on (they pass tracks ordered
|
||||||
|
// by disc/track/path).
|
||||||
|
func clusterTracks(tracks []LocalTrack) (clusters []TrackCluster, memberOf []int) {
|
||||||
|
type rep struct{ album, artist string }
|
||||||
|
|
||||||
var clusters []TrackCluster
|
var reps []rep
|
||||||
|
|
||||||
for _, t := range tracks {
|
memberOf = make([]int, len(tracks))
|
||||||
album := Normalize(t.AlbumTag)
|
|
||||||
if album == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
|
for i, t := range tracks {
|
||||||
|
if Normalize(t.AlbumTag) == "" {
|
||||||
if i, ok := index[k]; ok {
|
memberOf[i] = -1
|
||||||
clusters[i].Tracks = append(clusters[i].Tracks, t)
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
index[k] = len(clusters)
|
joined := -1
|
||||||
clusters = append(clusters, TrackCluster{
|
|
||||||
AlbumName: t.AlbumTag,
|
for ci, r := range reps {
|
||||||
AlbumArtist: t.AlbumArtistTag,
|
artistMatches := t.AlbumArtistTag == "" || r.artist == "" ||
|
||||||
Tracks: []LocalTrack{t},
|
stringDist(t.AlbumArtistTag, r.artist) <= clusterFuzzyThreshold
|
||||||
})
|
|
||||||
|
if artistMatches && stringDist(t.AlbumTag, r.album) <= clusterFuzzyThreshold {
|
||||||
|
joined = ci
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if joined < 0 {
|
||||||
|
joined = len(clusters)
|
||||||
|
|
||||||
|
reps = append(reps, rep{album: t.AlbumTag, artist: t.AlbumArtistTag})
|
||||||
|
clusters = append(clusters, TrackCluster{
|
||||||
|
AlbumName: t.AlbumTag,
|
||||||
|
AlbumArtist: t.AlbumArtistTag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
clusters[joined].Tracks = append(clusters[joined].Tracks, t)
|
||||||
|
memberOf[i] = joined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return clusters, memberOf
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterByAlbumArtist groups tracks by album/album-artist tag
|
||||||
|
// similarity (see clusterTracks) and returns the clusters with at
|
||||||
|
// least clusterMinSize members, in first-seen order. Tracks with no
|
||||||
|
// album tag, or whose cluster never reaches clusterMinSize, are
|
||||||
|
// omitted — they belong in the leftover folder, not a synthetic group
|
||||||
|
// of their own.
|
||||||
|
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
||||||
|
clusters, _ := clusterTracks(tracks)
|
||||||
|
|
||||||
out := clusters[:0]
|
out := clusters[:0]
|
||||||
|
|
||||||
for _, c := range clusters {
|
for _, c := range clusters {
|
||||||
@@ -134,55 +176,19 @@ func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
|||||||
// SplitPlan returns the full set of synthetic groups a mixed-bag
|
// SplitPlan returns the full set of synthetic groups a mixed-bag
|
||||||
// folder should be torn into: ClusterByAlbumArtist's tag-matched
|
// folder should be torn into: ClusterByAlbumArtist's tag-matched
|
||||||
// sub-albums, plus a one-track cluster for every track that didn't
|
// sub-albums, plus a one-track cluster for every track that didn't
|
||||||
// share an (album, album-artist) pair with anything else in the
|
// end up sharing a cluster with anything else in the folder. Unlike
|
||||||
// folder. Unlike ClusterByAlbumArtist alone — which leaves
|
// ClusterByAlbumArtist alone — which leaves unclustered tracks behind
|
||||||
// unclustered tracks behind in the parent group, where they'd still
|
// in the parent group, where they'd still get folded into whatever
|
||||||
// get folded into whatever partial-album match the scorer finds for
|
// partial-album match the scorer finds for the rest of the pile —
|
||||||
// the rest of the pile — this guarantees every track leaves the
|
// this guarantees every track leaves the parent, so a folder of
|
||||||
// parent, so a folder of entirely unrelated singles (no two tracks
|
// entirely unrelated singles (no two tracks share an album tag) still
|
||||||
// share an album tag) still gets torn apart instead of being scored
|
// gets torn apart instead of being scored as one bogus album with a
|
||||||
// as one bogus album with a pile of "extra" tracks. Each singleton's
|
// pile of "extra" tracks. Each singleton's evidence-scaled score
|
||||||
// evidence-scaled score (rank.go) keeps it appropriately humble on
|
// (rank.go) keeps it appropriately humble on its own — it just no
|
||||||
// its own — it just no longer drags an unrelated release's score
|
// longer drags an unrelated release's score down, or gets dragged
|
||||||
// down, or gets dragged down by one.
|
// down by one.
|
||||||
func SplitPlan(tracks []LocalTrack) []TrackCluster {
|
func SplitPlan(tracks []LocalTrack) []TrackCluster {
|
||||||
type key struct{ album, artist string }
|
clusters, memberOf := clusterTracks(tracks)
|
||||||
|
|
||||||
index := make(map[key]int, 4) //nolint:mnd
|
|
||||||
|
|
||||||
var clusters []TrackCluster
|
|
||||||
|
|
||||||
// memberOf[i] is 1+the cluster index track i was assigned to (by
|
|
||||||
// album/artist tag match), or 0 if it never matched anything.
|
|
||||||
// Tracked by slice position rather than any LocalTrack field —
|
|
||||||
// AudioFileID/FilePath are frequently zero-valued in this
|
|
||||||
// package's own tests and would collide, wrongly treating
|
|
||||||
// distinct untagged tracks as duplicates of one another.
|
|
||||||
memberOf := make([]int, len(tracks))
|
|
||||||
|
|
||||||
for i, t := range tracks {
|
|
||||||
album := Normalize(t.AlbumTag)
|
|
||||||
if album == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
|
|
||||||
|
|
||||||
if ci, ok := index[k]; ok {
|
|
||||||
clusters[ci].Tracks = append(clusters[ci].Tracks, t)
|
|
||||||
memberOf[i] = ci + 1
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
index[k] = len(clusters)
|
|
||||||
memberOf[i] = len(clusters) + 1
|
|
||||||
clusters = append(clusters, TrackCluster{
|
|
||||||
AlbumName: t.AlbumTag,
|
|
||||||
AlbumArtist: t.AlbumArtistTag,
|
|
||||||
Tracks: []LocalTrack{t},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clusters that never reached clusterMinSize don't survive as a
|
// Clusters that never reached clusterMinSize don't survive as a
|
||||||
// group; their sole member falls through to the singleton pass
|
// group; their sole member falls through to the singleton pass
|
||||||
@@ -198,7 +204,7 @@ func SplitPlan(tracks []LocalTrack) []TrackCluster {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, t := range tracks {
|
for i, t := range tracks {
|
||||||
if ci := memberOf[i] - 1; ci >= 0 {
|
if ci := memberOf[i]; ci >= 0 {
|
||||||
if _, ok := keptIndex[ci]; ok {
|
if _, ok := keptIndex[ci]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,84 @@ func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClusterByAlbumArtist_TypoVariantsMergeIntoOneCluster(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A dropped diacritic and a stray trailing space are the kind of
|
||||||
|
// noise exact Normalize()-equality clustering used to treat as
|
||||||
|
// two different albums, splitting one real album across clusters
|
||||||
|
// even though a candidate search on either would land on the same
|
||||||
|
// release. Fuzzy clustering absorbs both into one cluster.
|
||||||
|
tracks := []LocalTrack{
|
||||||
|
{
|
||||||
|
Title: "Song A",
|
||||||
|
Artist: "Sigur Ros",
|
||||||
|
AlbumTag: "Agaetis Byrjun",
|
||||||
|
AlbumArtistTag: "Sigur Ros",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Song B",
|
||||||
|
Artist: "Sigur Ros",
|
||||||
|
AlbumTag: "Ágætis byrjun",
|
||||||
|
AlbumArtistTag: "Sigur Ros",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Song C",
|
||||||
|
Artist: "Sigur Ros",
|
||||||
|
AlbumTag: "Agaetis Byrjun ",
|
||||||
|
AlbumArtistTag: "Sigur Ros",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
clusters := ClusterByAlbumArtist(tracks)
|
||||||
|
|
||||||
|
if len(clusters) != 1 {
|
||||||
|
t.Fatalf(
|
||||||
|
"expected typo variants to merge into 1 cluster, got %d: %+v",
|
||||||
|
len(clusters),
|
||||||
|
clusters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clusters[0].Tracks) != 3 { //nolint:mnd
|
||||||
|
t.Fatalf("expected all 3 tracks in the merged cluster, got %d", len(clusters[0].Tracks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterByAlbumArtist_DifferentAlbumsBySameArtistStaySeparate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Fuzzy clustering must not blur genuinely different albums by
|
||||||
|
// the same artist into one cluster just because they share an
|
||||||
|
// artist tag — the threshold has to stay tight enough for this.
|
||||||
|
tracks := []LocalTrack{
|
||||||
|
{
|
||||||
|
Title: "Song A",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
AlbumTag: "OK Computer",
|
||||||
|
AlbumArtistTag: "Radiohead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Song B",
|
||||||
|
Artist: "Radiohead",
|
||||||
|
AlbumTag: "OK Computer",
|
||||||
|
AlbumArtistTag: "Radiohead",
|
||||||
|
},
|
||||||
|
{Title: "Song C", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
|
||||||
|
{Title: "Song D", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
|
||||||
|
}
|
||||||
|
|
||||||
|
clusters := ClusterByAlbumArtist(tracks)
|
||||||
|
|
||||||
|
if len(clusters) != 2 { //nolint:mnd
|
||||||
|
t.Fatalf(
|
||||||
|
"expected OK Computer and Kid A to stay separate, got %d clusters: %+v",
|
||||||
|
len(clusters),
|
||||||
|
clusters,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
|
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package autotag_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"yellowjacket/backend/autotag"
|
"yellowjacket/backend/autotag"
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedAlbum drops a minimal release_group + recordings + audio_files
|
// 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) {
|
func seed(t *testing.T, db *database.DB, album seededAlbum) {
|
||||||
t.Helper()
|
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 {
|
for _, tr := range album.tracks {
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
Name: tr.title,
|
FilePath: tr.filePath,
|
||||||
ArtistCreditID: ac.ID,
|
Title: tr.title,
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
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(`
|
if _, err := db.ExecContext(`
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const applyJobPrefix = "autotag:"
|
|||||||
// registry gets progress, cancel and the global indicator for free; the
|
// registry gets progress, cancel and the global indicator for free; the
|
||||||
// three subsystems that lacked them were the three that were not
|
// three subsystems that lacked them were the three that were not
|
||||||
// registered.
|
// registered.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.jobsReg = reg
|
s.jobsReg = reg
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
"yellowjacket/backend/autotag"
|
"yellowjacket/backend/autotag"
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
@@ -199,13 +201,20 @@ func NewService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetContext stores the Wails runtime context (called from
|
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||||
// OnStartup).
|
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||||
func (s *Service) SetContext(ctx context.Context) {
|
// replaces v2's SetContext, which had to be called by hand from
|
||||||
|
// OnStartup and was exported, so it was also bound to the frontend.
|
||||||
|
func (s *Service) ServiceStartup(
|
||||||
|
ctx context.Context,
|
||||||
|
_ application.ServiceOptions,
|
||||||
|
) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
s.ctx = ctx
|
s.ctx = ctx
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// emitEvent emits a Wails runtime event under the service lock, which
|
// emitEvent emits a Wails runtime event under the service lock, which
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package autotagservice
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"yellowjacket/backend/autotag"
|
"yellowjacket/backend/autotag"
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestService builds a Service with just enough wired up for
|
// 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) {
|
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := db.Ctx
|
|
||||||
q := db.Queries
|
|
||||||
|
|
||||||
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
|
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
|
||||||
ac, err := q.UpsertArtistCredit(ctx, artist)
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
if err != nil {
|
FilePath: filePath,
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
Title: title,
|
||||||
}
|
Artist: artist,
|
||||||
|
Album: album,
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
AlbumArtist: albumArtist,
|
||||||
Name: title,
|
TrackNumber: int64(trackNum),
|
||||||
ArtistCreditID: ac.ID,
|
LengthMs: 200000,
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
|
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)
|
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) {
|
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := db.Ctx
|
for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} {
|
||||||
q := db.Queries
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
|
FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1),
|
||||||
ac, err := q.UpsertArtistCredit(ctx, "The Beatles")
|
Title: title,
|
||||||
if err != nil {
|
Artist: "The Beatles",
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
Album: "Abbey Road",
|
||||||
}
|
TrackNumber: int64(i + 1),
|
||||||
|
LengthMs: 200000,
|
||||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
LibraryID: libraryID,
|
||||||
Name: "Abbey Road",
|
GroupKey: groupKey,
|
||||||
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},
|
|
||||||
})
|
})
|
||||||
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(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
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 {
|
`, groupKey, libraryID); err != nil {
|
||||||
t.Fatalf("insert tagging item: %v", err)
|
t.Fatalf("insert tagging item: %v", err)
|
||||||
}
|
}
|
||||||
@@ -293,20 +213,11 @@ func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) {
|
|||||||
|
|
||||||
db := database.NewTestDB(t)
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
if _, err := db.Queries.CreateAudioFileWithGroupKey(
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
db.Ctx,
|
FilePath: "/coherent/01.mp3",
|
||||||
sqlcgen.CreateAudioFileWithGroupKeyParams{
|
Title: "Track",
|
||||||
FilePath: "/coherent/01.mp3",
|
GroupKey: "g-coherent",
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
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)
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
// A real, live folder — must survive.
|
// A real, live folder — must survive.
|
||||||
if _, err := db.Queries.CreateAudioFileWithGroupKey(
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
db.Ctx,
|
FilePath: "/live/01.mp3",
|
||||||
sqlcgen.CreateAudioFileWithGroupKeyParams{
|
Title: "Track",
|
||||||
FilePath: "/live/01.mp3",
|
GroupKey: "g-live",
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
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)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+117
-2
@@ -10,6 +10,7 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
"yellowjacket/backend/download"
|
"yellowjacket/backend/download"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
@@ -35,6 +36,7 @@ type Config struct {
|
|||||||
loaded bool // true once Load() succeeds
|
loaded bool // true once Load() succeeds
|
||||||
Library *library.Config `toml:"Library"`
|
Library *library.Config `toml:"Library"`
|
||||||
Theme *theme.Config `toml:"Theme"`
|
Theme *theme.Config `toml:"Theme"`
|
||||||
|
General *GeneralConfig `toml:"General"`
|
||||||
Window *WindowConfig `toml:"Window"`
|
Window *WindowConfig `toml:"Window"`
|
||||||
TrackList *tracklist.Config `toml:"TrackList"`
|
TrackList *tracklist.Config `toml:"TrackList"`
|
||||||
Favorites *favorites.Config `toml:"Favorites"`
|
Favorites *favorites.Config `toml:"Favorites"`
|
||||||
@@ -84,6 +86,12 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.General != nil {
|
||||||
|
if err := c.General.Validate(); err != nil {
|
||||||
|
configErrs = errors.Join(configErrs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.TrackList != nil {
|
if c.TrackList != nil {
|
||||||
if err := c.TrackList.Validate(); err != nil {
|
if err := c.TrackList.Validate(); err != nil {
|
||||||
configErrs = errors.Join(configErrs, err)
|
configErrs = errors.Join(configErrs, err)
|
||||||
@@ -240,6 +248,12 @@ func (c *Config) applyDefaults() {
|
|||||||
|
|
||||||
c.Theme.ApplyDefaults()
|
c.Theme.ApplyDefaults()
|
||||||
|
|
||||||
|
if c.General == nil {
|
||||||
|
c.General = &GeneralConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.General.ApplyDefaults()
|
||||||
|
|
||||||
if c.TrackList == nil {
|
if c.TrackList == nil {
|
||||||
c.TrackList = &tracklist.Config{}
|
c.TrackList = &tracklist.Config{}
|
||||||
}
|
}
|
||||||
@@ -267,9 +281,17 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Downloads.ApplyDefaults()
|
c.Downloads.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetContext sets the Wails runtime context for event emission.
|
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||||
func (c *Config) SetContext(ctx context.Context) {
|
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||||
|
// replaces v2's SetContext, which had to be called by hand from
|
||||||
|
// OnStartup and was exported, so it was also bound to the frontend.
|
||||||
|
func (c *Config) ServiceStartup(
|
||||||
|
ctx context.Context,
|
||||||
|
_ application.ServiceOptions,
|
||||||
|
) error {
|
||||||
c.ctx = ctx
|
c.ctx = ctx
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLibraryDirectory returns the currently configured library directory path.
|
// GetLibraryDirectory returns the currently configured library directory path.
|
||||||
@@ -505,6 +527,99 @@ func (c *Config) emitThemeChanged() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDefaultPage returns the view the app opens to on launch.
|
||||||
|
func (c *Config) GetDefaultPage() string {
|
||||||
|
if c.General == nil {
|
||||||
|
return string(DefaultDefaultPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(c.General.DefaultPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultPage validates and saves a new launch page.
|
||||||
|
func (c *Config) SetDefaultPage(page string) error {
|
||||||
|
if c.General == nil {
|
||||||
|
c.General = &GeneralConfig{}
|
||||||
|
c.General.ApplyDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.General.DefaultPage = DefaultPage(page)
|
||||||
|
|
||||||
|
if err := c.General.Validate(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid default page: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not save config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
events.Emit(
|
||||||
|
c.ctx,
|
||||||
|
events.GeneralConfigChanged,
|
||||||
|
map[string]any{
|
||||||
|
"DefaultPage": string(c.General.DefaultPage),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.logger.Info(
|
||||||
|
"default page updated",
|
||||||
|
"page", page,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueueFallback returns what plays, if anything, once the queue
|
||||||
|
// runs out.
|
||||||
|
func (c *Config) GetQueueFallback() string {
|
||||||
|
if c.General == nil {
|
||||||
|
return string(DefaultQueueFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(c.General.QueueFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQueueFallback validates and saves a new queue-fallback mode.
|
||||||
|
func (c *Config) SetQueueFallback(mode string) error {
|
||||||
|
if c.General == nil {
|
||||||
|
c.General = &GeneralConfig{}
|
||||||
|
c.General.ApplyDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.General.QueueFallback = QueueFallback(mode)
|
||||||
|
|
||||||
|
if err := c.General.Validate(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid queue fallback: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not save config: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
events.Emit(
|
||||||
|
c.ctx,
|
||||||
|
events.GeneralConfigChanged,
|
||||||
|
map[string]any{
|
||||||
|
"QueueFallback": string(c.General.QueueFallback),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
c.logger.Info(
|
||||||
|
"queue fallback updated",
|
||||||
|
"mode", mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetTrackListColumns returns the configured track-list columns.
|
// GetTrackListColumns returns the configured track-list columns.
|
||||||
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
||||||
if c.TrackList == nil {
|
if c.TrackList == nil {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +30,10 @@ func setupRecordedConfig(t *testing.T) (*Config, *events.Recorder) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rec := events.NewRecorder()
|
rec := events.NewRecorder()
|
||||||
conf.SetContext(events.WithSink(context.Background(), rec))
|
_ = conf.ServiceStartup(
|
||||||
|
events.WithSink(context.Background(), rec),
|
||||||
|
application.ServiceOptions{},
|
||||||
|
)
|
||||||
|
|
||||||
return conf, rec
|
return conf, rec
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPage identifies which view the app opens to on launch.
|
||||||
|
type DefaultPage string
|
||||||
|
|
||||||
|
// Valid DefaultPage values, matching the frontend's top-level route ids.
|
||||||
|
const (
|
||||||
|
DefaultPageHome DefaultPage = "home"
|
||||||
|
DefaultPageTracks DefaultPage = "tracks"
|
||||||
|
DefaultPageAlbums DefaultPage = "albums"
|
||||||
|
DefaultPageArtists DefaultPage = "artists"
|
||||||
|
DefaultPageGenres DefaultPage = "genres"
|
||||||
|
DefaultPagePlaylists DefaultPage = "playlists"
|
||||||
|
DefaultPageExplore DefaultPage = "explore"
|
||||||
|
DefaultPageDownloads DefaultPage = "downloads"
|
||||||
|
DefaultPageAutotag DefaultPage = "autotag"
|
||||||
|
DefaultPageJobs DefaultPage = "jobs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultDefaultPage is the launch page for a fresh install.
|
||||||
|
const DefaultDefaultPage = DefaultPageHome
|
||||||
|
|
||||||
|
var errUnknownDefaultPage = errors.New("unknown default page")
|
||||||
|
|
||||||
|
// QueueFallback identifies what plays, if anything, once the queue
|
||||||
|
// runs out with nothing left to auto-advance to.
|
||||||
|
type QueueFallback string
|
||||||
|
|
||||||
|
// Valid QueueFallback values.
|
||||||
|
const (
|
||||||
|
QueueFallbackStop QueueFallback = "stop"
|
||||||
|
QueueFallbackFavorites QueueFallback = "favorites"
|
||||||
|
QueueFallbackDynamicMix QueueFallback = "dynamicMix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultQueueFallback is the fallback behavior for a fresh install.
|
||||||
|
const DefaultQueueFallback = QueueFallbackFavorites
|
||||||
|
|
||||||
|
var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
||||||
|
|
||||||
|
// GeneralConfig holds general application preferences that don't
|
||||||
|
// belong to a more specific subsystem.
|
||||||
|
type GeneralConfig struct {
|
||||||
|
DefaultPage DefaultPage `toml:"DefaultPage"`
|
||||||
|
QueueFallback QueueFallback `toml:"QueueFallback"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||||
|
func (c *GeneralConfig) ApplyDefaults() {
|
||||||
|
if c.DefaultPage == "" {
|
||||||
|
c.DefaultPage = DefaultDefaultPage
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.QueueFallback == "" {
|
||||||
|
c.QueueFallback = DefaultQueueFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that all values are well-formed.
|
||||||
|
func (c *GeneralConfig) Validate() error {
|
||||||
|
c.ApplyDefaults()
|
||||||
|
|
||||||
|
switch c.DefaultPage {
|
||||||
|
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
|
||||||
|
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
|
||||||
|
DefaultPageAutotag, DefaultPageJobs:
|
||||||
|
// Valid.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch c.QueueFallback {
|
||||||
|
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
|
||||||
|
// Valid.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", errUnknownQueueFallback, c.QueueFallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -12,7 +12,15 @@ import (
|
|||||||
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
||||||
const PathPrefix = "/covers/"
|
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 {
|
type URLs struct {
|
||||||
Original string
|
Original string
|
||||||
Small string
|
Small string
|
||||||
@@ -36,25 +44,41 @@ func CoversDir() (string, error) {
|
|||||||
return filepath.Join(dataDir, dirName), nil
|
return filepath.Join(dataDir, dirName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SizedFilename derives a sized-variant filename from an original cover art
|
// Suffixes are the size variants a cover is stored as, largest last.
|
||||||
// filename and a size suffix.
|
var Suffixes = []string{"_sm", "_md", "_lg"}
|
||||||
// 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)
|
|
||||||
|
|
||||||
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
|
// ResolveURLs converts a cover art filesystem path into URL paths
|
||||||
// for the original and all size variants (small, medium, large).
|
// for the original and all size variants (small, medium, large).
|
||||||
func ResolveURLs(filesystemPath string) URLs {
|
func ResolveURLs(filesystemPath string) URLs {
|
||||||
base := filepath.Base(filesystemPath)
|
base := filepath.Base(filesystemPath)
|
||||||
|
large := PathPrefix + SizedFilename(base, "_lg")
|
||||||
|
|
||||||
return URLs{
|
return URLs{
|
||||||
Original: PathPrefix + base,
|
Original: large,
|
||||||
Small: PathPrefix + SizedFilename(base, "_sm"),
|
Small: PathPrefix + SizedFilename(base, "_sm"),
|
||||||
Medium: PathPrefix + SizedFilename(base, "_md"),
|
Medium: PathPrefix + SizedFilename(base, "_md"),
|
||||||
Large: PathPrefix + SizedFilename(base, "_lg"),
|
Large: large,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
|
// Original is the largest kept variant: the full-resolution
|
||||||
|
// image is not stored (see URLs).
|
||||||
wantOrig string
|
wantOrig string
|
||||||
wantSm string
|
wantSm string
|
||||||
wantMd string
|
wantMd string
|
||||||
@@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "absolute path",
|
name: "absolute path",
|
||||||
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
||||||
wantOrig: "/covers/a1b2c3d4.jpg",
|
wantOrig: "/covers/a1b2c3d4_lg.jpg",
|
||||||
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
||||||
wantMd: "/covers/a1b2c3d4_md.jpg",
|
wantMd: "/covers/a1b2c3d4_md.jpg",
|
||||||
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
||||||
@@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "bare filename",
|
name: "bare filename",
|
||||||
path: "abcdef01.png",
|
path: "abcdef01.png",
|
||||||
wantOrig: "/covers/abcdef01.png",
|
wantOrig: "/covers/abcdef01_lg.jpg",
|
||||||
wantSm: "/covers/abcdef01_sm.jpg",
|
wantSm: "/covers/abcdef01_sm.jpg",
|
||||||
wantMd: "/covers/abcdef01_md.jpg",
|
wantMd: "/covers/abcdef01_md.jpg",
|
||||||
wantLg: "/covers/abcdef01_lg.jpg",
|
wantLg: "/covers/abcdef01_lg.jpg",
|
||||||
|
|||||||
+61
-188
@@ -5,13 +5,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
@@ -26,9 +23,6 @@ import (
|
|||||||
//go:embed sql/schemas/*.sql
|
//go:embed sql/schemas/*.sql
|
||||||
var schemas embed.FS
|
var schemas embed.FS
|
||||||
|
|
||||||
//go:embed sql/migrations/*.sql
|
|
||||||
var migrations embed.FS
|
|
||||||
|
|
||||||
// DB wraps the SQLite database connection and queries.
|
// DB wraps the SQLite database connection and queries.
|
||||||
//
|
//
|
||||||
// Two handles back a single database file. db is the single-writer
|
// Two handles back a single database file. db is the single-writer
|
||||||
@@ -193,7 +187,26 @@ var exploreIndexFTSTriggers = []string{
|
|||||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||||
END`,
|
END`,
|
||||||
`CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
|
// Narrowed to the three columns the FTS table actually indexes, and
|
||||||
|
// guarded on them having changed. An UPDATE that leaves all three
|
||||||
|
// alone has nothing to re-index, and re-indexing it is not free: an
|
||||||
|
// FTS5 delete has to find the old row's postings in a multi-million
|
||||||
|
// row index, which is the ~31 rows/s figure below.
|
||||||
|
//
|
||||||
|
// This is not a micro-optimisation. Every writer here upserts, and
|
||||||
|
// the merge rules keep existing values (`CASE WHEN excluded.title
|
||||||
|
// != '' ...`), so the common write is a row arriving unchanged: the
|
||||||
|
// discography backfill re-browsing a known artist, the incremental
|
||||||
|
// dump refreshing popularity. Each of those used to pay a full
|
||||||
|
// delete + insert against the FTS index while holding the single
|
||||||
|
// writer connection — measured at 91% of the app's CPU, with the
|
||||||
|
// play path queued behind it.
|
||||||
|
`CREATE TRIGGER explore_index_au AFTER UPDATE OF title, artist_name, aliases
|
||||||
|
ON explore_index
|
||||||
|
WHEN old.title IS NOT new.title
|
||||||
|
OR old.artist_name IS NOT new.artist_name
|
||||||
|
OR old.aliases IS NOT new.aliases
|
||||||
|
BEGIN
|
||||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||||
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||||
@@ -203,7 +216,17 @@ var exploreIndexFTSTriggers = []string{
|
|||||||
|
|
||||||
// createExploreIndexFTSTriggers installs the sync triggers. Safe to
|
// createExploreIndexFTSTriggers installs the sync triggers. Safe to
|
||||||
// call on a database that already has them.
|
// call on a database that already has them.
|
||||||
|
//
|
||||||
|
// It drops first rather than tolerating "already exists", because a
|
||||||
|
// trigger is a definition and not a row: an install that already has
|
||||||
|
// the old one would otherwise keep it forever, and these definitions
|
||||||
|
// are exactly where this table's write cost is decided. Three DDL
|
||||||
|
// statements against a table with no rows to rewrite, on open.
|
||||||
func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
||||||
|
if err := dropExploreIndexFTSTriggers(ctx, db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, stmt := range exploreIndexFTSTriggers {
|
for _, stmt := range exploreIndexFTSTriggers {
|
||||||
if _, err := db.ExecContext(ctx, stmt); err != nil &&
|
if _, err := db.ExecContext(ctx, stmt); err != nil &&
|
||||||
!strings.Contains(err.Error(), "already exists") {
|
!strings.Contains(err.Error(), "already exists") {
|
||||||
@@ -214,6 +237,23 @@ func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exploreIndexFTSTriggerNames is what both the drop paths remove.
|
||||||
|
var exploreIndexFTSTriggerNames = []string{
|
||||||
|
"explore_index_ai", "explore_index_ad", "explore_index_au",
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
||||||
|
for _, name := range exploreIndexFTSTriggerNames {
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx, "DROP TRIGGER IF EXISTS "+name,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("drop explore FTS trigger %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SuspendExploreIndexFTS drops the FTS sync triggers so a bulk load can
|
// SuspendExploreIndexFTS drops the FTS sync triggers so a bulk load can
|
||||||
// write explore_index without paying per-row FTS maintenance.
|
// write explore_index without paying per-row FTS maintenance.
|
||||||
//
|
//
|
||||||
@@ -227,12 +267,8 @@ func createExploreIndexFTSTriggers(ctx context.Context, db *sql.DB) error {
|
|||||||
// Callers MUST pair this with ResumeExploreIndexFTS — while suspended,
|
// Callers MUST pair this with ResumeExploreIndexFTS — while suspended,
|
||||||
// explore_index_fts stops tracking the table and search goes stale.
|
// explore_index_fts stops tracking the table and search goes stale.
|
||||||
func (d *DB) SuspendExploreIndexFTS() error {
|
func (d *DB) SuspendExploreIndexFTS() error {
|
||||||
for _, name := range []string{
|
if err := dropExploreIndexFTSTriggers(d.Ctx, d.db); err != nil {
|
||||||
"explore_index_ai", "explore_index_ad", "explore_index_au",
|
return fmt.Errorf("suspend explore FTS: %w", err)
|
||||||
} {
|
|
||||||
if _, err := d.db.ExecContext(d.Ctx, "DROP TRIGGER IF EXISTS "+name); err != nil {
|
|
||||||
return fmt.Errorf("suspend explore FTS: drop %s: %w", name, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -259,20 +295,19 @@ func (d *DB) ResumeExploreIndexFTS() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applySchema creates the full schema on a fresh database and brings
|
// applySchema creates the full schema.
|
||||||
// an existing one up to date via sql/migrations.
|
|
||||||
//
|
//
|
||||||
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS,
|
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS and
|
||||||
// so on a genuinely new database they create every table already at
|
// declare the current, latest shape of every table — so running them
|
||||||
// its current, latest shape — that's the fast path new installs
|
// against a fresh database produces exactly that shape, and running
|
||||||
// take. A database that already has an older shape (e.g. a
|
// them against a database already at that shape does nothing. That is
|
||||||
// tagging_items missing a column a later build added) needs the gap
|
// the whole mechanism; there is no migration chain and no
|
||||||
// closed, which IF NOT EXISTS can't do: it silently no-ops on a
|
// schema_migrations table.
|
||||||
// table that already exists, columns and all. sql/migrations holds
|
//
|
||||||
// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.)
|
// There was one, and it was squashed (see .planning/plans/013): a chain
|
||||||
// for exactly that gap, tracked in schema_migrations so each applies
|
// only earns its keep once real user databases exist in the wild, and
|
||||||
// at most once — see applyMigrations for how a fresh database's
|
// until then it is a second description of the schema that can drift
|
||||||
// already-current tables tolerate replaying them anyway.
|
// from the first — which this project has already been bitten by once.
|
||||||
func applySchema(ctx context.Context, db *sql.DB) error {
|
func applySchema(ctx context.Context, db *sql.DB) error {
|
||||||
dirEntries, err := schemas.ReadDir("sql/schemas")
|
dirEntries, err := schemas.ReadDir("sql/schemas")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,171 +337,9 @@ func applySchema(ctx context.Context, db *sql.DB) error {
|
|||||||
return fmt.Errorf("could not create explore FTS triggers: %w", err)
|
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
|
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
|
// applyPRAGMAs configures SQLite connection settings. Called by both
|
||||||
// NewDB and NewTestDB to ensure identical behavior.
|
// NewDB and NewTestDB to ensure identical behavior.
|
||||||
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
|
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
|
||||||
|
|||||||
@@ -312,31 +312,13 @@ func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
|
|||||||
|
|
||||||
// Create prerequisite data: artist_credit, recording,
|
// Create prerequisite data: artist_credit, recording,
|
||||||
// audio_file.
|
// audio_file.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
FilePath: "/test/music/song.mp3",
|
||||||
)
|
Title: "Test Song",
|
||||||
if err != nil {
|
Artist: "Test Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 180000,
|
||||||
}
|
LibraryID: libID,
|
||||||
|
})
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create playlist.
|
// Create playlist.
|
||||||
playlist, err := db.Queries.CreatePlaylist(
|
playlist, err := db.Queries.CreatePlaylist(
|
||||||
@@ -442,39 +424,18 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
|||||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
|
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
|
||||||
|
|
||||||
// Insert prerequisite recording.
|
// 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(
|
_, 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 " +
|
"INSERT INTO audio_files " +
|
||||||
"(id, file_path, length_milliseconds, file_type_id, " +
|
"(id, file_path, length_milliseconds, file_type_id, library_id) " +
|
||||||
"recording_id, library_id) " +
|
"VALUES (2, '/test/song2.mp3', 200000, 0, 999)",
|
||||||
"VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)",
|
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error(
|
t.Error(
|
||||||
@@ -483,16 +444,16 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count files by library.
|
// Count files by library.
|
||||||
count, err := db.Queries.CountAudioFilesByLibrary(
|
count, err := db.Queries.CountAudioFiles(
|
||||||
db.Ctx, libID,
|
db.Ctx, libID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CountAudioFilesByLibrary: %v", err)
|
t.Fatalf("CountAudioFiles: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Errorf(
|
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")
|
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
|
||||||
|
|
||||||
// Insert prerequisites.
|
// Insert prerequisites.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')",
|
FilePath: "/test/view.mp3",
|
||||||
)
|
Title: "View Track",
|
||||||
if err != nil {
|
Artist: "View Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 200000,
|
||||||
}
|
LibraryID: libID,
|
||||||
|
})
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query track_metadata VIEW and verify library_id is present
|
// Query track_metadata VIEW and verify library_id is present
|
||||||
// with the correct value.
|
// 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.
|
// 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.
|
// First, set up test data. The test DB already has library id=0.
|
||||||
_, err = db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
FilePath: "/test/play_history.mp3",
|
||||||
)
|
Title: "Test Track",
|
||||||
if err != nil {
|
Artist: "Test Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
TrackNumber: 1,
|
||||||
}
|
DiscNumber: 1,
|
||||||
|
})
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify default play_count is 0.
|
// Verify default play_count is 0.
|
||||||
var playCount int64
|
var playCount int64
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateDownloadRename performs the download subsystem's table rename
|
|
||||||
// for existing databases that still carry the old table names: the
|
|
||||||
// durable "I asked for this" record moved from download_wants to
|
|
||||||
// download_requests, and the one-shot search-and-grab attempt moved
|
|
||||||
// from download_requests to download_downloads (see CLAUDE.md and
|
|
||||||
// .planning/NOTES.md for the full Want->Request / Request->Download
|
|
||||||
// rename).
|
|
||||||
//
|
|
||||||
// This cannot be a plain sql/migrations file the way an ADD COLUMN
|
|
||||||
// migration is. That pattern's tolerance for "duplicate column name"
|
|
||||||
// works because a fresh database's sql/schemas pass already produces
|
|
||||||
// the identical target shape under the identical table name, so
|
|
||||||
// replaying the ALTER TABLE against it is a safe no-op. Here the name
|
|
||||||
// "download_requests" is reused for a different table before and after
|
|
||||||
// the rename, so a fresh database's schema pass creates a real, empty,
|
|
||||||
// correctly-shaped download_downloads AND a real, empty,
|
|
||||||
// correctly-shaped (new) download_requests before this ever runs.
|
|
||||||
// Blindly replaying "ALTER TABLE download_requests RENAME TO
|
|
||||||
// download_downloads" against that fresh database would rename the new,
|
|
||||||
// empty Request table into Download's place, destroying the fresh
|
|
||||||
// install rather than no-opping. Gating on whether the OLD
|
|
||||||
// download_wants table still exists — a name nothing creates or
|
|
||||||
// references once this has run — is what tells an old database and a
|
|
||||||
// fresh (or already migrated) one apart without executing anything
|
|
||||||
// destructive on the fresh path.
|
|
||||||
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
|
||||||
).Scan(&name)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
|
||||||
// Nothing to migrate: either a fresh install (sql/schemas
|
|
||||||
// already produced the target shape) or a database this has
|
|
||||||
// already run against.
|
|
||||||
case err != nil:
|
|
||||||
return fmt.Errorf("check for download_wants table: %w", err)
|
|
||||||
default:
|
|
||||||
if err := runDownloadRename(ctx, db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ensureDownloadIndexes(ctx, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runDownloadRename performs the actual rename dance against a
|
|
||||||
// database confirmed to still have the old download_wants table.
|
|
||||||
func runDownloadRename(ctx context.Context, db *sql.DB) error {
|
|
||||||
stmts := []string{
|
|
||||||
// The schema pass already created an empty, correctly-shaped
|
|
||||||
// download_downloads placeholder under this name (it never
|
|
||||||
// existed under the old naming), which would otherwise collide
|
|
||||||
// with the rename below.
|
|
||||||
`DROP TABLE IF EXISTS download_downloads`,
|
|
||||||
|
|
||||||
// 1. Free the "download_requests" name: the old one-shot
|
|
||||||
// attempt table becomes download_downloads.
|
|
||||||
`ALTER TABLE download_requests RENAME TO download_downloads`,
|
|
||||||
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
|
|
||||||
|
|
||||||
// 2. Claim the now-free "download_requests" name for the
|
|
||||||
// durable-intent table.
|
|
||||||
`ALTER TABLE download_wants RENAME TO download_requests`,
|
|
||||||
|
|
||||||
// 3. The transfer table's FK now points at download_downloads.
|
|
||||||
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
|
|
||||||
|
|
||||||
// Named indexes survive a table/column rename attached to their
|
|
||||||
// old name, so drop them here; ensureDownloadIndexes recreates
|
|
||||||
// them under the names sql/schemas' comments describe.
|
|
||||||
`DROP INDEX IF EXISTS idx_download_requests_created`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_requests_state`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_wants_due`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_wants_entity`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_wants_parent`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_items_request`,
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin download rename migration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
for _, stmt := range stmts {
|
|
||||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("download rename migration %q: %w", stmt, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("commit download rename migration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
|
|
||||||
// omits inline for the renamed table/columns (see
|
|
||||||
// migrateDownloadRename), under their final names. Safe to call
|
|
||||||
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
|
|
||||||
// the time this runs every column/table involved is guaranteed to be
|
|
||||||
// in its final shape on both a fresh and a migrated database.
|
|
||||||
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
|
|
||||||
stmts := []string{
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
|
||||||
ON download_downloads(created_at DESC)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
|
||||||
ON download_downloads(state)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
|
||||||
ON download_requests(next_try_at) WHERE state = 'wanted'`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
|
||||||
ON download_requests(entity, state)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
|
||||||
ON download_requests(parent_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_items_download
|
|
||||||
ON download_items(download_id)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, stmt := range stmts {
|
|
||||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("ensure download index: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
|
|
||||||
// are frozen snapshots of the download subsystem's tables exactly as
|
|
||||||
// they read before the Want/Request rename (see
|
|
||||||
// download_rename_migration.go) — i.e. what a real user's existing
|
|
||||||
// database looks like today, before upgrading to a build that includes
|
|
||||||
// this migration.
|
|
||||||
const oldDownloadRequestsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS download_requests (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
library_id INTEGER NOT NULL,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
|
||||||
release_mbid TEXT,
|
|
||||||
release_group_mbid TEXT,
|
|
||||||
recording_mbid TEXT,
|
|
||||||
artist TEXT NOT NULL DEFAULT '',
|
|
||||||
album TEXT NOT NULL DEFAULT '',
|
|
||||||
query TEXT NOT NULL DEFAULT '',
|
|
||||||
expected TEXT NOT NULL DEFAULT '[]',
|
|
||||||
state TEXT NOT NULL DEFAULT 'searching'
|
|
||||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
|
||||||
'verifying', 'tagging', 'importing',
|
|
||||||
'complete', 'cancelled', 'failed')),
|
|
||||||
error TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
|
||||||
ON download_requests(created_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
|
||||||
ON download_requests(state);
|
|
||||||
`
|
|
||||||
|
|
||||||
const oldDownloadWantsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS download_wants (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mbid TEXT NOT NULL,
|
|
||||||
entity TEXT NOT NULL
|
|
||||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
|
||||||
library_id INTEGER NOT NULL,
|
|
||||||
artist TEXT NOT NULL DEFAULT '',
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
scope TEXT NOT NULL DEFAULT 'future'
|
|
||||||
CHECK(scope IN ('future', 'all')),
|
|
||||||
secondary INTEGER NOT NULL DEFAULT 0,
|
|
||||||
state TEXT NOT NULL DEFAULT 'wanted'
|
|
||||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
|
||||||
parent_id INTEGER,
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT NOT NULL DEFAULT '',
|
|
||||||
last_tried_at DATETIME,
|
|
||||||
next_try_at DATETIME,
|
|
||||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(mbid, library_id),
|
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
|
||||||
ON download_wants(next_try_at)
|
|
||||||
WHERE state = 'wanted';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
|
||||||
ON download_wants(entity, state);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
|
||||||
ON download_wants(parent_id);
|
|
||||||
`
|
|
||||||
|
|
||||||
const oldDownloadItemsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS download_items (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
request_id TEXT NOT NULL,
|
|
||||||
provider_id INTEGER NOT NULL,
|
|
||||||
transport_id INTEGER,
|
|
||||||
external_id TEXT NOT NULL DEFAULT '',
|
|
||||||
candidate TEXT NOT NULL DEFAULT '{}',
|
|
||||||
state TEXT NOT NULL DEFAULT 'queued'
|
|
||||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
|
||||||
'verifying', 'tagging', 'importing',
|
|
||||||
'complete', 'cancelled', 'failed')),
|
|
||||||
staging_dir TEXT NOT NULL DEFAULT '',
|
|
||||||
bytes_done INTEGER NOT NULL DEFAULT 0,
|
|
||||||
bytes_total INTEGER NOT NULL DEFAULT 0,
|
|
||||||
imported_paths TEXT NOT NULL DEFAULT '[]',
|
|
||||||
error TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
|
||||||
ON download_items(state)
|
|
||||||
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
|
||||||
ON download_items(request_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
|
||||||
ON download_items(state);
|
|
||||||
`
|
|
||||||
|
|
||||||
// seedOldDownloadSchema builds the pre-rename download tables and
|
|
||||||
// inserts one row of real data into each, standing in for a real
|
|
||||||
// user's database at the moment it upgrades.
|
|
||||||
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, ddl := range []string{
|
|
||||||
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
|
|
||||||
} {
|
|
||||||
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
|
|
||||||
t.Fatalf("create old download schema: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed library: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO download_wants
|
|
||||||
(id, mbid, entity, library_id, artist, title, state)
|
|
||||||
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed download_wants: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO download_requests
|
|
||||||
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
|
|
||||||
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed download_requests: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO download_items
|
|
||||||
(id, request_id, provider_id, state)
|
|
||||||
VALUES ('item-1', 'dl-1', 1, 'complete')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed download_items: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
|
|
||||||
// brand-new database produces the target shape directly and that
|
|
||||||
// migrateDownloadRename's gate (checking for the old download_wants
|
|
||||||
// table) is a no-op there — the destructive path this test guards
|
|
||||||
// against is exactly the one described in download_rename_migration.go:
|
|
||||||
// blindly replaying the rename against a fresh database's already-
|
|
||||||
// correct, empty download_requests/download_downloads tables.
|
|
||||||
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := openMemDB(t)
|
|
||||||
|
|
||||||
if err := applySchema(t.Context(), db); err != nil {
|
|
||||||
t.Fatalf("apply schema (fresh): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
||||||
table,
|
|
||||||
).Scan(&name)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stray string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
|
||||||
).Scan(&stray)
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both auto-download guardrail indexes sql/schemas deliberately
|
|
||||||
// omits (see ensureDownloadIndexes) must still exist.
|
|
||||||
for _, idx := range []string{
|
|
||||||
"idx_download_requests_due",
|
|
||||||
"idx_download_requests_entity",
|
|
||||||
"idx_download_requests_parent",
|
|
||||||
"idx_download_items_download",
|
|
||||||
} {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
|
||||||
idx,
|
|
||||||
).Scan(&name)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDownloadRename_UpgradesExistingDatabase is the regression test
|
|
||||||
// for the rename itself: an old-shaped database (download_wants +
|
|
||||||
// old-style download_requests, both with real rows) must end up with
|
|
||||||
// the same table names, column names, and data a fresh install would
|
|
||||||
// have — nothing dropped, nothing silently emptied.
|
|
||||||
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fresh := openMemDB(t)
|
|
||||||
if err := applySchema(t.Context(), fresh); err != nil {
|
|
||||||
t.Fatalf("apply schema (fresh): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
upgraded := openMemDB(t)
|
|
||||||
|
|
||||||
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read libraries schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
|
||||||
t.Fatalf("create libraries table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
seedOldDownloadSchema(t, upgraded)
|
|
||||||
|
|
||||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
|
||||||
t.Fatalf("apply schema (upgrade path): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column order must match a fresh install's, for the same reason
|
|
||||||
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
|
|
||||||
// sqlc's `SELECT *` binds positionally.
|
|
||||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
|
||||||
freshCols := tableColumns(t, fresh, table)
|
|
||||||
upgradedCols := tableColumns(t, upgraded, table)
|
|
||||||
|
|
||||||
if len(freshCols) != len(upgradedCols) {
|
|
||||||
t.Fatalf(
|
|
||||||
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
|
|
||||||
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range freshCols {
|
|
||||||
if freshCols[i] != upgradedCols[i] {
|
|
||||||
t.Errorf(
|
|
||||||
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
|
|
||||||
table,
|
|
||||||
i,
|
|
||||||
freshCols[i],
|
|
||||||
upgradedCols[i],
|
|
||||||
freshCols,
|
|
||||||
upgradedCols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The seeded rows survived the rename under their new names.
|
|
||||||
var (
|
|
||||||
requestMBID string
|
|
||||||
requestEntity string
|
|
||||||
)
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
|
|
||||||
).Scan(&requestMBID, &requestEntity)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeded request row missing after rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestMBID != "artist-mbid" || requestEntity != "artist" {
|
|
||||||
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
downloadRequestID sql.NullInt64
|
|
||||||
downloadAlbum string
|
|
||||||
)
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
|
|
||||||
).Scan(&downloadRequestID, &downloadAlbum)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeded download row missing after rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
|
|
||||||
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if downloadAlbum != "OK Computer" {
|
|
||||||
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemDownloadID string
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
|
|
||||||
).Scan(&itemDownloadID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeded item row missing after rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if itemDownloadID != "dl-1" {
|
|
||||||
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The old table is gone, not just emptied.
|
|
||||||
var stray string
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
|
||||||
).Scan(&stray)
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Running the whole thing again (as a second app startup would) is
|
|
||||||
// a no-op: the gate sees no download_wants table and does nothing
|
|
||||||
// further, so this must not error or duplicate anything.
|
|
||||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
|
||||||
t.Fatalf("apply schema a second time: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
|
|
||||||
if err := upgraded.QueryRowContext(
|
|
||||||
t.Context(), `SELECT COUNT(*) FROM download_requests`,
|
|
||||||
).Scan(&count); err != nil {
|
|
||||||
t.Fatalf("count download_requests: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedExploreRow inserts one explore_index row.
|
// 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) {
|
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(mbid))
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
||||||
VALUES ('recording', ?, ?, ?, '')
|
VALUES (3 /* recording */, ?, ?, ?, x'')
|
||||||
`, mbid, title, artist); err != nil {
|
`, sum[:16], title, artist); err != nil {
|
||||||
t.Fatalf("seed %s: %v", mbid, err)
|
t.Fatalf("seed %s: %v", mbid, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,3 +166,163 @@ func TestExploreFTSSuspendIsIdempotent(t *testing.T) {
|
|||||||
t.Fatalf("resume: %v", err)
|
t.Fatalf("resume: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ftsSegmentCount reports how much the FTS index itself has been
|
||||||
|
// written to. Every delete + insert the update trigger performs
|
||||||
|
// appends to the shadow content table, so this is the observable that
|
||||||
|
// tells "the trigger re-indexed the row" from "the trigger declined
|
||||||
|
// to". Search results cannot: a no-op re-index leaves the same
|
||||||
|
// matches behind.
|
||||||
|
func ftsSegmentCount(t *testing.T, db *DB) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rows, err := db.QueryContext("SELECT COUNT(*) FROM explore_index_fts_data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fts data count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
|
||||||
|
if rows.Next() {
|
||||||
|
if err := rows.Scan(&n); err != nil {
|
||||||
|
t.Fatalf("scan fts data count: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// The common write in this schema is an upsert whose merge rules keep
|
||||||
|
// every existing value — the discography backfill re-browsing a known
|
||||||
|
// artist, the incremental dump refreshing popularity. Re-indexing
|
||||||
|
// those cost an FTS5 delete against a multi-million row index while
|
||||||
|
// holding the single writer connection, which is what starved the
|
||||||
|
// playback path. An update that leaves title, artist_name and aliases
|
||||||
|
// alone must not touch the FTS index at all.
|
||||||
|
func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
||||||
|
db := NewTestDB(t)
|
||||||
|
|
||||||
|
seedExploreRow(t, db, "mbid-1", "Unchanged Title", "Steady Artist")
|
||||||
|
|
||||||
|
before := ftsSegmentCount(t, db)
|
||||||
|
|
||||||
|
// A popularity refresh: an FTS column is not named at all.
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
"UPDATE explore_index SET popularity = 42 WHERE title = ?",
|
||||||
|
"Unchanged Title",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("popularity update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An upsert-shaped write that re-states the text identically, which
|
||||||
|
// is what the merge rules produce for a row that has not changed.
|
||||||
|
if _, err := db.ExecContext(`
|
||||||
|
UPDATE explore_index
|
||||||
|
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
|
||||||
|
WHERE title = ?
|
||||||
|
`, "Unchanged Title"); err != nil {
|
||||||
|
t.Fatalf("no-op text update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ftsSegmentCount(t, db); got != before {
|
||||||
|
t.Errorf(
|
||||||
|
"FTS index written by an update that changed no text: %d rows, want %d",
|
||||||
|
got, before,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ftsMatches(t, db, "Unchanged"); got != 1 {
|
||||||
|
t.Errorf("matches after unchanged updates = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the same guard: a real rename still re-indexes,
|
||||||
|
// old term gone and new term found.
|
||||||
|
func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
|
||||||
|
db := NewTestDB(t)
|
||||||
|
|
||||||
|
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
"UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?",
|
||||||
|
"Original Title",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ftsMatches(t, db, "Original"); got != 0 {
|
||||||
|
t.Errorf("matches for the old title = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ftsMatches(t, db, "Corrected"); got != 1 {
|
||||||
|
t.Errorf("matches for the new title = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same for the other two indexed columns.
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?",
|
||||||
|
"Corrected Title",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("artist rename: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ftsMatches(t, db, "Renamed"); got != 1 {
|
||||||
|
t.Errorf("matches for the new artist = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := ftsMatches(t, db, "AKA"); got != 1 {
|
||||||
|
t.Errorf("matches for the new alias = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An existing install already carries the previous, unguarded trigger,
|
||||||
|
// and a create that tolerated "already exists" would leave it there
|
||||||
|
// forever — so the definition has to be replaced on open, not merely
|
||||||
|
// offered.
|
||||||
|
func TestExploreFTSTriggersAreReplacedOnOpen(t *testing.T) {
|
||||||
|
db := NewTestDB(t)
|
||||||
|
|
||||||
|
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shape that shipped before: fires on every UPDATE.
|
||||||
|
if _, err := db.ExecContext(`
|
||||||
|
CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
|
||||||
|
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||||
|
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||||
|
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||||
|
VALUES (new.id, new.title, new.artist_name, new.aliases);
|
||||||
|
END
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("install old trigger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createExploreIndexFTSTriggers(db.Ctx, db.db); err != nil {
|
||||||
|
t.Fatalf("recreate triggers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'explore_index_au'",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read trigger sql: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
definition := ""
|
||||||
|
|
||||||
|
if rows.Next() {
|
||||||
|
if err := rows.Scan(&definition); err != nil {
|
||||||
|
t.Fatalf("scan trigger sql: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(definition, "UPDATE OF") ||
|
||||||
|
!strings.Contains(definition, "WHEN") {
|
||||||
|
t.Errorf("explore_index_au was not replaced; definition is:\n%s", definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+108
-111
@@ -1,15 +1,26 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"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
|
// 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 {
|
type LyricsHit struct {
|
||||||
RecordingID int64
|
AudioFileID int64
|
||||||
FilePath string
|
FilePath string
|
||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
Title string
|
Title string
|
||||||
@@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the matched recording (lyrics_index.rowid == recordings.id)
|
// lyrics_index.rowid is the audio file's id, so the hit is already
|
||||||
// to a representative playable file via the lowest audio_files id,
|
// a playable file - it used to be a recording id, which then had to
|
||||||
// then to the track_metadata VIEW for display fields.
|
// 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.
|
// 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
|
SELECT
|
||||||
r.id,
|
tm.id,
|
||||||
tm.file_path,
|
tm.file_path,
|
||||||
tm.length_milliseconds,
|
tm.length_milliseconds,
|
||||||
tm.title,
|
tm.title,
|
||||||
tm.artist_name,
|
tm.artist_name,
|
||||||
tm.album
|
tm.album
|
||||||
FROM lyrics_index li
|
FROM lyrics_index li
|
||||||
JOIN recordings r ON r.id = li.rowid
|
JOIN track_metadata tm ON tm.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
|
|
||||||
WHERE lyrics_index MATCH ?
|
WHERE lyrics_index MATCH ?
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var h LyricsHit
|
var h LyricsHit
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&h.RecordingID,
|
&h.AudioFileID,
|
||||||
&h.FilePath,
|
&h.FilePath,
|
||||||
&h.LengthMilliseconds,
|
&h.LengthMilliseconds,
|
||||||
&h.Title,
|
&h.Title,
|
||||||
@@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecordingLyrics returns the stored lyrics for a recording, or
|
// GetLyrics returns the stored lyrics for a file, or "" if none.
|
||||||
// an empty string if none are stored.
|
func (d *DB) GetLyrics(audioFileID int64) (string, error) {
|
||||||
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
|
|
||||||
var lyrics string
|
var lyrics string
|
||||||
|
|
||||||
err := d.db.QueryRowContext(d.Ctx,
|
err := d.reader().QueryRowContext(d.Ctx,
|
||||||
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
|
"SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||||
recordingID,
|
|
||||||
).Scan(&lyrics)
|
).Scan(&lyrics)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != 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
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
|
// SetLyrics writes lyrics for a file and keeps the FTS index in sync.
|
||||||
// lyrics_index in sync (delete + reinsert the single row). Used by
|
//
|
||||||
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
|
// `source` says where they came from, which is the question the old
|
||||||
// string clears both the column and the index entry.
|
// column could not answer: lyrics read from a USLT frame are rebuilt
|
||||||
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
|
// free by any rescan, and lyrics fetched from LRCLIB are network
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
// traffic nobody wants to repeat. Passing an empty string clears both
|
||||||
"UPDATE recordings SET lyrics = ? WHERE id = ?",
|
// the row and the index entry.
|
||||||
lyrics, recordingID,
|
func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error {
|
||||||
); err != nil {
|
if strings.TrimSpace(lyrics) == "" {
|
||||||
return fmt.Errorf("could not update recording lyrics: %w", err)
|
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
|
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
||||||
// contentless lyrics_index. contentless_delete=1 makes the DELETE
|
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||||
// valid; an empty lyrics string leaves the row deleted.
|
// lyrics string leaves the row deleted.
|
||||||
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
|
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
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.
|
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
||||||
recordingID, lyrics,
|
audioFileID, lyrics,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("could not insert lyrics_index row: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
|
// RebuildLyricsIndex repopulates lyrics_index from the lyrics table.
|
||||||
// current recordings table. Cheap for a personal library and safe to
|
|
||||||
// run after every scan.
|
|
||||||
func (d *DB) RebuildLyricsIndex() error {
|
func (d *DB) RebuildLyricsIndex() error {
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil {
|
||||||
"DELETE FROM lyrics_index",
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("could not clear lyrics_index: %w", err)
|
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, `
|
if _, err := d.db.ExecContext(d.Ctx, `
|
||||||
INSERT INTO lyrics_index(rowid, lyrics)
|
INSERT INTO lyrics_index(rowid, lyrics)
|
||||||
SELECT id, lyrics
|
SELECT audio_file_id, text FROM lyrics WHERE text != ''
|
||||||
FROM recordings
|
|
||||||
WHERE lyrics IS NOT NULL AND lyrics != ''
|
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
||||||
}
|
}
|
||||||
@@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordingsMissingLyrics returns recordings that have no stored
|
// LyricsCandidate identifies a file that needs its lyrics fetched and
|
||||||
// lyrics but do carry the artist/title/duration needed to look them
|
// carries the fields an external provider matches on.
|
||||||
// up from an external provider. Used by the LRCLIB backfill. The
|
type LyricsCandidate struct {
|
||||||
// limit bounds each batch so the backfill can be run incrementally.
|
AudioFileID int64
|
||||||
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
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 {
|
if limit <= 0 {
|
||||||
limit = 200
|
limit = 200
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := d.db.QueryContext(d.Ctx, `
|
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||||
SELECT
|
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||||
r.id,
|
tm.recording_mbid, tm.length_milliseconds
|
||||||
COALESCE(r.name, ''),
|
FROM track_metadata tm
|
||||||
COALESCE(ac.text, ''),
|
WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id)
|
||||||
COALESCE(rg.name, ''),
|
AND tm.title != '' AND tm.artist_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
|
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, limit)
|
`, limit)
|
||||||
if err != nil {
|
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() }()
|
defer func() { _ = rows.Close() }()
|
||||||
@@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c LyricsCandidate
|
var c LyricsCandidate
|
||||||
if err := rows.Scan(
|
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 {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
|
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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LyricsCandidate identifies a recording that needs its lyrics fetched
|
// FileLyricLookup returns the provider-match fields for one file, so
|
||||||
// and carries the fields an external provider matches on.
|
// lyrics can be fetched on demand. Returns nil if the file has no
|
||||||
type LyricsCandidate struct {
|
// artist/title to match on.
|
||||||
RecordingID int64
|
func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) {
|
||||||
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) {
|
|
||||||
var c LyricsCandidate
|
var c LyricsCandidate
|
||||||
|
|
||||||
err := d.db.QueryRowContext(d.Ctx, `
|
err := d.reader().QueryRowContext(d.Ctx, `
|
||||||
SELECT
|
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||||
r.id,
|
tm.recording_mbid, tm.length_milliseconds
|
||||||
COALESCE(r.name, ''),
|
FROM track_metadata tm
|
||||||
COALESCE(ac.text, ''),
|
WHERE tm.id = ?
|
||||||
COALESCE(rg.name, ''),
|
`, audioFileID).Scan(
|
||||||
COALESCE(MIN(af.length_milliseconds), 0)
|
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||||
FROM recordings r
|
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||||
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)
|
|
||||||
if err != nil {
|
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 == "" {
|
if c.Title == "" || c.Artist == "" {
|
||||||
|
|||||||
@@ -4,59 +4,33 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
|
// seedLyricsTrack inserts one file with the given lyrics, so lyric
|
||||||
// recording → audio_file → release_group link) for one track with the
|
// searches have something realistic to join against. It used to
|
||||||
// given lyrics, so lyric-search tests have realistic joins.
|
// insert a four-row FK chain by hand.
|
||||||
func seedLyricsTrack(
|
func seedLyricsTrack(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *DB,
|
db *DB,
|
||||||
id int64,
|
id int64,
|
||||||
title, artist, album, lyrics string,
|
title, artist, album, lyrics string,
|
||||||
lenMs int64,
|
lenMs int64,
|
||||||
) {
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
fileID := InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
|
FilePath: "/music/track" + itoa(id) + ".mp3",
|
||||||
); err != nil {
|
Title: title,
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
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(
|
return fileID
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(v int64) string {
|
func itoa(v int64) string {
|
||||||
@@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := hits[0]
|
h := hits[0]
|
||||||
if h.RecordingID != 1 {
|
if h.AudioFileID != 1 {
|
||||||
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
|
t.Errorf("RecordingID = %d, want 1", h.AudioFileID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.Title != "The Sound of Silence" {
|
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.
|
// Backfill lyrics — should update both the column and the FTS index.
|
||||||
const lyrics = "Yesterday all my troubles seemed so far away"
|
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)
|
t.Fatalf("SetRecordingLyrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := db.GetRecordingLyrics(1)
|
stored, err := db.GetLyrics(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetRecordingLyrics: %v", err)
|
t.Fatalf("GetRecordingLyrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
|||||||
t.Fatalf("SearchLyrics: %v", err)
|
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)
|
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, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
|
||||||
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
||||||
|
|
||||||
missing, err := db.RecordingsMissingLyrics(50)
|
missing, err := db.FilesMissingLyrics(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := missing[0]
|
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)
|
t.Errorf("unexpected candidate: %+v", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single-recording lookup mirrors the batch fields.
|
// Single-recording lookup mirrors the batch fields.
|
||||||
one, err := db.RecordingLyricLookup(2)
|
one, err := db.FileLyricLookup(2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordingLyricLookup: %v", err)
|
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"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SearchRow holds a single result from an FTS5 or basename search.
|
// SearchRow holds a single result from an FTS5 or basename search.
|
||||||
@@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackRow holds a full track result from an FTS5 search,
|
// trackMetadataColumns is the column list of the track_metadata view,
|
||||||
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
|
// in the order sqlc generates TrackMetadatum's fields. The FTS
|
||||||
type SearchTrackRow struct {
|
// searches below cannot be sqlc queries (MATCH is not in its grammar),
|
||||||
FilePath string
|
// so this is the one place the view's shape is written out by hand.
|
||||||
LengthMilliseconds int64
|
const trackMetadataColumns = `
|
||||||
Title string
|
tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name,
|
||||||
ArtistName string
|
tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year,
|
||||||
TrackNumber sql.NullInt64
|
tm.release_year, tm.composer, tm.file_type, tm.sample_rate,
|
||||||
DiscNumber sql.NullInt64
|
tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id,
|
||||||
Album string
|
tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid,
|
||||||
Genre string
|
tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id`
|
||||||
Year int64
|
|
||||||
Composer string
|
// scanTrackMetadata reads track_metadata rows into the generated row
|
||||||
FileType string
|
// type, so an FTS hit and an ordinary query produce the same Track.
|
||||||
SampleRate int64
|
func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) {
|
||||||
BitDepth int64
|
var out []sqlcgen.TrackMetadatum
|
||||||
Channels int64
|
|
||||||
Bitrate int64
|
for rows.Next() {
|
||||||
FileSize int64
|
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
|
// SearchFTSTracks performs a full-text search and returns whole tracks.
|
||||||
// metadata for each match. Unlike SearchFTS (which returns only 5
|
//
|
||||||
// columns), this includes all 16 fields needed for library.Track.
|
// 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(
|
func (d *DB) SearchFTSTracks(
|
||||||
query string, limit int,
|
query string, libraryID int64, limit int,
|
||||||
) ([]SearchTrackRow, error) {
|
) ([]sqlcgen.TrackMetadatum, error) {
|
||||||
query = strings.TrimSpace(query)
|
query = strings.TrimSpace(query)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ftsQuery := buildFTSQuery(query)
|
|
||||||
|
|
||||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
// 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
|
SELECT`+trackMetadataColumns+`
|
||||||
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
|
FROM search_index si
|
||||||
JOIN track_metadata tm ON tm.id = si.rowid
|
JOIN track_metadata tm ON tm.id = si.rowid
|
||||||
WHERE search_index MATCH ?
|
WHERE search_index MATCH ?
|
||||||
|
AND (? = 0 OR tm.library_id = ?)
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, ftsQuery, limit)
|
`, buildFTSQuery(query), libraryID, libraryID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("FTS track search failed: %w", err)
|
||||||
"FTS track search failed: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var results []SearchTrackRow
|
return scanTrackMetadata(rows)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
func scanSearchRows(
|
||||||
rows interface {
|
rows interface {
|
||||||
Next() bool
|
Next() bool
|
||||||
|
|||||||
+76
-232
@@ -3,6 +3,8 @@ package database
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedSearchData inserts ~7 tracks with the full FK chain required for
|
// 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 {
|
for _, tr := range tracks {
|
||||||
if _, ok := artistMap[tr.artist]; !ok {
|
var genres []string
|
||||||
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.
|
|
||||||
if tr.genre != "" {
|
if tr.genre != "" {
|
||||||
gID := genreMap[tr.genre]
|
genres = []string{tr.genre}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
var trackNum, discNum int64
|
||||||
"INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
|
if tr.trackNum != nil {
|
||||||
tr.id, gID,
|
trackNum = *tr.trackNum
|
||||||
)
|
}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err)
|
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)
|
db := NewTestDB(t)
|
||||||
seedSearchData(t, db)
|
seedSearchData(t, db)
|
||||||
|
|
||||||
results, err := db.SearchFTSTracks("queen", 10)
|
results, err := db.SearchFTSTracks("queen", 0, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchFTSTracks: %v", err)
|
t.Fatalf("SearchFTSTracks: %v", err)
|
||||||
}
|
}
|
||||||
@@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the Bohemian Rhapsody result and verify all 16 fields.
|
// Find the Bohemian Rhapsody result and verify all 16 fields.
|
||||||
var br *SearchTrackRow
|
var br *sqlcgen.TrackMetadatum
|
||||||
|
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
if r.Title == "Bohemian Rhapsody" {
|
if r.Title == "Bohemian Rhapsody" {
|
||||||
@@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) {
|
|||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Set up minimal FK chain for a single track.
|
// Set up minimal FK chain for a single track.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
FilePath: "/test/track.mp3",
|
||||||
)
|
Title: "Test Track",
|
||||||
if err != nil {
|
Artist: "Test Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 180000,
|
||||||
}
|
})
|
||||||
|
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into search index.
|
// Insert into search index.
|
||||||
if err := db.InsertSearchIndex(
|
if err := db.InsertSearchIndex(
|
||||||
@@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) {
|
|||||||
|
|
||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Seed the full entity graph WITHOUT inserting into search_index.
|
// Seed the file WITHOUT putting it in search_index.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')",
|
FilePath: "/rebuild/track.mp3",
|
||||||
)
|
Title: "Rebuild Track",
|
||||||
if err != nil {
|
Artist: "Rebuild Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
Album: "Rebuild Album",
|
||||||
}
|
LengthMs: 200000,
|
||||||
|
SkipSearchIndex: true,
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search should return nothing before rebuild.
|
// Search should return nothing before rebuild.
|
||||||
results, err := db.SearchFTS("Rebuild", 10)
|
results, err := db.SearchFTS("Rebuild", 10)
|
||||||
@@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
|||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Set up minimal FK chain for a single track at rowid 100.
|
// Set up minimal FK chain for a single track at rowid 100.
|
||||||
_, err := db.ExecContext(
|
id := InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')",
|
FilePath: "/test/update_cycle.mp3",
|
||||||
)
|
Title: "Old Title",
|
||||||
if err != nil {
|
Artist: "Old Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 200000,
|
||||||
}
|
SkipSearchIndex: true,
|
||||||
|
})
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Insert with old metadata.
|
// 1. Insert with old metadata.
|
||||||
if err := db.InsertSearchIndex(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("InsertSearchIndex (old): %v", err)
|
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)
|
results, err := db.SearchFTS("Old Title", 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchFTS(Old Title): %v", err)
|
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")
|
t.Fatal("SearchFTS(Old Title): got 0 results after insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delete rowid 100.
|
// 2. Delete the row.
|
||||||
if err := db.DeleteSearchIndex(100); err != nil {
|
if err := db.DeleteSearchIndex(id); err != nil {
|
||||||
t.Fatalf("DeleteSearchIndex(100): %v", err)
|
t.Fatalf("DeleteSearchIndex(%d): %v", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify "Old Title" no longer found.
|
// 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(
|
_, 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 {
|
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.
|
// 4. Re-insert the row with new metadata.
|
||||||
_, 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.
|
|
||||||
if err := db.InsertSearchIndex(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("InsertSearchIndex (new): %v", err)
|
t.Fatalf("InsertSearchIndex (new): %v", err)
|
||||||
}
|
}
|
||||||
@@ -1079,25 +935,13 @@ func TestSearchIndexSchema(t *testing.T) {
|
|||||||
t.Fatalf("insert artist: %v", err)
|
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(
|
_, err = db.ExecContext(
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')",
|
"INSERT INTO artists (id, name) VALUES (2, 'Test')",
|
||||||
)
|
|
||||||
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)",
|
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("duplicate artist_credit_artist insert should fail, got nil error")
|
t.Error("duplicate artist name should fail, got nil error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Adds SplitMixedFolder's synthetic-group bookkeeping to an
|
|
||||||
-- existing tagging_items table. A fresh database never runs this
|
|
||||||
-- file: sql/schemas/tagging_items.sql already declares these
|
|
||||||
-- columns, so applySchema's isFreshDatabase check stamps this
|
|
||||||
-- version as applied without executing it.
|
|
||||||
ALTER TABLE tagging_items ADD COLUMN synthetic INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE tagging_items ADD COLUMN parent_group_key TEXT NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_parent_group_key
|
|
||||||
ON tagging_items(parent_group_key) WHERE parent_group_key != '';
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- Repairs tagging_items rows left behind by a library-scan bug: the
|
|
||||||
-- rescan's orphan-cleanup phase deleted audio_files rows for files
|
|
||||||
-- removed from disk without decrementing/clearing their tagging
|
|
||||||
-- group, so a folder whose contents were fully replaced kept a
|
|
||||||
-- phantom entry (stale track_count, no matching audio_files) in the
|
|
||||||
-- autotag queue forever. The library scan code no longer has this
|
|
||||||
-- gap, but a database written before the fix still carries the
|
|
||||||
-- damage — this is a one-time repair, not ongoing bookkeeping.
|
|
||||||
--
|
|
||||||
-- Drop groups with no audio_files left at all.
|
|
||||||
DELETE FROM tagging_items
|
|
||||||
WHERE group_key NOT IN (
|
|
||||||
SELECT DISTINCT group_key FROM audio_files WHERE group_key != ''
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Reconcile track_count for groups that are still alive but drifted
|
|
||||||
-- (some, not all, of their tracks were removed without decrementing).
|
|
||||||
UPDATE tagging_items
|
|
||||||
SET track_count = (
|
|
||||||
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
|
|
||||||
)
|
|
||||||
WHERE track_count != (
|
|
||||||
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,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
|
-- Queries over artists.
|
||||||
INSERT INTO artists (name) VALUES (?)
|
--
|
||||||
|
-- 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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetArtist :one
|
-- name: GetArtist :one
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists WHERE id = ? LIMIT 1;
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetArtistByName :one
|
-- name: GetArtistByName :one
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists WHERE name = ? LIMIT 1;
|
||||||
WHERE name = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpsertArtist :one
|
-- name: SetArtistMBID :exec
|
||||||
INSERT INTO artists (name) VALUES (?)
|
UPDATE artists SET mbid = ? WHERE id = ?;
|
||||||
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: UpdateArtist :exec
|
|
||||||
UPDATE artists
|
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteArtist :exec
|
-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists WHERE id = ?;
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllArtists :exec
|
-- name: DeleteAllArtists :exec
|
||||||
DELETE FROM artists;
|
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
|
-- name: GetAllArtists :many
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists ORDER BY name;
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: GetAlbumArtists :many
|
-- name: GetAlbumArtists :many
|
||||||
SELECT DISTINCT a.id, a.name, a.mbid
|
SELECT DISTINCT a.id, a.name, a.mbid
|
||||||
FROM artists a
|
FROM artists a
|
||||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
JOIN albums al ON al.artist_id = a.id
|
||||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
WHERE EXISTS (
|
||||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
SELECT 1 FROM audio_files af
|
||||||
ORDER BY a.name;
|
WHERE af.album_id = al.id
|
||||||
|
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||||
-- 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 = ?
|
|
||||||
)
|
)
|
||||||
ORDER BY a.name;
|
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
|
-- 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 (
|
INSERT INTO audio_files (
|
||||||
file_path, length_milliseconds, file_type_id, recording_id,
|
file_path, library_id, file_type_id,
|
||||||
sample_rate, bit_depth, channels, bitrate, file_size, basename,
|
length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size,
|
||||||
library_id, group_key, tag_status, modified_at
|
title, artist_credit, artist_id, album_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
track_number, disc_number, total_tracks, year, composer, comment,
|
||||||
|
recording_mbid, basename, group_key, modified_at, tag_status
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetAudioFileGroupKey :one
|
-- name: UpdateAudioFileTags :exec
|
||||||
SELECT group_key FROM audio_files
|
-- A rescan of a file whose mtime moved: the tags are re-read and
|
||||||
WHERE id = ? LIMIT 1;
|
-- 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
|
-- name: SetAudioFileGroupKey :exec
|
||||||
UPDATE audio_files SET group_key = ? WHERE id = ?;
|
UPDATE audio_files SET group_key = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: GetAudioFile :one
|
-- name: PromoteAudioFileTagStatusIfUntagged :exec
|
||||||
SELECT * FROM audio_files
|
-- A rescan re-reads the tags of a file whose mtime moved, so a file
|
||||||
WHERE id = ? LIMIT 1;
|
-- another tagger stamped with MBIDs since import arrives here still
|
||||||
|
-- carrying the 'untagged' status it was created with (only the insert
|
||||||
-- name: GetAudioFileByPath :one
|
-- path sets it). Promote it the same way saveAudioFile does.
|
||||||
SELECT * FROM audio_files
|
-- Guarded on 'untagged' so it cannot overwrite a deliberate
|
||||||
WHERE file_path = ? LIMIT 1;
|
-- 'user_skipped_permanent', and so a file losing its MBIDs is left
|
||||||
|
-- alone -- demotion is the scan's judgement, not this statement's.
|
||||||
-- 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
|
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ?
|
SET tag_status = 'user_confirmed'
|
||||||
WHERE id = ?;
|
WHERE id = ? AND tag_status = 'untagged';
|
||||||
|
|
||||||
-- name: UpdateAudioFileStat :exec
|
-- name: UpdateAudioFileStat :exec
|
||||||
-- Records the on-disk mtime/size without re-reading tags. Used to
|
-- Records the on-disk mtime/size without re-reading tags. Used to
|
||||||
@@ -43,307 +64,146 @@ UPDATE audio_files
|
|||||||
SET modified_at = ?, file_size = ?
|
SET modified_at = ?, file_size = ?
|
||||||
WHERE id = ?;
|
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
|
-- name: GetLibraryMaxModifiedAt :one
|
||||||
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
|
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
|
||||||
-- the library is empty or no row has a baseline yet.
|
-- the library is empty or no row has a baseline yet.
|
||||||
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
||||||
WHERE library_id = ?;
|
WHERE library_id = ?;
|
||||||
|
|
||||||
-- name: DeleteAudioFile :exec
|
-- ---------------------------------------------------------------------
|
||||||
DELETE FROM audio_files
|
-- Reads: tracks
|
||||||
WHERE id = ?;
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
-- name: CountAudioFiles :one
|
-- name: GetTracks :many
|
||||||
SELECT count(*) FROM audio_files;
|
SELECT * FROM track_metadata
|
||||||
|
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
|
||||||
|
|
||||||
-- name: GetRandomAudioFilePath :one
|
-- name: GetTrackByPath :one
|
||||||
SELECT file_path FROM audio_files
|
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
|
||||||
ORDER BY RANDOM()
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetAllAudioFiles :many
|
-- name: GetTracksByAlbum :many
|
||||||
SELECT * FROM audio_files;
|
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
|
-- name: GetTracksByGenre :many
|
||||||
SELECT id, file_path FROM audio_files;
|
SELECT tm.* FROM track_metadata tm
|
||||||
|
JOIN file_genres fg ON fg.audio_file_id = tm.id
|
||||||
-- name: GetAudioFilesNeedingMetadata :many
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
SELECT * FROM audio_files
|
WHERE g.name = sqlc.arg(genre)
|
||||||
WHERE recording_id = 0;
|
AND tm.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), tm.library_id);
|
||||||
|
|
||||||
-- 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: LookupTrackMetaByPaths :many
|
-- 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
|
FROM track_metadata
|
||||||
WHERE file_path IN (sqlc.slice('paths'));
|
WHERE file_path IN (sqlc.slice('paths'));
|
||||||
|
|
||||||
-- name: GetAudioFilesByLibrary :many
|
-- name: SearchTracksByBasename :many
|
||||||
SELECT * FROM audio_files WHERE library_id = ?;
|
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
|
-- name: GetFilePathsByAlbums :many
|
||||||
DELETE FROM audio_files;
|
-- 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
|
||||||
-- name: GetAllTracksWithFullMetadataByLibrary :many
|
-- two together bind the wrong values - GetFilePathsByAlbums([1,2], 0)
|
||||||
SELECT
|
-- read album id 2 as the library id. Returning library_id and
|
||||||
af.file_path,
|
-- filtering the (small) result is the version that cannot be wrong.
|
||||||
af.length_milliseconds,
|
SELECT album_id, library_id, file_path FROM audio_files
|
||||||
COALESCE(r.name, '') AS title,
|
WHERE album_id IN (sqlc.slice('album_ids'))
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
ORDER BY disc_number, track_number;
|
||||||
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: GetFilePathsByRecordingMBIDs :many
|
-- name: GetFilePathsByRecordingMBIDs :many
|
||||||
SELECT r.mbid AS recording_mbid, af.file_path
|
-- The ownership question in its only honest form: which of these
|
||||||
FROM recordings r
|
-- catalog recordings has a *file* behind it. Asked of audio_files, so
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
-- a metadata row with no file cannot answer yes.
|
||||||
WHERE r.mbid IN (sqlc.slice('mbids'))
|
SELECT recording_mbid, library_id, file_path FROM audio_files
|
||||||
ORDER BY af.file_path;
|
WHERE recording_mbid IN (sqlc.slice('mbids'))
|
||||||
|
ORDER BY file_path;
|
||||||
|
|
||||||
-- name: GetFilePathsByRecordingMBIDsByLibrary :many
|
-- name: GetFilePathsByGenres :many
|
||||||
SELECT r.mbid AS recording_mbid, af.file_path
|
SELECT g.name AS genre, af.library_id, af.file_path
|
||||||
FROM recordings r
|
FROM audio_files af
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
WHERE r.mbid IN (sqlc.slice('mbids'))
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
AND af.library_id = ?
|
WHERE g.name IN (sqlc.slice('genres'))
|
||||||
ORDER BY af.file_path;
|
ORDER BY af.disc_number, af.track_number;
|
||||||
|
|
||||||
-- name: GetAudioFilesByPaths :many
|
-- name: GetFilePathsByArtistMBID :many
|
||||||
SELECT id, library_id, file_path, group_key FROM audio_files
|
SELECT DISTINCT af.file_path
|
||||||
WHERE file_path IN (sqlc.slice('paths'));
|
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
|
-- name: UpsertGenre :one
|
||||||
INSERT INTO genres (name) VALUES (?)
|
INSERT INTO genres (name) VALUES (?)
|
||||||
ON CONFLICT(name) DO UPDATE SET name = name
|
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: CreateRecordingGenre :exec
|
-- name: LinkFileGenre :exec
|
||||||
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
|
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?);
|
||||||
VALUES (?, ?);
|
|
||||||
|
|
||||||
-- name: DeleteRecordingGenres :exec
|
-- name: DeleteFileGenres :exec
|
||||||
DELETE FROM recording_genres
|
DELETE FROM file_genres WHERE audio_file_id = ?;
|
||||||
WHERE recording_id = ?;
|
|
||||||
|
|
||||||
-- name: GetGenresByRecordingID :many
|
-- name: GetGenreNamesByFile :many
|
||||||
SELECT g.*
|
SELECT g.name FROM genres g
|
||||||
FROM genres g
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
WHERE fg.audio_file_id = ?;
|
||||||
WHERE rg.recording_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllRecordingGenres :exec
|
-- name: GetGenreNamesByFilePaths :many
|
||||||
DELETE FROM recording_genres;
|
-- Genres for many files at once. The mix builder asked this one file
|
||||||
|
-- at a time, inside two nested loops -- twelve thousand single-row
|
||||||
-- name: DeleteAllGenres :exec
|
-- queries to assemble one mix.
|
||||||
DELETE FROM genres;
|
SELECT af.file_path, g.name
|
||||||
|
FROM audio_files af
|
||||||
-- name: GetTracksByGenre :many
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
SELECT
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
af.file_path,
|
WHERE af.file_path IN (sqlc.slice('paths'));
|
||||||
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: DeleteGenre :exec
|
-- name: DeleteGenre :exec
|
||||||
DELETE FROM genres WHERE id = ?;
|
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
|
-- 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
|
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
|
GROUP BY g.id, g.name
|
||||||
ORDER BY 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
|
-- Every one of these returns album ids and nothing else. The display
|
||||||
-- columns (cover art, artist credit, year) already have exactly one
|
-- 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
|
-- 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.
|
-- service joins the ids back to that one album list in Go.
|
||||||
|
|
||||||
-- name: HomeRecentlyPlayedAlbums :many
|
-- name: HomeRecentlyPlayedAlbums :many
|
||||||
-- Albums with the most recent play, newest first.
|
-- Albums with the most recent play, newest first.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.last_played) DESC
|
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
|
-- stands in for one: it is monotonic and assigned at import, which is
|
||||||
-- the same ordering an added_at column would give.
|
-- the same ordering an added_at column would give.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.id) DESC
|
ORDER BY MAX(af.id) DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
@@ -32,9 +30,8 @@ LIMIT ?;
|
|||||||
-- name: HomeMostPlayedAlbums :many
|
-- name: HomeMostPlayedAlbums :many
|
||||||
-- Albums by total plays across their tracks.
|
-- Albums by total plays across their tracks.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) > 0
|
HAVING SUM(af.play_count) > 0
|
||||||
ORDER BY SUM(af.play_count) DESC
|
ORDER BY SUM(af.play_count) DESC
|
||||||
@@ -45,9 +42,8 @@ LIMIT ?;
|
|||||||
-- shelf is a different suggestion each time rather than the same
|
-- shelf is a different suggestion each time rather than the same
|
||||||
-- alphabetical head of the list forever.
|
-- alphabetical head of the list forever.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) = 0
|
HAVING SUM(af.play_count) = 0
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
@@ -56,9 +52,8 @@ LIMIT ?;
|
|||||||
-- name: HomeStaleAlbums :many
|
-- name: HomeStaleAlbums :many
|
||||||
-- Played before, but not for a long while.
|
-- Played before, but not for a long while.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING MAX(af.last_played) < datetime('now', ?)
|
HAVING MAX(af.last_played) < datetime('now', ?)
|
||||||
@@ -67,9 +62,8 @@ LIMIT ?;
|
|||||||
|
|
||||||
-- name: HomeRandomAlbums :many
|
-- name: HomeRandomAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
@@ -78,10 +72,10 @@ LIMIT ?;
|
|||||||
-- A random sample of albums carrying a genre, so the same genre shelf
|
-- A random sample of albums carrying a genre, so the same genre shelf
|
||||||
-- is not the same ten albums every time the page opens.
|
-- is not the same ten albums every time the page opens.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
JOIN genres g ON g.id = rgen.genre_id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
WHERE g.name = ?
|
WHERE g.name = ?
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
@@ -93,10 +87,10 @@ LIMIT ?;
|
|||||||
-- album carries is a shelf about that one album.
|
-- album carries is a shelf about that one album.
|
||||||
SELECT
|
SELECT
|
||||||
g.name AS genre,
|
g.name AS genre,
|
||||||
COUNT(DISTINCT rgr.release_group_id) AS album_count
|
COUNT(DISTINCT af.album_id) AS album_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rgen ON rgen.genre_id = g.id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
GROUP BY g.id
|
GROUP BY g.id
|
||||||
HAVING album_count >= 3
|
HAVING album_count >= 3
|
||||||
ORDER BY album_count DESC
|
ORDER BY album_count DESC
|
||||||
@@ -106,14 +100,12 @@ LIMIT ?;
|
|||||||
-- Artists by total plays, as the album-artist credit text the album
|
-- Artists by total plays, as the album-artist credit text the album
|
||||||
-- list already displays.
|
-- list already displays.
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
rg.artist_credit AS artist_name,
|
||||||
SUM(af.play_count) AS plays
|
SUM(af.play_count) AS plays
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
WHERE rg.artist_credit <> ''
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
GROUP BY rg.artist_credit
|
||||||
WHERE ac.text <> ''
|
|
||||||
GROUP BY ac.text
|
|
||||||
HAVING plays > 0
|
HAVING plays > 0
|
||||||
ORDER BY plays DESC
|
ORDER BY plays DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|||||||
@@ -47,29 +47,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
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,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_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
|
|
||||||
WHERE pt.playlist_id = ?
|
WHERE pt.playlist_id = ?
|
||||||
ORDER BY pt.position;
|
ORDER BY pt.position;
|
||||||
|
|
||||||
@@ -79,29 +68,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
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,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_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
|
|
||||||
ORDER BY pt.playlist_id, pt.position;
|
ORDER BY pt.playlist_id, pt.position;
|
||||||
|
|
||||||
-- name: DeleteAllPlaylistTracks :exec
|
-- name: DeleteAllPlaylistTracks :exec
|
||||||
@@ -132,27 +110,8 @@ WHERE playlist_id = ? AND audio_file_id = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: GetTrackPhantomMetadata :one
|
-- name: GetTrackPhantomMetadata :one
|
||||||
SELECT
|
-- The display fields a playlist row keeps after its file goes away.
|
||||||
COALESCE(r.name, '') AS title,
|
SELECT title, artist_name AS artist, album,
|
||||||
COALESCE(ac.text, '') AS artist,
|
length_milliseconds AS duration_ms, genre, cover_art_path
|
||||||
COALESCE(rg.name, '') AS album,
|
FROM track_metadata
|
||||||
af.length_milliseconds AS duration_ms,
|
WHERE id = ?;
|
||||||
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 = ?;
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
-- name: GetQueueState :one
|
-- name: GetQueueState :one
|
||||||
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
|
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
|
||||||
FROM queue WHERE id = 1;
|
FROM queue WHERE id = 1;
|
||||||
|
|
||||||
-- name: UpdateQueueState :exec
|
-- name: UpdateQueueState :exec
|
||||||
UPDATE queue
|
UPDATE queue
|
||||||
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
|
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
|
||||||
WHERE id = 1;
|
WHERE id = 1;
|
||||||
|
|
||||||
-- name: UpdateQueuePosition :exec
|
-- name: UpdateQueuePosition :exec
|
||||||
@@ -13,27 +13,12 @@ SET current_position = ?
|
|||||||
WHERE id = 1;
|
WHERE id = 1;
|
||||||
|
|
||||||
-- name: GetQueueTracks :many
|
-- name: GetQueueTracks :many
|
||||||
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
|
-- The queue's rows, joined to the one track projection.
|
||||||
COALESCE(r.name, '') AS title,
|
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
|
||||||
COALESCE(ac.text, '') AS artist,
|
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
|
||||||
COALESCE(rg.name, '') AS album,
|
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
|
||||||
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 queue_tracks qt
|
FROM queue_tracks qt
|
||||||
JOIN audio_files af ON qt.audio_file_id = af.id
|
JOIN track_metadata tm ON tm.id = qt.audio_file_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
|
|
||||||
ORDER BY qt.position;
|
ORDER BY qt.position;
|
||||||
|
|
||||||
-- name: GetQueueTrackCount :one
|
-- name: GetQueueTrackCount :one
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
-- name: CreateRecording :one
|
|
||||||
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: CreateRecordingFull :one
|
|
||||||
INSERT INTO recordings (
|
|
||||||
name, artist_credit_id, track_number, disc_number,
|
|
||||||
year, genre, composer, lyrics, comment
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetRecording :one
|
|
||||||
SELECT * FROM recordings
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpdateRecording :exec
|
|
||||||
UPDATE recordings
|
|
||||||
SET name = ?, artist_credit_id = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateRecordingFull :exec
|
|
||||||
UPDATE recordings
|
|
||||||
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
|
|
||||||
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteRecording :exec
|
|
||||||
DELETE FROM recordings
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllRecordings :exec
|
|
||||||
DELETE FROM recordings;
|
|
||||||
|
|
||||||
-- name: GetAllRecordings :many
|
|
||||||
SELECT * FROM recordings
|
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: CountRecordingsByArtistCredit :one
|
|
||||||
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?;
|
|
||||||
|
|
||||||
-- name: GetOrphanedRecordingIDs :many
|
|
||||||
-- Recordings no longer backed by any audio_files row - left behind
|
|
||||||
-- when a scan's orphan cleanup deletes the file that used to own them,
|
|
||||||
-- since deleting audio_files doesn't cascade to recordings.
|
|
||||||
SELECT r.id FROM recordings r
|
|
||||||
LEFT JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE af.id IS NULL;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
-- name: CreateReleaseGroupRecording :one
|
|
||||||
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroupRecording :one
|
|
||||||
SELECT * FROM release_group_recordings
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroupRecordings :many
|
|
||||||
SELECT * FROM release_group_recordings
|
|
||||||
WHERE release_group_id = ?
|
|
||||||
ORDER BY disc_number, track_number;
|
|
||||||
|
|
||||||
-- name: GetRecordingReleaseGroups :many
|
|
||||||
SELECT * FROM release_group_recordings
|
|
||||||
WHERE recording_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecording :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecordingByFK :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE release_group_id = ? AND recording_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllReleaseGroupRecordings :exec
|
|
||||||
DELETE FROM release_group_recordings;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecordingsByRecording :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE recording_id = ?;
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
-- name: CreateReleaseGroup :one
|
|
||||||
INSERT INTO release_groups (name) VALUES (?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: CreateReleaseGroupFull :one
|
|
||||||
INSERT INTO release_groups (
|
|
||||||
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroup :one
|
|
||||||
SELECT * FROM release_groups
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroupByNameAndArtist :one
|
|
||||||
SELECT * FROM release_groups
|
|
||||||
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpsertReleaseGroup :one
|
|
||||||
INSERT INTO release_groups (name, album_artist_credit_id, year)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
|
|
||||||
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
|
|
||||||
year = COALESCE(excluded.year, release_groups.year)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: SetReleaseGroupOriginalYear :exec
|
|
||||||
-- Set the release group's original-release-year (release-group's
|
|
||||||
-- first-release-date from MusicBrainz). Called from autotag apply
|
|
||||||
-- when the user confirms a candidate; the file-tag year stays in
|
|
||||||
-- the year column.
|
|
||||||
UPDATE release_groups SET original_year = ? WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateReleaseGroup :exec
|
|
||||||
UPDATE release_groups
|
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateReleaseGroupCoverArt :exec
|
|
||||||
UPDATE release_groups
|
|
||||||
SET cover_art_id = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroup :exec
|
|
||||||
DELETE FROM release_groups
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllReleaseGroups :exec
|
|
||||||
DELETE FROM release_groups;
|
|
||||||
|
|
||||||
-- name: GetAllReleaseGroups :many
|
|
||||||
SELECT * FROM release_groups
|
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: GetAllAlbumsWithDetails :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
-- year prefers original release year (MB first-release-date)
|
|
||||||
-- over the file-tag year so the UI surfaces the album's
|
|
||||||
-- original year by default. release_year keeps the file-tag
|
|
||||||
-- year accessible.
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
rg.mbid,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
ORDER BY rg.name;
|
|
||||||
|
|
||||||
-- name: GetAllAlbumsWithDetailsByLibrary :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
-- year prefers original release year (MB first-release-date)
|
|
||||||
-- over the file-tag year so the UI surfaces the album's
|
|
||||||
-- original year by default. release_year keeps the file-tag
|
|
||||||
-- year accessible.
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
rg.mbid,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
WHERE rg.id IN (
|
|
||||||
SELECT DISTINCT rgr2.release_group_id
|
|
||||||
FROM release_group_recordings rgr2
|
|
||||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
|
||||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
|
||||||
WHERE af2.library_id = ?
|
|
||||||
)
|
|
||||||
ORDER BY rg.name;
|
|
||||||
|
|
||||||
-- name: GetAlbumsByArtist :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
WHERE aca.artist_id = ?
|
|
||||||
ORDER BY rg.name;
|
|
||||||
|
|
||||||
-- name: CountReleaseGroupRecordings :one
|
|
||||||
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
|
|
||||||
|
|
||||||
-- name: GetOrphanedReleaseGroupIDs :many
|
|
||||||
-- Release groups with no recordings left in them - run after orphaned
|
|
||||||
-- recordings (and their release_group_recordings rows) are deleted, so
|
|
||||||
-- a release group whose last owned track was removed is cleaned up too.
|
|
||||||
SELECT rg.id FROM release_groups rg
|
|
||||||
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
|
||||||
WHERE rgr.id IS NULL;
|
|
||||||
|
|
||||||
-- name: GetAlbumsByArtistByLibrary :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
WHERE aca.artist_id = ?
|
|
||||||
AND rg.id IN (
|
|
||||||
SELECT DISTINCT rgr2.release_group_id
|
|
||||||
FROM release_group_recordings rgr2
|
|
||||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
|
||||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
|
||||||
WHERE af2.library_id = ?
|
|
||||||
)
|
|
||||||
ORDER BY rg.name;
|
|
||||||
@@ -10,7 +10,27 @@ ON CONFLICT(group_key) DO UPDATE SET
|
|||||||
WHEN tagging_items.album_name = '' THEN excluded.album_name
|
WHEN tagging_items.album_name = '' THEN excluded.album_name
|
||||||
ELSE tagging_items.album_name
|
ELSE tagging_items.album_name
|
||||||
END,
|
END,
|
||||||
|
-- Tracks real consensus, not first-write-wins: stays set only
|
||||||
|
-- while every track that has contributed a non-empty value agrees.
|
||||||
|
-- A later track with a *different* non-empty value clears it back
|
||||||
|
-- to '' and latches album_artist_conflict, since a single
|
||||||
|
-- disagreeing tag means the folder no longer has one authoritative
|
||||||
|
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
|
||||||
|
-- value here as trusted, so leaving a stale first-seen value in
|
||||||
|
-- place would let one track's tag silently blind mixed-bag
|
||||||
|
-- detection for the whole folder. The latch (rather than just
|
||||||
|
-- clearing the text column) stops a later track from coincidentally
|
||||||
|
-- repeating an already-disputed value and resurrecting trust in it.
|
||||||
|
album_artist_conflict = CASE
|
||||||
|
WHEN tagging_items.album_artist_conflict = 1 THEN 1
|
||||||
|
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
|
||||||
|
AND tagging_items.album_artist != excluded.album_artist THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
album_artist = CASE
|
album_artist = CASE
|
||||||
|
WHEN tagging_items.album_artist_conflict = 1 THEN ''
|
||||||
|
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
|
||||||
|
AND tagging_items.album_artist != excluded.album_artist THEN ''
|
||||||
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
|
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
|
||||||
ELSE tagging_items.album_artist
|
ELSE tagging_items.album_artist
|
||||||
END;
|
END;
|
||||||
@@ -67,10 +87,7 @@ LIMIT 1;
|
|||||||
SELECT ti.group_key
|
SELECT ti.group_key
|
||||||
FROM tagging_items ti
|
FROM tagging_items ti
|
||||||
JOIN audio_files af ON af.group_key = ti.group_key
|
JOIN audio_files af ON af.group_key = ti.group_key
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums rg ON rg.id = af.album_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
|
|
||||||
WHERE ti.synthetic = 0
|
WHERE ti.synthetic = 0
|
||||||
AND ti.track_count >= 4
|
AND ti.track_count >= 4
|
||||||
AND (
|
AND (
|
||||||
@@ -78,13 +95,23 @@ WHERE ti.synthetic = 0
|
|||||||
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
||||||
)
|
)
|
||||||
GROUP BY ti.group_key
|
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;
|
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
|
||||||
|
|
||||||
-- name: CountPendingTaggingItems :one
|
-- name: CountPendingTaggingItems :one
|
||||||
SELECT COUNT(*) FROM tagging_items
|
-- "Needs tagging" is a question about the files, not about the row:
|
||||||
WHERE status = 'pending'
|
-- every scanned folder gets a tagging_items row (see
|
||||||
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
|
-- 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
|
-- name: ListPendingTaggingItemsAlphabetical :many
|
||||||
SELECT
|
SELECT
|
||||||
@@ -105,6 +132,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
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 (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
||||||
AND ti.cleared_at IS NULL
|
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
|
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
||||||
LIMIT @row_limit OFFSET @row_offset;
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
@@ -130,6 +169,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
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 (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
||||||
AND ti.cleared_at IS NULL
|
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)
|
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
||||||
LIMIT @row_limit OFFSET @row_offset;
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
@@ -184,61 +231,53 @@ ORDER BY ti.created_at DESC, ti.group_key
|
|||||||
LIMIT @row_limit OFFSET @row_offset;
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
-- name: ListAudioFilesInTaggingGroup :many
|
-- name: ListAudioFilesInTaggingGroup :many
|
||||||
-- album_name/album_artist are the PER-TRACK tags (via each track's
|
-- album_name/album_artist are the PER-TRACK tags (each file's own
|
||||||
-- own release_group link), not the folder-level tagging_items
|
-- album link), not the folder-level tagging_items values.
|
||||||
-- values. SplitMixedFolder clusters on these to find sub-albums
|
-- SplitMixedFolder clusters on these to find sub-albums hiding inside
|
||||||
-- hiding inside a folder full of unrelated tracks.
|
-- a folder full of unrelated tracks.
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
af.file_path,
|
af.file_path,
|
||||||
af.basename,
|
af.basename,
|
||||||
af.length_milliseconds,
|
af.length_milliseconds,
|
||||||
af.tag_status,
|
af.tag_status,
|
||||||
COALESCE(r.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(r.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS title,
|
af.title,
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
af.artist_credit AS artist_name,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(rg.name, '') AS album_name,
|
COALESCE(al.name, '') AS album_name,
|
||||||
COALESCE(rgac.text, '') AS album_artist
|
COALESCE(al.artist_credit, '') AS album_artist
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums al ON al.id = af.album_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
|
|
||||||
WHERE af.group_key = ?
|
WHERE af.group_key = ?
|
||||||
ORDER BY COALESCE(r.disc_number, 0),
|
ORDER BY COALESCE(af.disc_number, 0),
|
||||||
COALESCE(r.track_number, 0),
|
COALESCE(af.track_number, 0),
|
||||||
af.file_path;
|
af.file_path;
|
||||||
|
|
||||||
-- name: ListLocalReleaseGroupCandidates :many
|
-- name: ListLocalAlbumCandidates :many
|
||||||
-- Returns one row per (release_group, track) combination for any
|
-- One row per (album, track) for any local album carrying an MBID.
|
||||||
-- local release_group that has an MBID. Callers group these in Go
|
-- Callers group these in Go and filter by normalized album-name match;
|
||||||
-- and filter by normalized album-name match. Joined case-insensitive
|
-- the join is case-insensitive on name to pre-filter cheaply.
|
||||||
-- on name to pre-filter cheaply; Go does the real normalization.
|
|
||||||
SELECT
|
SELECT
|
||||||
rg.id AS release_group_id,
|
al.id AS album_id,
|
||||||
rg.mbid AS release_group_mbid,
|
al.mbid AS album_mbid,
|
||||||
rg.name AS album_name,
|
al.name AS album_name,
|
||||||
COALESCE(rg.year, 0) AS year,
|
COALESCE(al.year, 0) AS year,
|
||||||
COALESCE(ac.text, '') AS artist_credit,
|
al.artist_credit,
|
||||||
COALESCE(rgr.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(rgr.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS track_title,
|
af.title AS track_title,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
|
af.length_milliseconds
|
||||||
FROM release_groups rg
|
FROM albums al
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = al.id
|
||||||
JOIN recordings r ON r.id = rgr.recording_id
|
WHERE al.mbid IS NOT NULL
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
AND al.mbid != ''
|
||||||
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
|
AND af.recording_mbid IS NOT NULL
|
||||||
WHERE rg.mbid IS NOT NULL
|
AND af.recording_mbid != ''
|
||||||
AND rg.mbid != ''
|
AND al.name = ? COLLATE NOCASE
|
||||||
AND r.mbid IS NOT NULL
|
ORDER BY al.id, af.disc_number, af.track_number;
|
||||||
AND r.mbid != ''
|
|
||||||
AND rg.name = ? COLLATE NOCASE
|
|
||||||
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
|
|
||||||
|
|
||||||
-- name: SetTaggingItemBestMatch :exec
|
-- name: SetTaggingItemBestMatch :exec
|
||||||
UPDATE tagging_items
|
UPDATE tagging_items
|
||||||
@@ -264,17 +303,16 @@ WHERE group_key = ?;
|
|||||||
-- name: SetAudioFileTagStatus :exec
|
-- name: SetAudioFileTagStatus :exec
|
||||||
UPDATE audio_files SET tag_status = ? WHERE id = ?;
|
UPDATE audio_files SET tag_status = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetRecordingMBID :exec
|
-- name: SetFileRecordingMBID :exec
|
||||||
UPDATE recordings SET mbid = ? WHERE id = ?;
|
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetReleaseGroupMBID :exec
|
-- name: SetFileAlbumMBID :exec
|
||||||
UPDATE release_groups SET mbid = ? WHERE id = ?;
|
-- 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
|
||||||
-- name: GetRecordingReleaseGroupID :one
|
-- schema it had to look the release group up through two join tables
|
||||||
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
|
-- first (GetRecordingReleaseGroupID), which is gone.
|
||||||
FROM release_group_recordings rgr
|
UPDATE albums SET mbid = ?
|
||||||
WHERE rgr.recording_id = ?
|
WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?);
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetNextPendingTaggingItem :one
|
-- name: GetNextPendingTaggingItem :one
|
||||||
SELECT
|
SELECT
|
||||||
@@ -295,5 +333,12 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE ti.status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||||
AND ti.group_key > @after_group_key
|
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
|
ORDER BY ti.group_key
|
||||||
LIMIT 1;
|
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,31 @@
|
|||||||
|
-- Per-artist record of which catalog enrichment passes have completed,
|
||||||
|
-- so the owned-artist backfill knows what is left to do.
|
||||||
|
--
|
||||||
|
-- It is a table rather than more flag columns on `explore_index` for one
|
||||||
|
-- reason: the downloaded catalog artifact is merged into that table by
|
||||||
|
-- column list (see artifactimport.go), so a flag added there is a second
|
||||||
|
-- place to remember, and forgetting it silently wipes every mark on the
|
||||||
|
-- next catalog update. These marks are about *this install's* fetching,
|
||||||
|
-- which the artifact knows nothing about.
|
||||||
|
--
|
||||||
|
-- Each column is a separate fetch with its own failure mode, which is
|
||||||
|
-- why they are not one boolean: an MB browse failing must not claim the
|
||||||
|
-- similar-artists fetch, or vice versa. NULL means "not done" — the
|
||||||
|
-- timestamp is for debugging and for any future re-fetch policy, not
|
||||||
|
-- for expiry. Nothing expires these today.
|
||||||
|
--
|
||||||
|
-- `explore_index.discog_fetched` is deliberately NOT duplicated here: it
|
||||||
|
-- means "this artist's top release groups and recordings are present",
|
||||||
|
-- which the artifact legitimately answers for artists it covers.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS artist_enrichment (
|
||||||
|
artist_mbid TEXT PRIMARY KEY,
|
||||||
|
|
||||||
|
-- The full MusicBrainz browse-by-artist landed: every release group,
|
||||||
|
-- with primary and secondary types. ListenBrainz's top-release-groups
|
||||||
|
-- endpoint gives neither the tail nor the types.
|
||||||
|
browsed_at DATETIME,
|
||||||
|
|
||||||
|
-- similar_artist_map has been filled for this artist.
|
||||||
|
similar_at DATETIME
|
||||||
|
);
|
||||||
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images (
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
|
-- No index on artist_mbid alone: the UNIQUE index below has it as its
|
||||||
ON artist_images(artist_mbid);
|
-- leftmost column.
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
|
||||||
ON artist_images(artist_mbid, source, source_url);
|
ON artist_images(artist_mbid, source, source_url);
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
|
|||||||
PRIMARY KEY (mbid, source)
|
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 (
|
CREATE TABLE IF NOT EXISTS audio_files (
|
||||||
id integer PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
file_path text NOT NULL UNIQUE,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
length_milliseconds int NOT NULL,
|
library_id INTEGER NOT NULL DEFAULT 0,
|
||||||
file_type_id int NOT NULL,
|
file_type_id INTEGER NOT NULL,
|
||||||
recording_id int NOT NULL,
|
|
||||||
sample_rate int NOT NULL DEFAULT 0,
|
-- Audio properties, read from the file itself.
|
||||||
bit_depth int NOT NULL DEFAULT 0,
|
length_milliseconds INTEGER NOT NULL,
|
||||||
channels int NOT NULL DEFAULT 0,
|
sample_rate INTEGER NOT NULL DEFAULT 0,
|
||||||
bitrate int NOT NULL DEFAULT 0,
|
bit_depth INTEGER NOT NULL DEFAULT 0,
|
||||||
file_size int NOT NULL DEFAULT 0,
|
channels INTEGER NOT NULL DEFAULT 0,
|
||||||
basename text NOT NULL DEFAULT '',
|
bitrate INTEGER NOT NULL DEFAULT 0,
|
||||||
library_id int NOT NULL DEFAULT 0,
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
play_count int NOT NULL DEFAULT 0,
|
|
||||||
last_played datetime,
|
-- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is
|
||||||
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
-- 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 (
|
CHECK(tag_status IN (
|
||||||
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
'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(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
|
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
|
||||||
ON 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 != '';
|
ON audio_files(group_key) WHERE group_key != '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
|
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
|
-- The ownership question, asked by MBID: "is there a *file* with this
|
||||||
ON audio_files(recording_id);
|
-- 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
|
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
|
||||||
ON audio_files(library_id) WHERE 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 (
|
CREATE TABLE IF NOT EXISTS explore_index (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
entity_type TEXT NOT NULL,
|
entity_type INTEGER NOT NULL,
|
||||||
mbid TEXT NOT NULL,
|
mbid BLOB NOT NULL CHECK(length(mbid) = 16),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
artist_name 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 '',
|
aliases TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
-- Popularity signals, derived from the ListenBrainz listens dump.
|
-- Popularity signals, derived from the ListenBrainz listens dump.
|
||||||
@@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
|||||||
|
|
||||||
-- Recording-specific fields.
|
-- Recording-specific fields.
|
||||||
duration INTEGER NOT NULL DEFAULT 0,
|
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_name TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
-- Release-group-specific fields.
|
-- Release-group-specific fields.
|
||||||
@@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
|||||||
secondary_types TEXT NOT NULL DEFAULT '',
|
secondary_types TEXT NOT NULL DEFAULT '',
|
||||||
release_date 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-specific fields.
|
||||||
artist_type TEXT NOT NULL DEFAULT '',
|
artist_type TEXT NOT NULL DEFAULT '',
|
||||||
country TEXT NOT NULL DEFAULT '',
|
country TEXT NOT NULL DEFAULT '',
|
||||||
@@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
|||||||
UNIQUE(mbid)
|
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
|
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
|
||||||
ON explore_index(LOWER(artist_name))
|
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
|
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
|
||||||
ON explore_index(caa_release_mbid)
|
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
|
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
|
||||||
ON explore_index(artist_mbid, entity_type, popularity DESC);
|
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
|
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
|
||||||
ON explore_index(LOWER(title))
|
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
|
-- One row per folder the user has added as a music library.
|
||||||
-- the files are in the library or the attempt is abandoned.
|
|
||||||
--
|
--
|
||||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
-- Everything else keyed by library_id means "which of these folders did
|
||||||
-- carries one can be matched against a known tracklist at import time,
|
-- this come from"; a library_id of 0 in a query means "all of them".
|
||||||
-- which is what makes unattended completion safe. Free-text requests
|
|
||||||
-- (both NULL) are always presented to the user for confirmation.
|
|
||||||
--
|
--
|
||||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
-- autotag_warning_acked records that the user has been told what
|
||||||
-- import do not have to re-resolve it, and so a request survives the
|
-- autotagging will do to the files in this folder, which is a decision
|
||||||
-- explore index being rebuilt underneath it.
|
-- they made and not something a rescan can rediscover.
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS libraries (
|
CREATE TABLE IF NOT EXISTS libraries (
|
||||||
id INTEGER PRIMARY KEY,
|
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;
|
||||||
@@ -5,6 +5,13 @@ CREATE TABLE IF NOT EXISTS queue (
|
|||||||
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
|
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
|
||||||
repeat_mode TEXT NOT NULL DEFAULT 'off',
|
repeat_mode TEXT NOT NULL DEFAULT 'off',
|
||||||
shuffle_order TEXT,
|
shuffle_order TEXT,
|
||||||
|
-- source_playlist_id above is unused dead weight (nothing has ever
|
||||||
|
-- written it a nonzero value); source_type/source_id/source_label
|
||||||
|
-- below are its generalized replacement, covering albums, playlists,
|
||||||
|
-- smart playlists, genres and artists rather than playlists alone.
|
||||||
|
source_type TEXT NOT NULL DEFAULT '',
|
||||||
|
source_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source_label TEXT NOT NULL DEFAULT '',
|
||||||
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET 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,15 +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,
|
|
||||||
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,
|
|
||||||
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 (
|
CREATE TABLE IF NOT EXISTS release_to_rg (
|
||||||
release_mbid TEXT PRIMARY KEY,
|
release_mbid TEXT PRIMARY KEY,
|
||||||
rg_mbid TEXT NOT NULL
|
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)
|
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
|
-- No index on source_artist_mbid alone: the PRIMARY KEY has it as its
|
||||||
ON similar_artist_map(source_artist_mbid);
|
-- leftmost column.
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ CREATE TABLE IF NOT EXISTS tagging_items (
|
|||||||
-- first time; append-only from the second migration on.
|
-- first time; append-only from the second migration on.
|
||||||
synthetic INTEGER NOT NULL DEFAULT 0,
|
synthetic INTEGER NOT NULL DEFAULT 0,
|
||||||
parent_group_key TEXT NOT NULL DEFAULT '',
|
parent_group_key TEXT NOT NULL DEFAULT '',
|
||||||
|
-- album_artist_conflict latches to 1 the first time two tracks
|
||||||
|
-- added to this group carry different non-empty album_artist tags,
|
||||||
|
-- and never resets. Without it, UpsertTaggingItemOnTrackAdd's
|
||||||
|
-- consensus tracking on album_artist can't tell "no non-empty
|
||||||
|
-- value contributed yet" apart from "conflicting values were
|
||||||
|
-- observed and it was cleared" — both look like '' — so a later
|
||||||
|
-- track that happens to repeat an earlier, already-disputed value
|
||||||
|
-- would wrongly resurrect trust in it. See IsMixedBag
|
||||||
|
-- (backend/autotag/mixedbag.go), which trusts a non-empty
|
||||||
|
-- album_artist unconditionally.
|
||||||
|
album_artist_conflict INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id)
|
FOREIGN KEY(library_id) REFERENCES libraries(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
af.file_path,
|
af.file_path,
|
||||||
af.length_milliseconds,
|
af.length_milliseconds,
|
||||||
COALESCE(r.name, '') AS title,
|
af.title,
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
af.artist_credit AS artist_name,
|
||||||
r.track_number,
|
af.track_number,
|
||||||
r.disc_number,
|
af.disc_number,
|
||||||
COALESCE(rg.name, '') AS album,
|
COALESCE(al.name, '') AS album,
|
||||||
CAST(COALESCE(
|
CAST(COALESCE(
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
(SELECT GROUP_CONCAT(g.name, '||')
|
||||||
FROM recording_genres rg_sub
|
FROM file_genres fg
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
WHERE rg_sub.recording_id = r.id),
|
WHERE fg.audio_file_id = af.id),
|
||||||
''
|
''
|
||||||
) AS TEXT) AS genre,
|
) AS TEXT) AS genre,
|
||||||
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
|
COALESCE(al.original_year, al.year, af.year, 0) AS year,
|
||||||
COALESCE(rg.year, r.year, 0) AS release_year,
|
COALESCE(al.year, af.year, 0) AS release_year,
|
||||||
COALESCE(r.composer, '') AS composer,
|
af.composer,
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
COALESCE(ft.extension, '') AS file_type,
|
||||||
af.sample_rate,
|
af.sample_rate,
|
||||||
af.bit_depth,
|
af.bit_depth,
|
||||||
@@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS
|
|||||||
af.play_count,
|
af.play_count,
|
||||||
af.last_played,
|
af.last_played,
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
COALESCE(ar.mbid, '') AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(al.mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
|
af.album_id,
|
||||||
|
af.artist_id
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums al ON al.id = af.album_id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
LEFT JOIN artists ar ON ar.id = af.artist_id
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
LEFT JOIN file_types ft ON ft.id = af.file_type_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;
|
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"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
|
const deleteAllArtists = `-- name: DeleteAllArtists :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
`
|
`
|
||||||
@@ -31,8 +20,7 @@ func (q *Queries) DeleteAllArtists(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteArtist = `-- name: DeleteArtist :exec
|
const deleteArtist = `-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists WHERE id = ?
|
||||||
WHERE id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
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
|
const getAlbumArtists = `-- name: GetAlbumArtists :many
|
||||||
SELECT DISTINCT a.id, a.name, a.mbid
|
SELECT DISTINCT a.id, a.name, a.mbid
|
||||||
FROM artists a
|
FROM artists a
|
||||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
JOIN albums al ON al.artist_id = a.id
|
||||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
WHERE EXISTS (
|
||||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
SELECT 1 FROM audio_files af
|
||||||
ORDER BY a.name
|
WHERE af.album_id = al.id
|
||||||
`
|
AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
|
||||||
|
|
||||||
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 = ?
|
|
||||||
)
|
)
|
||||||
ORDER BY a.name
|
ORDER BY a.name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) ([]Artist, error) {
|
func (q *Queries) GetAlbumArtists(ctx context.Context, libraryID int64) ([]Artist, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getAlbumArtistsByLibrary, libraryID)
|
rows, err := q.db.QueryContext(ctx, getAlbumArtists, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -115,8 +64,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllArtists = `-- name: GetAllArtists :many
|
const getAllArtists = `-- name: GetAllArtists :many
|
||||||
SELECT id, name, mbid FROM artists
|
SELECT id, name, mbid FROM artists ORDER BY name
|
||||||
ORDER BY name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
|
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
|
const getArtist = `-- name: GetArtist :one
|
||||||
SELECT id, name, mbid FROM artists
|
SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
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
|
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
|
const getArtistByName = `-- name: GetArtistByName :one
|
||||||
SELECT id, name, mbid FROM artists
|
SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1
|
||||||
WHERE name = ? LIMIT 1
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
|
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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many
|
const getUnreferencedArtistIDs = `-- name: GetUnreferencedArtistIDs :many
|
||||||
SELECT a.id FROM artists a
|
SELECT id FROM artists a
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
|
||||||
SELECT 1 FROM artist_credit_artist aca WHERE aca.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
|
// Artists no file and no album points at any more.
|
||||||
// behind when a scan's orphan cleanup removes the audio_files that used
|
func (q *Queries) GetUnreferencedArtistIDs(ctx context.Context) ([]int64, error) {
|
||||||
// to justify them, since deleting an audio_files row doesn't cascade.
|
rows, err := q.db.QueryContext(ctx, getUnreferencedArtistIDs)
|
||||||
func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -199,30 +162,42 @@ func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateArtist = `-- name: UpdateArtist :exec
|
const setArtistMBID = `-- name: SetArtistMBID :exec
|
||||||
UPDATE artists
|
UPDATE artists SET mbid = ? WHERE id = ?
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateArtistParams struct {
|
type SetArtistMBIDParams struct {
|
||||||
Name string
|
Mbid sql.NullString
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) error {
|
func (q *Queries) SetArtistMBID(ctx context.Context, arg SetArtistMBIDParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
|
_, err := q.db.ExecContext(ctx, setArtistMBID, arg.Mbid, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertArtist = `-- name: UpsertArtist :one
|
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
|
RETURNING id, name, mbid
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) {
|
type UpsertArtistParams struct {
|
||||||
row := q.db.QueryRowContext(ctx, upsertArtist, name)
|
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
|
var i Artist
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
||||||
return i, err
|
return i, err
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,36 +7,9 @@ package sqlcgen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countGenreReferences = `-- name: CountGenreReferences :one
|
|
||||||
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, countGenreReferences, genreID)
|
|
||||||
var count int64
|
|
||||||
err := row.Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
|
|
||||||
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
|
|
||||||
VALUES (?, ?)
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateRecordingGenreParams struct {
|
|
||||||
RecordingID int64
|
|
||||||
GenreID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllGenres = `-- name: DeleteAllGenres :exec
|
const deleteAllGenres = `-- name: DeleteAllGenres :exec
|
||||||
DELETE FROM genres
|
DELETE FROM genres
|
||||||
`
|
`
|
||||||
@@ -46,12 +19,12 @@ func (q *Queries) DeleteAllGenres(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec
|
const deleteFileGenres = `-- name: DeleteFileGenres :exec
|
||||||
DELETE FROM recording_genres
|
DELETE FROM file_genres WHERE audio_file_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error {
|
func (q *Queries) DeleteFileGenres(ctx context.Context, audioFileID int64) error {
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllRecordingGenres)
|
_, err := q.db.ExecContext(ctx, deleteFileGenres, audioFileID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +37,12 @@ func (q *Queries) DeleteGenre(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec
|
|
||||||
DELETE FROM recording_genres
|
|
||||||
WHERE recording_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
|
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
|
||||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
SELECT g.name, COUNT(fg.audio_file_id) AS track_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
|
WHERE af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
|
||||||
GROUP BY g.id, g.name
|
GROUP BY g.id, g.name
|
||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
`
|
`
|
||||||
@@ -87,8 +52,8 @@ type GetAllGenresWithCountsRow struct {
|
|||||||
TrackCount int64
|
TrackCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) {
|
func (q *Queries) GetAllGenresWithCounts(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts)
|
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -110,35 +75,25 @@ func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWit
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAllGenresWithCountsByLibrary = `-- name: GetAllGenresWithCountsByLibrary :many
|
const getGenreNamesByFile = `-- name: GetGenreNamesByFile :many
|
||||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
SELECT g.name FROM genres g
|
||||||
FROM genres g
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
WHERE fg.audio_file_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
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetAllGenresWithCountsByLibraryRow struct {
|
func (q *Queries) GetGenreNamesByFile(ctx context.Context, audioFileID int64) ([]string, error) {
|
||||||
Name string
|
rows, err := q.db.QueryContext(ctx, getGenreNamesByFile, audioFileID)
|
||||||
TrackCount int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsByLibraryRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCountsByLibrary, libraryID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetAllGenresWithCountsByLibraryRow
|
var items []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetAllGenresWithCountsByLibraryRow
|
var name string
|
||||||
if err := rows.Scan(&i.Name, &i.TrackCount); err != nil {
|
if err := rows.Scan(&name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, name)
|
||||||
}
|
}
|
||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -149,45 +104,42 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many
|
const getGenreNamesByFilePaths = `-- name: GetGenreNamesByFilePaths :many
|
||||||
|
SELECT af.file_path, g.name
|
||||||
SELECT g.name AS genre_name, af.file_path
|
FROM audio_files af
|
||||||
FROM genres g
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
WHERE af.file_path IN (/*SLICE:paths*/?)
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE g.name IN (/*SLICE:genre_names*/?)
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetFilePathsByGenresRow struct {
|
type GetGenreNamesByFilePathsRow struct {
|
||||||
GenreName string
|
FilePath string
|
||||||
FilePath string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
// Genres for many files at once. The mix builder asked this one file
|
||||||
// one query instead of one per genre, and file paths instead of whole
|
// at a time, inside two nested loops -- twelve thousand single-row
|
||||||
// track rows, which was 6 MB over the IPC for five genres.
|
// queries to assemble one mix.
|
||||||
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) {
|
func (q *Queries) GetGenreNamesByFilePaths(ctx context.Context, paths []string) ([]GetGenreNamesByFilePathsRow, error) {
|
||||||
query := getFilePathsByGenres
|
query := getGenreNamesByFilePaths
|
||||||
var queryParams []interface{}
|
var queryParams []interface{}
|
||||||
if len(genreNames) > 0 {
|
if len(paths) > 0 {
|
||||||
for _, v := range genreNames {
|
for _, v := range paths {
|
||||||
queryParams = append(queryParams, v)
|
queryParams = append(queryParams, v)
|
||||||
}
|
}
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1)
|
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
|
||||||
} else {
|
} else {
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
|
||||||
}
|
}
|
||||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetFilePathsByGenresRow
|
var items []GetGenreNamesByFilePathsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetFilePathsByGenresRow
|
var i GetGenreNamesByFilePathsRow
|
||||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
if err := rows.Scan(&i.FilePath, &i.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -201,51 +153,24 @@ func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many
|
const getUnusedGenreIDs = `-- name: GetUnusedGenreIDs :many
|
||||||
SELECT g.name AS genre_name, af.file_path
|
SELECT id FROM genres g
|
||||||
FROM genres g
|
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id)
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE g.name IN (/*SLICE:genre_names*/?)
|
|
||||||
AND af.library_id = ?
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetFilePathsByGenresByLibraryParams struct {
|
func (q *Queries) GetUnusedGenreIDs(ctx context.Context) ([]int64, error) {
|
||||||
GenreNames []string
|
rows, err := q.db.QueryContext(ctx, getUnusedGenreIDs)
|
||||||
LibraryID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetFilePathsByGenresByLibraryRow struct {
|
|
||||||
GenreName string
|
|
||||||
FilePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
|
|
||||||
query := getFilePathsByGenresByLibrary
|
|
||||||
var queryParams []interface{}
|
|
||||||
if len(arg.GenreNames) > 0 {
|
|
||||||
for _, v := range arg.GenreNames {
|
|
||||||
queryParams = append(queryParams, v)
|
|
||||||
}
|
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
|
|
||||||
} else {
|
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
|
||||||
}
|
|
||||||
queryParams = append(queryParams, arg.LibraryID)
|
|
||||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetFilePathsByGenresByLibraryRow
|
var items []int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetFilePathsByGenresByLibraryRow
|
var id int64
|
||||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
if err := rows.Scan(&id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, id)
|
||||||
}
|
}
|
||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -256,247 +181,32 @@ func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFile
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
const linkFileGenre = `-- name: LinkFileGenre :exec
|
||||||
SELECT g.id, g.name
|
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?)
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
WHERE rg.recording_id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) {
|
type LinkFileGenreParams struct {
|
||||||
rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID)
|
AudioFileID int64
|
||||||
if err != nil {
|
GenreID int64
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Genre
|
|
||||||
for rows.Next() {
|
|
||||||
var i Genre
|
|
||||||
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByGenre = `-- name: GetTracksByGenre :many
|
func (q *Queries) LinkFileGenre(ctx context.Context, arg LinkFileGenreParams) error {
|
||||||
SELECT
|
_, err := q.db.ExecContext(ctx, linkFileGenre, arg.AudioFileID, arg.GenreID)
|
||||||
af.file_path,
|
return err
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rlg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
|
||||||
FROM recording_genres rg2
|
|
||||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
|
||||||
WHERE rg2.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE g.name = ?
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetTracksByGenreRow struct {
|
|
||||||
FilePath string
|
|
||||||
LengthMilliseconds int64
|
|
||||||
Title string
|
|
||||||
ArtistName string
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Album string
|
|
||||||
Genre string
|
|
||||||
Year int64
|
|
||||||
Composer string
|
|
||||||
FileType string
|
|
||||||
SampleRate int64
|
|
||||||
BitDepth int64
|
|
||||||
Channels int64
|
|
||||||
Bitrate int64
|
|
||||||
FileSize int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getTracksByGenre, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetTracksByGenreRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetTracksByGenreRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.FilePath,
|
|
||||||
&i.LengthMilliseconds,
|
|
||||||
&i.Title,
|
|
||||||
&i.ArtistName,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Album,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Year,
|
|
||||||
&i.Composer,
|
|
||||||
&i.FileType,
|
|
||||||
&i.SampleRate,
|
|
||||||
&i.BitDepth,
|
|
||||||
&i.Channels,
|
|
||||||
&i.Bitrate,
|
|
||||||
&i.FileSize,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTracksByGenreByLibrary = `-- name: GetTracksByGenreByLibrary :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rlg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
|
||||||
FROM recording_genres rg2
|
|
||||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
|
||||||
WHERE rg2.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE g.name = ? AND af.library_id = ?
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetTracksByGenreByLibraryParams struct {
|
|
||||||
Name string
|
|
||||||
LibraryID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetTracksByGenreByLibraryRow struct {
|
|
||||||
FilePath string
|
|
||||||
LengthMilliseconds int64
|
|
||||||
Title string
|
|
||||||
ArtistName string
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Album string
|
|
||||||
Genre string
|
|
||||||
Year int64
|
|
||||||
Composer string
|
|
||||||
FileType string
|
|
||||||
SampleRate int64
|
|
||||||
BitDepth int64
|
|
||||||
Channels int64
|
|
||||||
Bitrate int64
|
|
||||||
FileSize int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetTracksByGenreByLibrary(ctx context.Context, arg GetTracksByGenreByLibraryParams) ([]GetTracksByGenreByLibraryRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getTracksByGenreByLibrary, arg.Name, arg.LibraryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetTracksByGenreByLibraryRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetTracksByGenreByLibraryRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.FilePath,
|
|
||||||
&i.LengthMilliseconds,
|
|
||||||
&i.Title,
|
|
||||||
&i.ArtistName,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Album,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Year,
|
|
||||||
&i.Composer,
|
|
||||||
&i.FileType,
|
|
||||||
&i.SampleRate,
|
|
||||||
&i.BitDepth,
|
|
||||||
&i.Channels,
|
|
||||||
&i.Bitrate,
|
|
||||||
&i.FileSize,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertGenre = `-- name: UpsertGenre :one
|
const upsertGenre = `-- name: UpsertGenre :one
|
||||||
|
|
||||||
INSERT INTO genres (name) VALUES (?)
|
INSERT INTO genres (name) VALUES (?)
|
||||||
ON CONFLICT(name) DO UPDATE SET name = name
|
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||||
RETURNING id, name
|
RETURNING id, name
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Queries over genres and file_genres.
|
||||||
|
//
|
||||||
|
// The track-returning ones live in audio_files.sql with the rest of the
|
||||||
|
// track_metadata reads; what is left here is the genre list itself and
|
||||||
|
// the link table's writes.
|
||||||
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
|
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
|
||||||
row := q.db.QueryRowContext(ctx, upsertGenre, name)
|
row := q.db.QueryRowContext(ctx, upsertGenre, name)
|
||||||
var i Genre
|
var i Genre
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
|
|
||||||
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
|
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
JOIN genres g ON g.id = rgen.genre_id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
WHERE g.name = ?
|
WHERE g.name = ?
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
@@ -54,9 +54,8 @@ func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenrePa
|
|||||||
|
|
||||||
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
|
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) > 0
|
HAVING SUM(af.play_count) > 0
|
||||||
ORDER BY SUM(af.play_count) DESC
|
ORDER BY SUM(af.play_count) DESC
|
||||||
@@ -89,9 +88,8 @@ func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int6
|
|||||||
|
|
||||||
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
|
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -122,9 +120,8 @@ func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, e
|
|||||||
|
|
||||||
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
|
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.id) DESC
|
ORDER BY MAX(af.id) DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -159,9 +156,8 @@ func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]i
|
|||||||
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
|
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
|
||||||
|
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.last_played) DESC
|
ORDER BY MAX(af.last_played) DESC
|
||||||
@@ -172,7 +168,7 @@ LIMIT ?
|
|||||||
//
|
//
|
||||||
// Every one of these returns album ids and nothing else. The display
|
// Every one of these returns album ids and nothing else. The display
|
||||||
// columns (cover art, artist credit, year) already have exactly one
|
// 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
|
// 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.
|
// service joins the ids back to that one album list in Go.
|
||||||
// Albums with the most recent play, newest first.
|
// Albums with the most recent play, newest first.
|
||||||
@@ -201,9 +197,8 @@ func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]
|
|||||||
|
|
||||||
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
|
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING MAX(af.last_played) < datetime('now', ?)
|
HAVING MAX(af.last_played) < datetime('now', ?)
|
||||||
@@ -242,14 +237,12 @@ func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams
|
|||||||
|
|
||||||
const homeTopArtists = `-- name: HomeTopArtists :many
|
const homeTopArtists = `-- name: HomeTopArtists :many
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
rg.artist_credit AS artist_name,
|
||||||
SUM(af.play_count) AS plays
|
SUM(af.play_count) AS plays
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
WHERE rg.artist_credit <> ''
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
GROUP BY rg.artist_credit
|
||||||
WHERE ac.text <> ''
|
|
||||||
GROUP BY ac.text
|
|
||||||
HAVING plays > 0
|
HAVING plays > 0
|
||||||
ORDER BY plays DESC
|
ORDER BY plays DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -288,10 +281,10 @@ func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArt
|
|||||||
const homeTopGenres = `-- name: HomeTopGenres :many
|
const homeTopGenres = `-- name: HomeTopGenres :many
|
||||||
SELECT
|
SELECT
|
||||||
g.name AS genre,
|
g.name AS genre,
|
||||||
COUNT(DISTINCT rgr.release_group_id) AS album_count
|
COUNT(DISTINCT af.album_id) AS album_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rgen ON rgen.genre_id = g.id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
GROUP BY g.id
|
GROUP BY g.id
|
||||||
HAVING album_count >= 3
|
HAVING album_count >= 3
|
||||||
ORDER BY album_count DESC
|
ORDER BY album_count DESC
|
||||||
@@ -331,9 +324,8 @@ func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenr
|
|||||||
|
|
||||||
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
|
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) = 0
|
HAVING SUM(af.play_count) = 0
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
|
|||||||
@@ -9,21 +9,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Album struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
ArtistCredit string
|
||||||
|
ArtistID sql.NullInt64
|
||||||
|
Mbid sql.NullString
|
||||||
|
Year sql.NullInt64
|
||||||
|
OriginalYear sql.NullInt64
|
||||||
|
CoverArtID sql.NullInt64
|
||||||
|
PendingReleaseMbid sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Mbid sql.NullString
|
Mbid sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistCredit struct {
|
type ArtistEnrichment struct {
|
||||||
ID int64
|
ArtistMbid string
|
||||||
Text string
|
BrowsedAt sql.NullTime
|
||||||
}
|
SimilarAt sql.NullTime
|
||||||
|
|
||||||
type ArtistCreditArtist struct {
|
|
||||||
ID int64
|
|
||||||
ArtistID int64
|
|
||||||
CreditID int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistImage struct {
|
type ArtistImage struct {
|
||||||
@@ -50,21 +57,31 @@ type ArtistMetadatum struct {
|
|||||||
type AudioFile struct {
|
type AudioFile struct {
|
||||||
ID int64
|
ID int64
|
||||||
FilePath string
|
FilePath string
|
||||||
LengthMilliseconds int64
|
LibraryID int64
|
||||||
FileTypeID int64
|
FileTypeID int64
|
||||||
RecordingID int64
|
LengthMilliseconds int64
|
||||||
SampleRate int64
|
SampleRate int64
|
||||||
BitDepth int64
|
BitDepth int64
|
||||||
Channels int64
|
Channels int64
|
||||||
Bitrate int64
|
Bitrate int64
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
Title string
|
||||||
|
ArtistCredit string
|
||||||
|
ArtistID sql.NullInt64
|
||||||
|
AlbumID sql.NullInt64
|
||||||
|
TrackNumber sql.NullInt64
|
||||||
|
DiscNumber sql.NullInt64
|
||||||
|
TotalTracks sql.NullInt64
|
||||||
|
Year sql.NullInt64
|
||||||
|
Composer string
|
||||||
|
Comment string
|
||||||
|
RecordingMbid sql.NullString
|
||||||
Basename string
|
Basename string
|
||||||
LibraryID int64
|
GroupKey string
|
||||||
|
ModifiedAt int64
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
LastPlayed sql.NullTime
|
LastPlayed sql.NullTime
|
||||||
TagStatus string
|
TagStatus string
|
||||||
GroupKey string
|
|
||||||
ModifiedAt int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoverArt struct {
|
type CoverArt struct {
|
||||||
@@ -154,20 +171,21 @@ type ExploreChampionFt struct {
|
|||||||
|
|
||||||
type ExploreIndex struct {
|
type ExploreIndex struct {
|
||||||
ID int64
|
ID int64
|
||||||
EntityType string
|
EntityType int64
|
||||||
Mbid string
|
Mbid []byte
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
ArtistMbid string
|
ArtistMbid []byte
|
||||||
Aliases string
|
Aliases string
|
||||||
Popularity int64
|
Popularity int64
|
||||||
ListenerCount int64
|
ListenerCount int64
|
||||||
Duration int64
|
Duration int64
|
||||||
CaaReleaseMbid string
|
CaaReleaseMbid []byte
|
||||||
ReleaseName string
|
ReleaseName string
|
||||||
PrimaryType string
|
PrimaryType string
|
||||||
SecondaryTypes string
|
SecondaryTypes string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
|
TotalTracks int64
|
||||||
ArtistType string
|
ArtistType string
|
||||||
Country string
|
Country string
|
||||||
Disambiguation string
|
Disambiguation string
|
||||||
@@ -191,6 +209,11 @@ type ExploreIndexMetum struct {
|
|||||||
Value string
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileGenre struct {
|
||||||
|
AudioFileID int64
|
||||||
|
GenreID int64
|
||||||
|
}
|
||||||
|
|
||||||
type FileType struct {
|
type FileType struct {
|
||||||
ID int64
|
ID int64
|
||||||
Extension string
|
Extension string
|
||||||
@@ -225,6 +248,14 @@ type Library struct {
|
|||||||
AutotagWarningAcked int64
|
AutotagWarningAcked int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Lyric struct {
|
||||||
|
AudioFileID int64
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
RecordingMbid sql.NullString
|
||||||
|
FetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type LyricsIndex struct {
|
type LyricsIndex struct {
|
||||||
Lyrics string
|
Lyrics string
|
||||||
}
|
}
|
||||||
@@ -274,6 +305,9 @@ type Queue struct {
|
|||||||
ShuffleMode bool
|
ShuffleMode bool
|
||||||
RepeatMode string
|
RepeatMode string
|
||||||
ShuffleOrder sql.NullString
|
ShuffleOrder sql.NullString
|
||||||
|
SourceType string
|
||||||
|
SourceID int64
|
||||||
|
SourceLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueueTrack struct {
|
type QueueTrack struct {
|
||||||
@@ -282,46 +316,6 @@ type QueueTrack struct {
|
|||||||
Position int64
|
Position int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Recording struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
ArtistCreditID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
Genre sql.NullString
|
|
||||||
Composer sql.NullString
|
|
||||||
Lyrics sql.NullString
|
|
||||||
Comment sql.NullString
|
|
||||||
Mbid sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecordingGenre struct {
|
|
||||||
ID int64
|
|
||||||
RecordingID int64
|
|
||||||
GenreID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseGroup struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
CoverArtID sql.NullInt64
|
|
||||||
AlbumArtistCreditID sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
TotalTracks sql.NullInt64
|
|
||||||
TotalDiscs sql.NullInt64
|
|
||||||
Mbid sql.NullString
|
|
||||||
OriginalYear sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseGroupRecording struct {
|
|
||||||
ID int64
|
|
||||||
ReleaseGroupID int64
|
|
||||||
RecordingID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseToRg struct {
|
type ReleaseToRg struct {
|
||||||
ReleaseMbid string
|
ReleaseMbid string
|
||||||
RgMbid string
|
RgMbid string
|
||||||
@@ -370,6 +364,7 @@ type TaggingItem struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Synthetic int64
|
Synthetic int64
|
||||||
ParentGroupKey string
|
ParentGroupKey string
|
||||||
|
AlbumArtistConflict int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackMetadatum struct {
|
type TrackMetadatum struct {
|
||||||
@@ -398,4 +393,6 @@ type TrackMetadatum struct {
|
|||||||
ArtistMbid string
|
ArtistMbid string
|
||||||
ReleaseGroupMbid string
|
ReleaseGroupMbid string
|
||||||
RecordingMbid string
|
RecordingMbid string
|
||||||
|
AlbumID sql.NullInt64
|
||||||
|
ArtistID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,29 +124,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
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,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_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
|
|
||||||
ORDER BY pt.playlist_id, pt.position
|
ORDER BY pt.playlist_id, pt.position
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -368,29 +357,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
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,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_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
|
|
||||||
WHERE pt.playlist_id = ?
|
WHERE pt.playlist_id = ?
|
||||||
ORDER BY pt.position
|
ORDER BY pt.position
|
||||||
`
|
`
|
||||||
@@ -451,30 +429,10 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one
|
const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one
|
||||||
SELECT
|
SELECT title, artist_name AS artist, album,
|
||||||
COALESCE(r.name, '') AS title,
|
length_milliseconds AS duration_ms, genre, cover_art_path
|
||||||
COALESCE(ac.text, '') AS artist,
|
FROM track_metadata
|
||||||
COALESCE(rg.name, '') AS album,
|
WHERE id = ?
|
||||||
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 = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetTrackPhantomMetadataRow struct {
|
type GetTrackPhantomMetadataRow struct {
|
||||||
@@ -486,6 +444,7 @@ type GetTrackPhantomMetadataRow struct {
|
|||||||
CoverArtPath string
|
CoverArtPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The display fields a playlist row keeps after its file goes away.
|
||||||
func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) {
|
func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id)
|
row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id)
|
||||||
var i GetTrackPhantomMetadataRow
|
var i GetTrackPhantomMetadataRow
|
||||||
|
|||||||
@@ -20,27 +20,31 @@ func (q *Queries) ClearQueueTracks(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getQueueState = `-- name: GetQueueState :one
|
const getQueueState = `-- name: GetQueueState :one
|
||||||
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
|
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
|
||||||
FROM queue WHERE id = 1
|
FROM queue WHERE id = 1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetQueueStateRow struct {
|
type GetQueueStateRow struct {
|
||||||
SourcePlaylistID sql.NullInt64
|
CurrentPosition int64
|
||||||
CurrentPosition int64
|
ShuffleMode bool
|
||||||
ShuffleMode bool
|
RepeatMode string
|
||||||
RepeatMode string
|
ShuffleOrder sql.NullString
|
||||||
ShuffleOrder sql.NullString
|
SourceType string
|
||||||
|
SourceID int64
|
||||||
|
SourceLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetQueueState(ctx context.Context) (GetQueueStateRow, error) {
|
func (q *Queries) GetQueueState(ctx context.Context) (GetQueueStateRow, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getQueueState)
|
row := q.db.QueryRowContext(ctx, getQueueState)
|
||||||
var i GetQueueStateRow
|
var i GetQueueStateRow
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.SourcePlaylistID,
|
|
||||||
&i.CurrentPosition,
|
&i.CurrentPosition,
|
||||||
&i.ShuffleMode,
|
&i.ShuffleMode,
|
||||||
&i.RepeatMode,
|
&i.RepeatMode,
|
||||||
&i.ShuffleOrder,
|
&i.ShuffleOrder,
|
||||||
|
&i.SourceType,
|
||||||
|
&i.SourceID,
|
||||||
|
&i.SourceLabel,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -57,27 +61,11 @@ func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getQueueTracks = `-- name: GetQueueTracks :many
|
const getQueueTracks = `-- name: GetQueueTracks :many
|
||||||
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
|
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
|
||||||
COALESCE(r.name, '') AS title,
|
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
|
||||||
COALESCE(ac.text, '') AS artist,
|
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
|
||||||
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 queue_tracks qt
|
FROM queue_tracks qt
|
||||||
JOIN audio_files af ON qt.audio_file_id = af.id
|
JOIN track_metadata tm ON tm.id = qt.audio_file_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
|
|
||||||
ORDER BY qt.position
|
ORDER BY qt.position
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -95,6 +83,7 @@ type GetQueueTracksRow struct {
|
|||||||
RecordingMbid string
|
RecordingMbid string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queue's rows, joined to the one track projection.
|
||||||
func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
|
func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getQueueTracks)
|
rows, err := q.db.QueryContext(ctx, getQueueTracks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,25 +189,29 @@ func (q *Queries) UpdateQueuePosition(ctx context.Context, currentPosition int64
|
|||||||
|
|
||||||
const updateQueueState = `-- name: UpdateQueueState :exec
|
const updateQueueState = `-- name: UpdateQueueState :exec
|
||||||
UPDATE queue
|
UPDATE queue
|
||||||
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
|
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateQueueStateParams struct {
|
type UpdateQueueStateParams struct {
|
||||||
SourcePlaylistID sql.NullInt64
|
CurrentPosition int64
|
||||||
CurrentPosition int64
|
ShuffleMode bool
|
||||||
ShuffleMode bool
|
RepeatMode string
|
||||||
RepeatMode string
|
ShuffleOrder sql.NullString
|
||||||
ShuffleOrder sql.NullString
|
SourceType string
|
||||||
|
SourceID int64
|
||||||
|
SourceLabel string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateQueueState(ctx context.Context, arg UpdateQueueStateParams) error {
|
func (q *Queries) UpdateQueueState(ctx context.Context, arg UpdateQueueStateParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, updateQueueState,
|
_, err := q.db.ExecContext(ctx, updateQueueState,
|
||||||
arg.SourcePlaylistID,
|
|
||||||
arg.CurrentPosition,
|
arg.CurrentPosition,
|
||||||
arg.ShuffleMode,
|
arg.ShuffleMode,
|
||||||
arg.RepeatMode,
|
arg.RepeatMode,
|
||||||
arg.ShuffleOrder,
|
arg.ShuffleOrder,
|
||||||
|
arg.SourceType,
|
||||||
|
arg.SourceID,
|
||||||
|
arg.SourceLabel,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.30.0
|
|
||||||
// source: recordings.sql
|
|
||||||
|
|
||||||
package sqlcgen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
const countRecordingsByArtistCredit = `-- name: CountRecordingsByArtistCredit :one
|
|
||||||
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CountRecordingsByArtistCredit(ctx context.Context, artistCreditID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, countRecordingsByArtistCredit, artistCreditID)
|
|
||||||
var count int64
|
|
||||||
err := row.Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRecording = `-- name: CreateRecording :one
|
|
||||||
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
|
|
||||||
RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateRecordingParams struct {
|
|
||||||
Name string
|
|
||||||
ArtistCreditID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateRecording(ctx context.Context, arg CreateRecordingParams) (Recording, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createRecording, arg.Name, arg.ArtistCreditID)
|
|
||||||
var i Recording
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.ArtistCreditID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Year,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Composer,
|
|
||||||
&i.Lyrics,
|
|
||||||
&i.Comment,
|
|
||||||
&i.Mbid,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRecordingFull = `-- name: CreateRecordingFull :one
|
|
||||||
INSERT INTO recordings (
|
|
||||||
name, artist_credit_id, track_number, disc_number,
|
|
||||||
year, genre, composer, lyrics, comment
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateRecordingFullParams struct {
|
|
||||||
Name string
|
|
||||||
ArtistCreditID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
Genre sql.NullString
|
|
||||||
Composer sql.NullString
|
|
||||||
Lyrics sql.NullString
|
|
||||||
Comment sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFullParams) (Recording, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createRecordingFull,
|
|
||||||
arg.Name,
|
|
||||||
arg.ArtistCreditID,
|
|
||||||
arg.TrackNumber,
|
|
||||||
arg.DiscNumber,
|
|
||||||
arg.Year,
|
|
||||||
arg.Genre,
|
|
||||||
arg.Composer,
|
|
||||||
arg.Lyrics,
|
|
||||||
arg.Comment,
|
|
||||||
)
|
|
||||||
var i Recording
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.ArtistCreditID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Year,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Composer,
|
|
||||||
&i.Lyrics,
|
|
||||||
&i.Comment,
|
|
||||||
&i.Mbid,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllRecordings = `-- name: DeleteAllRecordings :exec
|
|
||||||
DELETE FROM recordings
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteAllRecordings(ctx context.Context) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllRecordings)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteRecording = `-- name: DeleteRecording :exec
|
|
||||||
DELETE FROM recordings
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteRecording(ctx context.Context, id int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteRecording, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllRecordings = `-- name: GetAllRecordings :many
|
|
||||||
SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid FROM recordings
|
|
||||||
ORDER BY name
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetAllRecordings(ctx context.Context) ([]Recording, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAllRecordings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Recording
|
|
||||||
for rows.Next() {
|
|
||||||
var i Recording
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.ArtistCreditID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Year,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Composer,
|
|
||||||
&i.Lyrics,
|
|
||||||
&i.Comment,
|
|
||||||
&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 getOrphanedRecordingIDs = `-- name: GetOrphanedRecordingIDs :many
|
|
||||||
SELECT r.id FROM recordings r
|
|
||||||
LEFT JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE af.id IS NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (q *Queries) GetOrphanedRecordingIDs(ctx context.Context) ([]int64, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getOrphanedRecordingIDs)
|
|
||||||
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 getRecording = `-- name: GetRecording :one
|
|
||||||
SELECT id, name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment, mbid FROM recordings
|
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetRecording(ctx context.Context, id int64) (Recording, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getRecording, id)
|
|
||||||
var i Recording
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.ArtistCreditID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Year,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Composer,
|
|
||||||
&i.Lyrics,
|
|
||||||
&i.Comment,
|
|
||||||
&i.Mbid,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRecording = `-- name: UpdateRecording :exec
|
|
||||||
UPDATE recordings
|
|
||||||
SET name = ?, artist_credit_id = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateRecordingParams struct {
|
|
||||||
Name string
|
|
||||||
ArtistCreditID int64
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateRecording(ctx context.Context, arg UpdateRecordingParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, updateRecording, arg.Name, arg.ArtistCreditID, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRecordingFull = `-- name: UpdateRecordingFull :exec
|
|
||||||
UPDATE recordings
|
|
||||||
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
|
|
||||||
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateRecordingFullParams struct {
|
|
||||||
Name string
|
|
||||||
ArtistCreditID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
Genre sql.NullString
|
|
||||||
Composer sql.NullString
|
|
||||||
Lyrics sql.NullString
|
|
||||||
Comment sql.NullString
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateRecordingFull(ctx context.Context, arg UpdateRecordingFullParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, updateRecordingFull,
|
|
||||||
arg.Name,
|
|
||||||
arg.ArtistCreditID,
|
|
||||||
arg.TrackNumber,
|
|
||||||
arg.DiscNumber,
|
|
||||||
arg.Year,
|
|
||||||
arg.Genre,
|
|
||||||
arg.Composer,
|
|
||||||
arg.Lyrics,
|
|
||||||
arg.Comment,
|
|
||||||
arg.ID,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.30.0
|
|
||||||
// source: release_group_recordings.sql
|
|
||||||
|
|
||||||
package sqlcgen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createReleaseGroupRecording = `-- name: CreateReleaseGroupRecording :one
|
|
||||||
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
RETURNING id, release_group_id, recording_id, track_number, disc_number
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateReleaseGroupRecordingParams struct {
|
|
||||||
ReleaseGroupID int64
|
|
||||||
RecordingID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateReleaseGroupRecordingParams) (ReleaseGroupRecording, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createReleaseGroupRecording,
|
|
||||||
arg.ReleaseGroupID,
|
|
||||||
arg.RecordingID,
|
|
||||||
arg.TrackNumber,
|
|
||||||
arg.DiscNumber,
|
|
||||||
)
|
|
||||||
var i ReleaseGroupRecording
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ReleaseGroupID,
|
|
||||||
&i.RecordingID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllReleaseGroupRecordings = `-- name: DeleteAllReleaseGroupRecordings :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteAllReleaseGroupRecordings(ctx context.Context) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllReleaseGroupRecordings)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteReleaseGroupRecording(ctx context.Context, id int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteReleaseGroupRecording, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteReleaseGroupRecordingByFK = `-- name: DeleteReleaseGroupRecordingByFK :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE release_group_id = ? AND recording_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type DeleteReleaseGroupRecordingByFKParams struct {
|
|
||||||
ReleaseGroupID int64
|
|
||||||
RecordingID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) DeleteReleaseGroupRecordingByFK(ctx context.Context, arg DeleteReleaseGroupRecordingByFKParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteReleaseGroupRecordingByFK, arg.ReleaseGroupID, arg.RecordingID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteReleaseGroupRecordingsByRecording = `-- name: DeleteReleaseGroupRecordingsByRecording :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE recording_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteReleaseGroupRecordingsByRecording(ctx context.Context, recordingID int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteReleaseGroupRecordingsByRecording, recordingID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
|
|
||||||
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
|
|
||||||
WHERE recording_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int64) ([]ReleaseGroupRecording, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getRecordingReleaseGroups, recordingID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []ReleaseGroupRecording
|
|
||||||
for rows.Next() {
|
|
||||||
var i ReleaseGroupRecording
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ReleaseGroupID,
|
|
||||||
&i.RecordingID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
); 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 getReleaseGroupRecording = `-- name: GetReleaseGroupRecording :one
|
|
||||||
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
|
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (ReleaseGroupRecording, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getReleaseGroupRecording, id)
|
|
||||||
var i ReleaseGroupRecording
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ReleaseGroupID,
|
|
||||||
&i.RecordingID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
|
|
||||||
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
|
|
||||||
WHERE release_group_id = ?
|
|
||||||
ORDER BY disc_number, track_number
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetReleaseGroupRecordings(ctx context.Context, releaseGroupID int64) ([]ReleaseGroupRecording, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getReleaseGroupRecordings, releaseGroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []ReleaseGroupRecording
|
|
||||||
for rows.Next() {
|
|
||||||
var i ReleaseGroupRecording
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.ReleaseGroupID,
|
|
||||||
&i.RecordingID,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
); 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
|
|
||||||
}
|
|
||||||
@@ -1,633 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.30.0
|
|
||||||
// source: release_groups.sql
|
|
||||||
|
|
||||||
package sqlcgen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
)
|
|
||||||
|
|
||||||
const countReleaseGroupRecordings = `-- name: CountReleaseGroupRecordings :one
|
|
||||||
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, countReleaseGroupRecordings, releaseGroupID)
|
|
||||||
var count int64
|
|
||||||
err := row.Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createReleaseGroup = `-- name: CreateReleaseGroup :one
|
|
||||||
INSERT INTO release_groups (name) VALUES (?)
|
|
||||||
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createReleaseGroup, name)
|
|
||||||
var i ReleaseGroup
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.CoverArtID,
|
|
||||||
&i.AlbumArtistCreditID,
|
|
||||||
&i.Year,
|
|
||||||
&i.TotalTracks,
|
|
||||||
&i.TotalDiscs,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.OriginalYear,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
|
|
||||||
INSERT INTO release_groups (
|
|
||||||
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateReleaseGroupFullParams struct {
|
|
||||||
Name string
|
|
||||||
CoverArtID sql.NullInt64
|
|
||||||
AlbumArtistCreditID sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
TotalTracks sql.NullInt64
|
|
||||||
TotalDiscs sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseGroupFullParams) (ReleaseGroup, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createReleaseGroupFull,
|
|
||||||
arg.Name,
|
|
||||||
arg.CoverArtID,
|
|
||||||
arg.AlbumArtistCreditID,
|
|
||||||
arg.Year,
|
|
||||||
arg.TotalTracks,
|
|
||||||
arg.TotalDiscs,
|
|
||||||
)
|
|
||||||
var i ReleaseGroup
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.CoverArtID,
|
|
||||||
&i.AlbumArtistCreditID,
|
|
||||||
&i.Year,
|
|
||||||
&i.TotalTracks,
|
|
||||||
&i.TotalDiscs,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.OriginalYear,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllReleaseGroups = `-- name: DeleteAllReleaseGroups :exec
|
|
||||||
DELETE FROM release_groups
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteAllReleaseGroups(ctx context.Context) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllReleaseGroups)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
|
|
||||||
DELETE FROM release_groups
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteReleaseGroup(ctx context.Context, id int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteReleaseGroup, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAlbumsByArtist = `-- 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
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetAlbumsByArtistRow struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
Year sql.NullInt64
|
|
||||||
ReleaseYear int64
|
|
||||||
ArtistName string
|
|
||||||
ArtistMbid string
|
|
||||||
CoverArtPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetAlbumsByArtist(ctx context.Context, artistID int64) ([]GetAlbumsByArtistRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAlbumsByArtist, artistID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetAlbumsByArtistRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetAlbumsByArtistRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.Year,
|
|
||||||
&i.ReleaseYear,
|
|
||||||
&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 getAlbumsByArtistByLibrary = `-- 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
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetAlbumsByArtistByLibraryParams struct {
|
|
||||||
ArtistID int64
|
|
||||||
LibraryID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetAlbumsByArtistByLibraryRow struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
Year sql.NullInt64
|
|
||||||
ReleaseYear int64
|
|
||||||
ArtistName string
|
|
||||||
ArtistMbid string
|
|
||||||
CoverArtPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetAlbumsByArtistByLibrary(ctx context.Context, arg GetAlbumsByArtistByLibraryParams) ([]GetAlbumsByArtistByLibraryRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAlbumsByArtistByLibrary, arg.ArtistID, arg.LibraryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetAlbumsByArtistByLibraryRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetAlbumsByArtistByLibraryRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.Year,
|
|
||||||
&i.ReleaseYear,
|
|
||||||
&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 getAllAlbumsWithDetails = `-- 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
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetAllAlbumsWithDetailsRow struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
Year sql.NullInt64
|
|
||||||
ReleaseYear int64
|
|
||||||
Mbid sql.NullString
|
|
||||||
ArtistName string
|
|
||||||
ArtistMbid string
|
|
||||||
CoverArtPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetAllAlbumsWithDetails(ctx context.Context) ([]GetAllAlbumsWithDetailsRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAllAlbumsWithDetails)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetAllAlbumsWithDetailsRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetAllAlbumsWithDetailsRow
|
|
||||||
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 getAllAlbumsWithDetailsByLibrary = `-- 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
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetAllAlbumsWithDetailsByLibraryRow struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
Year sql.NullInt64
|
|
||||||
ReleaseYear int64
|
|
||||||
Mbid sql.NullString
|
|
||||||
ArtistName string
|
|
||||||
ArtistMbid string
|
|
||||||
CoverArtPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryID int64) ([]GetAllAlbumsWithDetailsByLibraryRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAllAlbumsWithDetailsByLibrary, libraryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetAllAlbumsWithDetailsByLibraryRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetAllAlbumsWithDetailsByLibraryRow
|
|
||||||
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 getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
|
|
||||||
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
|
|
||||||
ORDER BY name
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAllReleaseGroups)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []ReleaseGroup
|
|
||||||
for rows.Next() {
|
|
||||||
var i ReleaseGroup
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.CoverArtID,
|
|
||||||
&i.AlbumArtistCreditID,
|
|
||||||
&i.Year,
|
|
||||||
&i.TotalTracks,
|
|
||||||
&i.TotalDiscs,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.OriginalYear,
|
|
||||||
); 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 getOrphanedReleaseGroupIDs = `-- name: GetOrphanedReleaseGroupIDs :many
|
|
||||||
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
|
|
||||||
`
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (q *Queries) GetOrphanedReleaseGroupIDs(ctx context.Context) ([]int64, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getOrphanedReleaseGroupIDs)
|
|
||||||
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 getReleaseGroup = `-- name: GetReleaseGroup :one
|
|
||||||
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
|
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getReleaseGroup, id)
|
|
||||||
var i ReleaseGroup
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.CoverArtID,
|
|
||||||
&i.AlbumArtistCreditID,
|
|
||||||
&i.Year,
|
|
||||||
&i.TotalTracks,
|
|
||||||
&i.TotalDiscs,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.OriginalYear,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one
|
|
||||||
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
|
|
||||||
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetReleaseGroupByNameAndArtistParams struct {
|
|
||||||
Name string
|
|
||||||
AlbumArtistCreditID sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetReleaseGroupByNameAndArtistParams) (ReleaseGroup, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getReleaseGroupByNameAndArtist, arg.Name, arg.AlbumArtistCreditID)
|
|
||||||
var i ReleaseGroup
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.CoverArtID,
|
|
||||||
&i.AlbumArtistCreditID,
|
|
||||||
&i.Year,
|
|
||||||
&i.TotalTracks,
|
|
||||||
&i.TotalDiscs,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.OriginalYear,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const setReleaseGroupOriginalYear = `-- name: SetReleaseGroupOriginalYear :exec
|
|
||||||
UPDATE release_groups SET original_year = ? WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type SetReleaseGroupOriginalYearParams struct {
|
|
||||||
OriginalYear sql.NullInt64
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (q *Queries) SetReleaseGroupOriginalYear(ctx context.Context, arg SetReleaseGroupOriginalYearParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, setReleaseGroupOriginalYear, arg.OriginalYear, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateReleaseGroup = `-- name: UpdateReleaseGroup :exec
|
|
||||||
UPDATE release_groups
|
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateReleaseGroupParams struct {
|
|
||||||
Name string
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateReleaseGroup(ctx context.Context, arg UpdateReleaseGroupParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, updateReleaseGroup, arg.Name, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateReleaseGroupCoverArt = `-- name: UpdateReleaseGroupCoverArt :exec
|
|
||||||
UPDATE release_groups
|
|
||||||
SET cover_art_id = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateReleaseGroupCoverArtParams struct {
|
|
||||||
CoverArtID sql.NullInt64
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateReleaseGroupCoverArt(ctx context.Context, arg UpdateReleaseGroupCoverArtParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, updateReleaseGroupCoverArt, arg.CoverArtID, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertReleaseGroup = `-- 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 id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpsertReleaseGroupParams struct {
|
|
||||||
Name string
|
|
||||||
AlbumArtistCreditID sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroupParams) (ReleaseGroup, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, upsertReleaseGroup, arg.Name, arg.AlbumArtistCreditID, arg.Year)
|
|
||||||
var i ReleaseGroup
|
|
||||||
err := row.Scan(
|
|
||||||
&i.ID,
|
|
||||||
&i.Name,
|
|
||||||
&i.CoverArtID,
|
|
||||||
&i.AlbumArtistCreditID,
|
|
||||||
&i.Year,
|
|
||||||
&i.TotalTracks,
|
|
||||||
&i.TotalDiscs,
|
|
||||||
&i.Mbid,
|
|
||||||
&i.OriginalYear,
|
|
||||||
)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
@@ -25,11 +25,21 @@ func (q *Queries) ClearCompletedTaggingItems(ctx context.Context, libraryID int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
|
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
|
||||||
SELECT COUNT(*) FROM tagging_items
|
SELECT COUNT(*) FROM tagging_items ti
|
||||||
WHERE status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
|
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// "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.
|
||||||
func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) {
|
func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
|
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
|
||||||
var count int64
|
var count int64
|
||||||
@@ -77,6 +87,13 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE ti.status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
AND ti.group_key > ?2
|
AND ti.group_key > ?2
|
||||||
|
-- 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
|
ORDER BY ti.group_key
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -185,22 +202,8 @@ func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRecordingReleaseGroupID = `-- 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
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getRecordingReleaseGroupID, recordingID)
|
|
||||||
var release_group_id int64
|
|
||||||
err := row.Scan(&release_group_id)
|
|
||||||
return release_group_id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTaggingItem = `-- name: GetTaggingItem :one
|
const getTaggingItem = `-- name: GetTaggingItem :one
|
||||||
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key FROM tagging_items
|
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key, album_artist_conflict FROM tagging_items
|
||||||
WHERE group_key = ?
|
WHERE group_key = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -223,6 +226,7 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
|
|||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Synthetic,
|
&i.Synthetic,
|
||||||
&i.ParentGroupKey,
|
&i.ParentGroupKey,
|
||||||
|
&i.AlbumArtistConflict,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -234,22 +238,18 @@ SELECT
|
|||||||
af.basename,
|
af.basename,
|
||||||
af.length_milliseconds,
|
af.length_milliseconds,
|
||||||
af.tag_status,
|
af.tag_status,
|
||||||
COALESCE(r.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(r.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS title,
|
af.title,
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
af.artist_credit AS artist_name,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(rg.name, '') AS album_name,
|
COALESCE(al.name, '') AS album_name,
|
||||||
COALESCE(rgac.text, '') AS album_artist
|
COALESCE(al.artist_credit, '') AS album_artist
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums al ON al.id = af.album_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
|
|
||||||
WHERE af.group_key = ?
|
WHERE af.group_key = ?
|
||||||
ORDER BY COALESCE(r.disc_number, 0),
|
ORDER BY COALESCE(af.disc_number, 0),
|
||||||
COALESCE(r.track_number, 0),
|
COALESCE(af.track_number, 0),
|
||||||
af.file_path
|
af.file_path
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -268,10 +268,10 @@ type ListAudioFilesInTaggingGroupRow struct {
|
|||||||
AlbumArtist string
|
AlbumArtist string
|
||||||
}
|
}
|
||||||
|
|
||||||
// album_name/album_artist are the PER-TRACK tags (via each track's
|
// album_name/album_artist are the PER-TRACK tags (each file's own
|
||||||
// own release_group link), not the folder-level tagging_items
|
// album link), not the folder-level tagging_items values.
|
||||||
// values. SplitMixedFolder clusters on these to find sub-albums
|
// SplitMixedFolder clusters on these to find sub-albums hiding inside
|
||||||
// hiding inside a folder full of unrelated tracks.
|
// a folder full of unrelated tracks.
|
||||||
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
|
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
|
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -312,10 +312,7 @@ const listLikelyMixedBagGroupKeys = `-- name: ListLikelyMixedBagGroupKeys :many
|
|||||||
SELECT ti.group_key
|
SELECT ti.group_key
|
||||||
FROM tagging_items ti
|
FROM tagging_items ti
|
||||||
JOIN audio_files af ON af.group_key = ti.group_key
|
JOIN audio_files af ON af.group_key = ti.group_key
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums rg ON rg.id = af.album_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
|
|
||||||
WHERE ti.synthetic = 0
|
WHERE ti.synthetic = 0
|
||||||
AND ti.track_count >= 4
|
AND ti.track_count >= 4
|
||||||
AND (
|
AND (
|
||||||
@@ -323,7 +320,7 @@ WHERE ti.synthetic = 0
|
|||||||
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
||||||
)
|
)
|
||||||
GROUP BY ti.group_key
|
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
|
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -358,34 +355,31 @@ func (q *Queries) ListLikelyMixedBagGroupKeys(ctx context.Context) ([]string, er
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listLocalReleaseGroupCandidates = `-- name: ListLocalReleaseGroupCandidates :many
|
const listLocalAlbumCandidates = `-- name: ListLocalAlbumCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
rg.id AS release_group_id,
|
al.id AS album_id,
|
||||||
rg.mbid AS release_group_mbid,
|
al.mbid AS album_mbid,
|
||||||
rg.name AS album_name,
|
al.name AS album_name,
|
||||||
COALESCE(rg.year, 0) AS year,
|
COALESCE(al.year, 0) AS year,
|
||||||
COALESCE(ac.text, '') AS artist_credit,
|
al.artist_credit,
|
||||||
COALESCE(rgr.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(rgr.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS track_title,
|
af.title AS track_title,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
|
af.length_milliseconds
|
||||||
FROM release_groups rg
|
FROM albums al
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = al.id
|
||||||
JOIN recordings r ON r.id = rgr.recording_id
|
WHERE al.mbid IS NOT NULL
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
AND al.mbid != ''
|
||||||
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
|
AND af.recording_mbid IS NOT NULL
|
||||||
WHERE rg.mbid IS NOT NULL
|
AND af.recording_mbid != ''
|
||||||
AND rg.mbid != ''
|
AND al.name = ? COLLATE NOCASE
|
||||||
AND r.mbid IS NOT NULL
|
ORDER BY al.id, af.disc_number, af.track_number
|
||||||
AND r.mbid != ''
|
|
||||||
AND rg.name = ? COLLATE NOCASE
|
|
||||||
ORDER BY rg.id, rgr.disc_number, rgr.track_number
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListLocalReleaseGroupCandidatesRow struct {
|
type ListLocalAlbumCandidatesRow struct {
|
||||||
ReleaseGroupID int64
|
AlbumID int64
|
||||||
ReleaseGroupMbid sql.NullString
|
AlbumMbid sql.NullString
|
||||||
AlbumName string
|
AlbumName string
|
||||||
Year int64
|
Year int64
|
||||||
ArtistCredit string
|
ArtistCredit string
|
||||||
@@ -396,22 +390,21 @@ type ListLocalReleaseGroupCandidatesRow struct {
|
|||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns one row per (release_group, track) combination for any
|
// One row per (album, track) for any local album carrying an MBID.
|
||||||
// local release_group that has an MBID. Callers group these in Go
|
// Callers group these in Go and filter by normalized album-name match;
|
||||||
// and filter by normalized album-name match. Joined case-insensitive
|
// the join is case-insensitive on name to pre-filter cheaply.
|
||||||
// on name to pre-filter cheaply; Go does the real normalization.
|
func (q *Queries) ListLocalAlbumCandidates(ctx context.Context, name string) ([]ListLocalAlbumCandidatesRow, error) {
|
||||||
func (q *Queries) ListLocalReleaseGroupCandidates(ctx context.Context, name string) ([]ListLocalReleaseGroupCandidatesRow, error) {
|
rows, err := q.db.QueryContext(ctx, listLocalAlbumCandidates, name)
|
||||||
rows, err := q.db.QueryContext(ctx, listLocalReleaseGroupCandidates, name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []ListLocalReleaseGroupCandidatesRow
|
var items []ListLocalAlbumCandidatesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListLocalReleaseGroupCandidatesRow
|
var i ListLocalAlbumCandidatesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ReleaseGroupID,
|
&i.AlbumID,
|
||||||
&i.ReleaseGroupMbid,
|
&i.AlbumMbid,
|
||||||
&i.AlbumName,
|
&i.AlbumName,
|
||||||
&i.Year,
|
&i.Year,
|
||||||
&i.ArtistCredit,
|
&i.ArtistCredit,
|
||||||
@@ -453,6 +446,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
||||||
AND ti.cleared_at IS NULL
|
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
|
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
||||||
LIMIT ?4 OFFSET ?3
|
LIMIT ?4 OFFSET ?3
|
||||||
`
|
`
|
||||||
@@ -627,6 +632,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
||||||
AND ti.cleared_at IS NULL
|
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)
|
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
||||||
LIMIT ?4 OFFSET ?3
|
LIMIT ?4 OFFSET ?3
|
||||||
`
|
`
|
||||||
@@ -757,31 +770,36 @@ func (q *Queries) SetAudioFileTagStatus(ctx context.Context, arg SetAudioFileTag
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const setRecordingMBID = `-- name: SetRecordingMBID :exec
|
const setFileAlbumMBID = `-- name: SetFileAlbumMBID :exec
|
||||||
UPDATE recordings SET mbid = ? WHERE id = ?
|
UPDATE albums SET mbid = ?
|
||||||
|
WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetRecordingMBIDParams struct {
|
type SetFileAlbumMBIDParams struct {
|
||||||
Mbid sql.NullString
|
Mbid sql.NullString
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetRecordingMBID(ctx context.Context, arg SetRecordingMBIDParams) error {
|
// The album MBID for the album a file belongs to. Keyed by file
|
||||||
_, err := q.db.ExecContext(ctx, setRecordingMBID, arg.Mbid, arg.ID)
|
// 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.
|
||||||
|
func (q *Queries) SetFileAlbumMBID(ctx context.Context, arg SetFileAlbumMBIDParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setFileAlbumMBID, arg.Mbid, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const setReleaseGroupMBID = `-- name: SetReleaseGroupMBID :exec
|
const setFileRecordingMBID = `-- name: SetFileRecordingMBID :exec
|
||||||
UPDATE release_groups SET mbid = ? WHERE id = ?
|
UPDATE audio_files SET recording_mbid = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetReleaseGroupMBIDParams struct {
|
type SetFileRecordingMBIDParams struct {
|
||||||
Mbid sql.NullString
|
RecordingMbid sql.NullString
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetReleaseGroupMBID(ctx context.Context, arg SetReleaseGroupMBIDParams) error {
|
func (q *Queries) SetFileRecordingMBID(ctx context.Context, arg SetFileRecordingMBIDParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, setReleaseGroupMBID, arg.Mbid, arg.ID)
|
_, err := q.db.ExecContext(ctx, setFileRecordingMBID, arg.RecordingMbid, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,7 +877,27 @@ ON CONFLICT(group_key) DO UPDATE SET
|
|||||||
WHEN tagging_items.album_name = '' THEN excluded.album_name
|
WHEN tagging_items.album_name = '' THEN excluded.album_name
|
||||||
ELSE tagging_items.album_name
|
ELSE tagging_items.album_name
|
||||||
END,
|
END,
|
||||||
|
-- Tracks real consensus, not first-write-wins: stays set only
|
||||||
|
-- while every track that has contributed a non-empty value agrees.
|
||||||
|
-- A later track with a *different* non-empty value clears it back
|
||||||
|
-- to '' and latches album_artist_conflict, since a single
|
||||||
|
-- disagreeing tag means the folder no longer has one authoritative
|
||||||
|
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
|
||||||
|
-- value here as trusted, so leaving a stale first-seen value in
|
||||||
|
-- place would let one track's tag silently blind mixed-bag
|
||||||
|
-- detection for the whole folder. The latch (rather than just
|
||||||
|
-- clearing the text column) stops a later track from coincidentally
|
||||||
|
-- repeating an already-disputed value and resurrecting trust in it.
|
||||||
|
album_artist_conflict = CASE
|
||||||
|
WHEN tagging_items.album_artist_conflict = 1 THEN 1
|
||||||
|
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
|
||||||
|
AND tagging_items.album_artist != excluded.album_artist THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
album_artist = CASE
|
album_artist = CASE
|
||||||
|
WHEN tagging_items.album_artist_conflict = 1 THEN ''
|
||||||
|
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
|
||||||
|
AND tagging_items.album_artist != excluded.album_artist THEN ''
|
||||||
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
|
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
|
||||||
ELSE tagging_items.album_artist
|
ELSE tagging_items.album_artist
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ func TestTagStatusBackfillFromRecordingMBID(t *testing.T) {
|
|||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET tag_status = 'user_confirmed'
|
SET tag_status = 'user_confirmed'
|
||||||
WHERE tag_status = 'untagged'
|
WHERE tag_status = 'untagged'
|
||||||
AND recording_id IN (
|
AND recording_mbid IS NOT NULL AND recording_mbid != ''
|
||||||
SELECT id FROM recordings
|
|
||||||
WHERE mbid IS NOT NULL AND mbid != ''
|
|
||||||
)
|
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
t.Fatalf("backfill: %v", err)
|
t.Fatalf("backfill: %v", err)
|
||||||
}
|
}
|
||||||
@@ -205,6 +202,12 @@ func TestTaggingItems_ListPendingAndCount(t *testing.T) {
|
|||||||
seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending")
|
seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending")
|
||||||
seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed")
|
seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed")
|
||||||
|
|
||||||
|
// A pending group is only listed while it still holds untagged
|
||||||
|
// files — see TestTaggingItems_FullyTaggedGroupIsNotPending.
|
||||||
|
seedGroupFile(t, db, "g1", "/music/a1.mp3", "untagged")
|
||||||
|
seedGroupFile(t, db, "g2", "/music/b1.mp3", "untagged")
|
||||||
|
seedGroupFile(t, db, "g3", "/music/c1.mp3", "user_confirmed")
|
||||||
|
|
||||||
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
|
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("count: %v", err)
|
t.Fatalf("count: %v", err)
|
||||||
@@ -239,6 +242,62 @@ func TestTaggingItems_ListPendingAndCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClearUnreviewedConfirmedTaggingItems mirrors migration 0007 the
|
||||||
|
// way TestTagStatusBackfillFromRecordingMBID mirrors the tag_status
|
||||||
|
// backfill: NewTestDB applies migrations to an empty database, so the
|
||||||
|
// only way to exercise one that rewrites existing rows is to seed the
|
||||||
|
// shapes and re-issue its statement. Keep the two in step.
|
||||||
|
func TestClearUnreviewedConfirmedTaggingItems(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
// Stamped 'confirmed' by the old backfill: never scored, never
|
||||||
|
// checked, never matched — the app has not touched it.
|
||||||
|
seedTaggingItem(t, db, "g-backfill", 0, "Bulk", "Artist A", 2, "confirmed")
|
||||||
|
|
||||||
|
// Confirmed by a real apply, which stamps last_checked_at.
|
||||||
|
seedTaggingItem(t, db, "g-applied", 0, "Applied", "Artist B", 2, "confirmed")
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`UPDATE tagging_items SET last_checked_at = CURRENT_TIMESTAMP, score = 0.98
|
||||||
|
WHERE group_key = 'g-applied'`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("mark applied: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A skipped row is not confirmed and must be left alone.
|
||||||
|
seedTaggingItem(t, db, "g-skipped", 0, "Skipped", "Artist C", 1, "skipped")
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(`
|
||||||
|
UPDATE tagging_items
|
||||||
|
SET cleared_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE status = 'confirmed'
|
||||||
|
AND cleared_at IS NULL
|
||||||
|
AND last_checked_at IS NULL
|
||||||
|
AND score IS NULL
|
||||||
|
AND (best_match_release_mbid IS NULL OR best_match_release_mbid = '')
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"g-backfill": true,
|
||||||
|
"g-applied": false,
|
||||||
|
"g-skipped": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, wantCleared := range cases {
|
||||||
|
got := scalarInt(t, db,
|
||||||
|
`SELECT cleared_at IS NOT NULL FROM tagging_items WHERE group_key = ?`,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
if (got == 1) != wantCleared {
|
||||||
|
t.Errorf("%s cleared = %v, want %v", key, got == 1, wantCleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -248,9 +307,13 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
|||||||
|
|
||||||
rows, err := db.QueryContext(`
|
rows, err := db.QueryContext(`
|
||||||
EXPLAIN QUERY PLAN
|
EXPLAIN QUERY PLAN
|
||||||
SELECT COUNT(*) FROM tagging_items
|
SELECT COUNT(*) FROM tagging_items ti
|
||||||
WHERE status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(0 AS INTEGER) = 0 OR library_id = 0)
|
AND (CAST(0 AS INTEGER) = 0 OR ti.library_id = 0)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("explain: %v", err)
|
t.Fatalf("explain: %v", err)
|
||||||
@@ -281,6 +344,97 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
|||||||
plan.String(),
|
plan.String(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The untagged-files existence check is asked once per candidate
|
||||||
|
// row, so it has to be a seek. idx_audio_files_tag_status_untagged
|
||||||
|
// is keyed on library_id and cannot serve it; the group_key one
|
||||||
|
// can, and covers the query outright.
|
||||||
|
if !strings.Contains(plan.String(), "idx_audio_files_untagged_group_key") {
|
||||||
|
t.Errorf(
|
||||||
|
"untagged-files check does not use idx_audio_files_untagged_group_key:\n%s",
|
||||||
|
plan.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTaggingItems_FullyTaggedGroupIsNotPending pins the rule that
|
||||||
|
// decides what the autotag review page shows: every scanned folder
|
||||||
|
// gets a tagging_items row, so "pending" has to mean "still holds
|
||||||
|
// untagged files" rather than "has a row". Without it a fully
|
||||||
|
// MB-tagged library queues its entire album count for review — and
|
||||||
|
// the background prefetch scores every one of them against
|
||||||
|
// MusicBrainz.
|
||||||
|
func TestTaggingItems_FullyTaggedGroupIsNotPending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
seedTaggingItem(t, db, "g-partial", 0, "Half Tagged", "Artist A", 2, "pending")
|
||||||
|
seedGroupFile(t, db, "g-partial", "/music/partial-1.mp3", "user_confirmed")
|
||||||
|
seedGroupFile(t, db, "g-partial", "/music/partial-2.mp3", "untagged")
|
||||||
|
|
||||||
|
seedTaggingItem(t, db, "g-done", 0, "Already Tagged", "Artist B", 2, "pending")
|
||||||
|
seedGroupFile(t, db, "g-done", "/music/done-1.mp3", "user_confirmed")
|
||||||
|
seedGroupFile(t, db, "g-done", "/music/done-2.mp3", "user_confirmed")
|
||||||
|
|
||||||
|
// Reviewed rows are history, not work: an applied folder is fully
|
||||||
|
// tagged by definition and must stay in the Completed section.
|
||||||
|
seedTaggingItem(t, db, "g-applied", 0, "Applied Here", "Artist C", 1, "confirmed")
|
||||||
|
seedGroupFile(t, db, "g-applied", "/music/applied-1.mp3", "user_confirmed")
|
||||||
|
|
||||||
|
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("pending count = %d, want 1 (only the part-tagged folder)", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := db.Queries.ListPendingTaggingItemsByScore(
|
||||||
|
db.Ctx,
|
||||||
|
sqlcgen.ListPendingTaggingItemsByScoreParams{
|
||||||
|
LibraryID: 0,
|
||||||
|
StatusFilter: "all",
|
||||||
|
RowLimit: 50,
|
||||||
|
RowOffset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list by score: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed := make(map[string]bool, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
listed[it.GroupKey] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !listed["g-partial"] {
|
||||||
|
t.Error("expected g-partial (one untagged file left) to be listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if listed["g-done"] {
|
||||||
|
t.Error("expected g-done (nothing left to tag) to be filtered out")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !listed["g-applied"] {
|
||||||
|
t.Error("expected g-applied (confirmed by a real apply) to stay listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := db.Queries.GetNextPendingTaggingItem(
|
||||||
|
db.Ctx,
|
||||||
|
sqlcgen.GetNextPendingTaggingItemParams{LibraryID: 0, AfterGroupKey: ""},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("next pending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor must not stop on a folder the sidebar no longer
|
||||||
|
// shows: alphabetically g-done sorts before g-partial, so a
|
||||||
|
// missing predicate here surfaces as "next" landing on nothing.
|
||||||
|
if next.GroupKey != "g-partial" {
|
||||||
|
t.Errorf("next pending = %q, want %q", next.GroupKey, "g-partial")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list
|
// TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list
|
||||||
@@ -333,6 +487,82 @@ func TestListPendingFolders_SampleFilePathUsesIndex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpsertTaggingItemOnTrackAdd_AlbumArtistTracksConsensus guards
|
||||||
|
// against regressing to first-write-wins: a folder's album_artist
|
||||||
|
// must reflect whether every contributing track actually agreed, not
|
||||||
|
// just whichever track happened to be scanned first. IsMixedBag
|
||||||
|
// (backend/autotag) trusts a non-empty album_artist unconditionally,
|
||||||
|
// so a stale first-seen value here would silently defeat mixed-bag
|
||||||
|
// detection for the rest of the folder's tracks.
|
||||||
|
func TestUpsertTaggingItemOnTrackAdd_AlbumArtistTracksConsensus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
upsert := func(t *testing.T, groupKey, albumArtist string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if err := db.Queries.UpsertTaggingItemOnTrackAdd(
|
||||||
|
db.Ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
|
||||||
|
GroupKey: groupKey,
|
||||||
|
LibraryID: 0,
|
||||||
|
AlbumName: "",
|
||||||
|
AlbumArtist: albumArtist,
|
||||||
|
DiscNumber: 0,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("upsert: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumArtist := func(t *testing.T, groupKey string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return scalarString(t, db,
|
||||||
|
`SELECT album_artist FROM tagging_items WHERE group_key = ?`, groupKey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every contributing track agrees: the value sticks.
|
||||||
|
upsert(t, "agree", "Artist One")
|
||||||
|
upsert(t, "agree", "Artist One")
|
||||||
|
upsert(t, "agree", "Artist One")
|
||||||
|
|
||||||
|
if got := albumArtist(t, "agree"); got != "Artist One" {
|
||||||
|
t.Errorf("unanimous album_artist = %q, want %q", got, "Artist One")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later track disagrees: the value must clear, not freeze on
|
||||||
|
// whichever track was scanned first.
|
||||||
|
upsert(t, "disagree", "Artist One")
|
||||||
|
upsert(t, "disagree", "Artist Two")
|
||||||
|
upsert(t, "disagree", "Artist One")
|
||||||
|
|
||||||
|
if got := albumArtist(t, "disagree"); got != "" {
|
||||||
|
t.Errorf("disagreeing album_artist = %q, want empty (no consensus)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untagged track (empty AlbumArtist) must not overwrite an
|
||||||
|
// established consensus value, nor count as disagreement.
|
||||||
|
upsert(t, "partial-tags", "Artist One")
|
||||||
|
upsert(t, "partial-tags", "")
|
||||||
|
upsert(t, "partial-tags", "Artist One")
|
||||||
|
|
||||||
|
if got := albumArtist(t, "partial-tags"); got != "Artist One" {
|
||||||
|
t.Errorf("partial-tags album_artist = %q, want %q", got, "Artist One")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once cleared by disagreement, a later untagged track must not
|
||||||
|
// resurrect a stale value.
|
||||||
|
upsert(t, "cleared-stays-cleared", "Artist One")
|
||||||
|
upsert(t, "cleared-stays-cleared", "Artist Two")
|
||||||
|
upsert(t, "cleared-stays-cleared", "")
|
||||||
|
|
||||||
|
if got := albumArtist(t, "cleared-stays-cleared"); got != "" {
|
||||||
|
t.Errorf("cleared-stays-cleared album_artist = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
|
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -374,9 +604,7 @@ func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
|
|||||||
// helpers
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// seedAF inserts a minimal recording + audio_files pair and returns
|
// seedAF inserts one file and returns its audio_files id.
|
||||||
// the new audio_files id. All FK-satisfying rows (artist_credit,
|
|
||||||
// recordings, file_types[0]) are created inline.
|
|
||||||
func seedAF(
|
func seedAF(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *database.DB,
|
db *database.DB,
|
||||||
@@ -386,49 +614,32 @@ func seedAF(
|
|||||||
) int64 {
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Test Artist")
|
return database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
if err != nil {
|
FilePath: filePath,
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
Title: recordingName,
|
||||||
}
|
RecordingMBID: recordingMBID,
|
||||||
|
DiscNumber: discNumber,
|
||||||
|
LibraryID: libraryID,
|
||||||
|
LengthMs: 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
rec, err := db.Queries.CreateRecordingFull(
|
// seedGroupFile attaches one file to a tagging group with an explicit
|
||||||
db.Ctx,
|
// tag_status - the thing the queue's "is there anything left to tag
|
||||||
sqlcgen.CreateRecordingFullParams{
|
// here" predicate reads.
|
||||||
Name: recordingName,
|
func seedGroupFile(
|
||||||
ArtistCreditID: ac.ID,
|
t *testing.T,
|
||||||
},
|
db *database.DB,
|
||||||
)
|
groupKey, filePath, tagStatus string,
|
||||||
if err != nil {
|
) {
|
||||||
t.Fatalf("create recording: %v", err)
|
t.Helper()
|
||||||
}
|
|
||||||
|
|
||||||
if recordingMBID != "" {
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
if _, err := db.ExecContext(
|
FilePath: filePath,
|
||||||
`UPDATE recordings SET mbid = ? WHERE id = ?`,
|
Title: filePath,
|
||||||
recordingMBID, rec.ID,
|
GroupKey: groupKey,
|
||||||
); err != nil {
|
TagStatus: tagStatus,
|
||||||
t.Fatalf("set mbid: %v", err)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
af, err := db.Queries.CreateAudioFile(
|
|
||||||
db.Ctx,
|
|
||||||
sqlcgen.CreateAudioFileParams{
|
|
||||||
FilePath: filePath,
|
|
||||||
LengthMilliseconds: 1000,
|
|
||||||
FileTypeID: 0,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
Basename: filePath,
|
|
||||||
LibraryID: libraryID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = discNumber // reserved for callers that want specific disc values
|
|
||||||
|
|
||||||
return af.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedTaggingItem(
|
func seedTaggingItem(
|
||||||
|
|||||||
+201
-14
@@ -2,7 +2,10 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
@@ -10,16 +13,30 @@ import (
|
|||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestDB returns an in-memory SQLite database that mirrors the
|
// NewTestDB returns an in-memory SQLite database shaped like the real
|
||||||
// production setup (PRAGMAs + all migrations). The database is
|
// one: a single-writer handle and a separate query-only read pool over
|
||||||
// automatically closed when the test completes via t.Cleanup.
|
// the same database, built by the same applySchema production uses.
|
||||||
|
//
|
||||||
|
// The two handles matter. The test DB used to be one shared connection
|
||||||
|
// with readDB nil, so `reader()` returned the *writer* — which is how a
|
||||||
|
// query-shaped write (`INSERT ... RETURNING` through QueryContext)
|
||||||
|
// passed every test and then failed for a user with "attempt to write a
|
||||||
|
// readonly database". `TestNoWritesOnTheReadPool` had to walk the source
|
||||||
|
// tree to catch what a test could not.
|
||||||
|
//
|
||||||
|
// A shared-cache in-memory database is what lets two handles see one
|
||||||
|
// database; the connections are capped the way production caps them.
|
||||||
|
// It is closed when the test completes via t.Cleanup.
|
||||||
func NewTestDB(t *testing.T) *DB {
|
func NewTestDB(t *testing.T) *DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := sql.Open(
|
// A per-test name, so parallel tests do not share a database.
|
||||||
"sqlite",
|
dsn := fmt.Sprintf(
|
||||||
":memory:?_busy_timeout=5000&_journal_mode=WAL",
|
"file:testdb%d?mode=memory&cache=shared&_pragma=busy_timeout(5000)",
|
||||||
|
testDBSeq.Add(1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not open test database: %v", err)
|
t.Fatalf("could not open test database: %v", err)
|
||||||
}
|
}
|
||||||
@@ -47,21 +64,32 @@ func NewTestDB(t *testing.T) *DB {
|
|||||||
t.Fatalf("could not insert test library: %v", err)
|
t.Fatalf("could not insert test library: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queries := sqlcgen.New(db)
|
readDB, err := sql.Open("sqlite", dsn+"&_pragma=query_only(true)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not open test read pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
readDB.SetMaxOpenConns(readPoolConns)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = readDB.Close()
|
||||||
|
_ = db.Close()
|
||||||
|
})
|
||||||
|
|
||||||
return &DB{
|
return &DB{
|
||||||
db: db,
|
db: db,
|
||||||
Ctx: ctx,
|
readDB: readDB,
|
||||||
// The in-memory test DB shares one connection, so reads and
|
Ctx: ctx,
|
||||||
// writes use the same handle; ReadQueries aliases Queries.
|
Queries: sqlcgen.New(db),
|
||||||
Queries: queries,
|
ReadQueries: sqlcgen.New(readDB),
|
||||||
ReadQueries: queries,
|
|
||||||
logger: slog.Default(),
|
logger: slog.Default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testDBSeq names each test database uniquely, so parallel tests do not
|
||||||
|
// share one through the shared cache.
|
||||||
|
var testDBSeq atomic.Int64
|
||||||
|
|
||||||
// NewTestDBWithLibrary returns a test DB with a library row
|
// NewTestDBWithLibrary returns a test DB with a library row
|
||||||
// pre-inserted. Returns the DB and the library ID.
|
// pre-inserted. Returns the DB and the library ID.
|
||||||
func NewTestDBWithLibrary(
|
func NewTestDBWithLibrary(
|
||||||
@@ -85,3 +113,162 @@ func NewTestDBWithLibrary(
|
|||||||
|
|
||||||
return db, lib.ID
|
return db, lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTrack describes one file to seed into a test database. Zero
|
||||||
|
// values are fine: only FilePath is required.
|
||||||
|
type TestTrack struct {
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
ArtistMBID string
|
||||||
|
Album string
|
||||||
|
AlbumArtist string
|
||||||
|
AlbumMBID string
|
||||||
|
RecordingMBID string
|
||||||
|
Genres []string
|
||||||
|
TrackNumber int64
|
||||||
|
DiscNumber int64
|
||||||
|
TotalTracks int64
|
||||||
|
Year int64
|
||||||
|
LengthMs int64
|
||||||
|
LibraryID int64
|
||||||
|
PlayCount int64
|
||||||
|
TagStatus string
|
||||||
|
GroupKey string
|
||||||
|
// SkipSearchIndex leaves the file out of the FTS index, for the
|
||||||
|
// tests that assert on a rebuild putting it there.
|
||||||
|
SkipSearchIndex bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertTestTrack seeds one file, with the artist and album its tags
|
||||||
|
// name, and returns the audio_files id.
|
||||||
|
//
|
||||||
|
// There is one of these because there is one shape. Twenty test files
|
||||||
|
// used to carry their own seeder, each inserting a recording, an artist
|
||||||
|
// credit, a credit-artist link and a release-group link in the right
|
||||||
|
// order - which is exactly the ceremony the schema change removed, and
|
||||||
|
// exactly why every one of those seeders was subtly different.
|
||||||
|
func InsertTestTrack(t *testing.T, db *DB, tr TestTrack) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if tr.Title == "" {
|
||||||
|
tr.Title = "Test Track"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.Artist == "" {
|
||||||
|
tr.Artist = "Test Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.TagStatus == "" {
|
||||||
|
tr.TagStatus = "untagged"
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.Queries.UpsertArtist(db.Ctx, sqlcgen.UpsertArtistParams{
|
||||||
|
Name: tr.Artist,
|
||||||
|
Mbid: nullString(tr.ArtistMBID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed artist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistID := sql.NullInt64{Int64: artist.ID, Valid: true}
|
||||||
|
albumID := sql.NullInt64{}
|
||||||
|
|
||||||
|
if tr.Album != "" {
|
||||||
|
credit := tr.AlbumArtist
|
||||||
|
if credit == "" {
|
||||||
|
credit = tr.Artist
|
||||||
|
}
|
||||||
|
|
||||||
|
album, albErr := db.Queries.UpsertAlbum(db.Ctx, sqlcgen.UpsertAlbumParams{
|
||||||
|
Name: tr.Album,
|
||||||
|
ArtistCredit: credit,
|
||||||
|
ArtistID: artistID,
|
||||||
|
Year: nullInt64(tr.Year),
|
||||||
|
})
|
||||||
|
if albErr != nil {
|
||||||
|
t.Fatalf("seed album: %v", albErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.AlbumMBID != "" {
|
||||||
|
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
|
||||||
|
Mbid: nullString(tr.AlbumMBID),
|
||||||
|
ID: album.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed album mbid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumID = sql.NullInt64{Int64: album.ID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
af, err := db.Queries.CreateAudioFile(db.Ctx, sqlcgen.CreateAudioFileParams{
|
||||||
|
FilePath: tr.FilePath,
|
||||||
|
LibraryID: tr.LibraryID,
|
||||||
|
LengthMilliseconds: tr.LengthMs,
|
||||||
|
Title: tr.Title,
|
||||||
|
ArtistCredit: tr.Artist,
|
||||||
|
ArtistID: artistID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
TrackNumber: nullInt64(tr.TrackNumber),
|
||||||
|
DiscNumber: nullInt64(tr.DiscNumber),
|
||||||
|
TotalTracks: nullInt64(tr.TotalTracks),
|
||||||
|
Year: nullInt64(tr.Year),
|
||||||
|
RecordingMbid: nullString(tr.RecordingMBID),
|
||||||
|
Basename: filepath.Base(tr.FilePath),
|
||||||
|
GroupKey: tr.GroupKey,
|
||||||
|
TagStatus: tr.TagStatus,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed audio file %q: %v", tr.FilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range tr.Genres {
|
||||||
|
g, gErr := db.Queries.UpsertGenre(db.Ctx, name)
|
||||||
|
if gErr != nil {
|
||||||
|
t.Fatalf("seed genre %q: %v", name, gErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Queries.LinkFileGenre(db.Ctx, sqlcgen.LinkFileGenreParams{
|
||||||
|
AudioFileID: af.ID,
|
||||||
|
GenreID: g.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed file genre: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.PlayCount > 0 {
|
||||||
|
if _, err := db.db.ExecContext(db.Ctx,
|
||||||
|
"UPDATE audio_files SET play_count = ? WHERE id = ?",
|
||||||
|
tr.PlayCount, af.ID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed play count: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tr.SkipSearchIndex {
|
||||||
|
if err := db.InsertSearchIndex(
|
||||||
|
af.ID, tr.FilePath, tr.Title, tr.Artist, tr.Album,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed search index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return af.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(v string) sql.NullString {
|
||||||
|
if v == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullString{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt64(v int64) sql.NullInt64 {
|
||||||
|
if v == 0 {
|
||||||
|
return sql.NullInt64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullInt64{Int64: v, Valid: true}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user