feat(database): shape the library like files, and shrink the catalog
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s

Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
This commit is contained in:
2026-08-16 13:58:15 -04:00
co-authored by Claude Opus 5
parent 1128881e8d
commit e7748f1fd5
208 changed files with 10944 additions and 12104 deletions
@@ -63,5 +63,24 @@ Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead
address on purpose, so no seed depends on what the explore artifact
server happened to be serving.
Rebuild a seed after any schema change, or the restored database is
migrated on open in a way the seed's author never saw.
Rebuild a seed after any schema change. Nothing migrates a restored
database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed
keeps its old columns, the app starts, and the first query dies on
`no such column`.
**Restoring the seed does not disable the artifact fetch — only
*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone,
so on a developer machine the restored app immediately downloads and
imports the real ~1.1M-row catalog, through the one writer connection,
while whatever you started it for is running. A full `make e2e` against
that reported **14 failures** that were all contention; the same suite
against the same seed with
```bash
YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default
```
is the configuration CI runs (`ci.yml` sets exactly that address) and is
what to use before believing a failure. The tell is in `.dev/app.log`
an import logging progress — and in how the failures look: timeouts
spread across unrelated specs rather than one surface being wrong.
@@ -1,66 +1,76 @@
# Changing the database schema
The reasoning — why there are two files, what the old 48-step migration
chain got wrong, and when squashing is legitimate — is in `CLAUDE.md`
under *Backend packages → database*. Read it once. This is the
checklist.
The reasoning — why the local library is shaped like files rather than
like MusicBrainz, and what the metadata tables cost before they went —
is in `CLAUDE.md` under *Backend packages → database*. Read it once.
This is the checklist.
**A brand-new table needs one file, not two.** The rule below is about
a *column added to a table that already exists*. `applySchema` runs
every file in `sql/schemas/` on every open, so a
`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a
migration for it would be a second description of the same table — the
thing the third rule forbids. Its indexes go in the schema file too,
because the column and the index arrive together.
**There is one description of the schema and no migration chain.**
`sql/schemas/*.sql` declares the current shape; `applySchema` runs every
file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent.
`sql/migrations/`, `applyMigrations` and `schema_migrations` were
squashed away with plan 013. So:
**Adding a table or a column is one edit to one file.**
```bash
make generate # sqlc + templ
go test ./backend/database/ ./backend/datamap/
make test
```
A new table has a second gate: **`backend/datamap`**. Add an entry
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
wants an explicit exemption with a note, because authored data is what
a user cannot get back.
wants an explicit exemption with a note, because authored data is what a
user cannot get back. If a *column* holds a different Kind from its
table (an authored flag on an owned projection, a fetched value beside a
tag-derived one), say so in the entry's note; `audio_files` and `lyrics`
are the worked examples.
Adding a **column** to an existing table needs **two** files, not one:
**Existing databases are not migrated.** Nothing upgrades a database
from an older shape — delete your dev `YJ_HOME` and rescan, and rebuild
any seed you rely on (`make sandbox-seed NAME=default`). Revisit this
once real user databases exist in the wild.
1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT
EXISTS`, the literal target shape, what sqlc reads and what a fresh
install gets verbatim. Add the new column **last** in the
`CREATE TABLE`.
2. **`backend/database/sql/migrations/NNNN_description.sql`** — the
`ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an
existing database to the same shape. Schema files are a no-op against
a table that already exists, so without this an upgrade never gets
the column.
**A stale one fails at the first query, not at open**, which is worth
knowing before you read the error. `applySchema` is
`CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns
and gains nothing; the app then starts fine and dies on
`no such column: title`. Every tier that does not *run the app* — unit
tests, `make ui-test`, `tsc` — is green while this is true, because
they build their database from the current schema. `make e2e` and
`make dev` are the two that will tell you, and only after the seed has
been rebuilt.
Then:
## The four ways this goes wrong
```bash
make generate # sqlc + templ
go test ./backend/database/ # migration + column-order tests
make test
```
- **A query file must be ASCII.** sqlc's parameter rewriter works on
byte offsets, so a single non-ASCII character in a *query* comment
(an em dash, a curly quote) shifts every placeholder and generates
garbage like `SELECid` — a parse error a long way from its cause.
Schema files are not rewritten and may contain anything.
- **A slice and a named parameter do not compose.** `sqlc.slice`
expands to N placeholders, but `sqlc.arg` is numbered independently,
so the two in one query bind the wrong values —
`GetFilePathsByAlbums([1,2], 0)` read album id 2 as the library id.
Where a query needs both, return the column and filter in Go.
- **A write wearing a query's shape still needs the writer.**
`QueryContext`/`QueryRow` route to the query-only read pool, so an
`INSERT ... RETURNING` through one fails at runtime with "attempt to
write a readonly database (8)". Use `ExecContext`, or
`QueryRowWriter`. `TestNoWritesOnTheReadPool` walks the tree for it.
- **A view is dropped and recreated.** `CREATE VIEW IF NOT EXISTS`
no-ops against a database holding the old definition, so
`track_metadata.sql` opens with `DROP VIEW IF EXISTS`.
Rebuild any seed you rely on (`make sandbox-seed NAME=default`) and
delete your own dev `YJ_HOME` if you want to see the fresh-install path
rather than the migrated one.
## The three ways this goes wrong
- **Column order must match between the two paths.** `ADD COLUMN`
always appends, so a migrated column declared anywhere but last in
`CREATE TABLE` leaves fresh and upgraded installs disagreeing on
order — and sqlc binds `SELECT *` positionally, so one of them
silently reads the wrong field.
`TestMigrations_ColumnOrderMatchesFreshInstall` is the regression test.
- **Do not put an index on a migrated column in `sql/schemas/`.**
Schema files run *before* migrations, against a database that may not
have the column yet, and the predicate fails. Declare the index in the
migration, after the `ALTER TABLE`.
- **Do not add a third description of the schema anywhere.** A
migration's `ADD COLUMN` failing with "duplicate column name" against
an already-current database is expected and tolerated, not an error to
route around.
## Where things go
New queries go in `backend/database/sql/queries/`; generated Go lands in
`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests
use `database.NewTestDB(t)`, built by the same `applySchema` production
uses, so the two cannot diverge.
`backend/database/sql/sqlcgen/`, which is never edited by hand. Anything
returning a track selects from the `track_metadata` view rather than
re-joining — that is why there is one row type and one mapper.
Tests use `database.NewTestDB(t)`, built by the same `applySchema`
production uses, and seed rows with `database.InsertTestTrack(t, db,
database.TestTrack{...})` rather than assembling inserts by hand.
+40
View File
@@ -2354,3 +2354,43 @@ Five more things worth keeping:
child does not prove the icon inside it is `pointer-events: none`.
Both were checked with a real mouse (`mousemove`/`mousedown`/
`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.
@@ -0,0 +1,639 @@
# 013 — The database audit
**Status:** **complete** (2026-08-16). R1R10 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) | ~70100 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: R5R10
| | 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.
+265 -86
View File
@@ -223,80 +223,101 @@ rather than renaming them.
- `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.
**Schema changes need two things, not one.** `sql/schemas/*.sql` is
`CREATE ... IF NOT EXISTS` and is what sqlc reads — it's the single
source of truth for "what the schema looks like right now", and it's
what a fresh install gets verbatim. But it's a no-op against a
database that already has the table, so an existing install needs a
matching file in `sql/schemas/../migrations/` (e.g.
`NNNN_description.sql`, `ALTER TABLE ... ADD COLUMN ...` /
`CREATE INDEX ...`) to actually reach that shape. Both run on every
open, migrations after schema files, tracked in `schema_migrations`
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.
**The schema is one description, and there is no migration chain.**
`sql/schemas/*.sql` is `CREATE ... IF NOT EXISTS`, declares the
current shape of every table, and is what sqlc reads and what an
install gets verbatim. That is the whole mechanism: `sql/migrations/`,
`applyMigrations` and `schema_migrations` were squashed away with the
file-shaped rewrite (plan 013). A schema change is one edit to one
file plus `make generate`. Reintroducing a chain means reintroducing
the drift it caused before — `sql/schemas/` and the migrations
disagreed, and sqlc generated against the stale one.
A few things that bite if forgotten:
- **Column order must match between the two paths.** `ALTER TABLE
ADD COLUMN` always appends at the end, so a migrated column must
also be declared *last* in the `CREATE TABLE` in `sql/schemas/`
— otherwise a fresh install and an upgraded install disagree on
column order, and a `SELECT *` query (sqlc binds those
positionally) silently reads the wrong field on one of them. See
`backend/database/migrations_test.go`'s
`TestMigrations_ColumnOrderMatchesFreshInstall`, which is the
regression test for exactly this.
- **Don't put an index on a migrated column in `sql/schemas/`.**
Schema files run before migrations, against a database that may
not have that column yet — the index's predicate would fail
(this is precisely the bug an earlier session shipped and a user
hit at `make sandbox`). Declare it in the migration file instead,
after the `ALTER TABLE` that adds the column.
- This project **had** a 48-step migration chain before and tore it
out (see `.planning/NOTES.md`, "No migration chain") because
`sql/schemas/` had drifted from what the migrations actually
produced and sqlc silently generated against the stale version.
The design here avoids that by keeping `sql/schemas/` as the
literal target shape (not a hand-maintained description of it)
and letting migrations replay tolerantly against it — but the
same drift is possible again if a schema change ships without
updating both files. Don't reintroduce a *second* description of
the schema anywhere else.
**The local library is shaped like files, not like MusicBrainz.**
`audio_files` carries its own tags — title, artist credit, track and
disc numbers, year, composer, the recording MBID — and points at two
shared rows: `albums` (many files to one) and `artists` (many albums
to one). `file_genres` is the one genuine many-to-many. That is the
entire local model.
It used to be MusicBrainz's: `recordings`, `release_group_recordings`,
`artist_credit` and `artist_credit_artist` sat between a file and its
own tags. Measured on a real 25,966-file library, **every**
many-to-many that model expressed was 1:1 in the data — no recording
had two files, none belonged to two release groups, and 3 credits of
2,823 listed more than one artist. What it cost was a six-way join in
every read, a `MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine to undo fan-outs that never
happened, and a class of bugs where a metadata row **outlived the file
that created it**: retagging a file created a new recording and
abandoned the old one, so that library carried 812 orphaned
recordings, 216 release groups and 260 artists — and 129 catalog rows
that confidently claimed to be owned by files that no longer existed.
A few things that follow, and bite if forgotten:
- **Ownership is a file.** "Do I own this" is asked of `audio_files`
and nothing else — `GetFilePathsByRecordingMBIDs`,
`LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
`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.**
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
*query-only* read pool (a second `sql.DB` over the same file), so
an `INSERT ... RETURNING` issued through one fails at runtime with
"attempt to write a readonly database (8)" — which is exactly what
`CreateSmartPlaylist` did, meaning no smart playlist could be
created at all. Use `ExecContext`, or `QueryRowWriter` when the
statement really does return a row. Nothing caught this because
`NewTestDB` shares one in-memory connection and leaves `readDB`
nil, so `reader()` returns the *writer* under test and the unit
tests exercised a handle the app does not have.
`TestNoWritesOnTheReadPool` walks the tree for it, in the same
spirit as `TestNoDirectRuntimeEmits` and for the same reason — a
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.
"attempt to write a readonly database (8)". Use `ExecContext`, or
`QueryRowWriter` when the statement really does return a row.
Nothing caught this because `NewTestDB` shares one in-memory
connection and leaves `readDB` nil, so `reader()` returns the
*writer* under test. `TestNoWritesOnTheReadPool` walks the tree for
it, in the same spirit as `TestNoDirectRuntimeEmits`.
- **A new table has to say what kind of data it holds.**
`backend/datamap` is a catalogue of every table's Kind and
Lifetime, and `TestCatalogCoversSchema` fails on a table missing
from it. `TestAuthoredCascadesAreDeliberate` then makes an
*authored* table that cascades an explicit, argued exemption —
authored data is what a user cannot get back.
- **Squashing is fine pre-1.0.** While this hasn't shipped to real
users, periodically folding `sql/migrations/` into `sql/schemas/`
and deleting the migration files (then wiping your own dev/sandbox
DB) is a legitimate way to keep the migrations directory from
accumulating dev-only churn — same effect as the old "just nuke
it" workflow, opt-in instead of mandatory. Stop doing that once
real user databases exist in the wild.
authored data is what a user cannot get back. Two entries say
**MIXED KIND** and mean it: `audio_files` is an owned projection
except for `play_count`, `last_played` and `tag_status`, which are
authored; `lyrics` carries a `source` column because a lyric read
from a tag is free to rebuild and one fetched from LRCLIB is not.
- **Test data has one seeder.** `database.InsertTestTrack` inserts a
file with its artist, album and genres. Twenty test files used to
carry their own, each assembling the old FK chain in a slightly
different order.
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
- `jobs` — The registry every long-running operation reports through:
progress, pause/cancel, a global indicator and (for scans) a pause
@@ -391,6 +412,67 @@ work happens **once, centrally**, and users download the result:
(`dumpincremental.go`), and resolves artists outside the artifact's
coverage lazily on first view.
**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
@@ -453,7 +535,20 @@ 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.
Four things about the marks are load-bearing. The marks are **a table, not
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
@@ -505,6 +600,33 @@ that invents its own flat layout agrees with the bug.
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.
@@ -958,31 +1080,47 @@ 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
plays nothing (or seven tracks of forty) is worse than none.
`albumLibraryStatus()` is deliberately *not* what decides that. It is
four claims of decreasing confidence OR'd into one tick — a local album
id, the backend's cross-reference, a cached MBID match, and finally
*any single track* marked `inLibrary` — which is a fine answer to "is
any of this mine" and a useless basis for a button. `ownership()`
counts the displayed tracklist instead, whose `inLibrary` flags the
backend sets per recording MBID.
**The page asks one question, once, and it is "is there a file".**
Ownership used to be several claims of decreasing confidence OR'd
together — a local album id, the backend's cross-reference, a cached
MBID match, and finally *any single track* flagged `inLibrary`none
of which is "there is a file to play", which is why the tick could be
green on an album whose every action did nothing, and why the
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
album id is used wherever there is one, because a library-only album
has *no* recording MBIDs — its tracks are synthesised from
`GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed
lookup on an untagged library resolves to nothing and Play queues
nothing while looking entirely correct.
`GetFilePathsByRecordingMBIDs` is the catalog-only fallback and the
third member of the `GetFilePathsBy…` family: one 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**.
nothing while looking entirely correct. Those synthesised tracks carry
their `FilePath` from the same rows, so they populate `filePaths`
directly and cost no lookup at all; the batched
`GetFilePathsByRecordingMBIDs` is what answers for a *catalog*
tracklist. It is the third member of the `GetFilePathsBy…` family: one
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 is `inLibrary: true`,
so owned always equals total and "do I have all of this" had no local
answer. The album page therefore asked MusicBrainz, and
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
@@ -990,8 +1128,7 @@ 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
`release_group_recordings.total_tracks` now, 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.
@@ -1010,9 +1147,30 @@ 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.
What tags cannot 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
**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
@@ -1095,6 +1253,27 @@ 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
file paths with one `GetAlbumTracks` per album, sequentially, and every
one of the four sites doing that asked for whole track rows to read
+29 -30
View File
@@ -328,43 +328,42 @@ func (a *Applier) Apply(
func (a *Applier) syncDBMBIDs(
ctx context.Context, tr TrackApply, cand Candidate,
) error {
// Look up recording row via audio_file.
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err != nil {
return fmt.Errorf("get audio_file: %w", err)
}
if tr.CandidateTrack.MBID != "" {
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
ID: af.RecordingID,
if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
ID: tr.Local.AudioFileID,
}); err != nil {
return fmt.Errorf("set recording mbid: %w", err)
}
}
if cand.ReleaseGroupMBID != "" {
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
if err == nil && rgID > 0 {
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ID: rgID,
}); err != nil {
return fmt.Errorf("set release group mbid: %w", err)
}
if cand.ReleaseGroupMBID == "" {
return nil
}
// Stamp the release-group's original-release year too —
// this is what the tracklist / smart-playlist year rule
// surfaces by default once the user accepts a candidate.
if year := parseYear(cand.OriginalDate); year > 0 {
if err := a.q.SetReleaseGroupOriginalYear(
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
ID: rgID,
},
); err != nil {
return fmt.Errorf("set release group original year: %w", err)
}
// The album is reached through the file rather than through two
// join tables; SetFileAlbumMBID takes the file id and does the
// lookup in one statement.
if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
ID: tr.Local.AudioFileID,
}); err != nil {
return fmt.Errorf("set album mbid: %w", err)
}
// Stamp the album's original-release year too - this is what the
// tracklist and the smart-playlist year rule surface by default
// once the user accepts a candidate.
if year := parseYear(cand.OriginalDate); year > 0 {
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
if err == nil && af.AlbumID.Valid {
if err := a.q.SetAlbumOriginalYear(
ctx, sqlcgen.SetAlbumOriginalYearParams{
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
ID: af.AlbumID.Int64,
},
); err != nil {
return fmt.Errorf("set album original year: %w", err)
}
}
}
+10 -40
View File
@@ -2,7 +2,6 @@ package autotag_test
import (
"context"
"database/sql"
"log/slog"
"sync"
"testing"
@@ -87,51 +86,22 @@ func seedAudioFiles(
q := db.Queries
ctx := db.Ctx
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: "Test Album",
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
out := make([]sqlcgen.AudioFile, 0, len(paths))
for i, p := range paths {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: p,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
id := database.InsertTestTrack(t, db, database.TestTrack{
FilePath: p,
Title: p,
Artist: "Test Artist",
Album: "Test Album",
TrackNumber: int64(i + 1),
LengthMs: 100000,
GroupKey: groupKey,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: p,
LengthMilliseconds: 100000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: p,
LibraryID: 0,
GroupKey: groupKey,
TagStatus: "untagged",
})
af, err := q.GetAudioFile(ctx, id)
if err != nil {
t.Fatalf("create audio file: %v", err)
t.Fatalf("read seeded audio file: %v", err)
}
out = append(out, af)
+9 -9
View File
@@ -56,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup(
}
// ResolveLocal returns candidate releases sourced from the local
// DB's release_groups rows (filtered to those carrying an MBID)
// DB's albums (filtered to those carrying an MBID)
// whose normalized name matches the tagging item's album name.
// No network calls. Candidates carry all tracks flat; caller runs
// AlignTracks on each to produce per-track alignments.
@@ -67,7 +67,7 @@ func (r *LocalResolver) ResolveLocal(
return nil, nil
}
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName)
if err != nil {
return nil, fmt.Errorf("list local candidates: %w", err)
}
@@ -84,12 +84,12 @@ func (r *LocalResolver) ResolveLocal(
continue
}
if _, ok := byID[row.ReleaseGroupID]; !ok {
byID[row.ReleaseGroupID] = localCandidate(row)
if _, ok := byID[row.AlbumID]; !ok {
byID[row.AlbumID] = localCandidate(row)
}
tracksByID[row.ReleaseGroupID] = append(
tracksByID[row.ReleaseGroupID],
tracksByID[row.AlbumID] = append(
tracksByID[row.AlbumID],
CandidateTrack{
Position: int(row.TrackNumber),
DiscNumber: int(row.DiscNumber),
@@ -113,15 +113,15 @@ func (r *LocalResolver) ResolveLocal(
// localCandidate converts one sqlc row (minus track-level fields)
// into a Candidate shell. Track fields and alignments are filled
// in by the caller.
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate {
date := ""
if row.Year > 0 {
date = fmt.Sprintf("%04d", row.Year)
}
mbid := ""
if row.ReleaseGroupMbid.Valid {
mbid = row.ReleaseGroupMbid.String
if row.AlbumMbid.Valid {
mbid = row.AlbumMbid.String
}
return &Candidate{
+11 -64
View File
@@ -2,13 +2,11 @@ package autotag_test
import (
"context"
"database/sql"
"log/slog"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// seedAlbum drops a minimal release_group + recordings + audio_files
@@ -33,70 +31,19 @@ type seededTrack struct {
func seed(t *testing.T, db *database.DB, album seededAlbum) {
t.Helper()
ctx := db.Ctx
q := db.Queries
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert ac: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: album.albumName,
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert rg: %v", err)
}
if album.releaseMBID != "" {
if _, err := db.ExecContext(
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
album.releaseMBID, rg.ID,
); err != nil {
t.Fatalf("set rg mbid: %v", err)
}
}
for _, tr := range album.tracks {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: tr.title,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: tr.filePath,
Title: tr.title,
Artist: "Test Artist",
Album: album.albumName,
AlbumMBID: album.releaseMBID,
RecordingMBID: tr.recordingMBID,
TrackNumber: int64(tr.trackNumber),
LengthMs: tr.lengthMillis,
LibraryID: album.libraryID,
GroupKey: album.groupKey,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if tr.recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
tr.recordingMBID, rec.ID,
); err != nil {
t.Fatalf("set recording mbid: %v", err)
}
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
}); err != nil {
t.Fatalf("link rg recording: %v", err)
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: tr.filePath,
LengthMilliseconds: tr.lengthMillis,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: tr.filePath,
LibraryID: album.libraryID,
GroupKey: album.groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
}
if _, err := db.ExecContext(`
+2
View File
@@ -19,6 +19,8 @@ const applyJobPrefix = "autotag:"
// registry gets progress, cancel and the global indicator for free; the
// three subsystems that lacked them were the three that were not
// registered.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
s.mu.Lock()
s.jobsReg = reg
+32 -149
View File
@@ -3,12 +3,12 @@ package autotagservice
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"testing"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
)
// newTestService builds a Service with just enough wired up for
@@ -36,62 +36,18 @@ func newTestService(t *testing.T, db *database.DB) *Service {
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
t.Helper()
ctx := db.Ctx
q := db.Queries
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
ac, err := q.UpsertArtistCredit(ctx, artist)
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: title,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: filePath,
Title: title,
Artist: artist,
Album: album,
AlbumArtist: albumArtist,
TrackNumber: int64(trackNum),
LengthMs: 200000,
LibraryID: libraryID,
GroupKey: groupKey,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if album != "" {
albumArtistAC, err := q.UpsertArtistCredit(ctx, albumArtist)
if err != nil {
t.Fatalf("upsert album artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: album,
AlbumArtistCreditID: sql.NullInt64{Int64: albumArtistAC.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert release group: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(
ctx,
sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
},
); err != nil {
t.Fatalf("link release group recording: %v", err)
}
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: filePath,
LengthMilliseconds: 200000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: filePath,
LibraryID: libraryID,
GroupKey: groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
}
addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1)
@@ -113,58 +69,22 @@ func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryI
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
t.Helper()
ctx := db.Ctx
q := db.Queries
ac, err := q.UpsertArtistCredit(ctx, "The Beatles")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
Name: "Abbey Road",
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
})
if err != nil {
t.Fatalf("upsert release group: %v", err)
}
titles := []string{"Come Together", "Something", "Maxwell's Silver Hammer", "Oh! Darling"}
for i, title := range titles {
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
Name: title,
ArtistCreditID: ac.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} {
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1),
Title: title,
Artist: "The Beatles",
Album: "Abbey Road",
TrackNumber: int64(i + 1),
LengthMs: 200000,
LibraryID: libraryID,
GroupKey: groupKey,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: rg.ID,
RecordingID: rec.ID,
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
}); err != nil {
t.Fatalf("link release group recording: %v", err)
}
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: groupKey + "/" + title + ".mp3",
LengthMilliseconds: 200000,
FileTypeID: 0,
RecordingID: rec.ID,
Basename: title + ".mp3",
LibraryID: libraryID,
GroupKey: groupKey,
TagStatus: "untagged",
}); err != nil {
t.Fatalf("create audio file: %v", err)
}
}
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
VALUES (?, ?, 4, 'Abbey Road', 'The Beatles', 0, 'pending')
VALUES (?, ?, 3, 'Abbey Road', 'The Beatles', 0, 'pending')
`, groupKey, libraryID); err != nil {
t.Fatalf("insert tagging item: %v", err)
}
@@ -293,20 +213,11 @@ func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) {
db := database.NewTestDB(t)
if _, err := db.Queries.CreateAudioFileWithGroupKey(
db.Ctx,
sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: "/coherent/01.mp3",
FileTypeID: 0,
RecordingID: mustCreateRecording(t, db, "Track"),
Basename: "01.mp3",
LibraryID: 0,
GroupKey: "g-coherent",
TagStatus: "untagged",
},
); err != nil {
t.Fatalf("create audio file: %v", err)
}
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: "/coherent/01.mp3",
Title: "Track",
GroupKey: "g-coherent",
})
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
@@ -328,20 +239,11 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
db := database.NewTestDB(t)
// A real, live folder — must survive.
if _, err := db.Queries.CreateAudioFileWithGroupKey(
db.Ctx,
sqlcgen.CreateAudioFileWithGroupKeyParams{
FilePath: "/live/01.mp3",
FileTypeID: 0,
RecordingID: mustCreateRecording(t, db, "Track"),
Basename: "01.mp3",
LibraryID: 0,
GroupKey: "g-live",
TagStatus: "untagged",
},
); err != nil {
t.Fatalf("create audio file: %v", err)
}
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: "/live/01.mp3",
Title: "Track",
GroupKey: "g-live",
})
if _, err := db.ExecContext(`
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
@@ -385,22 +287,3 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err)
}
}
func mustCreateRecording(t *testing.T, db *database.DB, title string) int64 {
t.Helper()
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
rec, err := db.Queries.CreateRecordingFull(db.Ctx, sqlcgen.CreateRecordingFullParams{
Name: title,
ArtistCreditID: ac.ID,
})
if err != nil {
t.Fatalf("create recording: %v", err)
}
return rec.ID
}
+34 -10
View File
@@ -12,7 +12,15 @@ import (
// PathPrefix is the URL path prefix for cover art served by the asset handler.
const PathPrefix = "/covers/"
// URLs holds the resolved URL paths for all cover art size variants.
// URLs holds the resolved URL paths for a cover's size variants.
//
// Original is the largest variant kept, which is the Large one: the
// full-resolution image is no longer stored. It was 1,134 MB of a
// 1.4 GB covers directory on a real 2,057-album library against 110 MB
// for all three rendered tiers, and nothing rendered it - the grid caps
// at 350 px and the largest tier is 400. The field keeps its name
// because it is what a caller means by "the cover", and the bytes it
// came from are still in the audio file if a bigger one is ever wanted.
type URLs struct {
Original string
Small string
@@ -36,25 +44,41 @@ func CoversDir() (string, error) {
return filepath.Join(dataDir, dirName), nil
}
// SizedFilename derives a sized-variant filename from an original cover art
// filename and a size suffix.
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
func SizedFilename(originalFilename, suffix string) string {
ext := filepath.Ext(originalFilename)
name := strings.TrimSuffix(originalFilename, ext)
// Suffixes are the size variants a cover is stored as, largest last.
var Suffixes = []string{"_sm", "_md", "_lg"}
return name + suffix + ".jpg"
// SizedFilename derives a sized-variant filename from a cover art
// filename and a size suffix. The input may itself be a variant, so
// its suffix is stripped first: SizedFilename("a1b2_lg.jpg", "_sm")
// and SizedFilename("a1b2.jpg", "_sm") both return "a1b2_sm.jpg".
func SizedFilename(filename, suffix string) string {
return BaseName(filename) + suffix + ".jpg"
}
// BaseName strips the extension and any size suffix from a cover art
// filename, leaving the content hash that identifies the cover.
func BaseName(filename string) string {
name := strings.TrimSuffix(filename, filepath.Ext(filename))
for _, suffix := range Suffixes {
if strings.HasSuffix(name, suffix) {
return strings.TrimSuffix(name, suffix)
}
}
return name
}
// ResolveURLs converts a cover art filesystem path into URL paths
// for the original and all size variants (small, medium, large).
func ResolveURLs(filesystemPath string) URLs {
base := filepath.Base(filesystemPath)
large := PathPrefix + SizedFilename(base, "_lg")
return URLs{
Original: PathPrefix + base,
Original: large,
Small: PathPrefix + SizedFilename(base, "_sm"),
Medium: PathPrefix + SizedFilename(base, "_md"),
Large: PathPrefix + SizedFilename(base, "_lg"),
Large: large,
}
}
+6 -4
View File
@@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path string
name string
path string
// Original is the largest kept variant: the full-resolution
// image is not stored (see URLs).
wantOrig string
wantSm string
wantMd string
@@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) {
{
name: "absolute path",
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
wantOrig: "/covers/a1b2c3d4.jpg",
wantOrig: "/covers/a1b2c3d4_lg.jpg",
wantSm: "/covers/a1b2c3d4_sm.jpg",
wantMd: "/covers/a1b2c3d4_md.jpg",
wantLg: "/covers/a1b2c3d4_lg.jpg",
@@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) {
{
name: "bare filename",
path: "abcdef01.png",
wantOrig: "/covers/abcdef01.png",
wantOrig: "/covers/abcdef01_lg.jpg",
wantSm: "/covers/abcdef01_sm.jpg",
wantMd: "/covers/abcdef01_md.jpg",
wantLg: "/covers/abcdef01_lg.jpg",
+12 -181
View File
@@ -5,13 +5,10 @@ import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"log/slog"
"path"
"sort"
"strconv"
"strings"
_ "modernc.org/sqlite" // Register sqlite driver.
@@ -26,9 +23,6 @@ import (
//go:embed sql/schemas/*.sql
var schemas embed.FS
//go:embed sql/migrations/*.sql
var migrations embed.FS
// DB wraps the SQLite database connection and queries.
//
// Two handles back a single database file. db is the single-writer
@@ -301,20 +295,19 @@ func (d *DB) ResumeExploreIndexFTS() error {
return nil
}
// applySchema creates the full schema on a fresh database and brings
// an existing one up to date via sql/migrations.
// applySchema creates the full schema.
//
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS,
// so on a genuinely new database they create every table already at
// its current, latest shape — that's the fast path new installs
// take. A database that already has an older shape (e.g. a
// tagging_items missing a column a later build added) needs the gap
// closed, which IF NOT EXISTS can't do: it silently no-ops on a
// table that already exists, columns and all. sql/migrations holds
// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.)
// for exactly that gap, tracked in schema_migrations so each applies
// at most once — see applyMigrations for how a fresh database's
// already-current tables tolerate replaying them anyway.
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS and
// declare the current, latest shape of every table — so running them
// against a fresh database produces exactly that shape, and running
// them against a database already at that shape does nothing. That is
// the whole mechanism; there is no migration chain and no
// schema_migrations table.
//
// There was one, and it was squashed (see .planning/plans/013): a chain
// only earns its keep once real user databases exist in the wild, and
// until then it is a second description of the schema that can drift
// from the first — which this project has already been bitten by once.
func applySchema(ctx context.Context, db *sql.DB) error {
dirEntries, err := schemas.ReadDir("sql/schemas")
if err != nil {
@@ -344,171 +337,9 @@ func applySchema(ctx context.Context, db *sql.DB) error {
return fmt.Errorf("could not create explore FTS triggers: %w", err)
}
if err := applyMigrations(ctx, db); err != nil {
return fmt.Errorf("could not apply migrations: %w", err)
}
// The download subsystem's Want/Request rename reuses table names
// (download_requests names a different table before and after), so
// it cannot be a plain sql/migrations file the way an ADD COLUMN
// migration can; see download_rename_migration.go for why.
if err := migrateDownloadRename(ctx, db); err != nil {
return fmt.Errorf("could not migrate download rename: %w", err)
}
return nil
}
// schemaMigrationsTable tracks which sql/migrations files have run,
// by their leading numeric prefix.
const schemaMigrationsTable = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`
// applyMigrations runs every sql/migrations file not yet recorded in
// schema_migrations, in filename order (numeric prefix), one
// statement at a time.
//
// Every migration runs on EVERY database, fresh or old — there is no
// "skip on fresh install" branch. A fresh database's tables already
// carry a migration's effect (sql/schemas declares the target shape
// directly), so its statements are expected to sometimes be no-ops
// there: "duplicate column name" from an ALTER TABLE ADD COLUMN is
// tolerated and treated as "already applied", the same way
// createExploreIndexFTSTriggers tolerates "already exists". Any
// other error is fatal. This is deliberately simpler than detecting
// "is this database fresh" — every migration converges both a fresh
// and an upgraded database to the identical final schema (including
// column order — ALTER TABLE ADD COLUMN always appends at the end,
// so sql/schemas must declare a migrated column last too; see the
// comment on tagging_items.sql and the regression test in
// migrations_test.go).
func applyMigrations(ctx context.Context, db *sql.DB) error {
if _, err := db.ExecContext(ctx, schemaMigrationsTable); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
dirEntries, err := migrations.ReadDir("sql/migrations")
if err != nil {
return fmt.Errorf("could not read migrations directory: %w", err)
}
sort.Slice(dirEntries, func(i, j int) bool {
return dirEntries[i].Name() < dirEntries[j].Name()
})
for _, dirEntry := range dirEntries {
if dirEntry.IsDir() {
continue
}
version, err := migrationVersion(dirEntry.Name())
if err != nil {
return err
}
applied, err := migrationApplied(ctx, db, version)
if err != nil {
return err
}
if applied {
continue
}
filePath := path.Join("sql/migrations", dirEntry.Name())
sqlContent, err := fs.ReadFile(migrations, filePath)
if err != nil {
return fmt.Errorf("could not read file %s: %w", filePath, err)
}
if err := execMigrationStatements(ctx, db, string(sqlContent)); err != nil {
return fmt.Errorf("error executing migration %s: %w", dirEntry.Name(), err)
}
if _, err := db.ExecContext(
ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, version,
); err != nil {
return fmt.Errorf("record migration %d applied: %w", version, err)
}
}
return nil
}
// execMigrationStatements runs a migration file one statement at a
// time — NOT as one multi-statement Exec — so that one statement
// being a tolerable no-op (ALTER TABLE ADD COLUMN on a fresh
// database) doesn't abort the statements after it in the same file
// (e.g. a trailing CREATE INDEX that a fresh database still needs,
// since sql/schemas deliberately doesn't declare an index on a
// migrated column — see the comment on tagging_items.sql).
//
// Splitting on ";" is safe for the simple ALTER/CREATE TABLE/CREATE
// INDEX statements migrations are expected to contain; it is NOT
// safe for statements embedding a literal semicolon (e.g. a CREATE
// TRIGGER body) — write those with executeContext calls in Go
// instead of a sql/migrations file, the same way the explore FTS
// triggers already are.
func execMigrationStatements(ctx context.Context, db *sql.DB, script string) error {
for stmt := range strings.SplitSeq(script, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := db.ExecContext(ctx, stmt); err != nil {
if strings.Contains(err.Error(), "duplicate column name") {
continue
}
return fmt.Errorf("statement %q: %w", stmt, err)
}
}
return nil
}
// migrationVersion extracts the leading integer prefix from a
// migration filename, e.g. "0001_tagging_items_synthetic.sql" -> 1.
func migrationVersion(filename string) (int, error) {
prefix, _, ok := strings.Cut(filename, "_")
if !ok {
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
}
version, err := strconv.Atoi(prefix)
if err != nil {
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
}
return version, nil
}
var errMigrationFilename = errors.New(
"migration filename must start with a numeric prefix followed by '_' (e.g. 0001_description.sql)",
)
func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) {
var v int
err := db.QueryRowContext(
ctx, `SELECT version FROM schema_migrations WHERE version = ?`, version,
).Scan(&v)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("check migration %d: %w", version, err)
default:
return true, nil
}
}
// applyPRAGMAs configures SQLite connection settings. Called by both
// NewDB and NewTestDB to ensure identical behavior.
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
+34 -107
View File
@@ -312,31 +312,13 @@ func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
// Create prerequisite data: artist_credit, recording,
// audio_file.
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) " +
"VALUES (1, 'Test Song', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files "+
"(id, file_path, length_milliseconds, file_type_id, "+
"recording_id, library_id) "+
"VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)",
libID,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
InsertTestTrack(t, db, TestTrack{
FilePath: "/test/music/song.mp3",
Title: "Test Song",
Artist: "Test Artist",
LengthMs: 180000,
LibraryID: libID,
})
// Create playlist.
playlist, err := db.Queries.CreatePlaylist(
@@ -442,39 +424,18 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
// Insert prerequisite recording.
InsertTestTrack(t, db, TestTrack{
FilePath: "/test/track.mp3",
Title: "Track",
Artist: "Test",
LibraryID: libID,
})
// Insert audio file with invalid library_id - should fail FK.
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) " +
"VALUES (1, 'Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
// Insert audio file with valid library_id — should succeed.
_, err = db.ExecContext(
"INSERT INTO audio_files "+
"(id, file_path, length_milliseconds, file_type_id, "+
"recording_id, library_id) "+
"VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)",
libID,
)
if err != nil {
t.Fatalf("insert audio_file with valid library: %v", err)
}
// Insert audio file with invalid library_id — should fail FK.
_, err = db.ExecContext(
"INSERT INTO audio_files " +
"(id, file_path, length_milliseconds, file_type_id, " +
"recording_id, library_id) " +
"VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)",
"(id, file_path, length_milliseconds, file_type_id, library_id) " +
"VALUES (2, '/test/song2.mp3', 200000, 0, 999)",
)
if err == nil {
t.Error(
@@ -483,16 +444,16 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
}
// Count files by library.
count, err := db.Queries.CountAudioFilesByLibrary(
count, err := db.Queries.CountAudioFiles(
db.Ctx, libID,
)
if err != nil {
t.Fatalf("CountAudioFilesByLibrary: %v", err)
t.Fatalf("CountAudioFiles: %v", err)
}
if count != 1 {
t.Errorf(
"CountAudioFilesByLibrary = %d, want 1", count,
"CountAudioFiles = %d, want 1", count,
)
}
}
@@ -503,31 +464,13 @@ func TestTrackMetadataViewHasLibraryID(t *testing.T) {
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
// Insert prerequisites.
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) " +
"VALUES (1, 'View Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files "+
"(id, file_path, length_milliseconds, file_type_id, "+
"recording_id, library_id) "+
"VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)",
libID,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
InsertTestTrack(t, db, TestTrack{
FilePath: "/test/view.mp3",
Title: "View Track",
Artist: "View Artist",
LengthMs: 200000,
LibraryID: libID,
})
// Query track_metadata VIEW and verify library_id is present
// with the correct value.
@@ -842,29 +785,13 @@ func TestPlayHistoryTable(t *testing.T) {
// Round-trip: insert a play_history row and verify play_count update.
// First, set up test data. The test DB already has library id=0.
_, err = db.ExecContext(
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
`INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number)
VALUES (1, 'Test Track', 1, 1, 1)`,
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
`INSERT INTO audio_files
(id, file_path, length_milliseconds, file_type_id, recording_id, library_id)
VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
InsertTestTrack(t, db, TestTrack{
FilePath: "/test/play_history.mp3",
Title: "Test Track",
Artist: "Test Artist",
TrackNumber: 1,
DiscNumber: 1,
})
// Verify default play_count is 0.
var playCount int64
@@ -1,141 +0,0 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
)
// migrateDownloadRename performs the download subsystem's table rename
// for existing databases that still carry the old table names: the
// durable "I asked for this" record moved from download_wants to
// download_requests, and the one-shot search-and-grab attempt moved
// from download_requests to download_downloads (see CLAUDE.md and
// .planning/NOTES.md for the full Want->Request / Request->Download
// rename).
//
// This cannot be a plain sql/migrations file the way an ADD COLUMN
// migration is. That pattern's tolerance for "duplicate column name"
// works because a fresh database's sql/schemas pass already produces
// the identical target shape under the identical table name, so
// replaying the ALTER TABLE against it is a safe no-op. Here the name
// "download_requests" is reused for a different table before and after
// the rename, so a fresh database's schema pass creates a real, empty,
// correctly-shaped download_downloads AND a real, empty,
// correctly-shaped (new) download_requests before this ever runs.
// Blindly replaying "ALTER TABLE download_requests RENAME TO
// download_downloads" against that fresh database would rename the new,
// empty Request table into Download's place, destroying the fresh
// install rather than no-opping. Gating on whether the OLD
// download_wants table still exists — a name nothing creates or
// references once this has run — is what tells an old database and a
// fresh (or already migrated) one apart without executing anything
// destructive on the fresh path.
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
var name string
err := db.QueryRowContext(
ctx,
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&name)
switch {
case errors.Is(err, sql.ErrNoRows):
// Nothing to migrate: either a fresh install (sql/schemas
// already produced the target shape) or a database this has
// already run against.
case err != nil:
return fmt.Errorf("check for download_wants table: %w", err)
default:
if err := runDownloadRename(ctx, db); err != nil {
return err
}
}
return ensureDownloadIndexes(ctx, db)
}
// runDownloadRename performs the actual rename dance against a
// database confirmed to still have the old download_wants table.
func runDownloadRename(ctx context.Context, db *sql.DB) error {
stmts := []string{
// The schema pass already created an empty, correctly-shaped
// download_downloads placeholder under this name (it never
// existed under the old naming), which would otherwise collide
// with the rename below.
`DROP TABLE IF EXISTS download_downloads`,
// 1. Free the "download_requests" name: the old one-shot
// attempt table becomes download_downloads.
`ALTER TABLE download_requests RENAME TO download_downloads`,
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
// 2. Claim the now-free "download_requests" name for the
// durable-intent table.
`ALTER TABLE download_wants RENAME TO download_requests`,
// 3. The transfer table's FK now points at download_downloads.
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
// Named indexes survive a table/column rename attached to their
// old name, so drop them here; ensureDownloadIndexes recreates
// them under the names sql/schemas' comments describe.
`DROP INDEX IF EXISTS idx_download_requests_created`,
`DROP INDEX IF EXISTS idx_download_requests_state`,
`DROP INDEX IF EXISTS idx_download_wants_due`,
`DROP INDEX IF EXISTS idx_download_wants_entity`,
`DROP INDEX IF EXISTS idx_download_wants_parent`,
`DROP INDEX IF EXISTS idx_download_items_request`,
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin download rename migration: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("download rename migration %q: %w", stmt, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit download rename migration: %w", err)
}
return nil
}
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
// omits inline for the renamed table/columns (see
// migrateDownloadRename), under their final names. Safe to call
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
// the time this runs every column/table involved is guaranteed to be
// in its final shape on both a fresh and a migrated database.
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
stmts := []string{
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
ON download_downloads(created_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
ON download_downloads(state)`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
ON download_requests(next_try_at) WHERE state = 'wanted'`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
ON download_requests(entity, state)`,
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
ON download_requests(parent_id)`,
`CREATE INDEX IF NOT EXISTS idx_download_items_download
ON download_items(download_id)`,
}
for _, stmt := range stmts {
if _, err := db.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("ensure download index: %w", err)
}
}
return nil
}
@@ -1,365 +0,0 @@
package database
import (
"database/sql"
"errors"
"testing"
)
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
// are frozen snapshots of the download subsystem's tables exactly as
// they read before the Want/Request rename (see
// download_rename_migration.go) — i.e. what a real user's existing
// database looks like today, before upgrading to a build that includes
// this migration.
const oldDownloadRequestsDDL = `
CREATE TABLE IF NOT EXISTS download_requests (
id TEXT PRIMARY KEY,
library_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
release_mbid TEXT,
release_group_mbid TEXT,
recording_mbid TEXT,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL DEFAULT '',
expected TEXT NOT NULL DEFAULT '[]',
state TEXT NOT NULL DEFAULT 'searching'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_requests_created
ON download_requests(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_requests_state
ON download_requests(state);
`
const oldDownloadWantsDDL = `
CREATE TABLE IF NOT EXISTS download_wants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mbid TEXT NOT NULL,
entity TEXT NOT NULL
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
library_id INTEGER NOT NULL,
artist TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'future'
CHECK(scope IN ('future', 'all')),
secondary INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'wanted'
CHECK(state IN ('wanted', 'satisfied', 'paused')),
parent_id INTEGER,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
last_tried_at DATETIME,
next_try_at DATETIME,
external_ids TEXT NOT NULL DEFAULT '{}',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(mbid, library_id),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_wants_due
ON download_wants(next_try_at)
WHERE state = 'wanted';
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
ON download_wants(entity, state);
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
ON download_wants(parent_id);
`
const oldDownloadItemsDDL = `
CREATE TABLE IF NOT EXISTS download_items (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
provider_id INTEGER NOT NULL,
transport_id INTEGER,
external_id TEXT NOT NULL DEFAULT '',
candidate TEXT NOT NULL DEFAULT '{}',
state TEXT NOT NULL DEFAULT 'queued'
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
'verifying', 'tagging', 'importing',
'complete', 'cancelled', 'failed')),
staging_dir TEXT NOT NULL DEFAULT '',
bytes_done INTEGER NOT NULL DEFAULT 0,
bytes_total INTEGER NOT NULL DEFAULT 0,
imported_paths TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_download_items_live
ON download_items(state)
WHERE state NOT IN ('complete', 'cancelled', 'failed');
CREATE INDEX IF NOT EXISTS idx_download_items_request
ON download_items(request_id);
CREATE INDEX IF NOT EXISTS idx_download_items_state
ON download_items(state);
`
// seedOldDownloadSchema builds the pre-rename download tables and
// inserts one row of real data into each, standing in for a real
// user's database at the moment it upgrades.
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
t.Helper()
for _, ddl := range []string{
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
} {
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
t.Fatalf("create old download schema: %v", err)
}
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
); err != nil {
t.Fatalf("seed library: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_wants
(id, mbid, entity, library_id, artist, title, state)
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
); err != nil {
t.Fatalf("seed download_wants: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_requests
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
); err != nil {
t.Fatalf("seed download_requests: %v", err)
}
if _, err := db.ExecContext(
t.Context(),
`INSERT INTO download_items
(id, request_id, provider_id, state)
VALUES ('item-1', 'dl-1', 1, 'complete')`,
); err != nil {
t.Fatalf("seed download_items: %v", err)
}
}
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
// brand-new database produces the target shape directly and that
// migrateDownloadRename's gate (checking for the old download_wants
// table) is a no-op there — the destructive path this test guards
// against is exactly the one described in download_rename_migration.go:
// blindly replaying the rename against a fresh database's already-
// correct, empty download_requests/download_downloads tables.
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
t.Parallel()
db := openMemDB(t)
if err := applySchema(t.Context(), db); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
var name string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
table,
).Scan(&name)
if err != nil {
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
}
}
var stray string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&stray)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
}
// Both auto-download guardrail indexes sql/schemas deliberately
// omits (see ensureDownloadIndexes) must still exist.
for _, idx := range []string{
"idx_download_requests_due",
"idx_download_requests_entity",
"idx_download_requests_parent",
"idx_download_items_download",
} {
var name string
err := db.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
idx,
).Scan(&name)
if err != nil {
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
}
}
}
// TestDownloadRename_UpgradesExistingDatabase is the regression test
// for the rename itself: an old-shaped database (download_wants +
// old-style download_requests, both with real rows) must end up with
// the same table names, column names, and data a fresh install would
// have — nothing dropped, nothing silently emptied.
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
t.Parallel()
fresh := openMemDB(t)
if err := applySchema(t.Context(), fresh); err != nil {
t.Fatalf("apply schema (fresh): %v", err)
}
upgraded := openMemDB(t)
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
if err != nil {
t.Fatalf("read libraries schema: %v", err)
}
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
t.Fatalf("create libraries table: %v", err)
}
seedOldDownloadSchema(t, upgraded)
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema (upgrade path): %v", err)
}
// Column order must match a fresh install's, for the same reason
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
// sqlc's `SELECT *` binds positionally.
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
freshCols := tableColumns(t, fresh, table)
upgradedCols := tableColumns(t, upgraded, table)
if len(freshCols) != len(upgradedCols) {
t.Fatalf(
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
)
}
for i := range freshCols {
if freshCols[i] != upgradedCols[i] {
t.Errorf(
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
table,
i,
freshCols[i],
upgradedCols[i],
freshCols,
upgradedCols,
)
}
}
}
// The seeded rows survived the rename under their new names.
var (
requestMBID string
requestEntity string
)
err = upgraded.QueryRowContext(
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
).Scan(&requestMBID, &requestEntity)
if err != nil {
t.Fatalf("seeded request row missing after rename: %v", err)
}
if requestMBID != "artist-mbid" || requestEntity != "artist" {
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
}
var (
downloadRequestID sql.NullInt64
downloadAlbum string
)
err = upgraded.QueryRowContext(
t.Context(),
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
).Scan(&downloadRequestID, &downloadAlbum)
if err != nil {
t.Fatalf("seeded download row missing after rename: %v", err)
}
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
}
if downloadAlbum != "OK Computer" {
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
}
var itemDownloadID string
err = upgraded.QueryRowContext(
t.Context(),
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
).Scan(&itemDownloadID)
if err != nil {
t.Fatalf("seeded item row missing after rename: %v", err)
}
if itemDownloadID != "dl-1" {
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
}
// The old table is gone, not just emptied.
var stray string
err = upgraded.QueryRowContext(
t.Context(),
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
).Scan(&stray)
if !errors.Is(err, sql.ErrNoRows) {
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
}
// Running the whole thing again (as a second app startup would) is
// a no-op: the gate sees no download_wants table and does nothing
// further, so this must not error or duplicate anything.
if err := applySchema(t.Context(), upgraded); err != nil {
t.Fatalf("apply schema a second time: %v", err)
}
var count int
if err := upgraded.QueryRowContext(
t.Context(), `SELECT COUNT(*) FROM download_requests`,
).Scan(&count); err != nil {
t.Fatalf("count download_requests: %v", err)
}
if count != 1 {
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
}
}
+18 -7
View File
@@ -1,18 +1,26 @@
package database
import (
"crypto/sha256"
"strings"
"testing"
)
// seedExploreRow inserts one explore_index row.
//
// The catalog stores an MBID as 16 raw bytes and an entity type as a
// code (see backend/explore/mbid.go), and the column says so, so the
// label these tests use as an id is hashed into something the table
// will accept. What they actually assert on is the FTS text.
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
t.Helper()
sum := sha256.Sum256([]byte(mbid))
if _, err := db.ExecContext(`
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
VALUES ('recording', ?, ?, ?, '')
`, mbid, title, artist); err != nil {
VALUES (3 /* recording */, ?, ?, ?, x'')
`, sum[:16], title, artist); err != nil {
t.Fatalf("seed %s: %v", mbid, err)
}
}
@@ -202,7 +210,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
// A popularity refresh: an FTS column is not named at all.
if _, err := db.ExecContext(
"UPDATE explore_index SET popularity = 42 WHERE mbid = 'mbid-1'",
"UPDATE explore_index SET popularity = 42 WHERE title = ?",
"Unchanged Title",
); err != nil {
t.Fatalf("popularity update: %v", err)
}
@@ -212,8 +221,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
if _, err := db.ExecContext(`
UPDATE explore_index
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
WHERE mbid = 'mbid-1'
`); err != nil {
WHERE title = ?
`, "Unchanged Title"); err != nil {
t.Fatalf("no-op text update: %v", err)
}
@@ -237,7 +246,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
if _, err := db.ExecContext(
"UPDATE explore_index SET title = 'Corrected Title' WHERE mbid = 'mbid-2'",
"UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?",
"Original Title",
); err != nil {
t.Fatalf("rename: %v", err)
}
@@ -252,7 +262,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
// The same for the other two indexed columns.
if _, err := db.ExecContext(
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE mbid = 'mbid-2'",
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?",
"Corrected Title",
); err != nil {
t.Fatalf("artist rename: %v", err)
}
+108 -111
View File
@@ -1,15 +1,26 @@
package database
import (
"database/sql"
"errors"
"fmt"
"strings"
"unicode"
)
// toNullString treats an empty string as NULL.
func toNullString(v string) sql.NullString {
if v == "" {
return sql.NullString{}
}
return sql.NullString{String: v, Valid: true}
}
// LyricsHit is a single result from a lyric-fragment search: the
// matched recording plus enough metadata to render and play it.
// matched file plus enough metadata to render and play it.
type LyricsHit struct {
RecordingID int64
AudioFileID int64
FilePath string
LengthMilliseconds int64
Title string
@@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
return nil, nil
}
// Map the matched recording (lyrics_index.rowid == recordings.id)
// to a representative playable file via the lowest audio_files id,
// then to the track_metadata VIEW for display fields.
// lyrics_index.rowid is the audio file's id, so the hit is already
// a playable file - it used to be a recording id, which then had to
// be mapped back to "some file of that recording" by a grouped
// subquery.
//
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, `
rows, err := d.reader().QueryContext(d.Ctx, `
SELECT
r.id,
tm.id,
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.album
FROM lyrics_index li
JOIN recordings r ON r.id = li.rowid
JOIN (
SELECT recording_id, MIN(id) AS af_id
FROM audio_files
GROUP BY recording_id
) af ON af.recording_id = r.id
JOIN track_metadata tm ON tm.id = af.af_id
JOIN track_metadata tm ON tm.id = li.rowid
WHERE lyrics_index MATCH ?
ORDER BY rank
LIMIT ?
@@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
for rows.Next() {
var h LyricsHit
if err := rows.Scan(
&h.RecordingID,
&h.AudioFileID,
&h.FilePath,
&h.LengthMilliseconds,
&h.Title,
@@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
return results, nil
}
// GetRecordingLyrics returns the stored lyrics for a recording, or
// an empty string if none are stored.
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
// GetLyrics returns the stored lyrics for a file, or "" if none.
func (d *DB) GetLyrics(audioFileID int64) (string, error) {
var lyrics string
err := d.db.QueryRowContext(d.Ctx,
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
recordingID,
err := d.reader().QueryRowContext(d.Ctx,
"SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID,
).Scan(&lyrics)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("could not read recording lyrics: %w", err)
return "", fmt.Errorf("could not read lyrics: %w", err)
}
return lyrics, nil
}
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
// lyrics_index in sync (delete + reinsert the single row). Used by
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
// string clears both the column and the index entry.
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
if _, err := d.db.ExecContext(d.Ctx,
"UPDATE recordings SET lyrics = ? WHERE id = ?",
lyrics, recordingID,
); err != nil {
return fmt.Errorf("could not update recording lyrics: %w", err)
// SetLyrics writes lyrics for a file and keeps the FTS index in sync.
//
// `source` says where they came from, which is the question the old
// column could not answer: lyrics read from a USLT frame are rebuilt
// free by any rescan, and lyrics fetched from LRCLIB are network
// traffic nobody wants to repeat. Passing an empty string clears both
// the row and the index entry.
func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error {
if strings.TrimSpace(lyrics) == "" {
if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics WHERE audio_file_id = ?", audioFileID,
); err != nil {
return fmt.Errorf("could not delete lyrics: %w", err)
}
return d.upsertLyricsIndex(audioFileID, "")
}
return d.upsertLyricsIndex(recordingID, lyrics)
if _, err := d.db.ExecContext(d.Ctx, `
INSERT INTO lyrics (audio_file_id, text, source, recording_mbid)
VALUES (?, ?, ?, ?)
ON CONFLICT(audio_file_id) DO UPDATE SET
text = excluded.text,
source = excluded.source,
recording_mbid = COALESCE(excluded.recording_mbid, lyrics.recording_mbid),
fetched_at = CURRENT_TIMESTAMP
`, audioFileID, lyrics, source, toNullString(recordingMBID)); err != nil {
return fmt.Errorf("could not write lyrics: %w", err)
}
return d.upsertLyricsIndex(audioFileID, lyrics)
}
// upsertLyricsIndex refreshes a single recording's entry in the
// contentless lyrics_index. contentless_delete=1 makes the DELETE
// valid; an empty lyrics string leaves the row deleted.
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
// upsertLyricsIndex refreshes a single file's entry in the contentless
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
// lyrics string leaves the row deleted.
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
); err != nil {
return fmt.Errorf("could not delete lyrics_index row: %w", err)
}
@@ -141,7 +168,7 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
if _, err := d.db.ExecContext(d.Ctx,
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
recordingID, lyrics,
audioFileID, lyrics,
); err != nil {
return fmt.Errorf("could not insert lyrics_index row: %w", err)
}
@@ -149,22 +176,16 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
return nil
}
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
// current recordings table. Cheap for a personal library and safe to
// run after every scan.
// RebuildLyricsIndex repopulates lyrics_index from the lyrics table.
func (d *DB) RebuildLyricsIndex() error {
if _, err := d.db.ExecContext(d.Ctx,
"DELETE FROM lyrics_index",
); err != nil {
if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil {
return fmt.Errorf("could not clear lyrics_index: %w", err)
}
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input.
// SAFETY: FTS5 virtual table INSERT. Values sourced from lyrics; no user input.
if _, err := d.db.ExecContext(d.Ctx, `
INSERT INTO lyrics_index(rowid, lyrics)
SELECT id, lyrics
FROM recordings
WHERE lyrics IS NOT NULL AND lyrics != ''
SELECT audio_file_id, text FROM lyrics WHERE text != ''
`); err != nil {
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
}
@@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error {
return nil
}
// RecordingsMissingLyrics returns recordings that have no stored
// lyrics but do carry the artist/title/duration needed to look them
// up from an external provider. Used by the LRCLIB backfill. The
// limit bounds each batch so the backfill can be run incrementally.
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
// LyricsCandidate identifies a file that needs its lyrics fetched and
// carries the fields an external provider matches on.
type LyricsCandidate struct {
AudioFileID int64
Title string
Artist string
Album string
RecordingMBID string
LengthMilliseconds int64
}
// FilesMissingLyrics returns files with no stored lyrics that carry
// the artist/title/duration needed to look them up. Used by the
// LRCLIB backfill; the limit bounds each batch.
func (d *DB) FilesMissingLyrics(limit int) ([]LyricsCandidate, error) {
if limit <= 0 {
limit = 200
}
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
r.id,
COALESCE(r.name, ''),
COALESCE(ac.text, ''),
COALESCE(rg.name, ''),
MIN(af.length_milliseconds)
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE (r.lyrics IS NULL OR r.lyrics = '')
AND r.name IS NOT NULL AND r.name != ''
AND ac.text IS NOT NULL AND ac.text != ''
GROUP BY r.id
rows, err := d.reader().QueryContext(d.Ctx, `
SELECT tm.id, tm.title, tm.artist_name, tm.album,
tm.recording_mbid, tm.length_milliseconds
FROM track_metadata tm
WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id)
AND tm.title != '' AND tm.artist_name != ''
LIMIT ?
`, limit)
if err != nil {
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err)
return nil, fmt.Errorf("could not query files missing lyrics: %w", err)
}
defer func() { _ = rows.Close() }()
@@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
for rows.Next() {
var c LyricsCandidate
if err := rows.Scan(
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds,
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
&c.RecordingMBID, &c.LengthMilliseconds,
); err != nil {
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
}
@@ -229,44 +247,23 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
return out, nil
}
// LyricsCandidate identifies a recording that needs its lyrics fetched
// and carries the fields an external provider matches on.
type LyricsCandidate struct {
RecordingID int64
Title string
Artist string
Album string
LengthMilliseconds int64
}
// RecordingLyricLookup returns the provider-match fields (artist,
// title, album, duration) for a single recording, so lyrics can be
// fetched on demand. Returns nil if the recording has no audio file
// or no artist/title to match on.
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
// FileLyricLookup returns the provider-match fields for one file, so
// lyrics can be fetched on demand. Returns nil if the file has no
// artist/title to match on.
func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) {
var c LyricsCandidate
err := d.db.QueryRowContext(d.Ctx, `
SELECT
r.id,
COALESCE(r.name, ''),
COALESCE(ac.text, ''),
COALESCE(rg.name, ''),
COALESCE(MIN(af.length_milliseconds), 0)
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
WHERE r.id = ?
GROUP BY r.id
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
err := d.reader().QueryRowContext(d.Ctx, `
SELECT tm.id, tm.title, tm.artist_name, tm.album,
tm.recording_mbid, tm.length_milliseconds
FROM track_metadata tm
WHERE tm.id = ?
`, audioFileID).Scan(
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
&c.RecordingMBID, &c.LengthMilliseconds,
)
if err != nil {
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err)
return nil, fmt.Errorf("could not look up file for lyrics: %w", err)
}
if c.Title == "" || c.Artist == "" {
+25 -51
View File
@@ -4,59 +4,33 @@ import (
"testing"
)
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
// recording → audio_file → release_group link) for one track with the
// given lyrics, so lyric-search tests have realistic joins.
// seedLyricsTrack inserts one file with the given lyrics, so lyric
// searches have something realistic to join against. It used to
// insert a four-row FK chain by hand.
func seedLyricsTrack(
t *testing.T,
db *DB,
id int64,
title, artist, album, lyrics string,
lenMs int64,
) {
) int64 {
t.Helper()
if _, err := db.ExecContext(
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
); err != nil {
t.Fatalf("insert artist_credit: %v", err)
fileID := InsertTestTrack(t, db, TestTrack{
FilePath: "/music/track" + itoa(id) + ".mp3",
Title: title,
Artist: artist,
Album: album,
LengthMs: lenMs,
})
if lyrics != "" {
if err := db.SetLyrics(fileID, lyrics, "tag", ""); err != nil {
t.Fatalf("seed lyrics: %v", err)
}
}
if _, err := db.ExecContext(
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
); err != nil {
t.Fatalf("insert release_group: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
id, title, id, nullableLyrics(lyrics),
); err != nil {
t.Fatalf("insert recording: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
"VALUES (?, ?, ?, ?, ?)",
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
); err != nil {
t.Fatalf("insert audio_file: %v", err)
}
if _, err := db.ExecContext(
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
id, id,
); err != nil {
t.Fatalf("insert release_group_recordings: %v", err)
}
}
func nullableLyrics(l string) any {
if l == "" {
return nil
}
return l
return fileID
}
func itoa(v int64) string {
@@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) {
}
h := hits[0]
if h.RecordingID != 1 {
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
if h.AudioFileID != 1 {
t.Errorf("RecordingID = %d, want 1", h.AudioFileID)
}
if h.Title != "The Sound of Silence" {
@@ -191,11 +165,11 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
// Backfill lyrics — should update both the column and the FTS index.
const lyrics = "Yesterday all my troubles seemed so far away"
if err := db.SetRecordingLyrics(1, lyrics); err != nil {
if err := db.SetLyrics(1, lyrics, "lrclib", ""); err != nil {
t.Fatalf("SetRecordingLyrics: %v", err)
}
stored, err := db.GetRecordingLyrics(1)
stored, err := db.GetLyrics(1)
if err != nil {
t.Fatalf("GetRecordingLyrics: %v", err)
}
@@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
t.Fatalf("SearchLyrics: %v", err)
}
if len(hits) != 1 || hits[0].RecordingID != 1 {
if len(hits) != 1 || hits[0].AudioFileID != 1 {
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
}
}
@@ -222,7 +196,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
missing, err := db.RecordingsMissingLyrics(50)
missing, err := db.FilesMissingLyrics(50)
if err != nil {
t.Fatalf("RecordingsMissingLyrics: %v", err)
}
@@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
}
c := missing[0]
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
if c.AudioFileID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
t.Errorf("unexpected candidate: %+v", c)
}
@@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
}
// Single-recording lookup mirrors the batch fields.
one, err := db.RecordingLyricLookup(2)
one, err := db.FileLyricLookup(2)
if err != nil {
t.Fatalf("RecordingLyricLookup: %v", err)
}
-196
View File
@@ -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, &notnull, &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)
}
}
+86
View File
@@ -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
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"fmt"
"strings"
"yellowjacket/backend/database/sql/sqlcgen"
)
// SearchRow holds a single result from an FTS5 or basename search.
@@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error {
return nil
}
// SearchTrackRow holds a full track result from an FTS5 search,
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
type SearchTrackRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
// trackMetadataColumns is the column list of the track_metadata view,
// in the order sqlc generates TrackMetadatum's fields. The FTS
// searches below cannot be sqlc queries (MATCH is not in its grammar),
// so this is the one place the view's shape is written out by hand.
const trackMetadataColumns = `
tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name,
tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year,
tm.release_year, tm.composer, tm.file_type, tm.sample_rate,
tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id,
tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid,
tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id`
// scanTrackMetadata reads track_metadata rows into the generated row
// type, so an FTS hit and an ordinary query produce the same Track.
func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) {
var out []sqlcgen.TrackMetadatum
for rows.Next() {
var r sqlcgen.TrackMetadatum
if err := rows.Scan(
&r.ID, &r.FilePath, &r.LengthMilliseconds, &r.Title, &r.ArtistName,
&r.TrackNumber, &r.DiscNumber, &r.Album, &r.Genre, &r.Year,
&r.ReleaseYear, &r.Composer, &r.FileType, &r.SampleRate,
&r.BitDepth, &r.Channels, &r.Bitrate, &r.FileSize, &r.LibraryID,
&r.PlayCount, &r.LastPlayed, &r.CoverArtPath, &r.ArtistMbid,
&r.ReleaseGroupMbid, &r.RecordingMbid, &r.AlbumID, &r.ArtistID,
); err != nil {
return nil, fmt.Errorf("scan track metadata: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate track metadata: %w", err)
}
return out, nil
}
// SearchFTSTracks performs a full-text search and returns full track
// metadata for each match. Unlike SearchFTS (which returns only 5
// columns), this includes all 16 fields needed for library.Track.
// SearchFTSTracks performs a full-text search and returns whole tracks.
//
// A library id of 0 means every library. There were two of these, one
// per case, each with its own copy of a sixteen-column projection that
// silently dropped the MBIDs and the play count - which is why the
// caller used to pass zeros for them.
func (d *DB) SearchFTSTracks(
query string, limit int,
) ([]SearchTrackRow, error) {
query string, libraryID int64, limit int,
) ([]sqlcgen.TrackMetadatum, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
ftsQuery := buildFTSQuery(query)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.track_number,
tm.disc_number,
tm.album,
tm.genre,
tm.year,
tm.composer,
tm.file_type,
tm.sample_rate,
tm.bit_depth,
tm.channels,
tm.bitrate,
tm.file_size
rows, err := d.reader().QueryContext(d.Ctx, `
SELECT`+trackMetadataColumns+`
FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ?
AND (? = 0 OR tm.library_id = ?)
ORDER BY rank
LIMIT ?
`, ftsQuery, limit)
`, buildFTSQuery(query), libraryID, libraryID, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS track search failed: %w", err,
)
return nil, fmt.Errorf("FTS track search failed: %w", err)
}
defer func() { _ = rows.Close() }()
var results []SearchTrackRow
for rows.Next() {
var r SearchTrackRow
if err := rows.Scan(
&r.FilePath,
&r.LengthMilliseconds,
&r.Title,
&r.ArtistName,
&r.TrackNumber,
&r.DiscNumber,
&r.Album,
&r.Genre,
&r.Year,
&r.Composer,
&r.FileType,
&r.SampleRate,
&r.BitDepth,
&r.Channels,
&r.Bitrate,
&r.FileSize,
); err != nil {
return nil, fmt.Errorf(
"could not scan search track row: %w",
err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"search track row iteration error: %w",
err,
)
}
return results, nil
return scanTrackMetadata(rows)
}
// SearchFTSTracksByLibrary performs a full-text search scoped to a
// specific library and returns full track metadata for each match.
func (d *DB) SearchFTSTracksByLibrary(
query string, limit int, libraryID int64,
) ([]SearchTrackRow, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, nil
}
ftsQuery := buildFTSQuery(query)
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
rows, err := d.db.QueryContext(d.Ctx, `
SELECT
tm.file_path,
tm.length_milliseconds,
tm.title,
tm.artist_name,
tm.track_number,
tm.disc_number,
tm.album,
tm.genre,
tm.year,
tm.composer,
tm.file_type,
tm.sample_rate,
tm.bit_depth,
tm.channels,
tm.bitrate,
tm.file_size
FROM search_index si
JOIN track_metadata tm ON tm.id = si.rowid
WHERE search_index MATCH ? AND tm.library_id = ?
ORDER BY rank
LIMIT ?
`, ftsQuery, libraryID, limit)
if err != nil {
return nil, fmt.Errorf(
"FTS library track search failed: %w", err,
)
}
defer func() { _ = rows.Close() }()
var results []SearchTrackRow
for rows.Next() {
var r SearchTrackRow
if err := rows.Scan(
&r.FilePath,
&r.LengthMilliseconds,
&r.Title,
&r.ArtistName,
&r.TrackNumber,
&r.DiscNumber,
&r.Album,
&r.Genre,
&r.Year,
&r.Composer,
&r.FileType,
&r.SampleRate,
&r.BitDepth,
&r.Channels,
&r.Bitrate,
&r.FileSize,
); err != nil {
return nil, fmt.Errorf(
"could not scan library search track row: %w",
err,
)
}
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf(
"library search track row iteration error: %w",
err,
)
}
return results, nil
}
// scanSearchRows reads all rows from a query result into a slice.
func scanSearchRows(
rows interface {
Next() bool
+76 -232
View File
@@ -3,6 +3,8 @@ package database
import (
"fmt"
"testing"
"yellowjacket/backend/database/sql/sqlcgen"
)
// seedSearchData inserts ~7 tracks with the full FK chain required for
@@ -84,128 +86,44 @@ func seedSearchData(t *testing.T, db *DB) {
},
}
// Build unique sets.
artistMap := map[string]int64{}
albumMap := map[string]int64{}
var artistID, albumID int64
for _, tr := range tracks {
if _, ok := artistMap[tr.artist]; !ok {
artistID++
artistMap[tr.artist] = artistID
}
if _, ok := albumMap[tr.album]; !ok {
albumID++
albumMap[tr.album] = albumID
}
}
// Insert artist_credit rows.
for text, id := range artistMap {
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (?, ?)",
id, text,
)
if err != nil {
t.Fatalf("insert artist_credit %q: %v", text, err)
}
}
// Insert release_groups.
for name, id := range albumMap {
_, err := db.ExecContext(
"INSERT INTO release_groups (id, name) VALUES (?, ?)",
id, name,
)
if err != nil {
t.Fatalf("insert release_group %q: %v", name, err)
}
}
// Insert genres + recording_genres.
genreMap := map[string]int64{}
var genreID int64
for _, tr := range tracks {
if tr.genre == "" {
continue
}
if _, ok := genreMap[tr.genre]; !ok {
genreID++
genreMap[tr.genre] = genreID
_, err := db.ExecContext(
"INSERT INTO genres (id, name) VALUES (?, ?)",
genreID, tr.genre,
)
if err != nil {
t.Fatalf("insert genre %q: %v", tr.genre, err)
}
}
}
for _, tr := range tracks {
acID := artistMap[tr.artist]
rgID := albumMap[tr.album]
// Insert recording.
_, err := db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id, "+
"track_number, disc_number, year, genre, composer) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
tr.id, tr.title, acID, tr.trackNum, tr.discNum,
tr.year, tr.genre, tr.composer,
)
if err != nil {
t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err)
}
// Insert audio_files.
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, "+
"length_milliseconds, file_type_id, recording_id, "+
"sample_rate, bit_depth, channels, bitrate, file_size) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id,
tr.sr, tr.bd, tr.ch, tr.br, tr.fsize,
)
if err != nil {
t.Fatalf("insert audio_file %d: %v", tr.id, err)
}
// Link recording to release_group.
_, err = db.ExecContext(
"INSERT INTO release_group_recordings "+
"(release_group_id, recording_id, track_number, disc_number) "+
"VALUES (?, ?, ?, ?)",
rgID, tr.id, tr.trackNum, tr.discNum,
)
if err != nil {
t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err)
}
// Insert search_index entry (rowid must match audio_files.id).
if err := db.InsertSearchIndex(
tr.id, tr.filePath, tr.title, tr.artist, tr.album,
); err != nil {
t.Fatalf("insert search_index for %d: %v", tr.id, err)
}
// Insert recording_genres link.
var genres []string
if tr.genre != "" {
gID := genreMap[tr.genre]
genres = []string{tr.genre}
}
_, err = db.ExecContext(
"INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
tr.id, gID,
)
if err != nil {
t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err)
}
var trackNum, discNum int64
if tr.trackNum != nil {
trackNum = *tr.trackNum
}
if tr.discNum != nil {
discNum = *tr.discNum
}
id := InsertTestTrack(t, db, TestTrack{
FilePath: tr.filePath,
Title: tr.title,
Artist: tr.artist,
Album: tr.album,
Genres: genres,
TrackNumber: trackNum,
DiscNumber: discNum,
Year: tr.year,
LengthMs: tr.lenMs,
})
// The fixtures assert on audio properties and the composer,
// which InsertTestTrack does not carry - they are not part of
// what a seeder should have to know about a track.
if _, err := db.ExecContext(
`UPDATE audio_files
SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
channels = ?, bitrate = ?, file_size = ?, composer = ?
WHERE id = ?`,
tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
); err != nil {
t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
}
}
}
@@ -553,7 +471,7 @@ func TestSearchFTSTracks(t *testing.T) {
db := NewTestDB(t)
seedSearchData(t, db)
results, err := db.SearchFTSTracks("queen", 10)
results, err := db.SearchFTSTracks("queen", 0, 10)
if err != nil {
t.Fatalf("SearchFTSTracks: %v", err)
}
@@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) {
}
// Find the Bohemian Rhapsody result and verify all 16 fields.
var br *SearchTrackRow
var br *sqlcgen.TrackMetadatum
for i, r := range results {
if r.Title == "Bohemian Rhapsody" {
@@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) {
db := NewTestDB(t)
// Set up minimal FK chain for a single track.
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)",
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
InsertTestTrack(t, db, TestTrack{
FilePath: "/test/track.mp3",
Title: "Test Track",
Artist: "Test Artist",
LengthMs: 180000,
})
// Insert into search index.
if err := db.InsertSearchIndex(
@@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) {
db := NewTestDB(t)
// Seed the full entity graph WITHOUT inserting into search_index.
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)",
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')",
)
if err != nil {
t.Fatalf("insert release_group: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)",
)
if err != nil {
t.Fatalf("insert release_group_recordings: %v", err)
}
// Seed the file WITHOUT putting it in search_index.
InsertTestTrack(t, db, TestTrack{
FilePath: "/rebuild/track.mp3",
Title: "Rebuild Track",
Artist: "Rebuild Artist",
Album: "Rebuild Album",
LengthMs: 200000,
SkipSearchIndex: true,
})
// Search should return nothing before rebuild.
results, err := db.SearchFTS("Rebuild", 10)
@@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
db := NewTestDB(t)
// Set up minimal FK chain for a single track at rowid 100.
_, err := db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (100, 'Old Title', 100)",
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) " +
"VALUES (100, '/test/update_cycle.mp3', 200000, 0, 100)",
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
id := InsertTestTrack(t, db, TestTrack{
FilePath: "/test/update_cycle.mp3",
Title: "Old Title",
Artist: "Old Artist",
LengthMs: 200000,
SkipSearchIndex: true,
})
// 1. Insert with old metadata.
if err := db.InsertSearchIndex(
100, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
id, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
); err != nil {
t.Fatalf("InsertSearchIndex (old): %v", err)
}
// Verify search for "Old Title" returns rowid 100.
// Verify search for "Old Title" finds it.
results, err := db.SearchFTS("Old Title", 10)
if err != nil {
t.Fatalf("SearchFTS(Old Title): %v", err)
@@ -926,9 +790,9 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
t.Fatal("SearchFTS(Old Title): got 0 results after insert")
}
// 2. Delete rowid 100.
if err := db.DeleteSearchIndex(100); err != nil {
t.Fatalf("DeleteSearchIndex(100): %v", err)
// 2. Delete the row.
if err := db.DeleteSearchIndex(id); err != nil {
t.Fatalf("DeleteSearchIndex(%d): %v", id, err)
}
// Verify "Old Title" no longer found.
@@ -944,25 +808,17 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
)
}
// 3. Update the recording name in the DB to simulate tag edit.
// 3. Update the file's title in the DB to simulate a tag edit.
_, err = db.ExecContext(
"UPDATE recordings SET name = 'New Title' WHERE id = 100",
"UPDATE audio_files SET title = 'New Title' WHERE file_path = '/test/update_cycle.mp3'",
)
if err != nil {
t.Fatalf("update recording: %v", err)
t.Fatalf("update title: %v", err)
}
// Also add a new artist_credit for the new artist.
_, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (101, 'New Artist')",
)
if err != nil {
t.Fatalf("insert new artist_credit: %v", err)
}
// 4. Re-insert rowid 100 with new metadata.
// 4. Re-insert the row with new metadata.
if err := db.InsertSearchIndex(
100, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
id, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
); err != nil {
t.Fatalf("InsertSearchIndex (new): %v", err)
}
@@ -1079,25 +935,13 @@ func TestSearchIndexSchema(t *testing.T) {
t.Fatalf("insert artist: %v", err)
}
// The credit tables this used to assert a UNIQUE constraint on are
// gone; a file names its artist directly, and artists are unique by
// name, which is asserted below.
_, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')",
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
)
if err != nil {
t.Fatalf("first insert artist_credit_artist: %v", err)
}
// Duplicate insert should fail with UNIQUE constraint.
_, err = db.ExecContext(
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
"INSERT INTO artists (id, name) VALUES (2, 'Test')",
)
if err == nil {
t.Error("duplicate artist_credit_artist insert should fail, got nil error")
t.Error("duplicate artist name should fail, got nil error")
}
}
@@ -1,10 +0,0 @@
-- Adds SplitMixedFolder's synthetic-group bookkeeping to an
-- existing tagging_items table. A fresh database never runs this
-- file: sql/schemas/tagging_items.sql already declares these
-- columns, so applySchema's isFreshDatabase check stamps this
-- version as applied without executing it.
ALTER TABLE tagging_items ADD COLUMN synthetic INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tagging_items ADD COLUMN parent_group_key TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_tagging_items_parent_group_key
ON tagging_items(parent_group_key) WHERE parent_group_key != '';
@@ -1,24 +0,0 @@
-- Repairs tagging_items rows left behind by a library-scan bug: the
-- rescan's orphan-cleanup phase deleted audio_files rows for files
-- removed from disk without decrementing/clearing their tagging
-- group, so a folder whose contents were fully replaced kept a
-- phantom entry (stale track_count, no matching audio_files) in the
-- autotag queue forever. The library scan code no longer has this
-- gap, but a database written before the fix still carries the
-- damage — this is a one-time repair, not ongoing bookkeeping.
--
-- Drop groups with no audio_files left at all.
DELETE FROM tagging_items
WHERE group_key NOT IN (
SELECT DISTINCT group_key FROM audio_files WHERE group_key != ''
);
-- Reconcile track_count for groups that are still alive but drifted
-- (some, not all, of their tracks were removed without decrementing).
UPDATE tagging_items
SET track_count = (
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
)
WHERE track_count != (
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
);
@@ -1 +0,0 @@
ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0;
@@ -1,3 +0,0 @@
ALTER TABLE queue ADD COLUMN source_type TEXT NOT NULL DEFAULT '';
ALTER TABLE queue ADD COLUMN source_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE queue ADD COLUMN source_label TEXT NOT NULL DEFAULT '';
@@ -1 +0,0 @@
ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT;
@@ -1 +0,0 @@
ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER;
+137
View File
@@ -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 = ?;
+36 -48
View File
@@ -1,67 +1,55 @@
-- name: CreateArtist :one
INSERT INTO artists (name) VALUES (?)
-- Queries over artists.
--
-- An artist row is reachable two ways: as a file's primary artist
-- (audio_files.artist_id) and as an album's artist (albums.artist_id).
-- Both used to route through artist_credit + artist_credit_artist,
-- which is how "which album artists are in library 2" came to be a
-- five-join subquery inside a three-join query.
-- name: UpsertArtist :one
INSERT INTO artists (name, mbid) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
mbid = COALESCE(excluded.mbid, artists.mbid)
RETURNING *;
-- name: GetArtist :one
SELECT * FROM artists
WHERE id = ? LIMIT 1;
SELECT * FROM artists WHERE id = ? LIMIT 1;
-- name: GetArtistByName :one
SELECT * FROM artists
WHERE name = ? LIMIT 1;
SELECT * FROM artists WHERE name = ? LIMIT 1;
-- name: UpsertArtist :one
INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING *;
-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
WHERE id = ?;
-- name: SetArtistMBID :exec
UPDATE artists SET mbid = ? WHERE id = ?;
-- name: DeleteArtist :exec
DELETE FROM artists
WHERE id = ?;
DELETE FROM artists WHERE id = ?;
-- name: DeleteAllArtists :exec
DELETE FROM artists;
-- name: GetUnreferencedArtistIDs :many
-- Artists no file and no album points at any more.
SELECT id FROM artists a
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id);
-- name: GetAllArtists :many
SELECT * FROM artists
ORDER BY name;
SELECT * FROM artists ORDER BY name;
-- name: GetAlbumArtists :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
ORDER BY a.name;
-- name: GetOrphanedArtistIDs :many
-- Artists no longer credited on any recording or release group - left
-- behind when a scan's orphan cleanup removes the audio_files that used
-- to justify them, since deleting an audio_files row doesn't cascade.
SELECT a.id FROM artists a
WHERE NOT EXISTS (
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
);
-- name: GetAlbumArtistsByLibrary :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
WHERE a.id IN (
SELECT DISTINCT aca2.artist_id
FROM artist_credit_artist aca2
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
JOIN albums al ON al.artist_id = a.id
WHERE EXISTS (
SELECT 1 FROM audio_files af
WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
)
ORDER BY a.name;
-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
LEFT JOIN artists a ON a.id = af.artist_id
WHERE af.file_path = ?
LIMIT 1;
+170 -310
View File
@@ -1,39 +1,60 @@
-- Queries over audio_files and the track_metadata view above it.
--
-- Every query that returns "a track" selects from `track_metadata`,
-- which is the one place the projection is defined. The scoped and
-- unscoped variants that used to be written twice are one query now:
-- library_id 0 means "every library", and `(:id = 0 OR library_id = :id)`
-- costs nothing measurable (23 ms vs 21 ms over 26k rows) because these
-- queries scan either way.
-- ---------------------------------------------------------------------
-- Writes
-- ---------------------------------------------------------------------
-- name: CreateAudioFile :one
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: CreateAudioFileWithGroupKey :one
INSERT INTO audio_files (
file_path, length_milliseconds, file_type_id, recording_id,
sample_rate, bit_depth, channels, bitrate, file_size, basename,
library_id, group_key, tag_status, modified_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
file_path, library_id, file_type_id,
length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size,
title, artist_credit, artist_id, album_id,
track_number, disc_number, total_tracks, year, composer, comment,
recording_mbid, basename, group_key, modified_at, tag_status
) VALUES (
?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)
RETURNING *;
-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files
WHERE id = ? LIMIT 1;
-- name: UpdateAudioFileTags :exec
-- A rescan of a file whose mtime moved: the tags are re-read and
-- written over the same row. Under the old schema this created a
-- *new* recording and repointed the file at it, abandoning the old one
-- -- which is where 812 orphaned rows and every phantom "you own this"
-- came from. There is nothing to orphan now.
UPDATE audio_files
SET title = ?, artist_credit = ?, artist_id = ?, album_id = ?,
track_number = ?, disc_number = ?, total_tracks = ?, year = ?,
composer = ?, comment = ?, recording_mbid = ?,
sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?,
file_size = ?, length_milliseconds = ?, modified_at = ?
WHERE id = ?;
-- name: SetAudioFileGroupKey :exec
UPDATE audio_files SET group_key = ? WHERE id = ?;
-- name: GetAudioFile :one
SELECT * FROM audio_files
WHERE id = ? LIMIT 1;
-- name: GetAudioFileByPath :one
SELECT * FROM audio_files
WHERE file_path = ? LIMIT 1;
-- name: UpdateAudioFile :exec
UPDATE audio_files
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
WHERE id = ?;
-- name: UpdateAudioFileRecording :exec
-- name: PromoteAudioFileTagStatusIfUntagged :exec
-- A rescan re-reads the tags of a file whose mtime moved, so a file
-- another tagger stamped with MBIDs since import arrives here still
-- carrying the 'untagged' status it was created with (only the insert
-- path sets it). Promote it the same way saveAudioFile does.
-- Guarded on 'untagged' so it cannot overwrite a deliberate
-- 'user_skipped_permanent', and so a file losing its MBIDs is left
-- alone -- demotion is the scan's judgement, not this statement's.
UPDATE audio_files
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ?
WHERE id = ?;
SET tag_status = 'user_confirmed'
WHERE id = ? AND tag_status = 'untagged';
-- name: UpdateAudioFileStat :exec
-- Records the on-disk mtime/size without re-reading tags. Used to
@@ -43,307 +64,146 @@ UPDATE audio_files
SET modified_at = ?, file_size = ?
WHERE id = ?;
-- name: SetAudioFileRecordingMBID :exec
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
-- name: DeleteAudioFile :exec
DELETE FROM audio_files WHERE id = ?;
-- name: DeleteAllAudioFiles :exec
DELETE FROM audio_files;
-- ---------------------------------------------------------------------
-- Reads: the file row itself
-- ---------------------------------------------------------------------
-- name: GetAudioFile :one
SELECT * FROM audio_files WHERE id = ? LIMIT 1;
-- name: GetAudioFileByPath :one
SELECT * FROM audio_files WHERE file_path = ? LIMIT 1;
-- name: GetAudioFileGroupKey :one
SELECT group_key FROM audio_files WHERE id = ? LIMIT 1;
-- name: GetAllAudioFilePaths :many
SELECT id, file_path FROM audio_files;
-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (sqlc.slice('paths'));
-- name: GetRandomAudioFilePath :one
SELECT file_path FROM audio_files ORDER BY RANDOM() LIMIT 1;
-- name: CountAudioFiles :one
SELECT COUNT(*) AS count FROM audio_files
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
-- name: GetLibraryMaxModifiedAt :one
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
-- the library is empty or no row has a baseline yet.
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
WHERE library_id = ?;
-- name: DeleteAudioFile :exec
DELETE FROM audio_files
WHERE id = ?;
-- ---------------------------------------------------------------------
-- Reads: tracks
-- ---------------------------------------------------------------------
-- name: CountAudioFiles :one
SELECT count(*) FROM audio_files;
-- name: GetTracks :many
SELECT * FROM track_metadata
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
-- name: GetRandomAudioFilePath :one
SELECT file_path FROM audio_files
ORDER BY RANDOM()
LIMIT 1;
-- name: GetTrackByPath :one
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
-- name: GetAllAudioFiles :many
SELECT * FROM audio_files;
-- name: GetTracksByAlbum :many
SELECT * FROM track_metadata
WHERE album_id = sqlc.arg(album_id)
AND library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id)
ORDER BY disc_number, track_number;
-- name: GetAllAudioFilePaths :many
SELECT id, file_path FROM audio_files;
-- name: GetAudioFilesNeedingMetadata :many
SELECT * FROM audio_files
WHERE recording_id = 0;
-- name: GetAllAudioFilesWithArtist :many
SELECT
af.id,
af.file_path,
af.length_milliseconds,
af.file_type_id,
af.recording_id,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.name, '') AS title
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id;
-- name: GetTrackMetadataByPath :one
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1;
-- name: GetAllTracksWithFullMetadata :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
-- name: SearchAudioFilesByBasename :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
WHERE af.basename = ?
LIMIT ?;
-- name: GetTracksByGenre :many
SELECT tm.* FROM track_metadata tm
JOIN file_genres fg ON fg.audio_file_id = tm.id
JOIN genres g ON g.id = fg.genre_id
WHERE g.name = sqlc.arg(genre)
AND tm.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), tm.library_id);
-- name: LookupTrackMetaByPaths :many
SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid
SELECT id, file_path, title, artist_name, album, cover_art_path,
artist_mbid, release_group_mbid, recording_mbid
FROM track_metadata
WHERE file_path IN (sqlc.slice('paths'));
-- name: GetAudioFilesByLibrary :many
SELECT * FROM audio_files WHERE library_id = ?;
-- name: SearchTracksByBasename :many
SELECT id, file_path, length_milliseconds, title, artist_name, album
FROM track_metadata
WHERE file_path IN (
SELECT file_path FROM audio_files WHERE basename = sqlc.arg(basename)
)
LIMIT sqlc.arg(lim);
-- name: CountAudioFilesByLibrary :one
SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?;
-- ---------------------------------------------------------------------
-- Reads: file paths, grouped by whatever the caller asked about
-- ---------------------------------------------------------------------
-- These answer "what can I play" and they all ask audio_files, because
-- that is the only table whose rows are files. Grouped rather than
-- flattened because the caller owns the order.
-- name: DeleteAllAudioFiles :exec
DELETE FROM audio_files;
-- name: GetAllTracksWithFullMetadataByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE af.library_id = ?;
-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
rgr.track_number,
rgr.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE rgr.release_group_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
-- name: GetAudioFilesByReleaseGroupByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
rgr.track_number,
rgr.disc_number,
COALESCE(rg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE rgr.release_group_id = ? AND af.library_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
-- "Play this artist" and "play these albums" wanted file paths and asked
-- for whole track rows to get them, one round trip per album (perf.m2).
-- These answer the same question in one query and carry only what the
-- caller uses; the release group id comes back so the caller can keep
-- its own album ordering.
-- name: GetFilePathsByReleaseGroups :many
SELECT rgr.release_group_id, af.file_path
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
ORDER BY rgr.disc_number, rgr.track_number;
-- name: GetFilePathsByReleaseGroupsByLibrary :many
SELECT rgr.release_group_id, af.file_path
FROM release_group_recordings rgr
JOIN recordings r ON rgr.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
AND af.library_id = ?
ORDER BY rgr.disc_number, rgr.track_number;
-- Same shape again, keyed on recording MBID, for the catalog side.
-- An Explore album page knows which of its tracks the user owns only
-- as a set of recording MBIDs -- that is exactly how the backend
-- decides `inLibrary` (markReleasesInLibrary -> CheckMBIDs) -- and
-- MBTrack.LocalID is declared but never written by anything, so there
-- is no id to ask by. Grouped by MBID because a recording can have
-- more than one file (the duplicate fixtures are precisely that) and
-- because the caller owns the order: the tracklist's, not the
-- database's.
-- name: GetFilePathsByAlbums :many
-- The library filter is applied in Go rather than here: sqlc numbers a
-- named parameter (?2) but expands a slice into N placeholders, so the
-- two together bind the wrong values - GetFilePathsByAlbums([1,2], 0)
-- read album id 2 as the library id. Returning library_id and
-- filtering the (small) result is the version that cannot be wrong.
SELECT album_id, library_id, file_path FROM audio_files
WHERE album_id IN (sqlc.slice('album_ids'))
ORDER BY disc_number, track_number;
-- name: GetFilePathsByRecordingMBIDs :many
SELECT r.mbid AS recording_mbid, af.file_path
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
WHERE r.mbid IN (sqlc.slice('mbids'))
ORDER BY af.file_path;
-- The ownership question in its only honest form: which of these
-- catalog recordings has a *file* behind it. Asked of audio_files, so
-- a metadata row with no file cannot answer yes.
SELECT recording_mbid, library_id, file_path FROM audio_files
WHERE recording_mbid IN (sqlc.slice('mbids'))
ORDER BY file_path;
-- name: GetFilePathsByRecordingMBIDsByLibrary :many
SELECT r.mbid AS recording_mbid, af.file_path
FROM recordings r
JOIN audio_files af ON af.recording_id = r.id
WHERE r.mbid IN (sqlc.slice('mbids'))
AND af.library_id = ?
ORDER BY af.file_path;
-- name: GetFilePathsByGenres :many
SELECT g.name AS genre, af.library_id, af.file_path
FROM audio_files af
JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = fg.genre_id
WHERE g.name IN (sqlc.slice('genres'))
ORDER BY af.disc_number, af.track_number;
-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (sqlc.slice('paths'));
-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN artists a ON a.id = af.artist_id
WHERE a.mbid = ?;
-- ---------------------------------------------------------------------
-- Ownership, asked in bulk
-- ---------------------------------------------------------------------
-- name: OwnedRecordingMBIDs :many
-- Which of these recording MBIDs are actually in the library. This is
-- what marks a catalog tracklist owned; it used to be
-- `SELECT mbid FROM recordings`, which answered yes for 129 tracks in a
-- real library that had no file at all.
SELECT DISTINCT recording_mbid FROM audio_files
WHERE recording_mbid IN (sqlc.slice('mbids'));
-- name: OwnedAlbumMBIDs :many
SELECT DISTINCT al.mbid FROM albums al
JOIN audio_files af ON af.album_id = al.id
WHERE al.mbid IN (sqlc.slice('mbids'));
-- name: OwnedArtistMBIDs :many
SELECT DISTINCT a.mbid FROM artists a
JOIN audio_files af ON af.artist_id = a.id
WHERE a.mbid IN (sqlc.slice('mbids'));
-- name: GetAudioFilesInLibrary :many
SELECT * FROM audio_files WHERE library_id = ?;
+35 -135
View File
@@ -1,150 +1,50 @@
-- Queries over genres and file_genres.
--
-- The track-returning ones live in audio_files.sql with the rest of the
-- track_metadata reads; what is left here is the genre list itself and
-- the link table's writes.
-- name: UpsertGenre :one
INSERT INTO genres (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = name
ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING *;
-- name: CreateRecordingGenre :exec
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
VALUES (?, ?);
-- name: LinkFileGenre :exec
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?);
-- name: DeleteRecordingGenres :exec
DELETE FROM recording_genres
WHERE recording_id = ?;
-- name: DeleteFileGenres :exec
DELETE FROM file_genres WHERE audio_file_id = ?;
-- name: GetGenresByRecordingID :many
SELECT g.*
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
WHERE rg.recording_id = ?;
-- name: GetGenreNamesByFile :many
SELECT g.name FROM genres g
JOIN file_genres fg ON fg.genre_id = g.id
WHERE fg.audio_file_id = ?;
-- name: DeleteAllRecordingGenres :exec
DELETE FROM recording_genres;
-- name: DeleteAllGenres :exec
DELETE FROM genres;
-- name: GetTracksByGenre :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ?
ORDER BY r.name;
-- name: GetTracksByGenreByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ? AND af.library_id = ?
ORDER BY r.name;
-- name: CountGenreReferences :one
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?;
-- name: GetGenreNamesByFilePaths :many
-- Genres for many files at once. The mix builder asked this one file
-- at a time, inside two nested loops -- twelve thousand single-row
-- queries to assemble one mix.
SELECT af.file_path, g.name
FROM audio_files af
JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = fg.genre_id
WHERE af.file_path IN (sqlc.slice('paths'));
-- name: DeleteGenre :exec
DELETE FROM genres WHERE id = ?;
-- name: DeleteAllGenres :exec
DELETE FROM genres;
-- name: GetUnusedGenreIDs :many
SELECT id FROM genres g
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id);
-- name: GetAllGenresWithCounts :many
SELECT g.name, COUNT(rg.recording_id) AS track_count
SELECT g.name, COUNT(fg.audio_file_id) AS track_count
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN file_genres fg ON fg.genre_id = g.id
JOIN audio_files af ON af.id = fg.audio_file_id
WHERE af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
GROUP BY g.id, g.name
ORDER BY g.name;
-- name: GetAllGenresWithCountsByLibrary :many
SELECT g.name, COUNT(rg.recording_id) AS track_count
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.library_id = ?
GROUP BY g.id, g.name
ORDER BY g.name;
-- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
-- one query instead of one per genre, and file paths instead of whole
-- track rows, which was 6 MB over the IPC for five genres.
-- name: GetFilePathsByGenres :many
SELECT g.name AS genre_name, af.file_path
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (sqlc.slice('genre_names'))
ORDER BY r.name;
-- name: GetFilePathsByGenresByLibrary :many
SELECT g.name AS genre_name, af.file_path
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (sqlc.slice('genre_names'))
AND af.library_id = ?
ORDER BY r.name;
+25 -33
View File
@@ -2,16 +2,15 @@
--
-- Every one of these returns album ids and nothing else. The display
-- columns (cover art, artist credit, year) already have exactly one
-- correct expression of them, in GetAllAlbumsWithDetails, and a second
-- correct expression of them, in GetAlbums, and a second
-- copy per shelf would be six more places for that to drift. The home
-- service joins the ids back to that one album list in Go.
-- name: HomeRecentlyPlayedAlbums :many
-- Albums with the most recent play, newest first.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC
@@ -22,9 +21,8 @@ LIMIT ?;
-- stands in for one: it is monotonic and assigned at import, which is
-- the same ordering an added_at column would give.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
ORDER BY MAX(af.id) DESC
LIMIT ?;
@@ -32,9 +30,8 @@ LIMIT ?;
-- name: HomeMostPlayedAlbums :many
-- Albums by total plays across their tracks.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC
@@ -45,9 +42,8 @@ LIMIT ?;
-- shelf is a different suggestion each time rather than the same
-- alphabetical head of the list forever.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
HAVING SUM(af.play_count) = 0
ORDER BY RANDOM()
@@ -56,9 +52,8 @@ LIMIT ?;
-- name: HomeStaleAlbums :many
-- Played before, but not for a long while.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?)
@@ -67,9 +62,8 @@ LIMIT ?;
-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?;
@@ -78,10 +72,10 @@ LIMIT ?;
-- A random sample of albums carrying a genre, so the same genre shelf
-- is not the same ten albums every time the page opens.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
JOIN genres g ON g.id = rgen.genre_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = fg.genre_id
WHERE g.name = ?
GROUP BY rg.id
ORDER BY RANDOM()
@@ -93,10 +87,10 @@ LIMIT ?;
-- album carries is a shelf about that one album.
SELECT
g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count
COUNT(DISTINCT af.album_id) AS album_count
FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
JOIN file_genres fg ON fg.genre_id = g.id
JOIN audio_files af ON af.id = fg.audio_file_id
GROUP BY g.id
HAVING album_count >= 3
ORDER BY album_count DESC
@@ -106,14 +100,12 @@ LIMIT ?;
-- Artists by total plays, as the album-artist credit text the album
-- list already displays.
SELECT
COALESCE(ac.text, '') AS artist_name,
rg.artist_credit AS artist_name,
SUM(af.play_count) AS plays
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE ac.text <> ''
GROUP BY ac.text
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
WHERE rg.artist_credit <> ''
GROUP BY rg.artist_credit
HAVING plays > 0
ORDER BY plays DESC
LIMIT ?;
-30
View File
@@ -1,30 +0,0 @@
-- Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
-- expanding a seed selection into a candidate pool by artist similarity
-- and genre overlap, restricted to what is actually in the library.
-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?;
-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?;
-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1;
+25 -66
View File
@@ -47,29 +47,18 @@ SELECT
pt.playlist_id,
pt.audio_file_id,
pt.position,
COALESCE(af.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
COALESCE(tm.file_path, '') AS file_path,
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
WHERE pt.playlist_id = ?
ORDER BY pt.position;
@@ -79,29 +68,18 @@ SELECT
pt.playlist_id,
pt.audio_file_id,
pt.position,
COALESCE(af.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
COALESCE(tm.file_path, '') AS file_path,
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
ORDER BY pt.playlist_id, pt.position;
-- name: DeleteAllPlaylistTracks :exec
@@ -132,27 +110,8 @@ WHERE playlist_id = ? AND audio_file_id = (
);
-- name: GetTrackPhantomMetadata :one
SELECT
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
af.length_milliseconds AS duration_ms,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.id = ?;
-- The display fields a playlist row keeps after its file goes away.
SELECT title, artist_name AS artist, album,
length_milliseconds AS duration_ms, genre, cover_art_path
FROM track_metadata
WHERE id = ?;
+5 -20
View File
@@ -13,27 +13,12 @@ SET current_position = ?
WHERE id = 1;
-- name: GetQueueTracks :many
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
-- The queue's rows, joined to the one track projection.
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
FROM queue_tracks qt
JOIN audio_files af ON qt.audio_file_id = af.id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
JOIN track_metadata tm ON tm.id = qt.audio_file_id
ORDER BY qt.position;
-- name: GetQueueTrackCount :one
@@ -1,47 +0,0 @@
-- name: CreateRecording :one
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
RETURNING *;
-- name: CreateRecordingFull :one
INSERT INTO recordings (
name, artist_credit_id, track_number, disc_number,
year, genre, composer, lyrics, comment
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetRecording :one
SELECT * FROM recordings
WHERE id = ? LIMIT 1;
-- name: UpdateRecording :exec
UPDATE recordings
SET name = ?, artist_credit_id = ?
WHERE id = ?;
-- name: UpdateRecordingFull :exec
UPDATE recordings
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
WHERE id = ?;
-- name: DeleteRecording :exec
DELETE FROM recordings
WHERE id = ?;
-- name: DeleteAllRecordings :exec
DELETE FROM recordings;
-- name: GetAllRecordings :many
SELECT * FROM recordings
ORDER BY name;
-- name: CountRecordingsByArtistCredit :one
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?;
-- name: GetOrphanedRecordingIDs :many
-- Recordings no longer backed by any audio_files row - left behind
-- when a scan's orphan cleanup deletes the file that used to own them,
-- since deleting audio_files doesn't cascade to recordings.
SELECT r.id FROM recordings r
LEFT JOIN audio_files af ON af.recording_id = r.id
WHERE af.id IS NULL;
@@ -1,50 +0,0 @@
-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (
release_group_id, recording_id, track_number, disc_number, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs;
-- name: GetReleaseGroupRecording :one
SELECT * FROM release_group_recordings
WHERE id = ? LIMIT 1;
-- name: GetReleaseGroupRecordings :many
SELECT * FROM release_group_recordings
WHERE release_group_id = ?
ORDER BY disc_number, track_number;
-- name: GetRecordingReleaseGroups :many
SELECT * FROM release_group_recordings
WHERE recording_id = ?;
-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
WHERE id = ?;
-- name: DeleteReleaseGroupRecordingByFK :exec
DELETE FROM release_group_recordings
WHERE release_group_id = ? AND recording_id = ?;
-- name: DeleteAllReleaseGroupRecordings :exec
DELETE FROM release_group_recordings;
-- name: DeleteReleaseGroupRecordingsByRecording :exec
DELETE FROM release_group_recordings
WHERE recording_id = ?;
@@ -1,216 +0,0 @@
-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING *;
-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: GetReleaseGroup :one
SELECT * FROM release_groups
WHERE id = ? LIMIT 1;
-- name: GetReleaseGroupByNameAndArtist :one
SELECT * FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1;
-- name: UpsertReleaseGroup :one
INSERT INTO release_groups (name, album_artist_credit_id, year)
VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING *;
-- name: SetReleaseGroupOriginalYear :exec
-- Set the release group's original-release-year (release-group's
-- first-release-date from MusicBrainz). Called from autotag apply
-- when the user confirms a candidate; the file-tag year stays in
-- the year column.
UPDATE release_groups SET original_year = ? WHERE id = ?;
-- name: UpdateReleaseGroup :exec
UPDATE release_groups
SET name = ?
WHERE id = ?;
-- name: UpdateReleaseGroupCoverArt :exec
UPDATE release_groups
SET cover_art_id = ?
WHERE id = ?;
-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
WHERE id = ?;
-- name: DeleteAllReleaseGroups :exec
DELETE FROM release_groups;
-- name: GetAllReleaseGroups :many
SELECT * FROM release_groups
ORDER BY name;
-- name: GetAllAlbumsWithDetails :many
SELECT
rg.id,
rg.name,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
ORDER BY rg.name;
-- name: GetAllAlbumsWithDetailsByLibrary :many
SELECT
rg.id,
rg.name,
-- year prefers original release year (MB first-release-date)
-- over the file-tag year so the UI surfaces the album's
-- original year by default. release_year keeps the file-tag
-- year accessible.
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
rg.mbid,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
WHERE rg.id IN (
SELECT DISTINCT rgr2.release_group_id
FROM release_group_recordings rgr2
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
)
ORDER BY rg.name;
-- name: GetAlbumsByArtist :many
SELECT
rg.id,
rg.name,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
WHERE aca.artist_id = ?
ORDER BY rg.name;
-- name: CountReleaseGroupRecordings :one
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
-- name: GetOrphanedReleaseGroupIDs :many
-- Release groups with no recordings left in them - run after orphaned
-- recordings (and their release_group_recordings rows) are deleted, so
-- a release group whose last owned track was removed is cleaned up too.
SELECT rg.id FROM release_groups rg
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
WHERE rgr.id IS NULL;
-- name: GetAlbumsByArtistByLibrary :many
SELECT
rg.id,
rg.name,
COALESCE(rg.original_year, rg.year) AS year,
COALESCE(rg.year, 0) AS release_year,
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
-- primary (first-credited) album artist's MBID, for linking the
-- artist name to its detail page. Empty when the album has no
-- MB-tagged album-artist credit.
CAST(COALESCE((
SELECT a.mbid
FROM artist_credit_artist aca_p
JOIN artists a ON a.id = aca_p.artist_id
WHERE aca_p.credit_id = rg.album_artist_credit_id
ORDER BY aca_p.id
LIMIT 1
), '') AS TEXT) as artist_mbid,
COALESCE(ca.file_path, '') as cover_art_path
FROM release_groups rg
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN (
SELECT rgr.release_group_id, ac2.text
FROM release_group_recordings rgr
JOIN recordings rec ON rec.id = rgr.recording_id
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
GROUP BY rgr.release_group_id
) fallback_ac ON fallback_ac.release_group_id = rg.id
WHERE aca.artist_id = ?
AND rg.id IN (
SELECT DISTINCT rgr2.release_group_id
FROM release_group_recordings rgr2
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
)
ORDER BY rg.name;
+87 -62
View File
@@ -87,10 +87,7 @@ LIMIT 1;
SELECT ti.group_key
FROM tagging_items ti
JOIN audio_files af ON af.group_key = ti.group_key
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN albums rg ON rg.id = af.album_id
WHERE ti.synthetic = 0
AND ti.track_count >= 4
AND (
@@ -98,13 +95,23 @@ WHERE ti.synthetic = 0
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
)
GROUP BY ti.group_key
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
-- "Needs tagging" is a question about the files, not about the row:
-- every scanned folder gets a tagging_items row (see
-- UpsertTaggingItemOnTrackAdd), including one whose files all arrived
-- carrying a recording MBID. Without the EXISTS a fully MB-tagged
-- library reports its entire album count as pending work. See the
-- same predicate on the three list queries below.
SELECT COUNT(*) FROM tagging_items ti
WHERE ti.status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
);
-- name: ListPendingTaggingItemsAlphabetical :many
SELECT
@@ -125,6 +132,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL
-- Actionable rows must have something to act on: see
-- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are
-- exempt because they are history, not work -- an applied folder is
-- fully tagged by definition and would otherwise vanish from the
-- sidebar's Completed section the instant it succeeded.
AND (
ti.status IN ('confirmed', 'skipped')
OR EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
)
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT @row_limit OFFSET @row_offset;
@@ -150,6 +169,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
AND ti.cleared_at IS NULL
-- See ListPendingTaggingItemsAlphabetical.
AND (
ti.status IN ('confirmed', 'skipped')
OR EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
)
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT @row_limit OFFSET @row_offset;
@@ -204,61 +231,53 @@ ORDER BY ti.created_at DESC, ti.group_key
LIMIT @row_limit OFFSET @row_offset;
-- name: ListAudioFilesInTaggingGroup :many
-- album_name/album_artist are the PER-TRACK tags (via each track's
-- own release_group link), not the folder-level tagging_items
-- values. SplitMixedFolder clusters on these to find sub-albums
-- hiding inside a folder full of unrelated tracks.
-- album_name/album_artist are the PER-TRACK tags (each file's own
-- album link), not the folder-level tagging_items values.
-- SplitMixedFolder clusters on these to find sub-albums hiding inside
-- a folder full of unrelated tracks.
SELECT
af.id,
af.file_path,
af.basename,
af.length_milliseconds,
af.tag_status,
COALESCE(r.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(rg.name, '') AS album_name,
COALESCE(rgac.text, '') AS album_artist
COALESCE(af.track_number, 0) AS track_number,
COALESCE(af.disc_number, 0) AS disc_number,
af.title,
af.artist_credit AS artist_name,
COALESCE(af.recording_mbid, '') AS recording_mbid,
COALESCE(al.name, '') AS album_name,
COALESCE(al.artist_credit, '') AS album_artist
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
LEFT JOIN albums al ON al.id = af.album_id
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
ORDER BY COALESCE(af.disc_number, 0),
COALESCE(af.track_number, 0),
af.file_path;
-- name: ListLocalReleaseGroupCandidates :many
-- Returns one row per (release_group, track) combination for any
-- local release_group that has an MBID. Callers group these in Go
-- and filter by normalized album-name match. Joined case-insensitive
-- on name to pre-filter cheaply; Go does the real normalization.
-- name: ListLocalAlbumCandidates :many
-- One row per (album, track) for any local album carrying an MBID.
-- Callers group these in Go and filter by normalized album-name match;
-- the join is case-insensitive on name to pre-filter cheaply.
SELECT
rg.id AS release_group_id,
rg.mbid AS release_group_mbid,
rg.name AS album_name,
COALESCE(rg.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit,
COALESCE(rgr.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recordings r ON r.id = rgr.recording_id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
WHERE rg.mbid IS NOT NULL
AND rg.mbid != ''
AND r.mbid IS NOT NULL
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
al.id AS album_id,
al.mbid AS album_mbid,
al.name AS album_name,
COALESCE(al.year, 0) AS year,
al.artist_credit,
COALESCE(af.track_number, 0) AS track_number,
COALESCE(af.disc_number, 0) AS disc_number,
af.title AS track_title,
COALESCE(af.recording_mbid, '') AS recording_mbid,
af.length_milliseconds
FROM albums al
JOIN audio_files af ON af.album_id = al.id
WHERE al.mbid IS NOT NULL
AND al.mbid != ''
AND af.recording_mbid IS NOT NULL
AND af.recording_mbid != ''
AND al.name = ? COLLATE NOCASE
ORDER BY al.id, af.disc_number, af.track_number;
-- name: SetTaggingItemBestMatch :exec
UPDATE tagging_items
@@ -284,17 +303,16 @@ WHERE group_key = ?;
-- name: SetAudioFileTagStatus :exec
UPDATE audio_files SET tag_status = ? WHERE id = ?;
-- name: SetRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?;
-- name: SetFileRecordingMBID :exec
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
-- name: SetReleaseGroupMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?;
-- name: GetRecordingReleaseGroupID :one
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
FROM release_group_recordings rgr
WHERE rgr.recording_id = ?
LIMIT 1;
-- name: SetFileAlbumMBID :exec
-- The album MBID for the album a file belongs to. Keyed by file
-- because that is what the autotag apply path holds; under the old
-- schema it had to look the release group up through two join tables
-- first (GetRecordingReleaseGroupID), which is gone.
UPDATE albums SET mbid = ?
WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?);
-- name: GetNextPendingTaggingItem :one
SELECT
@@ -315,5 +333,12 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending'
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
AND ti.group_key > @after_group_key
-- See CountPendingTaggingItems: the cursor must not stop on a
-- folder the list query no longer shows, or "next" walks folders
-- that are not in the sidebar.
AND EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
ORDER BY ti.group_key
LIMIT 1;
+43
View File
@@ -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);
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
ON artist_images(artist_mbid);
-- No index on artist_mbid alone: the UNIQUE index below has it as its
-- leftmost column.
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
ON artist_images(artist_mbid, source, source_url);
@@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
PRIMARY KEY (mbid, source)
);
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid);
-- No index on mbid alone: PRIMARY KEY (mbid, source) already has it as
-- its leftmost column, so a second one costs a write per row and serves
-- no read.
+92 -27
View File
@@ -1,34 +1,92 @@
-- One row per audio file, and the file's tags live on it.
--
-- This table used to be a stub — path, format, a foreign key — with
-- every tag-derived field one join away in `recordings`, which was in
-- turn linked to an album through `release_group_recordings` and to an
-- artist through `artist_credit` + `artist_credit_artist`. That is
-- MusicBrainz's data model, and it is the right model for MusicBrainz:
-- a recording really can appear on many releases and a credit really
-- can list many artists.
--
-- It was the wrong model here, and the library said so. Measured on a
-- real 25,966-file library: **no** recording had more than one file,
-- **no** recording belonged to more than one release group, and 3 of
-- 2,823 credits listed more than one artist. Every many-to-many the
-- schema modelled was 1:1 in the data, and the cost of modelling it
-- anyway was a six-way join in every read, a `MIN(release_group_id)`
-- subquery in eleven queries to collapse a fan-out that never happened,
-- a first-credited-artist subquery in nine more to collapse the other
-- one, and — the reason this changed — a whole class of bugs where a
-- `recordings` row **outlived the file that created it**. Retagging a
-- file created a new recording and abandoned the old one, so the same
-- library carried 812 recordings, 216 release groups and 260 artists
-- with no file behind them, and everything that asked "do I own this"
-- by looking for a metadata row got 129 confident yeses for tracks
-- that could not be played.
--
-- With the tags on the file, ownership is not a rule anyone can forget:
-- the row *is* the file.
CREATE TABLE IF NOT EXISTS audio_files (
id integer PRIMARY KEY,
file_path text NOT NULL UNIQUE,
length_milliseconds int NOT NULL,
file_type_id int NOT NULL,
recording_id int NOT NULL,
sample_rate int NOT NULL DEFAULT 0,
bit_depth int NOT NULL DEFAULT 0,
channels int NOT NULL DEFAULT 0,
bitrate int NOT NULL DEFAULT 0,
file_size int NOT NULL DEFAULT 0,
basename text NOT NULL DEFAULT '',
library_id int NOT NULL DEFAULT 0,
play_count int NOT NULL DEFAULT 0,
last_played datetime,
tag_status TEXT NOT NULL DEFAULT 'untagged'
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL UNIQUE,
library_id INTEGER NOT NULL DEFAULT 0,
file_type_id INTEGER NOT NULL,
-- Audio properties, read from the file itself.
length_milliseconds INTEGER NOT NULL,
sample_rate INTEGER NOT NULL DEFAULT 0,
bit_depth INTEGER NOT NULL DEFAULT 0,
channels INTEGER NOT NULL DEFAULT 0,
bitrate INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
-- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is
-- for display; `artist_id` is the primary artist it resolves to, and
-- is what grouping, browsing and the artist page use. Keeping both
-- is what makes the credit table unnecessary: the string is the only
-- thing that was ever read off it.
title TEXT NOT NULL DEFAULT '',
artist_credit TEXT NOT NULL DEFAULT '',
artist_id INTEGER,
album_id INTEGER,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the tag declared: the 12 in "5/12", per disc. It
-- is what lets "do I have all of this album" be answered from disk
-- instead of from MusicBrainz. NULL means the tag did not say, which
-- is a third state and not the same as zero.
total_tracks INTEGER,
year INTEGER,
composer TEXT NOT NULL DEFAULT '',
comment TEXT NOT NULL DEFAULT '',
recording_mbid TEXT,
-- Library bookkeeping.
basename TEXT NOT NULL DEFAULT '',
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import and
-- compared against the on-disk mtime during a scan to detect files
-- another application retagged in place.
modified_at INTEGER NOT NULL DEFAULT 0,
play_count INTEGER NOT NULL DEFAULT 0,
last_played DATETIME,
tag_status TEXT NOT NULL DEFAULT 'untagged'
CHECK(tag_status IN (
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
)),
group_key TEXT NOT NULL DEFAULT '',
-- File mtime as a Unix timestamp in seconds, captured at import.
-- Compared against the on-disk mtime during a scan to detect files
-- another application retagged in place. 0 means "never recorded"
-- (rows predating migration 47) and is treated as not-stale so an
-- upgrade does not re-import the whole library.
modified_at int NOT NULL DEFAULT 0,
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(library_id) REFERENCES libraries(id)
FOREIGN KEY(library_id) REFERENCES libraries(id),
FOREIGN KEY(artist_id) REFERENCES artists(id),
FOREIGN KEY(album_id) REFERENCES albums(id)
);
CREATE INDEX IF NOT EXISTS idx_audio_files_album_id
ON audio_files(album_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_artist_id
ON audio_files(artist_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
ON audio_files(basename);
@@ -36,10 +94,17 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
ON audio_files(group_key) WHERE group_key != '';
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
ON audio_files(library_id);
ON audio_files(library_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
ON audio_files(recording_id);
-- The ownership question, asked by MBID: "is there a *file* with this
-- recording MBID". Nothing may answer it from a metadata table again.
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_mbid
ON audio_files(recording_mbid) WHERE recording_mbid IS NOT NULL;
-- Answers "does this tagging group still contain untagged files" in one
-- seek per group. The autotag queue asks it once per row.
CREATE INDEX IF NOT EXISTS idx_audio_files_untagged_group_key
ON audio_files(group_key) WHERE tag_status = 'untagged';
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
ON audio_files(library_id) WHERE tag_status = 'untagged';
+39 -7
View File
@@ -1,10 +1,25 @@
-- The downloaded MusicBrainz/ListenBrainz catalog.
--
-- MusicBrainz ids are stored as their 16 raw bytes and entity types as
-- small integers, which is a size decision: on a real 2,052,200-row
-- catalog those four columns were 220 MB of a 383 MB table and were
-- carried again in every index keyed on them, and the conversion took
-- the table and its four indexes from 677 MB to 389 MB. See
-- backend/explore/mbid.go, which is the only place that encoding is
-- known -- everything above it speaks dashed strings and entity names.
--
-- The CHECK constraints are what make a mistake loud. SQLite does not
-- coerce between TEXT and BLOB, so a query comparing this column
-- against a 36-character string returns no rows rather than an error;
-- a *write* of one fails here instead, at the insert that made it.
CREATE TABLE IF NOT EXISTS explore_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
mbid TEXT NOT NULL,
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL CHECK(length(mbid) = 16),
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid TEXT NOT NULL,
artist_mbid BLOB NOT NULL
CHECK(length(artist_mbid) IN (0, 16)),
aliases TEXT NOT NULL DEFAULT '',
-- Popularity signals, derived from the ListenBrainz listens dump.
@@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index (
-- Recording-specific fields.
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid TEXT NOT NULL DEFAULT '',
caa_release_mbid BLOB NOT NULL DEFAULT x''
CHECK(length(caa_release_mbid) IN (0, 16)),
release_name TEXT NOT NULL DEFAULT '',
-- Release-group-specific fields.
@@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index (
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
-- How many tracks the release group's canonical release has, so
-- "do I have all of this" is answerable offline for an album the
-- library holds no tags for. Zero means the catalog does not say,
-- which is the same third state the local answer has -- and is what
-- every row carries until a central dump build fills it.
total_tracks INTEGER NOT NULL DEFAULT 0,
-- Artist-specific fields.
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
@@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index (
UNIQUE(mbid)
);
-- The exact-match tier's two indexes.
--
-- Their predicate is the champion set - the popular rows plus whatever
-- the user owns - and matching it to `ExactMatches`' own WHERE clause is
-- what makes them small. They used to say `popularity > 0`, which on a
-- real 2,052,200-row catalog covered 2,046,645 of them: a full index
-- wearing a partial index's clothes, 101 MB for the pair. Narrowed to
-- the set the tier can actually return, they are 3 MB and the query
-- plan is unchanged (measured, on that catalog).
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
ON explore_index(LOWER(artist_name))
WHERE popularity > 0;
WHERE popularity >= 10000 OR in_library = 1;
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
ON explore_index(caa_release_mbid)
WHERE entity_type = 'release_group' AND caa_release_mbid != '';
WHERE entity_type = 2 AND caa_release_mbid != x'';
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
ON explore_index(artist_mbid, entity_type, popularity DESC);
@@ -62,4 +94,4 @@ CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
ON explore_index(LOWER(title))
WHERE popularity > 0;
WHERE popularity >= 10000 OR in_library = 1;
@@ -0,0 +1,18 @@
-- Genres per file. This is `recording_genres` with the recording taken
-- out of the middle: it is the one many-to-many in the local library
-- that is actually many-to-many (a real library runs about four genre
-- rows per file), which is why it stays a join table when the others
-- did not.
CREATE TABLE IF NOT EXISTS file_genres (
audio_file_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
PRIMARY KEY (audio_file_id, genre_id),
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE,
FOREIGN KEY(genre_id) REFERENCES genres(id)
) WITHOUT ROWID;
-- The reverse direction ("which files are in this genre"). The
-- forward direction is served by the primary key, so — unlike the
-- table this replaces — there is no third index restating it.
CREATE INDEX IF NOT EXISTS idx_file_genres_genre_id
ON file_genres(genre_id);
+6 -10
View File
@@ -1,15 +1,11 @@
-- One row per "go find me this", from the moment the user asks until
-- the files are in the library or the attempt is abandoned.
-- One row per folder the user has added as a music library.
--
-- release_mbid / release_group_mbid are the anchor: a request that
-- carries one can be matched against a known tracklist at import time,
-- which is what makes unattended completion safe. Free-text requests
-- (both NULL) are always presented to the user for confirmation.
-- Everything else keyed by library_id means "which of these folders did
-- this come from"; a library_id of 0 in a query means "all of them".
--
-- `expected` caches the anchor's tracklist as JSON so ranking and
-- import do not have to re-resolve it, and so a request survives the
-- explore index being rebuilt underneath it.
-- autotag_warning_acked records that the user has been told what
-- autotagging will do to the files in this folder, which is a decision
-- they made and not something a rescan can rediscover.
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY,
+25
View File
@@ -0,0 +1,25 @@
-- Lyrics for a file, and where they came from.
--
-- These used to be a column on `recordings`, in a table classified
-- `Owned` — data a rescan can rebuild from the files. That was true of
-- lyrics read out of a USLT frame and false of lyrics fetched from
-- LRCLIB, and nothing recorded which was which, so a library with
-- 24,294 of them could not answer how many were free to rebuild and how
-- many were network traffic waiting to happen. `source` answers it.
--
-- `recording_mbid` is carried alongside the file id so a future
-- re-import can re-adopt fetched lyrics without asking LRCLIB again;
-- the file id is the key because untagged files have no MBID and are
-- exactly the ones whose lyrics had to be fetched.
CREATE TABLE IF NOT EXISTS lyrics (
audio_file_id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'tag'
CHECK(source IN ('tag', 'lrclib')),
recording_mbid TEXT,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_lyrics_recording_mbid
ON lyrics(recording_mbid) WHERE recording_mbid IS NOT NULL;
@@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS recording_genres (
id INTEGER PRIMARY KEY,
recording_id INTEGER NOT NULL,
genre_id INTEGER NOT NULL,
FOREIGN KEY(recording_id) REFERENCES recordings(id),
FOREIGN KEY(genre_id) REFERENCES genres(id),
UNIQUE(recording_id, genre_id)
);
CREATE INDEX IF NOT EXISTS idx_recording_genres_genre_id
ON recording_genres(genre_id);
CREATE INDEX IF NOT EXISTS idx_recording_genres_recording_id
ON recording_genres(recording_id);
@@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
artist_credit_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
composer TEXT,
lyrics TEXT,
comment TEXT,
mbid TEXT,
FOREIGN KEY(artist_credit_id) REFERENCES artist_credit(id)
);
CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id
ON recordings(artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL;
@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS release_group_recordings (
id INTEGER PRIMARY KEY,
release_group_id INTEGER NOT NULL,
recording_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the file's own tag declared: the 12 in "5/12", per
-- disc. Read off every file at scan and, until now, discarded — so
-- "do I have all of this album" had no local answer and the album
-- page asked MusicBrainz. NULL means the tag did not say, which is
-- a third state and not the same as zero.
total_tracks INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_recording_id
ON release_group_recordings(recording_id);
CREATE INDEX IF NOT EXISTS idx_release_group_recordings_release_group_id
ON release_group_recordings(release_group_id);
@@ -1,20 +0,0 @@
CREATE TABLE IF NOT EXISTS "release_groups" (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
cover_art_id INTEGER,
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER, pending_release_mbid TEXT,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
UNIQUE(name, album_artist_credit_id)
);
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id
ON release_groups(album_artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id
ON release_groups(cover_art_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL;
@@ -1,4 +1,11 @@
-- Release MBID -> release-group MBID, captured during a dump import.
--
-- It is empty on an ordinary install and looks droppable for that
-- reason: only a local dump build (`indexbuild`) fills it. The daily
-- incremental refresh reads it to roll per-release listen counts up to
-- the release group they belong to, so an install that has built its
-- own index does need it.
CREATE TABLE IF NOT EXISTS release_to_rg (
release_mbid TEXT PRIMARY KEY,
rg_mbid TEXT NOT NULL
) WITHOUT ROWID;
) WITHOUT ROWID;
@@ -6,5 +6,5 @@ CREATE TABLE IF NOT EXISTS similar_artist_map (
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
);
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
ON similar_artist_map(source_artist_mbid);
-- No index on source_artist_mbid alone: the PRIMARY KEY has it as its
-- leftmost column.
+40 -28
View File
@@ -1,23 +1,42 @@
CREATE VIEW IF NOT EXISTS track_metadata AS
-- The one definition of "a track, with everything a list needs".
--
-- A view is a definition, not data, so it is dropped and recreated on
-- every open rather than carrying a migration alongside it: CREATE VIEW
-- IF NOT EXISTS silently keeps an older database on the old definition,
-- and a migration file restating it would be the second description of
-- the schema the migration rules exist to prevent.
--
-- This projection used to exist **nine times** — four copies in
-- audio_files.sql, two in playlists.sql, two in genres.sql, one in
-- queue.sql — plus this view, which only the raw-SQL search paths used.
-- They had already drifted: this view preferred the album's
-- original_year for `year` and GetAllTracksWithFullMetadata used the
-- track's own, so the same library reported different years on
-- different screens. Every query that wants a track row now selects
-- from here, which is also why there is one row type and one mapper on
-- the Go side instead of nine and a twenty-two-argument function.
DROP VIEW IF EXISTS track_metadata;
CREATE VIEW track_metadata AS
SELECT
af.id,
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
af.title,
af.artist_credit AS artist_name,
af.track_number,
af.disc_number,
COALESCE(al.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
FROM file_genres fg
JOIN genres g ON g.id = fg.genre_id
WHERE fg.audio_file_id = af.id),
''
) AS TEXT) AS genre,
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
COALESCE(rg.year, r.year, 0) AS release_year,
COALESCE(r.composer, '') AS composer,
COALESCE(al.original_year, al.year, af.year, 0) AS year,
COALESCE(al.year, af.year, 0) AS release_year,
af.composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
@@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS
af.play_count,
af.last_played,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
COALESCE(ar.mbid, '') AS artist_mbid,
COALESCE(al.mbid, '') AS release_group_mbid,
COALESCE(af.recording_mbid, '') AS recording_mbid,
af.album_id,
af.artist_id
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
LEFT JOIN albums al ON al.id = af.album_id
LEFT JOIN artists ar ON ar.id = af.artist_id
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
LEFT JOIN file_types ft ON ft.id = af.file_type_id;
+426
View File
@@ -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
}
+63 -88
View File
@@ -7,20 +7,9 @@ package sqlcgen
import (
"context"
"database/sql"
)
const createArtist = `-- name: CreateArtist :one
INSERT INTO artists (name) VALUES (?)
RETURNING id, name, mbid
`
func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) {
row := q.db.QueryRowContext(ctx, createArtist, name)
var i Artist
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
return i, err
}
const deleteAllArtists = `-- name: DeleteAllArtists :exec
DELETE FROM artists
`
@@ -31,8 +20,7 @@ func (q *Queries) DeleteAllArtists(ctx context.Context) error {
}
const deleteArtist = `-- name: DeleteArtist :exec
DELETE FROM artists
WHERE id = ?
DELETE FROM artists WHERE id = ?
`
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
@@ -43,56 +31,17 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
const getAlbumArtists = `-- name: GetAlbumArtists :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
ORDER BY a.name
`
func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) {
rows, err := q.db.QueryContext(ctx, getAlbumArtists)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Artist
for rows.Next() {
var i Artist
if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAlbumArtistsByLibrary = `-- name: GetAlbumArtistsByLibrary :many
SELECT DISTINCT a.id, a.name, a.mbid
FROM artists a
JOIN artist_credit_artist aca ON aca.artist_id = a.id
JOIN artist_credit ac ON ac.id = aca.credit_id
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
WHERE a.id IN (
SELECT DISTINCT aca2.artist_id
FROM artist_credit_artist aca2
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
JOIN recordings r2 ON r2.id = rgr2.recording_id
JOIN audio_files af2 ON af2.recording_id = r2.id
WHERE af2.library_id = ?
JOIN albums al ON al.artist_id = a.id
WHERE EXISTS (
SELECT 1 FROM audio_files af
WHERE af.album_id = al.id
AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
)
ORDER BY a.name
`
func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) ([]Artist, error) {
rows, err := q.db.QueryContext(ctx, getAlbumArtistsByLibrary, libraryID)
func (q *Queries) GetAlbumArtists(ctx context.Context, libraryID int64) ([]Artist, error) {
rows, err := q.db.QueryContext(ctx, getAlbumArtists, libraryID)
if err != nil {
return nil, err
}
@@ -115,8 +64,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64)
}
const getAllArtists = `-- name: GetAllArtists :many
SELECT id, name, mbid FROM artists
ORDER BY name
SELECT id, name, mbid FROM artists ORDER BY name
`
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
@@ -143,8 +91,7 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
}
const getArtist = `-- name: GetArtist :one
SELECT id, name, mbid FROM artists
WHERE id = ? LIMIT 1
SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1
`
func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
@@ -154,9 +101,28 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
return i, err
}
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
LEFT JOIN artists a ON a.id = af.artist_id
WHERE af.file_path = ?
LIMIT 1
`
type GetArtistByFilePathRow struct {
ArtistName string
ArtistMbid string
}
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
var i GetArtistByFilePathRow
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
return i, err
}
const getArtistByName = `-- name: GetArtistByName :one
SELECT id, name, mbid FROM artists
WHERE name = ? LIMIT 1
SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1
`
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
@@ -166,18 +132,15 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err
return i, err
}
const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many
SELECT a.id FROM artists a
WHERE NOT EXISTS (
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
)
const getUnreferencedArtistIDs = `-- name: GetUnreferencedArtistIDs :many
SELECT id FROM artists a
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id)
`
// Artists no longer credited on any recording or release group - left
// behind when a scan's orphan cleanup removes the audio_files that used
// to justify them, since deleting an audio_files row doesn't cascade.
func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs)
// Artists no file and no album points at any more.
func (q *Queries) GetUnreferencedArtistIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getUnreferencedArtistIDs)
if err != nil {
return nil, err
}
@@ -199,30 +162,42 @@ func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
return items, nil
}
const updateArtist = `-- name: UpdateArtist :exec
UPDATE artists
SET name = ?
WHERE id = ?
const setArtistMBID = `-- name: SetArtistMBID :exec
UPDATE artists SET mbid = ? WHERE id = ?
`
type UpdateArtistParams struct {
Name string
type SetArtistMBIDParams struct {
Mbid sql.NullString
ID int64
}
func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) error {
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
func (q *Queries) SetArtistMBID(ctx context.Context, arg SetArtistMBIDParams) error {
_, err := q.db.ExecContext(ctx, setArtistMBID, arg.Mbid, arg.ID)
return err
}
const upsertArtist = `-- name: UpsertArtist :one
INSERT INTO artists (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = excluded.name
INSERT INTO artists (name, mbid) VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
mbid = COALESCE(excluded.mbid, artists.mbid)
RETURNING id, name, mbid
`
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) {
row := q.db.QueryRowContext(ctx, upsertArtist, name)
type UpsertArtistParams struct {
Name string
Mbid sql.NullString
}
// Queries over artists.
//
// An artist row is reachable two ways: as a file's primary artist
// (audio_files.artist_id) and as an album's artist (albums.artist_id).
// Both used to route through artist_credit + artist_credit_artist,
// which is how "which album artists are in library 2" came to be a
// five-join subquery inside a three-join query.
func (q *Queries) UpsertArtist(ctx context.Context, arg UpsertArtistParams) (Artist, error) {
row := q.db.QueryRowContext(ctx, upsertArtist, arg.Name, arg.Mbid)
var i Artist
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
return i, err
File diff suppressed because it is too large Load Diff
+65 -355
View File
@@ -7,36 +7,9 @@ package sqlcgen
import (
"context"
"database/sql"
"strings"
)
const countGenreReferences = `-- name: CountGenreReferences :one
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?
`
func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countGenreReferences, genreID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
VALUES (?, ?)
`
type CreateRecordingGenreParams struct {
RecordingID int64
GenreID int64
}
func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error {
_, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID)
return err
}
const deleteAllGenres = `-- name: DeleteAllGenres :exec
DELETE FROM genres
`
@@ -46,12 +19,12 @@ func (q *Queries) DeleteAllGenres(ctx context.Context) error {
return err
}
const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec
DELETE FROM recording_genres
const deleteFileGenres = `-- name: DeleteFileGenres :exec
DELETE FROM file_genres WHERE audio_file_id = ?
`
func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteAllRecordingGenres)
func (q *Queries) DeleteFileGenres(ctx context.Context, audioFileID int64) error {
_, err := q.db.ExecContext(ctx, deleteFileGenres, audioFileID)
return err
}
@@ -64,20 +37,12 @@ func (q *Queries) DeleteGenre(ctx context.Context, id int64) error {
return err
}
const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec
DELETE FROM recording_genres
WHERE recording_id = ?
`
func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error {
_, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID)
return err
}
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
SELECT g.name, COUNT(rg.recording_id) AS track_count
SELECT g.name, COUNT(fg.audio_file_id) AS track_count
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN file_genres fg ON fg.genre_id = g.id
JOIN audio_files af ON af.id = fg.audio_file_id
WHERE af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
GROUP BY g.id, g.name
ORDER BY g.name
`
@@ -87,8 +52,8 @@ type GetAllGenresWithCountsRow struct {
TrackCount int64
}
func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts)
func (q *Queries) GetAllGenresWithCounts(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsRow, error) {
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts, libraryID)
if err != nil {
return nil, err
}
@@ -110,35 +75,25 @@ func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWit
return items, nil
}
const getAllGenresWithCountsByLibrary = `-- name: GetAllGenresWithCountsByLibrary :many
SELECT g.name, COUNT(rg.recording_id) AS track_count
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.library_id = ?
GROUP BY g.id, g.name
ORDER BY g.name
const getGenreNamesByFile = `-- name: GetGenreNamesByFile :many
SELECT g.name FROM genres g
JOIN file_genres fg ON fg.genre_id = g.id
WHERE fg.audio_file_id = ?
`
type GetAllGenresWithCountsByLibraryRow struct {
Name string
TrackCount int64
}
func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsByLibraryRow, error) {
rows, err := q.db.QueryContext(ctx, getAllGenresWithCountsByLibrary, libraryID)
func (q *Queries) GetGenreNamesByFile(ctx context.Context, audioFileID int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getGenreNamesByFile, audioFileID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllGenresWithCountsByLibraryRow
var items []string
for rows.Next() {
var i GetAllGenresWithCountsByLibraryRow
if err := rows.Scan(&i.Name, &i.TrackCount); err != nil {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, i)
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
@@ -149,45 +104,42 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
return items, nil
}
const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many
SELECT g.name AS genre_name, af.file_path
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (/*SLICE:genre_names*/?)
ORDER BY r.name
const getGenreNamesByFilePaths = `-- name: GetGenreNamesByFilePaths :many
SELECT af.file_path, g.name
FROM audio_files af
JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = fg.genre_id
WHERE af.file_path IN (/*SLICE:paths*/?)
`
type GetFilePathsByGenresRow struct {
GenreName string
FilePath string
type GetGenreNamesByFilePathsRow struct {
FilePath string
Name string
}
// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
// one query instead of one per genre, and file paths instead of whole
// track rows, which was 6 MB over the IPC for five genres.
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) {
query := getFilePathsByGenres
// Genres for many files at once. The mix builder asked this one file
// at a time, inside two nested loops -- twelve thousand single-row
// queries to assemble one mix.
func (q *Queries) GetGenreNamesByFilePaths(ctx context.Context, paths []string) ([]GetGenreNamesByFilePathsRow, error) {
query := getGenreNamesByFilePaths
var queryParams []interface{}
if len(genreNames) > 0 {
for _, v := range genreNames {
if len(paths) > 0 {
for _, v := range paths {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1)
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFilePathsByGenresRow
var items []GetGenreNamesByFilePathsRow
for rows.Next() {
var i GetFilePathsByGenresRow
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
var i GetGenreNamesByFilePathsRow
if err := rows.Scan(&i.FilePath, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
@@ -201,51 +153,24 @@ func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string)
return items, nil
}
const getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many
SELECT g.name AS genre_name, af.file_path
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE g.name IN (/*SLICE:genre_names*/?)
AND af.library_id = ?
ORDER BY r.name
const getUnusedGenreIDs = `-- name: GetUnusedGenreIDs :many
SELECT id FROM genres g
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id)
`
type GetFilePathsByGenresByLibraryParams struct {
GenreNames []string
LibraryID int64
}
type GetFilePathsByGenresByLibraryRow struct {
GenreName string
FilePath string
}
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
query := getFilePathsByGenresByLibrary
var queryParams []interface{}
if len(arg.GenreNames) > 0 {
for _, v := range arg.GenreNames {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
}
queryParams = append(queryParams, arg.LibraryID)
rows, err := q.db.QueryContext(ctx, query, queryParams...)
func (q *Queries) GetUnusedGenreIDs(ctx context.Context) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, getUnusedGenreIDs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFilePathsByGenresByLibraryRow
var items []int64
for rows.Next() {
var i GetFilePathsByGenresByLibraryRow
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, i)
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
@@ -256,247 +181,32 @@ func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFile
return items, nil
}
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
SELECT g.id, g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
WHERE rg.recording_id = ?
const linkFileGenre = `-- name: LinkFileGenre :exec
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?)
`
func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) {
rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Genre
for rows.Next() {
var i Genre
if err := rows.Scan(&i.ID, &i.Name); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
type LinkFileGenreParams struct {
AudioFileID int64
GenreID int64
}
const getTracksByGenre = `-- name: GetTracksByGenre :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ?
ORDER BY r.name
`
type GetTracksByGenreRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) {
rows, err := q.db.QueryContext(ctx, getTracksByGenre, name)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTracksByGenreRow
for rows.Next() {
var i GetTracksByGenreRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.Composer,
&i.FileType,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getTracksByGenreByLibrary = `-- name: GetTracksByGenreByLibrary :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rlg.name, '') AS album,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g2.name, '||')
FROM recording_genres rg2
JOIN genres g2 ON rg2.genre_id = g2.id
WHERE rg2.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type,
af.sample_rate,
af.bit_depth,
af.channels,
af.bitrate,
af.file_size
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id,
MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
WHERE g.name = ? AND af.library_id = ?
ORDER BY r.name
`
type GetTracksByGenreByLibraryParams struct {
Name string
LibraryID int64
}
type GetTracksByGenreByLibraryRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
SampleRate int64
BitDepth int64
Channels int64
Bitrate int64
FileSize int64
}
func (q *Queries) GetTracksByGenreByLibrary(ctx context.Context, arg GetTracksByGenreByLibraryParams) ([]GetTracksByGenreByLibraryRow, error) {
rows, err := q.db.QueryContext(ctx, getTracksByGenreByLibrary, arg.Name, arg.LibraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTracksByGenreByLibraryRow
for rows.Next() {
var i GetTracksByGenreByLibraryRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.Composer,
&i.FileType,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
func (q *Queries) LinkFileGenre(ctx context.Context, arg LinkFileGenreParams) error {
_, err := q.db.ExecContext(ctx, linkFileGenre, arg.AudioFileID, arg.GenreID)
return err
}
const upsertGenre = `-- name: UpsertGenre :one
INSERT INTO genres (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name = name
ON CONFLICT(name) DO UPDATE SET name = excluded.name
RETURNING id, name
`
// Queries over genres and file_genres.
//
// The track-returning ones live in audio_files.sql with the rest of the
// track_metadata reads; what is left here is the genre list itself and
// the link table's writes.
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
row := q.db.QueryRowContext(ctx, upsertGenre, name)
var i Genre
+25 -33
View File
@@ -12,10 +12,10 @@ import (
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
JOIN genres g ON g.id = rgen.genre_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
JOIN file_genres fg ON fg.audio_file_id = af.id
JOIN genres g ON g.id = fg.genre_id
WHERE g.name = ?
GROUP BY rg.id
ORDER BY RANDOM()
@@ -54,9 +54,8 @@ func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenrePa
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC
@@ -89,9 +88,8 @@ func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int6
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?
@@ -122,9 +120,8 @@ func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, e
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
ORDER BY MAX(af.id) DESC
LIMIT ?
@@ -159,9 +156,8 @@ func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]i
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC
@@ -172,7 +168,7 @@ LIMIT ?
//
// Every one of these returns album ids and nothing else. The display
// columns (cover art, artist credit, year) already have exactly one
// correct expression of them, in GetAllAlbumsWithDetails, and a second
// correct expression of them, in GetAlbums, and a second
// copy per shelf would be six more places for that to drift. The home
// service joins the ids back to that one album list in Go.
// Albums with the most recent play, newest first.
@@ -201,9 +197,8 @@ func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?)
@@ -242,14 +237,12 @@ func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams
const homeTopArtists = `-- name: HomeTopArtists :many
SELECT
COALESCE(ac.text, '') AS artist_name,
rg.artist_credit AS artist_name,
SUM(af.play_count) AS plays
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE ac.text <> ''
GROUP BY ac.text
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
WHERE rg.artist_credit <> ''
GROUP BY rg.artist_credit
HAVING plays > 0
ORDER BY plays DESC
LIMIT ?
@@ -288,10 +281,10 @@ func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArt
const homeTopGenres = `-- name: HomeTopGenres :many
SELECT
g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count
COUNT(DISTINCT af.album_id) AS album_count
FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
JOIN file_genres fg ON fg.genre_id = g.id
JOIN audio_files af ON af.id = fg.audio_file_id
GROUP BY g.id
HAVING album_count >= 3
ORDER BY album_count DESC
@@ -331,9 +324,8 @@ func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenr
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
FROM albums rg
JOIN audio_files af ON af.album_id = rg.id
GROUP BY rg.id
HAVING SUM(af.play_count) = 0
ORDER BY RANDOM()
-103
View File
@@ -1,103 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: mix.sql
package sqlcgen
import (
"context"
"database/sql"
)
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1
`
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 getFilePathsByArtistMBID = `-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?
`
// Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
// expanding a seed selection into a candidate pool by artist similarity
// and genre overlap, restricted to what is actually in the library.
func (q *Queries) GetFilePathsByArtistMBID(ctx context.Context, mbid sql.NullString) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getFilePathsByArtistMBID, mbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var file_path string
if err := rows.Scan(&file_path); err != nil {
return nil, err
}
items = append(items, file_path)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGenreNamesByFilePath = `-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?
`
func (q *Queries) GetGenreNamesByFilePath(ctx context.Context, filePath string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getGenreNamesByFilePath, filePath)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+47 -62
View File
@@ -9,23 +9,24 @@ import (
"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 {
ID int64
Name string
Mbid sql.NullString
}
type ArtistCredit struct {
ID int64
Text string
}
type ArtistCreditArtist struct {
ID int64
ArtistID int64
CreditID int64
}
type ArtistEnrichment struct {
ArtistMbid string
BrowsedAt sql.NullTime
@@ -56,21 +57,31 @@ type ArtistMetadatum struct {
type AudioFile struct {
ID int64
FilePath string
LengthMilliseconds int64
LibraryID int64
FileTypeID int64
RecordingID int64
LengthMilliseconds int64
SampleRate int64
BitDepth int64
Channels int64
Bitrate 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
LibraryID int64
GroupKey string
ModifiedAt int64
PlayCount int64
LastPlayed sql.NullTime
TagStatus string
GroupKey string
ModifiedAt int64
}
type CoverArt struct {
@@ -160,20 +171,21 @@ type ExploreChampionFt struct {
type ExploreIndex struct {
ID int64
EntityType string
Mbid string
EntityType int64
Mbid []byte
Title string
ArtistName string
ArtistMbid string
ArtistMbid []byte
Aliases string
Popularity int64
ListenerCount int64
Duration int64
CaaReleaseMbid string
CaaReleaseMbid []byte
ReleaseName string
PrimaryType string
SecondaryTypes string
ReleaseDate string
TotalTracks int64
ArtistType string
Country string
Disambiguation string
@@ -197,6 +209,11 @@ type ExploreIndexMetum struct {
Value string
}
type FileGenre struct {
AudioFileID int64
GenreID int64
}
type FileType struct {
ID int64
Extension string
@@ -231,6 +248,14 @@ type Library struct {
AutotagWarningAcked int64
}
type Lyric struct {
AudioFileID int64
Text string
Source string
RecordingMbid sql.NullString
FetchedAt time.Time
}
type LyricsIndex struct {
Lyrics string
}
@@ -291,48 +316,6 @@ type QueueTrack struct {
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
PendingReleaseMbid sql.NullString
}
type ReleaseGroupRecording struct {
ID int64
ReleaseGroupID int64
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
}
type ReleaseToRg struct {
ReleaseMbid string
RgMbid string
@@ -410,4 +393,6 @@ type TrackMetadatum struct {
ArtistMbid string
ReleaseGroupMbid string
RecordingMbid string
AlbumID sql.NullInt64
ArtistID sql.NullInt64
}
+25 -66
View File
@@ -124,29 +124,18 @@ SELECT
pt.playlist_id,
pt.audio_file_id,
pt.position,
COALESCE(af.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
COALESCE(tm.file_path, '') AS file_path,
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
ORDER BY pt.playlist_id, pt.position
`
@@ -368,29 +357,18 @@ SELECT
pt.playlist_id,
pt.audio_file_id,
pt.position,
COALESCE(af.file_path, '') AS file_path,
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
COALESCE(r.name, pt.phantom_title, '') AS title,
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
COALESCE(rg.name, pt.phantom_album, '') AS album,
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
COALESCE(tm.file_path, '') AS file_path,
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
COALESCE(tm.title, pt.phantom_title, '') AS title,
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
COALESCE(tm.album, pt.phantom_album, '') AS album,
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
COALESCE(tm.recording_mbid, '') AS recording_mbid
FROM playlist_tracks pt
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
WHERE pt.playlist_id = ?
ORDER BY pt.position
`
@@ -451,30 +429,10 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID
}
const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one
SELECT
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
af.length_milliseconds AS duration_ms,
CAST(COALESCE(
(SELECT GROUP_CONCAT(g.name, '||')
FROM recording_genres rg_sub
JOIN genres g ON rg_sub.genre_id = g.id
WHERE rg_sub.recording_id = r.id),
''
) AS TEXT) AS genre,
COALESCE(ca.file_path, '') AS cover_art_path
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.id = ?
SELECT title, artist_name AS artist, album,
length_milliseconds AS duration_ms, genre, cover_art_path
FROM track_metadata
WHERE id = ?
`
type GetTrackPhantomMetadataRow struct {
@@ -486,6 +444,7 @@ type GetTrackPhantomMetadataRow struct {
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) {
row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id)
var i GetTrackPhantomMetadataRow
+5 -20
View File
@@ -61,27 +61,11 @@ func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) {
}
const getQueueTracks = `-- name: GetQueueTracks :many
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist,
COALESCE(rg.name, '') AS album,
COALESCE(ca.file_path, '') AS cover_art_path,
COALESCE(a.mbid, '') AS artist_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(r.mbid, '') AS recording_mbid
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
FROM queue_tracks qt
JOIN audio_files af ON qt.audio_file_id = af.id
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
LEFT JOIN artists a ON a.id = aca.artist_id
LEFT JOIN (
SELECT recording_id, MIN(release_group_id) AS release_group_id
FROM release_group_recordings
GROUP BY recording_id
) rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
JOIN track_metadata tm ON tm.id = qt.audio_file_id
ORDER BY qt.position
`
@@ -99,6 +83,7 @@ type GetQueueTracksRow struct {
RecordingMbid string
}
// The queue's rows, joined to the one track projection.
func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
rows, err := q.db.QueryContext(ctx, getQueueTracks)
if err != nil {
@@ -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,211 +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, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, release_group_id, recording_id, track_number, disc_number, total_tracks
`
type CreateReleaseGroupRecordingParams struct {
ReleaseGroupID int64
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks 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,
arg.TotalTracks,
)
var i ReleaseGroupRecording
err := row.Scan(
&i.ID,
&i.ReleaseGroupID,
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
)
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 getAlbumCompleteness = `-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs
`
type GetAlbumCompletenessRow struct {
Owned int64
Expected int64
DiscsUntotalled int64
}
func (q *Queries) GetAlbumCompleteness(ctx context.Context, releaseGroupID int64) (GetAlbumCompletenessRow, error) {
row := q.db.QueryRowContext(ctx, getAlbumCompleteness, releaseGroupID)
var i GetAlbumCompletenessRow
err := row.Scan(&i.Owned, &i.Expected, &i.DiscsUntotalled)
return i, err
}
const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks 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,
&i.TotalTracks,
); 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, total_tracks 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,
&i.TotalTracks,
)
return i, err
}
const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks 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,
&i.TotalTracks,
); 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,639 +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, pending_release_mbid
`
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,
&i.PendingReleaseMbid,
)
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, pending_release_mbid
`
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,
&i.PendingReleaseMbid,
)
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, pending_release_mbid 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,
&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 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, pending_release_mbid 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,
&i.PendingReleaseMbid,
)
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, pending_release_mbid 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,
&i.PendingReleaseMbid,
)
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, pending_release_mbid
`
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,
&i.PendingReleaseMbid,
)
return i, err
}
+104 -87
View File
@@ -25,11 +25,21 @@ func (q *Queries) ClearCompletedTaggingItems(ctx context.Context, libraryID int6
}
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
SELECT COUNT(*) FROM tagging_items ti
WHERE ti.status = 'pending'
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) {
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
var count int64
@@ -77,6 +87,13 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE ti.status = 'pending'
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
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
LIMIT 1
`
@@ -185,20 +202,6 @@ func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (
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
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 = ?
@@ -235,22 +238,18 @@ SELECT
af.basename,
af.length_milliseconds,
af.tag_status,
COALESCE(r.track_number, 0) AS track_number,
COALESCE(r.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(rg.name, '') AS album_name,
COALESCE(rgac.text, '') AS album_artist
COALESCE(af.track_number, 0) AS track_number,
COALESCE(af.disc_number, 0) AS disc_number,
af.title,
af.artist_credit AS artist_name,
COALESCE(af.recording_mbid, '') AS recording_mbid,
COALESCE(al.name, '') AS album_name,
COALESCE(al.artist_credit, '') AS album_artist
FROM audio_files af
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
LEFT JOIN albums al ON al.id = af.album_id
WHERE af.group_key = ?
ORDER BY COALESCE(r.disc_number, 0),
COALESCE(r.track_number, 0),
ORDER BY COALESCE(af.disc_number, 0),
COALESCE(af.track_number, 0),
af.file_path
`
@@ -269,10 +268,10 @@ type ListAudioFilesInTaggingGroupRow struct {
AlbumArtist string
}
// album_name/album_artist are the PER-TRACK tags (via each track's
// own release_group link), not the folder-level tagging_items
// values. SplitMixedFolder clusters on these to find sub-albums
// hiding inside a folder full of unrelated tracks.
// album_name/album_artist are the PER-TRACK tags (each file's own
// album link), not the folder-level tagging_items values.
// SplitMixedFolder clusters on these to find sub-albums hiding inside
// a folder full of unrelated tracks.
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
if err != nil {
@@ -313,10 +312,7 @@ const listLikelyMixedBagGroupKeys = `-- name: ListLikelyMixedBagGroupKeys :many
SELECT ti.group_key
FROM tagging_items ti
JOIN audio_files af ON af.group_key = ti.group_key
LEFT JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
LEFT JOIN albums rg ON rg.id = af.album_id
WHERE ti.synthetic = 0
AND ti.track_count >= 4
AND (
@@ -324,7 +320,7 @@ WHERE ti.synthetic = 0
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
)
GROUP BY ti.group_key
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1
`
@@ -359,34 +355,31 @@ func (q *Queries) ListLikelyMixedBagGroupKeys(ctx context.Context) ([]string, er
return items, nil
}
const listLocalReleaseGroupCandidates = `-- name: ListLocalReleaseGroupCandidates :many
const listLocalAlbumCandidates = `-- name: ListLocalAlbumCandidates :many
SELECT
rg.id AS release_group_id,
rg.mbid AS release_group_mbid,
rg.name AS album_name,
COALESCE(rg.year, 0) AS year,
COALESCE(ac.text, '') AS artist_credit,
COALESCE(rgr.track_number, 0) AS track_number,
COALESCE(rgr.disc_number, 0) AS disc_number,
COALESCE(r.name, '') AS track_title,
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recordings r ON r.id = rgr.recording_id
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
WHERE rg.mbid IS NOT NULL
AND rg.mbid != ''
AND r.mbid IS NOT NULL
AND r.mbid != ''
AND rg.name = ? COLLATE NOCASE
ORDER BY rg.id, rgr.disc_number, rgr.track_number
al.id AS album_id,
al.mbid AS album_mbid,
al.name AS album_name,
COALESCE(al.year, 0) AS year,
al.artist_credit,
COALESCE(af.track_number, 0) AS track_number,
COALESCE(af.disc_number, 0) AS disc_number,
af.title AS track_title,
COALESCE(af.recording_mbid, '') AS recording_mbid,
af.length_milliseconds
FROM albums al
JOIN audio_files af ON af.album_id = al.id
WHERE al.mbid IS NOT NULL
AND al.mbid != ''
AND af.recording_mbid IS NOT NULL
AND af.recording_mbid != ''
AND al.name = ? COLLATE NOCASE
ORDER BY al.id, af.disc_number, af.track_number
`
type ListLocalReleaseGroupCandidatesRow struct {
ReleaseGroupID int64
ReleaseGroupMbid sql.NullString
type ListLocalAlbumCandidatesRow struct {
AlbumID int64
AlbumMbid sql.NullString
AlbumName string
Year int64
ArtistCredit string
@@ -397,22 +390,21 @@ type ListLocalReleaseGroupCandidatesRow struct {
LengthMilliseconds int64
}
// Returns one row per (release_group, track) combination for any
// local release_group that has an MBID. Callers group these in Go
// and filter by normalized album-name match. Joined case-insensitive
// on name to pre-filter cheaply; Go does the real normalization.
func (q *Queries) ListLocalReleaseGroupCandidates(ctx context.Context, name string) ([]ListLocalReleaseGroupCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, listLocalReleaseGroupCandidates, name)
// One row per (album, track) for any local album carrying an MBID.
// Callers group these in Go and filter by normalized album-name match;
// the join is case-insensitive on name to pre-filter cheaply.
func (q *Queries) ListLocalAlbumCandidates(ctx context.Context, name string) ([]ListLocalAlbumCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, listLocalAlbumCandidates, name)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLocalReleaseGroupCandidatesRow
var items []ListLocalAlbumCandidatesRow
for rows.Next() {
var i ListLocalReleaseGroupCandidatesRow
var i ListLocalAlbumCandidatesRow
if err := rows.Scan(
&i.ReleaseGroupID,
&i.ReleaseGroupMbid,
&i.AlbumID,
&i.AlbumMbid,
&i.AlbumName,
&i.Year,
&i.ArtistCredit,
@@ -454,6 +446,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
AND ti.cleared_at IS NULL
-- Actionable rows must have something to act on: see
-- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are
-- exempt because they are history, not work -- an applied folder is
-- fully tagged by definition and would otherwise vanish from the
-- sidebar's Completed section the instant it succeeded.
AND (
ti.status IN ('confirmed', 'skipped')
OR EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
)
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
LIMIT ?4 OFFSET ?3
`
@@ -628,6 +632,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
AND ti.cleared_at IS NULL
-- See ListPendingTaggingItemsAlphabetical.
AND (
ti.status IN ('confirmed', 'skipped')
OR EXISTS (
SELECT 1 FROM audio_files af
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
)
)
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
LIMIT ?4 OFFSET ?3
`
@@ -758,31 +770,36 @@ func (q *Queries) SetAudioFileTagStatus(ctx context.Context, arg SetAudioFileTag
return err
}
const setRecordingMBID = `-- name: SetRecordingMBID :exec
UPDATE recordings SET mbid = ? WHERE id = ?
const setFileAlbumMBID = `-- name: SetFileAlbumMBID :exec
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
ID int64
}
func (q *Queries) SetRecordingMBID(ctx context.Context, arg SetRecordingMBIDParams) error {
_, err := q.db.ExecContext(ctx, setRecordingMBID, arg.Mbid, arg.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
// 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
}
const setReleaseGroupMBID = `-- name: SetReleaseGroupMBID :exec
UPDATE release_groups SET mbid = ? WHERE id = ?
const setFileRecordingMBID = `-- name: SetFileRecordingMBID :exec
UPDATE audio_files SET recording_mbid = ? WHERE id = ?
`
type SetReleaseGroupMBIDParams struct {
Mbid sql.NullString
ID int64
type SetFileRecordingMBIDParams struct {
RecordingMbid sql.NullString
ID int64
}
func (q *Queries) SetReleaseGroupMBID(ctx context.Context, arg SetReleaseGroupMBIDParams) error {
_, err := q.db.ExecContext(ctx, setReleaseGroupMBID, arg.Mbid, arg.ID)
func (q *Queries) SetFileRecordingMBID(ctx context.Context, arg SetFileRecordingMBIDParams) error {
_, err := q.db.ExecContext(ctx, setFileRecordingMBID, arg.RecordingMbid, arg.ID)
return err
}
+186 -51
View File
@@ -69,10 +69,7 @@ func TestTagStatusBackfillFromRecordingMBID(t *testing.T) {
UPDATE audio_files
SET tag_status = 'user_confirmed'
WHERE tag_status = 'untagged'
AND recording_id IN (
SELECT id FROM recordings
WHERE mbid IS NOT NULL AND mbid != ''
)
AND recording_mbid IS NOT NULL AND recording_mbid != ''
`); err != nil {
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, "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)
if err != nil {
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) {
t.Parallel()
@@ -248,9 +307,13 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
rows, err := db.QueryContext(`
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM tagging_items
WHERE status = 'pending'
AND (CAST(0 AS INTEGER) = 0 OR library_id = 0)
SELECT COUNT(*) FROM tagging_items ti
WHERE ti.status = 'pending'
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 {
t.Fatalf("explain: %v", err)
@@ -281,6 +344,97 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
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
@@ -450,9 +604,7 @@ func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
// helpers
// ---------------------------------------------------------------------------
// seedAF inserts a minimal recording + audio_files pair and returns
// the new audio_files id. All FK-satisfying rows (artist_credit,
// recordings, file_types[0]) are created inline.
// seedAF inserts one file and returns its audio_files id.
func seedAF(
t *testing.T,
db *database.DB,
@@ -462,49 +614,32 @@ func seedAF(
) int64 {
t.Helper()
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Test Artist")
if err != nil {
t.Fatalf("upsert artist credit: %v", err)
}
return database.InsertTestTrack(t, db, database.TestTrack{
FilePath: filePath,
Title: recordingName,
RecordingMBID: recordingMBID,
DiscNumber: discNumber,
LibraryID: libraryID,
LengthMs: 1000,
})
}
rec, err := db.Queries.CreateRecordingFull(
db.Ctx,
sqlcgen.CreateRecordingFullParams{
Name: recordingName,
ArtistCreditID: ac.ID,
},
)
if err != nil {
t.Fatalf("create recording: %v", err)
}
// seedGroupFile attaches one file to a tagging group with an explicit
// tag_status - the thing the queue's "is there anything left to tag
// here" predicate reads.
func seedGroupFile(
t *testing.T,
db *database.DB,
groupKey, filePath, tagStatus string,
) {
t.Helper()
if recordingMBID != "" {
if _, err := db.ExecContext(
`UPDATE recordings SET mbid = ? WHERE id = ?`,
recordingMBID, rec.ID,
); err != nil {
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
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: filePath,
Title: filePath,
GroupKey: groupKey,
TagStatus: tagStatus,
})
}
func seedTaggingItem(
+201 -14
View File
@@ -2,7 +2,10 @@ package database
import (
"database/sql"
"fmt"
"log/slog"
"path/filepath"
"sync/atomic"
"testing"
_ "modernc.org/sqlite" // Register sqlite driver.
@@ -10,16 +13,30 @@ import (
"yellowjacket/backend/database/sql/sqlcgen"
)
// NewTestDB returns an in-memory SQLite database that mirrors the
// production setup (PRAGMAs + all migrations). The database is
// automatically closed when the test completes via t.Cleanup.
// NewTestDB returns an in-memory SQLite database shaped like the real
// one: a single-writer handle and a separate query-only read pool over
// 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 {
t.Helper()
db, err := sql.Open(
"sqlite",
":memory:?_busy_timeout=5000&_journal_mode=WAL",
// A per-test name, so parallel tests do not share a database.
dsn := fmt.Sprintf(
"file:testdb%d?mode=memory&cache=shared&_pragma=busy_timeout(5000)",
testDBSeq.Add(1),
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
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)
}
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{
db: db,
Ctx: ctx,
// The in-memory test DB shares one connection, so reads and
// writes use the same handle; ReadQueries aliases Queries.
Queries: queries,
ReadQueries: queries,
db: db,
readDB: readDB,
Ctx: ctx,
Queries: sqlcgen.New(db),
ReadQueries: sqlcgen.New(readDB),
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
// pre-inserted. Returns the DB and the library ID.
func NewTestDBWithLibrary(
@@ -85,3 +113,162 @@ func NewTestDBWithLibrary(
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}
}
+31 -36
View File
@@ -105,13 +105,10 @@ var internalTables = map[string]bool{
// tables is the catalog. Keep it alphabetical.
var tables = []Table{
{
Name: "artist_credit", Kind: Owned, Lifetime: Swept,
Note: "Credit strings parsed from file tags. Orphan-swept when " +
"no recording references them.",
},
{
Name: "artist_credit_artist", Kind: Owned, Lifetime: Swept,
Note: "Join table between credits and artists.",
Name: "albums", Kind: Owned, Lifetime: Swept,
Note: "Albums as named by file tags — the local counterpart of a " +
"catalog release group, which is a different thing and lives " +
"in explore_index. Swept when the last file on one goes.",
},
{
Name: "artist_enrichment", Kind: Derived, Lifetime: Retained,
@@ -138,11 +135,12 @@ var tables = []Table{
},
{
Name: "audio_files", Kind: Owned, Lifetime: Swept,
Note: "MIXED KIND. Mostly an owned projection of files on disk, " +
"but play_count, last_played and tag_status are authored and " +
"exist nowhere else. Deleting a row to rebuild it destroys " +
"that authored state — which is why a file rename currently " +
"loses play counts. See the data architecture plan.",
Note: "MIXED KIND. One row per file, carrying its tags: mostly an " +
"owned projection of what is on disk, but play_count, " +
"last_played and tag_status are authored and exist nowhere " +
"else. Deleting a row to rebuild it destroys that authored " +
"state — which is why a file rename currently loses play " +
"counts. See the data architecture plan.",
},
{
Name: "cover_art", Kind: Owned, Lifetime: Swept,
@@ -209,6 +207,15 @@ var tables = []Table{
"rescan clears it \u2014 the only way back for a path removed by " +
"mistake.",
},
{
Name: "file_genres", Kind: Owned, Lifetime: Swept,
Note: "Genres per file. The one many-to-many in the local library " +
"that really is one. It cascades with the *file*, but the " +
"genre_id key is NO ACTION, so a genre cannot be deleted " +
"while a link survives — the genre sweep runs after the file " +
"sweep for that reason, and only deletes genres nothing " +
"references.",
},
{
Name: "file_types", Kind: Derived, Lifetime: Retained,
Note: "Static lookup rows seeded from code, not user data.",
@@ -232,6 +239,15 @@ var tables = []Table{
Note: "The directories the user chose. Removed only by explicit " +
"user action via RemoveLibrary.",
},
{
Name: "lyrics", Kind: Cache, Lifetime: Cascade,
Note: "MIXED KIND, and it says which: source='tag' is Owned (any " +
"rescan reads it back off the file) and source='lrclib' is " +
"Cache (re-fetching it is network traffic and someone else's " +
"rate limit). These used to be one untyped column on " +
"recordings, in a table classified Owned, so 24,294 rows in a " +
"real library could not say which of the two they were.",
},
{
Name: "lyrics_index", Kind: Derived, Lifetime: Retained, FTS: true,
Note: "Full-text index over embedded and fetched lyrics. Rebuilt " +
@@ -266,32 +282,11 @@ var tables = []Table{
Note: "Queue entries. Cascade with their track; the queue is " +
"compacted afterwards.",
},
{
Name: "recording_genres", Kind: Owned, Lifetime: Swept,
Note: "Join table between recordings and genres.",
},
{
Name: "recordings", Kind: Owned, Lifetime: Swept,
Note: "Tracks as parsed from file tags. Orphan-swept.",
},
{
Name: "release_group_recordings", Kind: Owned, Lifetime: Swept,
Note: "Join table between release groups and recordings.",
},
{
Name: "release_groups", Kind: Owned, Lifetime: Swept,
Note: "Albums as parsed from file tags. Orphan-swept.",
},
{
Name: "release_to_rg", Kind: Cache, Lifetime: Retained,
Note: "Release to release-group mapping from the dump.",
},
{
Name: "schema_migrations", Kind: Derived, Lifetime: Retained,
Note: "Bookkeeping for sql/migrations: which numbered files have " +
"run. Safe to lose — replaying an already-applied migration " +
"tolerates its ALTER TABLE ADD COLUMN as a no-op and just " +
"re-records it.",
Note: "Release to release-group mapping, captured during a local " +
"dump import and read by the incremental listen-count " +
"refresh. Empty unless this install built its own index.",
},
{
Name: "search_clicks", Kind: Authored, Lifetime: Retained,
+2
View File
@@ -486,6 +486,8 @@ func (s *Service) ClearFinished() error {
// SetReconciler wires the request-list loop. Optional: without it the
// request list still stores and lists requests, it just never acts on
// them.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (s *Service) SetReconciler(r *Reconciler) {
s.reconciler = r
}
+89 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite" // SQLite driver for reading the artifact file.
@@ -91,9 +92,90 @@ type artifactInfo struct {
// TestArtifactColumnsMatchExporter.
const artifactCatalogColumns = `entity_type, mbid, title, artist_name, artist_mbid,
aliases, popularity, listener_count, duration, caa_release_mbid,
release_name, primary_type, secondary_types, release_date,
release_name, primary_type, secondary_types, release_date, total_tracks,
artist_type, country, disambiguation, sort_name, discog_fetched`
// artifactSelectColumns is the same list as read *from an artifact*,
// converting the two columns whose storage this app changed.
//
// The local table stores an MBID as 16 raw bytes and an entity type as a
// small integer, which took the catalog and its indexes from 677 MB to
// 389 MB. A published artifact still carries the text form, and there
// is no reason it should not: converting on the way in costs one
// `unhex` per row on a once-a-month import, and it means a new build
// reads the artifact that is already out there rather than requiring
// one to be rebuilt and re-downloaded first.
//
// An artifact that already carries the compact form is copied straight
// through - `artifactStoresText` decides which, by asking the artifact
// rather than by trusting a version number.
func artifactSelectColumns(text, totals bool) string {
totalTracks := "total_tracks"
if !totals {
// An artifact built before the column existed. Zero is what the
// column means by "the catalog does not say", so an older
// artifact imports as one that declines to answer rather than
// failing to import at all.
totalTracks = "0"
}
if !text {
return strings.Replace(
artifactCatalogColumns, "total_tracks", totalTracks, 1,
)
}
return `CASE entity_type
WHEN 'artist' THEN 1
WHEN 'release_group' THEN 2
WHEN 'recording' THEN 3
ELSE 0 END,
unhex(replace(mbid, '-', '')),
title, artist_name,
CASE WHEN artist_mbid = '' THEN x''
ELSE unhex(replace(artist_mbid, '-', '')) END,
aliases, popularity, listener_count, duration,
CASE WHEN caa_release_mbid = '' THEN x''
ELSE unhex(replace(caa_release_mbid, '-', '')) END,
release_name, primary_type, secondary_types, release_date, ` +
totalTracks + `,
artist_type, country, disambiguation, sort_name, discog_fetched`
}
// artifactHasTotals reports whether the attached artifact carries the
// per-release-group track denominator. An artifact published before
// that column existed is still a perfectly good catalog, so it is asked
// rather than assumed - the same rule, and the same handle, as
// artifactStoresText below.
func (si *SearchIndex) artifactHasTotals() bool {
var n int
err := si.db.QueryRowWriter(
`SELECT COUNT(*) FROM pragma_table_info('explore_index', 'core')
WHERE name = 'total_tracks'`,
).Scan(&n)
return err == nil && n > 0
}
// artifactStoresText reports whether the attached artifact carries the
// old text encoding.
func (si *SearchIndex) artifactStoresText() bool {
// The writer, not QueryContext: "core" is attached to that one
// connection and does not exist on the read pool. Asking the wrong
// handle errors, and the fallback would then convert an artifact
// that needs no conversion.
var kind string
if err := si.db.QueryRowWriter(
"SELECT typeof(mbid) FROM core.explore_index LIMIT 1",
).Scan(&kind); err != nil {
return true
}
return kind == "text"
}
// inspectArtifact opens the artifact read-only and reports what it
// declares, without touching the live index. Validation happens here so
// a bad download is rejected before anything is attached.
@@ -263,9 +345,13 @@ func (si *SearchIndex) analyzeIndex() {
// is an index range scan and a cancelled import leaves committed work
// behind rather than rolling it all back.
func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) {
selectColumns := artifactSelectColumns(
si.artifactStoresText(), si.artifactHasTotals(),
)
insertSQL := `
INSERT INTO explore_index (` + artifactCatalogColumns + `)
SELECT ` + artifactCatalogColumns + `
SELECT ` + selectColumns + `
FROM core.explore_index
WHERE mbid > ?` + upsertIndexConflictSQL
@@ -273,7 +359,7 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
// appended only while one exists.
insertRangeSQL := `
INSERT INTO explore_index (` + artifactCatalogColumns + `)
SELECT ` + artifactCatalogColumns + `
SELECT ` + selectColumns + `
FROM core.explore_index
WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL
+158 -3
View File
@@ -202,7 +202,7 @@ func TestImportCoreArtifactPreservesLocalData(t *testing.T) {
if err := db.QueryRowWriter(`
SELECT popularity, duration, in_library, discog_fetched
FROM explore_index WHERE mbid = ?`, recA,
FROM explore_index WHERE mbid = ?`, dbMBID(recA),
).Scan(&popularity, &duration, &inLibrary, &discogFetched); err != nil {
t.Fatalf("read merged row: %v", err)
}
@@ -376,7 +376,7 @@ func TestAddFromCacheNeverStoresMBIDAsName(t *testing.T) {
var artistName string
if err := db.QueryRowWriter(
`SELECT artist_name FROM explore_index WHERE mbid = ?`, rgA,
`SELECT artist_name FROM explore_index WHERE mbid = ?`, dbMBID(rgA),
).Scan(&artistName); err != nil {
t.Fatalf("read release group: %v", err)
}
@@ -391,7 +391,7 @@ func TestAddFromCacheNeverStoresMBIDAsName(t *testing.T) {
})
if err := db.QueryRowWriter(
`SELECT artist_name FROM explore_index WHERE mbid = ?`, rgA,
`SELECT artist_name FROM explore_index WHERE mbid = ?`, dbMBID(rgA),
).Scan(&artistName); err != nil {
t.Fatalf("re-read release group: %v", err)
}
@@ -442,3 +442,158 @@ func TestArtifactColumnsMatchExporter(t *testing.T) {
exporter, importer)
}
}
// TestImportCoreArtifactAcceptsBothEncodings is the compatibility half
// of the storage change.
//
// The catalog stores an MBID as 16 raw bytes and an entity type as a
// code, which took the table and its indexes from 677 MB to 389 MB. A
// published artifact carries whichever form the exporter that built it
// used, and there is one already out there in the older text form — so
// the importer decides by asking the artifact, not by trusting a
// version number, and both must land identically.
func TestImportCoreArtifactAcceptsBothEncodings(t *testing.T) {
compact := filepath.Join(t.TempDir(), "core-index.db")
db, err := sql.Open("sqlite", "file:"+compact)
if err != nil {
t.Fatalf("open artifact: %v", err)
}
if _, err := db.Exec(`CREATE TABLE explore_index (
entity_type INTEGER NOT NULL,
mbid BLOB NOT NULL,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
artist_mbid BLOB NOT NULL,
aliases TEXT NOT NULL DEFAULT '',
popularity INTEGER NOT NULL DEFAULT 0,
listener_count INTEGER NOT NULL DEFAULT 0,
duration INTEGER NOT NULL DEFAULT 0,
caa_release_mbid BLOB NOT NULL DEFAULT x'',
release_name TEXT NOT NULL DEFAULT '',
primary_type TEXT NOT NULL DEFAULT '',
secondary_types TEXT NOT NULL DEFAULT '',
release_date TEXT NOT NULL DEFAULT '',
artist_type TEXT NOT NULL DEFAULT '',
country TEXT NOT NULL DEFAULT '',
disambiguation TEXT NOT NULL DEFAULT '',
sort_name TEXT NOT NULL DEFAULT '',
discog_fetched INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (mbid)
)`); err != nil {
t.Fatalf("create artifact table: %v", err)
}
if _, err := db.Exec(
`CREATE TABLE artifact_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
); err != nil {
t.Fatalf("create artifact meta: %v", err)
}
for k, v := range validMeta() {
if _, err := db.Exec(
"INSERT INTO artifact_meta (key, value) VALUES (?, ?)", k, v,
); err != nil {
t.Fatalf("write artifact meta: %v", err)
}
}
if _, err := db.Exec(`
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity)
VALUES (1, ?, 'Artist A', 'Artist A', ?, 5000)`,
mbidBytes(artA), mbidBytes(artA),
); err != nil {
t.Fatalf("write artifact row: %v", err)
}
_ = db.Close()
live := database.NewTestDB(t)
si := NewSearchIndex(live, nil, nil, testLogger())
if err := si.importCoreArtifact(context.Background(), compact); err != nil {
t.Fatalf("importCoreArtifact (compact): %v", err)
}
got := si.LookupArtistByMBID(artA)
if got == nil {
t.Fatal("artist from a compact artifact was not imported")
}
if got.Title != "Artist A" || got.MBID != artA {
t.Errorf("imported %+v, want Artist A / %s", got, artA)
}
}
// TestImportCoreArtifactReadsTotalsWhenPresent covers both halves of the
// denominator's arrival: an artifact that carries total_tracks imports
// it, and one built before the column existed still imports at all.
//
// The second half is the one worth a test. Adding a column to the
// importer's SELECT list is how you break every artifact already
// published - "no such column: total_tracks", on a file nobody can
// re-cut retroactively - so the importer asks the artifact what it has,
// the same way it asks which encoding it uses.
func TestImportCoreArtifactReadsTotalsWhenPresent(t *testing.T) {
path := writeTestArtifact(t, validMeta(), []artifactRow{
{"release_group", rgA, "Big Album", "Solo Star", artA, 5000},
})
// The exporter writes the column; writeTestArtifact builds the older
// shape, so add it here rather than changing every other test's
// fixture to carry a value they do not use.
artifact, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatalf("reopen artifact: %v", err)
}
for _, stmt := range []string{
`ALTER TABLE explore_index ADD COLUMN total_tracks INTEGER NOT NULL DEFAULT 0`,
`UPDATE explore_index SET total_tracks = 12`,
} {
if _, err := artifact.Exec(stmt); err != nil {
t.Fatalf("add total_tracks: %v", err)
}
}
_ = artifact.Close()
live := database.NewTestDB(t)
si := NewSearchIndex(live, nil, nil, testLogger())
if err := si.importCoreArtifact(context.Background(), path); err != nil {
t.Fatalf("importCoreArtifact: %v", err)
}
rg := si.LookupReleaseGroupByMBID(rgA)
if rg == nil {
t.Fatal("release group was not imported")
}
if rg.TotalTracks != 12 {
t.Errorf("TotalTracks = %d, want 12", rg.TotalTracks)
}
// And the shape that predates the column: the same import, from an
// artifact that has no total_tracks at all.
older := writeTestArtifact(t, validMeta(), []artifactRow{
{"release_group", rgB, "Duet Album", "Solo Star", artA, 4000},
})
live2 := database.NewTestDB(t)
si2 := NewSearchIndex(live2, nil, nil, testLogger())
if err := si2.importCoreArtifact(context.Background(), older); err != nil {
t.Fatalf("importCoreArtifact (no total_tracks column): %v", err)
}
old := si2.LookupReleaseGroupByMBID(rgB)
if old == nil {
t.Fatal("release group from a column-less artifact was not imported")
}
if old.TotalTracks != 0 {
t.Errorf("TotalTracks = %d, want 0 (the catalog does not say)", old.TotalTracks)
}
}
+9 -13
View File
@@ -12,19 +12,15 @@ import (
func seedIndexArtist(t *testing.T, db *database.DB, mbid string, discogFetched int) {
t.Helper()
if _, err := db.ExecContext(
upsertIndexSQL,
"artist", mbid, "Seeded Artist", "Seeded Artist", mbid, "",
0, 0,
0, "", "",
"", "", "",
"", "", "", "",
1, 0,
0, 0, 0,
discogFetched,
); err != nil {
t.Fatalf("seed explore_index row for %q: %v", mbid, err)
}
seedIndexResult(t, db, SearchIndexResult{
EntityType: EntityArtist,
MBID: testMBID(mbid),
Title: "Seeded Artist",
ArtistName: "Seeded Artist",
ArtistMBID: testMBID(mbid),
InLibrary: true,
DiscogFetched: discogFetched == 1,
})
}
// TestArtistEnrichmentMarksAreIndependent is the reason these are two
+113
View File
@@ -0,0 +1,113 @@
package explore
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"yellowjacket/backend/database"
)
// lbPopularityServer serves both top-for-artist popularity endpoints
// with a fixed status and body, and counts what it was asked.
func lbPopularityServer(
t *testing.T, status int, body string,
) (*ListenBrainzClient, *int) {
t.Helper()
requests := 0
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
},
))
t.Cleanup(srv.Close)
lb := NewListenBrainzClient(NewRateLimiterN(1000), nil, slog.Default())
lb.SetBaseURL(srv.URL)
return lb, &requests
}
// TestTopFetchesSeparateEmptyFromFailed is the distinction the
// owned-artist backfill's mark rests on. ListenBrainz answers 200 with
// `[]` for an artist it has no popularity data for — which is most of a
// long-tail library — and an empty answer is a *complete* one. When
// discog_fetched was keyed on "did rows come back", those artists were
// never marked, stayed in unenrichedLibraryArtistMBIDs forever, and
// "Filling in artist details" re-ran for them on every single launch.
func TestTopFetchesSeparateEmptyFromFailed(t *testing.T) {
t.Parallel()
si := NewSearchIndex(database.NewTestDB(t), nil, nil, slog.Default())
artist := lbSitewideArtist{
ArtistMBID: "44444444-4444-4444-4444-444444444444",
ArtistName: "Nobody Has Listened",
}
t.Run("empty is success", func(t *testing.T) {
t.Parallel()
lb, _ := lbPopularityServer(t, http.StatusOK, `[]`)
rgs, err := si.fetchTopReleaseGroups(t.Context(), lb, artist, 50)
if err != nil || len(rgs) != 0 {
t.Errorf("top RGs: got %d rows, err %v; want 0 rows and no error", len(rgs), err)
}
recs, err := si.fetchTopRecordings(t.Context(), lb, artist, 200)
if err != nil || len(recs) != 0 {
t.Errorf("top recordings: got %d rows, err %v; want 0 rows and no error",
len(recs), err,
)
}
})
// Below indexMinPopularity is the same shape one step in: the
// endpoint answered, we simply keep none of it.
t.Run("everything below the popularity floor is success", func(t *testing.T) {
t.Parallel()
lb, _ := lbPopularityServer(t, http.StatusOK,
`[{"release_group_mbid":"rg-1","total_listen_count":3,`+
`"release_group":{"name":"Obscure"}}]`)
rgs, err := si.fetchTopReleaseGroups(t.Context(), lb, artist, 50)
if err != nil || len(rgs) != 0 {
t.Errorf("top RGs: got %d rows, err %v; want 0 rows and no error", len(rgs), err)
}
})
// A failure must stay a failure, or the retry this is built on goes
// away and a throttled run marks artists it never fetched.
t.Run("HTTP error is a failure", func(t *testing.T) {
t.Parallel()
lb, _ := lbPopularityServer(t, http.StatusServiceUnavailable, `nope`)
if _, err := si.fetchTopReleaseGroups(t.Context(), lb, artist, 50); err == nil {
t.Error("top RGs reported success on a 503")
}
if _, err := si.fetchTopRecordings(t.Context(), lb, artist, 200); err == nil {
t.Error("top recordings reported success on a 503")
}
})
t.Run("unparseable body is a failure", func(t *testing.T) {
t.Parallel()
lb, _ := lbPopularityServer(t, http.StatusOK, `{"not":"an array"}`)
if _, err := si.fetchTopReleaseGroups(t.Context(), lb, artist, 50); err == nil {
t.Error("top RGs reported success on a body it could not read")
}
})
}
+34 -5
View File
@@ -47,6 +47,13 @@ const (
// canonicalProgressRows controls progress reporting during the
// canonical CSV scan (~30M rows total).
canonicalProgressRows = 2_000_000
// maxReleaseTracks caps the per-release track count, so a malformed
// row cannot turn the denominator into nonsense. Well above the
// longest real release, and it is a cap rather than a rejection
// because a box set reporting 999 is still a better answer than one
// reporting nothing.
maxReleaseTracks = 999
)
// Per-artist discography coverage (S2). The global budgets above keep
@@ -329,6 +336,18 @@ type canonicalScan struct {
releaseToRG map[uuid16]rgTarget
artistNames map[uuid16]string
// releaseTracks counts the canonical dump's rows per kept release,
// which is that release's track count: the dump carries one row per
// recording per canonical release.
//
// It is counted *before* the popularity filter below, unlike almost
// everything else here, because a denominator built from the kept
// recordings would say "9" about a twelve-track album whose other
// three are unpopular - which is worse than saying nothing, and is
// exactly the confident lie that kept whole tracklists out of the
// artifact. Bounded by the kept release set, not by MusicBrainz.
releaseTracks map[uuid16]uint16
// artistTracks accumulates each target artist's top recordings for
// S2 coverage; merged into recordings before assembly.
artistTracks *perArtistTracks
@@ -454,10 +473,11 @@ func (imp *dumpImporter) scanCanonicalDump(
defer zr.Close()
scan := &canonicalScan{
releaseInfos: make(map[uuid16]releaseInfo, len(ks.releases)),
releaseToRG: make(map[uuid16]rgTarget, len(ks.releases)),
artistNames: make(map[uuid16]string, len(ks.artists)),
artistTracks: newPerArtistTracks(),
releaseInfos: make(map[uuid16]releaseInfo, len(ks.releases)),
releaseToRG: make(map[uuid16]rgTarget, len(ks.releases)),
artistNames: make(map[uuid16]string, len(ks.artists)),
releaseTracks: make(map[uuid16]uint16, len(ks.releases)),
artistTracks: newPerArtistTracks(),
}
sawData, sawRedirect := false, false
@@ -614,9 +634,17 @@ func (imp *dumpImporter) scanCanonicalData(
}
}
// Release display info for release-group titling.
// Release display info for release-group titling, and the
// release's track count. Both are for kept releases only, and
// the count is taken here rather than below because every row
// of this dump is one track of its release regardless of how
// often anyone played it.
if relOK {
if _, kept := ks.releases[relMBID]; kept {
if n := scan.releaseTracks[relMBID]; n < maxReleaseTracks {
scan.releaseTracks[relMBID] = n + 1
}
if _, seen := scan.releaseInfos[relMBID]; !seen {
firstArtist := ""
if len(artistMBIDs) > 0 {
@@ -1004,6 +1032,7 @@ func (imp *dumpImporter) assembleIndex(
ArtistName: info.artistName,
ArtistMBID: info.artistMBID,
Popularity: int(agg.listens),
TotalTracks: int(scan.releaseTracks[agg.bestRel]),
CAAReleaseMBID: formatUUID(agg.canonical[:]),
})
+49 -3
View File
@@ -277,6 +277,12 @@ func canonicalDataCSV(t *testing.T) []byte {
"3", "11", "{" + artA + "," + artB + "}", "Solo Star feat. Guest",
relB, "Duet Album", recC, "Duet Song", "x", "1",
},
// Unplayed, so it survives no popularity floor and is not
// indexed -- but it is still a track on Big Album.
{
"4", "10", "{" + artA + "}", "Solo Star",
relA, "Big Album", recD, "Album Filler", "x", "1",
},
}
return csvBytes(t, rows)
@@ -594,7 +600,7 @@ func TestDumpImportEndToEnd(t *testing.T) {
rows, err := db.QueryContext(
"SELECT title, popularity FROM explore_index WHERE mbid = ? AND entity_type = ?",
mbid, entityType,
dbMBID(mbid), dbEntityType(entityType),
)
if err != nil {
t.Fatalf("query: %v", err)
@@ -627,11 +633,51 @@ func TestDumpImportEndToEnd(t *testing.T) {
assertRow(rgB, "release_group", "Duet Album", 12)
assertRow(artA, "artist", "Solo Star", 35)
// The per-release-group denominator: how many tracks the release
// has, counted from the canonical dump *before* the popularity
// filter. Big Album has three, one of which nobody has played and
// which is therefore not indexed as a recording at all -- a
// denominator built from the kept recordings would say two, and
// "you have 2 of 2" about a three-track album is worse than saying
// nothing.
assertTotalTracks := func(mbid string, want int) {
t.Helper()
var got int
if err := db.QueryRowWriter(
"SELECT total_tracks FROM explore_index WHERE mbid = ? AND entity_type = 2",
dbMBID(mbid),
).Scan(&got); err != nil {
t.Fatalf("read total_tracks for %s: %v", mbid, err)
}
if got != want {
t.Errorf("total_tracks for %s = %d, want %d", mbid, got, want)
}
}
assertTotalTracks(rgA, 3)
assertTotalTracks(rgB, 1)
// And the unplayed track is still not an indexed recording.
var fillerRows int
if err := db.QueryRowWriter(
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(recD),
).Scan(&fillerRows); err != nil {
t.Fatalf("count recD rows: %v", err)
}
if fillerRows != 0 {
t.Errorf("unplayed track was indexed as a recording (%d rows)", fillerRows)
}
// artB only ever appears in a multi-artist credit: no name is
// derivable from the dump, so it must be queued for the API
// metadata patch instead of being written nameless.
rows, err := db.QueryContext(
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", artB,
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(artB),
)
if err != nil {
t.Fatalf("query artB: %v", err)
@@ -1006,7 +1052,7 @@ func TestListenerCountUpdateDoesNotTouchPopularity(t *testing.T) {
}
rows, err := db.QueryContext(
"SELECT popularity, listener_count FROM explore_index WHERE mbid = ?", recA,
"SELECT popularity, listener_count FROM explore_index WHERE mbid = ?", dbMBID(recA),
)
if err != nil {
t.Fatalf("query: %v", err)
+7 -2
View File
@@ -277,7 +277,10 @@ func (si *SearchIndex) commitListenDeltas(
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(
"CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid TEXT, kind TEXT, delta INTEGER)",
// mbid and kind are stored the way explore_index stores them,
// so the join below is a plain equality rather than a
// conversion per row.
"CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid BLOB, kind INTEGER, delta INTEGER)",
); err != nil {
return fmt.Errorf("incremental temp table: %w", err)
}
@@ -357,8 +360,10 @@ func insertDeltas(tx *sql.Tx, kind string, deltas map[string]uint32) error {
return nil
}
code := entityCode(kind)
for mbid, d := range deltas {
rowArgs = append(rowArgs, mbid, kind, int64(d))
rowArgs = append(rowArgs, mbidBytes(mbid), code, int64(d))
pending++
if pending >= deltaInsertBatch {
+1 -1
View File
@@ -36,7 +36,7 @@ func popularityOf(t *testing.T, db *database.DB, mbid string) (int, bool) {
t.Helper()
rows, err := db.QueryContext(
"SELECT popularity FROM explore_index WHERE mbid = ?", mbid,
"SELECT popularity FROM explore_index WHERE mbid = ?", dbMBID(mbid),
)
if err != nil {
t.Fatalf("query popularity: %v", err)
+6 -6
View File
@@ -54,7 +54,7 @@ func (imp *dumpImporter) runPatchPasses(ctx context.Context) {
func (imp *dumpImporter) patchArtistMetadata(ctx context.Context) {
rows, err := imp.si.db.QueryContext(`
SELECT mbid FROM explore_index
WHERE entity_type = 'artist'
WHERE entity_type = 1 /* artist */
AND (artist_type = '' OR country = '' OR title = '' OR title = mbid)
`)
if err != nil {
@@ -152,7 +152,7 @@ func (imp *dumpImporter) patchSimilarArtists(ctx context.Context) {
for _, s := range similar {
_, _ = imp.si.db.ExecContext(
"UPDATE explore_index SET is_similar = 1 WHERE artist_mbid = ?",
s.ArtistMBID,
dbMBID(s.ArtistMBID),
)
}
}
@@ -219,7 +219,7 @@ func (imp *dumpImporter) topMBIDs(entityType string, limit int) []string {
WHERE entity_type = ? AND listener_count = 0
ORDER BY popularity DESC
LIMIT ?
`, entityType, limit)
`, dbEntityType(entityType), limit)
if err != nil {
return nil
}
@@ -229,9 +229,9 @@ func (imp *dumpImporter) topMBIDs(entityType string, limit int) []string {
var mbids []string
for rows.Next() {
var m string
var m dbMBID
if err := rows.Scan(&m); err == nil {
mbids = append(mbids, m)
mbids = append(mbids, string(m))
}
}
@@ -264,7 +264,7 @@ func (si *SearchIndex) updateListenerCounts(updates map[string]PopularityData) i
`UPDATE explore_index
SET listener_count = ?
WHERE mbid = ? AND listener_count < ?`,
data.ListenerCount, strings.ToLower(mbid), data.ListenerCount,
data.ListenerCount, dbMBID(strings.ToLower(mbid)), data.ListenerCount,
)
if err != nil {
continue
+108
View File
@@ -0,0 +1,108 @@
package explore
import (
"testing"
"yellowjacket/backend/database"
)
// TestStoredEncodingRoundTrips walks every read path in the package
// against a row written by the real upsert.
//
// It exists because of how this storage change fails when it fails.
// MBIDs are stored as 16 raw bytes and entity types as codes - which
// took the catalog and its indexes from 780 MB to 405 MB, measured on a
// real 2,052,200-row catalog - and SQLite does not coerce between TEXT
// and BLOB. A query that still compares against a 36-character string
// therefore returns *no rows* rather than an error, and a scan into a
// plain string yields sixteen bytes of mojibake. Neither shows up as a
// failure anywhere except in a result that is quietly empty.
//
// So this is not a unit test of the encoding (that is TestMBIDRoundTrip)
// but a sweep: every query that reads the catalog, asserted to return
// something and to hand back canonical dashed ids.
func TestStoredEncodingRoundTrips(t *testing.T) {
db := database.NewTestDB(t)
si := NewSearchIndex(db, nil, nil, testLogger())
const (
artist = "c0b2500e-0cef-4130-9b13-1b9d9a2f2c07"
album = "11111111-2222-3333-4444-555555555555"
)
si.upsertBatch([]SearchIndexResult{
{
EntityType: EntityArtist,
MBID: artist,
Title: "Radiohead",
ArtistName: "Radiohead",
ArtistMBID: artist,
Popularity: 90000,
},
{
EntityType: EntityReleaseGroup,
MBID: album,
Title: "Kid A",
ArtistName: "Radiohead",
ArtistMBID: artist,
Popularity: 80000,
CAAReleaseMBID: album,
},
})
// The stored form is the compact one, not the strings above.
var typeMBID, typeEntity string
if err := db.QueryRowWriter(
"SELECT typeof(mbid), typeof(entity_type) FROM explore_index LIMIT 1",
).Scan(&typeMBID, &typeEntity); err != nil {
t.Fatalf("typeof: %v", err)
}
if typeMBID != "blob" || typeEntity != "integer" {
t.Errorf("stored as mbid=%s entity_type=%s, want blob/integer", typeMBID, typeEntity)
}
si.MarkReadyIfPopulated()
// Read paths.
if got := si.LookupArtistByMBID(artist); got == nil {
t.Error("LookupArtistByMBID found nothing")
} else if got.MBID != artist || got.ArtistMBID != artist || got.EntityType != EntityArtist {
t.Errorf("lookup artist = %+v, want dashed ids and the artist type", got)
}
if got := si.LookupReleaseGroupByMBID(album); got == nil {
t.Error("LookupReleaseGroupByMBID found nothing")
} else if got.MBID != album || got.EntityType != EntityReleaseGroup {
t.Errorf("lookup album = %+v, want the dashed id and the release-group type", got)
}
if rgs := si.TopReleaseGroupsByArtist(artist, 5); len(rgs) == 0 {
t.Error("TopReleaseGroupsByArtist found nothing")
} else if rgs[0].MBID != album || rgs[0].ArtistMBID != artist {
t.Errorf("top release groups[0] = %+v, want %s by %s", rgs[0], album, artist)
}
// The exact-match tier reads the two partial LOWER() indexes, whose
// predicate has to agree with its WHERE clause or the seek silently
// becomes a scan.
if m := si.ExactMatches("radiohead", 3); len(m) == 0 {
t.Error("ExactMatches found nothing")
} else if m[0].MBID != artist || m[0].EntityType != EntityArtist {
t.Errorf("exact match[0] = %+v, want the artist", m[0])
}
if hits := si.Search(t.Context(), "radiohead", 5); len(hits) == 0 {
t.Error("Search found nothing")
} else if hits[0].MBID != artist || hits[0].EntityType != EntityArtist {
t.Errorf("search hit[0] = %+v, want the artist", hits[0])
}
if b := si.GetPopularityBatch([]string{artist, album}); b == nil || len(b.Popularity) != 2 {
t.Errorf("GetPopularityBatch = %+v, want two entries", b)
}
if m := si.ReleaseGroupMBIDsForCAAReleaseMBIDs([]string{album}); len(m) != 1 {
t.Errorf("CAA lookup = %v, want one entry", m)
}
}
+38 -6
View File
@@ -22,8 +22,8 @@ func seedIndexRow(
_, err := db.ExecContext(`
INSERT INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count)
VALUES (?, ?, ?, ?, '', ?, ?)
`, entityType, mbid, title, artist, popularity, popularity/10)
VALUES (?, ?, ?, ?, x'', ?, ?)
`, dbEntityType(entityType), dbMBID(testMBID(mbid)), title, artist, popularity, popularity/10)
if err != nil {
t.Fatalf("seed %s/%s: %v", entityType, mbid, err)
}
@@ -73,12 +73,12 @@ func TestEvalHarnessIndexRanking(t *testing.T) {
{
Query: "radiohead",
Note: "popular exact artist match",
Expect: []eval.Expected{{Type: "artist", MBID: "rh"}},
Expect: []eval.Expected{{Type: "artist", MBID: testMBID("rh")}},
},
{
Query: "the teenagers",
Note: "low-popularity exact match must beat high-popularity article match",
Expect: []eval.Expected{{Type: "artist", MBID: "teenagers"}},
Expect: []eval.Expected{{Type: "artist", MBID: testMBID("teenagers")}},
},
}
@@ -123,8 +123,13 @@ func TestExploreFTSDiacriticFolding(t *testing.T) {
t.Fatalf("query %q returned no hits", tc.query)
}
if hits[0].MBID != tc.wantMBID {
t.Errorf("query %q: top hit = %q, want %q", tc.query, hits[0].MBID, tc.wantMBID)
if hits[0].MBID != testMBID(tc.wantMBID) {
t.Errorf(
"query %q: top hit = %q, want %q",
tc.query,
hits[0].MBID,
testMBID(tc.wantMBID),
)
}
})
}
@@ -148,3 +153,30 @@ func TestEvalFixtureFileParses(t *testing.T) {
}
}
}
// seedIndexResult writes one explore_index row through the same
// upsertBatch every other writer uses.
//
// The three seeders that used to bind upsertIndexSQL's parameters by
// hand said, in a comment, that this was on purpose: a schema change
// should break the tests where it breaks the app. It did not - it
// broke them at "missing argument with index 25", one file at a time,
// for a column none of them cares about. Going through the one writer
// keeps the property they wanted (a field written to the wrong column
// still fails here) without three copies of a 24-argument list.
func seedIndexResult(t *testing.T, db *database.DB, r SearchIndexResult) {
t.Helper()
NewSearchIndex(db, nil, nil, slog.Default()).
upsertBatch([]SearchIndexResult{r})
// upsertBatch logs and swallows, which is right for a background
// merge and useless for a fixture, so the row is checked for.
var n int
if err := db.QueryRowWriter(
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(r.MBID),
).Scan(&n); err != nil || n == 0 {
t.Fatalf("seed explore_index row %q: not written (%v)", r.MBID, err)
}
}
+7
View File
@@ -130,12 +130,16 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
// MusicBrainz returns the shared cached MB client so other services
// (e.g. autotag) can reuse it without spinning up a second limiter.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) MusicBrainz() *MusicBrainzClient {
return e.mb
}
// CAALimiter returns the shared Cover Art Archive rate limiter.
// Consumers must respect it for any fresh CAA HTTP GETs.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) CAALimiter() *RateLimiter {
return e.caaLimiter
}
@@ -185,6 +189,8 @@ func (e *Service) CoreCatalogImported() bool {
// SetJobRegistry wires the background job registry into the search
// index so its build reports progress and controls to the frontend.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) SetJobRegistry(reg *jobs.Registry) {
e.index.SetJobRegistry(reg)
}
@@ -561,6 +567,7 @@ func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) {
FirstReleaseDate: indexed.ReleaseDate,
InLibrary: indexed.InLibrary || indexed.LocalReleaseGroupID > 0,
LocalID: indexed.LocalReleaseGroupID,
TotalTracks: indexed.TotalTracks,
}
// Background: fetch full MB data once per RG to fill in fields
+38 -15
View File
@@ -18,9 +18,22 @@ func NewLibraryMBIDIndex(db *database.DB) *LibraryMBIDIndex {
return &LibraryMBIDIndex{db: db}
}
// CheckMBIDs returns which of the given MBIDs exist in the local
// library. The returned map has MBID entity type ("artist",
// CheckMBIDs returns which of the given MBIDs the library actually
// has a file for. The returned map is MBID -> entity type ("artist",
// "release_group", or "recording").
//
// The "has a file" part is the whole point and is what this used to get
// wrong. It was three `SELECT mbid FROM <metadata table>` queries, and
// a metadata row could outlive the file that created it - retagging a
// file abandoned its old recording row, which kept the old MBID
// forever. Measured on a real library: 812 orphaned recordings, of
// which 218 carried MBIDs, and 129 catalog rows that this function
// therefore reported as owned. Every one of them rendered as a track
// you have, with a play button that could not work, because playback
// resolves files and this resolved metadata.
//
// Each branch now joins audio_files. An entity is in your library if
// and only if a file says so.
func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
if len(mbids) == 0 {
return nil
@@ -28,16 +41,26 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
result := make(map[string]string, len(mbids))
// Batch check all MBIDs against each table with a single IN query.
type tableEntity struct {
table string
type entityQuery struct {
entityType string
query string
}
tables := []tableEntity{
{"artists", "artist"},
{"release_groups", "release_group"},
{"recordings", "recording"},
queries := []entityQuery{
{"recording", `SELECT DISTINCT recording_mbid FROM audio_files
WHERE recording_mbid IN (%s)`},
{"release_group", `SELECT DISTINCT al.mbid FROM albums al
JOIN audio_files af ON af.album_id = al.id
WHERE al.mbid IN (%s)`},
{"artist", `SELECT DISTINCT a.mbid FROM artists a
WHERE a.mbid IN (%s) AND (
EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
OR EXISTS (
SELECT 1 FROM albums al
JOIN audio_files af2 ON af2.album_id = al.id
WHERE al.artist_id = a.id
)
)`},
}
// Build a set of MBIDs still unresolved.
@@ -48,12 +71,11 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
}
}
for _, te := range tables {
for _, eq := range queries {
if len(remaining) == 0 {
break
}
// Build IN clause from remaining MBIDs.
placeholders := make([]string, 0, len(remaining))
args := make([]any, 0, len(remaining))
@@ -62,9 +84,10 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
args = append(args, m)
}
//nolint:gosec // table name is hardcoded from the tables slice above
query := "SELECT mbid FROM " + te.table + " WHERE mbid IN (" +
strings.Join(placeholders, ",") + ")"
//nolint:gosec // the query text is a constant from the slice above
query := strings.Replace(
eq.query, "%s", strings.Join(placeholders, ","), 1,
)
rows, err := idx.db.QueryContext(query, args...)
if err != nil {
@@ -74,7 +97,7 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
for rows.Next() {
var mbid string
if err := rows.Scan(&mbid); err == nil {
result[mbid] = te.entityType
result[mbid] = eq.entityType
delete(remaining, mbid)
}
}
+18 -6
View File
@@ -33,6 +33,12 @@ type ListenBrainzClient struct {
limiter *RateLimiter
cache *Cache
logger *slog.Logger
// baseURL is listenBrainzBaseURL unless a test redirects it. It is
// per-client rather than a package variable — the MB client's
// SetBaseURL shape — so a test that points one client at an
// httptest server does not stop being parallel-safe.
baseURL string
}
// NewListenBrainzClient creates a ListenBrainz API client.
@@ -46,9 +52,15 @@ func NewListenBrainzClient(
limiter: limiter,
cache: cache,
logger: logger,
baseURL: listenBrainzBaseURL,
}
}
// SetBaseURL redirects this client at another host. Tests only.
func (c *ListenBrainzClient) SetBaseURL(url string) {
c.baseURL = strings.TrimSuffix(url, "/")
}
// TopRecordingsForArtist returns the most-listened recordings for
// the artist identified by artistMBID.
func (c *ListenBrainzClient) TopRecordingsForArtist(
@@ -56,7 +68,7 @@ func (c *ListenBrainzClient) TopRecordingsForArtist(
) ([]LBTopRecording, error) {
url := fmt.Sprintf(
"%s/1/popularity/top-recordings-for-artist/%s",
listenBrainzBaseURL,
c.baseURL,
artistMBID,
)
cacheKey := "lb:top-recordings:" + artistMBID
@@ -104,7 +116,7 @@ func (c *ListenBrainzClient) TopReleaseGroupsForArtist(
) ([]LBTopReleaseGroup, error) {
url := fmt.Sprintf(
"%s/1/popularity/top-release-groups-for-artist/%s",
listenBrainzBaseURL,
c.baseURL,
artistMBID,
)
cacheKey := "lb:top-release-groups:" + artistMBID
@@ -234,7 +246,7 @@ func (c *ListenBrainzClient) ArtistPopularity(
return nil, nil //nolint:nilnil
}
url := listenBrainzBaseURL + "/1/popularity/artist"
url := c.baseURL + "/1/popularity/artist"
cacheKey := "lb:pop:artist:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
@@ -265,7 +277,7 @@ func (c *ListenBrainzClient) RecordingPopularity(
return nil, nil //nolint:nilnil
}
url := listenBrainzBaseURL + "/1/popularity/recording"
url := c.baseURL + "/1/popularity/recording"
cacheKey := "lb:pop:recording:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
@@ -296,7 +308,7 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity(
return nil, nil //nolint:nilnil
}
url := listenBrainzBaseURL + "/1/popularity/release-group"
url := c.baseURL + "/1/popularity/release-group"
cacheKey := "lb:pop:release-group:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
@@ -342,7 +354,7 @@ func (c *ListenBrainzClient) BatchArtistMetadata(
return nil, nil //nolint:nilnil
}
url := listenBrainzBaseURL + "/1/metadata/artist/?artist_mbids=" + strings.Join(mbids, ",")
url := c.baseURL + "/1/metadata/artist/?artist_mbids=" + strings.Join(mbids, ",")
cacheKey := "lb:meta:artist:" + hashMBIDs(mbids)
if data, ok := c.cache.Get(cacheKey); ok {
+13 -9
View File
@@ -10,7 +10,7 @@ import (
// LyricsResult is a single lyric-search hit, mapped from the DB layer
// into the camelCase shape the frontend consumes.
type LyricsResult struct {
RecordingID int64 `json:"recordingId"`
AudioFileID int64 `json:"audioFileId"`
FilePath string `json:"filePath"`
LengthMs int64 `json:"lengthMs"`
Title string `json:"title"`
@@ -55,7 +55,7 @@ func (e *Service) SearchLyrics(query string) []LyricsResult {
out := make([]LyricsResult, 0, len(hits))
for _, h := range hits {
out = append(out, LyricsResult{
RecordingID: h.RecordingID,
AudioFileID: h.AudioFileID,
FilePath: h.FilePath,
LengthMs: h.LengthMilliseconds,
Title: h.Title,
@@ -67,18 +67,18 @@ func (e *Service) SearchLyrics(query string) []LyricsResult {
return out
}
// GetTrackLyrics returns lyrics for a recording. If the library
// GetTrackLyrics returns lyrics for a file. If the library
// already has them (from embedded tags) they're returned as-is;
// otherwise it fetches from LRCLIB, persists them (updating the FTS
// index), and returns them. Never returns an error to the frontend —
// a miss just yields an empty result.
func (e *Service) GetTrackLyrics(recordingID int64) TrackLyrics {
stored, err := e.db.GetRecordingLyrics(recordingID)
func (e *Service) GetTrackLyrics(audioFileID int64) TrackLyrics {
stored, err := e.db.GetLyrics(audioFileID)
if err == nil && stored != "" {
return TrackLyrics{Plain: stored, Source: "embedded"}
}
lookup, err := e.db.RecordingLyricLookup(recordingID)
lookup, err := e.db.FileLyricLookup(audioFileID)
if err != nil || lookup == nil {
return TrackLyrics{}
}
@@ -150,7 +150,7 @@ func (e *Service) backfillLibraryLyrics(ctx context.Context) {
return
}
candidates, err := e.db.RecordingsMissingLyrics(lyricsBackfillBatch)
candidates, err := e.db.FilesMissingLyrics(lyricsBackfillBatch)
if err != nil {
e.logger.Warn("lyrics backfill: query failed", "err", err)
@@ -223,8 +223,12 @@ func (e *Service) fetchAndStoreLyrics(
return nil
}
if err := e.db.SetRecordingLyrics(c.RecordingID, lyrics.Plain); err != nil {
e.logger.Warn("lyrics store failed", "recordingId", c.RecordingID, "err", err)
// Marked `lrclib` rather than `tag`: these came off the network and
// a rebuild that discards them pays for them again.
if err := e.db.SetLyrics(
c.AudioFileID, lyrics.Plain, "lrclib", c.RecordingMBID,
); err != nil {
e.logger.Warn("lyrics store failed", "audioFileId", c.AudioFileID, "err", err)
return nil
}
+203
View File
@@ -0,0 +1,203 @@
package explore
import (
"database/sql/driver"
"encoding/hex"
"errors"
"fmt"
"strings"
)
// The catalog stores a MusicBrainz id as its 16 raw bytes and an entity
// type as a small integer, rather than as the 36-character text and the
// words the rest of the app uses.
//
// This is a size decision and it is a large one. Measured on a real
// 2,052,200-row catalog: the three MBID columns and `entity_type` are
// 220 MB of a 383 MB table, and they are carried again in every index
// that keys on them. Converting the table and its four indexes took
// **677 MB to 389 MB** with the same row count.
//
// Everything above this file still speaks strings: `SearchIndexResult`
// carries `"artist"` and a dashed MBID, the frontend receives them, and
// the conversion happens only where a value crosses into SQL. The
// alternative - blobs and codes reaching the rest of the app - would
// trade 288 MB for a type that nothing else wants.
//
// Two things make a mistake here loud rather than silent, which matters
// because SQLite does not coerce between TEXT and BLOB: a query
// comparing a blob column against a string returns *no rows* rather
// than an error.
//
// - Writes are guarded by a CHECK on the column (16 bytes, or empty),
// so a stringly write fails at the insert rather than sitting in
// the table looking fine.
// - Reads scan into `dbMBID`, whose Scan rejects anything that is not
// 16 bytes or empty. A column that somehow holds text produces an
// error instead of a garbled id.
type dbMBID string
// mbidLen is the byte length of a raw MusicBrainz id.
const mbidLen = 16
// Value encodes the id for storage: 16 raw bytes, or empty for "none".
func (m dbMBID) Value() (driver.Value, error) {
return mbidBytes(string(m)), nil
}
// Scan decodes a stored id back to its canonical dashed form.
func (m *dbMBID) Scan(src any) error {
switch v := src.(type) {
case nil:
*m = ""
return nil
case []byte:
s, err := mbidFromBytes(v)
if err != nil {
return err
}
*m = dbMBID(s)
return nil
case string:
// Tolerated for the one caller that reads through a view or a
// literal: a canonical id is already the right answer.
*m = dbMBID(v)
return nil
default:
return fmt.Errorf("%w: %T", errMBIDType, src)
}
}
// mbidBytes encodes a dashed MusicBrainz id as its 16 raw bytes. An
// id that is not one - including the empty string, which is how "no
// MBID" is spelled throughout - encodes as empty.
func mbidBytes(s string) []byte {
if s == "" {
return []byte{}
}
raw, err := hex.DecodeString(strings.ReplaceAll(s, "-", ""))
if err != nil || len(raw) != mbidLen {
return []byte{}
}
return raw
}
// mbidFromBytes decodes stored bytes back to the canonical dashed form.
func mbidFromBytes(b []byte) (string, error) {
if len(b) == 0 {
return "", nil
}
if len(b) != mbidLen {
return "", fmt.Errorf("%w: %d bytes", errMBIDLength, len(b))
}
h := hex.EncodeToString(b)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:], nil
}
// Entity types are stored as codes. The names are the app's, the codes
// are the table's, and nothing outside this file should see a code.
const (
entityCodeArtist = 1
entityCodeReleaseGroup = 2
entityCodeRecording = 3
)
// Entity type names, as everything above the SQL boundary spells them.
const (
EntityArtist = "artist"
EntityReleaseGroup = "release_group"
EntityRecording = "recording"
)
// entityCode maps a name to its stored code. An unknown name yields 0,
// which matches no row - the same answer the old string comparison gave
// and the reason this is not an error.
func entityCode(name string) int {
switch name {
case EntityArtist:
return entityCodeArtist
case EntityReleaseGroup:
return entityCodeReleaseGroup
case EntityRecording:
return entityCodeRecording
default:
return 0
}
}
// entityName maps a stored code back to its name.
func entityName(code int) string {
switch code {
case entityCodeArtist:
return EntityArtist
case entityCodeReleaseGroup:
return EntityReleaseGroup
case entityCodeRecording:
return EntityRecording
default:
return ""
}
}
// dbEntityType scans a stored entity-type code as its name.
//
// The db prefix keeps it out of the way of the many `mbid string` and
// `entityType string` locals in this package: these two types exist
// only at the SQL boundary, and a name that shadowed one of those
// would turn a conversion into a confusing compile error rather than
// an obvious one.
type dbEntityType string
// Value encodes the name for storage.
func (e dbEntityType) Value() (driver.Value, error) {
return int64(entityCode(string(e))), nil
}
// Scan decodes a stored code back to its name.
func (e *dbEntityType) Scan(src any) error {
switch v := src.(type) {
case nil:
*e = ""
return nil
case int64:
*e = dbEntityType(entityName(int(v)))
return nil
case string:
*e = dbEntityType(v)
return nil
case []byte:
*e = dbEntityType(v)
return nil
default:
return fmt.Errorf("%w: %T", errEntityTypeType, src)
}
}
// Errors from the encoding boundary. They exist so a type confusion
// here is reported rather than silently producing an id that matches
// nothing.
var (
errMBIDType = errors.New("cannot scan MusicBrainz id from")
errMBIDLength = errors.New("stored MusicBrainz id has the wrong length")
errEntityTypeType = errors.New("cannot scan entity type from")
)
// A query that names an entity type inline writes the code with the
// name beside it - `entity_type = 1 /* artist */`. Splicing a Go
// constant into the SQL would keep them in step automatically but makes
// every such query a concatenation; the codes are pinned by
// TestEntityCodesAreStable instead, because they are a storage format
// and changing one is not a refactor.
+105
View File
@@ -0,0 +1,105 @@
package explore
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// testMBID turns a short fixture label into a well-formed MusicBrainz
// id, and passes a real one through unchanged.
//
// The catalog stores an id as its 16 raw bytes and the column says so
// (`CHECK(length(mbid) = 16)`), so a fixture can no longer call itself
// "rh". Deriving one from the label keeps the fixtures readable — the
// same label is the same id in a seed and in the assertion that reads
// it back — without letting a test write something the app could not.
func testMBID(label string) string {
if len(mbidBytes(label)) == mbidLen {
return label
}
sum := sha256.Sum256([]byte(label))
h := hex.EncodeToString(sum[:mbidLen])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
// TestEntityCodesAreStable pins the stored entity-type codes.
//
// They are a storage format, not an enum: the queries that name a type
// inline write the number with the name beside it
// (`entity_type = 1 /* artist */`), so changing one here without
// changing those would leave the catalog answering the wrong questions
// silently.
func TestEntityCodesAreStable(t *testing.T) {
t.Parallel()
for name, want := range map[string]int{
EntityArtist: 1,
EntityReleaseGroup: 2,
EntityRecording: 3,
} {
if got := entityCode(name); got != want {
t.Errorf("entityCode(%q) = %d, want %d", name, got, want)
}
if back := entityName(want); back != name {
t.Errorf("entityName(%d) = %q, want %q", want, back, name)
}
}
if got := entityCode("nonsense"); got != 0 {
t.Errorf("entityCode of an unknown name = %d, want 0 (matches nothing)", got)
}
}
// TestMBIDRoundTrip pins the encoding both ways, including the two
// values that are not ids: empty, which is how "no MBID" is spelled
// everywhere, and rubbish, which must not become a valid-looking id.
func TestMBIDRoundTrip(t *testing.T) {
t.Parallel()
const canonical = "c0b2500e-0cef-4130-9b13-1b9d9a2f2c07"
encoded := mbidBytes(canonical)
if len(encoded) != mbidLen {
t.Fatalf("encoded length = %d, want %d", len(encoded), mbidLen)
}
back, err := mbidFromBytes(encoded)
if err != nil {
t.Fatalf("decode: %v", err)
}
if back != canonical {
t.Errorf("round trip = %q, want %q", back, canonical)
}
if got := mbidBytes(""); len(got) != 0 {
t.Errorf("empty encoded to %d bytes, want 0", len(got))
}
if got := mbidBytes("not-an-mbid"); len(got) != 0 {
t.Errorf("rubbish encoded to %d bytes, want 0", len(got))
}
// A stored value of the wrong length is an error, not a guess.
if _, err := mbidFromBytes([]byte{1, 2, 3}); err == nil {
t.Error("decoding three bytes should fail")
}
}
// TestMBIDScanRejectsText is the guard for the failure this encoding
// could otherwise hide: SQLite does not coerce TEXT to BLOB, so a
// column holding the old 36-character form would silently compare equal
// to nothing. Scanning it must say so.
func TestMBIDScanRejectsText(t *testing.T) {
t.Parallel()
var m dbMBID
if err := m.Scan([]byte("c0b2500e-0cef-4130-9b13-1b9d9a2f2c07")); err == nil {
t.Error("scanning 36 bytes as an MBID should fail")
}
}
+72 -11
View File
@@ -107,12 +107,11 @@ func (e *Service) mixSeedProfile(
artistCounts[artist.ArtistMbid]++
artistNames[artist.ArtistMbid] = artist.ArtistName
}
}
names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p)
if err == nil {
for _, g := range names {
genres[g] = true
}
for _, names := range e.genresByPath(ctx, seedPaths) {
for _, g := range names {
genres[g] = true
}
}
@@ -148,6 +147,12 @@ func (e *Service) mixCandidates(
candidates := map[string]float64{}
// Every candidate path's genres, in one query. This used to be a
// single-row lookup *per candidate* inside two nested loops -
// twenty seed artists by twenty similar artists by thirty paths is
// twelve thousand queries to assemble one mix.
pathGenres := e.genresByPath(ctx, e.similarArtistPaths(ctx, artistCounts))
for seedArtistMBID, count := range artistCounts {
similar, err := e.SimilarArtists(seedArtistMBID)
if err != nil {
@@ -178,13 +183,11 @@ func (e *Service) mixCandidates(
continue
}
if names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p); err == nil {
for _, g := range names {
if seedGenres[g] {
weight += mixGenreBoost
for _, g := range pathGenres[p] {
if seedGenres[g] {
weight += mixGenreBoost
break
}
break
}
}
@@ -196,6 +199,64 @@ func (e *Service) mixCandidates(
return candidates
}
// genresByPath returns the genres of many files in one query.
func (e *Service) genresByPath(
ctx context.Context, paths []string,
) map[string][]string {
out := make(map[string][]string, len(paths))
if len(paths) == 0 {
return out
}
rows, err := e.db.ReadQueries.GetGenreNamesByFilePaths(ctx, paths)
if err != nil {
return out
}
for _, row := range rows {
out[row.FilePath] = append(out[row.FilePath], row.Name)
}
return out
}
// similarArtistPaths collects every owned file by an artist similar to
// one of the seeds, so their genres can be fetched in one go.
func (e *Service) similarArtistPaths(
ctx context.Context, artistCounts map[string]int,
) []string {
var paths []string
for seedArtistMBID := range artistCounts {
similar, err := e.SimilarArtists(seedArtistMBID)
if err != nil {
continue
}
if len(similar) > mixSimilarArtistsPerSeed {
similar = similar[:mixSimilarArtistsPerSeed]
}
for _, sim := range similar {
if sim.ArtistMBID == "" {
continue
}
p, err := e.db.ReadQueries.GetFilePathsByArtistMBID(
ctx, sql.NullString{String: sim.ArtistMBID, Valid: true},
)
if err != nil {
continue
}
paths = append(paths, p...)
}
}
return paths
}
// weightedSample picks up to n distinct keys from weights without
// replacement, biased toward higher weight (roulette-wheel selection).
// A key with zero or negative weight is never picked.
+8 -63
View File
@@ -23,69 +23,14 @@ func seedMixTrack(
fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id)
_, err := db.ExecContext(
"INSERT INTO artists (id, name, mbid) VALUES (?, ?, ?) "+
"ON CONFLICT(name) DO NOTHING",
id, artistName, artistMBID,
)
if err != nil {
t.Fatalf("insert artist: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (?, ?) "+
"ON CONFLICT(text) DO NOTHING",
id, artistName,
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT OR IGNORE INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)",
id, id,
)
if err != nil {
t.Fatalf("insert artist_credit_artist: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, ?)",
id, fmt.Sprintf("Track %d", id), id,
)
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 (?, ?, 180000, 0, ?)",
id, fp, id,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
for _, g := range genreNames {
var genreID int64
row := db.QueryRowWriter(
"INSERT INTO genres (name) VALUES (?) "+
"ON CONFLICT(name) DO UPDATE SET name = name RETURNING id",
g,
)
if err := row.Scan(&genreID); err != nil {
t.Fatalf("upsert genre %q: %v", g, err)
}
_, err = db.ExecContext(
"INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
id, genreID,
)
if err != nil {
t.Fatalf("insert recording_genre: %v", err)
}
}
database.InsertTestTrack(t, db, database.TestTrack{
FilePath: fp,
Title: fmt.Sprintf("Track %d", id),
Artist: artistName,
ArtistMBID: artistMBID,
Genres: genreNames,
LengthMs: 180000,
})
return fp
}

Some files were not shown because too many files have changed in this diff Show More