feat(database): shape the library like files, and shrink the catalog
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:
@@ -63,5 +63,24 @@ Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead
|
|||||||
address on purpose, so no seed depends on what the explore artifact
|
address on purpose, so no seed depends on what the explore artifact
|
||||||
server happened to be serving.
|
server happened to be serving.
|
||||||
|
|
||||||
Rebuild a seed after any schema change, or the restored database is
|
Rebuild a seed after any schema change. Nothing migrates a restored
|
||||||
migrated on open in a way the seed's author never saw.
|
database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed
|
||||||
|
keeps its old columns, the app starts, and the first query dies on
|
||||||
|
`no such column`.
|
||||||
|
|
||||||
|
**Restoring the seed does not disable the artifact fetch — only
|
||||||
|
*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone,
|
||||||
|
so on a developer machine the restored app immediately downloads and
|
||||||
|
imports the real ~1.1M-row catalog, through the one writer connection,
|
||||||
|
while whatever you started it for is running. A full `make e2e` against
|
||||||
|
that reported **14 failures** that were all contention; the same suite
|
||||||
|
against the same seed with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default
|
||||||
|
```
|
||||||
|
|
||||||
|
is the configuration CI runs (`ci.yml` sets exactly that address) and is
|
||||||
|
what to use before believing a failure. The tell is in `.dev/app.log` —
|
||||||
|
an import logging progress — and in how the failures look: timeouts
|
||||||
|
spread across unrelated specs rather than one surface being wrong.
|
||||||
|
|||||||
@@ -1,66 +1,76 @@
|
|||||||
# Changing the database schema
|
# Changing the database schema
|
||||||
|
|
||||||
The reasoning — why there are two files, what the old 48-step migration
|
The reasoning — why the local library is shaped like files rather than
|
||||||
chain got wrong, and when squashing is legitimate — is in `CLAUDE.md`
|
like MusicBrainz, and what the metadata tables cost before they went —
|
||||||
under *Backend packages → database*. Read it once. This is the
|
is in `CLAUDE.md` under *Backend packages → database*. Read it once.
|
||||||
checklist.
|
This is the checklist.
|
||||||
|
|
||||||
**A brand-new table needs one file, not two.** The rule below is about
|
**There is one description of the schema and no migration chain.**
|
||||||
a *column added to a table that already exists*. `applySchema` runs
|
`sql/schemas/*.sql` declares the current shape; `applySchema` runs every
|
||||||
every file in `sql/schemas/` on every open, so a
|
file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent.
|
||||||
`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a
|
`sql/migrations/`, `applyMigrations` and `schema_migrations` were
|
||||||
migration for it would be a second description of the same table — the
|
squashed away with plan 013. So:
|
||||||
thing the third rule forbids. Its indexes go in the schema file too,
|
|
||||||
because the column and the index arrive together.
|
**Adding a table or a column is one edit to one file.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make generate # sqlc + templ
|
||||||
|
go test ./backend/database/ ./backend/datamap/
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
A new table has a second gate: **`backend/datamap`**. Add an entry
|
A new table has a second gate: **`backend/datamap`**. Add an entry
|
||||||
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
|
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
|
||||||
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
|
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
|
||||||
wants an explicit exemption with a note, because authored data is what
|
wants an explicit exemption with a note, because authored data is what a
|
||||||
a user cannot get back.
|
user cannot get back. If a *column* holds a different Kind from its
|
||||||
|
table (an authored flag on an owned projection, a fetched value beside a
|
||||||
|
tag-derived one), say so in the entry's note; `audio_files` and `lyrics`
|
||||||
|
are the worked examples.
|
||||||
|
|
||||||
Adding a **column** to an existing table needs **two** files, not one:
|
**Existing databases are not migrated.** Nothing upgrades a database
|
||||||
|
from an older shape — delete your dev `YJ_HOME` and rescan, and rebuild
|
||||||
|
any seed you rely on (`make sandbox-seed NAME=default`). Revisit this
|
||||||
|
once real user databases exist in the wild.
|
||||||
|
|
||||||
1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT
|
**A stale one fails at the first query, not at open**, which is worth
|
||||||
EXISTS`, the literal target shape, what sqlc reads and what a fresh
|
knowing before you read the error. `applySchema` is
|
||||||
install gets verbatim. Add the new column **last** in the
|
`CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns
|
||||||
`CREATE TABLE`.
|
and gains nothing; the app then starts fine and dies on
|
||||||
2. **`backend/database/sql/migrations/NNNN_description.sql`** — the
|
`no such column: title`. Every tier that does not *run the app* — unit
|
||||||
`ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an
|
tests, `make ui-test`, `tsc` — is green while this is true, because
|
||||||
existing database to the same shape. Schema files are a no-op against
|
they build their database from the current schema. `make e2e` and
|
||||||
a table that already exists, so without this an upgrade never gets
|
`make dev` are the two that will tell you, and only after the seed has
|
||||||
the column.
|
been rebuilt.
|
||||||
|
|
||||||
Then:
|
## The four ways this goes wrong
|
||||||
|
|
||||||
```bash
|
- **A query file must be ASCII.** sqlc's parameter rewriter works on
|
||||||
make generate # sqlc + templ
|
byte offsets, so a single non-ASCII character in a *query* comment
|
||||||
go test ./backend/database/ # migration + column-order tests
|
(an em dash, a curly quote) shifts every placeholder and generates
|
||||||
make test
|
garbage like `SELECid` — a parse error a long way from its cause.
|
||||||
```
|
Schema files are not rewritten and may contain anything.
|
||||||
|
- **A slice and a named parameter do not compose.** `sqlc.slice`
|
||||||
|
expands to N placeholders, but `sqlc.arg` is numbered independently,
|
||||||
|
so the two in one query bind the wrong values —
|
||||||
|
`GetFilePathsByAlbums([1,2], 0)` read album id 2 as the library id.
|
||||||
|
Where a query needs both, return the column and filter in Go.
|
||||||
|
- **A write wearing a query's shape still needs the writer.**
|
||||||
|
`QueryContext`/`QueryRow` route to the query-only read pool, so an
|
||||||
|
`INSERT ... RETURNING` through one fails at runtime with "attempt to
|
||||||
|
write a readonly database (8)". Use `ExecContext`, or
|
||||||
|
`QueryRowWriter`. `TestNoWritesOnTheReadPool` walks the tree for it.
|
||||||
|
- **A view is dropped and recreated.** `CREATE VIEW IF NOT EXISTS`
|
||||||
|
no-ops against a database holding the old definition, so
|
||||||
|
`track_metadata.sql` opens with `DROP VIEW IF EXISTS`.
|
||||||
|
|
||||||
Rebuild any seed you rely on (`make sandbox-seed NAME=default`) and
|
## Where things go
|
||||||
delete your own dev `YJ_HOME` if you want to see the fresh-install path
|
|
||||||
rather than the migrated one.
|
|
||||||
|
|
||||||
## The three ways this goes wrong
|
|
||||||
|
|
||||||
- **Column order must match between the two paths.** `ADD COLUMN`
|
|
||||||
always appends, so a migrated column declared anywhere but last in
|
|
||||||
`CREATE TABLE` leaves fresh and upgraded installs disagreeing on
|
|
||||||
order — and sqlc binds `SELECT *` positionally, so one of them
|
|
||||||
silently reads the wrong field.
|
|
||||||
`TestMigrations_ColumnOrderMatchesFreshInstall` is the regression test.
|
|
||||||
- **Do not put an index on a migrated column in `sql/schemas/`.**
|
|
||||||
Schema files run *before* migrations, against a database that may not
|
|
||||||
have the column yet, and the predicate fails. Declare the index in the
|
|
||||||
migration, after the `ALTER TABLE`.
|
|
||||||
- **Do not add a third description of the schema anywhere.** A
|
|
||||||
migration's `ADD COLUMN` failing with "duplicate column name" against
|
|
||||||
an already-current database is expected and tolerated, not an error to
|
|
||||||
route around.
|
|
||||||
|
|
||||||
New queries go in `backend/database/sql/queries/`; generated Go lands in
|
New queries go in `backend/database/sql/queries/`; generated Go lands in
|
||||||
`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests
|
`backend/database/sql/sqlcgen/`, which is never edited by hand. Anything
|
||||||
use `database.NewTestDB(t)`, built by the same `applySchema` production
|
returning a track selects from the `track_metadata` view rather than
|
||||||
uses, so the two cannot diverge.
|
re-joining — that is why there is one row type and one mapper.
|
||||||
|
|
||||||
|
Tests use `database.NewTestDB(t)`, built by the same `applySchema`
|
||||||
|
production uses, and seed rows with `database.InsertTestTrack(t, db,
|
||||||
|
database.TestTrack{...})` rather than assembling inserts by hand.
|
||||||
|
|||||||
@@ -2354,3 +2354,43 @@ Five more things worth keeping:
|
|||||||
child does not prove the icon inside it is `pointer-events: none`.
|
child does not prove the icon inside it is `pointer-events: none`.
|
||||||
Both were checked with a real mouse (`mousemove`/`mousedown`/
|
Both were checked with a real mouse (`mousemove`/`mousedown`/
|
||||||
`mouseup`) and a real Tab/Enter before either was believed.
|
`mouseup`) and a real Tab/Enter before either was believed.
|
||||||
|
|
||||||
|
## A queue of work has to ask whether there is work
|
||||||
|
|
||||||
|
Reported as "the autotag page has every album in it, even the MB-tagged
|
||||||
|
ones". Both halves of that were true and they were two different bugs,
|
||||||
|
which is why the first answer found (the pending list) accounted for
|
||||||
|
nine rows out of 2172.
|
||||||
|
|
||||||
|
**Every scanned folder gets a `tagging_items` row.**
|
||||||
|
`UpsertTaggingItemOnTrackAdd` runs per track with `status = 'pending'`
|
||||||
|
and no condition, so the table is a row per album folder, not a queue.
|
||||||
|
`saveAudioFile` *does* record the answer — `audio_files.tag_status` is
|
||||||
|
`user_confirmed` on import for any file carrying a recording MBID, and
|
||||||
|
`idx_audio_files_tag_status_untagged` was declared for the filter — but
|
||||||
|
no query in the app read the column. Pending therefore meant "has a
|
||||||
|
row". The four queue queries ask the files now
|
||||||
|
(`EXISTS … tag_status = 'untagged'`), which matters most where it is
|
||||||
|
least visible: `startPrefetch` was scoring every album in a tagged
|
||||||
|
library against MusicBrainz.
|
||||||
|
|
||||||
|
**The 2094 were the Completed section, and nobody completed them.** A
|
||||||
|
backfill stamped `status = 'confirmed'` on every folder whose files
|
||||||
|
already had MBIDs, and the sidebar keeps confirmed rows deliberately —
|
||||||
|
so the review page's history became a list of the library. They are
|
||||||
|
distinguishable without new state: every status flip the app performs
|
||||||
|
goes through `SetTaggingItemStatus` or `SetTaggingItemBestMatch` and
|
||||||
|
both stamp `last_checked_at`, so *confirmed with no `last_checked_at`,
|
||||||
|
no score and no best match* is the backfill's row and not the user's.
|
||||||
|
All 2094 were that shape; the two the app had actually applied were
|
||||||
|
not. Migration 0007 sets `cleared_at` on them — the column exists for
|
||||||
|
exactly this, and it keeps the row so a rescan cannot reset review
|
||||||
|
state.
|
||||||
|
|
||||||
|
Two smaller things fell out. The reviewed states are **exempt** from
|
||||||
|
the untagged-files predicate, or an applied folder would vanish from
|
||||||
|
Completed the instant it succeeded — the section is history, not work.
|
||||||
|
And `tag_status` was only ever written by the *insert* path, so a file
|
||||||
|
another tagger stamped after import kept `untagged` for ever and its
|
||||||
|
folder kept asking; `updateAudioFile` promotes it now, guarded on
|
||||||
|
`untagged` so a deliberate `user_skipped_permanent` survives a rescan.
|
||||||
|
|||||||
@@ -0,0 +1,639 @@
|
|||||||
|
# 013 — The database audit
|
||||||
|
|
||||||
|
**Status:** **complete** (2026-08-16). R1–R10 landed, the album page
|
||||||
|
that prompted the audit with them, and the one part of R5 that ships
|
||||||
|
*in the artifact* — a per-release-group track denominator — landed as
|
||||||
|
plan 014.
|
||||||
|
The audit below is unchanged from when it was written — the measurements
|
||||||
|
describe the *old* shape and are the reason for the new one.
|
||||||
|
**Branch:** none
|
||||||
|
**Created:** 2026-08-15
|
||||||
|
**Supersedes:** the four-part album-page fix sketched in conversation
|
||||||
|
(it survives, reduced, as R1 and R3 below)
|
||||||
|
**Related:** 010 (owned albums offline), 011 (owned artists'
|
||||||
|
discography), 012 (API call audit), 002 (data lifecycle)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Every number here is measured against the **real 25,966-track library**
|
||||||
|
at `~/.local/share/yellowjacket/yj.db` (copied read-only), not against
|
||||||
|
a fixture and not inferred from the code. Where a claim rests on a
|
||||||
|
capability rather than a count — "sqlc can do X" — it was executed, not
|
||||||
|
assumed.
|
||||||
|
|
||||||
|
The brief: *efficiency and simplicity — the minimum required to achieve
|
||||||
|
our featureset*, with fewer lines and a smaller database as evidence
|
||||||
|
rather than as the goal. Two named sources of confusion to resolve:
|
||||||
|
**local versus remote** versions of a thing, **files versus tracks**,
|
||||||
|
and **indexed versus live** lookups. One added constraint: **avoid
|
||||||
|
hitting APIs by storing intelligently, without a ridiculous base
|
||||||
|
install.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The measurements
|
||||||
|
|
||||||
|
### The database is 1.00 GB, and 78% of it is one table
|
||||||
|
|
||||||
|
| object | size | rows |
|
||||||
|
|---|---|---|
|
||||||
|
| `explore_index` | 383 MB | 2,052,200 |
|
||||||
|
| its five indexes + `UNIQUE(mbid)` | 395 MB | — |
|
||||||
|
| its two FTS tables | 85 MB | 2,052,200 + 96,451 |
|
||||||
|
| `recordings` | 38 MB (27 MB of it lyrics) | 26,778 |
|
||||||
|
| `lyrics_index` | 18 MB | 24,294 |
|
||||||
|
| `artist_metadata` | 12 MB | 7,673 |
|
||||||
|
| `http_cache` | 9 MB | 2,930 |
|
||||||
|
| `audio_files` | 5 MB | 25,966 |
|
||||||
|
| everything else | < 10 MB | — |
|
||||||
|
|
||||||
|
The local library — the part that is *the user's* — is about 50 MB.
|
||||||
|
The catalog and its indexes are 780 MB.
|
||||||
|
|
||||||
|
### Inside `explore_index`, half the bytes are three text columns
|
||||||
|
|
||||||
|
| column | bytes | note |
|
||||||
|
|---|---|---|
|
||||||
|
| `mbid` | 70 MB | 36-char text; 16 bytes as a blob |
|
||||||
|
| `artist_mbid` | 70 MB | same, and it is a foreign key in disguise |
|
||||||
|
| `caa_release_mbid` | 62 MB | same |
|
||||||
|
| `entity_type` | 18 MB | three distinct values, stored as words |
|
||||||
|
| `title` / `artist_name` / `release_name` | 74 MB | real data |
|
||||||
|
|
||||||
|
Five columns are declared, shipped in the artifact, selected in every
|
||||||
|
query, and **empty**: `aliases` (0 rows), `sort_name` (0),
|
||||||
|
`disambiguation` (0), `country` (69 rows of 2.05 M), `artist_type`
|
||||||
|
(72). `aliases` is additionally a column in *both* FTS tables, so the
|
||||||
|
tokenizer indexes nothing, twice.
|
||||||
|
|
||||||
|
### Two 50 MB indexes have a `WHERE` clause that excludes 0.3% of rows
|
||||||
|
|
||||||
|
`idx_explore_title_lower` (53 MB) and `idx_explore_artist_lower`
|
||||||
|
(48 MB) are `WHERE popularity > 0`. 2,046,645 of 2,052,200 rows satisfy
|
||||||
|
that. They are full indexes wearing a partial index's clothes, and they
|
||||||
|
exist to serve one exact-match tier (`ExactMatches`,
|
||||||
|
`searchindex.go:1298`) that the champion FTS — 96,451 rows, 2 MB —
|
||||||
|
already covers the popular half of.
|
||||||
|
|
||||||
|
### The local library models many-to-many relationships that are all 1:1
|
||||||
|
|
||||||
|
| claim | measured |
|
||||||
|
|---|---|
|
||||||
|
| recordings with more than one file | **0** |
|
||||||
|
| recordings in more than one release group | **0** |
|
||||||
|
| artist credits with more than one artist | **3** of 2,823 |
|
||||||
|
| files sharing a recording | **0** |
|
||||||
|
|
||||||
|
`recordings` (26,778) is one row per file. `release_group_recordings`
|
||||||
|
(26,778) is one row per file. `artist_credit` (2,823) and
|
||||||
|
`artist_credit_artist` (2,826) differ by three.
|
||||||
|
|
||||||
|
### …and it leaks rows that outlive the files
|
||||||
|
|
||||||
|
| orphan | count |
|
||||||
|
|---|---|
|
||||||
|
| `recordings` with no `audio_files` row | **812** (218 carry MBIDs) |
|
||||||
|
| `release_groups` with no file underneath | **216** |
|
||||||
|
| `artists` credited on no file | **260** |
|
||||||
|
| `explore_index` rows flagged **`in_library` with no file behind them** | **129** recordings, 2 release groups, 1 artist |
|
||||||
|
|
||||||
|
That last row is the bug reported today, in the user's own data.
|
||||||
|
|
||||||
|
### The query surface
|
||||||
|
|
||||||
|
| surface | count |
|
||||||
|
|---|---|
|
||||||
|
| sqlc queries | 235 (7,850 generated Go lines) |
|
||||||
|
| raw SQL call sites outside sqlc | 188 |
|
||||||
|
| bound IPC methods | 272 |
|
||||||
|
| `X` / `XByLibrary` query twins | 14 (8 of them exposed as separate bindings) |
|
||||||
|
| copies of the "one row per file with its metadata" projection | **9**, plus the view that already defines it |
|
||||||
|
|
||||||
|
`mapTrackRow` takes **22 positional arguments** and is called from 9
|
||||||
|
places, because each duplicated query generates its own row struct.
|
||||||
|
|
||||||
|
### The data directory is 8.5 GB — the database is the small part
|
||||||
|
|
||||||
|
| path | size | of which |
|
||||||
|
|---|---|---|
|
||||||
|
| `artist-images/` | 5.4 GB | **4,125 MB is candidate images no code path reads**; 1,222 MB is primaries + tiers for **5,770 artists** in a library with **1,301** |
|
||||||
|
| `covers/` | 1.4 GB | **1,134 MB is originals**; all three rendered tiers together are 110 MB |
|
||||||
|
| `ffmpeg/` | 283 MB | bundled binary |
|
||||||
|
| `yj.db` | 1.0 GB | above |
|
||||||
|
| `yj.db.bak` + `.bak.20260309` | 452 MB | nothing deletes these |
|
||||||
|
| art caches (`cover-art-cache`, `artist-image-cache`) | 81 MB | catalog art, fine |
|
||||||
|
|
||||||
|
The 4.1 GB of unreachable artist candidates is the bug `CLAUDE.md`
|
||||||
|
records as fixed; this install still carries it, so **the janitor jobs
|
||||||
|
have never run here**. Worth confirming they run at all before
|
||||||
|
declaring that one closed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The diagnosis
|
||||||
|
|
||||||
|
Everything below is downstream of one thing.
|
||||||
|
|
||||||
|
**There are three different notions of "a track" in this app, and the
|
||||||
|
code keeps asking the wrong one.**
|
||||||
|
|
||||||
|
1. **A file** — a row in `audio_files`. The only thing that is
|
||||||
|
unambiguously *yours*: it has a path, it plays.
|
||||||
|
2. **A local entity** — a row in `recordings` / `release_groups` /
|
||||||
|
`artists`. Created by a scan *from* a file, but with an independent
|
||||||
|
lifetime: nothing deletes it when the file goes, and retagging a
|
||||||
|
file **creates a new one and abandons the old**
|
||||||
|
(`library.go:1722` repoints `audio_files.recording_id` at a fresh
|
||||||
|
recording; `pruneOrphanedMetadata` only runs on the scan's
|
||||||
|
*deleted-file* branch, `library.go:982`). This is where the 812
|
||||||
|
orphans come from — and autotagging is the machine that makes them.
|
||||||
|
3. **A catalog entity** — a row in `explore_index`, downloaded, global,
|
||||||
|
identical for every user.
|
||||||
|
|
||||||
|
"Is this mine" is asked of **(2)** almost everywhere, and answered by
|
||||||
|
**(1)** whenever the user actually does something:
|
||||||
|
|
||||||
|
- `LibraryMBIDIndex.CheckMBIDs` (`librarymbid.go:64`) is literally
|
||||||
|
`SELECT mbid FROM recordings WHERE mbid IN (…)`. It sets `inLibrary`
|
||||||
|
on every catalog tracklist.
|
||||||
|
- `pruneStaleLocalCrossReferences` (`searchindex.go:2480`) clears
|
||||||
|
`explore_index.in_library` when the **`recordings` row** disappears —
|
||||||
|
not when the file does. Hence 129 phantom "you own this" rows.
|
||||||
|
- `albumLibraryStatus()` in `explore-album-details.ts` ORs four claims
|
||||||
|
of decreasing confidence, none of which is "a file exists".
|
||||||
|
- But `GetFilePathsByRecordingMBIDs`, which every *action* goes
|
||||||
|
through, joins `audio_files`. It is the only one that tells the
|
||||||
|
truth.
|
||||||
|
|
||||||
|
So a retagged file leaves behind a recording carrying the **old** MBID;
|
||||||
|
the catalog matches that MBID; the row renders owned, undimmed, with a
|
||||||
|
Play button; and every action on it fails with "could not be found in
|
||||||
|
your library" — on a fully-tagged library. The user's instinct that the
|
||||||
|
check is fragile is correct, and the fragility is not the live lookup.
|
||||||
|
**The live lookup is the only part that is right.**
|
||||||
|
|
||||||
|
The same confusion explains "files vs tracks" and "local vs remote":
|
||||||
|
tables (2) exist to be a local mirror of the catalog's shape, so a
|
||||||
|
"track" is sometimes a file, sometimes a mirror row, sometimes a
|
||||||
|
catalog row, and the three are joined by MBID — a key that **two of the
|
||||||
|
three can lack or lie about**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings and recommendations
|
||||||
|
|
||||||
|
### R1 — Ownership is "a file exists". Say it once, in SQL.
|
||||||
|
|
||||||
|
*Cheap, immediate, and it fixes the reported bug.*
|
||||||
|
|
||||||
|
- `CheckMBIDs`' `recordings` and `release_groups` branches gain a join
|
||||||
|
to `audio_files`. (`artists` too, via credit.)
|
||||||
|
- `pruneStaleLocalCrossReferences` tests for a file, not for a local
|
||||||
|
row.
|
||||||
|
- `pruneOrphanedMetadata` runs after the retag path as well as the
|
||||||
|
delete path — or, better, is deleted along with the tables that need
|
||||||
|
it (R2).
|
||||||
|
- One-shot cleanup of the 812/216/260 existing orphans at open.
|
||||||
|
|
||||||
|
**Effect:** 129 lying rows in this library become honest; the class
|
||||||
|
cannot recur while (2) exists.
|
||||||
|
|
||||||
|
### R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one
|
||||||
|
|
||||||
|
*The big one. It is what makes R1 structural rather than a patch.*
|
||||||
|
|
||||||
|
The local model imitates MusicBrainz's normalization — `artist_credit`
|
||||||
|
is an MB concept — for a dataset in which **every relationship it
|
||||||
|
models is 1:1** (measured above). The cost of that imitation:
|
||||||
|
|
||||||
|
- 5 tables (`recordings`, `release_group_recordings`, `artist_credit`,
|
||||||
|
`artist_credit_artist`, `release_to_rg` — the last has **0 rows** and
|
||||||
|
no schema-file writer) and ~12 indexes.
|
||||||
|
- A 6-way join in every read, including a `MIN(release_group_id)`
|
||||||
|
subquery repeated in **11 places** to undo a many-to-many that never
|
||||||
|
happens, and a "first credited artist" subquery in **9** to undo
|
||||||
|
another (the row-multiplication bug class documented at length in
|
||||||
|
`CLAUDE.md`, which serves 3 rows).
|
||||||
|
- An orphan-cleanup subsystem (`GetOrphaned*IDs` ×3, `Count*References`
|
||||||
|
×2, `pruneOrphanedMetadata`) that exists only because these rows can
|
||||||
|
outlive their file — and which does not actually work (812 orphans).
|
||||||
|
- The entire phantom-ownership class above.
|
||||||
|
|
||||||
|
Proposed shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
audio_files id, path, library_id, …, title, track_no, disc_no, year,
|
||||||
|
composer, comment, artist_credit TEXT, artist_id→artists,
|
||||||
|
album_id→albums, recording_mbid, modified_at, …
|
||||||
|
albums id, name, artist_id, mbid, year, original_year,
|
||||||
|
cover_art_id, total_tracks… (genuinely many files→1)
|
||||||
|
artists id, name, mbid (genuinely many→1)
|
||||||
|
genres + file_genres (genuinely many↔many:
|
||||||
|
107k rows / 26k files)
|
||||||
|
```
|
||||||
|
|
||||||
|
`artist_credit` survives as **text on the file** (display: "A feat.
|
||||||
|
B") plus `artist_id` (the primary artist, for grouping) — which is
|
||||||
|
everything the UI does with it today, minus the join that multiplies
|
||||||
|
rows.
|
||||||
|
|
||||||
|
**Effect:** a row exists iff a file exists, so R1 becomes a foreign key
|
||||||
|
rather than a rule anyone can forget. Removes 5 tables, ~12 indexes,
|
||||||
|
~30 sqlc queries, the orphan subsystem, both repeated subqueries, and
|
||||||
|
the `AUTOMATIC COVERING INDEX` SQLite builds on every library load.
|
||||||
|
Estimated −1,500 to −2,500 lines across `backend/library`,
|
||||||
|
`backend/database/sql/*` and `sqlcgen`.
|
||||||
|
|
||||||
|
**Cost:** one real migration of user data (not an `ADD COLUMN`), and it
|
||||||
|
touches autotag, tagwriter, playlist matching and the explore xref.
|
||||||
|
This is the item to sequence carefully; everything else is independent
|
||||||
|
of it.
|
||||||
|
|
||||||
|
### R3 — One projection, one row type, one mapper
|
||||||
|
|
||||||
|
`track_metadata` (the view) already *is* the canonical "one row per
|
||||||
|
file" definition, and **only the raw-SQL search paths use it**
|
||||||
|
(`search.go`, `lyrics_search.go`). Every sqlc query re-implements it —
|
||||||
|
9 copies, which have already drifted: the view prefers
|
||||||
|
`rg.original_year` for `year`, `GetAllTracksWithFullMetadata` uses
|
||||||
|
`r.year`. The same library shows a different year depending on which
|
||||||
|
screen you are on.
|
||||||
|
|
||||||
|
**Verified, not assumed:** sqlc generates cleanly against the view —
|
||||||
|
`SELECT * FROM track_metadata WHERE …` yields one `TrackMetadatum`
|
||||||
|
struct with correct types (run during this audit).
|
||||||
|
|
||||||
|
And the 14 `X`/`XByLibrary` twins collapse into one query each:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
|
||||||
|
OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Measured cost of the collapse: none.** Scoped-with-OR 23 ms, scoped
|
||||||
|
direct 21 ms, unscoped 145 ms over the full 26k rows.
|
||||||
|
|
||||||
|
**Effect:** −14 queries, −8 bindings, −8 frontend branches, 9 row
|
||||||
|
structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly −2,000
|
||||||
|
generated lines and −300 hand-written ones, and the year inconsistency
|
||||||
|
cannot exist.
|
||||||
|
|
||||||
|
### R4 — Put `explore_index` on a diet (~200 MB, no feature loss)
|
||||||
|
|
||||||
|
| change | saved |
|
||||||
|
|---|---|
|
||||||
|
| `mbid`, `artist_mbid`, `caa_release_mbid` as 16-byte blobs | ~110 MB in the table |
|
||||||
|
| …and the same keys in `UNIQUE(mbid)` (99 MB) and `idx_explore_index_artist_mbid` (131 MB) | ~70–100 MB |
|
||||||
|
| `entity_type` → INTEGER | 18 MB + index |
|
||||||
|
| drop `aliases`, `sort_name`, `disambiguation` (0 rows); reconsider `country`/`artist_type` (69/72 rows) | small bytes, real clarity — and one fewer empty FTS column |
|
||||||
|
| make the two `LOWER()` indexes' partial predicate *mean* something (`popularity >= championPopThreshold OR in_library`), or retire the tier onto the champion FTS | up to 101 MB |
|
||||||
|
|
||||||
|
Better still for `artist_mbid`: it is a foreign key spelled as text.
|
||||||
|
An integer reference to the artist row is 8 bytes instead of 36 and
|
||||||
|
makes the 131 MB index a fraction of its size.
|
||||||
|
|
||||||
|
**Also worth separating:** `in_library`, `local_*_id`, `is_similar` and
|
||||||
|
`discog_fetched` are *personalization* stored inside the *shipped
|
||||||
|
catalog* table, which is why the artifact import has to merge by
|
||||||
|
explicit column list and why `artist_enrichment` had to become its own
|
||||||
|
table for exactly this reason. Measured: `in_library` and
|
||||||
|
`local_*_id IS NOT NULL` agree on **every one of 2,052,200 rows** —
|
||||||
|
they are the same fact stored twice. A `library_xref(mbid, kind,
|
||||||
|
local_id)` side table would make the catalog table purely the artifact
|
||||||
|
and delete the merge-by-column-list rule.
|
||||||
|
|
||||||
|
### R5 — Ask the network less, without a bigger install
|
||||||
|
|
||||||
|
Present state (from `musicbrainz.go:17-27`): search 24 h, **entity 7
|
||||||
|
days**, releases 90 days. MusicBrainz entity data changes on the order
|
||||||
|
of *never* for the fields we read, and 251 of 2,930 cache rows are
|
||||||
|
already expired on this install — so a fully-populated artist page
|
||||||
|
re-fetches itself weekly, forever.
|
||||||
|
|
||||||
|
- **Raise `cacheTTLEntity` to a year** (or drop expiry and revalidate
|
||||||
|
in the background). Cost: bytes already stored. Benefit: the
|
||||||
|
steady-state network cost of browsing your own library goes to
|
||||||
|
roughly zero.
|
||||||
|
- **Ship a per-release-group `total_tracks` in the artifact.** 010
|
||||||
|
correctly rejects shipping *tracklists* (the per-artist track budget
|
||||||
|
would truncate them, and "Play 7 of 9" for a twelve-track album is a
|
||||||
|
confident lie). But the **denominator** is one small integer per
|
||||||
|
release group — 400,677 rows, ~2 bytes — and it is exactly what
|
||||||
|
`albumLibraryStatus`/`ownership()` needs to say complete /
|
||||||
|
incomplete / unknown for a catalog album with no local tags. Tiny,
|
||||||
|
honest, and it does not depend on coverage.
|
||||||
|
- **Keep 010's per-user backfill** for the tracklists themselves; this
|
||||||
|
does not replace it, it shrinks what it has to cover.
|
||||||
|
- `http_cache` has no size bound and no vacuum beyond expiry. Give it a
|
||||||
|
ceiling.
|
||||||
|
|
||||||
|
### R6 — The 5.3 GB on disk that no feature needs
|
||||||
|
|
||||||
|
- **4,125 MB of artist candidate images** that nothing reads (the
|
||||||
|
documented bug — but the janitors have not run on this install;
|
||||||
|
verify they run at all).
|
||||||
|
- Artist images exist for **5,770 artists** in a **1,301-artist**
|
||||||
|
library. Fetching art for artists you do not own is the same
|
||||||
|
"prefetch everything" instinct as the discography backfill 011
|
||||||
|
corrected.
|
||||||
|
- **1,134 MB of cover originals** versus 110 MB for all three rendered
|
||||||
|
tiers. Nothing renders the original; and it is re-derivable from the
|
||||||
|
audio file itself, which is on disk by definition. Keep `_lg` as the
|
||||||
|
largest and drop originals — that is 1.1 GB with no visible change.
|
||||||
|
- `yj.db.bak` (394 MB) and `yj.db.bak.20260309` (58 MB) accumulate with
|
||||||
|
nothing to clean them.
|
||||||
|
|
||||||
|
This is the largest single win available and it does not touch the
|
||||||
|
schema.
|
||||||
|
|
||||||
|
### R7 — Redundant indexes and dead columns
|
||||||
|
|
||||||
|
Five indexes are prefixes of an existing UNIQUE/PK and can be dropped
|
||||||
|
outright (they cost write time on every insert):
|
||||||
|
|
||||||
|
`idx_recording_genres_recording_id` ⊂ `UNIQUE(recording_id, genre_id)` ·
|
||||||
|
`idx_similar_artist_map_source` ⊂ `PK(source, similar)` ·
|
||||||
|
`idx_artist_credit_artist_artist_id` ⊂ `UNIQUE(artist_id, credit_id)` ·
|
||||||
|
`idx_artist_metadata_mbid` ⊂ `PK(mbid, source)` ·
|
||||||
|
`idx_artist_images_mbid` ⊂ `UNIQUE(artist_mbid, source, source_url)`.
|
||||||
|
|
||||||
|
Dead data:
|
||||||
|
|
||||||
|
- **`recordings.genre`** — populated on 25,619 rows at every scan and
|
||||||
|
**read by nothing**. Every genre read goes through
|
||||||
|
`recording_genres` + `genres`. Write-only column.
|
||||||
|
- **`release_groups.total_tracks` / `total_discs`** — 0 rows populated;
|
||||||
|
the feature that needed them put the number on
|
||||||
|
`release_group_recordings` instead.
|
||||||
|
- **`release_to_rg`** — 0 rows, no writer in any schema file.
|
||||||
|
- `libraries.sql` carries a doc comment about `download_requests`,
|
||||||
|
pasted from another file. Small, but it is the kind of drift the
|
||||||
|
two-file schema rule exists to catch.
|
||||||
|
|
||||||
|
### R8 — One genuine N+1
|
||||||
|
|
||||||
|
`mixCandidates` (`explore/mix.go:181`) issues
|
||||||
|
`GetGenreNamesByFilePath` **per candidate path**, inside a loop over
|
||||||
|
similar artists, inside a loop over seed artists. Twenty seeds × twenty
|
||||||
|
similar × thirty paths is 12,000 single-row queries for one mix. It is
|
||||||
|
one query with an `IN` clause, or one query for the whole weighted set.
|
||||||
|
(`mixSeedProfile` above it is the same shape, bounded by seed size.)
|
||||||
|
|
||||||
|
Nothing else in the tree matches this pattern — a scan of every query
|
||||||
|
issued inside a loop turned up 72 candidates and this is the only real
|
||||||
|
one.
|
||||||
|
|
||||||
|
### R9 — The IPC surface has internals in it
|
||||||
|
|
||||||
|
Bound and reachable from the frontend today: `AcquirePipelineLock`,
|
||||||
|
`ReleasePipelineLock`, `SetJobRegistry`, `SetScanHooks`,
|
||||||
|
`SetRescanHooks`, `SetRemovalHooks`, `MusicBrainz`, `CAALimiter`,
|
||||||
|
`PopulateLocalCrossReferences`. v3's generator binds every exported
|
||||||
|
method; these want to be unexported or moved off the service type.
|
||||||
|
Free lines, and one less way to wedge the app from a console.
|
||||||
|
|
||||||
|
### R10 — The test DB is not the shape production runs
|
||||||
|
|
||||||
|
`NewTestDB` shares one in-memory connection and leaves `readDB` nil, so
|
||||||
|
`reader()` returns the writer. That is why the read-pool write bug
|
||||||
|
(documented in `CLAUDE.md`) reached a user, and why
|
||||||
|
`TestNoWritesOnTheReadPool` had to be a tree-walk instead of a test.
|
||||||
|
Giving the test DB two handles over one shared in-memory file would let
|
||||||
|
that be an ordinary test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I recommend leaving alone
|
||||||
|
|
||||||
|
- **The download subsystem** (requests / downloads / items). Three
|
||||||
|
tables, clean lifetimes, well argued in the schema comments. The
|
||||||
|
`download_wants` table in this install is the pre-rename name; the
|
||||||
|
rename migration will clear it on next launch.
|
||||||
|
- **The champion FTS.** 96k rows, 2 MB, a real latency tier.
|
||||||
|
- **The dual write/read handle**, WAL, and the persist-writer queues.
|
||||||
|
These are recent, measured, and correct.
|
||||||
|
- **File paths as the frontend's identity for a track.** Integer ids
|
||||||
|
would be cheaper over IPC, but `CLAUDE.md`'s argument (an index goes
|
||||||
|
stale on re-sort/refilter, a path does not) is right, and the cost is
|
||||||
|
bounded.
|
||||||
|
- **Storing lyrics locally** (27 MB + 18 MB index for 24k tracks). That
|
||||||
|
is the API-avoidance trade working exactly as intended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What landed (2026-08-15 / 16)
|
||||||
|
|
||||||
|
### The third pass: the album page, which is where the report came from
|
||||||
|
|
||||||
|
The audit started from a user report — a fully-tagged library saying
|
||||||
|
"not in your library", on hover rather than on click — and R1 fixed the
|
||||||
|
half of that which lives in SQL. The other half was the page: ownership
|
||||||
|
was four claims OR'd into a tick, and the context menu asked the backend
|
||||||
|
per row, as the menu opened.
|
||||||
|
|
||||||
|
`explore-album-details` now resolves the displayed tracklist's file
|
||||||
|
paths **once**, from `updated()`, into one `filePaths` map that the
|
||||||
|
badge, the Play count, the dimmed rows and every menu item read. The
|
||||||
|
synthesised local tracks carry their own `FilePath`, so a library album
|
||||||
|
costs no lookup at all; a catalog tracklist costs one batched
|
||||||
|
`GetFilePathsByRecordingMBIDs`. `catalogScope()` no longer returns
|
||||||
|
`'library'` here — that was the second complaint in the same report, and
|
||||||
|
the artist page keeps it because a library-only *artist* really is
|
||||||
|
missing sections.
|
||||||
|
|
||||||
|
Two bugs fell out of doing it this way, and neither is the one that was
|
||||||
|
reported:
|
||||||
|
|
||||||
|
- The render loop. Guarding the lookup on `filePaths` (answered) rather
|
||||||
|
than on `askedFor` (asked) re-requests every *unowned* MBID forever,
|
||||||
|
because an unowned MBID never lands in the map.
|
||||||
|
- "No release data available" over a tracklist held in memory.
|
||||||
|
`loadLocalTracks` rebuilt the version list only when catalog releases
|
||||||
|
existed, but the "Your Library" entry is synthesised *from* the local
|
||||||
|
tracks — so the no-releases case was the one case it skipped. Nothing
|
||||||
|
caught it because the old ownership check answered from the local
|
||||||
|
album id and never needed the tracklist to exist.
|
||||||
|
|
||||||
|
### The second pass: R5–R10
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| the two exact-match indexes | 101 MB | **3 MB** (predicate narrowed to the champion set; plan unchanged, measured) |
|
||||||
|
| cover art on disk | original + 3 tiers | **3 tiers** — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it |
|
||||||
|
| browsed artist art | 90-day expiry, no ceiling | expiry **plus a 256 MB budget**, oldest evicted first; owned artists never in it |
|
||||||
|
| MusicBrainz entity TTL | 7 days | **1 year**, with a 128 MB ceiling on the response cache |
|
||||||
|
| redundant indexes | 5 | **0** (3 dropped here, 2 went with their tables) |
|
||||||
|
| internal methods on the IPC surface | 24 | **0** (`//wails:ignore`; 272 → 248 bound methods) |
|
||||||
|
| test DB | one handle, `readDB` nil | **two handles**, the shape production runs |
|
||||||
|
|
||||||
|
The catalog line is R4, finished the day after: MBIDs stored as 16 raw
|
||||||
|
bytes and entity types as codes, measured by converting the real
|
||||||
|
2,052,200-row catalog through the shipped schema. It needed no artifact
|
||||||
|
rebuild — the importer asks the artifact which encoding it carries and
|
||||||
|
converts the older text form on the way in. Plan 014 has the detail.
|
||||||
|
|
||||||
|
Two of those repaid immediately. Giving the test database its own
|
||||||
|
read pool **caught three tests writing through it** on the first run —
|
||||||
|
the exact bug class that reached a user as "attempt to write a readonly
|
||||||
|
database" and that `TestNoWritesOnTheReadPool` had to walk the source
|
||||||
|
tree to find. And the artist-image sweep's own test turned out to seed
|
||||||
|
an `artists` row with no file and call it owned: the phantom this whole
|
||||||
|
audit is about, sitting in the fixture of the test that guards it.
|
||||||
|
|
||||||
|
**One finding in this audit was wrong.** `aliases`, `sort_name`,
|
||||||
|
`disambiguation`, `country` and `artist_type` are not dead columns. They
|
||||||
|
are empty on that install because the artist-enrichment pass had barely
|
||||||
|
run (which is finding 011's subject), but `indexOneArtist` writes all
|
||||||
|
five, and `aliases` is an FTS column that makes an artist findable by
|
||||||
|
alias. They stay.
|
||||||
|
|
||||||
|
### The first pass: R2, carrying R1 and R3
|
||||||
|
|
||||||
|
R2 shipped with R1 and R3 inside it, because the collapse made them
|
||||||
|
free rather than separate work. No migration: fresh installs only, by
|
||||||
|
the user's decision, so `sql/migrations/` went with it.
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| local tables | 9 | 5 (`audio_files`, `albums`, `artists`, `genres`, `file_genres`) |
|
||||||
|
| sqlc queries | 235 | 185 |
|
||||||
|
| generated Go | 7,850 | 6,023 |
|
||||||
|
| bound IPC methods | 272 | 264 |
|
||||||
|
| copies of the track projection | 9 + the view | the view |
|
||||||
|
| `X`/`XByLibrary` query twins | 14 | 0 |
|
||||||
|
| migration files + runner | 7 + ~120 lines | 0 |
|
||||||
|
| **net** | | **−5,070 lines** across 122 files |
|
||||||
|
|
||||||
|
Gone: `recordings`, `release_group_recordings`, `artist_credit`,
|
||||||
|
`artist_credit_artist`, `pruneOrphanedMetadata`'s four sweeps,
|
||||||
|
`RemoveLibrary`'s eight, `mapTrackRow`'s 22 positional arguments, and
|
||||||
|
340 lines of `tagwriter/dbsync.go` that existed to relink and then
|
||||||
|
un-orphan those tables.
|
||||||
|
|
||||||
|
Ownership is now a file in every one of the places that used to ask a
|
||||||
|
metadata table: `CheckMBIDs`, `collectLibraryEntities`,
|
||||||
|
`pruneStaleLocalCrossReferences` and `GetFilePathsByRecordingMBIDs`.
|
||||||
|
|
||||||
|
Three things found on the way, each written down where it can be hit
|
||||||
|
again (`CLAUDE.md`, `references/schema-change.md`):
|
||||||
|
|
||||||
|
- **sqlc's parameter rewriter is byte-offset based**, so one em dash in
|
||||||
|
a *query* comment corrupts generation into `SELECid`.
|
||||||
|
- **`sqlc.slice` and `sqlc.arg` do not compose** — slice expansion
|
||||||
|
renumbers, so `GetFilePathsByAlbums([1,2], 0)` read album id 2 as the
|
||||||
|
library id. Caught by a test, not by a type.
|
||||||
|
- **`release_to_rg` looked dead and was not**: 0 rows on any ordinary
|
||||||
|
install, because only a local `indexbuild` fills it, and the daily
|
||||||
|
incremental refresh reads it. Restored.
|
||||||
|
|
||||||
|
Verified: `make lint` (3 configurations), `go test ./...` plus the
|
||||||
|
`indexbuild` and `dev` tag passes, `tsc --noEmit`, `make ui-test`
|
||||||
|
(768), and a new end-to-end test that scans the real fixture library
|
||||||
|
and asserts no row outlives its file
|
||||||
|
(`TestScan_FixtureLibraryLeavesNothingBehind`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequence
|
||||||
|
|
||||||
|
**Revised 2026-08-15, after the compatibility constraint was lifted:**
|
||||||
|
breaking changes are acceptable and the schema may be squashed. That
|
||||||
|
inverts the order — R2 was last only because of the migration, and it
|
||||||
|
*subsumes* R1 (ownership becomes a foreign key) and reshapes R3 (the
|
||||||
|
projection is defined over the new tables). Doing R1 and R3 against the
|
||||||
|
old shape first would be work thrown away.
|
||||||
|
|
||||||
|
1. **R2** — the schema collapse, with the rebuild below. It carries R1
|
||||||
|
and R3 with it.
|
||||||
|
2. **R6** — reclaim the 5.3 GB on disk; confirm the janitors run.
|
||||||
|
3. **R7 / R9 / R8 / R10** — the small correctness and hygiene items.
|
||||||
|
4. **R4** — the `explore_index` diet. Artifact rebuild + format bump.
|
||||||
|
5. **R5** — cache TTLs (trivial) and the shipped denominator (rides
|
||||||
|
along with R4's artifact change).
|
||||||
|
|
||||||
|
### "Break everything" has a floor, and it is not the schema
|
||||||
|
|
||||||
|
Reshaping tables freely is fine. **Dropping the database is not**, and
|
||||||
|
the numbers say so — a wipe-and-rescan would destroy:
|
||||||
|
|
||||||
|
| | count | why a rescan does not restore it |
|
||||||
|
|---|---|---|
|
||||||
|
| files marked `user_confirmed` | **25,014** | the user's autotag review decisions |
|
||||||
|
| reviewed tagging folders (`confirmed`/`skipped`) | **2,109** | ditto, plus every `skipped` becomes pending again |
|
||||||
|
| rows in `recordings.lyrics` | **24,294** | an unknown share came from **LRCLIB**, not from tags — re-fetching them is precisely the API traffic we are trying to avoid |
|
||||||
|
| playlists / playlist tracks | 22 / 1,917 | `Authored`; nothing else has them |
|
||||||
|
|
||||||
|
So the change ships as a **one-shot in-place rebuild**: create the new
|
||||||
|
tables, `INSERT … SELECT` across, drop the old ones, in a single
|
||||||
|
transaction at open. Seconds on 26k rows, ~40 lines of SQL, no
|
||||||
|
migration *chain* and no rollback path — which is the freedom that was
|
||||||
|
actually being asked for. `sql/migrations/` gets squashed into
|
||||||
|
`sql/schemas/` at the same time (`NOTES.md` already blesses this
|
||||||
|
pre-1.0).
|
||||||
|
|
||||||
|
### Two tables are classified as one Kind and hold another
|
||||||
|
|
||||||
|
`backend/datamap` already encodes what is safe to lose (`Owned` and
|
||||||
|
`Derived` rebuild from the files; `Cache` is expensive; `Authored` is
|
||||||
|
irreplaceable). The audit found two places where the *column* disagrees
|
||||||
|
with the *table's* entry, which is exactly why a wipe looked cheaper
|
||||||
|
than it is:
|
||||||
|
|
||||||
|
- **`audio_files.tag_status`** — the table is `Owned` (a projection of
|
||||||
|
the files), but `user_confirmed` / `user_skipped_permanent` are
|
||||||
|
**`Authored`**: a decision the user made that exists nowhere else.
|
||||||
|
- **`recordings.lyrics`** — the table is `Owned`, but lyrics fetched by
|
||||||
|
the LRCLIB backfill are **`Cache`**, and nothing records which of the
|
||||||
|
24,294 rows came from a tag and which from the network.
|
||||||
|
|
||||||
|
The new schema fixes both by construction: lyrics move to their own
|
||||||
|
MBID-keyed table with a `source` column (so they survive any rebuild of
|
||||||
|
the owned tables, and the provenance question becomes answerable), and
|
||||||
|
`tag_status`' authored values are carried across explicitly rather than
|
||||||
|
recomputed.
|
||||||
|
|
||||||
|
**Expected outcome if all of it lands:** database ~1.0 GB → ~0.75 GB,
|
||||||
|
data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go
|
||||||
|
7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters —
|
||||||
|
one definition of "this is mine" that a file either satisfies or does
|
||||||
|
not.
|
||||||
|
|
||||||
|
## The open questions, answered
|
||||||
|
|
||||||
|
1. **R2's migration** — the user's call, and it was "just assume this
|
||||||
|
new version will only be installed by a new user". So there is no
|
||||||
|
in-place rebuild and no chain: `sql/schemas/` is the whole
|
||||||
|
description. An existing `YJ_HOME` does not open (its `audio_files`
|
||||||
|
has `recording_id` and none of the tag columns, and
|
||||||
|
`CREATE TABLE IF NOT EXISTS` cannot add them) — delete and rescan,
|
||||||
|
and rebuild any seed with `make sandbox-seed`.
|
||||||
|
2. **R4's artifact format** — no break was needed. The importer asks
|
||||||
|
the artifact what it carries rather than trusting a version, so the
|
||||||
|
published text-form artifact still imports. Plan 014 has it.
|
||||||
|
3. **Yes, the janitors run.** `Runner.Start` calls `RunDue` immediately
|
||||||
|
and `lastRun` is in-memory, so every launch runs everything due.
|
||||||
|
The 4.1 GB survived because `OrphanedArtistImagesJob` joined a bare
|
||||||
|
MBID onto a *sharded* directory — deleting the rows and leaving the
|
||||||
|
files, which is worse than not running — and because
|
||||||
|
`StrayArtistImageFilesJob` did not exist. Both are fixed; it was a
|
||||||
|
bug report, not a cleanup.
|
||||||
|
|
||||||
|
## Measured on the finished refactor
|
||||||
|
|
||||||
|
| | expected | actual |
|
||||||
|
|---|---|---|
|
||||||
|
| sqlc queries | ~180 | **185** |
|
||||||
|
| generated Go | ~5,000 | **6,024** |
|
||||||
|
| bound methods | ~255 | **248** |
|
||||||
|
| `explore_index` + indexes | — | **780 MB → 405 MB** |
|
||||||
|
|
||||||
|
## The one recommendation not taken
|
||||||
|
|
||||||
|
R4's "better still" for `artist_mbid`: an integer reference to the
|
||||||
|
artist row (8 bytes) rather than the 16 raw bytes it now stores. It is
|
||||||
|
a further ~30 MB on `idx_explore_index_artist_mbid`, and the reason to
|
||||||
|
stop short is that the *artifact* carries MBIDs and not local ids, so
|
||||||
|
the import would have to resolve every row against a table it is in the
|
||||||
|
middle of filling. Worth its own argument, not a footnote to this one.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 014 — The catalog's compact encoding, and the denominator it owed
|
||||||
|
|
||||||
|
**Status:** **complete** (2026-08-16). The encoding landed first; the
|
||||||
|
per-release-group `total_tracks` denominator landed with the album page
|
||||||
|
that spends it.
|
||||||
|
**Branch:** none
|
||||||
|
**Created:** 2026-08-16
|
||||||
|
**Depends on:** nothing
|
||||||
|
**Related:** 013 (the database audit, which measured all of this), 010
|
||||||
|
(owned albums offline), 001 (ship core index)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The encoding
|
||||||
|
|
||||||
|
Measured on the real 2,052,200-row catalog, converting it through the
|
||||||
|
shipped schema (not a projection):
|
||||||
|
|
||||||
|
| object | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `explore_index` | 383 MB | **242 MB** |
|
||||||
|
| `idx_explore_index_artist_mbid` | 131 MB | **65 MB** |
|
||||||
|
| `UNIQUE(mbid)` | 99 MB | **54 MB** |
|
||||||
|
| `idx_explore_index_entity_pop` | 47 MB | **28 MB** |
|
||||||
|
| `idx_explore_caa_release` | 17 MB | **11 MB** |
|
||||||
|
| the two `LOWER()` indexes | 101 MB | **3 MB** (013) |
|
||||||
|
| **total** | **780 MB** | **405 MB** |
|
||||||
|
|
||||||
|
Every row converted with the `CHECK` constraints live, which is also a
|
||||||
|
result: no MBID in a real 2 M-row catalog is malformed.
|
||||||
|
|
||||||
|
**No format bump, and no rebuilt artifact needed.** The importer asks
|
||||||
|
the artifact what encoding it carries (`typeof(mbid)`) and converts on
|
||||||
|
the way in if it is the old text form, so the artifact already
|
||||||
|
published keeps working and the exporter switches whenever CI next
|
||||||
|
runs. That is strictly better than the version negotiation this plan
|
||||||
|
originally proposed.
|
||||||
|
|
||||||
|
The silent-failure risk the plan was written around was handled by
|
||||||
|
making the failure loud instead of by avoiding the change: a `CHECK` on
|
||||||
|
the column turns a stringly write into an error at the insert, the
|
||||||
|
22-column projection became one constant and one scanner instead of
|
||||||
|
four copies, and `TestStoredEncodingRoundTrips` sweeps every read path
|
||||||
|
in the package. It found one real bug on its first run — the artifact
|
||||||
|
probe was asking the read pool, where the attached artifact does not
|
||||||
|
exist.
|
||||||
|
|
||||||
|
## The denominator
|
||||||
|
|
||||||
|
`total_tracks` on `explore_index`, ~2 bytes across 400,677 release
|
||||||
|
groups. It makes "do I have all of this" answerable offline for an
|
||||||
|
album whose **files declared no total**, which is a great deal of any
|
||||||
|
untagged library and the one thing `GetAlbumCompleteness` cannot answer
|
||||||
|
from tags. 010 rightly rejected shipping whole tracklists — the
|
||||||
|
per-artist track budget truncates them, and a truncated tracklist is a
|
||||||
|
confident lie about which tracks exist. A denominator has no such
|
||||||
|
problem, and the album page spends it as one: the numerator stays
|
||||||
|
local (distinct track numbers on disk), only the denominator is
|
||||||
|
borrowed, and only where the tags have none.
|
||||||
|
|
||||||
|
Four things about it are load-bearing.
|
||||||
|
|
||||||
|
**It is counted before the popularity filter.** `cmd/indexbuild` counts
|
||||||
|
the canonical dump's rows per kept release, which is that release's
|
||||||
|
track count because the dump carries one row per recording per
|
||||||
|
canonical release. Counting the *kept* recordings instead would say
|
||||||
|
"9" about a twelve-track album whose other three nobody has played —
|
||||||
|
worse than saying nothing, and the same class of lie as the truncated
|
||||||
|
tracklist. `TestDumpImportEndToEnd` has an unplayed track on a fixture
|
||||||
|
album for exactly this: three tracks in the total, two indexed as
|
||||||
|
recordings.
|
||||||
|
|
||||||
|
**Zero means "the catalog does not say"**, which is the same third
|
||||||
|
state the local answer already has. An album neither side can total
|
||||||
|
wears no ring rather than a wrong one.
|
||||||
|
|
||||||
|
**Adding a column to the importer's SELECT is how you break every
|
||||||
|
artifact already published.** `artifactHasTotals()` asks the attached
|
||||||
|
artifact whether the column exists, the same way and on the same handle
|
||||||
|
as `artifactStoresText()`, and selects a literal `0` when it does not.
|
||||||
|
Verified by forcing the probe true: the older shape then fails with
|
||||||
|
`no such column: total_tracks`, which is what a shipped build would
|
||||||
|
have done to a file nobody can re-cut retroactively.
|
||||||
|
|
||||||
|
**A test seeder that binds the upsert's parameters by hand is not
|
||||||
|
"breaking where the app breaks".** Three of them did, on the argument
|
||||||
|
that a schema change should fail the tests in the same place — and what
|
||||||
|
it actually produced was `missing argument with index 25`, three files
|
||||||
|
at a time, for a column none of them cares about. They go through
|
||||||
|
`upsertBatch` now, which is the one writer, and keep the property they
|
||||||
|
wanted: a field written to the wrong column still fails there.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- [x] `GetAlbumCompleteness`'s gap is answerable for a catalog album the
|
||||||
|
library has no tags for, with no network call.
|
||||||
|
- [x] The artifact grows by less than a megabyte (~800 kB at 400,677
|
||||||
|
release groups).
|
||||||
|
- [x] An artifact published before the column still imports.
|
||||||
@@ -223,80 +223,101 @@ rather than renaming them.
|
|||||||
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below.
|
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below.
|
||||||
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
|
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
|
||||||
|
|
||||||
**Schema changes need two things, not one.** `sql/schemas/*.sql` is
|
**The schema is one description, and there is no migration chain.**
|
||||||
`CREATE ... IF NOT EXISTS` and is what sqlc reads — it's the single
|
`sql/schemas/*.sql` is `CREATE ... IF NOT EXISTS`, declares the
|
||||||
source of truth for "what the schema looks like right now", and it's
|
current shape of every table, and is what sqlc reads and what an
|
||||||
what a fresh install gets verbatim. But it's a no-op against a
|
install gets verbatim. That is the whole mechanism: `sql/migrations/`,
|
||||||
database that already has the table, so an existing install needs a
|
`applyMigrations` and `schema_migrations` were squashed away with the
|
||||||
matching file in `sql/schemas/../migrations/` (e.g.
|
file-shaped rewrite (plan 013). A schema change is one edit to one
|
||||||
`NNNN_description.sql`, `ALTER TABLE ... ADD COLUMN ...` /
|
file plus `make generate`. Reintroducing a chain means reintroducing
|
||||||
`CREATE INDEX ...`) to actually reach that shape. Both run on every
|
the drift it caused before — `sql/schemas/` and the migrations
|
||||||
open, migrations after schema files, tracked in `schema_migrations`
|
disagreed, and sqlc generated against the stale one.
|
||||||
so each applies once; a migration's `ALTER TABLE ADD COLUMN` failing
|
|
||||||
with "duplicate column name" on an already-current database is
|
|
||||||
expected and tolerated, not an error.
|
|
||||||
|
|
||||||
A few things that bite if forgotten:
|
**The local library is shaped like files, not like MusicBrainz.**
|
||||||
- **Column order must match between the two paths.** `ALTER TABLE
|
`audio_files` carries its own tags — title, artist credit, track and
|
||||||
ADD COLUMN` always appends at the end, so a migrated column must
|
disc numbers, year, composer, the recording MBID — and points at two
|
||||||
also be declared *last* in the `CREATE TABLE` in `sql/schemas/`
|
shared rows: `albums` (many files to one) and `artists` (many albums
|
||||||
— otherwise a fresh install and an upgraded install disagree on
|
to one). `file_genres` is the one genuine many-to-many. That is the
|
||||||
column order, and a `SELECT *` query (sqlc binds those
|
entire local model.
|
||||||
positionally) silently reads the wrong field on one of them. See
|
|
||||||
`backend/database/migrations_test.go`'s
|
It used to be MusicBrainz's: `recordings`, `release_group_recordings`,
|
||||||
`TestMigrations_ColumnOrderMatchesFreshInstall`, which is the
|
`artist_credit` and `artist_credit_artist` sat between a file and its
|
||||||
regression test for exactly this.
|
own tags. Measured on a real 25,966-file library, **every**
|
||||||
- **Don't put an index on a migrated column in `sql/schemas/`.**
|
many-to-many that model expressed was 1:1 in the data — no recording
|
||||||
Schema files run before migrations, against a database that may
|
had two files, none belonged to two release groups, and 3 credits of
|
||||||
not have that column yet — the index's predicate would fail
|
2,823 listed more than one artist. What it cost was a six-way join in
|
||||||
(this is precisely the bug an earlier session shipped and a user
|
every read, a `MIN(release_group_id)` subquery in eleven queries and a
|
||||||
hit at `make sandbox`). Declare it in the migration file instead,
|
first-credited-artist subquery in nine to undo fan-outs that never
|
||||||
after the `ALTER TABLE` that adds the column.
|
happened, and a class of bugs where a metadata row **outlived the file
|
||||||
- This project **had** a 48-step migration chain before and tore it
|
that created it**: retagging a file created a new recording and
|
||||||
out (see `.planning/NOTES.md`, "No migration chain") because
|
abandoned the old one, so that library carried 812 orphaned
|
||||||
`sql/schemas/` had drifted from what the migrations actually
|
recordings, 216 release groups and 260 artists — and 129 catalog rows
|
||||||
produced and sqlc silently generated against the stale version.
|
that confidently claimed to be owned by files that no longer existed.
|
||||||
The design here avoids that by keeping `sql/schemas/` as the
|
|
||||||
literal target shape (not a hand-maintained description of it)
|
A few things that follow, and bite if forgotten:
|
||||||
and letting migrations replay tolerantly against it — but the
|
- **Ownership is a file.** "Do I own this" is asked of `audio_files`
|
||||||
same drift is possible again if a schema change ships without
|
and nothing else — `GetFilePathsByRecordingMBIDs`,
|
||||||
updating both files. Don't reintroduce a *second* description of
|
`LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
|
||||||
the schema anywhere else.
|
`pruneStaleLocalCrossReferences` all join it. A metadata row is not
|
||||||
|
ownership; that was the bug, and it is now structurally impossible
|
||||||
|
for a row to exist without its file.
|
||||||
|
- **The projection is defined once, in the `track_metadata` view.**
|
||||||
|
Every query that returns a track selects from it, which is why
|
||||||
|
there is one row type (`sqlcgen.TrackMetadatum`) and one mapper
|
||||||
|
(`trackFromRow`). It existed before and only the raw-SQL search
|
||||||
|
paths used it, so nine hand-rolled copies had already drifted: the
|
||||||
|
view preferred the album's original year and one copy used the
|
||||||
|
track's, and the same library reported different years on different
|
||||||
|
screens. The FTS searches cannot be sqlc queries (MATCH is not in
|
||||||
|
its grammar) and spell the column list out in `search.go` — that is
|
||||||
|
the one exception and it is one constant.
|
||||||
|
- **`library_id = 0` means every library.** Each list query used to
|
||||||
|
exist twice, scoped and unscoped, with a branch at every call site
|
||||||
|
and a separate binding for each. One query answers both, and the
|
||||||
|
scoped form costs nothing measurable (23 ms against 21 ms over 26k
|
||||||
|
rows).
|
||||||
|
- **A slice and a named parameter do not compose in sqlc.**
|
||||||
|
`sqlc.slice` expands to N placeholders but a named argument is
|
||||||
|
numbered independently, so `GetFilePathsByAlbums([1,2], 0)` read
|
||||||
|
album id 2 as the library id. Where a query needs both, return
|
||||||
|
`library_id` and filter in Go (`inLibrary`).
|
||||||
|
- **A cache without a ceiling is a leak with a schedule.** Every
|
||||||
|
store that grows with use declares a budget beside its retention
|
||||||
|
(`browsedArtBudget`, `httpCacheBudget`), because an age bound does
|
||||||
|
not bound anything a user can outrun in an afternoon.
|
||||||
|
- **A query file must be ASCII.** sqlc's parameter rewriter works on
|
||||||
|
byte offsets, so one non-ASCII character in a *query* comment
|
||||||
|
corrupts the generated Go into garbage like `SELECid`. Schema files
|
||||||
|
are not rewritten and may contain anything.
|
||||||
|
- **A view is dropped and recreated, not migrated.**
|
||||||
|
`CREATE VIEW IF NOT EXISTS` no-ops against a database holding the
|
||||||
|
old definition, so `track_metadata.sql` opens with
|
||||||
|
`DROP VIEW IF EXISTS`; a view holds no data, so rebuilding it on
|
||||||
|
every open costs nothing.
|
||||||
- **A write wearing a query's shape still needs the writer.**
|
- **A write wearing a query's shape still needs the writer.**
|
||||||
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
|
`DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a
|
||||||
*query-only* read pool (a second `sql.DB` over the same file), so
|
*query-only* read pool (a second `sql.DB` over the same file), so
|
||||||
an `INSERT ... RETURNING` issued through one fails at runtime with
|
an `INSERT ... RETURNING` issued through one fails at runtime with
|
||||||
"attempt to write a readonly database (8)" — which is exactly what
|
"attempt to write a readonly database (8)". Use `ExecContext`, or
|
||||||
`CreateSmartPlaylist` did, meaning no smart playlist could be
|
`QueryRowWriter` when the statement really does return a row.
|
||||||
created at all. Use `ExecContext`, or `QueryRowWriter` when the
|
Nothing caught this because `NewTestDB` shares one in-memory
|
||||||
statement really does return a row. Nothing caught this because
|
connection and leaves `readDB` nil, so `reader()` returns the
|
||||||
`NewTestDB` shares one in-memory connection and leaves `readDB`
|
*writer* under test. `TestNoWritesOnTheReadPool` walks the tree for
|
||||||
nil, so `reader()` returns the *writer* under test and the unit
|
it, in the same spirit as `TestNoDirectRuntimeEmits`.
|
||||||
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.
|
|
||||||
- **A new table has to say what kind of data it holds.**
|
- **A new table has to say what kind of data it holds.**
|
||||||
`backend/datamap` is a catalogue of every table's Kind and
|
`backend/datamap` is a catalogue of every table's Kind and
|
||||||
Lifetime, and `TestCatalogCoversSchema` fails on a table missing
|
Lifetime, and `TestCatalogCoversSchema` fails on a table missing
|
||||||
from it. `TestAuthoredCascadesAreDeliberate` then makes an
|
from it. `TestAuthoredCascadesAreDeliberate` then makes an
|
||||||
*authored* table that cascades an explicit, argued exemption —
|
*authored* table that cascades an explicit, argued exemption —
|
||||||
authored data is what a user cannot get back.
|
authored data is what a user cannot get back. Two entries say
|
||||||
- **Squashing is fine pre-1.0.** While this hasn't shipped to real
|
**MIXED KIND** and mean it: `audio_files` is an owned projection
|
||||||
users, periodically folding `sql/migrations/` into `sql/schemas/`
|
except for `play_count`, `last_played` and `tag_status`, which are
|
||||||
and deleting the migration files (then wiping your own dev/sandbox
|
authored; `lyrics` carries a `source` column because a lyric read
|
||||||
DB) is a legitimate way to keep the migrations directory from
|
from a tag is free to rebuild and one fetched from LRCLIB is not.
|
||||||
accumulating dev-only churn — same effect as the old "just nuke
|
- **Test data has one seeder.** `database.InsertTestTrack` inserts a
|
||||||
it" workflow, opt-in instead of mandatory. Stop doing that once
|
file with its artist, album and genres. Twenty test files used to
|
||||||
real user databases exist in the wild.
|
carry their own, each assembling the old FK chain in a slightly
|
||||||
|
different order.
|
||||||
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
|
- `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC).
|
||||||
- `jobs` — The registry every long-running operation reports through:
|
- `jobs` — The registry every long-running operation reports through:
|
||||||
progress, pause/cancel, a global indicator and (for scans) a pause
|
progress, pause/cancel, a global indicator and (for scans) a pause
|
||||||
@@ -391,6 +412,67 @@ work happens **once, centrally**, and users download the result:
|
|||||||
(`dumpincremental.go`), and resolves artists outside the artifact's
|
(`dumpincremental.go`), and resolves artists outside the artifact's
|
||||||
coverage lazily on first view.
|
coverage lazily on first view.
|
||||||
|
|
||||||
|
**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
|
**Background work yields, and says so in the context.** The post-scan
|
||||||
backfills share MusicBrainz's rate limiters with every page the user
|
backfills share MusicBrainz's rate limiters with every page the user
|
||||||
can open, and both were FIFO — so a thousand-artist enrichment put an
|
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
|
`MaxOpenConns(1)`, so the workers queue at the Go level rather than
|
||||||
racing for the file.
|
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
|
more `explore_index` columns**, because `artifactimport.go` merges the
|
||||||
downloaded catalog by column list — a flag added there is a second
|
downloaded catalog by column list — a flag added there is a second
|
||||||
place to remember, and forgetting it silently wipes every mark on the
|
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
|
keeping only `explore.ArtistImageKeepNames()` and refusing an empty
|
||||||
keep set for the reason the covers sweep refuses an empty live set.
|
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`.
|
**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.
|
**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
|
and **no play button at all** when none is, because a Play button that
|
||||||
plays nothing (or seven tracks of forty) is worse than none.
|
plays nothing (or seven tracks of forty) is worse than none.
|
||||||
|
|
||||||
`albumLibraryStatus()` is deliberately *not* what decides that. It is
|
**The page asks one question, once, and it is "is there a file".**
|
||||||
four claims of decreasing confidence OR'd into one tick — a local album
|
Ownership used to be several claims of decreasing confidence OR'd
|
||||||
id, the backend's cross-reference, a cached MBID match, and finally
|
together — a local album id, the backend's cross-reference, a cached
|
||||||
*any single track* marked `inLibrary` — which is a fine answer to "is
|
MBID match, and finally *any single track* flagged `inLibrary` — none
|
||||||
any of this mine" and a useless basis for a button. `ownership()`
|
of which is "there is a file to play", which is why the tick could be
|
||||||
counts the displayed tracklist instead, whose `inLibrary` flags the
|
green on an album whose every action did nothing, and why the
|
||||||
backend sets per recording MBID.
|
tracklist's context menu asked the backend on **hover** whether the row
|
||||||
|
it was drawing was owned. `filePaths` is the one answer: a map from a
|
||||||
|
displayed track to its path, filled once from `updated()` by a single
|
||||||
|
batched `GetFilePathsByRecordingMBIDs`, and read by the badge, the Play
|
||||||
|
button's count, the dimmed rows and every menu item. `askedFor` is a
|
||||||
|
separate set from `filePaths` because the guard has to be *asked*, not
|
||||||
|
*answered* — an unowned MBID never lands in the map, so guarding on the
|
||||||
|
map re-requests it on every render, forever.
|
||||||
|
|
||||||
|
The other half is that the *displayed* tracklist is also one thing.
|
||||||
|
`buildVersionEntries` synthesises the "Your Library" entry from
|
||||||
|
`localTracks`, so an album the catalog cannot answer for is exactly the
|
||||||
|
case that needs the version list rebuilt — `loadLocalTracks` guarded
|
||||||
|
that rebuild on `releases.length > 0` and so rendered "No release data
|
||||||
|
available" over a tracklist it was holding in memory.
|
||||||
|
|
||||||
**And the key it plays by is not the key it looks owned by.** The local
|
**And the key it plays by is not the key it looks owned by.** The local
|
||||||
album id is used wherever there is one, because a library-only album
|
album id is used wherever there is one, because a library-only album
|
||||||
has *no* recording MBIDs — its tracks are synthesised from
|
has *no* recording MBIDs — its tracks are synthesised from
|
||||||
`GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed
|
`GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed
|
||||||
lookup on an untagged library resolves to nothing and Play queues
|
lookup on an untagged library resolves to nothing and Play queues
|
||||||
nothing while looking entirely correct.
|
nothing while looking entirely correct. Those synthesised tracks carry
|
||||||
`GetFilePathsByRecordingMBIDs` is the catalog-only fallback and the
|
their `FilePath` from the same rows, so they populate `filePaths`
|
||||||
third member of the `GetFilePathsBy…` family: one query, paths only,
|
directly and cost no lookup at all; the batched
|
||||||
grouped so the caller keeps the tracklist's order. It exists rather
|
`GetFilePathsByRecordingMBIDs` is what answers for a *catalog*
|
||||||
than a lookup by track id because **`MBTrack.LocalID` is declared and
|
tracklist. It is the third member of the `GetFilePathsBy…` family: one
|
||||||
nothing in the backend ever writes it**.
|
query, paths only, grouped so the caller keeps the tracklist's order.
|
||||||
|
It exists rather than a lookup by track id because **`MBTrack.LocalID`
|
||||||
|
is declared and nothing in the backend ever writes it**.
|
||||||
|
|
||||||
**How much of an album is here is a question the files can answer.**
|
**How much of an album is here is a question the files can answer.**
|
||||||
`ownership()` above counts the *displayed* tracklist, which for a
|
`ownership()` above counts the *displayed* tracklist, which for a
|
||||||
library copy is a tautology — every local track is `inLibrary: true`,
|
library copy is a tautology — every local track has a file, so owned
|
||||||
so owned always equals total and "do I have all of this" had no local
|
always equals total and "do I have all of this" had no local answer.
|
||||||
answer. The album page therefore asked MusicBrainz, and
|
The album page therefore asked MusicBrainz, and
|
||||||
`BrowseReleases` is the most expensive call the app makes: releases
|
`BrowseReleases` is the most expensive call the app makes: releases
|
||||||
plus every version's full tracklist, on a 1 req/s limiter shared with
|
plus every version's full tracklist, on a 1 req/s limiter shared with
|
||||||
`PrefetchReleases`, which fires up to eight when an artist page
|
`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"
|
The denominator was already on disk. `metadata` has read the "5/12"
|
||||||
totals off every file since forever (`m.Track()`, `m.Disc()`) and
|
totals off every file since forever (`m.Track()`, `m.Disc()`) and
|
||||||
discarded them; they persist to
|
discarded them; they persist to `audio_files.total_tracks` now, and
|
||||||
`release_group_recordings.total_tracks` now, and
|
|
||||||
`GetAlbumCompleteness` sums them. **A complete, MBID-matched album
|
`GetAlbumCompleteness` sums them. **A complete, MBID-matched album
|
||||||
makes no catalog call at all** — identity from the MBID, tracklist from
|
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.
|
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
|
this app detects duplicates, and counting two files of track 3 twice
|
||||||
would report a short album as complete.
|
would report a short album as complete.
|
||||||
|
|
||||||
What tags cannot give is *which* tracks are missing, only how many — so
|
**Where the tags have no total, the catalog does.** `explore_index`
|
||||||
an incomplete album still browses, and that is now the exception rather
|
carries a per-release-group `total_tracks` — ~2 bytes across 400,677
|
||||||
than every album load. Two smaller consequences: existing databases
|
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
|
read "unknown" until a rescan repopulates the column (which degrades to
|
||||||
exactly the old behaviour, so nothing breaks), and our own `tagwriter`
|
exactly the old behaviour, so nothing breaks), and our own `tagwriter`
|
||||||
writes track and disc *numbers* but not totals, so autotagging a folder
|
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
|
and the timer is a 60 s backstop for a genuine hang rather than the
|
||||||
verdict.
|
verdict.
|
||||||
|
|
||||||
|
**Activating a row plays the list the row is in, from that row.** A
|
||||||
|
double-click — and Play on a single row's context menu — queues the
|
||||||
|
list as *displayed* with `startIndex` on that row, not a queue of one
|
||||||
|
track that stops when the song ends. The two playlist views and
|
||||||
|
`cover-grid`'s album dropdown always did this; the album page and
|
||||||
|
`track-list` did not, so playing anything from the two largest
|
||||||
|
tracklists in the app discarded the album around it. Three rules come
|
||||||
|
with it. **A menu asks how much is selected**: one row is a position
|
||||||
|
and means "from here", several rows are an explicit choice of *those*
|
||||||
|
tracks and become the queue on their own (which is also the only case
|
||||||
|
where `shuffleStart` still applies, since no one row was named as the
|
||||||
|
place to start). **The index is into the paths, not into the rows** —
|
||||||
|
`explore-album-details` queues `ownedFilePaths()` and its dimmed rows
|
||||||
|
are not in it, so an index taken from the tracklist starts an album
|
||||||
|
somewhere else entirely, or past its end. And **the index is looked up
|
||||||
|
when it is used, not remembered**: selection keys are file paths
|
||||||
|
because those survive a re-sort, a re-filter and a refetch, and an
|
||||||
|
index survives none of the three — `displayIndexOf` is that lookup,
|
||||||
|
against `cachedSortedTracks`, which is the only order the user can see
|
||||||
|
and therefore the only one they can mean.
|
||||||
|
|
||||||
**Ask for what the caller uses, once.** "Play this artist" resolved
|
**Ask for what the caller uses, once.** "Play this artist" resolved
|
||||||
file paths with one `GetAlbumTracks` per album, sequentially, and every
|
file paths with one `GetAlbumTracks` per album, sequentially, and every
|
||||||
one of the four sites doing that asked for whole track rows to read
|
one of the four sites doing that asked for whole track rows to read
|
||||||
|
|||||||
+29
-30
@@ -328,43 +328,42 @@ func (a *Applier) Apply(
|
|||||||
func (a *Applier) syncDBMBIDs(
|
func (a *Applier) syncDBMBIDs(
|
||||||
ctx context.Context, tr TrackApply, cand Candidate,
|
ctx context.Context, tr TrackApply, cand Candidate,
|
||||||
) error {
|
) error {
|
||||||
// Look up recording row via audio_file.
|
|
||||||
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get audio_file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tr.CandidateTrack.MBID != "" {
|
if tr.CandidateTrack.MBID != "" {
|
||||||
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
|
if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
|
||||||
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
||||||
ID: af.RecordingID,
|
ID: tr.Local.AudioFileID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("set recording mbid: %w", err)
|
return fmt.Errorf("set recording mbid: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cand.ReleaseGroupMBID != "" {
|
if cand.ReleaseGroupMBID == "" {
|
||||||
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
|
return nil
|
||||||
if err == nil && rgID > 0 {
|
}
|
||||||
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
|
|
||||||
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
|
||||||
ID: rgID,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("set release group mbid: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stamp the release-group's original-release year too —
|
// The album is reached through the file rather than through two
|
||||||
// this is what the tracklist / smart-playlist year rule
|
// join tables; SetFileAlbumMBID takes the file id and does the
|
||||||
// surfaces by default once the user accepts a candidate.
|
// lookup in one statement.
|
||||||
if year := parseYear(cand.OriginalDate); year > 0 {
|
if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
|
||||||
if err := a.q.SetReleaseGroupOriginalYear(
|
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
||||||
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
|
ID: tr.Local.AudioFileID,
|
||||||
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
}); err != nil {
|
||||||
ID: rgID,
|
return fmt.Errorf("set album mbid: %w", err)
|
||||||
},
|
}
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("set release group original year: %w", err)
|
// Stamp the album's original-release year too - this is what the
|
||||||
}
|
// tracklist and the smart-playlist year rule surface by default
|
||||||
|
// once the user accepts a candidate.
|
||||||
|
if year := parseYear(cand.OriginalDate); year > 0 {
|
||||||
|
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
|
||||||
|
if err == nil && af.AlbumID.Valid {
|
||||||
|
if err := a.q.SetAlbumOriginalYear(
|
||||||
|
ctx, sqlcgen.SetAlbumOriginalYearParams{
|
||||||
|
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
||||||
|
ID: af.AlbumID.Int64,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("set album original year: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package autotag_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -87,51 +86,22 @@ func seedAudioFiles(
|
|||||||
q := db.Queries
|
q := db.Queries
|
||||||
ctx := db.Ctx
|
ctx := db.Ctx
|
||||||
|
|
||||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
|
||||||
Name: "Test Album",
|
|
||||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert rg: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]sqlcgen.AudioFile, 0, len(paths))
|
out := make([]sqlcgen.AudioFile, 0, len(paths))
|
||||||
|
|
||||||
for i, p := range paths {
|
for i, p := range paths {
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
id := database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
Name: p,
|
FilePath: p,
|
||||||
ArtistCreditID: ac.ID,
|
Title: p,
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
Artist: "Test Artist",
|
||||||
|
Album: "Test Album",
|
||||||
|
TrackNumber: int64(i + 1),
|
||||||
|
LengthMs: 100000,
|
||||||
|
GroupKey: groupKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
af, err := q.GetAudioFile(ctx, id)
|
||||||
ReleaseGroupID: rg.ID,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("link rg recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
|
||||||
FilePath: p,
|
|
||||||
LengthMilliseconds: 100000,
|
|
||||||
FileTypeID: 0,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
Basename: p,
|
|
||||||
LibraryID: 0,
|
|
||||||
GroupKey: groupKey,
|
|
||||||
TagStatus: "untagged",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create audio file: %v", err)
|
t.Fatalf("read seeded audio file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, af)
|
out = append(out, af)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResolveLocal returns candidate releases sourced from the local
|
// ResolveLocal returns candidate releases sourced from the local
|
||||||
// DB's release_groups rows (filtered to those carrying an MBID)
|
// DB's albums (filtered to those carrying an MBID)
|
||||||
// whose normalized name matches the tagging item's album name.
|
// whose normalized name matches the tagging item's album name.
|
||||||
// No network calls. Candidates carry all tracks flat; caller runs
|
// No network calls. Candidates carry all tracks flat; caller runs
|
||||||
// AlignTracks on each to produce per-track alignments.
|
// AlignTracks on each to produce per-track alignments.
|
||||||
@@ -67,7 +67,7 @@ func (r *LocalResolver) ResolveLocal(
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
|
rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list local candidates: %w", err)
|
return nil, fmt.Errorf("list local candidates: %w", err)
|
||||||
}
|
}
|
||||||
@@ -84,12 +84,12 @@ func (r *LocalResolver) ResolveLocal(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := byID[row.ReleaseGroupID]; !ok {
|
if _, ok := byID[row.AlbumID]; !ok {
|
||||||
byID[row.ReleaseGroupID] = localCandidate(row)
|
byID[row.AlbumID] = localCandidate(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
tracksByID[row.ReleaseGroupID] = append(
|
tracksByID[row.AlbumID] = append(
|
||||||
tracksByID[row.ReleaseGroupID],
|
tracksByID[row.AlbumID],
|
||||||
CandidateTrack{
|
CandidateTrack{
|
||||||
Position: int(row.TrackNumber),
|
Position: int(row.TrackNumber),
|
||||||
DiscNumber: int(row.DiscNumber),
|
DiscNumber: int(row.DiscNumber),
|
||||||
@@ -113,15 +113,15 @@ func (r *LocalResolver) ResolveLocal(
|
|||||||
// localCandidate converts one sqlc row (minus track-level fields)
|
// localCandidate converts one sqlc row (minus track-level fields)
|
||||||
// into a Candidate shell. Track fields and alignments are filled
|
// into a Candidate shell. Track fields and alignments are filled
|
||||||
// in by the caller.
|
// in by the caller.
|
||||||
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
|
func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate {
|
||||||
date := ""
|
date := ""
|
||||||
if row.Year > 0 {
|
if row.Year > 0 {
|
||||||
date = fmt.Sprintf("%04d", row.Year)
|
date = fmt.Sprintf("%04d", row.Year)
|
||||||
}
|
}
|
||||||
|
|
||||||
mbid := ""
|
mbid := ""
|
||||||
if row.ReleaseGroupMbid.Valid {
|
if row.AlbumMbid.Valid {
|
||||||
mbid = row.ReleaseGroupMbid.String
|
mbid = row.AlbumMbid.String
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Candidate{
|
return &Candidate{
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ package autotag_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"yellowjacket/backend/autotag"
|
"yellowjacket/backend/autotag"
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedAlbum drops a minimal release_group + recordings + audio_files
|
// seedAlbum drops a minimal release_group + recordings + audio_files
|
||||||
@@ -33,70 +31,19 @@ type seededTrack struct {
|
|||||||
func seed(t *testing.T, db *database.DB, album seededAlbum) {
|
func seed(t *testing.T, db *database.DB, album seededAlbum) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := db.Ctx
|
|
||||||
q := db.Queries
|
|
||||||
|
|
||||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert ac: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
|
||||||
Name: album.albumName,
|
|
||||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert rg: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if album.releaseMBID != "" {
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
|
|
||||||
album.releaseMBID, rg.ID,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("set rg mbid: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tr := range album.tracks {
|
for _, tr := range album.tracks {
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
Name: tr.title,
|
FilePath: tr.filePath,
|
||||||
ArtistCreditID: ac.ID,
|
Title: tr.title,
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
Artist: "Test Artist",
|
||||||
|
Album: album.albumName,
|
||||||
|
AlbumMBID: album.releaseMBID,
|
||||||
|
RecordingMBID: tr.recordingMBID,
|
||||||
|
TrackNumber: int64(tr.trackNumber),
|
||||||
|
LengthMs: tr.lengthMillis,
|
||||||
|
LibraryID: album.libraryID,
|
||||||
|
GroupKey: album.groupKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tr.recordingMBID != "" {
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`UPDATE recordings SET mbid = ? WHERE id = ?`,
|
|
||||||
tr.recordingMBID, rec.ID,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("set recording mbid: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
|
||||||
ReleaseGroupID: rg.ID,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("link rg recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
|
||||||
FilePath: tr.filePath,
|
|
||||||
LengthMilliseconds: tr.lengthMillis,
|
|
||||||
FileTypeID: 0,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
Basename: tr.filePath,
|
|
||||||
LibraryID: album.libraryID,
|
|
||||||
GroupKey: album.groupKey,
|
|
||||||
TagStatus: "untagged",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const applyJobPrefix = "autotag:"
|
|||||||
// registry gets progress, cancel and the global indicator for free; the
|
// registry gets progress, cancel and the global indicator for free; the
|
||||||
// three subsystems that lacked them were the three that were not
|
// three subsystems that lacked them were the three that were not
|
||||||
// registered.
|
// registered.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.jobsReg = reg
|
s.jobsReg = reg
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package autotagservice
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"yellowjacket/backend/autotag"
|
"yellowjacket/backend/autotag"
|
||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestService builds a Service with just enough wired up for
|
// newTestService builds a Service with just enough wired up for
|
||||||
@@ -36,62 +36,18 @@ func newTestService(t *testing.T, db *database.DB) *Service {
|
|||||||
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := db.Ctx
|
|
||||||
q := db.Queries
|
|
||||||
|
|
||||||
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
|
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
|
||||||
ac, err := q.UpsertArtistCredit(ctx, artist)
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
if err != nil {
|
FilePath: filePath,
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
Title: title,
|
||||||
}
|
Artist: artist,
|
||||||
|
Album: album,
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
AlbumArtist: albumArtist,
|
||||||
Name: title,
|
TrackNumber: int64(trackNum),
|
||||||
ArtistCreditID: ac.ID,
|
LengthMs: 200000,
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
|
LibraryID: libraryID,
|
||||||
|
GroupKey: groupKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if album != "" {
|
|
||||||
albumArtistAC, err := q.UpsertArtistCredit(ctx, albumArtist)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert album artist credit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
|
||||||
Name: album,
|
|
||||||
AlbumArtistCreditID: sql.NullInt64{Int64: albumArtistAC.ID, Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert release group: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateReleaseGroupRecording(
|
|
||||||
ctx,
|
|
||||||
sqlcgen.CreateReleaseGroupRecordingParams{
|
|
||||||
ReleaseGroupID: rg.ID,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true},
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("link release group recording: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
|
||||||
FilePath: filePath,
|
|
||||||
LengthMilliseconds: 200000,
|
|
||||||
FileTypeID: 0,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
Basename: filePath,
|
|
||||||
LibraryID: libraryID,
|
|
||||||
GroupKey: groupKey,
|
|
||||||
TagStatus: "untagged",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1)
|
addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1)
|
||||||
@@ -113,58 +69,22 @@ func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryI
|
|||||||
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ctx := db.Ctx
|
for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} {
|
||||||
q := db.Queries
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
|
FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1),
|
||||||
ac, err := q.UpsertArtistCredit(ctx, "The Beatles")
|
Title: title,
|
||||||
if err != nil {
|
Artist: "The Beatles",
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
Album: "Abbey Road",
|
||||||
}
|
TrackNumber: int64(i + 1),
|
||||||
|
LengthMs: 200000,
|
||||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
LibraryID: libraryID,
|
||||||
Name: "Abbey Road",
|
GroupKey: groupKey,
|
||||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert release group: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
titles := []string{"Come Together", "Something", "Maxwell's Silver Hammer", "Oh! Darling"}
|
|
||||||
for i, title := range titles {
|
|
||||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
|
||||||
Name: title,
|
|
||||||
ArtistCreditID: ac.ID,
|
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
|
||||||
ReleaseGroupID: rg.ID,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("link release group recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
|
||||||
FilePath: groupKey + "/" + title + ".mp3",
|
|
||||||
LengthMilliseconds: 200000,
|
|
||||||
FileTypeID: 0,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
Basename: title + ".mp3",
|
|
||||||
LibraryID: libraryID,
|
|
||||||
GroupKey: groupKey,
|
|
||||||
TagStatus: "untagged",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||||
VALUES (?, ?, 4, 'Abbey Road', 'The Beatles', 0, 'pending')
|
VALUES (?, ?, 3, 'Abbey Road', 'The Beatles', 0, 'pending')
|
||||||
`, groupKey, libraryID); err != nil {
|
`, groupKey, libraryID); err != nil {
|
||||||
t.Fatalf("insert tagging item: %v", err)
|
t.Fatalf("insert tagging item: %v", err)
|
||||||
}
|
}
|
||||||
@@ -293,20 +213,11 @@ func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) {
|
|||||||
|
|
||||||
db := database.NewTestDB(t)
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
if _, err := db.Queries.CreateAudioFileWithGroupKey(
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
db.Ctx,
|
FilePath: "/coherent/01.mp3",
|
||||||
sqlcgen.CreateAudioFileWithGroupKeyParams{
|
Title: "Track",
|
||||||
FilePath: "/coherent/01.mp3",
|
GroupKey: "g-coherent",
|
||||||
FileTypeID: 0,
|
})
|
||||||
RecordingID: mustCreateRecording(t, db, "Track"),
|
|
||||||
Basename: "01.mp3",
|
|
||||||
LibraryID: 0,
|
|
||||||
GroupKey: "g-coherent",
|
|
||||||
TagStatus: "untagged",
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||||
@@ -328,20 +239,11 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
|
|||||||
db := database.NewTestDB(t)
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
// A real, live folder — must survive.
|
// A real, live folder — must survive.
|
||||||
if _, err := db.Queries.CreateAudioFileWithGroupKey(
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
db.Ctx,
|
FilePath: "/live/01.mp3",
|
||||||
sqlcgen.CreateAudioFileWithGroupKeyParams{
|
Title: "Track",
|
||||||
FilePath: "/live/01.mp3",
|
GroupKey: "g-live",
|
||||||
FileTypeID: 0,
|
})
|
||||||
RecordingID: mustCreateRecording(t, db, "Track"),
|
|
||||||
Basename: "01.mp3",
|
|
||||||
LibraryID: 0,
|
|
||||||
GroupKey: "g-live",
|
|
||||||
TagStatus: "untagged",
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||||
@@ -385,22 +287,3 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
|
|||||||
t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err)
|
t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustCreateRecording(t *testing.T, db *database.DB, title string) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Artist")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, err := db.Queries.CreateRecordingFull(db.Ctx, sqlcgen.CreateRecordingFullParams{
|
|
||||||
Name: title,
|
|
||||||
ArtistCreditID: ac.ID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return rec.ID
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import (
|
|||||||
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
||||||
const PathPrefix = "/covers/"
|
const PathPrefix = "/covers/"
|
||||||
|
|
||||||
// URLs holds the resolved URL paths for all cover art size variants.
|
// URLs holds the resolved URL paths for a cover's size variants.
|
||||||
|
//
|
||||||
|
// Original is the largest variant kept, which is the Large one: the
|
||||||
|
// full-resolution image is no longer stored. It was 1,134 MB of a
|
||||||
|
// 1.4 GB covers directory on a real 2,057-album library against 110 MB
|
||||||
|
// for all three rendered tiers, and nothing rendered it - the grid caps
|
||||||
|
// at 350 px and the largest tier is 400. The field keeps its name
|
||||||
|
// because it is what a caller means by "the cover", and the bytes it
|
||||||
|
// came from are still in the audio file if a bigger one is ever wanted.
|
||||||
type URLs struct {
|
type URLs struct {
|
||||||
Original string
|
Original string
|
||||||
Small string
|
Small string
|
||||||
@@ -36,25 +44,41 @@ func CoversDir() (string, error) {
|
|||||||
return filepath.Join(dataDir, dirName), nil
|
return filepath.Join(dataDir, dirName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SizedFilename derives a sized-variant filename from an original cover art
|
// Suffixes are the size variants a cover is stored as, largest last.
|
||||||
// filename and a size suffix.
|
var Suffixes = []string{"_sm", "_md", "_lg"}
|
||||||
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
|
|
||||||
func SizedFilename(originalFilename, suffix string) string {
|
|
||||||
ext := filepath.Ext(originalFilename)
|
|
||||||
name := strings.TrimSuffix(originalFilename, ext)
|
|
||||||
|
|
||||||
return name + suffix + ".jpg"
|
// SizedFilename derives a sized-variant filename from a cover art
|
||||||
|
// filename and a size suffix. The input may itself be a variant, so
|
||||||
|
// its suffix is stripped first: SizedFilename("a1b2_lg.jpg", "_sm")
|
||||||
|
// and SizedFilename("a1b2.jpg", "_sm") both return "a1b2_sm.jpg".
|
||||||
|
func SizedFilename(filename, suffix string) string {
|
||||||
|
return BaseName(filename) + suffix + ".jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseName strips the extension and any size suffix from a cover art
|
||||||
|
// filename, leaving the content hash that identifies the cover.
|
||||||
|
func BaseName(filename string) string {
|
||||||
|
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||||
|
|
||||||
|
for _, suffix := range Suffixes {
|
||||||
|
if strings.HasSuffix(name, suffix) {
|
||||||
|
return strings.TrimSuffix(name, suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveURLs converts a cover art filesystem path into URL paths
|
// ResolveURLs converts a cover art filesystem path into URL paths
|
||||||
// for the original and all size variants (small, medium, large).
|
// for the original and all size variants (small, medium, large).
|
||||||
func ResolveURLs(filesystemPath string) URLs {
|
func ResolveURLs(filesystemPath string) URLs {
|
||||||
base := filepath.Base(filesystemPath)
|
base := filepath.Base(filesystemPath)
|
||||||
|
large := PathPrefix + SizedFilename(base, "_lg")
|
||||||
|
|
||||||
return URLs{
|
return URLs{
|
||||||
Original: PathPrefix + base,
|
Original: large,
|
||||||
Small: PathPrefix + SizedFilename(base, "_sm"),
|
Small: PathPrefix + SizedFilename(base, "_sm"),
|
||||||
Medium: PathPrefix + SizedFilename(base, "_md"),
|
Medium: PathPrefix + SizedFilename(base, "_md"),
|
||||||
Large: PathPrefix + SizedFilename(base, "_lg"),
|
Large: large,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
path string
|
path string
|
||||||
|
// Original is the largest kept variant: the full-resolution
|
||||||
|
// image is not stored (see URLs).
|
||||||
wantOrig string
|
wantOrig string
|
||||||
wantSm string
|
wantSm string
|
||||||
wantMd string
|
wantMd string
|
||||||
@@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "absolute path",
|
name: "absolute path",
|
||||||
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
||||||
wantOrig: "/covers/a1b2c3d4.jpg",
|
wantOrig: "/covers/a1b2c3d4_lg.jpg",
|
||||||
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
||||||
wantMd: "/covers/a1b2c3d4_md.jpg",
|
wantMd: "/covers/a1b2c3d4_md.jpg",
|
||||||
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
||||||
@@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "bare filename",
|
name: "bare filename",
|
||||||
path: "abcdef01.png",
|
path: "abcdef01.png",
|
||||||
wantOrig: "/covers/abcdef01.png",
|
wantOrig: "/covers/abcdef01_lg.jpg",
|
||||||
wantSm: "/covers/abcdef01_sm.jpg",
|
wantSm: "/covers/abcdef01_sm.jpg",
|
||||||
wantMd: "/covers/abcdef01_md.jpg",
|
wantMd: "/covers/abcdef01_md.jpg",
|
||||||
wantLg: "/covers/abcdef01_lg.jpg",
|
wantLg: "/covers/abcdef01_lg.jpg",
|
||||||
|
|||||||
+12
-181
@@ -5,13 +5,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
@@ -26,9 +23,6 @@ import (
|
|||||||
//go:embed sql/schemas/*.sql
|
//go:embed sql/schemas/*.sql
|
||||||
var schemas embed.FS
|
var schemas embed.FS
|
||||||
|
|
||||||
//go:embed sql/migrations/*.sql
|
|
||||||
var migrations embed.FS
|
|
||||||
|
|
||||||
// DB wraps the SQLite database connection and queries.
|
// DB wraps the SQLite database connection and queries.
|
||||||
//
|
//
|
||||||
// Two handles back a single database file. db is the single-writer
|
// Two handles back a single database file. db is the single-writer
|
||||||
@@ -301,20 +295,19 @@ func (d *DB) ResumeExploreIndexFTS() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applySchema creates the full schema on a fresh database and brings
|
// applySchema creates the full schema.
|
||||||
// an existing one up to date via sql/migrations.
|
|
||||||
//
|
//
|
||||||
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS,
|
// The schema files under sql/schemas are CREATE ... IF NOT EXISTS and
|
||||||
// so on a genuinely new database they create every table already at
|
// declare the current, latest shape of every table — so running them
|
||||||
// its current, latest shape — that's the fast path new installs
|
// against a fresh database produces exactly that shape, and running
|
||||||
// take. A database that already has an older shape (e.g. a
|
// them against a database already at that shape does nothing. That is
|
||||||
// tagging_items missing a column a later build added) needs the gap
|
// the whole mechanism; there is no migration chain and no
|
||||||
// closed, which IF NOT EXISTS can't do: it silently no-ops on a
|
// schema_migrations table.
|
||||||
// table that already exists, columns and all. sql/migrations holds
|
//
|
||||||
// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.)
|
// There was one, and it was squashed (see .planning/plans/013): a chain
|
||||||
// for exactly that gap, tracked in schema_migrations so each applies
|
// only earns its keep once real user databases exist in the wild, and
|
||||||
// at most once — see applyMigrations for how a fresh database's
|
// until then it is a second description of the schema that can drift
|
||||||
// already-current tables tolerate replaying them anyway.
|
// from the first — which this project has already been bitten by once.
|
||||||
func applySchema(ctx context.Context, db *sql.DB) error {
|
func applySchema(ctx context.Context, db *sql.DB) error {
|
||||||
dirEntries, err := schemas.ReadDir("sql/schemas")
|
dirEntries, err := schemas.ReadDir("sql/schemas")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -344,171 +337,9 @@ func applySchema(ctx context.Context, db *sql.DB) error {
|
|||||||
return fmt.Errorf("could not create explore FTS triggers: %w", err)
|
return fmt.Errorf("could not create explore FTS triggers: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := applyMigrations(ctx, db); err != nil {
|
|
||||||
return fmt.Errorf("could not apply migrations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The download subsystem's Want/Request rename reuses table names
|
|
||||||
// (download_requests names a different table before and after), so
|
|
||||||
// it cannot be a plain sql/migrations file the way an ADD COLUMN
|
|
||||||
// migration can; see download_rename_migration.go for why.
|
|
||||||
if err := migrateDownloadRename(ctx, db); err != nil {
|
|
||||||
return fmt.Errorf("could not migrate download rename: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaMigrationsTable tracks which sql/migrations files have run,
|
|
||||||
// by their leading numeric prefix.
|
|
||||||
const schemaMigrationsTable = `
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`
|
|
||||||
|
|
||||||
// applyMigrations runs every sql/migrations file not yet recorded in
|
|
||||||
// schema_migrations, in filename order (numeric prefix), one
|
|
||||||
// statement at a time.
|
|
||||||
//
|
|
||||||
// Every migration runs on EVERY database, fresh or old — there is no
|
|
||||||
// "skip on fresh install" branch. A fresh database's tables already
|
|
||||||
// carry a migration's effect (sql/schemas declares the target shape
|
|
||||||
// directly), so its statements are expected to sometimes be no-ops
|
|
||||||
// there: "duplicate column name" from an ALTER TABLE ADD COLUMN is
|
|
||||||
// tolerated and treated as "already applied", the same way
|
|
||||||
// createExploreIndexFTSTriggers tolerates "already exists". Any
|
|
||||||
// other error is fatal. This is deliberately simpler than detecting
|
|
||||||
// "is this database fresh" — every migration converges both a fresh
|
|
||||||
// and an upgraded database to the identical final schema (including
|
|
||||||
// column order — ALTER TABLE ADD COLUMN always appends at the end,
|
|
||||||
// so sql/schemas must declare a migrated column last too; see the
|
|
||||||
// comment on tagging_items.sql and the regression test in
|
|
||||||
// migrations_test.go).
|
|
||||||
func applyMigrations(ctx context.Context, db *sql.DB) error {
|
|
||||||
if _, err := db.ExecContext(ctx, schemaMigrationsTable); err != nil {
|
|
||||||
return fmt.Errorf("create schema_migrations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dirEntries, err := migrations.ReadDir("sql/migrations")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not read migrations directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(dirEntries, func(i, j int) bool {
|
|
||||||
return dirEntries[i].Name() < dirEntries[j].Name()
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, dirEntry := range dirEntries {
|
|
||||||
if dirEntry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := migrationVersion(dirEntry.Name())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
applied, err := migrationApplied(ctx, db, version)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if applied {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := path.Join("sql/migrations", dirEntry.Name())
|
|
||||||
|
|
||||||
sqlContent, err := fs.ReadFile(migrations, filePath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not read file %s: %w", filePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := execMigrationStatements(ctx, db, string(sqlContent)); err != nil {
|
|
||||||
return fmt.Errorf("error executing migration %s: %w", dirEntry.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, version,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("record migration %d applied: %w", version, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// execMigrationStatements runs a migration file one statement at a
|
|
||||||
// time — NOT as one multi-statement Exec — so that one statement
|
|
||||||
// being a tolerable no-op (ALTER TABLE ADD COLUMN on a fresh
|
|
||||||
// database) doesn't abort the statements after it in the same file
|
|
||||||
// (e.g. a trailing CREATE INDEX that a fresh database still needs,
|
|
||||||
// since sql/schemas deliberately doesn't declare an index on a
|
|
||||||
// migrated column — see the comment on tagging_items.sql).
|
|
||||||
//
|
|
||||||
// Splitting on ";" is safe for the simple ALTER/CREATE TABLE/CREATE
|
|
||||||
// INDEX statements migrations are expected to contain; it is NOT
|
|
||||||
// safe for statements embedding a literal semicolon (e.g. a CREATE
|
|
||||||
// TRIGGER body) — write those with executeContext calls in Go
|
|
||||||
// instead of a sql/migrations file, the same way the explore FTS
|
|
||||||
// triggers already are.
|
|
||||||
func execMigrationStatements(ctx context.Context, db *sql.DB, script string) error {
|
|
||||||
for stmt := range strings.SplitSeq(script, ";") {
|
|
||||||
stmt = strings.TrimSpace(stmt)
|
|
||||||
if stmt == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
|
||||||
if strings.Contains(err.Error(), "duplicate column name") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("statement %q: %w", stmt, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrationVersion extracts the leading integer prefix from a
|
|
||||||
// migration filename, e.g. "0001_tagging_items_synthetic.sql" -> 1.
|
|
||||||
func migrationVersion(filename string) (int, error) {
|
|
||||||
prefix, _, ok := strings.Cut(filename, "_")
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := strconv.Atoi(prefix)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
return version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var errMigrationFilename = errors.New(
|
|
||||||
"migration filename must start with a numeric prefix followed by '_' (e.g. 0001_description.sql)",
|
|
||||||
)
|
|
||||||
|
|
||||||
func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) {
|
|
||||||
var v int
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
ctx, `SELECT version FROM schema_migrations WHERE version = ?`, version,
|
|
||||||
).Scan(&v)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
|
||||||
return false, nil
|
|
||||||
case err != nil:
|
|
||||||
return false, fmt.Errorf("check migration %d: %w", version, err)
|
|
||||||
default:
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyPRAGMAs configures SQLite connection settings. Called by both
|
// applyPRAGMAs configures SQLite connection settings. Called by both
|
||||||
// NewDB and NewTestDB to ensure identical behavior.
|
// NewDB and NewTestDB to ensure identical behavior.
|
||||||
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
|
func applyPRAGMAs(ctx context.Context, db *sql.DB) error {
|
||||||
|
|||||||
@@ -312,31 +312,13 @@ func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
|
|||||||
|
|
||||||
// Create prerequisite data: artist_credit, recording,
|
// Create prerequisite data: artist_credit, recording,
|
||||||
// audio_file.
|
// audio_file.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
FilePath: "/test/music/song.mp3",
|
||||||
)
|
Title: "Test Song",
|
||||||
if err != nil {
|
Artist: "Test Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 180000,
|
||||||
}
|
LibraryID: libID,
|
||||||
|
})
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
|
||||||
"VALUES (1, 'Test Song', 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files "+
|
|
||||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
|
||||||
"recording_id, library_id) "+
|
|
||||||
"VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)",
|
|
||||||
libID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create playlist.
|
// Create playlist.
|
||||||
playlist, err := db.Queries.CreatePlaylist(
|
playlist, err := db.Queries.CreatePlaylist(
|
||||||
@@ -442,39 +424,18 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
|||||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
|
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
|
||||||
|
|
||||||
// Insert prerequisite recording.
|
// Insert prerequisite recording.
|
||||||
|
InsertTestTrack(t, db, TestTrack{
|
||||||
|
FilePath: "/test/track.mp3",
|
||||||
|
Title: "Track",
|
||||||
|
Artist: "Test",
|
||||||
|
LibraryID: libID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert audio file with invalid library_id - should fail FK.
|
||||||
_, err := db.ExecContext(
|
_, err := db.ExecContext(
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test')",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
|
||||||
"VALUES (1, 'Track', 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert audio file with valid library_id — should succeed.
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files "+
|
|
||||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
|
||||||
"recording_id, library_id) "+
|
|
||||||
"VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)",
|
|
||||||
libID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file with valid library: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert audio file with invalid library_id — should fail FK.
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files " +
|
"INSERT INTO audio_files " +
|
||||||
"(id, file_path, length_milliseconds, file_type_id, " +
|
"(id, file_path, length_milliseconds, file_type_id, library_id) " +
|
||||||
"recording_id, library_id) " +
|
"VALUES (2, '/test/song2.mp3', 200000, 0, 999)",
|
||||||
"VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)",
|
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error(
|
t.Error(
|
||||||
@@ -483,16 +444,16 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Count files by library.
|
// Count files by library.
|
||||||
count, err := db.Queries.CountAudioFilesByLibrary(
|
count, err := db.Queries.CountAudioFiles(
|
||||||
db.Ctx, libID,
|
db.Ctx, libID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CountAudioFilesByLibrary: %v", err)
|
t.Fatalf("CountAudioFiles: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"CountAudioFilesByLibrary = %d, want 1", count,
|
"CountAudioFiles = %d, want 1", count,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -503,31 +464,13 @@ func TestTrackMetadataViewHasLibraryID(t *testing.T) {
|
|||||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
|
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
|
||||||
|
|
||||||
// Insert prerequisites.
|
// Insert prerequisites.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')",
|
FilePath: "/test/view.mp3",
|
||||||
)
|
Title: "View Track",
|
||||||
if err != nil {
|
Artist: "View Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 200000,
|
||||||
}
|
LibraryID: libID,
|
||||||
|
})
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
|
||||||
"VALUES (1, 'View Track', 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files "+
|
|
||||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
|
||||||
"recording_id, library_id) "+
|
|
||||||
"VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)",
|
|
||||||
libID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query track_metadata VIEW and verify library_id is present
|
// Query track_metadata VIEW and verify library_id is present
|
||||||
// with the correct value.
|
// with the correct value.
|
||||||
@@ -842,29 +785,13 @@ func TestPlayHistoryTable(t *testing.T) {
|
|||||||
|
|
||||||
// Round-trip: insert a play_history row and verify play_count update.
|
// Round-trip: insert a play_history row and verify play_count update.
|
||||||
// First, set up test data. The test DB already has library id=0.
|
// First, set up test data. The test DB already has library id=0.
|
||||||
_, err = db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
FilePath: "/test/play_history.mp3",
|
||||||
)
|
Title: "Test Track",
|
||||||
if err != nil {
|
Artist: "Test Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
TrackNumber: 1,
|
||||||
}
|
DiscNumber: 1,
|
||||||
|
})
|
||||||
_, err = db.ExecContext(
|
|
||||||
`INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number)
|
|
||||||
VALUES (1, 'Test Track', 1, 1, 1)`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
`INSERT INTO audio_files
|
|
||||||
(id, file_path, length_milliseconds, file_type_id, recording_id, library_id)
|
|
||||||
VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify default play_count is 0.
|
// Verify default play_count is 0.
|
||||||
var playCount int64
|
var playCount int64
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// migrateDownloadRename performs the download subsystem's table rename
|
|
||||||
// for existing databases that still carry the old table names: the
|
|
||||||
// durable "I asked for this" record moved from download_wants to
|
|
||||||
// download_requests, and the one-shot search-and-grab attempt moved
|
|
||||||
// from download_requests to download_downloads (see CLAUDE.md and
|
|
||||||
// .planning/NOTES.md for the full Want->Request / Request->Download
|
|
||||||
// rename).
|
|
||||||
//
|
|
||||||
// This cannot be a plain sql/migrations file the way an ADD COLUMN
|
|
||||||
// migration is. That pattern's tolerance for "duplicate column name"
|
|
||||||
// works because a fresh database's sql/schemas pass already produces
|
|
||||||
// the identical target shape under the identical table name, so
|
|
||||||
// replaying the ALTER TABLE against it is a safe no-op. Here the name
|
|
||||||
// "download_requests" is reused for a different table before and after
|
|
||||||
// the rename, so a fresh database's schema pass creates a real, empty,
|
|
||||||
// correctly-shaped download_downloads AND a real, empty,
|
|
||||||
// correctly-shaped (new) download_requests before this ever runs.
|
|
||||||
// Blindly replaying "ALTER TABLE download_requests RENAME TO
|
|
||||||
// download_downloads" against that fresh database would rename the new,
|
|
||||||
// empty Request table into Download's place, destroying the fresh
|
|
||||||
// install rather than no-opping. Gating on whether the OLD
|
|
||||||
// download_wants table still exists — a name nothing creates or
|
|
||||||
// references once this has run — is what tells an old database and a
|
|
||||||
// fresh (or already migrated) one apart without executing anything
|
|
||||||
// destructive on the fresh path.
|
|
||||||
func migrateDownloadRename(ctx context.Context, db *sql.DB) error {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
ctx,
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
|
||||||
).Scan(&name)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, sql.ErrNoRows):
|
|
||||||
// Nothing to migrate: either a fresh install (sql/schemas
|
|
||||||
// already produced the target shape) or a database this has
|
|
||||||
// already run against.
|
|
||||||
case err != nil:
|
|
||||||
return fmt.Errorf("check for download_wants table: %w", err)
|
|
||||||
default:
|
|
||||||
if err := runDownloadRename(ctx, db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ensureDownloadIndexes(ctx, db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runDownloadRename performs the actual rename dance against a
|
|
||||||
// database confirmed to still have the old download_wants table.
|
|
||||||
func runDownloadRename(ctx context.Context, db *sql.DB) error {
|
|
||||||
stmts := []string{
|
|
||||||
// The schema pass already created an empty, correctly-shaped
|
|
||||||
// download_downloads placeholder under this name (it never
|
|
||||||
// existed under the old naming), which would otherwise collide
|
|
||||||
// with the rename below.
|
|
||||||
`DROP TABLE IF EXISTS download_downloads`,
|
|
||||||
|
|
||||||
// 1. Free the "download_requests" name: the old one-shot
|
|
||||||
// attempt table becomes download_downloads.
|
|
||||||
`ALTER TABLE download_requests RENAME TO download_downloads`,
|
|
||||||
`ALTER TABLE download_downloads RENAME COLUMN want_id TO request_id`,
|
|
||||||
|
|
||||||
// 2. Claim the now-free "download_requests" name for the
|
|
||||||
// durable-intent table.
|
|
||||||
`ALTER TABLE download_wants RENAME TO download_requests`,
|
|
||||||
|
|
||||||
// 3. The transfer table's FK now points at download_downloads.
|
|
||||||
`ALTER TABLE download_items RENAME COLUMN request_id TO download_id`,
|
|
||||||
|
|
||||||
// Named indexes survive a table/column rename attached to their
|
|
||||||
// old name, so drop them here; ensureDownloadIndexes recreates
|
|
||||||
// them under the names sql/schemas' comments describe.
|
|
||||||
`DROP INDEX IF EXISTS idx_download_requests_created`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_requests_state`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_wants_due`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_wants_entity`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_wants_parent`,
|
|
||||||
`DROP INDEX IF EXISTS idx_download_items_request`,
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin download rename migration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
for _, stmt := range stmts {
|
|
||||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("download rename migration %q: %w", stmt, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("commit download rename migration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureDownloadIndexes creates the indexes sql/schemas deliberately
|
|
||||||
// omits inline for the renamed table/columns (see
|
|
||||||
// migrateDownloadRename), under their final names. Safe to call
|
|
||||||
// unconditionally: IF NOT EXISTS makes it a no-op once created, and by
|
|
||||||
// the time this runs every column/table involved is guaranteed to be
|
|
||||||
// in its final shape on both a fresh and a migrated database.
|
|
||||||
func ensureDownloadIndexes(ctx context.Context, db *sql.DB) error {
|
|
||||||
stmts := []string{
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_created
|
|
||||||
ON download_downloads(created_at DESC)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_downloads_state
|
|
||||||
ON download_downloads(state)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_due
|
|
||||||
ON download_requests(next_try_at) WHERE state = 'wanted'`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_entity
|
|
||||||
ON download_requests(entity, state)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_requests_parent
|
|
||||||
ON download_requests(parent_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_download_items_download
|
|
||||||
ON download_items(download_id)`,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, stmt := range stmts {
|
|
||||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
|
||||||
return fmt.Errorf("ensure download index: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// oldDownloadRequestsDDL, oldDownloadWantsDDL and oldDownloadItemsDDL
|
|
||||||
// are frozen snapshots of the download subsystem's tables exactly as
|
|
||||||
// they read before the Want/Request rename (see
|
|
||||||
// download_rename_migration.go) — i.e. what a real user's existing
|
|
||||||
// database looks like today, before upgrading to a build that includes
|
|
||||||
// this migration.
|
|
||||||
const oldDownloadRequestsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS download_requests (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
library_id INTEGER NOT NULL,
|
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
want_id INTEGER REFERENCES download_wants(id) ON DELETE SET NULL,
|
|
||||||
release_mbid TEXT,
|
|
||||||
release_group_mbid TEXT,
|
|
||||||
recording_mbid TEXT,
|
|
||||||
artist TEXT NOT NULL DEFAULT '',
|
|
||||||
album TEXT NOT NULL DEFAULT '',
|
|
||||||
query TEXT NOT NULL DEFAULT '',
|
|
||||||
expected TEXT NOT NULL DEFAULT '[]',
|
|
||||||
state TEXT NOT NULL DEFAULT 'searching'
|
|
||||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
|
||||||
'verifying', 'tagging', 'importing',
|
|
||||||
'complete', 'cancelled', 'failed')),
|
|
||||||
error TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_requests_created
|
|
||||||
ON download_requests(created_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_requests_state
|
|
||||||
ON download_requests(state);
|
|
||||||
`
|
|
||||||
|
|
||||||
const oldDownloadWantsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS download_wants (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
mbid TEXT NOT NULL,
|
|
||||||
entity TEXT NOT NULL
|
|
||||||
CHECK(entity IN ('artist', 'release-group', 'release', 'recording')),
|
|
||||||
library_id INTEGER NOT NULL,
|
|
||||||
artist TEXT NOT NULL DEFAULT '',
|
|
||||||
title TEXT NOT NULL DEFAULT '',
|
|
||||||
scope TEXT NOT NULL DEFAULT 'future'
|
|
||||||
CHECK(scope IN ('future', 'all')),
|
|
||||||
secondary INTEGER NOT NULL DEFAULT 0,
|
|
||||||
state TEXT NOT NULL DEFAULT 'wanted'
|
|
||||||
CHECK(state IN ('wanted', 'satisfied', 'paused')),
|
|
||||||
parent_id INTEGER,
|
|
||||||
attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_error TEXT NOT NULL DEFAULT '',
|
|
||||||
last_tried_at DATETIME,
|
|
||||||
next_try_at DATETIME,
|
|
||||||
external_ids TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE(mbid, library_id),
|
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY(parent_id) REFERENCES download_wants(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_due
|
|
||||||
ON download_wants(next_try_at)
|
|
||||||
WHERE state = 'wanted';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_entity
|
|
||||||
ON download_wants(entity, state);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_wants_parent
|
|
||||||
ON download_wants(parent_id);
|
|
||||||
`
|
|
||||||
|
|
||||||
const oldDownloadItemsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS download_items (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
request_id TEXT NOT NULL,
|
|
||||||
provider_id INTEGER NOT NULL,
|
|
||||||
transport_id INTEGER,
|
|
||||||
external_id TEXT NOT NULL DEFAULT '',
|
|
||||||
candidate TEXT NOT NULL DEFAULT '{}',
|
|
||||||
state TEXT NOT NULL DEFAULT 'queued'
|
|
||||||
CHECK(state IN ('searching', 'found', 'queued', 'grabbing',
|
|
||||||
'verifying', 'tagging', 'importing',
|
|
||||||
'complete', 'cancelled', 'failed')),
|
|
||||||
staging_dir TEXT NOT NULL DEFAULT '',
|
|
||||||
bytes_done INTEGER NOT NULL DEFAULT 0,
|
|
||||||
bytes_total INTEGER NOT NULL DEFAULT 0,
|
|
||||||
imported_paths TEXT NOT NULL DEFAULT '[]',
|
|
||||||
error TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(request_id) REFERENCES download_requests(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_live
|
|
||||||
ON download_items(state)
|
|
||||||
WHERE state NOT IN ('complete', 'cancelled', 'failed');
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_request
|
|
||||||
ON download_items(request_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_download_items_state
|
|
||||||
ON download_items(state);
|
|
||||||
`
|
|
||||||
|
|
||||||
// seedOldDownloadSchema builds the pre-rename download tables and
|
|
||||||
// inserts one row of real data into each, standing in for a real
|
|
||||||
// user's database at the moment it upgrades.
|
|
||||||
func seedOldDownloadSchema(t *testing.T, db *sql.DB) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, ddl := range []string{
|
|
||||||
oldDownloadWantsDDL, oldDownloadRequestsDDL, oldDownloadItemsDDL,
|
|
||||||
} {
|
|
||||||
if _, err := db.ExecContext(t.Context(), ddl); err != nil {
|
|
||||||
t.Fatalf("create old download schema: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO libraries (id, name, path) VALUES (1, 'Test', '/music')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed library: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO download_wants
|
|
||||||
(id, mbid, entity, library_id, artist, title, state)
|
|
||||||
VALUES (1, 'artist-mbid', 'artist', 1, 'Radiohead', 'Radiohead', 'wanted')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed download_wants: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO download_requests
|
|
||||||
(id, library_id, source, want_id, release_group_mbid, artist, album, state)
|
|
||||||
VALUES ('dl-1', 1, 'wanted', 1, 'rg-mbid', 'Radiohead', 'OK Computer', 'complete')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed download_requests: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
t.Context(),
|
|
||||||
`INSERT INTO download_items
|
|
||||||
(id, request_id, provider_id, state)
|
|
||||||
VALUES ('item-1', 'dl-1', 1, 'complete')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed download_items: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDownloadRename_FreshInstallUntouched confirms applySchema on a
|
|
||||||
// brand-new database produces the target shape directly and that
|
|
||||||
// migrateDownloadRename's gate (checking for the old download_wants
|
|
||||||
// table) is a no-op there — the destructive path this test guards
|
|
||||||
// against is exactly the one described in download_rename_migration.go:
|
|
||||||
// blindly replaying the rename against a fresh database's already-
|
|
||||||
// correct, empty download_requests/download_downloads tables.
|
|
||||||
func TestDownloadRename_FreshInstallUntouched(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := openMemDB(t)
|
|
||||||
|
|
||||||
if err := applySchema(t.Context(), db); err != nil {
|
|
||||||
t.Fatalf("apply schema (fresh): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
||||||
table,
|
|
||||||
).Scan(&name)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("expected table %q to exist on a fresh install: %v", table, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stray string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
|
||||||
).Scan(&stray)
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
t.Errorf("old download_wants table should not exist on a fresh install, err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both auto-download guardrail indexes sql/schemas deliberately
|
|
||||||
// omits (see ensureDownloadIndexes) must still exist.
|
|
||||||
for _, idx := range []string{
|
|
||||||
"idx_download_requests_due",
|
|
||||||
"idx_download_requests_entity",
|
|
||||||
"idx_download_requests_parent",
|
|
||||||
"idx_download_items_download",
|
|
||||||
} {
|
|
||||||
var name string
|
|
||||||
|
|
||||||
err := db.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`,
|
|
||||||
idx,
|
|
||||||
).Scan(&name)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("expected index %q to exist on a fresh install: %v", idx, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDownloadRename_UpgradesExistingDatabase is the regression test
|
|
||||||
// for the rename itself: an old-shaped database (download_wants +
|
|
||||||
// old-style download_requests, both with real rows) must end up with
|
|
||||||
// the same table names, column names, and data a fresh install would
|
|
||||||
// have — nothing dropped, nothing silently emptied.
|
|
||||||
func TestDownloadRename_UpgradesExistingDatabase(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fresh := openMemDB(t)
|
|
||||||
if err := applySchema(t.Context(), fresh); err != nil {
|
|
||||||
t.Fatalf("apply schema (fresh): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
upgraded := openMemDB(t)
|
|
||||||
|
|
||||||
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read libraries schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
|
||||||
t.Fatalf("create libraries table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
seedOldDownloadSchema(t, upgraded)
|
|
||||||
|
|
||||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
|
||||||
t.Fatalf("apply schema (upgrade path): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column order must match a fresh install's, for the same reason
|
|
||||||
// TestMigrations_ColumnOrderMatchesFreshInstall checks tagging_items:
|
|
||||||
// sqlc's `SELECT *` binds positionally.
|
|
||||||
for _, table := range []string{"download_downloads", "download_requests", "download_items"} {
|
|
||||||
freshCols := tableColumns(t, fresh, table)
|
|
||||||
upgradedCols := tableColumns(t, upgraded, table)
|
|
||||||
|
|
||||||
if len(freshCols) != len(upgradedCols) {
|
|
||||||
t.Fatalf(
|
|
||||||
"%s: column count mismatch: fresh has %d (%v), upgraded has %d (%v)",
|
|
||||||
table, len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range freshCols {
|
|
||||||
if freshCols[i] != upgradedCols[i] {
|
|
||||||
t.Errorf(
|
|
||||||
"%s: column order mismatch at %d: fresh %q, upgraded %q\nfresh: %v\nupgraded: %v",
|
|
||||||
table,
|
|
||||||
i,
|
|
||||||
freshCols[i],
|
|
||||||
upgradedCols[i],
|
|
||||||
freshCols,
|
|
||||||
upgradedCols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The seeded rows survived the rename under their new names.
|
|
||||||
var (
|
|
||||||
requestMBID string
|
|
||||||
requestEntity string
|
|
||||||
)
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(), `SELECT mbid, entity FROM download_requests WHERE id = 1`,
|
|
||||||
).Scan(&requestMBID, &requestEntity)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeded request row missing after rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestMBID != "artist-mbid" || requestEntity != "artist" {
|
|
||||||
t.Errorf("request row corrupted: mbid=%q entity=%q", requestMBID, requestEntity)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
downloadRequestID sql.NullInt64
|
|
||||||
downloadAlbum string
|
|
||||||
)
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT request_id, album FROM download_downloads WHERE id = 'dl-1'`,
|
|
||||||
).Scan(&downloadRequestID, &downloadAlbum)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeded download row missing after rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !downloadRequestID.Valid || downloadRequestID.Int64 != 1 {
|
|
||||||
t.Errorf("download.request_id = %v, want 1 (renamed from want_id)", downloadRequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if downloadAlbum != "OK Computer" {
|
|
||||||
t.Errorf("download.album = %q, want OK Computer", downloadAlbum)
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemDownloadID string
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT download_id FROM download_items WHERE id = 'item-1'`,
|
|
||||||
).Scan(&itemDownloadID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("seeded item row missing after rename: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if itemDownloadID != "dl-1" {
|
|
||||||
t.Errorf("item.download_id = %q, want dl-1 (renamed from request_id)", itemDownloadID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The old table is gone, not just emptied.
|
|
||||||
var stray string
|
|
||||||
|
|
||||||
err = upgraded.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'download_wants'`,
|
|
||||||
).Scan(&stray)
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
t.Errorf("old download_wants table should be gone after migration, err=%v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Running the whole thing again (as a second app startup would) is
|
|
||||||
// a no-op: the gate sees no download_wants table and does nothing
|
|
||||||
// further, so this must not error or duplicate anything.
|
|
||||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
|
||||||
t.Fatalf("apply schema a second time: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
|
|
||||||
if err := upgraded.QueryRowContext(
|
|
||||||
t.Context(), `SELECT COUNT(*) FROM download_requests`,
|
|
||||||
).Scan(&count); err != nil {
|
|
||||||
t.Fatalf("count download_requests: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("download_requests has %d rows after a second migration pass, want 1", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedExploreRow inserts one explore_index row.
|
// seedExploreRow inserts one explore_index row.
|
||||||
|
//
|
||||||
|
// The catalog stores an MBID as 16 raw bytes and an entity type as a
|
||||||
|
// code (see backend/explore/mbid.go), and the column says so, so the
|
||||||
|
// label these tests use as an id is hashed into something the table
|
||||||
|
// will accept. What they actually assert on is the FTS text.
|
||||||
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
|
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
sum := sha256.Sum256([]byte(mbid))
|
||||||
|
|
||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
||||||
VALUES ('recording', ?, ?, ?, '')
|
VALUES (3 /* recording */, ?, ?, ?, x'')
|
||||||
`, mbid, title, artist); err != nil {
|
`, sum[:16], title, artist); err != nil {
|
||||||
t.Fatalf("seed %s: %v", mbid, err)
|
t.Fatalf("seed %s: %v", mbid, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +210,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
|||||||
|
|
||||||
// A popularity refresh: an FTS column is not named at all.
|
// A popularity refresh: an FTS column is not named at all.
|
||||||
if _, err := db.ExecContext(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("popularity update: %v", err)
|
t.Fatalf("popularity update: %v", err)
|
||||||
}
|
}
|
||||||
@@ -212,8 +221,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
|||||||
if _, err := db.ExecContext(`
|
if _, err := db.ExecContext(`
|
||||||
UPDATE explore_index
|
UPDATE explore_index
|
||||||
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
|
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
|
||||||
WHERE mbid = 'mbid-1'
|
WHERE title = ?
|
||||||
`); err != nil {
|
`, "Unchanged Title"); err != nil {
|
||||||
t.Fatalf("no-op text update: %v", err)
|
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")
|
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("rename: %v", err)
|
t.Fatalf("rename: %v", err)
|
||||||
}
|
}
|
||||||
@@ -252,7 +262,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
|
|||||||
|
|
||||||
// The same for the other two indexed columns.
|
// The same for the other two indexed columns.
|
||||||
if _, err := db.ExecContext(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("artist rename: %v", err)
|
t.Fatalf("artist rename: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-111
@@ -1,15 +1,26 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// toNullString treats an empty string as NULL.
|
||||||
|
func toNullString(v string) sql.NullString {
|
||||||
|
if v == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullString{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
// LyricsHit is a single result from a lyric-fragment search: the
|
// LyricsHit is a single result from a lyric-fragment search: the
|
||||||
// matched recording plus enough metadata to render and play it.
|
// matched file plus enough metadata to render and play it.
|
||||||
type LyricsHit struct {
|
type LyricsHit struct {
|
||||||
RecordingID int64
|
AudioFileID int64
|
||||||
FilePath string
|
FilePath string
|
||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
Title string
|
Title string
|
||||||
@@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the matched recording (lyrics_index.rowid == recordings.id)
|
// lyrics_index.rowid is the audio file's id, so the hit is already
|
||||||
// to a representative playable file via the lowest audio_files id,
|
// a playable file - it used to be a recording id, which then had to
|
||||||
// then to the track_metadata VIEW for display fields.
|
// be mapped back to "some file of that recording" by a grouped
|
||||||
|
// subquery.
|
||||||
//
|
//
|
||||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||||
rows, err := d.db.QueryContext(d.Ctx, `
|
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
tm.id,
|
||||||
tm.file_path,
|
tm.file_path,
|
||||||
tm.length_milliseconds,
|
tm.length_milliseconds,
|
||||||
tm.title,
|
tm.title,
|
||||||
tm.artist_name,
|
tm.artist_name,
|
||||||
tm.album
|
tm.album
|
||||||
FROM lyrics_index li
|
FROM lyrics_index li
|
||||||
JOIN recordings r ON r.id = li.rowid
|
JOIN track_metadata tm ON tm.id = li.rowid
|
||||||
JOIN (
|
|
||||||
SELECT recording_id, MIN(id) AS af_id
|
|
||||||
FROM audio_files
|
|
||||||
GROUP BY recording_id
|
|
||||||
) af ON af.recording_id = r.id
|
|
||||||
JOIN track_metadata tm ON tm.id = af.af_id
|
|
||||||
WHERE lyrics_index MATCH ?
|
WHERE lyrics_index MATCH ?
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var h LyricsHit
|
var h LyricsHit
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&h.RecordingID,
|
&h.AudioFileID,
|
||||||
&h.FilePath,
|
&h.FilePath,
|
||||||
&h.LengthMilliseconds,
|
&h.LengthMilliseconds,
|
||||||
&h.Title,
|
&h.Title,
|
||||||
@@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecordingLyrics returns the stored lyrics for a recording, or
|
// GetLyrics returns the stored lyrics for a file, or "" if none.
|
||||||
// an empty string if none are stored.
|
func (d *DB) GetLyrics(audioFileID int64) (string, error) {
|
||||||
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
|
|
||||||
var lyrics string
|
var lyrics string
|
||||||
|
|
||||||
err := d.db.QueryRowContext(d.Ctx,
|
err := d.reader().QueryRowContext(d.Ctx,
|
||||||
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
|
"SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||||
recordingID,
|
|
||||||
).Scan(&lyrics)
|
).Scan(&lyrics)
|
||||||
|
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not read recording lyrics: %w", err)
|
return "", fmt.Errorf("could not read lyrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
|
// SetLyrics writes lyrics for a file and keeps the FTS index in sync.
|
||||||
// lyrics_index in sync (delete + reinsert the single row). Used by
|
//
|
||||||
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
|
// `source` says where they came from, which is the question the old
|
||||||
// string clears both the column and the index entry.
|
// column could not answer: lyrics read from a USLT frame are rebuilt
|
||||||
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
|
// free by any rescan, and lyrics fetched from LRCLIB are network
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
// traffic nobody wants to repeat. Passing an empty string clears both
|
||||||
"UPDATE recordings SET lyrics = ? WHERE id = ?",
|
// the row and the index entry.
|
||||||
lyrics, recordingID,
|
func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error {
|
||||||
); err != nil {
|
if strings.TrimSpace(lyrics) == "" {
|
||||||
return fmt.Errorf("could not update recording lyrics: %w", err)
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
|
"DELETE FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("could not delete lyrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.upsertLyricsIndex(audioFileID, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.upsertLyricsIndex(recordingID, lyrics)
|
if _, err := d.db.ExecContext(d.Ctx, `
|
||||||
|
INSERT INTO lyrics (audio_file_id, text, source, recording_mbid)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(audio_file_id) DO UPDATE SET
|
||||||
|
text = excluded.text,
|
||||||
|
source = excluded.source,
|
||||||
|
recording_mbid = COALESCE(excluded.recording_mbid, lyrics.recording_mbid),
|
||||||
|
fetched_at = CURRENT_TIMESTAMP
|
||||||
|
`, audioFileID, lyrics, source, toNullString(recordingMBID)); err != nil {
|
||||||
|
return fmt.Errorf("could not write lyrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.upsertLyricsIndex(audioFileID, lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsertLyricsIndex refreshes a single recording's entry in the
|
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
||||||
// contentless lyrics_index. contentless_delete=1 makes the DELETE
|
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||||
// valid; an empty lyrics string leaves the row deleted.
|
// lyrics string leaves the row deleted.
|
||||||
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
|
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
||||||
}
|
}
|
||||||
@@ -141,7 +168,7 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
|||||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
|
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx,
|
||||||
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
||||||
recordingID, lyrics,
|
audioFileID, lyrics,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("could not insert lyrics_index row: %w", err)
|
return fmt.Errorf("could not insert lyrics_index row: %w", err)
|
||||||
}
|
}
|
||||||
@@ -149,22 +176,16 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
|
// RebuildLyricsIndex repopulates lyrics_index from the lyrics table.
|
||||||
// current recordings table. Cheap for a personal library and safe to
|
|
||||||
// run after every scan.
|
|
||||||
func (d *DB) RebuildLyricsIndex() error {
|
func (d *DB) RebuildLyricsIndex() error {
|
||||||
if _, err := d.db.ExecContext(d.Ctx,
|
if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil {
|
||||||
"DELETE FROM lyrics_index",
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("could not clear lyrics_index: %w", err)
|
return fmt.Errorf("could not clear lyrics_index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input.
|
// SAFETY: FTS5 virtual table INSERT. Values sourced from lyrics; no user input.
|
||||||
if _, err := d.db.ExecContext(d.Ctx, `
|
if _, err := d.db.ExecContext(d.Ctx, `
|
||||||
INSERT INTO lyrics_index(rowid, lyrics)
|
INSERT INTO lyrics_index(rowid, lyrics)
|
||||||
SELECT id, lyrics
|
SELECT audio_file_id, text FROM lyrics WHERE text != ''
|
||||||
FROM recordings
|
|
||||||
WHERE lyrics IS NOT NULL AND lyrics != ''
|
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
||||||
}
|
}
|
||||||
@@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordingsMissingLyrics returns recordings that have no stored
|
// LyricsCandidate identifies a file that needs its lyrics fetched and
|
||||||
// lyrics but do carry the artist/title/duration needed to look them
|
// carries the fields an external provider matches on.
|
||||||
// up from an external provider. Used by the LRCLIB backfill. The
|
type LyricsCandidate struct {
|
||||||
// limit bounds each batch so the backfill can be run incrementally.
|
AudioFileID int64
|
||||||
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
RecordingMBID string
|
||||||
|
LengthMilliseconds int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilesMissingLyrics returns files with no stored lyrics that carry
|
||||||
|
// the artist/title/duration needed to look them up. Used by the
|
||||||
|
// LRCLIB backfill; the limit bounds each batch.
|
||||||
|
func (d *DB) FilesMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 200
|
limit = 200
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := d.db.QueryContext(d.Ctx, `
|
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||||
SELECT
|
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||||
r.id,
|
tm.recording_mbid, tm.length_milliseconds
|
||||||
COALESCE(r.name, ''),
|
FROM track_metadata tm
|
||||||
COALESCE(ac.text, ''),
|
WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id)
|
||||||
COALESCE(rg.name, ''),
|
AND tm.title != '' AND tm.artist_name != ''
|
||||||
MIN(af.length_milliseconds)
|
|
||||||
FROM recordings r
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON rgr.recording_id = r.id
|
|
||||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
|
||||||
WHERE (r.lyrics IS NULL OR r.lyrics = '')
|
|
||||||
AND r.name IS NOT NULL AND r.name != ''
|
|
||||||
AND ac.text IS NOT NULL AND ac.text != ''
|
|
||||||
GROUP BY r.id
|
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, limit)
|
`, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err)
|
return nil, fmt.Errorf("could not query files missing lyrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
@@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c LyricsCandidate
|
var c LyricsCandidate
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds,
|
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||||
|
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
|
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
|
||||||
}
|
}
|
||||||
@@ -229,44 +247,23 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LyricsCandidate identifies a recording that needs its lyrics fetched
|
// FileLyricLookup returns the provider-match fields for one file, so
|
||||||
// and carries the fields an external provider matches on.
|
// lyrics can be fetched on demand. Returns nil if the file has no
|
||||||
type LyricsCandidate struct {
|
// artist/title to match on.
|
||||||
RecordingID int64
|
func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) {
|
||||||
Title string
|
|
||||||
Artist string
|
|
||||||
Album string
|
|
||||||
LengthMilliseconds int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordingLyricLookup returns the provider-match fields (artist,
|
|
||||||
// title, album, duration) for a single recording, so lyrics can be
|
|
||||||
// fetched on demand. Returns nil if the recording has no audio file
|
|
||||||
// or no artist/title to match on.
|
|
||||||
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
|
|
||||||
var c LyricsCandidate
|
var c LyricsCandidate
|
||||||
|
|
||||||
err := d.db.QueryRowContext(d.Ctx, `
|
err := d.reader().QueryRowContext(d.Ctx, `
|
||||||
SELECT
|
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||||
r.id,
|
tm.recording_mbid, tm.length_milliseconds
|
||||||
COALESCE(r.name, ''),
|
FROM track_metadata tm
|
||||||
COALESCE(ac.text, ''),
|
WHERE tm.id = ?
|
||||||
COALESCE(rg.name, ''),
|
`, audioFileID).Scan(
|
||||||
COALESCE(MIN(af.length_milliseconds), 0)
|
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||||
FROM recordings r
|
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
)
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON rgr.recording_id = r.id
|
|
||||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
|
||||||
WHERE r.id = ?
|
|
||||||
GROUP BY r.id
|
|
||||||
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err)
|
return nil, fmt.Errorf("could not look up file for lyrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Title == "" || c.Artist == "" {
|
if c.Title == "" || c.Artist == "" {
|
||||||
|
|||||||
@@ -4,59 +4,33 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
|
// seedLyricsTrack inserts one file with the given lyrics, so lyric
|
||||||
// recording → audio_file → release_group link) for one track with the
|
// searches have something realistic to join against. It used to
|
||||||
// given lyrics, so lyric-search tests have realistic joins.
|
// insert a four-row FK chain by hand.
|
||||||
func seedLyricsTrack(
|
func seedLyricsTrack(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *DB,
|
db *DB,
|
||||||
id int64,
|
id int64,
|
||||||
title, artist, album, lyrics string,
|
title, artist, album, lyrics string,
|
||||||
lenMs int64,
|
lenMs int64,
|
||||||
) {
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
fileID := InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
|
FilePath: "/music/track" + itoa(id) + ".mp3",
|
||||||
); err != nil {
|
Title: title,
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
Artist: artist,
|
||||||
|
Album: album,
|
||||||
|
LengthMs: lenMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
if lyrics != "" {
|
||||||
|
if err := db.SetLyrics(fileID, lyrics, "tag", ""); err != nil {
|
||||||
|
t.Fatalf("seed lyrics: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
return fileID
|
||||||
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("insert release_group: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
|
|
||||||
id, title, id, nullableLyrics(lyrics),
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
|
|
||||||
"VALUES (?, ?, ?, ?, ?)",
|
|
||||||
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
|
|
||||||
id, id,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("insert release_group_recordings: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nullableLyrics(l string) any {
|
|
||||||
if l == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return l
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(v int64) string {
|
func itoa(v int64) string {
|
||||||
@@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := hits[0]
|
h := hits[0]
|
||||||
if h.RecordingID != 1 {
|
if h.AudioFileID != 1 {
|
||||||
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
|
t.Errorf("RecordingID = %d, want 1", h.AudioFileID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.Title != "The Sound of Silence" {
|
if h.Title != "The Sound of Silence" {
|
||||||
@@ -191,11 +165,11 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
|||||||
|
|
||||||
// Backfill lyrics — should update both the column and the FTS index.
|
// Backfill lyrics — should update both the column and the FTS index.
|
||||||
const lyrics = "Yesterday all my troubles seemed so far away"
|
const lyrics = "Yesterday all my troubles seemed so far away"
|
||||||
if err := db.SetRecordingLyrics(1, lyrics); err != nil {
|
if err := db.SetLyrics(1, lyrics, "lrclib", ""); err != nil {
|
||||||
t.Fatalf("SetRecordingLyrics: %v", err)
|
t.Fatalf("SetRecordingLyrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := db.GetRecordingLyrics(1)
|
stored, err := db.GetLyrics(1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetRecordingLyrics: %v", err)
|
t.Fatalf("GetRecordingLyrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
|||||||
t.Fatalf("SearchLyrics: %v", err)
|
t.Fatalf("SearchLyrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(hits) != 1 || hits[0].RecordingID != 1 {
|
if len(hits) != 1 || hits[0].AudioFileID != 1 {
|
||||||
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
|
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,7 +196,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
|||||||
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
|
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
|
||||||
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
||||||
|
|
||||||
missing, err := db.RecordingsMissingLyrics(50)
|
missing, err := db.FilesMissingLyrics(50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := missing[0]
|
c := missing[0]
|
||||||
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
if c.AudioFileID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
||||||
t.Errorf("unexpected candidate: %+v", c)
|
t.Errorf("unexpected candidate: %+v", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single-recording lookup mirrors the batch fields.
|
// Single-recording lookup mirrors the batch fields.
|
||||||
one, err := db.RecordingLyricLookup(2)
|
one, err := db.FileLyricLookup(2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordingLyricLookup: %v", err)
|
t.Fatalf("RecordingLyricLookup: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// oldTaggingItemsDDL is a frozen snapshot of tagging_items exactly as
|
|
||||||
// it read before sql/migrations/0001_tagging_items_synthetic.sql —
|
|
||||||
// i.e. what a real user's existing database looks like today, before
|
|
||||||
// upgrading to a build that includes that migration.
|
|
||||||
const oldTaggingItemsDDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS tagging_items (
|
|
||||||
group_key TEXT PRIMARY KEY,
|
|
||||||
library_id INTEGER NOT NULL,
|
|
||||||
track_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
album_name TEXT NOT NULL DEFAULT '',
|
|
||||||
album_artist TEXT NOT NULL DEFAULT '',
|
|
||||||
disc_number INTEGER NOT NULL DEFAULT 0,
|
|
||||||
best_match_release_mbid TEXT,
|
|
||||||
score REAL,
|
|
||||||
last_checked_at DATETIME,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending'
|
|
||||||
CHECK(status IN ('pending', 'matched', 'confirmed', 'skipped')),
|
|
||||||
cleared_at DATETIME,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_library_status
|
|
||||||
ON tagging_items(library_id, status);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_status_pending
|
|
||||||
ON tagging_items(library_id) WHERE status = 'pending';
|
|
||||||
`
|
|
||||||
|
|
||||||
// tableColumns returns the column names of a table in on-disk
|
|
||||||
// (positional) order, via PRAGMA table_info — the order sqlc's
|
|
||||||
// generated `SELECT *` scans bind to positionally.
|
|
||||||
func tableColumns(t *testing.T, db *sql.DB, table string) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
rows, err := db.QueryContext(t.Context(), "PRAGMA table_info("+table+")")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PRAGMA table_info(%s): %v", table, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var cols []string
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
cid int
|
|
||||||
name string
|
|
||||||
ctype string
|
|
||||||
notnull int
|
|
||||||
dfltValue sql.NullString
|
|
||||||
primaryKey int
|
|
||||||
)
|
|
||||||
|
|
||||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &primaryKey); err != nil {
|
|
||||||
t.Fatalf("scan table_info row: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cols = append(cols, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
t.Fatalf("iterate table_info: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols
|
|
||||||
}
|
|
||||||
|
|
||||||
func openMemDB(t *testing.T) *sql.DB {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", ":memory:?_busy_timeout=5000&_journal_mode=WAL")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open in-memory db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db.SetMaxOpenConns(1)
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
|
|
||||||
if err := applyPRAGMAs(t.Context(), db); err != nil {
|
|
||||||
t.Fatalf("apply pragmas: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMigrations_ColumnOrderMatchesFreshInstall is the regression
|
|
||||||
// test for the exact failure mode that got the old 48-step migration
|
|
||||||
// chain torn out (see .planning/NOTES.md, "No migration chain"):
|
|
||||||
// sql/schemas drifting from what migrations actually produce, so
|
|
||||||
// sqlc-generated code silently reads the wrong thing.
|
|
||||||
//
|
|
||||||
// A fresh install takes tagging_items straight from sql/schemas
|
|
||||||
// (CREATE TABLE, columns in file order). An existing database takes
|
|
||||||
// it from sql/schemas (the base shape, unchanged since the table
|
|
||||||
// already existed) plus sql/migrations/0001 (`ALTER TABLE ADD
|
|
||||||
// COLUMN`, which SQLite always appends at the END of the column
|
|
||||||
// list, regardless of where the column sits in the CREATE TABLE
|
|
||||||
// statement). If sql/schemas ever declares a migrated column
|
|
||||||
// somewhere other than last, the two paths produce tables with the
|
|
||||||
// SAME columns in a DIFFERENT order — invisible until a `SELECT *`
|
|
||||||
// (e.g. GetTaggingItem) silently binds a value to the wrong field.
|
|
||||||
func TestMigrations_ColumnOrderMatchesFreshInstall(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fresh := openMemDB(t)
|
|
||||||
if err := applySchema(t.Context(), fresh); err != nil {
|
|
||||||
t.Fatalf("apply schema (fresh): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
upgraded := openMemDB(t)
|
|
||||||
|
|
||||||
librariesDDL, err := schemas.ReadFile("sql/schemas/libraries.sql")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read libraries schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := upgraded.ExecContext(t.Context(), string(librariesDDL)); err != nil {
|
|
||||||
t.Fatalf("create libraries table: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := upgraded.ExecContext(t.Context(), oldTaggingItemsDDL); err != nil {
|
|
||||||
t.Fatalf("create pre-migration tagging_items: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sql/schemas no-ops on the pre-existing tagging_items (IF NOT
|
|
||||||
// EXISTS), then sql/migrations/0001's ALTER TABLE statements
|
|
||||||
// actually add the missing columns for real this time.
|
|
||||||
if err := applySchema(t.Context(), upgraded); err != nil {
|
|
||||||
t.Fatalf("apply schema (upgrade path): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
freshCols := tableColumns(t, fresh, "tagging_items")
|
|
||||||
upgradedCols := tableColumns(t, upgraded, "tagging_items")
|
|
||||||
|
|
||||||
if len(freshCols) != len(upgradedCols) {
|
|
||||||
t.Fatalf(
|
|
||||||
"column count mismatch: fresh install has %d (%v), upgraded has %d (%v)",
|
|
||||||
len(freshCols), freshCols, len(upgradedCols), upgradedCols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range freshCols {
|
|
||||||
if freshCols[i] != upgradedCols[i] {
|
|
||||||
t.Errorf(
|
|
||||||
"column order mismatch at position %d: fresh install has %q, upgraded has %q\nfresh: %v\nupgraded: %v",
|
|
||||||
i,
|
|
||||||
freshCols[i],
|
|
||||||
upgradedCols[i],
|
|
||||||
freshCols,
|
|
||||||
upgradedCols,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMigrations_FreshDatabaseStillRecordsAndGetsIndex confirms a
|
|
||||||
// brand-new database runs migration 0001 (tolerating "duplicate
|
|
||||||
// column name" from its ALTER TABLE statements, since sql/schemas
|
|
||||||
// already declared those columns), records it applied, AND still
|
|
||||||
// gets the trailing CREATE INDEX statement sql/schemas deliberately
|
|
||||||
// omits for migrated columns.
|
|
||||||
func TestMigrations_FreshDatabaseStillRecordsAndGetsIndex(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fresh := openMemDB(t)
|
|
||||||
if err := applySchema(t.Context(), fresh); err != nil {
|
|
||||||
t.Fatalf("apply schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var version int
|
|
||||||
|
|
||||||
err := fresh.QueryRowContext(
|
|
||||||
t.Context(), "SELECT version FROM schema_migrations WHERE version = 1",
|
|
||||||
).Scan(&version)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected migration 1 to be recorded as applied on a fresh db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var indexName string
|
|
||||||
|
|
||||||
err = fresh.QueryRowContext(
|
|
||||||
t.Context(),
|
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_tagging_items_parent_group_key'",
|
|
||||||
).Scan(&indexName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected idx_tagging_items_parent_group_key to exist on a fresh db: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOneRowPerTrackForAMultiArtistCredit pins what is left of the
|
||||||
|
// multi-artist problem, which is now much smaller than it was.
|
||||||
|
//
|
||||||
|
// It used to be possible for one file to produce several rows: an
|
||||||
|
// artist credit was a row in its own table linking *many* artists, so
|
||||||
|
// any query that joined artist_credit_artist to read the artist MBID
|
||||||
|
// returned the same track once per credited artist. The playlist, the
|
||||||
|
// queue, the library list and the phantom resolver all did, and all
|
||||||
|
// showed collaborations twice. Nine queries carried a
|
||||||
|
// first-credited-artist subquery to work around it.
|
||||||
|
//
|
||||||
|
// The join is gone: a file carries its credit as text and points at one
|
||||||
|
// primary artist, so the fan-out has nothing to fan out from. What is
|
||||||
|
// still worth pinning is that the credit text survives intact - a
|
||||||
|
// collaboration must still *read* as one - and that the file resolves
|
||||||
|
// to exactly one row wherever it is asked for.
|
||||||
|
func TestOneRowPerTrackForAMultiArtistCredit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := NewTestDB(t)
|
||||||
|
|
||||||
|
id := InsertTestTrack(t, db, TestTrack{
|
||||||
|
FilePath: "/lib/collab.mp3",
|
||||||
|
Title: "Collab Song",
|
||||||
|
Artist: "A feat. B",
|
||||||
|
ArtistMBID: "mbid-a",
|
||||||
|
Album: "An Album",
|
||||||
|
LengthMs: 200000,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("one row in the view", func(t *testing.T) {
|
||||||
|
var n int
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
`SELECT COUNT(*) FROM track_metadata WHERE id = ?`, id,
|
||||||
|
).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("track_metadata rows = %d, want 1", n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the credit is preserved and the artist resolved", func(t *testing.T) {
|
||||||
|
rows, err := db.Queries.GetTracks(db.Ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get tracks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("tracks = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows[0].ArtistName != "A feat. B" {
|
||||||
|
t.Errorf("artist credit = %q, want %q", rows[0].ArtistName, "A feat. B")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows[0].ArtistMbid != "mbid-a" {
|
||||||
|
t.Errorf("artist mbid = %q, want %q", rows[0].ArtistMbid, "mbid-a")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("one row per album track", func(t *testing.T) {
|
||||||
|
var albumID int64
|
||||||
|
if err := db.QueryRowWriter(
|
||||||
|
`SELECT album_id FROM audio_files WHERE id = ?`, id,
|
||||||
|
).Scan(&albumID); err != nil {
|
||||||
|
t.Fatalf("album id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Queries.GetTracks(db.Ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("album tracks: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Errorf("album tracks = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+55
-176
@@ -5,6 +5,8 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SearchRow holds a single result from an FTS5 or basename search.
|
// SearchRow holds a single result from an FTS5 or basename search.
|
||||||
@@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchTrackRow holds a full track result from an FTS5 search,
|
// trackMetadataColumns is the column list of the track_metadata view,
|
||||||
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
|
// in the order sqlc generates TrackMetadatum's fields. The FTS
|
||||||
type SearchTrackRow struct {
|
// searches below cannot be sqlc queries (MATCH is not in its grammar),
|
||||||
FilePath string
|
// so this is the one place the view's shape is written out by hand.
|
||||||
LengthMilliseconds int64
|
const trackMetadataColumns = `
|
||||||
Title string
|
tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name,
|
||||||
ArtistName string
|
tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year,
|
||||||
TrackNumber sql.NullInt64
|
tm.release_year, tm.composer, tm.file_type, tm.sample_rate,
|
||||||
DiscNumber sql.NullInt64
|
tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id,
|
||||||
Album string
|
tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid,
|
||||||
Genre string
|
tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id`
|
||||||
Year int64
|
|
||||||
Composer string
|
// scanTrackMetadata reads track_metadata rows into the generated row
|
||||||
FileType string
|
// type, so an FTS hit and an ordinary query produce the same Track.
|
||||||
SampleRate int64
|
func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) {
|
||||||
BitDepth int64
|
var out []sqlcgen.TrackMetadatum
|
||||||
Channels int64
|
|
||||||
Bitrate int64
|
for rows.Next() {
|
||||||
FileSize int64
|
var r sqlcgen.TrackMetadatum
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&r.ID, &r.FilePath, &r.LengthMilliseconds, &r.Title, &r.ArtistName,
|
||||||
|
&r.TrackNumber, &r.DiscNumber, &r.Album, &r.Genre, &r.Year,
|
||||||
|
&r.ReleaseYear, &r.Composer, &r.FileType, &r.SampleRate,
|
||||||
|
&r.BitDepth, &r.Channels, &r.Bitrate, &r.FileSize, &r.LibraryID,
|
||||||
|
&r.PlayCount, &r.LastPlayed, &r.CoverArtPath, &r.ArtistMbid,
|
||||||
|
&r.ReleaseGroupMbid, &r.RecordingMbid, &r.AlbumID, &r.ArtistID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan track metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate track metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFTSTracks performs a full-text search and returns full track
|
// SearchFTSTracks performs a full-text search and returns whole tracks.
|
||||||
// metadata for each match. Unlike SearchFTS (which returns only 5
|
//
|
||||||
// columns), this includes all 16 fields needed for library.Track.
|
// A library id of 0 means every library. There were two of these, one
|
||||||
|
// per case, each with its own copy of a sixteen-column projection that
|
||||||
|
// silently dropped the MBIDs and the play count - which is why the
|
||||||
|
// caller used to pass zeros for them.
|
||||||
func (d *DB) SearchFTSTracks(
|
func (d *DB) SearchFTSTracks(
|
||||||
query string, limit int,
|
query string, libraryID int64, limit int,
|
||||||
) ([]SearchTrackRow, error) {
|
) ([]sqlcgen.TrackMetadatum, error) {
|
||||||
query = strings.TrimSpace(query)
|
query = strings.TrimSpace(query)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ftsQuery := buildFTSQuery(query)
|
|
||||||
|
|
||||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||||
rows, err := d.db.QueryContext(d.Ctx, `
|
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||||
SELECT
|
SELECT`+trackMetadataColumns+`
|
||||||
tm.file_path,
|
|
||||||
tm.length_milliseconds,
|
|
||||||
tm.title,
|
|
||||||
tm.artist_name,
|
|
||||||
tm.track_number,
|
|
||||||
tm.disc_number,
|
|
||||||
tm.album,
|
|
||||||
tm.genre,
|
|
||||||
tm.year,
|
|
||||||
tm.composer,
|
|
||||||
tm.file_type,
|
|
||||||
tm.sample_rate,
|
|
||||||
tm.bit_depth,
|
|
||||||
tm.channels,
|
|
||||||
tm.bitrate,
|
|
||||||
tm.file_size
|
|
||||||
FROM search_index si
|
FROM search_index si
|
||||||
JOIN track_metadata tm ON tm.id = si.rowid
|
JOIN track_metadata tm ON tm.id = si.rowid
|
||||||
WHERE search_index MATCH ?
|
WHERE search_index MATCH ?
|
||||||
|
AND (? = 0 OR tm.library_id = ?)
|
||||||
ORDER BY rank
|
ORDER BY rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, ftsQuery, limit)
|
`, buildFTSQuery(query), libraryID, libraryID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf("FTS track search failed: %w", err)
|
||||||
"FTS track search failed: %w", err,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var results []SearchTrackRow
|
return scanTrackMetadata(rows)
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var r SearchTrackRow
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&r.FilePath,
|
|
||||||
&r.LengthMilliseconds,
|
|
||||||
&r.Title,
|
|
||||||
&r.ArtistName,
|
|
||||||
&r.TrackNumber,
|
|
||||||
&r.DiscNumber,
|
|
||||||
&r.Album,
|
|
||||||
&r.Genre,
|
|
||||||
&r.Year,
|
|
||||||
&r.Composer,
|
|
||||||
&r.FileType,
|
|
||||||
&r.SampleRate,
|
|
||||||
&r.BitDepth,
|
|
||||||
&r.Channels,
|
|
||||||
&r.Bitrate,
|
|
||||||
&r.FileSize,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"could not scan search track row: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"search track row iteration error: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFTSTracksByLibrary performs a full-text search scoped to a
|
|
||||||
// specific library and returns full track metadata for each match.
|
|
||||||
func (d *DB) SearchFTSTracksByLibrary(
|
|
||||||
query string, limit int, libraryID int64,
|
|
||||||
) ([]SearchTrackRow, error) {
|
|
||||||
query = strings.TrimSpace(query)
|
|
||||||
if query == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ftsQuery := buildFTSQuery(query)
|
|
||||||
|
|
||||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
|
||||||
rows, err := d.db.QueryContext(d.Ctx, `
|
|
||||||
SELECT
|
|
||||||
tm.file_path,
|
|
||||||
tm.length_milliseconds,
|
|
||||||
tm.title,
|
|
||||||
tm.artist_name,
|
|
||||||
tm.track_number,
|
|
||||||
tm.disc_number,
|
|
||||||
tm.album,
|
|
||||||
tm.genre,
|
|
||||||
tm.year,
|
|
||||||
tm.composer,
|
|
||||||
tm.file_type,
|
|
||||||
tm.sample_rate,
|
|
||||||
tm.bit_depth,
|
|
||||||
tm.channels,
|
|
||||||
tm.bitrate,
|
|
||||||
tm.file_size
|
|
||||||
FROM search_index si
|
|
||||||
JOIN track_metadata tm ON tm.id = si.rowid
|
|
||||||
WHERE search_index MATCH ? AND tm.library_id = ?
|
|
||||||
ORDER BY rank
|
|
||||||
LIMIT ?
|
|
||||||
`, ftsQuery, libraryID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"FTS library track search failed: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var results []SearchTrackRow
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var r SearchTrackRow
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&r.FilePath,
|
|
||||||
&r.LengthMilliseconds,
|
|
||||||
&r.Title,
|
|
||||||
&r.ArtistName,
|
|
||||||
&r.TrackNumber,
|
|
||||||
&r.DiscNumber,
|
|
||||||
&r.Album,
|
|
||||||
&r.Genre,
|
|
||||||
&r.Year,
|
|
||||||
&r.Composer,
|
|
||||||
&r.FileType,
|
|
||||||
&r.SampleRate,
|
|
||||||
&r.BitDepth,
|
|
||||||
&r.Channels,
|
|
||||||
&r.Bitrate,
|
|
||||||
&r.FileSize,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"could not scan library search track row: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"library search track row iteration error: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanSearchRows reads all rows from a query result into a slice.
|
|
||||||
func scanSearchRows(
|
func scanSearchRows(
|
||||||
rows interface {
|
rows interface {
|
||||||
Next() bool
|
Next() bool
|
||||||
|
|||||||
+76
-232
@@ -3,6 +3,8 @@ package database
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedSearchData inserts ~7 tracks with the full FK chain required for
|
// seedSearchData inserts ~7 tracks with the full FK chain required for
|
||||||
@@ -84,128 +86,44 @@ func seedSearchData(t *testing.T, db *DB) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build unique sets.
|
|
||||||
artistMap := map[string]int64{}
|
|
||||||
albumMap := map[string]int64{}
|
|
||||||
|
|
||||||
var artistID, albumID int64
|
|
||||||
|
|
||||||
for _, tr := range tracks {
|
for _, tr := range tracks {
|
||||||
if _, ok := artistMap[tr.artist]; !ok {
|
var genres []string
|
||||||
artistID++
|
|
||||||
artistMap[tr.artist] = artistID
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := albumMap[tr.album]; !ok {
|
|
||||||
albumID++
|
|
||||||
albumMap[tr.album] = albumID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert artist_credit rows.
|
|
||||||
for text, id := range artistMap {
|
|
||||||
_, err := db.ExecContext(
|
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (?, ?)",
|
|
||||||
id, text,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert artist_credit %q: %v", text, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert release_groups.
|
|
||||||
for name, id := range albumMap {
|
|
||||||
_, err := db.ExecContext(
|
|
||||||
"INSERT INTO release_groups (id, name) VALUES (?, ?)",
|
|
||||||
id, name,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert release_group %q: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert genres + recording_genres.
|
|
||||||
genreMap := map[string]int64{}
|
|
||||||
|
|
||||||
var genreID int64
|
|
||||||
|
|
||||||
for _, tr := range tracks {
|
|
||||||
if tr.genre == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := genreMap[tr.genre]; !ok {
|
|
||||||
genreID++
|
|
||||||
genreMap[tr.genre] = genreID
|
|
||||||
|
|
||||||
_, err := db.ExecContext(
|
|
||||||
"INSERT INTO genres (id, name) VALUES (?, ?)",
|
|
||||||
genreID, tr.genre,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert genre %q: %v", tr.genre, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tr := range tracks {
|
|
||||||
acID := artistMap[tr.artist]
|
|
||||||
rgID := albumMap[tr.album]
|
|
||||||
|
|
||||||
// Insert recording.
|
|
||||||
_, err := db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id, "+
|
|
||||||
"track_number, disc_number, year, genre, composer) "+
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
tr.id, tr.title, acID, tr.trackNum, tr.discNum,
|
|
||||||
tr.year, tr.genre, tr.composer,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert audio_files.
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files (id, file_path, "+
|
|
||||||
"length_milliseconds, file_type_id, recording_id, "+
|
|
||||||
"sample_rate, bit_depth, channels, bitrate, file_size) "+
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id,
|
|
||||||
tr.sr, tr.bd, tr.ch, tr.br, tr.fsize,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file %d: %v", tr.id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link recording to release_group.
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO release_group_recordings "+
|
|
||||||
"(release_group_id, recording_id, track_number, disc_number) "+
|
|
||||||
"VALUES (?, ?, ?, ?)",
|
|
||||||
rgID, tr.id, tr.trackNum, tr.discNum,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert search_index entry (rowid must match audio_files.id).
|
|
||||||
if err := db.InsertSearchIndex(
|
|
||||||
tr.id, tr.filePath, tr.title, tr.artist, tr.album,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("insert search_index for %d: %v", tr.id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert recording_genres link.
|
|
||||||
if tr.genre != "" {
|
if tr.genre != "" {
|
||||||
gID := genreMap[tr.genre]
|
genres = []string{tr.genre}
|
||||||
|
}
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
var trackNum, discNum int64
|
||||||
"INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
|
if tr.trackNum != nil {
|
||||||
tr.id, gID,
|
trackNum = *tr.trackNum
|
||||||
)
|
}
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err)
|
if tr.discNum != nil {
|
||||||
}
|
discNum = *tr.discNum
|
||||||
|
}
|
||||||
|
|
||||||
|
id := InsertTestTrack(t, db, TestTrack{
|
||||||
|
FilePath: tr.filePath,
|
||||||
|
Title: tr.title,
|
||||||
|
Artist: tr.artist,
|
||||||
|
Album: tr.album,
|
||||||
|
Genres: genres,
|
||||||
|
TrackNumber: trackNum,
|
||||||
|
DiscNumber: discNum,
|
||||||
|
Year: tr.year,
|
||||||
|
LengthMs: tr.lenMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
// The fixtures assert on audio properties and the composer,
|
||||||
|
// which InsertTestTrack does not carry - they are not part of
|
||||||
|
// what a seeder should have to know about a track.
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`UPDATE audio_files
|
||||||
|
SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
|
||||||
|
channels = ?, bitrate = ?, file_size = ?, composer = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -553,7 +471,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
|||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
seedSearchData(t, db)
|
seedSearchData(t, db)
|
||||||
|
|
||||||
results, err := db.SearchFTSTracks("queen", 10)
|
results, err := db.SearchFTSTracks("queen", 0, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchFTSTracks: %v", err)
|
t.Fatalf("SearchFTSTracks: %v", err)
|
||||||
}
|
}
|
||||||
@@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the Bohemian Rhapsody result and verify all 16 fields.
|
// Find the Bohemian Rhapsody result and verify all 16 fields.
|
||||||
var br *SearchTrackRow
|
var br *sqlcgen.TrackMetadatum
|
||||||
|
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
if r.Title == "Bohemian Rhapsody" {
|
if r.Title == "Bohemian Rhapsody" {
|
||||||
@@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) {
|
|||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Set up minimal FK chain for a single track.
|
// Set up minimal FK chain for a single track.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
FilePath: "/test/track.mp3",
|
||||||
)
|
Title: "Test Track",
|
||||||
if err != nil {
|
Artist: "Test Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 180000,
|
||||||
}
|
})
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert into search index.
|
// Insert into search index.
|
||||||
if err := db.InsertSearchIndex(
|
if err := db.InsertSearchIndex(
|
||||||
@@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) {
|
|||||||
|
|
||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Seed the full entity graph WITHOUT inserting into search_index.
|
// Seed the file WITHOUT putting it in search_index.
|
||||||
_, err := db.ExecContext(
|
InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')",
|
FilePath: "/rebuild/track.mp3",
|
||||||
)
|
Title: "Rebuild Track",
|
||||||
if err != nil {
|
Artist: "Rebuild Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
Album: "Rebuild Album",
|
||||||
}
|
LengthMs: 200000,
|
||||||
|
SkipSearchIndex: true,
|
||||||
_, err = db.ExecContext(
|
})
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert release_group: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert release_group_recordings: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search should return nothing before rebuild.
|
// Search should return nothing before rebuild.
|
||||||
results, err := db.SearchFTS("Rebuild", 10)
|
results, err := db.SearchFTS("Rebuild", 10)
|
||||||
@@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
|||||||
db := NewTestDB(t)
|
db := NewTestDB(t)
|
||||||
|
|
||||||
// Set up minimal FK chain for a single track at rowid 100.
|
// Set up minimal FK chain for a single track at rowid 100.
|
||||||
_, err := db.ExecContext(
|
id := InsertTestTrack(t, db, TestTrack{
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')",
|
FilePath: "/test/update_cycle.mp3",
|
||||||
)
|
Title: "Old Title",
|
||||||
if err != nil {
|
Artist: "Old Artist",
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
LengthMs: 200000,
|
||||||
}
|
SkipSearchIndex: true,
|
||||||
|
})
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (100, 'Old Title', 100)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert recording: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) " +
|
|
||||||
"VALUES (100, '/test/update_cycle.mp3', 200000, 0, 100)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert audio_file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Insert with old metadata.
|
// 1. Insert with old metadata.
|
||||||
if err := db.InsertSearchIndex(
|
if err := db.InsertSearchIndex(
|
||||||
100, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
|
id, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("InsertSearchIndex (old): %v", err)
|
t.Fatalf("InsertSearchIndex (old): %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify search for "Old Title" returns rowid 100.
|
// Verify search for "Old Title" finds it.
|
||||||
results, err := db.SearchFTS("Old Title", 10)
|
results, err := db.SearchFTS("Old Title", 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchFTS(Old Title): %v", err)
|
t.Fatalf("SearchFTS(Old Title): %v", err)
|
||||||
@@ -926,9 +790,9 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
|||||||
t.Fatal("SearchFTS(Old Title): got 0 results after insert")
|
t.Fatal("SearchFTS(Old Title): got 0 results after insert")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delete rowid 100.
|
// 2. Delete the row.
|
||||||
if err := db.DeleteSearchIndex(100); err != nil {
|
if err := db.DeleteSearchIndex(id); err != nil {
|
||||||
t.Fatalf("DeleteSearchIndex(100): %v", err)
|
t.Fatalf("DeleteSearchIndex(%d): %v", id, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify "Old Title" no longer found.
|
// Verify "Old Title" no longer found.
|
||||||
@@ -944,25 +808,17 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Update the recording name in the DB to simulate tag edit.
|
// 3. Update the file's title in the DB to simulate a tag edit.
|
||||||
_, err = db.ExecContext(
|
_, err = db.ExecContext(
|
||||||
"UPDATE recordings SET name = 'New Title' WHERE id = 100",
|
"UPDATE audio_files SET title = 'New Title' WHERE file_path = '/test/update_cycle.mp3'",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("update recording: %v", err)
|
t.Fatalf("update title: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also add a new artist_credit for the new artist.
|
// 4. Re-insert the row with new metadata.
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (101, 'New Artist')",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert new artist_credit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Re-insert rowid 100 with new metadata.
|
|
||||||
if err := db.InsertSearchIndex(
|
if err := db.InsertSearchIndex(
|
||||||
100, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
|
id, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("InsertSearchIndex (new): %v", err)
|
t.Fatalf("InsertSearchIndex (new): %v", err)
|
||||||
}
|
}
|
||||||
@@ -1079,25 +935,13 @@ func TestSearchIndexSchema(t *testing.T) {
|
|||||||
t.Fatalf("insert artist: %v", err)
|
t.Fatalf("insert artist: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The credit tables this used to assert a UNIQUE constraint on are
|
||||||
|
// gone; a file names its artist directly, and artists are unique by
|
||||||
|
// name, which is asserted below.
|
||||||
_, err = db.ExecContext(
|
_, err = db.ExecContext(
|
||||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')",
|
"INSERT INTO artists (id, name) VALUES (2, 'Test')",
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("insert artist_credit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("first insert artist_credit_artist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate insert should fail with UNIQUE constraint.
|
|
||||||
_, err = db.ExecContext(
|
|
||||||
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
|
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("duplicate artist_credit_artist insert should fail, got nil error")
|
t.Error("duplicate artist name should fail, got nil error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
-- Adds SplitMixedFolder's synthetic-group bookkeeping to an
|
|
||||||
-- existing tagging_items table. A fresh database never runs this
|
|
||||||
-- file: sql/schemas/tagging_items.sql already declares these
|
|
||||||
-- columns, so applySchema's isFreshDatabase check stamps this
|
|
||||||
-- version as applied without executing it.
|
|
||||||
ALTER TABLE tagging_items ADD COLUMN synthetic INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE tagging_items ADD COLUMN parent_group_key TEXT NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tagging_items_parent_group_key
|
|
||||||
ON tagging_items(parent_group_key) WHERE parent_group_key != '';
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- Repairs tagging_items rows left behind by a library-scan bug: the
|
|
||||||
-- rescan's orphan-cleanup phase deleted audio_files rows for files
|
|
||||||
-- removed from disk without decrementing/clearing their tagging
|
|
||||||
-- group, so a folder whose contents were fully replaced kept a
|
|
||||||
-- phantom entry (stale track_count, no matching audio_files) in the
|
|
||||||
-- autotag queue forever. The library scan code no longer has this
|
|
||||||
-- gap, but a database written before the fix still carries the
|
|
||||||
-- damage — this is a one-time repair, not ongoing bookkeeping.
|
|
||||||
--
|
|
||||||
-- Drop groups with no audio_files left at all.
|
|
||||||
DELETE FROM tagging_items
|
|
||||||
WHERE group_key NOT IN (
|
|
||||||
SELECT DISTINCT group_key FROM audio_files WHERE group_key != ''
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Reconcile track_count for groups that are still alive but drifted
|
|
||||||
-- (some, not all, of their tracks were removed without decrementing).
|
|
||||||
UPDATE tagging_items
|
|
||||||
SET track_count = (
|
|
||||||
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
|
|
||||||
)
|
|
||||||
WHERE track_count != (
|
|
||||||
SELECT COUNT(*) FROM audio_files WHERE audio_files.group_key = tagging_items.group_key
|
|
||||||
);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE queue ADD COLUMN source_type TEXT NOT NULL DEFAULT '';
|
|
||||||
ALTER TABLE queue ADD COLUMN source_id INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE queue ADD COLUMN source_label TEXT NOT NULL DEFAULT '';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER;
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
-- Queries over albums (formerly release_groups).
|
||||||
|
--
|
||||||
|
-- The two-copy pattern is gone here too: one query answers both the
|
||||||
|
-- whole-library and the single-library case. The `fallback_ac`
|
||||||
|
-- subquery every album read used to carry -- "if the album has no album
|
||||||
|
-- artist credit, borrow one from any of its recordings" -- is gone with
|
||||||
|
-- it, because the album carries its own credit text now.
|
||||||
|
|
||||||
|
-- name: UpsertAlbum :one
|
||||||
|
INSERT INTO albums (name, artist_credit, artist_id, year, cover_art_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name, artist_credit) DO UPDATE SET
|
||||||
|
artist_id = COALESCE(excluded.artist_id, albums.artist_id),
|
||||||
|
year = COALESCE(excluded.year, albums.year),
|
||||||
|
cover_art_id = COALESCE(excluded.cover_art_id, albums.cover_art_id)
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetAlbum :one
|
||||||
|
SELECT * FROM albums WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: SetAlbumMBID :exec
|
||||||
|
UPDATE albums SET mbid = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: SetAlbumOriginalYear :exec
|
||||||
|
UPDATE albums SET original_year = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: SetAlbumCoverArt :exec
|
||||||
|
UPDATE albums SET cover_art_id = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: SetAlbumPendingReleaseMBID :exec
|
||||||
|
UPDATE albums SET pending_release_mbid = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: ResolveAlbumPendingReleaseMBID :exec
|
||||||
|
-- Clears the pending marker once the release-group MBID it stood in for
|
||||||
|
-- has been resolved. Guarded so a real MBID is never overwritten.
|
||||||
|
UPDATE albums
|
||||||
|
SET mbid = ?, pending_release_mbid = NULL
|
||||||
|
WHERE id = ? AND (mbid IS NULL OR mbid = '');
|
||||||
|
|
||||||
|
-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||||
|
SELECT id, pending_release_mbid FROM albums
|
||||||
|
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||||
|
AND (mbid IS NULL OR mbid = '');
|
||||||
|
|
||||||
|
-- name: DeleteAlbum :exec
|
||||||
|
DELETE FROM albums WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllAlbums :exec
|
||||||
|
DELETE FROM albums;
|
||||||
|
|
||||||
|
-- name: GetEmptyAlbumIDs :many
|
||||||
|
-- Albums with no file left behind them. Under the old schema this was
|
||||||
|
-- one of three orphan sweeps that had to run by hand and did not;
|
||||||
|
-- audio_files is the only thing that can leave an album empty now, so
|
||||||
|
-- this is the whole of it.
|
||||||
|
SELECT id FROM albums al
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: GetAlbums :many
|
||||||
|
SELECT
|
||||||
|
al.id,
|
||||||
|
al.name,
|
||||||
|
COALESCE(al.original_year, al.year) AS year,
|
||||||
|
COALESCE(al.year, 0) AS release_year,
|
||||||
|
al.mbid,
|
||||||
|
al.artist_credit AS artist_name,
|
||||||
|
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||||
|
COALESCE(ca.file_path, '') AS cover_art_path
|
||||||
|
FROM albums al
|
||||||
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||||
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.album_id = al.id
|
||||||
|
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||||
|
)
|
||||||
|
ORDER BY al.name;
|
||||||
|
|
||||||
|
-- name: GetAlbumsByArtistName :many
|
||||||
|
SELECT
|
||||||
|
al.id,
|
||||||
|
al.name,
|
||||||
|
COALESCE(al.original_year, al.year) AS year,
|
||||||
|
COALESCE(al.year, 0) AS release_year,
|
||||||
|
al.mbid,
|
||||||
|
al.artist_credit AS artist_name,
|
||||||
|
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||||
|
COALESCE(ca.file_path, '') AS cover_art_path
|
||||||
|
FROM albums al
|
||||||
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||||
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||||
|
WHERE (al.artist_credit = sqlc.arg(artist) OR ar.name = sqlc.arg(artist))
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.album_id = al.id
|
||||||
|
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||||
|
)
|
||||||
|
ORDER BY year, al.name;
|
||||||
|
|
||||||
|
-- name: GetAlbumCompleteness :one
|
||||||
|
-- "Do I have all of this album", answered from the tags on disk.
|
||||||
|
--
|
||||||
|
-- The expectation is a **sum over discs**, not one number: totals are
|
||||||
|
-- declared per disc ("5/12" on disc 2 means 12 tracks on disc 2), so a
|
||||||
|
-- multi-disc album's expectation is the sum of each disc's declared
|
||||||
|
-- total. A disc whose files declared nothing leaves the whole album
|
||||||
|
-- unknowable rather than being covered by the discs that did -- which is
|
||||||
|
-- what `known` reports.
|
||||||
|
--
|
||||||
|
-- Owned counts DISTINCT track numbers: this app detects duplicates, and
|
||||||
|
-- counting two files of track 3 twice would report a short album as
|
||||||
|
-- complete.
|
||||||
|
SELECT
|
||||||
|
-- Distinct (disc, track) pairs: this app detects duplicates, and
|
||||||
|
-- counting two files of track 3 twice would report a short album as
|
||||||
|
-- complete. A file with no track number falls back to its own id,
|
||||||
|
-- because three untagged files are three tracks, not one.
|
||||||
|
CAST(COUNT(DISTINCT CAST(COALESCE(a.disc_number, 1) AS TEXT) || ':' ||
|
||||||
|
COALESCE(CAST(a.track_number AS TEXT), 'f' || a.id)
|
||||||
|
) AS INTEGER) AS owned,
|
||||||
|
CAST(COALESCE((
|
||||||
|
SELECT SUM(per_disc.total)
|
||||||
|
FROM (
|
||||||
|
SELECT MAX(b.total_tracks) AS total
|
||||||
|
FROM audio_files b
|
||||||
|
WHERE b.album_id = sqlc.arg(album_id) AND b.total_tracks IS NOT NULL
|
||||||
|
GROUP BY COALESCE(b.disc_number, 1)
|
||||||
|
) per_disc
|
||||||
|
), 0) AS INTEGER) AS expected,
|
||||||
|
CAST((
|
||||||
|
SELECT COUNT(*) = 0 FROM audio_files c
|
||||||
|
WHERE c.album_id = sqlc.arg(album_id) AND c.total_tracks IS NULL
|
||||||
|
) AS INTEGER) AS known
|
||||||
|
FROM audio_files a
|
||||||
|
WHERE a.album_id = sqlc.arg(album_id);
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
-- name: CreateArtistCredit :one
|
|
||||||
INSERT INTO artist_credit (text) VALUES (?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetArtistCredit :one
|
|
||||||
SELECT * FROM artist_credit
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetArtistCreditByText :one
|
|
||||||
SELECT * FROM artist_credit
|
|
||||||
WHERE text = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpsertArtistCredit :one
|
|
||||||
INSERT INTO artist_credit (text) VALUES (?)
|
|
||||||
ON CONFLICT(text) DO UPDATE SET text = excluded.text
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: UpdateArtistCredit :exec
|
|
||||||
UPDATE artist_credit
|
|
||||||
SET text = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteArtistCredit :exec
|
|
||||||
DELETE FROM artist_credit
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllArtistCredits :exec
|
|
||||||
DELETE FROM artist_credit;
|
|
||||||
|
|
||||||
-- name: CountArtistCreditReferences :one
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) +
|
|
||||||
(SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1)
|
|
||||||
AS total;
|
|
||||||
|
|
||||||
-- name: GetOrphanedArtistCreditIDs :many
|
|
||||||
-- Artist credits no longer used by any recording or release group - run
|
|
||||||
-- after orphaned recordings/release groups are deleted, so a credit
|
|
||||||
-- that only existed for now-removed tracks is cleaned up too.
|
|
||||||
SELECT ac.id FROM artist_credit ac
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- name: CreateArtistCreditArtist :one
|
|
||||||
INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetArtistCreditArtist :one
|
|
||||||
SELECT * FROM artist_credit_artist
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpdateArtistCreditArtist :exec
|
|
||||||
UPDATE artist_credit_artist
|
|
||||||
SET artist_id = ?, credit_id = ?
|
|
||||||
WHERE id =?;
|
|
||||||
|
|
||||||
-- name: DeleteArtistCreditArtist :exec
|
|
||||||
DELETE FROM artist_credit_artist
|
|
||||||
WHERE id =?;
|
|
||||||
|
|
||||||
-- name: DeleteAllArtistCreditArtists :exec
|
|
||||||
DELETE FROM artist_credit_artist;
|
|
||||||
|
|
||||||
-- name: DeleteArtistCreditArtistByCredit :exec
|
|
||||||
DELETE FROM artist_credit_artist
|
|
||||||
WHERE credit_id = ?;
|
|
||||||
|
|
||||||
@@ -1,67 +1,55 @@
|
|||||||
-- name: CreateArtist :one
|
-- Queries over artists.
|
||||||
INSERT INTO artists (name) VALUES (?)
|
--
|
||||||
|
-- An artist row is reachable two ways: as a file's primary artist
|
||||||
|
-- (audio_files.artist_id) and as an album's artist (albums.artist_id).
|
||||||
|
-- Both used to route through artist_credit + artist_credit_artist,
|
||||||
|
-- which is how "which album artists are in library 2" came to be a
|
||||||
|
-- five-join subquery inside a three-join query.
|
||||||
|
|
||||||
|
-- name: UpsertArtist :one
|
||||||
|
INSERT INTO artists (name, mbid) VALUES (?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
mbid = COALESCE(excluded.mbid, artists.mbid)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetArtist :one
|
-- name: GetArtist :one
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists WHERE id = ? LIMIT 1;
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetArtistByName :one
|
-- name: GetArtistByName :one
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists WHERE name = ? LIMIT 1;
|
||||||
WHERE name = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpsertArtist :one
|
-- name: SetArtistMBID :exec
|
||||||
INSERT INTO artists (name) VALUES (?)
|
UPDATE artists SET mbid = ? WHERE id = ?;
|
||||||
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: UpdateArtist :exec
|
|
||||||
UPDATE artists
|
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteArtist :exec
|
-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists WHERE id = ?;
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllArtists :exec
|
-- name: DeleteAllArtists :exec
|
||||||
DELETE FROM artists;
|
DELETE FROM artists;
|
||||||
|
|
||||||
|
-- name: GetUnreferencedArtistIDs :many
|
||||||
|
-- Artists no file and no album points at any more.
|
||||||
|
SELECT id FROM artists a
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id);
|
||||||
|
|
||||||
-- name: GetAllArtists :many
|
-- name: GetAllArtists :many
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists ORDER BY name;
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: GetAlbumArtists :many
|
-- name: GetAlbumArtists :many
|
||||||
SELECT DISTINCT a.id, a.name, a.mbid
|
SELECT DISTINCT a.id, a.name, a.mbid
|
||||||
FROM artists a
|
FROM artists a
|
||||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
JOIN albums al ON al.artist_id = a.id
|
||||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
WHERE EXISTS (
|
||||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
SELECT 1 FROM audio_files af
|
||||||
ORDER BY a.name;
|
WHERE af.album_id = al.id
|
||||||
|
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||||
-- name: GetOrphanedArtistIDs :many
|
|
||||||
-- Artists no longer credited on any recording or release group - left
|
|
||||||
-- behind when a scan's orphan cleanup removes the audio_files that used
|
|
||||||
-- to justify them, since deleting an audio_files row doesn't cascade.
|
|
||||||
SELECT a.id FROM artists a
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
|
|
||||||
);
|
|
||||||
|
|
||||||
-- name: GetAlbumArtistsByLibrary :many
|
|
||||||
SELECT DISTINCT a.id, a.name, a.mbid
|
|
||||||
FROM artists a
|
|
||||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
|
||||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
|
||||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
|
||||||
WHERE a.id IN (
|
|
||||||
SELECT DISTINCT aca2.artist_id
|
|
||||||
FROM artist_credit_artist aca2
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
|
|
||||||
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
|
|
||||||
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
|
|
||||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
|
||||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
|
||||||
WHERE af2.library_id = ?
|
|
||||||
)
|
)
|
||||||
ORDER BY a.name;
|
ORDER BY a.name;
|
||||||
|
|
||||||
|
-- name: GetArtistByFilePath :one
|
||||||
|
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
|
||||||
|
FROM audio_files af
|
||||||
|
LEFT JOIN artists a ON a.id = af.artist_id
|
||||||
|
WHERE af.file_path = ?
|
||||||
|
LIMIT 1;
|
||||||
|
|||||||
@@ -1,39 +1,60 @@
|
|||||||
|
-- Queries over audio_files and the track_metadata view above it.
|
||||||
|
--
|
||||||
|
-- Every query that returns "a track" selects from `track_metadata`,
|
||||||
|
-- which is the one place the projection is defined. The scoped and
|
||||||
|
-- unscoped variants that used to be written twice are one query now:
|
||||||
|
-- library_id 0 means "every library", and `(:id = 0 OR library_id = :id)`
|
||||||
|
-- costs nothing measurable (23 ms vs 21 ms over 26k rows) because these
|
||||||
|
-- queries scan either way.
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Writes
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
-- name: CreateAudioFile :one
|
-- name: CreateAudioFile :one
|
||||||
INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: CreateAudioFileWithGroupKey :one
|
|
||||||
INSERT INTO audio_files (
|
INSERT INTO audio_files (
|
||||||
file_path, length_milliseconds, file_type_id, recording_id,
|
file_path, library_id, file_type_id,
|
||||||
sample_rate, bit_depth, channels, bitrate, file_size, basename,
|
length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size,
|
||||||
library_id, group_key, tag_status, modified_at
|
title, artist_credit, artist_id, album_id,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
track_number, disc_number, total_tracks, year, composer, comment,
|
||||||
|
recording_mbid, basename, group_key, modified_at, tag_status
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetAudioFileGroupKey :one
|
-- name: UpdateAudioFileTags :exec
|
||||||
SELECT group_key FROM audio_files
|
-- A rescan of a file whose mtime moved: the tags are re-read and
|
||||||
WHERE id = ? LIMIT 1;
|
-- written over the same row. Under the old schema this created a
|
||||||
|
-- *new* recording and repointed the file at it, abandoning the old one
|
||||||
|
-- -- which is where 812 orphaned rows and every phantom "you own this"
|
||||||
|
-- came from. There is nothing to orphan now.
|
||||||
|
UPDATE audio_files
|
||||||
|
SET title = ?, artist_credit = ?, artist_id = ?, album_id = ?,
|
||||||
|
track_number = ?, disc_number = ?, total_tracks = ?, year = ?,
|
||||||
|
composer = ?, comment = ?, recording_mbid = ?,
|
||||||
|
sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?,
|
||||||
|
file_size = ?, length_milliseconds = ?, modified_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetAudioFileGroupKey :exec
|
-- name: SetAudioFileGroupKey :exec
|
||||||
UPDATE audio_files SET group_key = ? WHERE id = ?;
|
UPDATE audio_files SET group_key = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: GetAudioFile :one
|
-- name: PromoteAudioFileTagStatusIfUntagged :exec
|
||||||
SELECT * FROM audio_files
|
-- A rescan re-reads the tags of a file whose mtime moved, so a file
|
||||||
WHERE id = ? LIMIT 1;
|
-- another tagger stamped with MBIDs since import arrives here still
|
||||||
|
-- carrying the 'untagged' status it was created with (only the insert
|
||||||
-- name: GetAudioFileByPath :one
|
-- path sets it). Promote it the same way saveAudioFile does.
|
||||||
SELECT * FROM audio_files
|
-- Guarded on 'untagged' so it cannot overwrite a deliberate
|
||||||
WHERE file_path = ? LIMIT 1;
|
-- 'user_skipped_permanent', and so a file losing its MBIDs is left
|
||||||
|
-- alone -- demotion is the scan's judgement, not this statement's.
|
||||||
-- name: UpdateAudioFile :exec
|
|
||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ?
|
SET tag_status = 'user_confirmed'
|
||||||
WHERE id = ?;
|
WHERE id = ? AND tag_status = 'untagged';
|
||||||
|
|
||||||
-- name: UpdateAudioFileRecording :exec
|
|
||||||
UPDATE audio_files
|
|
||||||
SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateAudioFileStat :exec
|
-- name: UpdateAudioFileStat :exec
|
||||||
-- Records the on-disk mtime/size without re-reading tags. Used to
|
-- Records the on-disk mtime/size without re-reading tags. Used to
|
||||||
@@ -43,307 +64,146 @@ UPDATE audio_files
|
|||||||
SET modified_at = ?, file_size = ?
|
SET modified_at = ?, file_size = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: SetAudioFileRecordingMBID :exec
|
||||||
|
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAudioFile :exec
|
||||||
|
DELETE FROM audio_files WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllAudioFiles :exec
|
||||||
|
DELETE FROM audio_files;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Reads: the file row itself
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: GetAudioFile :one
|
||||||
|
SELECT * FROM audio_files WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetAudioFileByPath :one
|
||||||
|
SELECT * FROM audio_files WHERE file_path = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetAudioFileGroupKey :one
|
||||||
|
SELECT group_key FROM audio_files WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetAllAudioFilePaths :many
|
||||||
|
SELECT id, file_path FROM audio_files;
|
||||||
|
|
||||||
|
-- name: GetAudioFilesByPaths :many
|
||||||
|
SELECT id, library_id, file_path, group_key FROM audio_files
|
||||||
|
WHERE file_path IN (sqlc.slice('paths'));
|
||||||
|
|
||||||
|
-- name: GetRandomAudioFilePath :one
|
||||||
|
SELECT file_path FROM audio_files ORDER BY RANDOM() LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CountAudioFiles :one
|
||||||
|
SELECT COUNT(*) AS count FROM audio_files
|
||||||
|
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
|
||||||
|
|
||||||
-- name: GetLibraryMaxModifiedAt :one
|
-- name: GetLibraryMaxModifiedAt :one
|
||||||
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
|
-- Newest recorded mtime in a library, for the startup soft scan. 0 when
|
||||||
-- the library is empty or no row has a baseline yet.
|
-- the library is empty or no row has a baseline yet.
|
||||||
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files
|
||||||
WHERE library_id = ?;
|
WHERE library_id = ?;
|
||||||
|
|
||||||
-- name: DeleteAudioFile :exec
|
-- ---------------------------------------------------------------------
|
||||||
DELETE FROM audio_files
|
-- Reads: tracks
|
||||||
WHERE id = ?;
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
-- name: CountAudioFiles :one
|
-- name: GetTracks :many
|
||||||
SELECT count(*) FROM audio_files;
|
SELECT * FROM track_metadata
|
||||||
|
WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id);
|
||||||
|
|
||||||
-- name: GetRandomAudioFilePath :one
|
-- name: GetTrackByPath :one
|
||||||
SELECT file_path FROM audio_files
|
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
|
||||||
ORDER BY RANDOM()
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetAllAudioFiles :many
|
-- name: GetTracksByAlbum :many
|
||||||
SELECT * FROM audio_files;
|
SELECT * FROM track_metadata
|
||||||
|
WHERE album_id = sqlc.arg(album_id)
|
||||||
|
AND library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id)
|
||||||
|
ORDER BY disc_number, track_number;
|
||||||
|
|
||||||
-- name: GetAllAudioFilePaths :many
|
-- name: GetTracksByGenre :many
|
||||||
SELECT id, file_path FROM audio_files;
|
SELECT tm.* FROM track_metadata tm
|
||||||
|
JOIN file_genres fg ON fg.audio_file_id = tm.id
|
||||||
-- name: GetAudioFilesNeedingMetadata :many
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
SELECT * FROM audio_files
|
WHERE g.name = sqlc.arg(genre)
|
||||||
WHERE recording_id = 0;
|
AND tm.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), tm.library_id);
|
||||||
|
|
||||||
-- name: GetAllAudioFilesWithArtist :many
|
|
||||||
SELECT
|
|
||||||
af.id,
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
af.file_type_id,
|
|
||||||
af.recording_id,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
COALESCE(r.name, '') AS title
|
|
||||||
FROM audio_files af
|
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id;
|
|
||||||
|
|
||||||
-- name: GetTrackMetadataByPath :one
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist,
|
|
||||||
COALESCE(rg.name, '') AS album,
|
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM audio_files af
|
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
WHERE af.file_path = ?
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetAllTracksWithFullMetadata :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
|
||||||
FROM recording_genres rg_sub
|
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
|
||||||
WHERE rg_sub.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size,
|
|
||||||
af.play_count,
|
|
||||||
af.last_played,
|
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM audio_files af
|
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
|
|
||||||
|
|
||||||
-- name: SearchAudioFilesByBasename :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist,
|
|
||||||
COALESCE(rg.name, '') AS album
|
|
||||||
FROM audio_files af
|
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
WHERE af.basename = ?
|
|
||||||
LIMIT ?;
|
|
||||||
|
|
||||||
-- name: LookupTrackMetaByPaths :many
|
-- name: LookupTrackMetaByPaths :many
|
||||||
SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid
|
SELECT id, file_path, title, artist_name, album, cover_art_path,
|
||||||
|
artist_mbid, release_group_mbid, recording_mbid
|
||||||
FROM track_metadata
|
FROM track_metadata
|
||||||
WHERE file_path IN (sqlc.slice('paths'));
|
WHERE file_path IN (sqlc.slice('paths'));
|
||||||
|
|
||||||
-- name: GetAudioFilesByLibrary :many
|
-- name: SearchTracksByBasename :many
|
||||||
SELECT * FROM audio_files WHERE library_id = ?;
|
SELECT id, file_path, length_milliseconds, title, artist_name, album
|
||||||
|
FROM track_metadata
|
||||||
|
WHERE file_path IN (
|
||||||
|
SELECT file_path FROM audio_files WHERE basename = sqlc.arg(basename)
|
||||||
|
)
|
||||||
|
LIMIT sqlc.arg(lim);
|
||||||
|
|
||||||
-- name: CountAudioFilesByLibrary :one
|
-- ---------------------------------------------------------------------
|
||||||
SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?;
|
-- Reads: file paths, grouped by whatever the caller asked about
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- These answer "what can I play" and they all ask audio_files, because
|
||||||
|
-- that is the only table whose rows are files. Grouped rather than
|
||||||
|
-- flattened because the caller owns the order.
|
||||||
|
|
||||||
-- name: DeleteAllAudioFiles :exec
|
-- name: GetFilePathsByAlbums :many
|
||||||
DELETE FROM audio_files;
|
-- The library filter is applied in Go rather than here: sqlc numbers a
|
||||||
|
-- named parameter (?2) but expands a slice into N placeholders, so the
|
||||||
-- name: GetAllTracksWithFullMetadataByLibrary :many
|
-- two together bind the wrong values - GetFilePathsByAlbums([1,2], 0)
|
||||||
SELECT
|
-- read album id 2 as the library id. Returning library_id and
|
||||||
af.file_path,
|
-- filtering the (small) result is the version that cannot be wrong.
|
||||||
af.length_milliseconds,
|
SELECT album_id, library_id, file_path FROM audio_files
|
||||||
COALESCE(r.name, '') AS title,
|
WHERE album_id IN (sqlc.slice('album_ids'))
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
ORDER BY disc_number, track_number;
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
|
||||||
FROM recording_genres rg_sub
|
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
|
||||||
WHERE rg_sub.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size,
|
|
||||||
af.play_count,
|
|
||||||
af.last_played,
|
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM audio_files af
|
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE af.library_id = ?;
|
|
||||||
|
|
||||||
-- name: GetAudioFilesByReleaseGroup :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
rgr.track_number,
|
|
||||||
rgr.disc_number,
|
|
||||||
COALESCE(rg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
|
||||||
FROM recording_genres rg_sub
|
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
|
||||||
WHERE rg_sub.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings r ON rgr.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE rgr.release_group_id = ?
|
|
||||||
ORDER BY rgr.disc_number, rgr.track_number;
|
|
||||||
|
|
||||||
-- name: GetAudioFilesByReleaseGroupByLibrary :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
rgr.track_number,
|
|
||||||
rgr.disc_number,
|
|
||||||
COALESCE(rg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
|
||||||
FROM recording_genres rg_sub
|
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
|
||||||
WHERE rg_sub.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings r ON rgr.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE rgr.release_group_id = ? AND af.library_id = ?
|
|
||||||
ORDER BY rgr.disc_number, rgr.track_number;
|
|
||||||
|
|
||||||
-- "Play this artist" and "play these albums" wanted file paths and asked
|
|
||||||
-- for whole track rows to get them, one round trip per album (perf.m2).
|
|
||||||
-- These answer the same question in one query and carry only what the
|
|
||||||
-- caller uses; the release group id comes back so the caller can keep
|
|
||||||
-- its own album ordering.
|
|
||||||
|
|
||||||
-- name: GetFilePathsByReleaseGroups :many
|
|
||||||
SELECT rgr.release_group_id, af.file_path
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings r ON rgr.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
|
||||||
ORDER BY rgr.disc_number, rgr.track_number;
|
|
||||||
|
|
||||||
-- name: GetFilePathsByReleaseGroupsByLibrary :many
|
|
||||||
SELECT rgr.release_group_id, af.file_path
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings r ON rgr.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids'))
|
|
||||||
AND af.library_id = ?
|
|
||||||
ORDER BY rgr.disc_number, rgr.track_number;
|
|
||||||
|
|
||||||
-- Same shape again, keyed on recording MBID, for the catalog side.
|
|
||||||
-- An Explore album page knows which of its tracks the user owns only
|
|
||||||
-- as a set of recording MBIDs -- that is exactly how the backend
|
|
||||||
-- decides `inLibrary` (markReleasesInLibrary -> CheckMBIDs) -- and
|
|
||||||
-- MBTrack.LocalID is declared but never written by anything, so there
|
|
||||||
-- is no id to ask by. Grouped by MBID because a recording can have
|
|
||||||
-- more than one file (the duplicate fixtures are precisely that) and
|
|
||||||
-- because the caller owns the order: the tracklist's, not the
|
|
||||||
-- database's.
|
|
||||||
|
|
||||||
-- name: GetFilePathsByRecordingMBIDs :many
|
-- name: GetFilePathsByRecordingMBIDs :many
|
||||||
SELECT r.mbid AS recording_mbid, af.file_path
|
-- The ownership question in its only honest form: which of these
|
||||||
FROM recordings r
|
-- catalog recordings has a *file* behind it. Asked of audio_files, so
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
-- a metadata row with no file cannot answer yes.
|
||||||
WHERE r.mbid IN (sqlc.slice('mbids'))
|
SELECT recording_mbid, library_id, file_path FROM audio_files
|
||||||
ORDER BY af.file_path;
|
WHERE recording_mbid IN (sqlc.slice('mbids'))
|
||||||
|
ORDER BY file_path;
|
||||||
|
|
||||||
-- name: GetFilePathsByRecordingMBIDsByLibrary :many
|
-- name: GetFilePathsByGenres :many
|
||||||
SELECT r.mbid AS recording_mbid, af.file_path
|
SELECT g.name AS genre, af.library_id, af.file_path
|
||||||
FROM recordings r
|
FROM audio_files af
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
WHERE r.mbid IN (sqlc.slice('mbids'))
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
AND af.library_id = ?
|
WHERE g.name IN (sqlc.slice('genres'))
|
||||||
ORDER BY af.file_path;
|
ORDER BY af.disc_number, af.track_number;
|
||||||
|
|
||||||
-- name: GetAudioFilesByPaths :many
|
-- name: GetFilePathsByArtistMBID :many
|
||||||
SELECT id, library_id, file_path, group_key FROM audio_files
|
SELECT DISTINCT af.file_path
|
||||||
WHERE file_path IN (sqlc.slice('paths'));
|
FROM audio_files af
|
||||||
|
JOIN artists a ON a.id = af.artist_id
|
||||||
|
WHERE a.mbid = ?;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Ownership, asked in bulk
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- name: OwnedRecordingMBIDs :many
|
||||||
|
-- Which of these recording MBIDs are actually in the library. This is
|
||||||
|
-- what marks a catalog tracklist owned; it used to be
|
||||||
|
-- `SELECT mbid FROM recordings`, which answered yes for 129 tracks in a
|
||||||
|
-- real library that had no file at all.
|
||||||
|
SELECT DISTINCT recording_mbid FROM audio_files
|
||||||
|
WHERE recording_mbid IN (sqlc.slice('mbids'));
|
||||||
|
|
||||||
|
-- name: OwnedAlbumMBIDs :many
|
||||||
|
SELECT DISTINCT al.mbid FROM albums al
|
||||||
|
JOIN audio_files af ON af.album_id = al.id
|
||||||
|
WHERE al.mbid IN (sqlc.slice('mbids'));
|
||||||
|
|
||||||
|
-- name: OwnedArtistMBIDs :many
|
||||||
|
SELECT DISTINCT a.mbid FROM artists a
|
||||||
|
JOIN audio_files af ON af.artist_id = a.id
|
||||||
|
WHERE a.mbid IN (sqlc.slice('mbids'));
|
||||||
|
|
||||||
|
-- name: GetAudioFilesInLibrary :many
|
||||||
|
SELECT * FROM audio_files WHERE library_id = ?;
|
||||||
|
|||||||
@@ -1,150 +1,50 @@
|
|||||||
|
-- Queries over genres and file_genres.
|
||||||
|
--
|
||||||
|
-- The track-returning ones live in audio_files.sql with the rest of the
|
||||||
|
-- track_metadata reads; what is left here is the genre list itself and
|
||||||
|
-- the link table's writes.
|
||||||
|
|
||||||
-- name: UpsertGenre :one
|
-- name: UpsertGenre :one
|
||||||
INSERT INTO genres (name) VALUES (?)
|
INSERT INTO genres (name) VALUES (?)
|
||||||
ON CONFLICT(name) DO UPDATE SET name = name
|
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: CreateRecordingGenre :exec
|
-- name: LinkFileGenre :exec
|
||||||
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
|
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?);
|
||||||
VALUES (?, ?);
|
|
||||||
|
|
||||||
-- name: DeleteRecordingGenres :exec
|
-- name: DeleteFileGenres :exec
|
||||||
DELETE FROM recording_genres
|
DELETE FROM file_genres WHERE audio_file_id = ?;
|
||||||
WHERE recording_id = ?;
|
|
||||||
|
|
||||||
-- name: GetGenresByRecordingID :many
|
-- name: GetGenreNamesByFile :many
|
||||||
SELECT g.*
|
SELECT g.name FROM genres g
|
||||||
FROM genres g
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
WHERE fg.audio_file_id = ?;
|
||||||
WHERE rg.recording_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllRecordingGenres :exec
|
-- name: GetGenreNamesByFilePaths :many
|
||||||
DELETE FROM recording_genres;
|
-- Genres for many files at once. The mix builder asked this one file
|
||||||
|
-- at a time, inside two nested loops -- twelve thousand single-row
|
||||||
-- name: DeleteAllGenres :exec
|
-- queries to assemble one mix.
|
||||||
DELETE FROM genres;
|
SELECT af.file_path, g.name
|
||||||
|
FROM audio_files af
|
||||||
-- name: GetTracksByGenre :many
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
SELECT
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
af.file_path,
|
WHERE af.file_path IN (sqlc.slice('paths'));
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rlg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
|
||||||
FROM recording_genres rg2
|
|
||||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
|
||||||
WHERE rg2.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE g.name = ?
|
|
||||||
ORDER BY r.name;
|
|
||||||
|
|
||||||
-- name: GetTracksByGenreByLibrary :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rlg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
|
||||||
FROM recording_genres rg2
|
|
||||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
|
||||||
WHERE rg2.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE g.name = ? AND af.library_id = ?
|
|
||||||
ORDER BY r.name;
|
|
||||||
|
|
||||||
-- name: CountGenreReferences :one
|
|
||||||
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteGenre :exec
|
-- name: DeleteGenre :exec
|
||||||
DELETE FROM genres WHERE id = ?;
|
DELETE FROM genres WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllGenres :exec
|
||||||
|
DELETE FROM genres;
|
||||||
|
|
||||||
|
-- name: GetUnusedGenreIDs :many
|
||||||
|
SELECT id FROM genres g
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id);
|
||||||
|
|
||||||
-- name: GetAllGenresWithCounts :many
|
-- name: GetAllGenresWithCounts :many
|
||||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
SELECT g.name, COUNT(fg.audio_file_id) AS track_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
|
WHERE af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||||
GROUP BY g.id, g.name
|
GROUP BY g.id, g.name
|
||||||
ORDER BY g.name;
|
ORDER BY g.name;
|
||||||
|
|
||||||
-- name: GetAllGenresWithCountsByLibrary :many
|
|
||||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE af.library_id = ?
|
|
||||||
GROUP BY g.id, g.name
|
|
||||||
ORDER BY g.name;
|
|
||||||
|
|
||||||
-- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
|
||||||
-- one query instead of one per genre, and file paths instead of whole
|
|
||||||
-- track rows, which was 6 MB over the IPC for five genres.
|
|
||||||
|
|
||||||
-- name: GetFilePathsByGenres :many
|
|
||||||
SELECT g.name AS genre_name, af.file_path
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE g.name IN (sqlc.slice('genre_names'))
|
|
||||||
ORDER BY r.name;
|
|
||||||
|
|
||||||
-- name: GetFilePathsByGenresByLibrary :many
|
|
||||||
SELECT g.name AS genre_name, af.file_path
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE g.name IN (sqlc.slice('genre_names'))
|
|
||||||
AND af.library_id = ?
|
|
||||||
ORDER BY r.name;
|
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
--
|
--
|
||||||
-- Every one of these returns album ids and nothing else. The display
|
-- Every one of these returns album ids and nothing else. The display
|
||||||
-- columns (cover art, artist credit, year) already have exactly one
|
-- columns (cover art, artist credit, year) already have exactly one
|
||||||
-- correct expression of them, in GetAllAlbumsWithDetails, and a second
|
-- correct expression of them, in GetAlbums, and a second
|
||||||
-- copy per shelf would be six more places for that to drift. The home
|
-- copy per shelf would be six more places for that to drift. The home
|
||||||
-- service joins the ids back to that one album list in Go.
|
-- service joins the ids back to that one album list in Go.
|
||||||
|
|
||||||
-- name: HomeRecentlyPlayedAlbums :many
|
-- name: HomeRecentlyPlayedAlbums :many
|
||||||
-- Albums with the most recent play, newest first.
|
-- Albums with the most recent play, newest first.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.last_played) DESC
|
ORDER BY MAX(af.last_played) DESC
|
||||||
@@ -22,9 +21,8 @@ LIMIT ?;
|
|||||||
-- stands in for one: it is monotonic and assigned at import, which is
|
-- stands in for one: it is monotonic and assigned at import, which is
|
||||||
-- the same ordering an added_at column would give.
|
-- the same ordering an added_at column would give.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.id) DESC
|
ORDER BY MAX(af.id) DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
@@ -32,9 +30,8 @@ LIMIT ?;
|
|||||||
-- name: HomeMostPlayedAlbums :many
|
-- name: HomeMostPlayedAlbums :many
|
||||||
-- Albums by total plays across their tracks.
|
-- Albums by total plays across their tracks.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) > 0
|
HAVING SUM(af.play_count) > 0
|
||||||
ORDER BY SUM(af.play_count) DESC
|
ORDER BY SUM(af.play_count) DESC
|
||||||
@@ -45,9 +42,8 @@ LIMIT ?;
|
|||||||
-- shelf is a different suggestion each time rather than the same
|
-- shelf is a different suggestion each time rather than the same
|
||||||
-- alphabetical head of the list forever.
|
-- alphabetical head of the list forever.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) = 0
|
HAVING SUM(af.play_count) = 0
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
@@ -56,9 +52,8 @@ LIMIT ?;
|
|||||||
-- name: HomeStaleAlbums :many
|
-- name: HomeStaleAlbums :many
|
||||||
-- Played before, but not for a long while.
|
-- Played before, but not for a long while.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING MAX(af.last_played) < datetime('now', ?)
|
HAVING MAX(af.last_played) < datetime('now', ?)
|
||||||
@@ -67,9 +62,8 @@ LIMIT ?;
|
|||||||
|
|
||||||
-- name: HomeRandomAlbums :many
|
-- name: HomeRandomAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
@@ -78,10 +72,10 @@ LIMIT ?;
|
|||||||
-- A random sample of albums carrying a genre, so the same genre shelf
|
-- A random sample of albums carrying a genre, so the same genre shelf
|
||||||
-- is not the same ten albums every time the page opens.
|
-- is not the same ten albums every time the page opens.
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
JOIN genres g ON g.id = rgen.genre_id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
WHERE g.name = ?
|
WHERE g.name = ?
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
@@ -93,10 +87,10 @@ LIMIT ?;
|
|||||||
-- album carries is a shelf about that one album.
|
-- album carries is a shelf about that one album.
|
||||||
SELECT
|
SELECT
|
||||||
g.name AS genre,
|
g.name AS genre,
|
||||||
COUNT(DISTINCT rgr.release_group_id) AS album_count
|
COUNT(DISTINCT af.album_id) AS album_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rgen ON rgen.genre_id = g.id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
GROUP BY g.id
|
GROUP BY g.id
|
||||||
HAVING album_count >= 3
|
HAVING album_count >= 3
|
||||||
ORDER BY album_count DESC
|
ORDER BY album_count DESC
|
||||||
@@ -106,14 +100,12 @@ LIMIT ?;
|
|||||||
-- Artists by total plays, as the album-artist credit text the album
|
-- Artists by total plays, as the album-artist credit text the album
|
||||||
-- list already displays.
|
-- list already displays.
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
rg.artist_credit AS artist_name,
|
||||||
SUM(af.play_count) AS plays
|
SUM(af.play_count) AS plays
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
WHERE rg.artist_credit <> ''
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
GROUP BY rg.artist_credit
|
||||||
WHERE ac.text <> ''
|
|
||||||
GROUP BY ac.text
|
|
||||||
HAVING plays > 0
|
HAVING plays > 0
|
||||||
ORDER BY plays DESC
|
ORDER BY plays DESC
|
||||||
LIMIT ?;
|
LIMIT ?;
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
-- Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
|
|
||||||
-- expanding a seed selection into a candidate pool by artist similarity
|
|
||||||
-- and genre overlap, restricted to what is actually in the library.
|
|
||||||
|
|
||||||
-- name: GetFilePathsByArtistMBID :many
|
|
||||||
SELECT DISTINCT af.file_path
|
|
||||||
FROM audio_files af
|
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
JOIN artists a ON a.id = aca.artist_id
|
|
||||||
WHERE a.mbid = ?;
|
|
||||||
|
|
||||||
-- name: GetGenreNamesByFilePath :many
|
|
||||||
SELECT DISTINCT g.name
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE af.file_path = ?;
|
|
||||||
|
|
||||||
-- name: GetArtistByFilePath :one
|
|
||||||
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
|
|
||||||
FROM audio_files af
|
|
||||||
JOIN recordings r ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
JOIN artists a ON a.id = aca.artist_id
|
|
||||||
WHERE af.file_path = ?
|
|
||||||
LIMIT 1;
|
|
||||||
@@ -47,29 +47,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||||
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
WHERE pt.playlist_id = ?
|
WHERE pt.playlist_id = ?
|
||||||
ORDER BY pt.position;
|
ORDER BY pt.position;
|
||||||
|
|
||||||
@@ -79,29 +68,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||||
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
ORDER BY pt.playlist_id, pt.position;
|
ORDER BY pt.playlist_id, pt.position;
|
||||||
|
|
||||||
-- name: DeleteAllPlaylistTracks :exec
|
-- name: DeleteAllPlaylistTracks :exec
|
||||||
@@ -132,27 +110,8 @@ WHERE playlist_id = ? AND audio_file_id = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: GetTrackPhantomMetadata :one
|
-- name: GetTrackPhantomMetadata :one
|
||||||
SELECT
|
-- The display fields a playlist row keeps after its file goes away.
|
||||||
COALESCE(r.name, '') AS title,
|
SELECT title, artist_name AS artist, album,
|
||||||
COALESCE(ac.text, '') AS artist,
|
length_milliseconds AS duration_ms, genre, cover_art_path
|
||||||
COALESCE(rg.name, '') AS album,
|
FROM track_metadata
|
||||||
af.length_milliseconds AS duration_ms,
|
WHERE id = ?;
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
|
||||||
FROM recording_genres rg_sub
|
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
|
||||||
WHERE rg_sub.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path
|
|
||||||
FROM audio_files af
|
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
WHERE af.id = ?;
|
|
||||||
|
|||||||
@@ -13,27 +13,12 @@ SET current_position = ?
|
|||||||
WHERE id = 1;
|
WHERE id = 1;
|
||||||
|
|
||||||
-- name: GetQueueTracks :many
|
-- name: GetQueueTracks :many
|
||||||
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
|
-- The queue's rows, joined to the one track projection.
|
||||||
COALESCE(r.name, '') AS title,
|
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
|
||||||
COALESCE(ac.text, '') AS artist,
|
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
|
||||||
COALESCE(rg.name, '') AS album,
|
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM queue_tracks qt
|
FROM queue_tracks qt
|
||||||
JOIN audio_files af ON qt.audio_file_id = af.id
|
JOIN track_metadata tm ON tm.id = qt.audio_file_id
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
ORDER BY qt.position;
|
ORDER BY qt.position;
|
||||||
|
|
||||||
-- name: GetQueueTrackCount :one
|
-- name: GetQueueTrackCount :one
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
-- name: CreateRecording :one
|
|
||||||
INSERT INTO recordings (name, artist_credit_id) VALUES (?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: CreateRecordingFull :one
|
|
||||||
INSERT INTO recordings (
|
|
||||||
name, artist_credit_id, track_number, disc_number,
|
|
||||||
year, genre, composer, lyrics, comment
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetRecording :one
|
|
||||||
SELECT * FROM recordings
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpdateRecording :exec
|
|
||||||
UPDATE recordings
|
|
||||||
SET name = ?, artist_credit_id = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateRecordingFull :exec
|
|
||||||
UPDATE recordings
|
|
||||||
SET name = ?, artist_credit_id = ?, track_number = ?, disc_number = ?,
|
|
||||||
year = ?, genre = ?, composer = ?, lyrics = ?, comment = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteRecording :exec
|
|
||||||
DELETE FROM recordings
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllRecordings :exec
|
|
||||||
DELETE FROM recordings;
|
|
||||||
|
|
||||||
-- name: GetAllRecordings :many
|
|
||||||
SELECT * FROM recordings
|
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: CountRecordingsByArtistCredit :one
|
|
||||||
SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?;
|
|
||||||
|
|
||||||
-- name: GetOrphanedRecordingIDs :many
|
|
||||||
-- Recordings no longer backed by any audio_files row - left behind
|
|
||||||
-- when a scan's orphan cleanup deletes the file that used to own them,
|
|
||||||
-- since deleting audio_files doesn't cascade to recordings.
|
|
||||||
SELECT r.id FROM recordings r
|
|
||||||
LEFT JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE af.id IS NULL;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
-- name: CreateReleaseGroupRecording :one
|
|
||||||
INSERT INTO release_group_recordings (
|
|
||||||
release_group_id, recording_id, track_number, disc_number, total_tracks
|
|
||||||
)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetAlbumCompleteness :one
|
|
||||||
WITH discs AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(rgr.disc_number, 1) AS disc,
|
|
||||||
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
|
|
||||||
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
WHERE rgr.release_group_id = ?
|
|
||||||
GROUP BY COALESCE(rgr.disc_number, 1)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
|
|
||||||
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
|
|
||||||
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
|
|
||||||
FROM discs;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroupRecording :one
|
|
||||||
SELECT * FROM release_group_recordings
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroupRecordings :many
|
|
||||||
SELECT * FROM release_group_recordings
|
|
||||||
WHERE release_group_id = ?
|
|
||||||
ORDER BY disc_number, track_number;
|
|
||||||
|
|
||||||
-- name: GetRecordingReleaseGroups :many
|
|
||||||
SELECT * FROM release_group_recordings
|
|
||||||
WHERE recording_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecording :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecordingByFK :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE release_group_id = ? AND recording_id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllReleaseGroupRecordings :exec
|
|
||||||
DELETE FROM release_group_recordings;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroupRecordingsByRecording :exec
|
|
||||||
DELETE FROM release_group_recordings
|
|
||||||
WHERE recording_id = ?;
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
-- name: CreateReleaseGroup :one
|
|
||||||
INSERT INTO release_groups (name) VALUES (?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: CreateReleaseGroupFull :one
|
|
||||||
INSERT INTO release_groups (
|
|
||||||
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroup :one
|
|
||||||
SELECT * FROM release_groups
|
|
||||||
WHERE id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetReleaseGroupByNameAndArtist :one
|
|
||||||
SELECT * FROM release_groups
|
|
||||||
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1;
|
|
||||||
|
|
||||||
-- name: UpsertReleaseGroup :one
|
|
||||||
INSERT INTO release_groups (name, album_artist_credit_id, year)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
|
|
||||||
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
|
|
||||||
year = COALESCE(excluded.year, release_groups.year)
|
|
||||||
RETURNING *;
|
|
||||||
|
|
||||||
-- name: SetReleaseGroupOriginalYear :exec
|
|
||||||
-- Set the release group's original-release-year (release-group's
|
|
||||||
-- first-release-date from MusicBrainz). Called from autotag apply
|
|
||||||
-- when the user confirms a candidate; the file-tag year stays in
|
|
||||||
-- the year column.
|
|
||||||
UPDATE release_groups SET original_year = ? WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateReleaseGroup :exec
|
|
||||||
UPDATE release_groups
|
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: UpdateReleaseGroupCoverArt :exec
|
|
||||||
UPDATE release_groups
|
|
||||||
SET cover_art_id = ?
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteReleaseGroup :exec
|
|
||||||
DELETE FROM release_groups
|
|
||||||
WHERE id = ?;
|
|
||||||
|
|
||||||
-- name: DeleteAllReleaseGroups :exec
|
|
||||||
DELETE FROM release_groups;
|
|
||||||
|
|
||||||
-- name: GetAllReleaseGroups :many
|
|
||||||
SELECT * FROM release_groups
|
|
||||||
ORDER BY name;
|
|
||||||
|
|
||||||
-- name: GetAllAlbumsWithDetails :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
-- year prefers original release year (MB first-release-date)
|
|
||||||
-- over the file-tag year so the UI surfaces the album's
|
|
||||||
-- original year by default. release_year keeps the file-tag
|
|
||||||
-- year accessible.
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
rg.mbid,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
ORDER BY rg.name;
|
|
||||||
|
|
||||||
-- name: GetAllAlbumsWithDetailsByLibrary :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
-- year prefers original release year (MB first-release-date)
|
|
||||||
-- over the file-tag year so the UI surfaces the album's
|
|
||||||
-- original year by default. release_year keeps the file-tag
|
|
||||||
-- year accessible.
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
rg.mbid,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
WHERE rg.id IN (
|
|
||||||
SELECT DISTINCT rgr2.release_group_id
|
|
||||||
FROM release_group_recordings rgr2
|
|
||||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
|
||||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
|
||||||
WHERE af2.library_id = ?
|
|
||||||
)
|
|
||||||
ORDER BY rg.name;
|
|
||||||
|
|
||||||
-- name: GetAlbumsByArtist :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
WHERE aca.artist_id = ?
|
|
||||||
ORDER BY rg.name;
|
|
||||||
|
|
||||||
-- name: CountReleaseGroupRecordings :one
|
|
||||||
SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?;
|
|
||||||
|
|
||||||
-- name: GetOrphanedReleaseGroupIDs :many
|
|
||||||
-- Release groups with no recordings left in them - run after orphaned
|
|
||||||
-- recordings (and their release_group_recordings rows) are deleted, so
|
|
||||||
-- a release group whose last owned track was removed is cleaned up too.
|
|
||||||
SELECT rg.id FROM release_groups rg
|
|
||||||
LEFT JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
|
||||||
WHERE rgr.id IS NULL;
|
|
||||||
|
|
||||||
-- name: GetAlbumsByArtistByLibrary :many
|
|
||||||
SELECT
|
|
||||||
rg.id,
|
|
||||||
rg.name,
|
|
||||||
COALESCE(rg.original_year, rg.year) AS year,
|
|
||||||
COALESCE(rg.year, 0) AS release_year,
|
|
||||||
COALESCE(ac.text, fallback_ac.text, '') as artist_name,
|
|
||||||
-- primary (first-credited) album artist's MBID, for linking the
|
|
||||||
-- artist name to its detail page. Empty when the album has no
|
|
||||||
-- MB-tagged album-artist credit.
|
|
||||||
CAST(COALESCE((
|
|
||||||
SELECT a.mbid
|
|
||||||
FROM artist_credit_artist aca_p
|
|
||||||
JOIN artists a ON a.id = aca_p.artist_id
|
|
||||||
WHERE aca_p.credit_id = rg.album_artist_credit_id
|
|
||||||
ORDER BY aca_p.id
|
|
||||||
LIMIT 1
|
|
||||||
), '') AS TEXT) as artist_mbid,
|
|
||||||
COALESCE(ca.file_path, '') as cover_art_path
|
|
||||||
FROM release_groups rg
|
|
||||||
JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
|
||||||
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT rgr.release_group_id, ac2.text
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
JOIN recordings rec ON rec.id = rgr.recording_id
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = rec.artist_credit_id
|
|
||||||
GROUP BY rgr.release_group_id
|
|
||||||
) fallback_ac ON fallback_ac.release_group_id = rg.id
|
|
||||||
WHERE aca.artist_id = ?
|
|
||||||
AND rg.id IN (
|
|
||||||
SELECT DISTINCT rgr2.release_group_id
|
|
||||||
FROM release_group_recordings rgr2
|
|
||||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
|
||||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
|
||||||
WHERE af2.library_id = ?
|
|
||||||
)
|
|
||||||
ORDER BY rg.name;
|
|
||||||
@@ -87,10 +87,7 @@ LIMIT 1;
|
|||||||
SELECT ti.group_key
|
SELECT ti.group_key
|
||||||
FROM tagging_items ti
|
FROM tagging_items ti
|
||||||
JOIN audio_files af ON af.group_key = ti.group_key
|
JOIN audio_files af ON af.group_key = ti.group_key
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums rg ON rg.id = af.album_id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
|
|
||||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
|
||||||
WHERE ti.synthetic = 0
|
WHERE ti.synthetic = 0
|
||||||
AND ti.track_count >= 4
|
AND ti.track_count >= 4
|
||||||
AND (
|
AND (
|
||||||
@@ -98,13 +95,23 @@ WHERE ti.synthetic = 0
|
|||||||
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
||||||
)
|
)
|
||||||
GROUP BY ti.group_key
|
GROUP BY ti.group_key
|
||||||
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
|
HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1
|
||||||
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
|
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1;
|
||||||
|
|
||||||
-- name: CountPendingTaggingItems :one
|
-- name: CountPendingTaggingItems :one
|
||||||
SELECT COUNT(*) FROM tagging_items
|
-- "Needs tagging" is a question about the files, not about the row:
|
||||||
WHERE status = 'pending'
|
-- every scanned folder gets a tagging_items row (see
|
||||||
AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id);
|
-- UpsertTaggingItemOnTrackAdd), including one whose files all arrived
|
||||||
|
-- carrying a recording MBID. Without the EXISTS a fully MB-tagged
|
||||||
|
-- library reports its entire album count as pending work. See the
|
||||||
|
-- same predicate on the three list queries below.
|
||||||
|
SELECT COUNT(*) FROM tagging_items ti
|
||||||
|
WHERE ti.status = 'pending'
|
||||||
|
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
);
|
||||||
|
|
||||||
-- name: ListPendingTaggingItemsAlphabetical :many
|
-- name: ListPendingTaggingItemsAlphabetical :many
|
||||||
SELECT
|
SELECT
|
||||||
@@ -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)
|
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||||
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
||||||
AND ti.cleared_at IS NULL
|
AND ti.cleared_at IS NULL
|
||||||
|
-- Actionable rows must have something to act on: see
|
||||||
|
-- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are
|
||||||
|
-- exempt because they are history, not work -- an applied folder is
|
||||||
|
-- fully tagged by definition and would otherwise vanish from the
|
||||||
|
-- sidebar's Completed section the instant it succeeded.
|
||||||
|
AND (
|
||||||
|
ti.status IN ('confirmed', 'skipped')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
||||||
LIMIT @row_limit OFFSET @row_offset;
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
@@ -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)
|
WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||||
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter)
|
||||||
AND ti.cleared_at IS NULL
|
AND ti.cleared_at IS NULL
|
||||||
|
-- See ListPendingTaggingItemsAlphabetical.
|
||||||
|
AND (
|
||||||
|
ti.status IN ('confirmed', 'skipped')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
||||||
LIMIT @row_limit OFFSET @row_offset;
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
@@ -204,61 +231,53 @@ ORDER BY ti.created_at DESC, ti.group_key
|
|||||||
LIMIT @row_limit OFFSET @row_offset;
|
LIMIT @row_limit OFFSET @row_offset;
|
||||||
|
|
||||||
-- name: ListAudioFilesInTaggingGroup :many
|
-- name: ListAudioFilesInTaggingGroup :many
|
||||||
-- album_name/album_artist are the PER-TRACK tags (via each track's
|
-- album_name/album_artist are the PER-TRACK tags (each file's own
|
||||||
-- own release_group link), not the folder-level tagging_items
|
-- album link), not the folder-level tagging_items values.
|
||||||
-- values. SplitMixedFolder clusters on these to find sub-albums
|
-- SplitMixedFolder clusters on these to find sub-albums hiding inside
|
||||||
-- hiding inside a folder full of unrelated tracks.
|
-- a folder full of unrelated tracks.
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
af.file_path,
|
af.file_path,
|
||||||
af.basename,
|
af.basename,
|
||||||
af.length_milliseconds,
|
af.length_milliseconds,
|
||||||
af.tag_status,
|
af.tag_status,
|
||||||
COALESCE(r.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(r.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS title,
|
af.title,
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
af.artist_credit AS artist_name,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(rg.name, '') AS album_name,
|
COALESCE(al.name, '') AS album_name,
|
||||||
COALESCE(rgac.text, '') AS album_artist
|
COALESCE(al.artist_credit, '') AS album_artist
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums al ON al.id = af.album_id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
|
|
||||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
|
||||||
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
|
|
||||||
WHERE af.group_key = ?
|
WHERE af.group_key = ?
|
||||||
ORDER BY COALESCE(r.disc_number, 0),
|
ORDER BY COALESCE(af.disc_number, 0),
|
||||||
COALESCE(r.track_number, 0),
|
COALESCE(af.track_number, 0),
|
||||||
af.file_path;
|
af.file_path;
|
||||||
|
|
||||||
-- name: ListLocalReleaseGroupCandidates :many
|
-- name: ListLocalAlbumCandidates :many
|
||||||
-- Returns one row per (release_group, track) combination for any
|
-- One row per (album, track) for any local album carrying an MBID.
|
||||||
-- local release_group that has an MBID. Callers group these in Go
|
-- Callers group these in Go and filter by normalized album-name match;
|
||||||
-- and filter by normalized album-name match. Joined case-insensitive
|
-- the join is case-insensitive on name to pre-filter cheaply.
|
||||||
-- on name to pre-filter cheaply; Go does the real normalization.
|
|
||||||
SELECT
|
SELECT
|
||||||
rg.id AS release_group_id,
|
al.id AS album_id,
|
||||||
rg.mbid AS release_group_mbid,
|
al.mbid AS album_mbid,
|
||||||
rg.name AS album_name,
|
al.name AS album_name,
|
||||||
COALESCE(rg.year, 0) AS year,
|
COALESCE(al.year, 0) AS year,
|
||||||
COALESCE(ac.text, '') AS artist_credit,
|
al.artist_credit,
|
||||||
COALESCE(rgr.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(rgr.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS track_title,
|
af.title AS track_title,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
|
af.length_milliseconds
|
||||||
FROM release_groups rg
|
FROM albums al
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = al.id
|
||||||
JOIN recordings r ON r.id = rgr.recording_id
|
WHERE al.mbid IS NOT NULL
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
AND al.mbid != ''
|
||||||
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
|
AND af.recording_mbid IS NOT NULL
|
||||||
WHERE rg.mbid IS NOT NULL
|
AND af.recording_mbid != ''
|
||||||
AND rg.mbid != ''
|
AND al.name = ? COLLATE NOCASE
|
||||||
AND r.mbid IS NOT NULL
|
ORDER BY al.id, af.disc_number, af.track_number;
|
||||||
AND r.mbid != ''
|
|
||||||
AND rg.name = ? COLLATE NOCASE
|
|
||||||
ORDER BY rg.id, rgr.disc_number, rgr.track_number;
|
|
||||||
|
|
||||||
-- name: SetTaggingItemBestMatch :exec
|
-- name: SetTaggingItemBestMatch :exec
|
||||||
UPDATE tagging_items
|
UPDATE tagging_items
|
||||||
@@ -284,17 +303,16 @@ WHERE group_key = ?;
|
|||||||
-- name: SetAudioFileTagStatus :exec
|
-- name: SetAudioFileTagStatus :exec
|
||||||
UPDATE audio_files SET tag_status = ? WHERE id = ?;
|
UPDATE audio_files SET tag_status = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetRecordingMBID :exec
|
-- name: SetFileRecordingMBID :exec
|
||||||
UPDATE recordings SET mbid = ? WHERE id = ?;
|
UPDATE audio_files SET recording_mbid = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: SetReleaseGroupMBID :exec
|
-- name: SetFileAlbumMBID :exec
|
||||||
UPDATE release_groups SET mbid = ? WHERE id = ?;
|
-- The album MBID for the album a file belongs to. Keyed by file
|
||||||
|
-- because that is what the autotag apply path holds; under the old
|
||||||
-- name: GetRecordingReleaseGroupID :one
|
-- schema it had to look the release group up through two join tables
|
||||||
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
|
-- first (GetRecordingReleaseGroupID), which is gone.
|
||||||
FROM release_group_recordings rgr
|
UPDATE albums SET mbid = ?
|
||||||
WHERE rgr.recording_id = ?
|
WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?);
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
-- name: GetNextPendingTaggingItem :one
|
-- name: GetNextPendingTaggingItem :one
|
||||||
SELECT
|
SELECT
|
||||||
@@ -315,5 +333,12 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE ti.status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id)
|
||||||
AND ti.group_key > @after_group_key
|
AND ti.group_key > @after_group_key
|
||||||
|
-- See CountPendingTaggingItems: the cursor must not stop on a
|
||||||
|
-- folder the list query no longer shows, or "next" walks folders
|
||||||
|
-- that are not in the sidebar.
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
ORDER BY ti.group_key
|
ORDER BY ti.group_key
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- One row per album in the library.
|
||||||
|
--
|
||||||
|
-- This is `release_groups` renamed, and the rename is the point: a
|
||||||
|
-- release group is a *MusicBrainz* concept and the catalog still has
|
||||||
|
-- them (`explore_index.entity_type = 'release_group'`). What this
|
||||||
|
-- table holds is the local thing — the album some files on disk belong
|
||||||
|
-- to — which may or may not have a catalog counterpart. Calling both
|
||||||
|
-- of them "release group" is most of why "is this album mine" was a
|
||||||
|
-- question three different subsystems answered three different ways.
|
||||||
|
--
|
||||||
|
-- `artist_credit` is the album artist as tagged ("Various Artists",
|
||||||
|
-- "A & B"); `artist_id` is the primary artist it resolves to. Album
|
||||||
|
-- identity is (name, artist_credit), which is what the old
|
||||||
|
-- UNIQUE(name, album_artist_credit_id) meant with a join in the way.
|
||||||
|
CREATE TABLE IF NOT EXISTS albums (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
artist_credit TEXT NOT NULL DEFAULT '',
|
||||||
|
artist_id INTEGER,
|
||||||
|
mbid TEXT,
|
||||||
|
-- year is the tagged year of the copy on disk; original_year is
|
||||||
|
-- MusicBrainz's first-release date when known. For a 2010 remaster
|
||||||
|
-- of a 1973 album: original_year 1973, year 2010.
|
||||||
|
year INTEGER,
|
||||||
|
original_year INTEGER,
|
||||||
|
cover_art_id INTEGER,
|
||||||
|
-- Set when the files carried a release MBID but no release-group
|
||||||
|
-- MBID; a background pass resolves it and clears this.
|
||||||
|
pending_release_mbid TEXT,
|
||||||
|
|
||||||
|
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
|
||||||
|
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
||||||
|
UNIQUE(name, artist_credit)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_albums_artist_id
|
||||||
|
ON albums(artist_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_albums_cover_art_id
|
||||||
|
ON albums(cover_art_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_albums_mbid
|
||||||
|
ON albums(mbid) WHERE mbid IS NOT NULL;
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS artist_credit (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
text TEXT NOT NULL UNIQUE
|
|
||||||
);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS artist_credit_artist (
|
|
||||||
id integer PRIMARY KEY,
|
|
||||||
artist_id int NOT NULL,
|
|
||||||
credit_id int NOT NULL,
|
|
||||||
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
|
||||||
FOREIGN KEY(credit_id) REFERENCES artist_credit(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_artist_id
|
|
||||||
ON artist_credit_artist(artist_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artist_credit_artist_credit_id
|
|
||||||
ON artist_credit_artist(credit_id);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_credit_artist_unique
|
|
||||||
ON artist_credit_artist(artist_id, credit_id);
|
|
||||||
@@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images (
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artist_images_mbid
|
-- No index on artist_mbid alone: the UNIQUE index below has it as its
|
||||||
ON artist_images(artist_mbid);
|
-- leftmost column.
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source
|
||||||
ON artist_images(artist_mbid, source, source_url);
|
ON artist_images(artist_mbid, source, source_url);
|
||||||
|
|||||||
@@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata (
|
|||||||
PRIMARY KEY (mbid, source)
|
PRIMARY KEY (mbid, source)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid);
|
-- No index on mbid alone: PRIMARY KEY (mbid, source) already has it as
|
||||||
|
-- its leftmost column, so a second one costs a write per row and serves
|
||||||
|
-- no read.
|
||||||
|
|||||||
@@ -1,34 +1,92 @@
|
|||||||
|
-- One row per audio file, and the file's tags live on it.
|
||||||
|
--
|
||||||
|
-- This table used to be a stub — path, format, a foreign key — with
|
||||||
|
-- every tag-derived field one join away in `recordings`, which was in
|
||||||
|
-- turn linked to an album through `release_group_recordings` and to an
|
||||||
|
-- artist through `artist_credit` + `artist_credit_artist`. That is
|
||||||
|
-- MusicBrainz's data model, and it is the right model for MusicBrainz:
|
||||||
|
-- a recording really can appear on many releases and a credit really
|
||||||
|
-- can list many artists.
|
||||||
|
--
|
||||||
|
-- It was the wrong model here, and the library said so. Measured on a
|
||||||
|
-- real 25,966-file library: **no** recording had more than one file,
|
||||||
|
-- **no** recording belonged to more than one release group, and 3 of
|
||||||
|
-- 2,823 credits listed more than one artist. Every many-to-many the
|
||||||
|
-- schema modelled was 1:1 in the data, and the cost of modelling it
|
||||||
|
-- anyway was a six-way join in every read, a `MIN(release_group_id)`
|
||||||
|
-- subquery in eleven queries to collapse a fan-out that never happened,
|
||||||
|
-- a first-credited-artist subquery in nine more to collapse the other
|
||||||
|
-- one, and — the reason this changed — a whole class of bugs where a
|
||||||
|
-- `recordings` row **outlived the file that created it**. Retagging a
|
||||||
|
-- file created a new recording and abandoned the old one, so the same
|
||||||
|
-- library carried 812 recordings, 216 release groups and 260 artists
|
||||||
|
-- with no file behind them, and everything that asked "do I own this"
|
||||||
|
-- by looking for a metadata row got 129 confident yeses for tracks
|
||||||
|
-- that could not be played.
|
||||||
|
--
|
||||||
|
-- With the tags on the file, ownership is not a rule anyone can forget:
|
||||||
|
-- the row *is* the file.
|
||||||
CREATE TABLE IF NOT EXISTS audio_files (
|
CREATE TABLE IF NOT EXISTS audio_files (
|
||||||
id integer PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
file_path text NOT NULL UNIQUE,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
length_milliseconds int NOT NULL,
|
library_id INTEGER NOT NULL DEFAULT 0,
|
||||||
file_type_id int NOT NULL,
|
file_type_id INTEGER NOT NULL,
|
||||||
recording_id int NOT NULL,
|
|
||||||
sample_rate int NOT NULL DEFAULT 0,
|
-- Audio properties, read from the file itself.
|
||||||
bit_depth int NOT NULL DEFAULT 0,
|
length_milliseconds INTEGER NOT NULL,
|
||||||
channels int NOT NULL DEFAULT 0,
|
sample_rate INTEGER NOT NULL DEFAULT 0,
|
||||||
bitrate int NOT NULL DEFAULT 0,
|
bit_depth INTEGER NOT NULL DEFAULT 0,
|
||||||
file_size int NOT NULL DEFAULT 0,
|
channels INTEGER NOT NULL DEFAULT 0,
|
||||||
basename text NOT NULL DEFAULT '',
|
bitrate INTEGER NOT NULL DEFAULT 0,
|
||||||
library_id int NOT NULL DEFAULT 0,
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
play_count int NOT NULL DEFAULT 0,
|
|
||||||
last_played datetime,
|
-- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is
|
||||||
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
-- for display; `artist_id` is the primary artist it resolves to, and
|
||||||
|
-- is what grouping, browsing and the artist page use. Keeping both
|
||||||
|
-- is what makes the credit table unnecessary: the string is the only
|
||||||
|
-- thing that was ever read off it.
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
artist_credit TEXT NOT NULL DEFAULT '',
|
||||||
|
artist_id INTEGER,
|
||||||
|
album_id INTEGER,
|
||||||
|
track_number INTEGER,
|
||||||
|
disc_number INTEGER,
|
||||||
|
-- The denominator the tag declared: the 12 in "5/12", per disc. It
|
||||||
|
-- is what lets "do I have all of this album" be answered from disk
|
||||||
|
-- instead of from MusicBrainz. NULL means the tag did not say, which
|
||||||
|
-- is a third state and not the same as zero.
|
||||||
|
total_tracks INTEGER,
|
||||||
|
year INTEGER,
|
||||||
|
composer TEXT NOT NULL DEFAULT '',
|
||||||
|
comment TEXT NOT NULL DEFAULT '',
|
||||||
|
recording_mbid TEXT,
|
||||||
|
|
||||||
|
-- Library bookkeeping.
|
||||||
|
basename TEXT NOT NULL DEFAULT '',
|
||||||
|
group_key TEXT NOT NULL DEFAULT '',
|
||||||
|
-- File mtime as a Unix timestamp in seconds, captured at import and
|
||||||
|
-- compared against the on-disk mtime during a scan to detect files
|
||||||
|
-- another application retagged in place.
|
||||||
|
modified_at INTEGER NOT NULL DEFAULT 0,
|
||||||
|
play_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_played DATETIME,
|
||||||
|
tag_status TEXT NOT NULL DEFAULT 'untagged'
|
||||||
CHECK(tag_status IN (
|
CHECK(tag_status IN (
|
||||||
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent'
|
||||||
)),
|
)),
|
||||||
group_key TEXT NOT NULL DEFAULT '',
|
|
||||||
-- File mtime as a Unix timestamp in seconds, captured at import.
|
|
||||||
-- Compared against the on-disk mtime during a scan to detect files
|
|
||||||
-- another application retagged in place. 0 means "never recorded"
|
|
||||||
-- (rows predating migration 47) and is treated as not-stale so an
|
|
||||||
-- upgrade does not re-import the whole library.
|
|
||||||
modified_at int NOT NULL DEFAULT 0,
|
|
||||||
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
|
FOREIGN KEY(file_type_id) REFERENCES file_types(id),
|
||||||
FOREIGN KEY(recording_id) REFERENCES recordings(id),
|
FOREIGN KEY(library_id) REFERENCES libraries(id),
|
||||||
FOREIGN KEY(library_id) REFERENCES libraries(id)
|
FOREIGN KEY(artist_id) REFERENCES artists(id),
|
||||||
|
FOREIGN KEY(album_id) REFERENCES albums(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_files_album_id
|
||||||
|
ON audio_files(album_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_files_artist_id
|
||||||
|
ON audio_files(artist_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
|
CREATE INDEX IF NOT EXISTS idx_audio_files_basename
|
||||||
ON audio_files(basename);
|
ON audio_files(basename);
|
||||||
|
|
||||||
@@ -36,10 +94,17 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_group_key
|
|||||||
ON audio_files(group_key) WHERE group_key != '';
|
ON audio_files(group_key) WHERE group_key != '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
|
CREATE INDEX IF NOT EXISTS idx_audio_files_library_id
|
||||||
ON audio_files(library_id);
|
ON audio_files(library_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id
|
-- The ownership question, asked by MBID: "is there a *file* with this
|
||||||
ON audio_files(recording_id);
|
-- recording MBID". Nothing may answer it from a metadata table again.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_mbid
|
||||||
|
ON audio_files(recording_mbid) WHERE recording_mbid IS NOT NULL;
|
||||||
|
|
||||||
|
-- Answers "does this tagging group still contain untagged files" in one
|
||||||
|
-- seek per group. The autotag queue asks it once per row.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audio_files_untagged_group_key
|
||||||
|
ON audio_files(group_key) WHERE tag_status = 'untagged';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
|
CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged
|
||||||
ON audio_files(library_id) WHERE tag_status = 'untagged';
|
ON audio_files(library_id) WHERE tag_status = 'untagged';
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
|
-- The downloaded MusicBrainz/ListenBrainz catalog.
|
||||||
|
--
|
||||||
|
-- MusicBrainz ids are stored as their 16 raw bytes and entity types as
|
||||||
|
-- small integers, which is a size decision: on a real 2,052,200-row
|
||||||
|
-- catalog those four columns were 220 MB of a 383 MB table and were
|
||||||
|
-- carried again in every index keyed on them, and the conversion took
|
||||||
|
-- the table and its four indexes from 677 MB to 389 MB. See
|
||||||
|
-- backend/explore/mbid.go, which is the only place that encoding is
|
||||||
|
-- known -- everything above it speaks dashed strings and entity names.
|
||||||
|
--
|
||||||
|
-- The CHECK constraints are what make a mistake loud. SQLite does not
|
||||||
|
-- coerce between TEXT and BLOB, so a query comparing this column
|
||||||
|
-- against a 36-character string returns no rows rather than an error;
|
||||||
|
-- a *write* of one fails here instead, at the insert that made it.
|
||||||
CREATE TABLE IF NOT EXISTS explore_index (
|
CREATE TABLE IF NOT EXISTS explore_index (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
entity_type TEXT NOT NULL,
|
entity_type INTEGER NOT NULL,
|
||||||
mbid TEXT NOT NULL,
|
mbid BLOB NOT NULL CHECK(length(mbid) = 16),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
artist_name TEXT NOT NULL,
|
artist_name TEXT NOT NULL,
|
||||||
artist_mbid TEXT NOT NULL,
|
artist_mbid BLOB NOT NULL
|
||||||
|
CHECK(length(artist_mbid) IN (0, 16)),
|
||||||
aliases TEXT NOT NULL DEFAULT '',
|
aliases TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
-- Popularity signals, derived from the ListenBrainz listens dump.
|
-- Popularity signals, derived from the ListenBrainz listens dump.
|
||||||
@@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
|||||||
|
|
||||||
-- Recording-specific fields.
|
-- Recording-specific fields.
|
||||||
duration INTEGER NOT NULL DEFAULT 0,
|
duration INTEGER NOT NULL DEFAULT 0,
|
||||||
caa_release_mbid TEXT NOT NULL DEFAULT '',
|
caa_release_mbid BLOB NOT NULL DEFAULT x''
|
||||||
|
CHECK(length(caa_release_mbid) IN (0, 16)),
|
||||||
release_name TEXT NOT NULL DEFAULT '',
|
release_name TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
-- Release-group-specific fields.
|
-- Release-group-specific fields.
|
||||||
@@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
|||||||
secondary_types TEXT NOT NULL DEFAULT '',
|
secondary_types TEXT NOT NULL DEFAULT '',
|
||||||
release_date TEXT NOT NULL DEFAULT '',
|
release_date TEXT NOT NULL DEFAULT '',
|
||||||
|
|
||||||
|
-- How many tracks the release group's canonical release has, so
|
||||||
|
-- "do I have all of this" is answerable offline for an album the
|
||||||
|
-- library holds no tags for. Zero means the catalog does not say,
|
||||||
|
-- which is the same third state the local answer has -- and is what
|
||||||
|
-- every row carries until a central dump build fills it.
|
||||||
|
total_tracks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
-- Artist-specific fields.
|
-- Artist-specific fields.
|
||||||
artist_type TEXT NOT NULL DEFAULT '',
|
artist_type TEXT NOT NULL DEFAULT '',
|
||||||
country TEXT NOT NULL DEFAULT '',
|
country TEXT NOT NULL DEFAULT '',
|
||||||
@@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index (
|
|||||||
UNIQUE(mbid)
|
UNIQUE(mbid)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- The exact-match tier's two indexes.
|
||||||
|
--
|
||||||
|
-- Their predicate is the champion set - the popular rows plus whatever
|
||||||
|
-- the user owns - and matching it to `ExactMatches`' own WHERE clause is
|
||||||
|
-- what makes them small. They used to say `popularity > 0`, which on a
|
||||||
|
-- real 2,052,200-row catalog covered 2,046,645 of them: a full index
|
||||||
|
-- wearing a partial index's clothes, 101 MB for the pair. Narrowed to
|
||||||
|
-- the set the tier can actually return, they are 3 MB and the query
|
||||||
|
-- plan is unchanged (measured, on that catalog).
|
||||||
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
|
CREATE INDEX IF NOT EXISTS idx_explore_artist_lower
|
||||||
ON explore_index(LOWER(artist_name))
|
ON explore_index(LOWER(artist_name))
|
||||||
WHERE popularity > 0;
|
WHERE popularity >= 10000 OR in_library = 1;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
|
CREATE INDEX IF NOT EXISTS idx_explore_caa_release
|
||||||
ON explore_index(caa_release_mbid)
|
ON explore_index(caa_release_mbid)
|
||||||
WHERE entity_type = 'release_group' AND caa_release_mbid != '';
|
WHERE entity_type = 2 AND caa_release_mbid != x'';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
|
CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid
|
||||||
ON explore_index(artist_mbid, entity_type, popularity DESC);
|
ON explore_index(artist_mbid, entity_type, popularity DESC);
|
||||||
@@ -62,4 +94,4 @@ CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
|
CREATE INDEX IF NOT EXISTS idx_explore_title_lower
|
||||||
ON explore_index(LOWER(title))
|
ON explore_index(LOWER(title))
|
||||||
WHERE popularity > 0;
|
WHERE popularity >= 10000 OR in_library = 1;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Genres per file. This is `recording_genres` with the recording taken
|
||||||
|
-- out of the middle: it is the one many-to-many in the local library
|
||||||
|
-- that is actually many-to-many (a real library runs about four genre
|
||||||
|
-- rows per file), which is why it stays a join table when the others
|
||||||
|
-- did not.
|
||||||
|
CREATE TABLE IF NOT EXISTS file_genres (
|
||||||
|
audio_file_id INTEGER NOT NULL,
|
||||||
|
genre_id INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (audio_file_id, genre_id),
|
||||||
|
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(genre_id) REFERENCES genres(id)
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
|
||||||
|
-- The reverse direction ("which files are in this genre"). The
|
||||||
|
-- forward direction is served by the primary key, so — unlike the
|
||||||
|
-- table this replaces — there is no third index restating it.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_file_genres_genre_id
|
||||||
|
ON file_genres(genre_id);
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
-- One row per "go find me this", from the moment the user asks until
|
-- One row per folder the user has added as a music library.
|
||||||
-- the files are in the library or the attempt is abandoned.
|
|
||||||
--
|
--
|
||||||
-- release_mbid / release_group_mbid are the anchor: a request that
|
-- Everything else keyed by library_id means "which of these folders did
|
||||||
-- carries one can be matched against a known tracklist at import time,
|
-- this come from"; a library_id of 0 in a query means "all of them".
|
||||||
-- which is what makes unattended completion safe. Free-text requests
|
|
||||||
-- (both NULL) are always presented to the user for confirmation.
|
|
||||||
--
|
--
|
||||||
-- `expected` caches the anchor's tracklist as JSON so ranking and
|
-- autotag_warning_acked records that the user has been told what
|
||||||
-- import do not have to re-resolve it, and so a request survives the
|
-- autotagging will do to the files in this folder, which is a decision
|
||||||
-- explore index being rebuilt underneath it.
|
-- they made and not something a rescan can rediscover.
|
||||||
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS libraries (
|
CREATE TABLE IF NOT EXISTS libraries (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Lyrics for a file, and where they came from.
|
||||||
|
--
|
||||||
|
-- These used to be a column on `recordings`, in a table classified
|
||||||
|
-- `Owned` — data a rescan can rebuild from the files. That was true of
|
||||||
|
-- lyrics read out of a USLT frame and false of lyrics fetched from
|
||||||
|
-- LRCLIB, and nothing recorded which was which, so a library with
|
||||||
|
-- 24,294 of them could not answer how many were free to rebuild and how
|
||||||
|
-- many were network traffic waiting to happen. `source` answers it.
|
||||||
|
--
|
||||||
|
-- `recording_mbid` is carried alongside the file id so a future
|
||||||
|
-- re-import can re-adopt fetched lyrics without asking LRCLIB again;
|
||||||
|
-- the file id is the key because untagged files have no MBID and are
|
||||||
|
-- exactly the ones whose lyrics had to be fetched.
|
||||||
|
CREATE TABLE IF NOT EXISTS lyrics (
|
||||||
|
audio_file_id INTEGER PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'tag'
|
||||||
|
CHECK(source IN ('tag', 'lrclib')),
|
||||||
|
recording_mbid TEXT,
|
||||||
|
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lyrics_recording_mbid
|
||||||
|
ON lyrics(recording_mbid) WHERE recording_mbid IS NOT NULL;
|
||||||
@@ -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 (
|
CREATE TABLE IF NOT EXISTS release_to_rg (
|
||||||
release_mbid TEXT PRIMARY KEY,
|
release_mbid TEXT PRIMARY KEY,
|
||||||
rg_mbid TEXT NOT NULL
|
rg_mbid TEXT NOT NULL
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ CREATE TABLE IF NOT EXISTS similar_artist_map (
|
|||||||
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
|
PRIMARY KEY (source_artist_mbid, similar_artist_mbid)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source
|
-- No index on source_artist_mbid alone: the PRIMARY KEY has it as its
|
||||||
ON similar_artist_map(source_artist_mbid);
|
-- leftmost column.
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
CREATE VIEW IF NOT EXISTS track_metadata AS
|
-- The one definition of "a track, with everything a list needs".
|
||||||
|
--
|
||||||
|
-- A view is a definition, not data, so it is dropped and recreated on
|
||||||
|
-- every open rather than carrying a migration alongside it: CREATE VIEW
|
||||||
|
-- IF NOT EXISTS silently keeps an older database on the old definition,
|
||||||
|
-- and a migration file restating it would be the second description of
|
||||||
|
-- the schema the migration rules exist to prevent.
|
||||||
|
--
|
||||||
|
-- This projection used to exist **nine times** — four copies in
|
||||||
|
-- audio_files.sql, two in playlists.sql, two in genres.sql, one in
|
||||||
|
-- queue.sql — plus this view, which only the raw-SQL search paths used.
|
||||||
|
-- They had already drifted: this view preferred the album's
|
||||||
|
-- original_year for `year` and GetAllTracksWithFullMetadata used the
|
||||||
|
-- track's own, so the same library reported different years on
|
||||||
|
-- different screens. Every query that wants a track row now selects
|
||||||
|
-- from here, which is also why there is one row type and one mapper on
|
||||||
|
-- the Go side instead of nine and a twenty-two-argument function.
|
||||||
|
DROP VIEW IF EXISTS track_metadata;
|
||||||
|
|
||||||
|
CREATE VIEW track_metadata AS
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
af.file_path,
|
af.file_path,
|
||||||
af.length_milliseconds,
|
af.length_milliseconds,
|
||||||
COALESCE(r.name, '') AS title,
|
af.title,
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
af.artist_credit AS artist_name,
|
||||||
r.track_number,
|
af.track_number,
|
||||||
r.disc_number,
|
af.disc_number,
|
||||||
COALESCE(rg.name, '') AS album,
|
COALESCE(al.name, '') AS album,
|
||||||
CAST(COALESCE(
|
CAST(COALESCE(
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
(SELECT GROUP_CONCAT(g.name, '||')
|
||||||
FROM recording_genres rg_sub
|
FROM file_genres fg
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
WHERE rg_sub.recording_id = r.id),
|
WHERE fg.audio_file_id = af.id),
|
||||||
''
|
''
|
||||||
) AS TEXT) AS genre,
|
) AS TEXT) AS genre,
|
||||||
COALESCE(rg.original_year, rg.year, r.year, 0) AS year,
|
COALESCE(al.original_year, al.year, af.year, 0) AS year,
|
||||||
COALESCE(rg.year, r.year, 0) AS release_year,
|
COALESCE(al.year, af.year, 0) AS release_year,
|
||||||
COALESCE(r.composer, '') AS composer,
|
af.composer,
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
COALESCE(ft.extension, '') AS file_type,
|
||||||
af.sample_rate,
|
af.sample_rate,
|
||||||
af.bit_depth,
|
af.bit_depth,
|
||||||
@@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS
|
|||||||
af.play_count,
|
af.play_count,
|
||||||
af.last_played,
|
af.last_played,
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
COALESCE(ca.file_path, '') AS cover_art_path,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
COALESCE(ar.mbid, '') AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(al.mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
|
af.album_id,
|
||||||
|
af.artist_id
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums al ON al.id = af.album_id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
LEFT JOIN artists ar ON ar.id = af.artist_id
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
LEFT JOIN file_types ft ON ft.id = af.file_type_id;
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
|
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.30.0
|
||||||
|
// source: albums.sql
|
||||||
|
|
||||||
|
package sqlcgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteAlbum = `-- name: DeleteAlbum :exec
|
||||||
|
DELETE FROM albums WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAlbum(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteAlbum, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAllAlbums = `-- name: DeleteAllAlbums :exec
|
||||||
|
DELETE FROM albums
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteAllAlbums(ctx context.Context) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteAllAlbums)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlbum = `-- name: GetAlbum :one
|
||||||
|
SELECT id, name, artist_credit, artist_id, mbid, year, original_year, cover_art_id, pending_release_mbid FROM albums WHERE id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetAlbum(ctx context.Context, id int64) (Album, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getAlbum, id)
|
||||||
|
var i Album
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ArtistCredit,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.Year,
|
||||||
|
&i.OriginalYear,
|
||||||
|
&i.CoverArtID,
|
||||||
|
&i.PendingReleaseMbid,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlbumCompleteness = `-- name: GetAlbumCompleteness :one
|
||||||
|
SELECT
|
||||||
|
-- Distinct (disc, track) pairs: this app detects duplicates, and
|
||||||
|
-- counting two files of track 3 twice would report a short album as
|
||||||
|
-- complete. A file with no track number falls back to its own id,
|
||||||
|
-- because three untagged files are three tracks, not one.
|
||||||
|
CAST(COUNT(DISTINCT CAST(COALESCE(a.disc_number, 1) AS TEXT) || ':' ||
|
||||||
|
COALESCE(CAST(a.track_number AS TEXT), 'f' || a.id)
|
||||||
|
) AS INTEGER) AS owned,
|
||||||
|
CAST(COALESCE((
|
||||||
|
SELECT SUM(per_disc.total)
|
||||||
|
FROM (
|
||||||
|
SELECT MAX(b.total_tracks) AS total
|
||||||
|
FROM audio_files b
|
||||||
|
WHERE b.album_id = ?1 AND b.total_tracks IS NOT NULL
|
||||||
|
GROUP BY COALESCE(b.disc_number, 1)
|
||||||
|
) per_disc
|
||||||
|
), 0) AS INTEGER) AS expected,
|
||||||
|
CAST((
|
||||||
|
SELECT COUNT(*) = 0 FROM audio_files c
|
||||||
|
WHERE c.album_id = ?1 AND c.total_tracks IS NULL
|
||||||
|
) AS INTEGER) AS known
|
||||||
|
FROM audio_files a
|
||||||
|
WHERE a.album_id = ?1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumCompletenessRow struct {
|
||||||
|
Owned int64
|
||||||
|
Expected int64
|
||||||
|
Known int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Do I have all of this album", answered from the tags on disk.
|
||||||
|
//
|
||||||
|
// The expectation is a **sum over discs**, not one number: totals are
|
||||||
|
// declared per disc ("5/12" on disc 2 means 12 tracks on disc 2), so a
|
||||||
|
// multi-disc album's expectation is the sum of each disc's declared
|
||||||
|
// total. A disc whose files declared nothing leaves the whole album
|
||||||
|
// unknowable rather than being covered by the discs that did -- which is
|
||||||
|
// what `known` reports.
|
||||||
|
//
|
||||||
|
// Owned counts DISTINCT track numbers: this app detects duplicates, and
|
||||||
|
// counting two files of track 3 twice would report a short album as
|
||||||
|
// complete.
|
||||||
|
func (q *Queries) GetAlbumCompleteness(ctx context.Context, albumID sql.NullInt64) (GetAlbumCompletenessRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getAlbumCompleteness, albumID)
|
||||||
|
var i GetAlbumCompletenessRow
|
||||||
|
err := row.Scan(&i.Owned, &i.Expected, &i.Known)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlbums = `-- name: GetAlbums :many
|
||||||
|
SELECT
|
||||||
|
al.id,
|
||||||
|
al.name,
|
||||||
|
COALESCE(al.original_year, al.year) AS year,
|
||||||
|
COALESCE(al.year, 0) AS release_year,
|
||||||
|
al.mbid,
|
||||||
|
al.artist_credit AS artist_name,
|
||||||
|
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||||
|
COALESCE(ca.file_path, '') AS cover_art_path
|
||||||
|
FROM albums al
|
||||||
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||||
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.album_id = al.id
|
||||||
|
AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
|
||||||
|
)
|
||||||
|
ORDER BY al.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumsRow struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Year sql.NullInt64
|
||||||
|
ReleaseYear int64
|
||||||
|
Mbid sql.NullString
|
||||||
|
ArtistName string
|
||||||
|
ArtistMbid string
|
||||||
|
CoverArtPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlbums(ctx context.Context, libraryID int64) ([]GetAlbumsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAlbums, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetAlbumsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetAlbumsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Year,
|
||||||
|
&i.ReleaseYear,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.ArtistMbid,
|
||||||
|
&i.CoverArtPath,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlbumsByArtistName = `-- name: GetAlbumsByArtistName :many
|
||||||
|
SELECT
|
||||||
|
al.id,
|
||||||
|
al.name,
|
||||||
|
COALESCE(al.original_year, al.year) AS year,
|
||||||
|
COALESCE(al.year, 0) AS release_year,
|
||||||
|
al.mbid,
|
||||||
|
al.artist_credit AS artist_name,
|
||||||
|
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||||
|
COALESCE(ca.file_path, '') AS cover_art_path
|
||||||
|
FROM albums al
|
||||||
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||||
|
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||||
|
WHERE (al.artist_credit = ?1 OR ar.name = ?1)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.album_id = al.id
|
||||||
|
AND af.library_id = COALESCE(NULLIF(CAST(?2 AS INTEGER), 0), af.library_id)
|
||||||
|
)
|
||||||
|
ORDER BY year, al.name
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumsByArtistNameParams struct {
|
||||||
|
Artist string
|
||||||
|
LibraryID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetAlbumsByArtistNameRow struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Year sql.NullInt64
|
||||||
|
ReleaseYear int64
|
||||||
|
Mbid sql.NullString
|
||||||
|
ArtistName string
|
||||||
|
ArtistMbid string
|
||||||
|
CoverArtPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlbumsByArtistName(ctx context.Context, arg GetAlbumsByArtistNameParams) ([]GetAlbumsByArtistNameRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAlbumsByArtistName, arg.Artist, arg.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetAlbumsByArtistNameRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetAlbumsByArtistNameRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Year,
|
||||||
|
&i.ReleaseYear,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.ArtistMbid,
|
||||||
|
&i.CoverArtPath,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAlbumsWithPendingReleaseMBID = `-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||||
|
SELECT id, pending_release_mbid FROM albums
|
||||||
|
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||||
|
AND (mbid IS NULL OR mbid = '')
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetAlbumsWithPendingReleaseMBIDRow struct {
|
||||||
|
ID int64
|
||||||
|
PendingReleaseMbid sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetAlbumsWithPendingReleaseMBID(ctx context.Context) ([]GetAlbumsWithPendingReleaseMBIDRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getAlbumsWithPendingReleaseMBID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []GetAlbumsWithPendingReleaseMBIDRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetAlbumsWithPendingReleaseMBIDRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.PendingReleaseMbid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmptyAlbumIDs = `-- name: GetEmptyAlbumIDs :many
|
||||||
|
SELECT id FROM albums al
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Albums with no file left behind them. Under the old schema this was
|
||||||
|
// one of three orphan sweeps that had to run by hand and did not;
|
||||||
|
// audio_files is the only thing that can leave an album empty now, so
|
||||||
|
// this is the whole of it.
|
||||||
|
func (q *Queries) GetEmptyAlbumIDs(ctx context.Context) ([]int64, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, getEmptyAlbumIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, id)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAlbumPendingReleaseMBID = `-- name: ResolveAlbumPendingReleaseMBID :exec
|
||||||
|
UPDATE albums
|
||||||
|
SET mbid = ?, pending_release_mbid = NULL
|
||||||
|
WHERE id = ? AND (mbid IS NULL OR mbid = '')
|
||||||
|
`
|
||||||
|
|
||||||
|
type ResolveAlbumPendingReleaseMBIDParams struct {
|
||||||
|
Mbid sql.NullString
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears the pending marker once the release-group MBID it stood in for
|
||||||
|
// has been resolved. Guarded so a real MBID is never overwritten.
|
||||||
|
func (q *Queries) ResolveAlbumPendingReleaseMBID(ctx context.Context, arg ResolveAlbumPendingReleaseMBIDParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, resolveAlbumPendingReleaseMBID, arg.Mbid, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlbumCoverArt = `-- name: SetAlbumCoverArt :exec
|
||||||
|
UPDATE albums SET cover_art_id = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlbumCoverArtParams struct {
|
||||||
|
CoverArtID sql.NullInt64
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlbumCoverArt(ctx context.Context, arg SetAlbumCoverArtParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setAlbumCoverArt, arg.CoverArtID, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlbumMBID = `-- name: SetAlbumMBID :exec
|
||||||
|
UPDATE albums SET mbid = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlbumMBIDParams struct {
|
||||||
|
Mbid sql.NullString
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlbumMBID(ctx context.Context, arg SetAlbumMBIDParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setAlbumMBID, arg.Mbid, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlbumOriginalYear = `-- name: SetAlbumOriginalYear :exec
|
||||||
|
UPDATE albums SET original_year = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlbumOriginalYearParams struct {
|
||||||
|
OriginalYear sql.NullInt64
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlbumOriginalYear(ctx context.Context, arg SetAlbumOriginalYearParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setAlbumOriginalYear, arg.OriginalYear, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAlbumPendingReleaseMBID = `-- name: SetAlbumPendingReleaseMBID :exec
|
||||||
|
UPDATE albums SET pending_release_mbid = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetAlbumPendingReleaseMBIDParams struct {
|
||||||
|
PendingReleaseMbid sql.NullString
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetAlbumPendingReleaseMBID(ctx context.Context, arg SetAlbumPendingReleaseMBIDParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setAlbumPendingReleaseMBID, arg.PendingReleaseMbid, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertAlbum = `-- name: UpsertAlbum :one
|
||||||
|
|
||||||
|
INSERT INTO albums (name, artist_credit, artist_id, year, cover_art_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(name, artist_credit) DO UPDATE SET
|
||||||
|
artist_id = COALESCE(excluded.artist_id, albums.artist_id),
|
||||||
|
year = COALESCE(excluded.year, albums.year),
|
||||||
|
cover_art_id = COALESCE(excluded.cover_art_id, albums.cover_art_id)
|
||||||
|
RETURNING id, name, artist_credit, artist_id, mbid, year, original_year, cover_art_id, pending_release_mbid
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertAlbumParams struct {
|
||||||
|
Name string
|
||||||
|
ArtistCredit string
|
||||||
|
ArtistID sql.NullInt64
|
||||||
|
Year sql.NullInt64
|
||||||
|
CoverArtID sql.NullInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries over albums (formerly release_groups).
|
||||||
|
//
|
||||||
|
// The two-copy pattern is gone here too: one query answers both the
|
||||||
|
// whole-library and the single-library case. The `fallback_ac`
|
||||||
|
// subquery every album read used to carry -- "if the album has no album
|
||||||
|
// artist credit, borrow one from any of its recordings" -- is gone with
|
||||||
|
// it, because the album carries its own credit text now.
|
||||||
|
func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertAlbum,
|
||||||
|
arg.Name,
|
||||||
|
arg.ArtistCredit,
|
||||||
|
arg.ArtistID,
|
||||||
|
arg.Year,
|
||||||
|
arg.CoverArtID,
|
||||||
|
)
|
||||||
|
var i Album
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.ArtistCredit,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.Year,
|
||||||
|
&i.OriginalYear,
|
||||||
|
&i.CoverArtID,
|
||||||
|
&i.PendingReleaseMbid,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.30.0
|
|
||||||
// source: artist_credit.sql
|
|
||||||
|
|
||||||
package sqlcgen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
const countArtistCreditReferences = `-- name: CountArtistCreditReferences :one
|
|
||||||
SELECT
|
|
||||||
(SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) +
|
|
||||||
(SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1)
|
|
||||||
AS total
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CountArtistCreditReferences(ctx context.Context, artistCreditID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, countArtistCreditReferences, artistCreditID)
|
|
||||||
var total int64
|
|
||||||
err := row.Scan(&total)
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createArtistCredit = `-- name: CreateArtistCredit :one
|
|
||||||
INSERT INTO artist_credit (text) VALUES (?)
|
|
||||||
RETURNING id, text
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createArtistCredit, text)
|
|
||||||
var i ArtistCredit
|
|
||||||
err := row.Scan(&i.ID, &i.Text)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllArtistCredits = `-- name: DeleteAllArtistCredits :exec
|
|
||||||
DELETE FROM artist_credit
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteAllArtistCredits(ctx context.Context) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllArtistCredits)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
|
|
||||||
DELETE FROM artist_credit
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteArtistCredit(ctx context.Context, id int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteArtistCredit, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getArtistCredit = `-- name: GetArtistCredit :one
|
|
||||||
SELECT id, text FROM artist_credit
|
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetArtistCredit(ctx context.Context, id int64) (ArtistCredit, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getArtistCredit, id)
|
|
||||||
var i ArtistCredit
|
|
||||||
err := row.Scan(&i.ID, &i.Text)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getArtistCreditByText = `-- name: GetArtistCreditByText :one
|
|
||||||
SELECT id, text FROM artist_credit
|
|
||||||
WHERE text = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetArtistCreditByText(ctx context.Context, text string) (ArtistCredit, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getArtistCreditByText, text)
|
|
||||||
var i ArtistCredit
|
|
||||||
err := row.Scan(&i.ID, &i.Text)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOrphanedArtistCreditIDs = `-- name: GetOrphanedArtistCreditIDs :many
|
|
||||||
SELECT ac.id FROM artist_credit ac
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM recordings r WHERE r.artist_credit_id = ac.id)
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM release_groups rg WHERE rg.album_artist_credit_id = ac.id)
|
|
||||||
`
|
|
||||||
|
|
||||||
// Artist credits no longer used by any recording or release group - run
|
|
||||||
// after orphaned recordings/release groups are deleted, so a credit
|
|
||||||
// that only existed for now-removed tracks is cleaned up too.
|
|
||||||
func (q *Queries) GetOrphanedArtistCreditIDs(ctx context.Context) ([]int64, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getOrphanedArtistCreditIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []int64
|
|
||||||
for rows.Next() {
|
|
||||||
var id int64
|
|
||||||
if err := rows.Scan(&id); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, id)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateArtistCredit = `-- name: UpdateArtistCredit :exec
|
|
||||||
UPDATE artist_credit
|
|
||||||
SET text = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateArtistCreditParams struct {
|
|
||||||
Text string
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateArtistCredit(ctx context.Context, arg UpdateArtistCreditParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, updateArtistCredit, arg.Text, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const upsertArtistCredit = `-- name: UpsertArtistCredit :one
|
|
||||||
INSERT INTO artist_credit (text) VALUES (?)
|
|
||||||
ON CONFLICT(text) DO UPDATE SET text = excluded.text
|
|
||||||
RETURNING id, text
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) UpsertArtistCredit(ctx context.Context, text string) (ArtistCredit, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, upsertArtistCredit, text)
|
|
||||||
var i ArtistCredit
|
|
||||||
err := row.Scan(&i.ID, &i.Text)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// sqlc v1.30.0
|
|
||||||
// source: artist_credit_artists.sql
|
|
||||||
|
|
||||||
package sqlcgen
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createArtistCreditArtist = `-- name: CreateArtistCreditArtist :one
|
|
||||||
INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)
|
|
||||||
RETURNING id, artist_id, credit_id
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateArtistCreditArtistParams struct {
|
|
||||||
ArtistID int64
|
|
||||||
CreditID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtistCreditArtistParams) (ArtistCreditArtist, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createArtistCreditArtist, arg.ArtistID, arg.CreditID)
|
|
||||||
var i ArtistCreditArtist
|
|
||||||
err := row.Scan(&i.ID, &i.ArtistID, &i.CreditID)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllArtistCreditArtists = `-- name: DeleteAllArtistCreditArtists :exec
|
|
||||||
DELETE FROM artist_credit_artist
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteAllArtistCreditArtists(ctx context.Context) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllArtistCreditArtists)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec
|
|
||||||
DELETE FROM artist_credit_artist
|
|
||||||
WHERE id =?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteArtistCreditArtist(ctx context.Context, id int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtist, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteArtistCreditArtistByCredit = `-- name: DeleteArtistCreditArtistByCredit :exec
|
|
||||||
DELETE FROM artist_credit_artist
|
|
||||||
WHERE credit_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteArtistCreditArtistByCredit(ctx context.Context, creditID int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteArtistCreditArtistByCredit, creditID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getArtistCreditArtist = `-- name: GetArtistCreditArtist :one
|
|
||||||
SELECT id, artist_id, credit_id FROM artist_credit_artist
|
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetArtistCreditArtist(ctx context.Context, id int64) (ArtistCreditArtist, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getArtistCreditArtist, id)
|
|
||||||
var i ArtistCreditArtist
|
|
||||||
err := row.Scan(&i.ID, &i.ArtistID, &i.CreditID)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateArtistCreditArtist = `-- name: UpdateArtistCreditArtist :exec
|
|
||||||
UPDATE artist_credit_artist
|
|
||||||
SET artist_id = ?, credit_id = ?
|
|
||||||
WHERE id =?
|
|
||||||
`
|
|
||||||
|
|
||||||
type UpdateArtistCreditArtistParams struct {
|
|
||||||
ArtistID int64
|
|
||||||
CreditID int64
|
|
||||||
ID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) UpdateArtistCreditArtist(ctx context.Context, arg UpdateArtistCreditArtistParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, updateArtistCreditArtist, arg.ArtistID, arg.CreditID, arg.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -7,20 +7,9 @@ package sqlcgen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createArtist = `-- name: CreateArtist :one
|
|
||||||
INSERT INTO artists (name) VALUES (?)
|
|
||||||
RETURNING id, name, mbid
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, createArtist, name)
|
|
||||||
var i Artist
|
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
|
||||||
return i, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllArtists = `-- name: DeleteAllArtists :exec
|
const deleteAllArtists = `-- name: DeleteAllArtists :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
`
|
`
|
||||||
@@ -31,8 +20,7 @@ func (q *Queries) DeleteAllArtists(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteArtist = `-- name: DeleteArtist :exec
|
const deleteArtist = `-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists WHERE id = ?
|
||||||
WHERE id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
||||||
@@ -43,56 +31,17 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error {
|
|||||||
const getAlbumArtists = `-- name: GetAlbumArtists :many
|
const getAlbumArtists = `-- name: GetAlbumArtists :many
|
||||||
SELECT DISTINCT a.id, a.name, a.mbid
|
SELECT DISTINCT a.id, a.name, a.mbid
|
||||||
FROM artists a
|
FROM artists a
|
||||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
JOIN albums al ON al.artist_id = a.id
|
||||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
WHERE EXISTS (
|
||||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
SELECT 1 FROM audio_files af
|
||||||
ORDER BY a.name
|
WHERE af.album_id = al.id
|
||||||
`
|
AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
|
||||||
|
|
||||||
func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAlbumArtists)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Artist
|
|
||||||
for rows.Next() {
|
|
||||||
var i Artist
|
|
||||||
if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAlbumArtistsByLibrary = `-- name: GetAlbumArtistsByLibrary :many
|
|
||||||
SELECT DISTINCT a.id, a.name, a.mbid
|
|
||||||
FROM artists a
|
|
||||||
JOIN artist_credit_artist aca ON aca.artist_id = a.id
|
|
||||||
JOIN artist_credit ac ON ac.id = aca.credit_id
|
|
||||||
JOIN release_groups rg ON rg.album_artist_credit_id = ac.id
|
|
||||||
WHERE a.id IN (
|
|
||||||
SELECT DISTINCT aca2.artist_id
|
|
||||||
FROM artist_credit_artist aca2
|
|
||||||
JOIN artist_credit ac2 ON ac2.id = aca2.credit_id
|
|
||||||
JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id
|
|
||||||
JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id
|
|
||||||
JOIN recordings r2 ON r2.id = rgr2.recording_id
|
|
||||||
JOIN audio_files af2 ON af2.recording_id = r2.id
|
|
||||||
WHERE af2.library_id = ?
|
|
||||||
)
|
)
|
||||||
ORDER BY a.name
|
ORDER BY a.name
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) ([]Artist, error) {
|
func (q *Queries) GetAlbumArtists(ctx context.Context, libraryID int64) ([]Artist, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getAlbumArtistsByLibrary, libraryID)
|
rows, err := q.db.QueryContext(ctx, getAlbumArtists, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -115,8 +64,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllArtists = `-- name: GetAllArtists :many
|
const getAllArtists = `-- name: GetAllArtists :many
|
||||||
SELECT id, name, mbid FROM artists
|
SELECT id, name, mbid FROM artists ORDER BY name
|
||||||
ORDER BY name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
|
func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
|
||||||
@@ -143,8 +91,7 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getArtist = `-- name: GetArtist :one
|
const getArtist = `-- name: GetArtist :one
|
||||||
SELECT id, name, mbid FROM artists
|
SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1
|
||||||
WHERE id = ? LIMIT 1
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
||||||
@@ -154,9 +101,28 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) {
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
|
||||||
|
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
|
||||||
|
FROM audio_files af
|
||||||
|
LEFT JOIN artists a ON a.id = af.artist_id
|
||||||
|
WHERE af.file_path = ?
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetArtistByFilePathRow struct {
|
||||||
|
ArtistName string
|
||||||
|
ArtistMbid string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
|
||||||
|
var i GetArtistByFilePathRow
|
||||||
|
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getArtistByName = `-- name: GetArtistByName :one
|
const getArtistByName = `-- name: GetArtistByName :one
|
||||||
SELECT id, name, mbid FROM artists
|
SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1
|
||||||
WHERE name = ? LIMIT 1
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
|
func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) {
|
||||||
@@ -166,18 +132,15 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many
|
const getUnreferencedArtistIDs = `-- name: GetUnreferencedArtistIDs :many
|
||||||
SELECT a.id FROM artists a
|
SELECT id FROM artists a
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
|
||||||
SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id
|
AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id)
|
||||||
)
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// Artists no longer credited on any recording or release group - left
|
// Artists no file and no album points at any more.
|
||||||
// behind when a scan's orphan cleanup removes the audio_files that used
|
func (q *Queries) GetUnreferencedArtistIDs(ctx context.Context) ([]int64, error) {
|
||||||
// to justify them, since deleting an audio_files row doesn't cascade.
|
rows, err := q.db.QueryContext(ctx, getUnreferencedArtistIDs)
|
||||||
func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -199,30 +162,42 @@ func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateArtist = `-- name: UpdateArtist :exec
|
const setArtistMBID = `-- name: SetArtistMBID :exec
|
||||||
UPDATE artists
|
UPDATE artists SET mbid = ? WHERE id = ?
|
||||||
SET name = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateArtistParams struct {
|
type SetArtistMBIDParams struct {
|
||||||
Name string
|
Mbid sql.NullString
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) error {
|
func (q *Queries) SetArtistMBID(ctx context.Context, arg SetArtistMBIDParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID)
|
_, err := q.db.ExecContext(ctx, setArtistMBID, arg.Mbid, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertArtist = `-- name: UpsertArtist :one
|
const upsertArtist = `-- name: UpsertArtist :one
|
||||||
INSERT INTO artists (name) VALUES (?)
|
|
||||||
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
INSERT INTO artists (name, mbid) VALUES (?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
|
mbid = COALESCE(excluded.mbid, artists.mbid)
|
||||||
RETURNING id, name, mbid
|
RETURNING id, name, mbid
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) {
|
type UpsertArtistParams struct {
|
||||||
row := q.db.QueryRowContext(ctx, upsertArtist, name)
|
Name string
|
||||||
|
Mbid sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queries over artists.
|
||||||
|
//
|
||||||
|
// An artist row is reachable two ways: as a file's primary artist
|
||||||
|
// (audio_files.artist_id) and as an album's artist (albums.artist_id).
|
||||||
|
// Both used to route through artist_credit + artist_credit_artist,
|
||||||
|
// which is how "which album artists are in library 2" came to be a
|
||||||
|
// five-join subquery inside a three-join query.
|
||||||
|
func (q *Queries) UpsertArtist(ctx context.Context, arg UpsertArtistParams) (Artist, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertArtist, arg.Name, arg.Mbid)
|
||||||
var i Artist
|
var i Artist
|
||||||
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
err := row.Scan(&i.ID, &i.Name, &i.Mbid)
|
||||||
return i, err
|
return i, err
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,36 +7,9 @@ package sqlcgen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const countGenreReferences = `-- name: CountGenreReferences :one
|
|
||||||
SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, countGenreReferences, genreID)
|
|
||||||
var count int64
|
|
||||||
err := row.Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
|
|
||||||
INSERT OR IGNORE INTO recording_genres (recording_id, genre_id)
|
|
||||||
VALUES (?, ?)
|
|
||||||
`
|
|
||||||
|
|
||||||
type CreateRecordingGenreParams struct {
|
|
||||||
RecordingID int64
|
|
||||||
GenreID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteAllGenres = `-- name: DeleteAllGenres :exec
|
const deleteAllGenres = `-- name: DeleteAllGenres :exec
|
||||||
DELETE FROM genres
|
DELETE FROM genres
|
||||||
`
|
`
|
||||||
@@ -46,12 +19,12 @@ func (q *Queries) DeleteAllGenres(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec
|
const deleteFileGenres = `-- name: DeleteFileGenres :exec
|
||||||
DELETE FROM recording_genres
|
DELETE FROM file_genres WHERE audio_file_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error {
|
func (q *Queries) DeleteFileGenres(ctx context.Context, audioFileID int64) error {
|
||||||
_, err := q.db.ExecContext(ctx, deleteAllRecordingGenres)
|
_, err := q.db.ExecContext(ctx, deleteFileGenres, audioFileID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +37,12 @@ func (q *Queries) DeleteGenre(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec
|
|
||||||
DELETE FROM recording_genres
|
|
||||||
WHERE recording_id = ?
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error {
|
|
||||||
_, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
|
const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many
|
||||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
SELECT g.name, COUNT(fg.audio_file_id) AS track_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
|
WHERE af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id)
|
||||||
GROUP BY g.id, g.name
|
GROUP BY g.id, g.name
|
||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
`
|
`
|
||||||
@@ -87,8 +52,8 @@ type GetAllGenresWithCountsRow struct {
|
|||||||
TrackCount int64
|
TrackCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) {
|
func (q *Queries) GetAllGenresWithCounts(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts)
|
rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -110,35 +75,25 @@ func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWit
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAllGenresWithCountsByLibrary = `-- name: GetAllGenresWithCountsByLibrary :many
|
const getGenreNamesByFile = `-- name: GetGenreNamesByFile :many
|
||||||
SELECT g.name, COUNT(rg.recording_id) AS track_count
|
SELECT g.name FROM genres g
|
||||||
FROM genres g
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
WHERE fg.audio_file_id = ?
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE af.library_id = ?
|
|
||||||
GROUP BY g.id, g.name
|
|
||||||
ORDER BY g.name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetAllGenresWithCountsByLibraryRow struct {
|
func (q *Queries) GetGenreNamesByFile(ctx context.Context, audioFileID int64) ([]string, error) {
|
||||||
Name string
|
rows, err := q.db.QueryContext(ctx, getGenreNamesByFile, audioFileID)
|
||||||
TrackCount int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsByLibraryRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getAllGenresWithCountsByLibrary, libraryID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetAllGenresWithCountsByLibraryRow
|
var items []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetAllGenresWithCountsByLibraryRow
|
var name string
|
||||||
if err := rows.Scan(&i.Name, &i.TrackCount); err != nil {
|
if err := rows.Scan(&name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, name)
|
||||||
}
|
}
|
||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -149,45 +104,42 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many
|
const getGenreNamesByFilePaths = `-- name: GetGenreNamesByFilePaths :many
|
||||||
|
SELECT af.file_path, g.name
|
||||||
SELECT g.name AS genre_name, af.file_path
|
FROM audio_files af
|
||||||
FROM genres g
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
WHERE af.file_path IN (/*SLICE:paths*/?)
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE g.name IN (/*SLICE:genre_names*/?)
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetFilePathsByGenresRow struct {
|
type GetGenreNamesByFilePathsRow struct {
|
||||||
GenreName string
|
FilePath string
|
||||||
FilePath string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2):
|
// Genres for many files at once. The mix builder asked this one file
|
||||||
// one query instead of one per genre, and file paths instead of whole
|
// at a time, inside two nested loops -- twelve thousand single-row
|
||||||
// track rows, which was 6 MB over the IPC for five genres.
|
// queries to assemble one mix.
|
||||||
func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) {
|
func (q *Queries) GetGenreNamesByFilePaths(ctx context.Context, paths []string) ([]GetGenreNamesByFilePathsRow, error) {
|
||||||
query := getFilePathsByGenres
|
query := getGenreNamesByFilePaths
|
||||||
var queryParams []interface{}
|
var queryParams []interface{}
|
||||||
if len(genreNames) > 0 {
|
if len(paths) > 0 {
|
||||||
for _, v := range genreNames {
|
for _, v := range paths {
|
||||||
queryParams = append(queryParams, v)
|
queryParams = append(queryParams, v)
|
||||||
}
|
}
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1)
|
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
|
||||||
} else {
|
} else {
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
|
||||||
}
|
}
|
||||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetFilePathsByGenresRow
|
var items []GetGenreNamesByFilePathsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetFilePathsByGenresRow
|
var i GetGenreNamesByFilePathsRow
|
||||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
if err := rows.Scan(&i.FilePath, &i.Name); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -201,51 +153,24 @@ func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many
|
const getUnusedGenreIDs = `-- name: GetUnusedGenreIDs :many
|
||||||
SELECT g.name AS genre_name, af.file_path
|
SELECT id FROM genres g
|
||||||
FROM genres g
|
WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id)
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
WHERE g.name IN (/*SLICE:genre_names*/?)
|
|
||||||
AND af.library_id = ?
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetFilePathsByGenresByLibraryParams struct {
|
func (q *Queries) GetUnusedGenreIDs(ctx context.Context) ([]int64, error) {
|
||||||
GenreNames []string
|
rows, err := q.db.QueryContext(ctx, getUnusedGenreIDs)
|
||||||
LibraryID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetFilePathsByGenresByLibraryRow struct {
|
|
||||||
GenreName string
|
|
||||||
FilePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) {
|
|
||||||
query := getFilePathsByGenresByLibrary
|
|
||||||
var queryParams []interface{}
|
|
||||||
if len(arg.GenreNames) > 0 {
|
|
||||||
for _, v := range arg.GenreNames {
|
|
||||||
queryParams = append(queryParams, v)
|
|
||||||
}
|
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1)
|
|
||||||
} else {
|
|
||||||
query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1)
|
|
||||||
}
|
|
||||||
queryParams = append(queryParams, arg.LibraryID)
|
|
||||||
rows, err := q.db.QueryContext(ctx, query, queryParams...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []GetFilePathsByGenresByLibraryRow
|
var items []int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetFilePathsByGenresByLibraryRow
|
var id int64
|
||||||
if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil {
|
if err := rows.Scan(&id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, id)
|
||||||
}
|
}
|
||||||
if err := rows.Close(); err != nil {
|
if err := rows.Close(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -256,247 +181,32 @@ func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFile
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
const linkFileGenre = `-- name: LinkFileGenre :exec
|
||||||
SELECT g.id, g.name
|
INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?)
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
WHERE rg.recording_id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) {
|
type LinkFileGenreParams struct {
|
||||||
rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID)
|
AudioFileID int64
|
||||||
if err != nil {
|
GenreID int64
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []Genre
|
|
||||||
for rows.Next() {
|
|
||||||
var i Genre
|
|
||||||
if err := rows.Scan(&i.ID, &i.Name); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByGenre = `-- name: GetTracksByGenre :many
|
func (q *Queries) LinkFileGenre(ctx context.Context, arg LinkFileGenreParams) error {
|
||||||
SELECT
|
_, err := q.db.ExecContext(ctx, linkFileGenre, arg.AudioFileID, arg.GenreID)
|
||||||
af.file_path,
|
return err
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rlg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
|
||||||
FROM recording_genres rg2
|
|
||||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
|
||||||
WHERE rg2.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE g.name = ?
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetTracksByGenreRow struct {
|
|
||||||
FilePath string
|
|
||||||
LengthMilliseconds int64
|
|
||||||
Title string
|
|
||||||
ArtistName string
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Album string
|
|
||||||
Genre string
|
|
||||||
Year int64
|
|
||||||
Composer string
|
|
||||||
FileType string
|
|
||||||
SampleRate int64
|
|
||||||
BitDepth int64
|
|
||||||
Channels int64
|
|
||||||
Bitrate int64
|
|
||||||
FileSize int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getTracksByGenre, name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetTracksByGenreRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetTracksByGenreRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.FilePath,
|
|
||||||
&i.LengthMilliseconds,
|
|
||||||
&i.Title,
|
|
||||||
&i.ArtistName,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Album,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Year,
|
|
||||||
&i.Composer,
|
|
||||||
&i.FileType,
|
|
||||||
&i.SampleRate,
|
|
||||||
&i.BitDepth,
|
|
||||||
&i.Channels,
|
|
||||||
&i.Bitrate,
|
|
||||||
&i.FileSize,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTracksByGenreByLibrary = `-- name: GetTracksByGenreByLibrary :many
|
|
||||||
SELECT
|
|
||||||
af.file_path,
|
|
||||||
af.length_milliseconds,
|
|
||||||
COALESCE(r.name, '') AS title,
|
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
|
||||||
r.track_number,
|
|
||||||
r.disc_number,
|
|
||||||
COALESCE(rlg.name, '') AS album,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g2.name, '||')
|
|
||||||
FROM recording_genres rg2
|
|
||||||
JOIN genres g2 ON rg2.genre_id = g2.id
|
|
||||||
WHERE rg2.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(r.year, 0) AS year,
|
|
||||||
COALESCE(r.composer, '') AS composer,
|
|
||||||
COALESCE(ft.extension, '') AS file_type,
|
|
||||||
af.sample_rate,
|
|
||||||
af.bit_depth,
|
|
||||||
af.channels,
|
|
||||||
af.bitrate,
|
|
||||||
af.file_size
|
|
||||||
FROM genres g
|
|
||||||
JOIN recording_genres rg ON g.id = rg.genre_id
|
|
||||||
JOIN recordings r ON rg.recording_id = r.id
|
|
||||||
JOIN audio_files af ON af.recording_id = r.id
|
|
||||||
JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id,
|
|
||||||
MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id
|
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.id
|
|
||||||
WHERE g.name = ? AND af.library_id = ?
|
|
||||||
ORDER BY r.name
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetTracksByGenreByLibraryParams struct {
|
|
||||||
Name string
|
|
||||||
LibraryID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetTracksByGenreByLibraryRow struct {
|
|
||||||
FilePath string
|
|
||||||
LengthMilliseconds int64
|
|
||||||
Title string
|
|
||||||
ArtistName string
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Album string
|
|
||||||
Genre string
|
|
||||||
Year int64
|
|
||||||
Composer string
|
|
||||||
FileType string
|
|
||||||
SampleRate int64
|
|
||||||
BitDepth int64
|
|
||||||
Channels int64
|
|
||||||
Bitrate int64
|
|
||||||
FileSize int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (q *Queries) GetTracksByGenreByLibrary(ctx context.Context, arg GetTracksByGenreByLibraryParams) ([]GetTracksByGenreByLibraryRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, getTracksByGenreByLibrary, arg.Name, arg.LibraryID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var items []GetTracksByGenreByLibraryRow
|
|
||||||
for rows.Next() {
|
|
||||||
var i GetTracksByGenreByLibraryRow
|
|
||||||
if err := rows.Scan(
|
|
||||||
&i.FilePath,
|
|
||||||
&i.LengthMilliseconds,
|
|
||||||
&i.Title,
|
|
||||||
&i.ArtistName,
|
|
||||||
&i.TrackNumber,
|
|
||||||
&i.DiscNumber,
|
|
||||||
&i.Album,
|
|
||||||
&i.Genre,
|
|
||||||
&i.Year,
|
|
||||||
&i.Composer,
|
|
||||||
&i.FileType,
|
|
||||||
&i.SampleRate,
|
|
||||||
&i.BitDepth,
|
|
||||||
&i.Channels,
|
|
||||||
&i.Bitrate,
|
|
||||||
&i.FileSize,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items = append(items, i)
|
|
||||||
}
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertGenre = `-- name: UpsertGenre :one
|
const upsertGenre = `-- name: UpsertGenre :one
|
||||||
|
|
||||||
INSERT INTO genres (name) VALUES (?)
|
INSERT INTO genres (name) VALUES (?)
|
||||||
ON CONFLICT(name) DO UPDATE SET name = name
|
ON CONFLICT(name) DO UPDATE SET name = excluded.name
|
||||||
RETURNING id, name
|
RETURNING id, name
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Queries over genres and file_genres.
|
||||||
|
//
|
||||||
|
// The track-returning ones live in audio_files.sql with the rest of the
|
||||||
|
// track_metadata reads; what is left here is the genre list itself and
|
||||||
|
// the link table's writes.
|
||||||
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
|
func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) {
|
||||||
row := q.db.QueryRowContext(ctx, upsertGenre, name)
|
row := q.db.QueryRowContext(ctx, upsertGenre, name)
|
||||||
var i Genre
|
var i Genre
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import (
|
|||||||
|
|
||||||
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
|
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
|
JOIN file_genres fg ON fg.audio_file_id = af.id
|
||||||
JOIN genres g ON g.id = rgen.genre_id
|
JOIN genres g ON g.id = fg.genre_id
|
||||||
WHERE g.name = ?
|
WHERE g.name = ?
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
@@ -54,9 +54,8 @@ func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenrePa
|
|||||||
|
|
||||||
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
|
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) > 0
|
HAVING SUM(af.play_count) > 0
|
||||||
ORDER BY SUM(af.play_count) DESC
|
ORDER BY SUM(af.play_count) DESC
|
||||||
@@ -89,9 +88,8 @@ func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int6
|
|||||||
|
|
||||||
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
|
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -122,9 +120,8 @@ func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, e
|
|||||||
|
|
||||||
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
|
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.id) DESC
|
ORDER BY MAX(af.id) DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -159,9 +156,8 @@ func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]i
|
|||||||
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
|
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
|
||||||
|
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
ORDER BY MAX(af.last_played) DESC
|
ORDER BY MAX(af.last_played) DESC
|
||||||
@@ -172,7 +168,7 @@ LIMIT ?
|
|||||||
//
|
//
|
||||||
// Every one of these returns album ids and nothing else. The display
|
// Every one of these returns album ids and nothing else. The display
|
||||||
// columns (cover art, artist credit, year) already have exactly one
|
// columns (cover art, artist credit, year) already have exactly one
|
||||||
// correct expression of them, in GetAllAlbumsWithDetails, and a second
|
// correct expression of them, in GetAlbums, and a second
|
||||||
// copy per shelf would be six more places for that to drift. The home
|
// copy per shelf would be six more places for that to drift. The home
|
||||||
// service joins the ids back to that one album list in Go.
|
// service joins the ids back to that one album list in Go.
|
||||||
// Albums with the most recent play, newest first.
|
// Albums with the most recent play, newest first.
|
||||||
@@ -201,9 +197,8 @@ func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]
|
|||||||
|
|
||||||
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
|
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
WHERE af.last_played IS NOT NULL
|
WHERE af.last_played IS NOT NULL
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING MAX(af.last_played) < datetime('now', ?)
|
HAVING MAX(af.last_played) < datetime('now', ?)
|
||||||
@@ -242,14 +237,12 @@ func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams
|
|||||||
|
|
||||||
const homeTopArtists = `-- name: HomeTopArtists :many
|
const homeTopArtists = `-- name: HomeTopArtists :many
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
rg.artist_credit AS artist_name,
|
||||||
SUM(af.play_count) AS plays
|
SUM(af.play_count) AS plays
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
WHERE rg.artist_credit <> ''
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
GROUP BY rg.artist_credit
|
||||||
WHERE ac.text <> ''
|
|
||||||
GROUP BY ac.text
|
|
||||||
HAVING plays > 0
|
HAVING plays > 0
|
||||||
ORDER BY plays DESC
|
ORDER BY plays DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -288,10 +281,10 @@ func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArt
|
|||||||
const homeTopGenres = `-- name: HomeTopGenres :many
|
const homeTopGenres = `-- name: HomeTopGenres :many
|
||||||
SELECT
|
SELECT
|
||||||
g.name AS genre,
|
g.name AS genre,
|
||||||
COUNT(DISTINCT rgr.release_group_id) AS album_count
|
COUNT(DISTINCT af.album_id) AS album_count
|
||||||
FROM genres g
|
FROM genres g
|
||||||
JOIN recording_genres rgen ON rgen.genre_id = g.id
|
JOIN file_genres fg ON fg.genre_id = g.id
|
||||||
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
|
JOIN audio_files af ON af.id = fg.audio_file_id
|
||||||
GROUP BY g.id
|
GROUP BY g.id
|
||||||
HAVING album_count >= 3
|
HAVING album_count >= 3
|
||||||
ORDER BY album_count DESC
|
ORDER BY album_count DESC
|
||||||
@@ -331,9 +324,8 @@ func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenr
|
|||||||
|
|
||||||
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
|
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
|
||||||
SELECT rg.id AS album_id
|
SELECT rg.id AS album_id
|
||||||
FROM release_groups rg
|
FROM albums rg
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = rg.id
|
||||||
JOIN audio_files af ON af.recording_id = rgr.recording_id
|
|
||||||
GROUP BY rg.id
|
GROUP BY rg.id
|
||||||
HAVING SUM(af.play_count) = 0
|
HAVING SUM(af.play_count) = 0
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -9,23 +9,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Album struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
ArtistCredit string
|
||||||
|
ArtistID sql.NullInt64
|
||||||
|
Mbid sql.NullString
|
||||||
|
Year sql.NullInt64
|
||||||
|
OriginalYear sql.NullInt64
|
||||||
|
CoverArtID sql.NullInt64
|
||||||
|
PendingReleaseMbid sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Mbid sql.NullString
|
Mbid sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArtistCredit struct {
|
|
||||||
ID int64
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArtistCreditArtist struct {
|
|
||||||
ID int64
|
|
||||||
ArtistID int64
|
|
||||||
CreditID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArtistEnrichment struct {
|
type ArtistEnrichment struct {
|
||||||
ArtistMbid string
|
ArtistMbid string
|
||||||
BrowsedAt sql.NullTime
|
BrowsedAt sql.NullTime
|
||||||
@@ -56,21 +57,31 @@ type ArtistMetadatum struct {
|
|||||||
type AudioFile struct {
|
type AudioFile struct {
|
||||||
ID int64
|
ID int64
|
||||||
FilePath string
|
FilePath string
|
||||||
LengthMilliseconds int64
|
LibraryID int64
|
||||||
FileTypeID int64
|
FileTypeID int64
|
||||||
RecordingID int64
|
LengthMilliseconds int64
|
||||||
SampleRate int64
|
SampleRate int64
|
||||||
BitDepth int64
|
BitDepth int64
|
||||||
Channels int64
|
Channels int64
|
||||||
Bitrate int64
|
Bitrate int64
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
Title string
|
||||||
|
ArtistCredit string
|
||||||
|
ArtistID sql.NullInt64
|
||||||
|
AlbumID sql.NullInt64
|
||||||
|
TrackNumber sql.NullInt64
|
||||||
|
DiscNumber sql.NullInt64
|
||||||
|
TotalTracks sql.NullInt64
|
||||||
|
Year sql.NullInt64
|
||||||
|
Composer string
|
||||||
|
Comment string
|
||||||
|
RecordingMbid sql.NullString
|
||||||
Basename string
|
Basename string
|
||||||
LibraryID int64
|
GroupKey string
|
||||||
|
ModifiedAt int64
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
LastPlayed sql.NullTime
|
LastPlayed sql.NullTime
|
||||||
TagStatus string
|
TagStatus string
|
||||||
GroupKey string
|
|
||||||
ModifiedAt int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoverArt struct {
|
type CoverArt struct {
|
||||||
@@ -160,20 +171,21 @@ type ExploreChampionFt struct {
|
|||||||
|
|
||||||
type ExploreIndex struct {
|
type ExploreIndex struct {
|
||||||
ID int64
|
ID int64
|
||||||
EntityType string
|
EntityType int64
|
||||||
Mbid string
|
Mbid []byte
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
ArtistMbid string
|
ArtistMbid []byte
|
||||||
Aliases string
|
Aliases string
|
||||||
Popularity int64
|
Popularity int64
|
||||||
ListenerCount int64
|
ListenerCount int64
|
||||||
Duration int64
|
Duration int64
|
||||||
CaaReleaseMbid string
|
CaaReleaseMbid []byte
|
||||||
ReleaseName string
|
ReleaseName string
|
||||||
PrimaryType string
|
PrimaryType string
|
||||||
SecondaryTypes string
|
SecondaryTypes string
|
||||||
ReleaseDate string
|
ReleaseDate string
|
||||||
|
TotalTracks int64
|
||||||
ArtistType string
|
ArtistType string
|
||||||
Country string
|
Country string
|
||||||
Disambiguation string
|
Disambiguation string
|
||||||
@@ -197,6 +209,11 @@ type ExploreIndexMetum struct {
|
|||||||
Value string
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FileGenre struct {
|
||||||
|
AudioFileID int64
|
||||||
|
GenreID int64
|
||||||
|
}
|
||||||
|
|
||||||
type FileType struct {
|
type FileType struct {
|
||||||
ID int64
|
ID int64
|
||||||
Extension string
|
Extension string
|
||||||
@@ -231,6 +248,14 @@ type Library struct {
|
|||||||
AutotagWarningAcked int64
|
AutotagWarningAcked int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Lyric struct {
|
||||||
|
AudioFileID int64
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
RecordingMbid sql.NullString
|
||||||
|
FetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type LyricsIndex struct {
|
type LyricsIndex struct {
|
||||||
Lyrics string
|
Lyrics string
|
||||||
}
|
}
|
||||||
@@ -291,48 +316,6 @@ type QueueTrack struct {
|
|||||||
Position int64
|
Position int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Recording struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
ArtistCreditID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
Genre sql.NullString
|
|
||||||
Composer sql.NullString
|
|
||||||
Lyrics sql.NullString
|
|
||||||
Comment sql.NullString
|
|
||||||
Mbid sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecordingGenre struct {
|
|
||||||
ID int64
|
|
||||||
RecordingID int64
|
|
||||||
GenreID int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseGroup struct {
|
|
||||||
ID int64
|
|
||||||
Name string
|
|
||||||
CoverArtID sql.NullInt64
|
|
||||||
AlbumArtistCreditID sql.NullInt64
|
|
||||||
Year sql.NullInt64
|
|
||||||
TotalTracks sql.NullInt64
|
|
||||||
TotalDiscs sql.NullInt64
|
|
||||||
Mbid sql.NullString
|
|
||||||
OriginalYear sql.NullInt64
|
|
||||||
PendingReleaseMbid sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseGroupRecording struct {
|
|
||||||
ID int64
|
|
||||||
ReleaseGroupID int64
|
|
||||||
RecordingID int64
|
|
||||||
TrackNumber sql.NullInt64
|
|
||||||
DiscNumber sql.NullInt64
|
|
||||||
TotalTracks sql.NullInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReleaseToRg struct {
|
type ReleaseToRg struct {
|
||||||
ReleaseMbid string
|
ReleaseMbid string
|
||||||
RgMbid string
|
RgMbid string
|
||||||
@@ -410,4 +393,6 @@ type TrackMetadatum struct {
|
|||||||
ArtistMbid string
|
ArtistMbid string
|
||||||
ReleaseGroupMbid string
|
ReleaseGroupMbid string
|
||||||
RecordingMbid string
|
RecordingMbid string
|
||||||
|
AlbumID sql.NullInt64
|
||||||
|
ArtistID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,29 +124,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||||
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
ORDER BY pt.playlist_id, pt.position
|
ORDER BY pt.playlist_id, pt.position
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -368,29 +357,18 @@ SELECT
|
|||||||
pt.playlist_id,
|
pt.playlist_id,
|
||||||
pt.audio_file_id,
|
pt.audio_file_id,
|
||||||
pt.position,
|
pt.position,
|
||||||
COALESCE(af.file_path, '') AS file_path,
|
COALESCE(tm.file_path, '') AS file_path,
|
||||||
COALESCE(af.length_milliseconds, 0) AS length_milliseconds,
|
COALESCE(tm.length_milliseconds, 0) AS length_milliseconds,
|
||||||
COALESCE(r.name, pt.phantom_title, '') AS title,
|
COALESCE(tm.title, pt.phantom_title, '') AS title,
|
||||||
COALESCE(ac.text, pt.phantom_artist, '') AS artist,
|
COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist,
|
||||||
COALESCE(rg.name, pt.phantom_album, '') AS album,
|
COALESCE(tm.album, pt.phantom_album, '') AS album,
|
||||||
COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path,
|
COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path,
|
||||||
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom,
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid,
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
COALESCE(tm.release_group_mbid, '') AS release_group_mbid,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
COALESCE(tm.recording_mbid, '') AS recording_mbid
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
LEFT JOIN audio_files af ON pt.audio_file_id = af.id
|
LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
WHERE pt.playlist_id = ?
|
WHERE pt.playlist_id = ?
|
||||||
ORDER BY pt.position
|
ORDER BY pt.position
|
||||||
`
|
`
|
||||||
@@ -451,30 +429,10 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one
|
const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one
|
||||||
SELECT
|
SELECT title, artist_name AS artist, album,
|
||||||
COALESCE(r.name, '') AS title,
|
length_milliseconds AS duration_ms, genre, cover_art_path
|
||||||
COALESCE(ac.text, '') AS artist,
|
FROM track_metadata
|
||||||
COALESCE(rg.name, '') AS album,
|
WHERE id = ?
|
||||||
af.length_milliseconds AS duration_ms,
|
|
||||||
CAST(COALESCE(
|
|
||||||
(SELECT GROUP_CONCAT(g.name, '||')
|
|
||||||
FROM recording_genres rg_sub
|
|
||||||
JOIN genres g ON rg_sub.genre_id = g.id
|
|
||||||
WHERE rg_sub.recording_id = r.id),
|
|
||||||
''
|
|
||||||
) AS TEXT) AS genre,
|
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path
|
|
||||||
FROM audio_files af
|
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
WHERE af.id = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetTrackPhantomMetadataRow struct {
|
type GetTrackPhantomMetadataRow struct {
|
||||||
@@ -486,6 +444,7 @@ type GetTrackPhantomMetadataRow struct {
|
|||||||
CoverArtPath string
|
CoverArtPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The display fields a playlist row keeps after its file goes away.
|
||||||
func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) {
|
func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) {
|
||||||
row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id)
|
row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id)
|
||||||
var i GetTrackPhantomMetadataRow
|
var i GetTrackPhantomMetadataRow
|
||||||
|
|||||||
@@ -61,27 +61,11 @@ func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getQueueTracks = `-- name: GetQueueTracks :many
|
const getQueueTracks = `-- name: GetQueueTracks :many
|
||||||
SELECT qt.id, qt.audio_file_id, qt.position, af.file_path,
|
SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path,
|
||||||
COALESCE(r.name, '') AS title,
|
tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path,
|
||||||
COALESCE(ac.text, '') AS artist,
|
tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid
|
||||||
COALESCE(rg.name, '') AS album,
|
|
||||||
COALESCE(ca.file_path, '') AS cover_art_path,
|
|
||||||
COALESCE(a.mbid, '') AS artist_mbid,
|
|
||||||
COALESCE(rg.mbid, '') AS release_group_mbid,
|
|
||||||
COALESCE(r.mbid, '') AS recording_mbid
|
|
||||||
FROM queue_tracks qt
|
FROM queue_tracks qt
|
||||||
JOIN audio_files af ON qt.audio_file_id = af.id
|
JOIN track_metadata tm ON tm.id = qt.audio_file_id
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id
|
|
||||||
LEFT JOIN artists a ON a.id = aca.artist_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
|
||||||
FROM release_group_recordings
|
|
||||||
GROUP BY recording_id
|
|
||||||
) rgr ON r.id = rgr.recording_id
|
|
||||||
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
|
|
||||||
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|
||||||
ORDER BY qt.position
|
ORDER BY qt.position
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -99,6 +83,7 @@ type GetQueueTracksRow struct {
|
|||||||
RecordingMbid string
|
RecordingMbid string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queue's rows, joined to the one track projection.
|
||||||
func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
|
func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, getQueueTracks)
|
rows, err := q.db.QueryContext(ctx, getQueueTracks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -25,11 +25,21 @@ func (q *Queries) ClearCompletedTaggingItems(ctx context.Context, libraryID int6
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
|
const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one
|
||||||
SELECT COUNT(*) FROM tagging_items
|
SELECT COUNT(*) FROM tagging_items ti
|
||||||
WHERE status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1)
|
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// "Needs tagging" is a question about the files, not about the row:
|
||||||
|
// every scanned folder gets a tagging_items row (see
|
||||||
|
// UpsertTaggingItemOnTrackAdd), including one whose files all arrived
|
||||||
|
// carrying a recording MBID. Without the EXISTS a fully MB-tagged
|
||||||
|
// library reports its entire album count as pending work. See the
|
||||||
|
// same predicate on the three list queries below.
|
||||||
func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) {
|
func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
|
row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID)
|
||||||
var count int64
|
var count int64
|
||||||
@@ -77,6 +87,13 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id
|
|||||||
WHERE ti.status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
AND ti.group_key > ?2
|
AND ti.group_key > ?2
|
||||||
|
-- See CountPendingTaggingItems: the cursor must not stop on a
|
||||||
|
-- folder the list query no longer shows, or "next" walks folders
|
||||||
|
-- that are not in the sidebar.
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
ORDER BY ti.group_key
|
ORDER BY ti.group_key
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -185,20 +202,6 @@ func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) (
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRecordingReleaseGroupID = `-- name: GetRecordingReleaseGroupID :one
|
|
||||||
SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id
|
|
||||||
FROM release_group_recordings rgr
|
|
||||||
WHERE rgr.recording_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID int64) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, getRecordingReleaseGroupID, recordingID)
|
|
||||||
var release_group_id int64
|
|
||||||
err := row.Scan(&release_group_id)
|
|
||||||
return release_group_id, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTaggingItem = `-- name: GetTaggingItem :one
|
const getTaggingItem = `-- name: GetTaggingItem :one
|
||||||
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key, album_artist_conflict FROM tagging_items
|
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key, album_artist_conflict FROM tagging_items
|
||||||
WHERE group_key = ?
|
WHERE group_key = ?
|
||||||
@@ -235,22 +238,18 @@ SELECT
|
|||||||
af.basename,
|
af.basename,
|
||||||
af.length_milliseconds,
|
af.length_milliseconds,
|
||||||
af.tag_status,
|
af.tag_status,
|
||||||
COALESCE(r.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(r.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS title,
|
af.title,
|
||||||
COALESCE(ac.text, '') AS artist_name,
|
af.artist_credit AS artist_name,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(rg.name, '') AS album_name,
|
COALESCE(al.name, '') AS album_name,
|
||||||
COALESCE(rgac.text, '') AS album_artist
|
COALESCE(al.artist_credit, '') AS album_artist
|
||||||
FROM audio_files af
|
FROM audio_files af
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums al ON al.id = af.album_id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
|
|
||||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
|
||||||
LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id
|
|
||||||
WHERE af.group_key = ?
|
WHERE af.group_key = ?
|
||||||
ORDER BY COALESCE(r.disc_number, 0),
|
ORDER BY COALESCE(af.disc_number, 0),
|
||||||
COALESCE(r.track_number, 0),
|
COALESCE(af.track_number, 0),
|
||||||
af.file_path
|
af.file_path
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -269,10 +268,10 @@ type ListAudioFilesInTaggingGroupRow struct {
|
|||||||
AlbumArtist string
|
AlbumArtist string
|
||||||
}
|
}
|
||||||
|
|
||||||
// album_name/album_artist are the PER-TRACK tags (via each track's
|
// album_name/album_artist are the PER-TRACK tags (each file's own
|
||||||
// own release_group link), not the folder-level tagging_items
|
// album link), not the folder-level tagging_items values.
|
||||||
// values. SplitMixedFolder clusters on these to find sub-albums
|
// SplitMixedFolder clusters on these to find sub-albums hiding inside
|
||||||
// hiding inside a folder full of unrelated tracks.
|
// a folder full of unrelated tracks.
|
||||||
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
|
func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
|
rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -313,10 +312,7 @@ const listLikelyMixedBagGroupKeys = `-- name: ListLikelyMixedBagGroupKeys :many
|
|||||||
SELECT ti.group_key
|
SELECT ti.group_key
|
||||||
FROM tagging_items ti
|
FROM tagging_items ti
|
||||||
JOIN audio_files af ON af.group_key = ti.group_key
|
JOIN audio_files af ON af.group_key = ti.group_key
|
||||||
LEFT JOIN recordings r ON af.recording_id = r.id
|
LEFT JOIN albums rg ON rg.id = af.album_id
|
||||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
|
||||||
LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id
|
|
||||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
|
||||||
WHERE ti.synthetic = 0
|
WHERE ti.synthetic = 0
|
||||||
AND ti.track_count >= 4
|
AND ti.track_count >= 4
|
||||||
AND (
|
AND (
|
||||||
@@ -324,7 +320,7 @@ WHERE ti.synthetic = 0
|
|||||||
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown')
|
||||||
)
|
)
|
||||||
GROUP BY ti.group_key
|
GROUP BY ti.group_key
|
||||||
HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1
|
HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1
|
||||||
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1
|
AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -359,34 +355,31 @@ func (q *Queries) ListLikelyMixedBagGroupKeys(ctx context.Context) ([]string, er
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listLocalReleaseGroupCandidates = `-- name: ListLocalReleaseGroupCandidates :many
|
const listLocalAlbumCandidates = `-- name: ListLocalAlbumCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
rg.id AS release_group_id,
|
al.id AS album_id,
|
||||||
rg.mbid AS release_group_mbid,
|
al.mbid AS album_mbid,
|
||||||
rg.name AS album_name,
|
al.name AS album_name,
|
||||||
COALESCE(rg.year, 0) AS year,
|
COALESCE(al.year, 0) AS year,
|
||||||
COALESCE(ac.text, '') AS artist_credit,
|
al.artist_credit,
|
||||||
COALESCE(rgr.track_number, 0) AS track_number,
|
COALESCE(af.track_number, 0) AS track_number,
|
||||||
COALESCE(rgr.disc_number, 0) AS disc_number,
|
COALESCE(af.disc_number, 0) AS disc_number,
|
||||||
COALESCE(r.name, '') AS track_title,
|
af.title AS track_title,
|
||||||
COALESCE(r.mbid, '') AS recording_mbid,
|
COALESCE(af.recording_mbid, '') AS recording_mbid,
|
||||||
COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds
|
af.length_milliseconds
|
||||||
FROM release_groups rg
|
FROM albums al
|
||||||
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
|
JOIN audio_files af ON af.album_id = al.id
|
||||||
JOIN recordings r ON r.id = rgr.recording_id
|
WHERE al.mbid IS NOT NULL
|
||||||
LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id
|
AND al.mbid != ''
|
||||||
LEFT JOIN audio_files local_af ON local_af.recording_id = r.id
|
AND af.recording_mbid IS NOT NULL
|
||||||
WHERE rg.mbid IS NOT NULL
|
AND af.recording_mbid != ''
|
||||||
AND rg.mbid != ''
|
AND al.name = ? COLLATE NOCASE
|
||||||
AND r.mbid IS NOT NULL
|
ORDER BY al.id, af.disc_number, af.track_number
|
||||||
AND r.mbid != ''
|
|
||||||
AND rg.name = ? COLLATE NOCASE
|
|
||||||
ORDER BY rg.id, rgr.disc_number, rgr.track_number
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListLocalReleaseGroupCandidatesRow struct {
|
type ListLocalAlbumCandidatesRow struct {
|
||||||
ReleaseGroupID int64
|
AlbumID int64
|
||||||
ReleaseGroupMbid sql.NullString
|
AlbumMbid sql.NullString
|
||||||
AlbumName string
|
AlbumName string
|
||||||
Year int64
|
Year int64
|
||||||
ArtistCredit string
|
ArtistCredit string
|
||||||
@@ -397,22 +390,21 @@ type ListLocalReleaseGroupCandidatesRow struct {
|
|||||||
LengthMilliseconds int64
|
LengthMilliseconds int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns one row per (release_group, track) combination for any
|
// One row per (album, track) for any local album carrying an MBID.
|
||||||
// local release_group that has an MBID. Callers group these in Go
|
// Callers group these in Go and filter by normalized album-name match;
|
||||||
// and filter by normalized album-name match. Joined case-insensitive
|
// the join is case-insensitive on name to pre-filter cheaply.
|
||||||
// on name to pre-filter cheaply; Go does the real normalization.
|
func (q *Queries) ListLocalAlbumCandidates(ctx context.Context, name string) ([]ListLocalAlbumCandidatesRow, error) {
|
||||||
func (q *Queries) ListLocalReleaseGroupCandidates(ctx context.Context, name string) ([]ListLocalReleaseGroupCandidatesRow, error) {
|
rows, err := q.db.QueryContext(ctx, listLocalAlbumCandidates, name)
|
||||||
rows, err := q.db.QueryContext(ctx, listLocalReleaseGroupCandidates, name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []ListLocalReleaseGroupCandidatesRow
|
var items []ListLocalAlbumCandidatesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListLocalReleaseGroupCandidatesRow
|
var i ListLocalAlbumCandidatesRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ReleaseGroupID,
|
&i.AlbumID,
|
||||||
&i.ReleaseGroupMbid,
|
&i.AlbumMbid,
|
||||||
&i.AlbumName,
|
&i.AlbumName,
|
||||||
&i.Year,
|
&i.Year,
|
||||||
&i.ArtistCredit,
|
&i.ArtistCredit,
|
||||||
@@ -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)
|
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
||||||
AND ti.cleared_at IS NULL
|
AND ti.cleared_at IS NULL
|
||||||
|
-- Actionable rows must have something to act on: see
|
||||||
|
-- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are
|
||||||
|
-- exempt because they are history, not work -- an applied folder is
|
||||||
|
-- fully tagged by definition and would otherwise vanish from the
|
||||||
|
-- sidebar's Completed section the instant it succeeded.
|
||||||
|
AND (
|
||||||
|
ti.status IN ('confirmed', 'skipped')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number
|
||||||
LIMIT ?4 OFFSET ?3
|
LIMIT ?4 OFFSET ?3
|
||||||
`
|
`
|
||||||
@@ -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)
|
WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1)
|
||||||
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2)
|
||||||
AND ti.cleared_at IS NULL
|
AND ti.cleared_at IS NULL
|
||||||
|
-- See ListPendingTaggingItemsAlphabetical.
|
||||||
|
AND (
|
||||||
|
ti.status IN ('confirmed', 'skipped')
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
|
)
|
||||||
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name)
|
||||||
LIMIT ?4 OFFSET ?3
|
LIMIT ?4 OFFSET ?3
|
||||||
`
|
`
|
||||||
@@ -758,31 +770,36 @@ func (q *Queries) SetAudioFileTagStatus(ctx context.Context, arg SetAudioFileTag
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const setRecordingMBID = `-- name: SetRecordingMBID :exec
|
const setFileAlbumMBID = `-- name: SetFileAlbumMBID :exec
|
||||||
UPDATE recordings SET mbid = ? WHERE id = ?
|
UPDATE albums SET mbid = ?
|
||||||
|
WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?)
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetRecordingMBIDParams struct {
|
type SetFileAlbumMBIDParams struct {
|
||||||
Mbid sql.NullString
|
Mbid sql.NullString
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetRecordingMBID(ctx context.Context, arg SetRecordingMBIDParams) error {
|
// The album MBID for the album a file belongs to. Keyed by file
|
||||||
_, err := q.db.ExecContext(ctx, setRecordingMBID, arg.Mbid, arg.ID)
|
// because that is what the autotag apply path holds; under the old
|
||||||
|
// schema it had to look the release group up through two join tables
|
||||||
|
// first (GetRecordingReleaseGroupID), which is gone.
|
||||||
|
func (q *Queries) SetFileAlbumMBID(ctx context.Context, arg SetFileAlbumMBIDParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setFileAlbumMBID, arg.Mbid, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const setReleaseGroupMBID = `-- name: SetReleaseGroupMBID :exec
|
const setFileRecordingMBID = `-- name: SetFileRecordingMBID :exec
|
||||||
UPDATE release_groups SET mbid = ? WHERE id = ?
|
UPDATE audio_files SET recording_mbid = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetReleaseGroupMBIDParams struct {
|
type SetFileRecordingMBIDParams struct {
|
||||||
Mbid sql.NullString
|
RecordingMbid sql.NullString
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetReleaseGroupMBID(ctx context.Context, arg SetReleaseGroupMBIDParams) error {
|
func (q *Queries) SetFileRecordingMBID(ctx context.Context, arg SetFileRecordingMBIDParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, setReleaseGroupMBID, arg.Mbid, arg.ID)
|
_, err := q.db.ExecContext(ctx, setFileRecordingMBID, arg.RecordingMbid, arg.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,10 +69,7 @@ func TestTagStatusBackfillFromRecordingMBID(t *testing.T) {
|
|||||||
UPDATE audio_files
|
UPDATE audio_files
|
||||||
SET tag_status = 'user_confirmed'
|
SET tag_status = 'user_confirmed'
|
||||||
WHERE tag_status = 'untagged'
|
WHERE tag_status = 'untagged'
|
||||||
AND recording_id IN (
|
AND recording_mbid IS NOT NULL AND recording_mbid != ''
|
||||||
SELECT id FROM recordings
|
|
||||||
WHERE mbid IS NOT NULL AND mbid != ''
|
|
||||||
)
|
|
||||||
`); err != nil {
|
`); err != nil {
|
||||||
t.Fatalf("backfill: %v", err)
|
t.Fatalf("backfill: %v", err)
|
||||||
}
|
}
|
||||||
@@ -205,6 +202,12 @@ func TestTaggingItems_ListPendingAndCount(t *testing.T) {
|
|||||||
seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending")
|
seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending")
|
||||||
seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed")
|
seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed")
|
||||||
|
|
||||||
|
// A pending group is only listed while it still holds untagged
|
||||||
|
// files — see TestTaggingItems_FullyTaggedGroupIsNotPending.
|
||||||
|
seedGroupFile(t, db, "g1", "/music/a1.mp3", "untagged")
|
||||||
|
seedGroupFile(t, db, "g2", "/music/b1.mp3", "untagged")
|
||||||
|
seedGroupFile(t, db, "g3", "/music/c1.mp3", "user_confirmed")
|
||||||
|
|
||||||
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
|
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("count: %v", err)
|
t.Fatalf("count: %v", err)
|
||||||
@@ -239,6 +242,62 @@ func TestTaggingItems_ListPendingAndCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClearUnreviewedConfirmedTaggingItems mirrors migration 0007 the
|
||||||
|
// way TestTagStatusBackfillFromRecordingMBID mirrors the tag_status
|
||||||
|
// backfill: NewTestDB applies migrations to an empty database, so the
|
||||||
|
// only way to exercise one that rewrites existing rows is to seed the
|
||||||
|
// shapes and re-issue its statement. Keep the two in step.
|
||||||
|
func TestClearUnreviewedConfirmedTaggingItems(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
// Stamped 'confirmed' by the old backfill: never scored, never
|
||||||
|
// checked, never matched — the app has not touched it.
|
||||||
|
seedTaggingItem(t, db, "g-backfill", 0, "Bulk", "Artist A", 2, "confirmed")
|
||||||
|
|
||||||
|
// Confirmed by a real apply, which stamps last_checked_at.
|
||||||
|
seedTaggingItem(t, db, "g-applied", 0, "Applied", "Artist B", 2, "confirmed")
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
`UPDATE tagging_items SET last_checked_at = CURRENT_TIMESTAMP, score = 0.98
|
||||||
|
WHERE group_key = 'g-applied'`,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("mark applied: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A skipped row is not confirmed and must be left alone.
|
||||||
|
seedTaggingItem(t, db, "g-skipped", 0, "Skipped", "Artist C", 1, "skipped")
|
||||||
|
|
||||||
|
if _, err := db.ExecContext(`
|
||||||
|
UPDATE tagging_items
|
||||||
|
SET cleared_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE status = 'confirmed'
|
||||||
|
AND cleared_at IS NULL
|
||||||
|
AND last_checked_at IS NULL
|
||||||
|
AND score IS NULL
|
||||||
|
AND (best_match_release_mbid IS NULL OR best_match_release_mbid = '')
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"g-backfill": true,
|
||||||
|
"g-applied": false,
|
||||||
|
"g-skipped": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, wantCleared := range cases {
|
||||||
|
got := scalarInt(t, db,
|
||||||
|
`SELECT cleared_at IS NOT NULL FROM tagging_items WHERE group_key = ?`,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
if (got == 1) != wantCleared {
|
||||||
|
t.Errorf("%s cleared = %v, want %v", key, got == 1, wantCleared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -248,9 +307,13 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
|||||||
|
|
||||||
rows, err := db.QueryContext(`
|
rows, err := db.QueryContext(`
|
||||||
EXPLAIN QUERY PLAN
|
EXPLAIN QUERY PLAN
|
||||||
SELECT COUNT(*) FROM tagging_items
|
SELECT COUNT(*) FROM tagging_items ti
|
||||||
WHERE status = 'pending'
|
WHERE ti.status = 'pending'
|
||||||
AND (CAST(0 AS INTEGER) = 0 OR library_id = 0)
|
AND (CAST(0 AS INTEGER) = 0 OR ti.library_id = 0)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM audio_files af
|
||||||
|
WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged'
|
||||||
|
)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("explain: %v", err)
|
t.Fatalf("explain: %v", err)
|
||||||
@@ -281,6 +344,97 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) {
|
|||||||
plan.String(),
|
plan.String(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The untagged-files existence check is asked once per candidate
|
||||||
|
// row, so it has to be a seek. idx_audio_files_tag_status_untagged
|
||||||
|
// is keyed on library_id and cannot serve it; the group_key one
|
||||||
|
// can, and covers the query outright.
|
||||||
|
if !strings.Contains(plan.String(), "idx_audio_files_untagged_group_key") {
|
||||||
|
t.Errorf(
|
||||||
|
"untagged-files check does not use idx_audio_files_untagged_group_key:\n%s",
|
||||||
|
plan.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTaggingItems_FullyTaggedGroupIsNotPending pins the rule that
|
||||||
|
// decides what the autotag review page shows: every scanned folder
|
||||||
|
// gets a tagging_items row, so "pending" has to mean "still holds
|
||||||
|
// untagged files" rather than "has a row". Without it a fully
|
||||||
|
// MB-tagged library queues its entire album count for review — and
|
||||||
|
// the background prefetch scores every one of them against
|
||||||
|
// MusicBrainz.
|
||||||
|
func TestTaggingItems_FullyTaggedGroupIsNotPending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
seedTaggingItem(t, db, "g-partial", 0, "Half Tagged", "Artist A", 2, "pending")
|
||||||
|
seedGroupFile(t, db, "g-partial", "/music/partial-1.mp3", "user_confirmed")
|
||||||
|
seedGroupFile(t, db, "g-partial", "/music/partial-2.mp3", "untagged")
|
||||||
|
|
||||||
|
seedTaggingItem(t, db, "g-done", 0, "Already Tagged", "Artist B", 2, "pending")
|
||||||
|
seedGroupFile(t, db, "g-done", "/music/done-1.mp3", "user_confirmed")
|
||||||
|
seedGroupFile(t, db, "g-done", "/music/done-2.mp3", "user_confirmed")
|
||||||
|
|
||||||
|
// Reviewed rows are history, not work: an applied folder is fully
|
||||||
|
// tagged by definition and must stay in the Completed section.
|
||||||
|
seedTaggingItem(t, db, "g-applied", 0, "Applied Here", "Artist C", 1, "confirmed")
|
||||||
|
seedGroupFile(t, db, "g-applied", "/music/applied-1.mp3", "user_confirmed")
|
||||||
|
|
||||||
|
count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("pending count = %d, want 1 (only the part-tagged folder)", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := db.Queries.ListPendingTaggingItemsByScore(
|
||||||
|
db.Ctx,
|
||||||
|
sqlcgen.ListPendingTaggingItemsByScoreParams{
|
||||||
|
LibraryID: 0,
|
||||||
|
StatusFilter: "all",
|
||||||
|
RowLimit: 50,
|
||||||
|
RowOffset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list by score: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed := make(map[string]bool, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
listed[it.GroupKey] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !listed["g-partial"] {
|
||||||
|
t.Error("expected g-partial (one untagged file left) to be listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if listed["g-done"] {
|
||||||
|
t.Error("expected g-done (nothing left to tag) to be filtered out")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !listed["g-applied"] {
|
||||||
|
t.Error("expected g-applied (confirmed by a real apply) to stay listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := db.Queries.GetNextPendingTaggingItem(
|
||||||
|
db.Ctx,
|
||||||
|
sqlcgen.GetNextPendingTaggingItemParams{LibraryID: 0, AfterGroupKey: ""},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("next pending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cursor must not stop on a folder the sidebar no longer
|
||||||
|
// shows: alphabetically g-done sorts before g-partial, so a
|
||||||
|
// missing predicate here surfaces as "next" landing on nothing.
|
||||||
|
if next.GroupKey != "g-partial" {
|
||||||
|
t.Errorf("next pending = %q, want %q", next.GroupKey, "g-partial")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list
|
// TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list
|
||||||
@@ -450,9 +604,7 @@ func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
|
|||||||
// helpers
|
// helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// seedAF inserts a minimal recording + audio_files pair and returns
|
// seedAF inserts one file and returns its audio_files id.
|
||||||
// the new audio_files id. All FK-satisfying rows (artist_credit,
|
|
||||||
// recordings, file_types[0]) are created inline.
|
|
||||||
func seedAF(
|
func seedAF(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
db *database.DB,
|
db *database.DB,
|
||||||
@@ -462,49 +614,32 @@ func seedAF(
|
|||||||
) int64 {
|
) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Test Artist")
|
return database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
if err != nil {
|
FilePath: filePath,
|
||||||
t.Fatalf("upsert artist credit: %v", err)
|
Title: recordingName,
|
||||||
}
|
RecordingMBID: recordingMBID,
|
||||||
|
DiscNumber: discNumber,
|
||||||
|
LibraryID: libraryID,
|
||||||
|
LengthMs: 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
rec, err := db.Queries.CreateRecordingFull(
|
// seedGroupFile attaches one file to a tagging group with an explicit
|
||||||
db.Ctx,
|
// tag_status - the thing the queue's "is there anything left to tag
|
||||||
sqlcgen.CreateRecordingFullParams{
|
// here" predicate reads.
|
||||||
Name: recordingName,
|
func seedGroupFile(
|
||||||
ArtistCreditID: ac.ID,
|
t *testing.T,
|
||||||
},
|
db *database.DB,
|
||||||
)
|
groupKey, filePath, tagStatus string,
|
||||||
if err != nil {
|
) {
|
||||||
t.Fatalf("create recording: %v", err)
|
t.Helper()
|
||||||
}
|
|
||||||
|
|
||||||
if recordingMBID != "" {
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
if _, err := db.ExecContext(
|
FilePath: filePath,
|
||||||
`UPDATE recordings SET mbid = ? WHERE id = ?`,
|
Title: filePath,
|
||||||
recordingMBID, rec.ID,
|
GroupKey: groupKey,
|
||||||
); err != nil {
|
TagStatus: tagStatus,
|
||||||
t.Fatalf("set mbid: %v", err)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
af, err := db.Queries.CreateAudioFile(
|
|
||||||
db.Ctx,
|
|
||||||
sqlcgen.CreateAudioFileParams{
|
|
||||||
FilePath: filePath,
|
|
||||||
LengthMilliseconds: 1000,
|
|
||||||
FileTypeID: 0,
|
|
||||||
RecordingID: rec.ID,
|
|
||||||
Basename: filePath,
|
|
||||||
LibraryID: libraryID,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create audio file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = discNumber // reserved for callers that want specific disc values
|
|
||||||
|
|
||||||
return af.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedTaggingItem(
|
func seedTaggingItem(
|
||||||
|
|||||||
+201
-14
@@ -2,7 +2,10 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
@@ -10,16 +13,30 @@ import (
|
|||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestDB returns an in-memory SQLite database that mirrors the
|
// NewTestDB returns an in-memory SQLite database shaped like the real
|
||||||
// production setup (PRAGMAs + all migrations). The database is
|
// one: a single-writer handle and a separate query-only read pool over
|
||||||
// automatically closed when the test completes via t.Cleanup.
|
// the same database, built by the same applySchema production uses.
|
||||||
|
//
|
||||||
|
// The two handles matter. The test DB used to be one shared connection
|
||||||
|
// with readDB nil, so `reader()` returned the *writer* — which is how a
|
||||||
|
// query-shaped write (`INSERT ... RETURNING` through QueryContext)
|
||||||
|
// passed every test and then failed for a user with "attempt to write a
|
||||||
|
// readonly database". `TestNoWritesOnTheReadPool` had to walk the source
|
||||||
|
// tree to catch what a test could not.
|
||||||
|
//
|
||||||
|
// A shared-cache in-memory database is what lets two handles see one
|
||||||
|
// database; the connections are capped the way production caps them.
|
||||||
|
// It is closed when the test completes via t.Cleanup.
|
||||||
func NewTestDB(t *testing.T) *DB {
|
func NewTestDB(t *testing.T) *DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := sql.Open(
|
// A per-test name, so parallel tests do not share a database.
|
||||||
"sqlite",
|
dsn := fmt.Sprintf(
|
||||||
":memory:?_busy_timeout=5000&_journal_mode=WAL",
|
"file:testdb%d?mode=memory&cache=shared&_pragma=busy_timeout(5000)",
|
||||||
|
testDBSeq.Add(1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not open test database: %v", err)
|
t.Fatalf("could not open test database: %v", err)
|
||||||
}
|
}
|
||||||
@@ -47,21 +64,32 @@ func NewTestDB(t *testing.T) *DB {
|
|||||||
t.Fatalf("could not insert test library: %v", err)
|
t.Fatalf("could not insert test library: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queries := sqlcgen.New(db)
|
readDB, err := sql.Open("sqlite", dsn+"&_pragma=query_only(true)")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("could not open test read pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
readDB.SetMaxOpenConns(readPoolConns)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = readDB.Close()
|
||||||
|
_ = db.Close()
|
||||||
|
})
|
||||||
|
|
||||||
return &DB{
|
return &DB{
|
||||||
db: db,
|
db: db,
|
||||||
Ctx: ctx,
|
readDB: readDB,
|
||||||
// The in-memory test DB shares one connection, so reads and
|
Ctx: ctx,
|
||||||
// writes use the same handle; ReadQueries aliases Queries.
|
Queries: sqlcgen.New(db),
|
||||||
Queries: queries,
|
ReadQueries: sqlcgen.New(readDB),
|
||||||
ReadQueries: queries,
|
|
||||||
logger: slog.Default(),
|
logger: slog.Default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testDBSeq names each test database uniquely, so parallel tests do not
|
||||||
|
// share one through the shared cache.
|
||||||
|
var testDBSeq atomic.Int64
|
||||||
|
|
||||||
// NewTestDBWithLibrary returns a test DB with a library row
|
// NewTestDBWithLibrary returns a test DB with a library row
|
||||||
// pre-inserted. Returns the DB and the library ID.
|
// pre-inserted. Returns the DB and the library ID.
|
||||||
func NewTestDBWithLibrary(
|
func NewTestDBWithLibrary(
|
||||||
@@ -85,3 +113,162 @@ func NewTestDBWithLibrary(
|
|||||||
|
|
||||||
return db, lib.ID
|
return db, lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTrack describes one file to seed into a test database. Zero
|
||||||
|
// values are fine: only FilePath is required.
|
||||||
|
type TestTrack struct {
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
ArtistMBID string
|
||||||
|
Album string
|
||||||
|
AlbumArtist string
|
||||||
|
AlbumMBID string
|
||||||
|
RecordingMBID string
|
||||||
|
Genres []string
|
||||||
|
TrackNumber int64
|
||||||
|
DiscNumber int64
|
||||||
|
TotalTracks int64
|
||||||
|
Year int64
|
||||||
|
LengthMs int64
|
||||||
|
LibraryID int64
|
||||||
|
PlayCount int64
|
||||||
|
TagStatus string
|
||||||
|
GroupKey string
|
||||||
|
// SkipSearchIndex leaves the file out of the FTS index, for the
|
||||||
|
// tests that assert on a rebuild putting it there.
|
||||||
|
SkipSearchIndex bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertTestTrack seeds one file, with the artist and album its tags
|
||||||
|
// name, and returns the audio_files id.
|
||||||
|
//
|
||||||
|
// There is one of these because there is one shape. Twenty test files
|
||||||
|
// used to carry their own seeder, each inserting a recording, an artist
|
||||||
|
// credit, a credit-artist link and a release-group link in the right
|
||||||
|
// order - which is exactly the ceremony the schema change removed, and
|
||||||
|
// exactly why every one of those seeders was subtly different.
|
||||||
|
func InsertTestTrack(t *testing.T, db *DB, tr TestTrack) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if tr.Title == "" {
|
||||||
|
tr.Title = "Test Track"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.Artist == "" {
|
||||||
|
tr.Artist = "Test Artist"
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.TagStatus == "" {
|
||||||
|
tr.TagStatus = "untagged"
|
||||||
|
}
|
||||||
|
|
||||||
|
artist, err := db.Queries.UpsertArtist(db.Ctx, sqlcgen.UpsertArtistParams{
|
||||||
|
Name: tr.Artist,
|
||||||
|
Mbid: nullString(tr.ArtistMBID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed artist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
artistID := sql.NullInt64{Int64: artist.ID, Valid: true}
|
||||||
|
albumID := sql.NullInt64{}
|
||||||
|
|
||||||
|
if tr.Album != "" {
|
||||||
|
credit := tr.AlbumArtist
|
||||||
|
if credit == "" {
|
||||||
|
credit = tr.Artist
|
||||||
|
}
|
||||||
|
|
||||||
|
album, albErr := db.Queries.UpsertAlbum(db.Ctx, sqlcgen.UpsertAlbumParams{
|
||||||
|
Name: tr.Album,
|
||||||
|
ArtistCredit: credit,
|
||||||
|
ArtistID: artistID,
|
||||||
|
Year: nullInt64(tr.Year),
|
||||||
|
})
|
||||||
|
if albErr != nil {
|
||||||
|
t.Fatalf("seed album: %v", albErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.AlbumMBID != "" {
|
||||||
|
if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{
|
||||||
|
Mbid: nullString(tr.AlbumMBID),
|
||||||
|
ID: album.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed album mbid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumID = sql.NullInt64{Int64: album.ID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
af, err := db.Queries.CreateAudioFile(db.Ctx, sqlcgen.CreateAudioFileParams{
|
||||||
|
FilePath: tr.FilePath,
|
||||||
|
LibraryID: tr.LibraryID,
|
||||||
|
LengthMilliseconds: tr.LengthMs,
|
||||||
|
Title: tr.Title,
|
||||||
|
ArtistCredit: tr.Artist,
|
||||||
|
ArtistID: artistID,
|
||||||
|
AlbumID: albumID,
|
||||||
|
TrackNumber: nullInt64(tr.TrackNumber),
|
||||||
|
DiscNumber: nullInt64(tr.DiscNumber),
|
||||||
|
TotalTracks: nullInt64(tr.TotalTracks),
|
||||||
|
Year: nullInt64(tr.Year),
|
||||||
|
RecordingMbid: nullString(tr.RecordingMBID),
|
||||||
|
Basename: filepath.Base(tr.FilePath),
|
||||||
|
GroupKey: tr.GroupKey,
|
||||||
|
TagStatus: tr.TagStatus,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed audio file %q: %v", tr.FilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range tr.Genres {
|
||||||
|
g, gErr := db.Queries.UpsertGenre(db.Ctx, name)
|
||||||
|
if gErr != nil {
|
||||||
|
t.Fatalf("seed genre %q: %v", name, gErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Queries.LinkFileGenre(db.Ctx, sqlcgen.LinkFileGenreParams{
|
||||||
|
AudioFileID: af.ID,
|
||||||
|
GenreID: g.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed file genre: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.PlayCount > 0 {
|
||||||
|
if _, err := db.db.ExecContext(db.Ctx,
|
||||||
|
"UPDATE audio_files SET play_count = ? WHERE id = ?",
|
||||||
|
tr.PlayCount, af.ID,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed play count: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tr.SkipSearchIndex {
|
||||||
|
if err := db.InsertSearchIndex(
|
||||||
|
af.ID, tr.FilePath, tr.Title, tr.Artist, tr.Album,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed search index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return af.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(v string) sql.NullString {
|
||||||
|
if v == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullString{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt64(v int64) sql.NullInt64 {
|
||||||
|
if v == 0 {
|
||||||
|
return sql.NullInt64{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql.NullInt64{Int64: v, Valid: true}
|
||||||
|
}
|
||||||
|
|||||||
+31
-36
@@ -105,13 +105,10 @@ var internalTables = map[string]bool{
|
|||||||
// tables is the catalog. Keep it alphabetical.
|
// tables is the catalog. Keep it alphabetical.
|
||||||
var tables = []Table{
|
var tables = []Table{
|
||||||
{
|
{
|
||||||
Name: "artist_credit", Kind: Owned, Lifetime: Swept,
|
Name: "albums", Kind: Owned, Lifetime: Swept,
|
||||||
Note: "Credit strings parsed from file tags. Orphan-swept when " +
|
Note: "Albums as named by file tags — the local counterpart of a " +
|
||||||
"no recording references them.",
|
"catalog release group, which is a different thing and lives " +
|
||||||
},
|
"in explore_index. Swept when the last file on one goes.",
|
||||||
{
|
|
||||||
Name: "artist_credit_artist", Kind: Owned, Lifetime: Swept,
|
|
||||||
Note: "Join table between credits and artists.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "artist_enrichment", Kind: Derived, Lifetime: Retained,
|
Name: "artist_enrichment", Kind: Derived, Lifetime: Retained,
|
||||||
@@ -138,11 +135,12 @@ var tables = []Table{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "audio_files", Kind: Owned, Lifetime: Swept,
|
Name: "audio_files", Kind: Owned, Lifetime: Swept,
|
||||||
Note: "MIXED KIND. Mostly an owned projection of files on disk, " +
|
Note: "MIXED KIND. One row per file, carrying its tags: mostly an " +
|
||||||
"but play_count, last_played and tag_status are authored and " +
|
"owned projection of what is on disk, but play_count, " +
|
||||||
"exist nowhere else. Deleting a row to rebuild it destroys " +
|
"last_played and tag_status are authored and exist nowhere " +
|
||||||
"that authored state — which is why a file rename currently " +
|
"else. Deleting a row to rebuild it destroys that authored " +
|
||||||
"loses play counts. See the data architecture plan.",
|
"state — which is why a file rename currently loses play " +
|
||||||
|
"counts. See the data architecture plan.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "cover_art", Kind: Owned, Lifetime: Swept,
|
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 " +
|
"rescan clears it \u2014 the only way back for a path removed by " +
|
||||||
"mistake.",
|
"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,
|
Name: "file_types", Kind: Derived, Lifetime: Retained,
|
||||||
Note: "Static lookup rows seeded from code, not user data.",
|
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 " +
|
Note: "The directories the user chose. Removed only by explicit " +
|
||||||
"user action via RemoveLibrary.",
|
"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,
|
Name: "lyrics_index", Kind: Derived, Lifetime: Retained, FTS: true,
|
||||||
Note: "Full-text index over embedded and fetched lyrics. Rebuilt " +
|
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 " +
|
Note: "Queue entries. Cascade with their track; the queue is " +
|
||||||
"compacted afterwards.",
|
"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,
|
Name: "release_to_rg", Kind: Cache, Lifetime: Retained,
|
||||||
Note: "Release to release-group mapping from the dump.",
|
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: "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.",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "search_clicks", Kind: Authored, Lifetime: Retained,
|
Name: "search_clicks", Kind: Authored, Lifetime: Retained,
|
||||||
|
|||||||
@@ -486,6 +486,8 @@ func (s *Service) ClearFinished() error {
|
|||||||
// SetReconciler wires the request-list loop. Optional: without it the
|
// SetReconciler wires the request-list loop. Optional: without it the
|
||||||
// request list still stores and lists requests, it just never acts on
|
// request list still stores and lists requests, it just never acts on
|
||||||
// them.
|
// them.
|
||||||
|
//
|
||||||
|
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||||
func (s *Service) SetReconciler(r *Reconciler) {
|
func (s *Service) SetReconciler(r *Reconciler) {
|
||||||
s.reconciler = r
|
s.reconciler = r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // SQLite driver for reading the artifact file.
|
_ "modernc.org/sqlite" // SQLite driver for reading the artifact file.
|
||||||
@@ -91,9 +92,90 @@ type artifactInfo struct {
|
|||||||
// TestArtifactColumnsMatchExporter.
|
// TestArtifactColumnsMatchExporter.
|
||||||
const artifactCatalogColumns = `entity_type, mbid, title, artist_name, artist_mbid,
|
const artifactCatalogColumns = `entity_type, mbid, title, artist_name, artist_mbid,
|
||||||
aliases, popularity, listener_count, duration, caa_release_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`
|
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
|
// inspectArtifact opens the artifact read-only and reports what it
|
||||||
// declares, without touching the live index. Validation happens here so
|
// declares, without touching the live index. Validation happens here so
|
||||||
// a bad download is rejected before anything is attached.
|
// 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
|
// is an index range scan and a cancelled import leaves committed work
|
||||||
// behind rather than rolling it all back.
|
// behind rather than rolling it all back.
|
||||||
func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) {
|
func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) {
|
||||||
|
selectColumns := artifactSelectColumns(
|
||||||
|
si.artifactStoresText(), si.artifactHasTotals(),
|
||||||
|
)
|
||||||
|
|
||||||
insertSQL := `
|
insertSQL := `
|
||||||
INSERT INTO explore_index (` + artifactCatalogColumns + `)
|
INSERT INTO explore_index (` + artifactCatalogColumns + `)
|
||||||
SELECT ` + artifactCatalogColumns + `
|
SELECT ` + selectColumns + `
|
||||||
FROM core.explore_index
|
FROM core.explore_index
|
||||||
WHERE mbid > ?` + upsertIndexConflictSQL
|
WHERE mbid > ?` + upsertIndexConflictSQL
|
||||||
|
|
||||||
@@ -273,7 +359,7 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e
|
|||||||
// appended only while one exists.
|
// appended only while one exists.
|
||||||
insertRangeSQL := `
|
insertRangeSQL := `
|
||||||
INSERT INTO explore_index (` + artifactCatalogColumns + `)
|
INSERT INTO explore_index (` + artifactCatalogColumns + `)
|
||||||
SELECT ` + artifactCatalogColumns + `
|
SELECT ` + selectColumns + `
|
||||||
FROM core.explore_index
|
FROM core.explore_index
|
||||||
WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL
|
WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func TestImportCoreArtifactPreservesLocalData(t *testing.T) {
|
|||||||
|
|
||||||
if err := db.QueryRowWriter(`
|
if err := db.QueryRowWriter(`
|
||||||
SELECT popularity, duration, in_library, discog_fetched
|
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 {
|
).Scan(&popularity, &duration, &inLibrary, &discogFetched); err != nil {
|
||||||
t.Fatalf("read merged row: %v", err)
|
t.Fatalf("read merged row: %v", err)
|
||||||
}
|
}
|
||||||
@@ -376,7 +376,7 @@ func TestAddFromCacheNeverStoresMBIDAsName(t *testing.T) {
|
|||||||
|
|
||||||
var artistName string
|
var artistName string
|
||||||
if err := db.QueryRowWriter(
|
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 {
|
).Scan(&artistName); err != nil {
|
||||||
t.Fatalf("read release group: %v", err)
|
t.Fatalf("read release group: %v", err)
|
||||||
}
|
}
|
||||||
@@ -391,7 +391,7 @@ func TestAddFromCacheNeverStoresMBIDAsName(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err := db.QueryRowWriter(
|
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 {
|
).Scan(&artistName); err != nil {
|
||||||
t.Fatalf("re-read release group: %v", err)
|
t.Fatalf("re-read release group: %v", err)
|
||||||
}
|
}
|
||||||
@@ -442,3 +442,158 @@ func TestArtifactColumnsMatchExporter(t *testing.T) {
|
|||||||
exporter, importer)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,19 +12,15 @@ import (
|
|||||||
func seedIndexArtist(t *testing.T, db *database.DB, mbid string, discogFetched int) {
|
func seedIndexArtist(t *testing.T, db *database.DB, mbid string, discogFetched int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
seedIndexResult(t, db, SearchIndexResult{
|
||||||
upsertIndexSQL,
|
EntityType: EntityArtist,
|
||||||
"artist", mbid, "Seeded Artist", "Seeded Artist", mbid, "",
|
MBID: testMBID(mbid),
|
||||||
0, 0,
|
Title: "Seeded Artist",
|
||||||
0, "", "",
|
ArtistName: "Seeded Artist",
|
||||||
"", "", "",
|
ArtistMBID: testMBID(mbid),
|
||||||
"", "", "", "",
|
InLibrary: true,
|
||||||
1, 0,
|
DiscogFetched: discogFetched == 1,
|
||||||
0, 0, 0,
|
})
|
||||||
discogFetched,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed explore_index row for %q: %v", mbid, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestArtistEnrichmentMarksAreIndependent is the reason these are two
|
// TestArtistEnrichmentMarksAreIndependent is the reason these are two
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ const (
|
|||||||
// canonicalProgressRows controls progress reporting during the
|
// canonicalProgressRows controls progress reporting during the
|
||||||
// canonical CSV scan (~30M rows total).
|
// canonical CSV scan (~30M rows total).
|
||||||
canonicalProgressRows = 2_000_000
|
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
|
// Per-artist discography coverage (S2). The global budgets above keep
|
||||||
@@ -329,6 +336,18 @@ type canonicalScan struct {
|
|||||||
releaseToRG map[uuid16]rgTarget
|
releaseToRG map[uuid16]rgTarget
|
||||||
artistNames map[uuid16]string
|
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
|
// artistTracks accumulates each target artist's top recordings for
|
||||||
// S2 coverage; merged into recordings before assembly.
|
// S2 coverage; merged into recordings before assembly.
|
||||||
artistTracks *perArtistTracks
|
artistTracks *perArtistTracks
|
||||||
@@ -454,10 +473,11 @@ func (imp *dumpImporter) scanCanonicalDump(
|
|||||||
defer zr.Close()
|
defer zr.Close()
|
||||||
|
|
||||||
scan := &canonicalScan{
|
scan := &canonicalScan{
|
||||||
releaseInfos: make(map[uuid16]releaseInfo, len(ks.releases)),
|
releaseInfos: make(map[uuid16]releaseInfo, len(ks.releases)),
|
||||||
releaseToRG: make(map[uuid16]rgTarget, len(ks.releases)),
|
releaseToRG: make(map[uuid16]rgTarget, len(ks.releases)),
|
||||||
artistNames: make(map[uuid16]string, len(ks.artists)),
|
artistNames: make(map[uuid16]string, len(ks.artists)),
|
||||||
artistTracks: newPerArtistTracks(),
|
releaseTracks: make(map[uuid16]uint16, len(ks.releases)),
|
||||||
|
artistTracks: newPerArtistTracks(),
|
||||||
}
|
}
|
||||||
|
|
||||||
sawData, sawRedirect := false, false
|
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 relOK {
|
||||||
if _, kept := ks.releases[relMBID]; kept {
|
if _, kept := ks.releases[relMBID]; kept {
|
||||||
|
if n := scan.releaseTracks[relMBID]; n < maxReleaseTracks {
|
||||||
|
scan.releaseTracks[relMBID] = n + 1
|
||||||
|
}
|
||||||
|
|
||||||
if _, seen := scan.releaseInfos[relMBID]; !seen {
|
if _, seen := scan.releaseInfos[relMBID]; !seen {
|
||||||
firstArtist := ""
|
firstArtist := ""
|
||||||
if len(artistMBIDs) > 0 {
|
if len(artistMBIDs) > 0 {
|
||||||
@@ -1004,6 +1032,7 @@ func (imp *dumpImporter) assembleIndex(
|
|||||||
ArtistName: info.artistName,
|
ArtistName: info.artistName,
|
||||||
ArtistMBID: info.artistMBID,
|
ArtistMBID: info.artistMBID,
|
||||||
Popularity: int(agg.listens),
|
Popularity: int(agg.listens),
|
||||||
|
TotalTracks: int(scan.releaseTracks[agg.bestRel]),
|
||||||
CAAReleaseMBID: formatUUID(agg.canonical[:]),
|
CAAReleaseMBID: formatUUID(agg.canonical[:]),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -277,6 +277,12 @@ func canonicalDataCSV(t *testing.T) []byte {
|
|||||||
"3", "11", "{" + artA + "," + artB + "}", "Solo Star feat. Guest",
|
"3", "11", "{" + artA + "," + artB + "}", "Solo Star feat. Guest",
|
||||||
relB, "Duet Album", recC, "Duet Song", "x", "1",
|
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)
|
return csvBytes(t, rows)
|
||||||
@@ -594,7 +600,7 @@ func TestDumpImportEndToEnd(t *testing.T) {
|
|||||||
|
|
||||||
rows, err := db.QueryContext(
|
rows, err := db.QueryContext(
|
||||||
"SELECT title, popularity FROM explore_index WHERE mbid = ? AND entity_type = ?",
|
"SELECT title, popularity FROM explore_index WHERE mbid = ? AND entity_type = ?",
|
||||||
mbid, entityType,
|
dbMBID(mbid), dbEntityType(entityType),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query: %v", err)
|
t.Fatalf("query: %v", err)
|
||||||
@@ -627,11 +633,51 @@ func TestDumpImportEndToEnd(t *testing.T) {
|
|||||||
assertRow(rgB, "release_group", "Duet Album", 12)
|
assertRow(rgB, "release_group", "Duet Album", 12)
|
||||||
assertRow(artA, "artist", "Solo Star", 35)
|
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
|
// artB only ever appears in a multi-artist credit: no name is
|
||||||
// derivable from the dump, so it must be queued for the API
|
// derivable from the dump, so it must be queued for the API
|
||||||
// metadata patch instead of being written nameless.
|
// metadata patch instead of being written nameless.
|
||||||
rows, err := db.QueryContext(
|
rows, err := db.QueryContext(
|
||||||
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", artB,
|
"SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(artB),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query artB: %v", err)
|
t.Fatalf("query artB: %v", err)
|
||||||
@@ -1006,7 +1052,7 @@ func TestListenerCountUpdateDoesNotTouchPopularity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := db.QueryContext(
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("query: %v", err)
|
t.Fatalf("query: %v", err)
|
||||||
|
|||||||
@@ -277,7 +277,10 @@ func (si *SearchIndex) commitListenDeltas(
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
if _, err := tx.Exec(
|
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 {
|
); err != nil {
|
||||||
return fmt.Errorf("incremental temp table: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code := entityCode(kind)
|
||||||
|
|
||||||
for mbid, d := range deltas {
|
for mbid, d := range deltas {
|
||||||
rowArgs = append(rowArgs, mbid, kind, int64(d))
|
rowArgs = append(rowArgs, mbidBytes(mbid), code, int64(d))
|
||||||
pending++
|
pending++
|
||||||
|
|
||||||
if pending >= deltaInsertBatch {
|
if pending >= deltaInsertBatch {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func popularityOf(t *testing.T, db *database.DB, mbid string) (int, bool) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rows, err := db.QueryContext(
|
rows, err := db.QueryContext(
|
||||||
"SELECT popularity FROM explore_index WHERE mbid = ?", mbid,
|
"SELECT popularity FROM explore_index WHERE mbid = ?", dbMBID(mbid),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query popularity: %v", err)
|
t.Fatalf("query popularity: %v", err)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (imp *dumpImporter) runPatchPasses(ctx context.Context) {
|
|||||||
func (imp *dumpImporter) patchArtistMetadata(ctx context.Context) {
|
func (imp *dumpImporter) patchArtistMetadata(ctx context.Context) {
|
||||||
rows, err := imp.si.db.QueryContext(`
|
rows, err := imp.si.db.QueryContext(`
|
||||||
SELECT mbid FROM explore_index
|
SELECT mbid FROM explore_index
|
||||||
WHERE entity_type = 'artist'
|
WHERE entity_type = 1 /* artist */
|
||||||
AND (artist_type = '' OR country = '' OR title = '' OR title = mbid)
|
AND (artist_type = '' OR country = '' OR title = '' OR title = mbid)
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,7 +152,7 @@ func (imp *dumpImporter) patchSimilarArtists(ctx context.Context) {
|
|||||||
for _, s := range similar {
|
for _, s := range similar {
|
||||||
_, _ = imp.si.db.ExecContext(
|
_, _ = imp.si.db.ExecContext(
|
||||||
"UPDATE explore_index SET is_similar = 1 WHERE artist_mbid = ?",
|
"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
|
WHERE entity_type = ? AND listener_count = 0
|
||||||
ORDER BY popularity DESC
|
ORDER BY popularity DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, entityType, limit)
|
`, dbEntityType(entityType), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -229,9 +229,9 @@ func (imp *dumpImporter) topMBIDs(entityType string, limit int) []string {
|
|||||||
var mbids []string
|
var mbids []string
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m string
|
var m dbMBID
|
||||||
if err := rows.Scan(&m); err == nil {
|
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
|
`UPDATE explore_index
|
||||||
SET listener_count = ?
|
SET listener_count = ?
|
||||||
WHERE mbid = ? AND 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 {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ func seedIndexRow(
|
|||||||
_, err := db.ExecContext(`
|
_, err := db.ExecContext(`
|
||||||
INSERT INTO explore_index
|
INSERT INTO explore_index
|
||||||
(entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count)
|
(entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count)
|
||||||
VALUES (?, ?, ?, ?, '', ?, ?)
|
VALUES (?, ?, ?, ?, x'', ?, ?)
|
||||||
`, entityType, mbid, title, artist, popularity, popularity/10)
|
`, dbEntityType(entityType), dbMBID(testMBID(mbid)), title, artist, popularity, popularity/10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seed %s/%s: %v", entityType, mbid, err)
|
t.Fatalf("seed %s/%s: %v", entityType, mbid, err)
|
||||||
}
|
}
|
||||||
@@ -73,12 +73,12 @@ func TestEvalHarnessIndexRanking(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Query: "radiohead",
|
Query: "radiohead",
|
||||||
Note: "popular exact artist match",
|
Note: "popular exact artist match",
|
||||||
Expect: []eval.Expected{{Type: "artist", MBID: "rh"}},
|
Expect: []eval.Expected{{Type: "artist", MBID: testMBID("rh")}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Query: "the teenagers",
|
Query: "the teenagers",
|
||||||
Note: "low-popularity exact match must beat high-popularity article match",
|
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)
|
t.Fatalf("query %q returned no hits", tc.query)
|
||||||
}
|
}
|
||||||
|
|
||||||
if 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, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,12 +130,16 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
|
|||||||
|
|
||||||
// MusicBrainz returns the shared cached MB client so other services
|
// MusicBrainz returns the shared cached MB client so other services
|
||||||
// (e.g. autotag) can reuse it without spinning up a second limiter.
|
// (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 {
|
func (e *Service) MusicBrainz() *MusicBrainzClient {
|
||||||
return e.mb
|
return e.mb
|
||||||
}
|
}
|
||||||
|
|
||||||
// CAALimiter returns the shared Cover Art Archive rate limiter.
|
// CAALimiter returns the shared Cover Art Archive rate limiter.
|
||||||
// Consumers must respect it for any fresh CAA HTTP GETs.
|
// 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 {
|
func (e *Service) CAALimiter() *RateLimiter {
|
||||||
return e.caaLimiter
|
return e.caaLimiter
|
||||||
}
|
}
|
||||||
@@ -185,6 +189,8 @@ func (e *Service) CoreCatalogImported() bool {
|
|||||||
|
|
||||||
// SetJobRegistry wires the background job registry into the search
|
// SetJobRegistry wires the background job registry into the search
|
||||||
// index so its build reports progress and controls to the frontend.
|
// 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) {
|
func (e *Service) SetJobRegistry(reg *jobs.Registry) {
|
||||||
e.index.SetJobRegistry(reg)
|
e.index.SetJobRegistry(reg)
|
||||||
}
|
}
|
||||||
@@ -561,6 +567,7 @@ func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) {
|
|||||||
FirstReleaseDate: indexed.ReleaseDate,
|
FirstReleaseDate: indexed.ReleaseDate,
|
||||||
InLibrary: indexed.InLibrary || indexed.LocalReleaseGroupID > 0,
|
InLibrary: indexed.InLibrary || indexed.LocalReleaseGroupID > 0,
|
||||||
LocalID: indexed.LocalReleaseGroupID,
|
LocalID: indexed.LocalReleaseGroupID,
|
||||||
|
TotalTracks: indexed.TotalTracks,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background: fetch full MB data once per RG to fill in fields
|
// Background: fetch full MB data once per RG to fill in fields
|
||||||
|
|||||||
@@ -18,9 +18,22 @@ func NewLibraryMBIDIndex(db *database.DB) *LibraryMBIDIndex {
|
|||||||
return &LibraryMBIDIndex{db: db}
|
return &LibraryMBIDIndex{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckMBIDs returns which of the given MBIDs exist in the local
|
// CheckMBIDs returns which of the given MBIDs the library actually
|
||||||
// library. The returned map has MBID → entity type ("artist",
|
// has a file for. The returned map is MBID -> entity type ("artist",
|
||||||
// "release_group", or "recording").
|
// "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 {
|
func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
|
||||||
if len(mbids) == 0 {
|
if len(mbids) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -28,16 +41,26 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
|
|||||||
|
|
||||||
result := make(map[string]string, len(mbids))
|
result := make(map[string]string, len(mbids))
|
||||||
|
|
||||||
// Batch check all MBIDs against each table with a single IN query.
|
type entityQuery struct {
|
||||||
type tableEntity struct {
|
|
||||||
table string
|
|
||||||
entityType string
|
entityType string
|
||||||
|
query string
|
||||||
}
|
}
|
||||||
|
|
||||||
tables := []tableEntity{
|
queries := []entityQuery{
|
||||||
{"artists", "artist"},
|
{"recording", `SELECT DISTINCT recording_mbid FROM audio_files
|
||||||
{"release_groups", "release_group"},
|
WHERE recording_mbid IN (%s)`},
|
||||||
{"recordings", "recording"},
|
{"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.
|
// 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 {
|
if len(remaining) == 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build IN clause from remaining MBIDs.
|
|
||||||
placeholders := make([]string, 0, len(remaining))
|
placeholders := make([]string, 0, len(remaining))
|
||||||
args := make([]any, 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)
|
args = append(args, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gosec // table name is hardcoded from the tables slice above
|
//nolint:gosec // the query text is a constant from the slice above
|
||||||
query := "SELECT mbid FROM " + te.table + " WHERE mbid IN (" +
|
query := strings.Replace(
|
||||||
strings.Join(placeholders, ",") + ")"
|
eq.query, "%s", strings.Join(placeholders, ","), 1,
|
||||||
|
)
|
||||||
|
|
||||||
rows, err := idx.db.QueryContext(query, args...)
|
rows, err := idx.db.QueryContext(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -74,7 +97,7 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var mbid string
|
var mbid string
|
||||||
if err := rows.Scan(&mbid); err == nil {
|
if err := rows.Scan(&mbid); err == nil {
|
||||||
result[mbid] = te.entityType
|
result[mbid] = eq.entityType
|
||||||
delete(remaining, mbid)
|
delete(remaining, mbid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ type ListenBrainzClient struct {
|
|||||||
limiter *RateLimiter
|
limiter *RateLimiter
|
||||||
cache *Cache
|
cache *Cache
|
||||||
logger *slog.Logger
|
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.
|
// NewListenBrainzClient creates a ListenBrainz API client.
|
||||||
@@ -46,9 +52,15 @@ func NewListenBrainzClient(
|
|||||||
limiter: limiter,
|
limiter: limiter,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
logger: logger,
|
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
|
// TopRecordingsForArtist returns the most-listened recordings for
|
||||||
// the artist identified by artistMBID.
|
// the artist identified by artistMBID.
|
||||||
func (c *ListenBrainzClient) TopRecordingsForArtist(
|
func (c *ListenBrainzClient) TopRecordingsForArtist(
|
||||||
@@ -56,7 +68,7 @@ func (c *ListenBrainzClient) TopRecordingsForArtist(
|
|||||||
) ([]LBTopRecording, error) {
|
) ([]LBTopRecording, error) {
|
||||||
url := fmt.Sprintf(
|
url := fmt.Sprintf(
|
||||||
"%s/1/popularity/top-recordings-for-artist/%s",
|
"%s/1/popularity/top-recordings-for-artist/%s",
|
||||||
listenBrainzBaseURL,
|
c.baseURL,
|
||||||
artistMBID,
|
artistMBID,
|
||||||
)
|
)
|
||||||
cacheKey := "lb:top-recordings:" + artistMBID
|
cacheKey := "lb:top-recordings:" + artistMBID
|
||||||
@@ -104,7 +116,7 @@ func (c *ListenBrainzClient) TopReleaseGroupsForArtist(
|
|||||||
) ([]LBTopReleaseGroup, error) {
|
) ([]LBTopReleaseGroup, error) {
|
||||||
url := fmt.Sprintf(
|
url := fmt.Sprintf(
|
||||||
"%s/1/popularity/top-release-groups-for-artist/%s",
|
"%s/1/popularity/top-release-groups-for-artist/%s",
|
||||||
listenBrainzBaseURL,
|
c.baseURL,
|
||||||
artistMBID,
|
artistMBID,
|
||||||
)
|
)
|
||||||
cacheKey := "lb:top-release-groups:" + artistMBID
|
cacheKey := "lb:top-release-groups:" + artistMBID
|
||||||
@@ -234,7 +246,7 @@ func (c *ListenBrainzClient) ArtistPopularity(
|
|||||||
return nil, nil //nolint:nilnil
|
return nil, nil //nolint:nilnil
|
||||||
}
|
}
|
||||||
|
|
||||||
url := listenBrainzBaseURL + "/1/popularity/artist"
|
url := c.baseURL + "/1/popularity/artist"
|
||||||
cacheKey := "lb:pop:artist:" + hashMBIDs(mbids)
|
cacheKey := "lb:pop:artist:" + hashMBIDs(mbids)
|
||||||
|
|
||||||
if data, ok := c.cache.Get(cacheKey); ok {
|
if data, ok := c.cache.Get(cacheKey); ok {
|
||||||
@@ -265,7 +277,7 @@ func (c *ListenBrainzClient) RecordingPopularity(
|
|||||||
return nil, nil //nolint:nilnil
|
return nil, nil //nolint:nilnil
|
||||||
}
|
}
|
||||||
|
|
||||||
url := listenBrainzBaseURL + "/1/popularity/recording"
|
url := c.baseURL + "/1/popularity/recording"
|
||||||
cacheKey := "lb:pop:recording:" + hashMBIDs(mbids)
|
cacheKey := "lb:pop:recording:" + hashMBIDs(mbids)
|
||||||
|
|
||||||
if data, ok := c.cache.Get(cacheKey); ok {
|
if data, ok := c.cache.Get(cacheKey); ok {
|
||||||
@@ -296,7 +308,7 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity(
|
|||||||
return nil, nil //nolint:nilnil
|
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)
|
cacheKey := "lb:pop:release-group:" + hashMBIDs(mbids)
|
||||||
|
|
||||||
if data, ok := c.cache.Get(cacheKey); ok {
|
if data, ok := c.cache.Get(cacheKey); ok {
|
||||||
@@ -342,7 +354,7 @@ func (c *ListenBrainzClient) BatchArtistMetadata(
|
|||||||
return nil, nil //nolint:nilnil
|
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)
|
cacheKey := "lb:meta:artist:" + hashMBIDs(mbids)
|
||||||
|
|
||||||
if data, ok := c.cache.Get(cacheKey); ok {
|
if data, ok := c.cache.Get(cacheKey); ok {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// LyricsResult is a single lyric-search hit, mapped from the DB layer
|
// LyricsResult is a single lyric-search hit, mapped from the DB layer
|
||||||
// into the camelCase shape the frontend consumes.
|
// into the camelCase shape the frontend consumes.
|
||||||
type LyricsResult struct {
|
type LyricsResult struct {
|
||||||
RecordingID int64 `json:"recordingId"`
|
AudioFileID int64 `json:"audioFileId"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
LengthMs int64 `json:"lengthMs"`
|
LengthMs int64 `json:"lengthMs"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -55,7 +55,7 @@ func (e *Service) SearchLyrics(query string) []LyricsResult {
|
|||||||
out := make([]LyricsResult, 0, len(hits))
|
out := make([]LyricsResult, 0, len(hits))
|
||||||
for _, h := range hits {
|
for _, h := range hits {
|
||||||
out = append(out, LyricsResult{
|
out = append(out, LyricsResult{
|
||||||
RecordingID: h.RecordingID,
|
AudioFileID: h.AudioFileID,
|
||||||
FilePath: h.FilePath,
|
FilePath: h.FilePath,
|
||||||
LengthMs: h.LengthMilliseconds,
|
LengthMs: h.LengthMilliseconds,
|
||||||
Title: h.Title,
|
Title: h.Title,
|
||||||
@@ -67,18 +67,18 @@ func (e *Service) SearchLyrics(query string) []LyricsResult {
|
|||||||
return out
|
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;
|
// already has them (from embedded tags) they're returned as-is;
|
||||||
// otherwise it fetches from LRCLIB, persists them (updating the FTS
|
// otherwise it fetches from LRCLIB, persists them (updating the FTS
|
||||||
// index), and returns them. Never returns an error to the frontend —
|
// index), and returns them. Never returns an error to the frontend —
|
||||||
// a miss just yields an empty result.
|
// a miss just yields an empty result.
|
||||||
func (e *Service) GetTrackLyrics(recordingID int64) TrackLyrics {
|
func (e *Service) GetTrackLyrics(audioFileID int64) TrackLyrics {
|
||||||
stored, err := e.db.GetRecordingLyrics(recordingID)
|
stored, err := e.db.GetLyrics(audioFileID)
|
||||||
if err == nil && stored != "" {
|
if err == nil && stored != "" {
|
||||||
return TrackLyrics{Plain: stored, Source: "embedded"}
|
return TrackLyrics{Plain: stored, Source: "embedded"}
|
||||||
}
|
}
|
||||||
|
|
||||||
lookup, err := e.db.RecordingLyricLookup(recordingID)
|
lookup, err := e.db.FileLyricLookup(audioFileID)
|
||||||
if err != nil || lookup == nil {
|
if err != nil || lookup == nil {
|
||||||
return TrackLyrics{}
|
return TrackLyrics{}
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ func (e *Service) backfillLibraryLyrics(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, err := e.db.RecordingsMissingLyrics(lyricsBackfillBatch)
|
candidates, err := e.db.FilesMissingLyrics(lyricsBackfillBatch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.logger.Warn("lyrics backfill: query failed", "err", err)
|
e.logger.Warn("lyrics backfill: query failed", "err", err)
|
||||||
|
|
||||||
@@ -223,8 +223,12 @@ func (e *Service) fetchAndStoreLyrics(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := e.db.SetRecordingLyrics(c.RecordingID, lyrics.Plain); err != nil {
|
// Marked `lrclib` rather than `tag`: these came off the network and
|
||||||
e.logger.Warn("lyrics store failed", "recordingId", c.RecordingID, "err", err)
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -107,12 +107,11 @@ func (e *Service) mixSeedProfile(
|
|||||||
artistCounts[artist.ArtistMbid]++
|
artistCounts[artist.ArtistMbid]++
|
||||||
artistNames[artist.ArtistMbid] = artist.ArtistName
|
artistNames[artist.ArtistMbid] = artist.ArtistName
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p)
|
for _, names := range e.genresByPath(ctx, seedPaths) {
|
||||||
if err == nil {
|
for _, g := range names {
|
||||||
for _, g := range names {
|
genres[g] = true
|
||||||
genres[g] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +147,12 @@ func (e *Service) mixCandidates(
|
|||||||
|
|
||||||
candidates := map[string]float64{}
|
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 {
|
for seedArtistMBID, count := range artistCounts {
|
||||||
similar, err := e.SimilarArtists(seedArtistMBID)
|
similar, err := e.SimilarArtists(seedArtistMBID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,13 +183,11 @@ func (e *Service) mixCandidates(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p); err == nil {
|
for _, g := range pathGenres[p] {
|
||||||
for _, g := range names {
|
if seedGenres[g] {
|
||||||
if seedGenres[g] {
|
weight += mixGenreBoost
|
||||||
weight += mixGenreBoost
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +199,64 @@ func (e *Service) mixCandidates(
|
|||||||
return candidates
|
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
|
// weightedSample picks up to n distinct keys from weights without
|
||||||
// replacement, biased toward higher weight (roulette-wheel selection).
|
// replacement, biased toward higher weight (roulette-wheel selection).
|
||||||
// A key with zero or negative weight is never picked.
|
// A key with zero or negative weight is never picked.
|
||||||
|
|||||||
@@ -23,69 +23,14 @@ func seedMixTrack(
|
|||||||
|
|
||||||
fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id)
|
fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id)
|
||||||
|
|
||||||
_, err := db.ExecContext(
|
database.InsertTestTrack(t, db, database.TestTrack{
|
||||||
"INSERT INTO artists (id, name, mbid) VALUES (?, ?, ?) "+
|
FilePath: fp,
|
||||||
"ON CONFLICT(name) DO NOTHING",
|
Title: fmt.Sprintf("Track %d", id),
|
||||||
id, artistName, artistMBID,
|
Artist: artistName,
|
||||||
)
|
ArtistMBID: artistMBID,
|
||||||
if err != nil {
|
Genres: genreNames,
|
||||||
t.Fatalf("insert artist: %v", err)
|
LengthMs: 180000,
|
||||||
}
|
})
|
||||||
|
|
||||||
_, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fp
|
return fp
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user