diff --git a/.pi/skills/yellowjacket-dev/references/fixtures.md b/.pi/skills/yellowjacket-dev/references/fixtures.md index 77579ab..52d96f7 100644 --- a/.pi/skills/yellowjacket-dev/references/fixtures.md +++ b/.pi/skills/yellowjacket-dev/references/fixtures.md @@ -63,5 +63,24 @@ Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead address on purpose, so no seed depends on what the explore artifact server happened to be serving. -Rebuild a seed after any schema change, or the restored database is -migrated on open in a way the seed's author never saw. +Rebuild a seed after any schema change. Nothing migrates a restored +database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed +keeps its old columns, the app starts, and the first query dies on +`no such column`. + +**Restoring the seed does not disable the artifact fetch — only +*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone, +so on a developer machine the restored app immediately downloads and +imports the real ~1.1M-row catalog, through the one writer connection, +while whatever you started it for is running. A full `make e2e` against +that reported **14 failures** that were all contention; the same suite +against the same seed with + +```bash +YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default +``` + +is the configuration CI runs (`ci.yml` sets exactly that address) and is +what to use before believing a failure. The tell is in `.dev/app.log` — +an import logging progress — and in how the failures look: timeouts +spread across unrelated specs rather than one surface being wrong. diff --git a/.pi/skills/yellowjacket-dev/references/schema-change.md b/.pi/skills/yellowjacket-dev/references/schema-change.md index 76f9971..a125a56 100644 --- a/.pi/skills/yellowjacket-dev/references/schema-change.md +++ b/.pi/skills/yellowjacket-dev/references/schema-change.md @@ -1,66 +1,76 @@ # Changing the database schema -The reasoning — why there are two files, what the old 48-step migration -chain got wrong, and when squashing is legitimate — is in `CLAUDE.md` -under *Backend packages → database*. Read it once. This is the -checklist. +The reasoning — why the local library is shaped like files rather than +like MusicBrainz, and what the metadata tables cost before they went — +is in `CLAUDE.md` under *Backend packages → database*. Read it once. +This is the checklist. -**A brand-new table needs one file, not two.** The rule below is about -a *column added to a table that already exists*. `applySchema` runs -every file in `sql/schemas/` on every open, so a -`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a -migration for it would be a second description of the same table — the -thing the third rule forbids. Its indexes go in the schema file too, -because the column and the index arrive together. +**There is one description of the schema and no migration chain.** +`sql/schemas/*.sql` declares the current shape; `applySchema` runs every +file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent. +`sql/migrations/`, `applyMigrations` and `schema_migrations` were +squashed away with plan 013. So: + +**Adding a table or a column is one edit to one file.** + +```bash +make generate # sqlc + templ +go test ./backend/database/ ./backend/datamap/ +make test +``` A new table has a second gate: **`backend/datamap`**. Add an entry stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate` -wants an explicit exemption with a note, because authored data is what -a user cannot get back. +wants an explicit exemption with a note, because authored data is what a +user cannot get back. If a *column* holds a different Kind from its +table (an authored flag on an owned projection, a fetched value beside a +tag-derived one), say so in the entry's note; `audio_files` and `lyrics` +are the worked examples. -Adding a **column** to an existing table needs **two** files, not one: +**Existing databases are not migrated.** Nothing upgrades a database +from an older shape — delete your dev `YJ_HOME` and rescan, and rebuild +any seed you rely on (`make sandbox-seed NAME=default`). Revisit this +once real user databases exist in the wild. -1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT - EXISTS`, the literal target shape, what sqlc reads and what a fresh - install gets verbatim. Add the new column **last** in the - `CREATE TABLE`. -2. **`backend/database/sql/migrations/NNNN_description.sql`** — the - `ALTER TABLE ... ADD COLUMN` (and any index on it) that gets an - existing database to the same shape. Schema files are a no-op against - a table that already exists, so without this an upgrade never gets - the column. +**A stale one fails at the first query, not at open**, which is worth +knowing before you read the error. `applySchema` is +`CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns +and gains nothing; the app then starts fine and dies on +`no such column: title`. Every tier that does not *run the app* — unit +tests, `make ui-test`, `tsc` — is green while this is true, because +they build their database from the current schema. `make e2e` and +`make dev` are the two that will tell you, and only after the seed has +been rebuilt. -Then: +## The four ways this goes wrong -```bash -make generate # sqlc + templ -go test ./backend/database/ # migration + column-order tests -make test -``` +- **A query file must be ASCII.** sqlc's parameter rewriter works on + byte offsets, so a single non-ASCII character in a *query* comment + (an em dash, a curly quote) shifts every placeholder and generates + garbage like `SELECid` — a parse error a long way from its cause. + Schema files are not rewritten and may contain anything. +- **A slice and a named parameter do not compose.** `sqlc.slice` + expands to N placeholders, but `sqlc.arg` is numbered independently, + so the two in one query bind the wrong values — + `GetFilePathsByAlbums([1,2], 0)` read album id 2 as the library id. + Where a query needs both, return the column and filter in Go. +- **A write wearing a query's shape still needs the writer.** + `QueryContext`/`QueryRow` route to the query-only read pool, so an + `INSERT ... RETURNING` through one fails at runtime with "attempt to + write a readonly database (8)". Use `ExecContext`, or + `QueryRowWriter`. `TestNoWritesOnTheReadPool` walks the tree for it. +- **A view is dropped and recreated.** `CREATE VIEW IF NOT EXISTS` + no-ops against a database holding the old definition, so + `track_metadata.sql` opens with `DROP VIEW IF EXISTS`. -Rebuild any seed you rely on (`make sandbox-seed NAME=default`) and -delete your own dev `YJ_HOME` if you want to see the fresh-install path -rather than the migrated one. - -## The three ways this goes wrong - -- **Column order must match between the two paths.** `ADD COLUMN` - always appends, so a migrated column declared anywhere but last in - `CREATE TABLE` leaves fresh and upgraded installs disagreeing on - order — and sqlc binds `SELECT *` positionally, so one of them - silently reads the wrong field. - `TestMigrations_ColumnOrderMatchesFreshInstall` is the regression test. -- **Do not put an index on a migrated column in `sql/schemas/`.** - Schema files run *before* migrations, against a database that may not - have the column yet, and the predicate fails. Declare the index in the - migration, after the `ALTER TABLE`. -- **Do not add a third description of the schema anywhere.** A - migration's `ADD COLUMN` failing with "duplicate column name" against - an already-current database is expected and tolerated, not an error to - route around. +## Where things go New queries go in `backend/database/sql/queries/`; generated Go lands in -`backend/database/sql/sqlcgen/`, which is never edited by hand. Tests -use `database.NewTestDB(t)`, built by the same `applySchema` production -uses, so the two cannot diverge. +`backend/database/sql/sqlcgen/`, which is never edited by hand. Anything +returning a track selects from the `track_metadata` view rather than +re-joining — that is why there is one row type and one mapper. + +Tests use `database.NewTestDB(t)`, built by the same `applySchema` +production uses, and seed rows with `database.InsertTestTrack(t, db, +database.TestTrack{...})` rather than assembling inserts by hand. diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 64172f2..e934304 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -2354,3 +2354,43 @@ Five more things worth keeping: child does not prove the icon inside it is `pointer-events: none`. Both were checked with a real mouse (`mousemove`/`mousedown`/ `mouseup`) and a real Tab/Enter before either was believed. + +## A queue of work has to ask whether there is work + +Reported as "the autotag page has every album in it, even the MB-tagged +ones". Both halves of that were true and they were two different bugs, +which is why the first answer found (the pending list) accounted for +nine rows out of 2172. + +**Every scanned folder gets a `tagging_items` row.** +`UpsertTaggingItemOnTrackAdd` runs per track with `status = 'pending'` +and no condition, so the table is a row per album folder, not a queue. +`saveAudioFile` *does* record the answer — `audio_files.tag_status` is +`user_confirmed` on import for any file carrying a recording MBID, and +`idx_audio_files_tag_status_untagged` was declared for the filter — but +no query in the app read the column. Pending therefore meant "has a +row". The four queue queries ask the files now +(`EXISTS … tag_status = 'untagged'`), which matters most where it is +least visible: `startPrefetch` was scoring every album in a tagged +library against MusicBrainz. + +**The 2094 were the Completed section, and nobody completed them.** A +backfill stamped `status = 'confirmed'` on every folder whose files +already had MBIDs, and the sidebar keeps confirmed rows deliberately — +so the review page's history became a list of the library. They are +distinguishable without new state: every status flip the app performs +goes through `SetTaggingItemStatus` or `SetTaggingItemBestMatch` and +both stamp `last_checked_at`, so *confirmed with no `last_checked_at`, +no score and no best match* is the backfill's row and not the user's. +All 2094 were that shape; the two the app had actually applied were +not. Migration 0007 sets `cleared_at` on them — the column exists for +exactly this, and it keeps the row so a rescan cannot reset review +state. + +Two smaller things fell out. The reviewed states are **exempt** from +the untagged-files predicate, or an applied folder would vanish from +Completed the instant it succeeded — the section is history, not work. +And `tag_status` was only ever written by the *insert* path, so a file +another tagger stamped after import kept `untagged` for ever and its +folder kept asking; `updateAudioFile` promotes it now, guarded on +`untagged` so a deliberate `user_skipped_permanent` survives a rescan. diff --git a/.planning/plans/completed/013-database-audit.md b/.planning/plans/completed/013-database-audit.md new file mode 100644 index 0000000..00f1b1f --- /dev/null +++ b/.planning/plans/completed/013-database-audit.md @@ -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. diff --git a/.planning/plans/completed/014-artifact-v2.md b/.planning/plans/completed/014-artifact-v2.md new file mode 100644 index 0000000..0649caa --- /dev/null +++ b/.planning/plans/completed/014-artifact-v2.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 86d98b6..a0696bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,80 +223,101 @@ rather than renaming them. - `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below. - `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand. - **Schema changes need two things, not one.** `sql/schemas/*.sql` is - `CREATE ... IF NOT EXISTS` and is what sqlc reads — it's the single - source of truth for "what the schema looks like right now", and it's - what a fresh install gets verbatim. But it's a no-op against a - database that already has the table, so an existing install needs a - matching file in `sql/schemas/../migrations/` (e.g. - `NNNN_description.sql`, `ALTER TABLE ... ADD COLUMN ...` / - `CREATE INDEX ...`) to actually reach that shape. Both run on every - open, migrations after schema files, tracked in `schema_migrations` - so each applies once; a migration's `ALTER TABLE ADD COLUMN` failing - with "duplicate column name" on an already-current database is - expected and tolerated, not an error. + **The schema is one description, and there is no migration chain.** + `sql/schemas/*.sql` is `CREATE ... IF NOT EXISTS`, declares the + current shape of every table, and is what sqlc reads and what an + install gets verbatim. That is the whole mechanism: `sql/migrations/`, + `applyMigrations` and `schema_migrations` were squashed away with the + file-shaped rewrite (plan 013). A schema change is one edit to one + file plus `make generate`. Reintroducing a chain means reintroducing + the drift it caused before — `sql/schemas/` and the migrations + disagreed, and sqlc generated against the stale one. - A few things that bite if forgotten: - - **Column order must match between the two paths.** `ALTER TABLE - ADD COLUMN` always appends at the end, so a migrated column must - also be declared *last* in the `CREATE TABLE` in `sql/schemas/` - — otherwise a fresh install and an upgraded install disagree on - column order, and a `SELECT *` query (sqlc binds those - positionally) silently reads the wrong field on one of them. See - `backend/database/migrations_test.go`'s - `TestMigrations_ColumnOrderMatchesFreshInstall`, which is the - regression test for exactly this. - - **Don't put an index on a migrated column in `sql/schemas/`.** - Schema files run before migrations, against a database that may - not have that column yet — the index's predicate would fail - (this is precisely the bug an earlier session shipped and a user - hit at `make sandbox`). Declare it in the migration file instead, - after the `ALTER TABLE` that adds the column. - - This project **had** a 48-step migration chain before and tore it - out (see `.planning/NOTES.md`, "No migration chain") because - `sql/schemas/` had drifted from what the migrations actually - produced and sqlc silently generated against the stale version. - The design here avoids that by keeping `sql/schemas/` as the - literal target shape (not a hand-maintained description of it) - and letting migrations replay tolerantly against it — but the - same drift is possible again if a schema change ships without - updating both files. Don't reintroduce a *second* description of - the schema anywhere else. + **The local library is shaped like files, not like MusicBrainz.** + `audio_files` carries its own tags — title, artist credit, track and + disc numbers, year, composer, the recording MBID — and points at two + shared rows: `albums` (many files to one) and `artists` (many albums + to one). `file_genres` is the one genuine many-to-many. That is the + entire local model. + + It used to be MusicBrainz's: `recordings`, `release_group_recordings`, + `artist_credit` and `artist_credit_artist` sat between a file and its + own tags. Measured on a real 25,966-file library, **every** + many-to-many that model expressed was 1:1 in the data — no recording + had two files, none belonged to two release groups, and 3 credits of + 2,823 listed more than one artist. What it cost was a six-way join in + every read, a `MIN(release_group_id)` subquery in eleven queries and a + first-credited-artist subquery in nine to undo fan-outs that never + happened, and a class of bugs where a metadata row **outlived the file + that created it**: retagging a file created a new recording and + abandoned the old one, so that library carried 812 orphaned + recordings, 216 release groups and 260 artists — and 129 catalog rows + that confidently claimed to be owned by files that no longer existed. + + A few things that follow, and bite if forgotten: + - **Ownership is a file.** "Do I own this" is asked of `audio_files` + and nothing else — `GetFilePathsByRecordingMBIDs`, + `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and + `pruneStaleLocalCrossReferences` all join it. A metadata row is not + ownership; that was the bug, and it is now structurally impossible + for a row to exist without its file. + - **The projection is defined once, in the `track_metadata` view.** + Every query that returns a track selects from it, which is why + there is one row type (`sqlcgen.TrackMetadatum`) and one mapper + (`trackFromRow`). It existed before and only the raw-SQL search + paths used it, so nine hand-rolled copies had already drifted: the + view preferred the album's original year and one copy used the + track's, and the same library reported different years on different + screens. The FTS searches cannot be sqlc queries (MATCH is not in + its grammar) and spell the column list out in `search.go` — that is + the one exception and it is one constant. + - **`library_id = 0` means every library.** Each list query used to + exist twice, scoped and unscoped, with a branch at every call site + and a separate binding for each. One query answers both, and the + scoped form costs nothing measurable (23 ms against 21 ms over 26k + rows). + - **A slice and a named parameter do not compose in sqlc.** + `sqlc.slice` expands to N placeholders but a named argument is + numbered independently, so `GetFilePathsByAlbums([1,2], 0)` read + album id 2 as the library id. Where a query needs both, return + `library_id` and filter in Go (`inLibrary`). + - **A cache without a ceiling is a leak with a schedule.** Every + store that grows with use declares a budget beside its retention + (`browsedArtBudget`, `httpCacheBudget`), because an age bound does + not bound anything a user can outrun in an afternoon. + - **A query file must be ASCII.** sqlc's parameter rewriter works on + byte offsets, so one non-ASCII character in a *query* comment + corrupts the generated Go into garbage like `SELECid`. Schema files + are not rewritten and may contain anything. + - **A view is dropped and recreated, not migrated.** + `CREATE VIEW IF NOT EXISTS` no-ops against a database holding the + old definition, so `track_metadata.sql` opens with + `DROP VIEW IF EXISTS`; a view holds no data, so rebuilding it on + every open costs nothing. - **A write wearing a query's shape still needs the writer.** `DB.QueryContext`/`QueryContextWith`/`QueryRow` route to a *query-only* read pool (a second `sql.DB` over the same file), so an `INSERT ... RETURNING` issued through one fails at runtime with - "attempt to write a readonly database (8)" — which is exactly what - `CreateSmartPlaylist` did, meaning no smart playlist could be - created at all. Use `ExecContext`, or `QueryRowWriter` when the - statement really does return a row. Nothing caught this because - `NewTestDB` shares one in-memory connection and leaves `readDB` - nil, so `reader()` returns the *writer* under test and the unit - tests exercised a handle the app does not have. - `TestNoWritesOnTheReadPool` walks the tree for it, in the same - spirit as `TestNoDirectRuntimeEmits` and for the same reason — a - lint pass only sees one build configuration. - - **A new table needs one file, not two.** The two-file rule is - about a column added to a table that already exists. - `applySchema` runs every file in `sql/schemas/` on *every* open, - so a `CREATE TABLE IF NOT EXISTS` reaches an existing install - verbatim and a migration for it would be a second description of - the same table — which is exactly what the third rule forbids. - `excluded_paths` is the worked example, index included, since the - column and its index arrive together. + "attempt to write a readonly database (8)". Use `ExecContext`, or + `QueryRowWriter` when the statement really does return a row. + Nothing caught this because `NewTestDB` shares one in-memory + connection and leaves `readDB` nil, so `reader()` returns the + *writer* under test. `TestNoWritesOnTheReadPool` walks the tree for + it, in the same spirit as `TestNoDirectRuntimeEmits`. - **A new table has to say what kind of data it holds.** `backend/datamap` is a catalogue of every table's Kind and Lifetime, and `TestCatalogCoversSchema` fails on a table missing from it. `TestAuthoredCascadesAreDeliberate` then makes an *authored* table that cascades an explicit, argued exemption — - authored data is what a user cannot get back. - - **Squashing is fine pre-1.0.** While this hasn't shipped to real - users, periodically folding `sql/migrations/` into `sql/schemas/` - and deleting the migration files (then wiping your own dev/sandbox - DB) is a legitimate way to keep the migrations directory from - accumulating dev-only churn — same effect as the old "just nuke - it" workflow, opt-in instead of mandatory. Stop doing that once - real user databases exist in the wild. + authored data is what a user cannot get back. Two entries say + **MIXED KIND** and mean it: `audio_files` is an owned projection + except for `play_count`, `last_played` and `tag_status`, which are + authored; `lyrics` carries a `source` column because a lyric read + from a tag is free to rebuild and one fetched from LRCLIB is not. + - **Test data has one seeder.** `database.InsertTestTrack` inserts a + file with its artist, album and genres. Twenty test files used to + carry their own, each assembling the old FK chain in a slightly + different order. - `metadata` — Tag extraction (ID3v2, Vorbis Comments, FLAC). - `jobs` — The registry every long-running operation reports through: progress, pause/cancel, a global indicator and (for scans) a pause @@ -391,6 +412,67 @@ work happens **once, centrally**, and users download the result: (`dumpincremental.go`), and resolves artists outside the artifact's coverage lazily on first view. +**The catalog stores ids as bytes, and that is a size decision.** +`explore_index` is 2,052,200 rows, and its MBIDs and entity types were +half of it: three 36-character text columns and one storing the words +"artist", "release_group", "recording" two million times. They are 16 +raw bytes and a small integer now. Measured on a real catalog, the +table and its six indexes went **780 MB to 405 MB** — the largest +single saving available in this app, and the reason a fresh install is +~0.6 GB rather than ~1.0 GB. + +`backend/explore/mbid.go` is the only place that encoding is known. +Everything above it speaks dashed strings and entity names — +`SearchIndexResult`, the bindings, the frontend — and `dbMBID` / +`dbEntityType` convert at the SQL boundary. That confinement is the +point: the alternative is blobs reaching code that has no use for them. + +Four things about it are load-bearing, and they exist because of *how* +this fails when it fails: **SQLite does not coerce between TEXT and +BLOB**, so a query comparing the column against a 36-character string +returns no rows rather than an error, and a scan into a plain string +yields sixteen bytes of mojibake. Neither is visible except as a result +that is quietly empty. + +- **The column checks itself.** `CHECK(length(mbid) = 16)` means a + stringly *write* fails at the insert that made it. It also caught + every fixture that had been using `"rh"` as an MBID; `testMBID()` + hashes a label into a real one so they stay readable. +- **The projection is one constant and one scanner.** + `indexRowColumns` / `scanIndexRow` replaced four copies of a 22-column + list and four matching `Scan` calls — four chances to decode wrongly. + `indexRowColumnsFor("i")` is the same list qualified, for the FTS join + where both sides have a `title`. +- **A query that names an entity type inline writes the code with the + name beside it** — `entity_type = 1 /* artist */`. Splicing a Go + constant in would keep them in step automatically but makes every such + query a concatenation; `TestEntityCodesAreStable` pins the mapping + instead, because it is a storage format and changing one is not a + refactor. +- **`TestStoredEncodingRoundTrips` sweeps every read path** — lookup, + top-N, exact match, FTS search, popularity batch, the CAA map — and + asserts each returns something with a dashed id. A missed conversion + site shows up there and essentially nowhere else. + +**The artifact is read in either encoding.** A published artifact +carries whichever form the exporter that built it used, and there is one +already out there in the old text form. `artifactStoresText` asks the +artifact (`typeof(mbid)`) rather than trusting a version number, and +`artifactSelectColumns` converts on the way in — one `unhex` per row on +a once-a-month import, against requiring a rebuilt artifact before a new +build can read anything. That probe **must** run on the writer: +`core` is attached to that one connection, so `QueryContext` asks a +pool where the artifact does not exist, and the error would silently +select the conversion path for an artifact that needs none. + +**Its shape is the pattern for every column added after the fact.** +`artifactHasTotals` is the same question about `total_tracks`, on the +same handle: an artifact built before a column existed is still a +perfectly good catalog, so it is *asked* and the missing column is +selected as a literal `0`. Adding the column to the importer's SELECT +list without that is how a published artifact — which nobody can re-cut +retroactively — starts failing with `no such column`. + **Background work yields, and says so in the context.** The post-scan backfills share MusicBrainz's rate limiters with every page the user can open, and both were FIFO — so a thousand-artist enrichment put an @@ -453,7 +535,20 @@ is what every other failure here already does. SQLite's writer pool is `MaxOpenConns(1)`, so the workers queue at the Go level rather than racing for the file. -Four things about the marks are load-bearing. The marks are **a table, not +Five things about the marks are load-bearing. **A mark records that the +upstream was asked, not that it answered with something.** ListenBrainz +returns 200 and `[]` for an artist it has no popularity data for — which +is most of a long-tail library, and the same is true of an artist whose +every row falls under `indexMinPopularity` — and keying +`discog_fetched` on "did rows come back" made those artists permanent +candidates: "Filling in artist details" re-ran for up to +`discogBackfillMaxPerRun` of them on **every launch**, forever, doing +the same two fetches to the same empty answer. So `indexOneArtist` sets +the mark when both fetches *succeeded* (`fetchTopReleaseGroups` and +`fetchTopRecordings` return an error for that reason), and only a real +failure — transport, non-2xx, unreadable body — leaves the artist for +the next run. `browseFullDiscography` already had this right: an artist +with genuinely no release groups is still marked browsed. The marks are **a table, not more `explore_index` columns**, because `artifactimport.go` merges the downloaded catalog by column list — a flag added there is a second place to remember, and forgetting it silently wipes every mark on the @@ -505,6 +600,33 @@ that invents its own flat layout agrees with the bug. keeping only `explore.ArtistImageKeepNames()` and refusing an empty keep set for the reason the covers sweep refuses an empty live set. +**An age is not a ceiling, and a cache needs one.** Art for an artist +the user owns is kept indefinitely; everything else aged out after 90 +days and nothing counted it, so the same install held portraits for +**5,770 artists in a 1,301-artist library** — every artist page opened +in Explore fetches one, and a browsing afternoon is entirely inside the +retention window. `browsedArtBudget` (256 MB) is the second pass: +oldest browsed artist first, until what is left fits, with owned +artists outside the budget entirely. `httpCacheBudget` is the same rule +one cache over, and it is what makes the year-long entity TTL below +safe — once answers stop expiring, expiry stops being a bound. + +**"Owned" is a file here too.** The sweep's live set used to be "there +is an `artists` row", which the file-shaped schema made meaningless; +it joins `audio_files` now, like every other ownership question. Its +test had seeded an artists row with no file and called it owned — the +exact phantom, in the fixture of the test that guards it. + +**Only the tiers of a cover are stored.** `saveCoverArt` writes +`_sm`/`_md`/`_lg` and records the largest as `cover_art.file_path`; +`coverart.ResolveURLs` reports that one as `Original` too, because it +is the largest kept. The full-resolution image used to be written +beside them and was **1,134 MB of a 1.4 GB covers directory** against +110 MB for all three tiers — with nothing rendering it, since the grid +caps at 350 px and the largest tier is 400. The bytes are still in the +audio file, which is where they came from, so the repair pass that +regenerated tiers *from the stored original* went with it. + **Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated as TypeScript in `frontend/bindings/`, nested by Go import path — don't edit by hand. The `@go` alias absorbs the constant prefix, so a call site imports `@go/library/library.js`. **One seam states what the generated types get wrong, rather than 78 patches.** v3's generator is honest where v2's lied: a Go `nil` slice marshals to JSON `null` and always has (v2 typed it `T[]`), and a Go named string type is a closed set (v2 typed it `string`). There is no flag to turn either off, correctly. So `utils/binding.ts` states the app's actual contract at the only place it is true — `list` yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil map and drop null-valued keys (which loses nothing: `noUncheckedIndexedAccess` already makes every read `V | undefined`), and `compact` is the same for a map arriving as a *field*. Where a nullable slice is a model field there is no boundary to put it at, and those are `?? []` at the point of use. @@ -958,31 +1080,47 @@ when the whole release is owned, **Play 7 of 12** when some of it is, and **no play button at all** when none is, because a Play button that plays nothing (or seven tracks of forty) is worse than none. -`albumLibraryStatus()` is deliberately *not* what decides that. It is -four claims of decreasing confidence OR'd into one tick — a local album -id, the backend's cross-reference, a cached MBID match, and finally -*any single track* marked `inLibrary` — which is a fine answer to "is -any of this mine" and a useless basis for a button. `ownership()` -counts the displayed tracklist instead, whose `inLibrary` flags the -backend sets per recording MBID. +**The page asks one question, once, and it is "is there a file".** +Ownership used to be several claims of decreasing confidence OR'd +together — a local album id, the backend's cross-reference, a cached +MBID match, and finally *any single track* flagged `inLibrary` — none +of which is "there is a file to play", which is why the tick could be +green on an album whose every action did nothing, and why the +tracklist's context menu asked the backend on **hover** whether the row +it was drawing was owned. `filePaths` is the one answer: a map from a +displayed track to its path, filled once from `updated()` by a single +batched `GetFilePathsByRecordingMBIDs`, and read by the badge, the Play +button's count, the dimmed rows and every menu item. `askedFor` is a +separate set from `filePaths` because the guard has to be *asked*, not +*answered* — an unowned MBID never lands in the map, so guarding on the +map re-requests it on every render, forever. + +The other half is that the *displayed* tracklist is also one thing. +`buildVersionEntries` synthesises the "Your Library" entry from +`localTracks`, so an album the catalog cannot answer for is exactly the +case that needs the version list rebuilt — `loadLocalTracks` guarded +that rebuild on `releases.length > 0` and so rendered "No release data +available" over a tracklist it was holding in memory. **And the key it plays by is not the key it looks owned by.** The local album id is used wherever there is one, because a library-only album has *no* recording MBIDs — its tracks are synthesised from `GetAlbumTracks` with `mbid: RecordingMBID || ''` — so an MBID-keyed lookup on an untagged library resolves to nothing and Play queues -nothing while looking entirely correct. -`GetFilePathsByRecordingMBIDs` is the catalog-only fallback and the -third member of the `GetFilePathsBy…` family: one query, paths only, -grouped so the caller keeps the tracklist's order. It exists rather -than a lookup by track id because **`MBTrack.LocalID` is declared and -nothing in the backend ever writes it**. +nothing while looking entirely correct. Those synthesised tracks carry +their `FilePath` from the same rows, so they populate `filePaths` +directly and cost no lookup at all; the batched +`GetFilePathsByRecordingMBIDs` is what answers for a *catalog* +tracklist. It is the third member of the `GetFilePathsBy…` family: one +query, paths only, grouped so the caller keeps the tracklist's order. +It exists rather than a lookup by track id because **`MBTrack.LocalID` +is declared and nothing in the backend ever writes it**. **How much of an album is here is a question the files can answer.** `ownership()` above counts the *displayed* tracklist, which for a -library copy is a tautology — every local track is `inLibrary: true`, -so owned always equals total and "do I have all of this" had no local -answer. The album page therefore asked MusicBrainz, and +library copy is a tautology — every local track has a file, so owned +always equals total and "do I have all of this" had no local answer. +The album page therefore asked MusicBrainz, and `BrowseReleases` is the most expensive call the app makes: releases plus every version's full tracklist, on a 1 req/s limiter shared with `PrefetchReleases`, which fires up to eight when an artist page @@ -990,8 +1128,7 @@ renders. The denominator was already on disk. `metadata` has read the "5/12" totals off every file since forever (`m.Track()`, `m.Disc()`) and -discarded them; they persist to -`release_group_recordings.total_tracks` now, and +discarded them; they persist to `audio_files.total_tracks` now, and `GetAlbumCompleteness` sums them. **A complete, MBID-matched album makes no catalog call at all** — identity from the MBID, tracklist from the tags, which between them are what the browse was being spent on. @@ -1010,9 +1147,30 @@ Owned counts *distinct track numbers* for the same reason in reverse: this app detects duplicates, and counting two files of track 3 twice would report a short album as complete. -What tags cannot give is *which* tracks are missing, only how many — so -an incomplete album still browses, and that is now the exception rather -than every album load. Two smaller consequences: existing databases +**Where the tags have no total, the catalog does.** `explore_index` +carries a per-release-group `total_tracks` — ~2 bytes across 400,677 +rows, about 800 kB of artifact — and `completenessAnswer()` merges the +two: the numerator stays local (distinct track numbers on disk) and +only the denominator is borrowed, because a catalog total describes the +canonical release while the files' own total, where they declare one, +describes the release the user actually has. Zero still means "the +catalog does not say", so an album neither side can total keeps the +plain tick. + +Two rules hold up the column itself. **It is counted before the +popularity filter**: `cmd/indexbuild` counts the canonical dump's rows +per kept release, and counting only the *kept recordings* would say "9" +about a twelve-track album whose other three nobody has played — the +same confident lie that kept whole tracklists out of the artifact. +And **adding a column to the importer's SELECT is how you break every +artifact already published**, so `artifactHasTotals()` asks the +attached artifact whether the column is there (on the writer, where +`core` is attached) and selects a literal `0` when it is not — the same +shape as the encoding probe beside it. + +What neither side can give is *which* tracks are missing, only how many +— so an incomplete album still browses, and that is now the exception +rather than every album load. Two smaller consequences: existing databases read "unknown" until a rescan repopulates the column (which degrades to exactly the old behaviour, so nothing breaks), and our own `tagwriter` writes track and disc *numbers* but not totals, so autotagging a folder @@ -1095,6 +1253,27 @@ missing half; `catalogFailed` is the only route to `unavailable` now, and the timer is a 60 s backstop for a genuine hang rather than the verdict. +**Activating a row plays the list the row is in, from that row.** A +double-click — and Play on a single row's context menu — queues the +list as *displayed* with `startIndex` on that row, not a queue of one +track that stops when the song ends. The two playlist views and +`cover-grid`'s album dropdown always did this; the album page and +`track-list` did not, so playing anything from the two largest +tracklists in the app discarded the album around it. Three rules come +with it. **A menu asks how much is selected**: one row is a position +and means "from here", several rows are an explicit choice of *those* +tracks and become the queue on their own (which is also the only case +where `shuffleStart` still applies, since no one row was named as the +place to start). **The index is into the paths, not into the rows** — +`explore-album-details` queues `ownedFilePaths()` and its dimmed rows +are not in it, so an index taken from the tracklist starts an album +somewhere else entirely, or past its end. And **the index is looked up +when it is used, not remembered**: selection keys are file paths +because those survive a re-sort, a re-filter and a refetch, and an +index survives none of the three — `displayIndexOf` is that lookup, +against `cachedSortedTracks`, which is the only order the user can see +and therefore the only one they can mean. + **Ask for what the caller uses, once.** "Play this artist" resolved file paths with one `GetAlbumTracks` per album, sequentially, and every one of the four sites doing that asked for whole track rows to read diff --git a/backend/autotag/apply.go b/backend/autotag/apply.go index 92aadb0..578798f 100644 --- a/backend/autotag/apply.go +++ b/backend/autotag/apply.go @@ -328,43 +328,42 @@ func (a *Applier) Apply( func (a *Applier) syncDBMBIDs( ctx context.Context, tr TrackApply, cand Candidate, ) error { - // Look up recording row via audio_file. - af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID) - if err != nil { - return fmt.Errorf("get audio_file: %w", err) - } - if tr.CandidateTrack.MBID != "" { - if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{ - Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true}, - ID: af.RecordingID, + if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{ + RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true}, + ID: tr.Local.AudioFileID, }); err != nil { return fmt.Errorf("set recording mbid: %w", err) } } - if cand.ReleaseGroupMBID != "" { - rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID) - if err == nil && rgID > 0 { - if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{ - Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true}, - ID: rgID, - }); err != nil { - return fmt.Errorf("set release group mbid: %w", err) - } + if cand.ReleaseGroupMBID == "" { + return nil + } - // Stamp the release-group's original-release year too — - // this is what the tracklist / smart-playlist year rule - // surfaces by default once the user accepts a candidate. - if year := parseYear(cand.OriginalDate); year > 0 { - if err := a.q.SetReleaseGroupOriginalYear( - ctx, sqlcgen.SetReleaseGroupOriginalYearParams{ - OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true}, - ID: rgID, - }, - ); err != nil { - return fmt.Errorf("set release group original year: %w", err) - } + // The album is reached through the file rather than through two + // join tables; SetFileAlbumMBID takes the file id and does the + // lookup in one statement. + if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{ + Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true}, + ID: tr.Local.AudioFileID, + }); err != nil { + return fmt.Errorf("set album mbid: %w", err) + } + + // Stamp the album's original-release year too - this is what the + // tracklist and the smart-playlist year rule surface by default + // once the user accepts a candidate. + if year := parseYear(cand.OriginalDate); year > 0 { + af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID) + if err == nil && af.AlbumID.Valid { + if err := a.q.SetAlbumOriginalYear( + ctx, sqlcgen.SetAlbumOriginalYearParams{ + OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true}, + ID: af.AlbumID.Int64, + }, + ); err != nil { + return fmt.Errorf("set album original year: %w", err) } } } diff --git a/backend/autotag/apply_test.go b/backend/autotag/apply_test.go index f8fa813..2dbb8dd 100644 --- a/backend/autotag/apply_test.go +++ b/backend/autotag/apply_test.go @@ -2,7 +2,6 @@ package autotag_test import ( "context" - "database/sql" "log/slog" "sync" "testing" @@ -87,51 +86,22 @@ func seedAudioFiles( q := db.Queries ctx := db.Ctx - ac, err := q.UpsertArtistCredit(ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: "Test Album", - AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true}, - }) - if err != nil { - t.Fatalf("upsert rg: %v", err) - } - out := make([]sqlcgen.AudioFile, 0, len(paths)) for i, p := range paths { - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: p, - ArtistCreditID: ac.ID, - TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true}, + id := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: p, + Title: p, + Artist: "Test Artist", + Album: "Test Album", + TrackNumber: int64(i + 1), + LengthMs: 100000, + GroupKey: groupKey, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: rg.ID, - RecordingID: rec.ID, - TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true}, - }); err != nil { - t.Fatalf("link rg recording: %v", err) - } - - af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: p, - LengthMilliseconds: 100000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: p, - LibraryID: 0, - GroupKey: groupKey, - TagStatus: "untagged", - }) + af, err := q.GetAudioFile(ctx, id) if err != nil { - t.Fatalf("create audio file: %v", err) + t.Fatalf("read seeded audio file: %v", err) } out = append(out, af) diff --git a/backend/autotag/local.go b/backend/autotag/local.go index 759fde7..6b3e915 100644 --- a/backend/autotag/local.go +++ b/backend/autotag/local.go @@ -56,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup( } // ResolveLocal returns candidate releases sourced from the local -// DB's release_groups rows (filtered to those carrying an MBID) +// DB's albums (filtered to those carrying an MBID) // whose normalized name matches the tagging item's album name. // No network calls. Candidates carry all tracks flat; caller runs // AlignTracks on each to produce per-track alignments. @@ -67,7 +67,7 @@ func (r *LocalResolver) ResolveLocal( return nil, nil } - rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName) + rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName) if err != nil { return nil, fmt.Errorf("list local candidates: %w", err) } @@ -84,12 +84,12 @@ func (r *LocalResolver) ResolveLocal( continue } - if _, ok := byID[row.ReleaseGroupID]; !ok { - byID[row.ReleaseGroupID] = localCandidate(row) + if _, ok := byID[row.AlbumID]; !ok { + byID[row.AlbumID] = localCandidate(row) } - tracksByID[row.ReleaseGroupID] = append( - tracksByID[row.ReleaseGroupID], + tracksByID[row.AlbumID] = append( + tracksByID[row.AlbumID], CandidateTrack{ Position: int(row.TrackNumber), DiscNumber: int(row.DiscNumber), @@ -113,15 +113,15 @@ func (r *LocalResolver) ResolveLocal( // localCandidate converts one sqlc row (minus track-level fields) // into a Candidate shell. Track fields and alignments are filled // in by the caller. -func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate { +func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate { date := "" if row.Year > 0 { date = fmt.Sprintf("%04d", row.Year) } mbid := "" - if row.ReleaseGroupMbid.Valid { - mbid = row.ReleaseGroupMbid.String + if row.AlbumMbid.Valid { + mbid = row.AlbumMbid.String } return &Candidate{ diff --git a/backend/autotag/scorer_test.go b/backend/autotag/scorer_test.go index 1fb9303..9a5ed3b 100644 --- a/backend/autotag/scorer_test.go +++ b/backend/autotag/scorer_test.go @@ -2,13 +2,11 @@ package autotag_test import ( "context" - "database/sql" "log/slog" "testing" "yellowjacket/backend/autotag" "yellowjacket/backend/database" - "yellowjacket/backend/database/sql/sqlcgen" ) // seedAlbum drops a minimal release_group + recordings + audio_files @@ -33,70 +31,19 @@ type seededTrack struct { func seed(t *testing.T, db *database.DB, album seededAlbum) { t.Helper() - ctx := db.Ctx - q := db.Queries - - ac, err := q.UpsertArtistCredit(ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert ac: %v", err) - } - - rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: album.albumName, - AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true}, - }) - if err != nil { - t.Fatalf("upsert rg: %v", err) - } - - if album.releaseMBID != "" { - if _, err := db.ExecContext( - `UPDATE release_groups SET mbid = ? WHERE id = ?`, - album.releaseMBID, rg.ID, - ); err != nil { - t.Fatalf("set rg mbid: %v", err) - } - } - for _, tr := range album.tracks { - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: tr.title, - ArtistCreditID: ac.ID, - TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true}, + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: tr.filePath, + Title: tr.title, + Artist: "Test Artist", + Album: album.albumName, + AlbumMBID: album.releaseMBID, + RecordingMBID: tr.recordingMBID, + TrackNumber: int64(tr.trackNumber), + LengthMs: tr.lengthMillis, + LibraryID: album.libraryID, + GroupKey: album.groupKey, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - if tr.recordingMBID != "" { - if _, err := db.ExecContext( - `UPDATE recordings SET mbid = ? WHERE id = ?`, - tr.recordingMBID, rec.ID, - ); err != nil { - t.Fatalf("set recording mbid: %v", err) - } - } - - if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: rg.ID, - RecordingID: rec.ID, - TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true}, - }); err != nil { - t.Fatalf("link rg recording: %v", err) - } - - if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: tr.filePath, - LengthMilliseconds: tr.lengthMillis, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: tr.filePath, - LibraryID: album.libraryID, - GroupKey: album.groupKey, - TagStatus: "untagged", - }); err != nil { - t.Fatalf("create audio file: %v", err) - } } if _, err := db.ExecContext(` diff --git a/backend/autotagservice/apply_jobs.go b/backend/autotagservice/apply_jobs.go index 56c8105..d41ab53 100644 --- a/backend/autotagservice/apply_jobs.go +++ b/backend/autotagservice/apply_jobs.go @@ -19,6 +19,8 @@ const applyJobPrefix = "autotag:" // registry gets progress, cancel and the global indicator for free; the // three subsystems that lacked them were the three that were not // registered. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (s *Service) SetJobRegistry(reg *jobs.Registry) { s.mu.Lock() s.jobsReg = reg diff --git a/backend/autotagservice/service_test.go b/backend/autotagservice/service_test.go index e341778..3092377 100644 --- a/backend/autotagservice/service_test.go +++ b/backend/autotagservice/service_test.go @@ -3,12 +3,12 @@ package autotagservice import ( "database/sql" "errors" + "fmt" "log/slog" "testing" "yellowjacket/backend/autotag" "yellowjacket/backend/database" - "yellowjacket/backend/database/sql/sqlcgen" ) // newTestService builds a Service with just enough wired up for @@ -36,62 +36,18 @@ func newTestService(t *testing.T, db *database.DB) *Service { func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) { t.Helper() - ctx := db.Ctx - q := db.Queries - addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) { - ac, err := q.UpsertArtistCredit(ctx, artist) - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: title, - ArtistCreditID: ac.ID, - TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true}, + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: filePath, + Title: title, + Artist: artist, + Album: album, + AlbumArtist: albumArtist, + TrackNumber: int64(trackNum), + LengthMs: 200000, + LibraryID: libraryID, + GroupKey: groupKey, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - if album != "" { - albumArtistAC, err := q.UpsertArtistCredit(ctx, albumArtist) - if err != nil { - t.Fatalf("upsert album artist credit: %v", err) - } - - rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: album, - AlbumArtistCreditID: sql.NullInt64{Int64: albumArtistAC.ID, Valid: true}, - }) - if err != nil { - t.Fatalf("upsert release group: %v", err) - } - - if _, err := q.CreateReleaseGroupRecording( - ctx, - sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: rg.ID, - RecordingID: rec.ID, - TrackNumber: sql.NullInt64{Int64: int64(trackNum), Valid: true}, - }, - ); err != nil { - t.Fatalf("link release group recording: %v", err) - } - } - - if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: filePath, - LengthMilliseconds: 200000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: filePath, - LibraryID: libraryID, - GroupKey: groupKey, - TagStatus: "untagged", - }); err != nil { - t.Fatalf("create audio file: %v", err) - } } addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1) @@ -113,58 +69,22 @@ func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryI func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) { t.Helper() - ctx := db.Ctx - q := db.Queries - - ac, err := q.UpsertArtistCredit(ctx, "The Beatles") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: "Abbey Road", - AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true}, - }) - if err != nil { - t.Fatalf("upsert release group: %v", err) - } - - titles := []string{"Come Together", "Something", "Maxwell's Silver Hammer", "Oh! Darling"} - for i, title := range titles { - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: title, - ArtistCreditID: ac.ID, - TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true}, + for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} { + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1), + Title: title, + Artist: "The Beatles", + Album: "Abbey Road", + TrackNumber: int64(i + 1), + LengthMs: 200000, + LibraryID: libraryID, + GroupKey: groupKey, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: rg.ID, - RecordingID: rec.ID, - TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true}, - }); err != nil { - t.Fatalf("link release group recording: %v", err) - } - - if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: groupKey + "/" + title + ".mp3", - LengthMilliseconds: 200000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: title + ".mp3", - LibraryID: libraryID, - GroupKey: groupKey, - TagStatus: "untagged", - }); err != nil { - t.Fatalf("create audio file: %v", err) - } } if _, err := db.ExecContext(` INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status) - VALUES (?, ?, 4, 'Abbey Road', 'The Beatles', 0, 'pending') + VALUES (?, ?, 3, 'Abbey Road', 'The Beatles', 0, 'pending') `, groupKey, libraryID); err != nil { t.Fatalf("insert tagging item: %v", err) } @@ -293,20 +213,11 @@ func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) { db := database.NewTestDB(t) - if _, err := db.Queries.CreateAudioFileWithGroupKey( - db.Ctx, - sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: "/coherent/01.mp3", - FileTypeID: 0, - RecordingID: mustCreateRecording(t, db, "Track"), - Basename: "01.mp3", - LibraryID: 0, - GroupKey: "g-coherent", - TagStatus: "untagged", - }, - ); err != nil { - t.Fatalf("create audio file: %v", err) - } + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/coherent/01.mp3", + Title: "Track", + GroupKey: "g-coherent", + }) if _, err := db.ExecContext(` INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status) @@ -328,20 +239,11 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) { db := database.NewTestDB(t) // A real, live folder — must survive. - if _, err := db.Queries.CreateAudioFileWithGroupKey( - db.Ctx, - sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: "/live/01.mp3", - FileTypeID: 0, - RecordingID: mustCreateRecording(t, db, "Track"), - Basename: "01.mp3", - LibraryID: 0, - GroupKey: "g-live", - TagStatus: "untagged", - }, - ); err != nil { - t.Fatalf("create audio file: %v", err) - } + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/live/01.mp3", + Title: "Track", + GroupKey: "g-live", + }) if _, err := db.ExecContext(` INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status) @@ -385,22 +287,3 @@ func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) { t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err) } } - -func mustCreateRecording(t *testing.T, db *database.DB, title string) int64 { - t.Helper() - - ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rec, err := db.Queries.CreateRecordingFull(db.Ctx, sqlcgen.CreateRecordingFullParams{ - Name: title, - ArtistCreditID: ac.ID, - }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - return rec.ID -} diff --git a/backend/coverart/coverart.go b/backend/coverart/coverart.go index 21f46b5..2a7f653 100644 --- a/backend/coverart/coverart.go +++ b/backend/coverart/coverart.go @@ -12,7 +12,15 @@ import ( // PathPrefix is the URL path prefix for cover art served by the asset handler. const PathPrefix = "/covers/" -// URLs holds the resolved URL paths for all cover art size variants. +// URLs holds the resolved URL paths for a cover's size variants. +// +// Original is the largest variant kept, which is the Large one: the +// full-resolution image is no longer stored. It was 1,134 MB of a +// 1.4 GB covers directory on a real 2,057-album library against 110 MB +// for all three rendered tiers, and nothing rendered it - the grid caps +// at 350 px and the largest tier is 400. The field keeps its name +// because it is what a caller means by "the cover", and the bytes it +// came from are still in the audio file if a bigger one is ever wanted. type URLs struct { Original string Small string @@ -36,25 +44,41 @@ func CoversDir() (string, error) { return filepath.Join(dataDir, dirName), nil } -// SizedFilename derives a sized-variant filename from an original cover art -// filename and a size suffix. -// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg". -func SizedFilename(originalFilename, suffix string) string { - ext := filepath.Ext(originalFilename) - name := strings.TrimSuffix(originalFilename, ext) +// Suffixes are the size variants a cover is stored as, largest last. +var Suffixes = []string{"_sm", "_md", "_lg"} - return name + suffix + ".jpg" +// SizedFilename derives a sized-variant filename from a cover art +// filename and a size suffix. The input may itself be a variant, so +// its suffix is stripped first: SizedFilename("a1b2_lg.jpg", "_sm") +// and SizedFilename("a1b2.jpg", "_sm") both return "a1b2_sm.jpg". +func SizedFilename(filename, suffix string) string { + return BaseName(filename) + suffix + ".jpg" +} + +// BaseName strips the extension and any size suffix from a cover art +// filename, leaving the content hash that identifies the cover. +func BaseName(filename string) string { + name := strings.TrimSuffix(filename, filepath.Ext(filename)) + + for _, suffix := range Suffixes { + if strings.HasSuffix(name, suffix) { + return strings.TrimSuffix(name, suffix) + } + } + + return name } // ResolveURLs converts a cover art filesystem path into URL paths // for the original and all size variants (small, medium, large). func ResolveURLs(filesystemPath string) URLs { base := filepath.Base(filesystemPath) + large := PathPrefix + SizedFilename(base, "_lg") return URLs{ - Original: PathPrefix + base, + Original: large, Small: PathPrefix + SizedFilename(base, "_sm"), Medium: PathPrefix + SizedFilename(base, "_md"), - Large: PathPrefix + SizedFilename(base, "_lg"), + Large: large, } } diff --git a/backend/coverart/coverart_test.go b/backend/coverart/coverart_test.go index 7e32f2b..2af0d00 100644 --- a/backend/coverart/coverart_test.go +++ b/backend/coverart/coverart_test.go @@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) { t.Parallel() tests := []struct { - name string - path string + name string + path string + // Original is the largest kept variant: the full-resolution + // image is not stored (see URLs). wantOrig string wantSm string wantMd string @@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) { { name: "absolute path", path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg", - wantOrig: "/covers/a1b2c3d4.jpg", + wantOrig: "/covers/a1b2c3d4_lg.jpg", wantSm: "/covers/a1b2c3d4_sm.jpg", wantMd: "/covers/a1b2c3d4_md.jpg", wantLg: "/covers/a1b2c3d4_lg.jpg", @@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) { { name: "bare filename", path: "abcdef01.png", - wantOrig: "/covers/abcdef01.png", + wantOrig: "/covers/abcdef01_lg.jpg", wantSm: "/covers/abcdef01_sm.jpg", wantMd: "/covers/abcdef01_md.jpg", wantLg: "/covers/abcdef01_lg.jpg", diff --git a/backend/database/database.go b/backend/database/database.go index 1366c9b..16c9e73 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -5,13 +5,10 @@ import ( "context" "database/sql" "embed" - "errors" "fmt" "io/fs" "log/slog" "path" - "sort" - "strconv" "strings" _ "modernc.org/sqlite" // Register sqlite driver. @@ -26,9 +23,6 @@ import ( //go:embed sql/schemas/*.sql var schemas embed.FS -//go:embed sql/migrations/*.sql -var migrations embed.FS - // DB wraps the SQLite database connection and queries. // // Two handles back a single database file. db is the single-writer @@ -301,20 +295,19 @@ func (d *DB) ResumeExploreIndexFTS() error { return nil } -// applySchema creates the full schema on a fresh database and brings -// an existing one up to date via sql/migrations. +// applySchema creates the full schema. // -// The schema files under sql/schemas are CREATE ... IF NOT EXISTS, -// so on a genuinely new database they create every table already at -// its current, latest shape — that's the fast path new installs -// take. A database that already has an older shape (e.g. a -// tagging_items missing a column a later build added) needs the gap -// closed, which IF NOT EXISTS can't do: it silently no-ops on a -// table that already exists, columns and all. sql/migrations holds -// small, additive, numbered files (ALTER TABLE, CREATE INDEX, etc.) -// for exactly that gap, tracked in schema_migrations so each applies -// at most once — see applyMigrations for how a fresh database's -// already-current tables tolerate replaying them anyway. +// The schema files under sql/schemas are CREATE ... IF NOT EXISTS and +// declare the current, latest shape of every table — so running them +// against a fresh database produces exactly that shape, and running +// them against a database already at that shape does nothing. That is +// the whole mechanism; there is no migration chain and no +// schema_migrations table. +// +// There was one, and it was squashed (see .planning/plans/013): a chain +// only earns its keep once real user databases exist in the wild, and +// until then it is a second description of the schema that can drift +// from the first — which this project has already been bitten by once. func applySchema(ctx context.Context, db *sql.DB) error { dirEntries, err := schemas.ReadDir("sql/schemas") if err != nil { @@ -344,171 +337,9 @@ func applySchema(ctx context.Context, db *sql.DB) error { return fmt.Errorf("could not create explore FTS triggers: %w", err) } - if err := applyMigrations(ctx, db); err != nil { - return fmt.Errorf("could not apply migrations: %w", err) - } - - // The download subsystem's Want/Request rename reuses table names - // (download_requests names a different table before and after), so - // it cannot be a plain sql/migrations file the way an ADD COLUMN - // migration can; see download_rename_migration.go for why. - if err := migrateDownloadRename(ctx, db); err != nil { - return fmt.Errorf("could not migrate download rename: %w", err) - } - return nil } -// schemaMigrationsTable tracks which sql/migrations files have run, -// by their leading numeric prefix. -const schemaMigrationsTable = ` -CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -)` - -// applyMigrations runs every sql/migrations file not yet recorded in -// schema_migrations, in filename order (numeric prefix), one -// statement at a time. -// -// Every migration runs on EVERY database, fresh or old — there is no -// "skip on fresh install" branch. A fresh database's tables already -// carry a migration's effect (sql/schemas declares the target shape -// directly), so its statements are expected to sometimes be no-ops -// there: "duplicate column name" from an ALTER TABLE ADD COLUMN is -// tolerated and treated as "already applied", the same way -// createExploreIndexFTSTriggers tolerates "already exists". Any -// other error is fatal. This is deliberately simpler than detecting -// "is this database fresh" — every migration converges both a fresh -// and an upgraded database to the identical final schema (including -// column order — ALTER TABLE ADD COLUMN always appends at the end, -// so sql/schemas must declare a migrated column last too; see the -// comment on tagging_items.sql and the regression test in -// migrations_test.go). -func applyMigrations(ctx context.Context, db *sql.DB) error { - if _, err := db.ExecContext(ctx, schemaMigrationsTable); err != nil { - return fmt.Errorf("create schema_migrations: %w", err) - } - - dirEntries, err := migrations.ReadDir("sql/migrations") - if err != nil { - return fmt.Errorf("could not read migrations directory: %w", err) - } - - sort.Slice(dirEntries, func(i, j int) bool { - return dirEntries[i].Name() < dirEntries[j].Name() - }) - - for _, dirEntry := range dirEntries { - if dirEntry.IsDir() { - continue - } - - version, err := migrationVersion(dirEntry.Name()) - if err != nil { - return err - } - - applied, err := migrationApplied(ctx, db, version) - if err != nil { - return err - } - - if applied { - continue - } - - filePath := path.Join("sql/migrations", dirEntry.Name()) - - sqlContent, err := fs.ReadFile(migrations, filePath) - if err != nil { - return fmt.Errorf("could not read file %s: %w", filePath, err) - } - - if err := execMigrationStatements(ctx, db, string(sqlContent)); err != nil { - return fmt.Errorf("error executing migration %s: %w", dirEntry.Name(), err) - } - - if _, err := db.ExecContext( - ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, version, - ); err != nil { - return fmt.Errorf("record migration %d applied: %w", version, err) - } - } - - return nil -} - -// execMigrationStatements runs a migration file one statement at a -// time — NOT as one multi-statement Exec — so that one statement -// being a tolerable no-op (ALTER TABLE ADD COLUMN on a fresh -// database) doesn't abort the statements after it in the same file -// (e.g. a trailing CREATE INDEX that a fresh database still needs, -// since sql/schemas deliberately doesn't declare an index on a -// migrated column — see the comment on tagging_items.sql). -// -// Splitting on ";" is safe for the simple ALTER/CREATE TABLE/CREATE -// INDEX statements migrations are expected to contain; it is NOT -// safe for statements embedding a literal semicolon (e.g. a CREATE -// TRIGGER body) — write those with executeContext calls in Go -// instead of a sql/migrations file, the same way the explore FTS -// triggers already are. -func execMigrationStatements(ctx context.Context, db *sql.DB, script string) error { - for stmt := range strings.SplitSeq(script, ";") { - stmt = strings.TrimSpace(stmt) - if stmt == "" { - continue - } - - if _, err := db.ExecContext(ctx, stmt); err != nil { - if strings.Contains(err.Error(), "duplicate column name") { - continue - } - - return fmt.Errorf("statement %q: %w", stmt, err) - } - } - - return nil -} - -// migrationVersion extracts the leading integer prefix from a -// migration filename, e.g. "0001_tagging_items_synthetic.sql" -> 1. -func migrationVersion(filename string) (int, error) { - prefix, _, ok := strings.Cut(filename, "_") - if !ok { - return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename) - } - - version, err := strconv.Atoi(prefix) - if err != nil { - return 0, fmt.Errorf("%w: %s", errMigrationFilename, filename) - } - - return version, nil -} - -var errMigrationFilename = errors.New( - "migration filename must start with a numeric prefix followed by '_' (e.g. 0001_description.sql)", -) - -func migrationApplied(ctx context.Context, db *sql.DB, version int) (bool, error) { - var v int - - err := db.QueryRowContext( - ctx, `SELECT version FROM schema_migrations WHERE version = ?`, version, - ).Scan(&v) - - switch { - case errors.Is(err, sql.ErrNoRows): - return false, nil - case err != nil: - return false, fmt.Errorf("check migration %d: %w", version, err) - default: - return true, nil - } -} - // applyPRAGMAs configures SQLite connection settings. Called by both // NewDB and NewTestDB to ensure identical behavior. func applyPRAGMAs(ctx context.Context, db *sql.DB) error { diff --git a/backend/database/database_test.go b/backend/database/database_test.go index 960238b..c8405ec 100644 --- a/backend/database/database_test.go +++ b/backend/database/database_test.go @@ -312,31 +312,13 @@ func TestPhantomPlaylistTracksAreCleaned(t *testing.T) { // Create prerequisite data: artist_credit, recording, // audio_file. - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) " + - "VALUES (1, 'Test Song', 1)", - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO audio_files "+ - "(id, file_path, length_milliseconds, file_type_id, "+ - "recording_id, library_id) "+ - "VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)", - libID, - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } + InsertTestTrack(t, db, TestTrack{ + FilePath: "/test/music/song.mp3", + Title: "Test Song", + Artist: "Test Artist", + LengthMs: 180000, + LibraryID: libID, + }) // Create playlist. playlist, err := db.Queries.CreatePlaylist( @@ -442,39 +424,18 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) { db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib") // Insert prerequisite recording. + InsertTestTrack(t, db, TestTrack{ + FilePath: "/test/track.mp3", + Title: "Track", + Artist: "Test", + LibraryID: libID, + }) + + // Insert audio file with invalid library_id - should fail FK. _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (1, 'Test')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) " + - "VALUES (1, 'Track', 1)", - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - // Insert audio file with valid library_id — should succeed. - _, err = db.ExecContext( - "INSERT INTO audio_files "+ - "(id, file_path, length_milliseconds, file_type_id, "+ - "recording_id, library_id) "+ - "VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)", - libID, - ) - if err != nil { - t.Fatalf("insert audio_file with valid library: %v", err) - } - - // Insert audio file with invalid library_id — should fail FK. - _, err = db.ExecContext( "INSERT INTO audio_files " + - "(id, file_path, length_milliseconds, file_type_id, " + - "recording_id, library_id) " + - "VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)", + "(id, file_path, length_milliseconds, file_type_id, library_id) " + + "VALUES (2, '/test/song2.mp3', 200000, 0, 999)", ) if err == nil { t.Error( @@ -483,16 +444,16 @@ func TestAudioFilesLibraryForeignKey(t *testing.T) { } // Count files by library. - count, err := db.Queries.CountAudioFilesByLibrary( + count, err := db.Queries.CountAudioFiles( db.Ctx, libID, ) if err != nil { - t.Fatalf("CountAudioFilesByLibrary: %v", err) + t.Fatalf("CountAudioFiles: %v", err) } if count != 1 { t.Errorf( - "CountAudioFilesByLibrary = %d, want 1", count, + "CountAudioFiles = %d, want 1", count, ) } } @@ -503,31 +464,13 @@ func TestTrackMetadataViewHasLibraryID(t *testing.T) { db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib") // Insert prerequisites. - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) " + - "VALUES (1, 'View Track', 1)", - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO audio_files "+ - "(id, file_path, length_milliseconds, file_type_id, "+ - "recording_id, library_id) "+ - "VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)", - libID, - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } + InsertTestTrack(t, db, TestTrack{ + FilePath: "/test/view.mp3", + Title: "View Track", + Artist: "View Artist", + LengthMs: 200000, + LibraryID: libID, + }) // Query track_metadata VIEW and verify library_id is present // with the correct value. @@ -842,29 +785,13 @@ func TestPlayHistoryTable(t *testing.T) { // Round-trip: insert a play_history row and verify play_count update. // First, set up test data. The test DB already has library id=0. - _, err = db.ExecContext( - "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - `INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number) - VALUES (1, 'Test Track', 1, 1, 1)`, - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - `INSERT INTO audio_files - (id, file_path, length_milliseconds, file_type_id, recording_id, library_id) - VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`, - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } + InsertTestTrack(t, db, TestTrack{ + FilePath: "/test/play_history.mp3", + Title: "Test Track", + Artist: "Test Artist", + TrackNumber: 1, + DiscNumber: 1, + }) // Verify default play_count is 0. var playCount int64 diff --git a/backend/database/download_rename_migration.go b/backend/database/download_rename_migration.go deleted file mode 100644 index 1e865aa..0000000 --- a/backend/database/download_rename_migration.go +++ /dev/null @@ -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 -} diff --git a/backend/database/download_rename_migration_test.go b/backend/database/download_rename_migration_test.go deleted file mode 100644 index 4b5d940..0000000 --- a/backend/database/download_rename_migration_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/database/explorefts_test.go b/backend/database/explorefts_test.go index a7d375e..da806cb 100644 --- a/backend/database/explorefts_test.go +++ b/backend/database/explorefts_test.go @@ -1,18 +1,26 @@ package database import ( + "crypto/sha256" "strings" "testing" ) // seedExploreRow inserts one explore_index row. +// +// The catalog stores an MBID as 16 raw bytes and an entity type as a +// code (see backend/explore/mbid.go), and the column says so, so the +// label these tests use as an id is hashed into something the table +// will accept. What they actually assert on is the FTS text. func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) { t.Helper() + sum := sha256.Sum256([]byte(mbid)) + if _, err := db.ExecContext(` INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid) - VALUES ('recording', ?, ?, ?, '') - `, mbid, title, artist); err != nil { + VALUES (3 /* recording */, ?, ?, ?, x'') + `, sum[:16], title, artist); err != nil { t.Fatalf("seed %s: %v", mbid, err) } } @@ -202,7 +210,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) { // A popularity refresh: an FTS column is not named at all. if _, err := db.ExecContext( - "UPDATE explore_index SET popularity = 42 WHERE mbid = 'mbid-1'", + "UPDATE explore_index SET popularity = 42 WHERE title = ?", + "Unchanged Title", ); err != nil { t.Fatalf("popularity update: %v", err) } @@ -212,8 +221,8 @@ func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) { if _, err := db.ExecContext(` UPDATE explore_index SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43 - WHERE mbid = 'mbid-1' - `); err != nil { + WHERE title = ? + `, "Unchanged Title"); err != nil { t.Fatalf("no-op text update: %v", err) } @@ -237,7 +246,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) { seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist") if _, err := db.ExecContext( - "UPDATE explore_index SET title = 'Corrected Title' WHERE mbid = 'mbid-2'", + "UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?", + "Original Title", ); err != nil { t.Fatalf("rename: %v", err) } @@ -252,7 +262,8 @@ func TestExploreFTSUpdateReindexesChangedText(t *testing.T) { // The same for the other two indexed columns. if _, err := db.ExecContext( - "UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE mbid = 'mbid-2'", + "UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?", + "Corrected Title", ); err != nil { t.Fatalf("artist rename: %v", err) } diff --git a/backend/database/lyrics_search.go b/backend/database/lyrics_search.go index 6eddcb0..a1f9ead 100644 --- a/backend/database/lyrics_search.go +++ b/backend/database/lyrics_search.go @@ -1,15 +1,26 @@ package database import ( + "database/sql" + "errors" "fmt" "strings" "unicode" ) +// toNullString treats an empty string as NULL. +func toNullString(v string) sql.NullString { + if v == "" { + return sql.NullString{} + } + + return sql.NullString{String: v, Valid: true} +} + // LyricsHit is a single result from a lyric-fragment search: the -// matched recording plus enough metadata to render and play it. +// matched file plus enough metadata to render and play it. type LyricsHit struct { - RecordingID int64 + AudioFileID int64 FilePath string LengthMilliseconds int64 Title string @@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) { return nil, nil } - // Map the matched recording (lyrics_index.rowid == recordings.id) - // to a representative playable file via the lowest audio_files id, - // then to the track_metadata VIEW for display fields. + // lyrics_index.rowid is the audio file's id, so the hit is already + // a playable file - it used to be a recording id, which then had to + // be mapped back to "some file of that recording" by a grouped + // subquery. // // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. - rows, err := d.db.QueryContext(d.Ctx, ` + rows, err := d.reader().QueryContext(d.Ctx, ` SELECT - r.id, + tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name, tm.album FROM lyrics_index li - JOIN recordings r ON r.id = li.rowid - JOIN ( - SELECT recording_id, MIN(id) AS af_id - FROM audio_files - GROUP BY recording_id - ) af ON af.recording_id = r.id - JOIN track_metadata tm ON tm.id = af.af_id + JOIN track_metadata tm ON tm.id = li.rowid WHERE lyrics_index MATCH ? ORDER BY rank LIMIT ? @@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) { for rows.Next() { var h LyricsHit if err := rows.Scan( - &h.RecordingID, + &h.AudioFileID, &h.FilePath, &h.LengthMilliseconds, &h.Title, @@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) { return results, nil } -// GetRecordingLyrics returns the stored lyrics for a recording, or -// an empty string if none are stored. -func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) { +// GetLyrics returns the stored lyrics for a file, or "" if none. +func (d *DB) GetLyrics(audioFileID int64) (string, error) { var lyrics string - err := d.db.QueryRowContext(d.Ctx, - "SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?", - recordingID, + err := d.reader().QueryRowContext(d.Ctx, + "SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID, ).Scan(&lyrics) + + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { - return "", fmt.Errorf("could not read recording lyrics: %w", err) + return "", fmt.Errorf("could not read lyrics: %w", err) } return lyrics, nil } -// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS -// lyrics_index in sync (delete + reinsert the single row). Used by -// the LRCLIB backfill to persist fetched lyrics. Passing an empty -// string clears both the column and the index entry. -func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error { - if _, err := d.db.ExecContext(d.Ctx, - "UPDATE recordings SET lyrics = ? WHERE id = ?", - lyrics, recordingID, - ); err != nil { - return fmt.Errorf("could not update recording lyrics: %w", err) +// SetLyrics writes lyrics for a file and keeps the FTS index in sync. +// +// `source` says where they came from, which is the question the old +// column could not answer: lyrics read from a USLT frame are rebuilt +// free by any rescan, and lyrics fetched from LRCLIB are network +// traffic nobody wants to repeat. Passing an empty string clears both +// the row and the index entry. +func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error { + if strings.TrimSpace(lyrics) == "" { + if _, err := d.db.ExecContext(d.Ctx, + "DELETE FROM lyrics WHERE audio_file_id = ?", audioFileID, + ); err != nil { + return fmt.Errorf("could not delete lyrics: %w", err) + } + + return d.upsertLyricsIndex(audioFileID, "") } - return d.upsertLyricsIndex(recordingID, lyrics) + if _, err := d.db.ExecContext(d.Ctx, ` + INSERT INTO lyrics (audio_file_id, text, source, recording_mbid) + VALUES (?, ?, ?, ?) + ON CONFLICT(audio_file_id) DO UPDATE SET + text = excluded.text, + source = excluded.source, + recording_mbid = COALESCE(excluded.recording_mbid, lyrics.recording_mbid), + fetched_at = CURRENT_TIMESTAMP + `, audioFileID, lyrics, source, toNullString(recordingMBID)); err != nil { + return fmt.Errorf("could not write lyrics: %w", err) + } + + return d.upsertLyricsIndex(audioFileID, lyrics) } -// upsertLyricsIndex refreshes a single recording's entry in the -// contentless lyrics_index. contentless_delete=1 makes the DELETE -// valid; an empty lyrics string leaves the row deleted. -func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error { +// upsertLyricsIndex refreshes a single file's entry in the contentless +// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty +// lyrics string leaves the row deleted. +func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error { if _, err := d.db.ExecContext(d.Ctx, - "DELETE FROM lyrics_index WHERE rowid = ?", recordingID, + "DELETE FROM lyrics_index WHERE rowid = ?", audioFileID, ); err != nil { return fmt.Errorf("could not delete lyrics_index row: %w", err) } @@ -141,7 +168,7 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error { // SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized. if _, err := d.db.ExecContext(d.Ctx, "INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)", - recordingID, lyrics, + audioFileID, lyrics, ); err != nil { return fmt.Errorf("could not insert lyrics_index row: %w", err) } @@ -149,22 +176,16 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error { return nil } -// RebuildLyricsIndex repopulates lyrics_index from scratch using the -// current recordings table. Cheap for a personal library and safe to -// run after every scan. +// RebuildLyricsIndex repopulates lyrics_index from the lyrics table. func (d *DB) RebuildLyricsIndex() error { - if _, err := d.db.ExecContext(d.Ctx, - "DELETE FROM lyrics_index", - ); err != nil { + if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil { return fmt.Errorf("could not clear lyrics_index: %w", err) } - // SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input. + // SAFETY: FTS5 virtual table INSERT. Values sourced from lyrics; no user input. if _, err := d.db.ExecContext(d.Ctx, ` INSERT INTO lyrics_index(rowid, lyrics) - SELECT id, lyrics - FROM recordings - WHERE lyrics IS NOT NULL AND lyrics != '' + SELECT audio_file_id, text FROM lyrics WHERE text != '' `); err != nil { return fmt.Errorf("could not rebuild lyrics_index: %w", err) } @@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error { return nil } -// RecordingsMissingLyrics returns recordings that have no stored -// lyrics but do carry the artist/title/duration needed to look them -// up from an external provider. Used by the LRCLIB backfill. The -// limit bounds each batch so the backfill can be run incrementally. -func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) { +// LyricsCandidate identifies a file that needs its lyrics fetched and +// carries the fields an external provider matches on. +type LyricsCandidate struct { + AudioFileID int64 + Title string + Artist string + Album string + RecordingMBID string + LengthMilliseconds int64 +} + +// FilesMissingLyrics returns files with no stored lyrics that carry +// the artist/title/duration needed to look them up. Used by the +// LRCLIB backfill; the limit bounds each batch. +func (d *DB) FilesMissingLyrics(limit int) ([]LyricsCandidate, error) { if limit <= 0 { limit = 200 } - rows, err := d.db.QueryContext(d.Ctx, ` - SELECT - r.id, - COALESCE(r.name, ''), - COALESCE(ac.text, ''), - COALESCE(rg.name, ''), - MIN(af.length_milliseconds) - FROM recordings r - JOIN audio_files af ON af.recording_id = r.id - LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id - LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id - ) rgr ON rgr.recording_id = r.id - LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id - WHERE (r.lyrics IS NULL OR r.lyrics = '') - AND r.name IS NOT NULL AND r.name != '' - AND ac.text IS NOT NULL AND ac.text != '' - GROUP BY r.id + rows, err := d.reader().QueryContext(d.Ctx, ` + SELECT tm.id, tm.title, tm.artist_name, tm.album, + tm.recording_mbid, tm.length_milliseconds + FROM track_metadata tm + WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id) + AND tm.title != '' AND tm.artist_name != '' LIMIT ? `, limit) if err != nil { - return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err) + return nil, fmt.Errorf("could not query files missing lyrics: %w", err) } defer func() { _ = rows.Close() }() @@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) { for rows.Next() { var c LyricsCandidate if err := rows.Scan( - &c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds, + &c.AudioFileID, &c.Title, &c.Artist, &c.Album, + &c.RecordingMBID, &c.LengthMilliseconds, ); err != nil { return nil, fmt.Errorf("could not scan lyrics candidate: %w", err) } @@ -229,44 +247,23 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) { return out, nil } -// LyricsCandidate identifies a recording that needs its lyrics fetched -// and carries the fields an external provider matches on. -type LyricsCandidate struct { - RecordingID int64 - Title string - Artist string - Album string - LengthMilliseconds int64 -} - -// RecordingLyricLookup returns the provider-match fields (artist, -// title, album, duration) for a single recording, so lyrics can be -// fetched on demand. Returns nil if the recording has no audio file -// or no artist/title to match on. -func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) { +// FileLyricLookup returns the provider-match fields for one file, so +// lyrics can be fetched on demand. Returns nil if the file has no +// artist/title to match on. +func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) { var c LyricsCandidate - err := d.db.QueryRowContext(d.Ctx, ` - SELECT - r.id, - COALESCE(r.name, ''), - COALESCE(ac.text, ''), - COALESCE(rg.name, ''), - COALESCE(MIN(af.length_milliseconds), 0) - FROM recordings r - JOIN audio_files af ON af.recording_id = r.id - LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id - LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id - ) rgr ON rgr.recording_id = r.id - LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id - WHERE r.id = ? - GROUP BY r.id - `, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds) + err := d.reader().QueryRowContext(d.Ctx, ` + SELECT tm.id, tm.title, tm.artist_name, tm.album, + tm.recording_mbid, tm.length_milliseconds + FROM track_metadata tm + WHERE tm.id = ? + `, audioFileID).Scan( + &c.AudioFileID, &c.Title, &c.Artist, &c.Album, + &c.RecordingMBID, &c.LengthMilliseconds, + ) if err != nil { - return nil, fmt.Errorf("could not look up recording for lyrics: %w", err) + return nil, fmt.Errorf("could not look up file for lyrics: %w", err) } if c.Title == "" || c.Artist == "" { diff --git a/backend/database/lyrics_search_test.go b/backend/database/lyrics_search_test.go index f0a6e15..ba9025b 100644 --- a/backend/database/lyrics_search_test.go +++ b/backend/database/lyrics_search_test.go @@ -4,59 +4,33 @@ import ( "testing" ) -// seedLyricsTrack inserts the minimal FK chain (artist_credit → -// recording → audio_file → release_group link) for one track with the -// given lyrics, so lyric-search tests have realistic joins. +// seedLyricsTrack inserts one file with the given lyrics, so lyric +// searches have something realistic to join against. It used to +// insert a four-row FK chain by hand. func seedLyricsTrack( t *testing.T, db *DB, id int64, title, artist, album, lyrics string, lenMs int64, -) { +) int64 { t.Helper() - if _, err := db.ExecContext( - "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist, - ); err != nil { - t.Fatalf("insert artist_credit: %v", err) + fileID := InsertTestTrack(t, db, TestTrack{ + FilePath: "/music/track" + itoa(id) + ".mp3", + Title: title, + Artist: artist, + Album: album, + LengthMs: lenMs, + }) + + if lyrics != "" { + if err := db.SetLyrics(fileID, lyrics, "tag", ""); err != nil { + t.Fatalf("seed lyrics: %v", err) + } } - if _, err := db.ExecContext( - "INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album, - ); err != nil { - t.Fatalf("insert release_group: %v", err) - } - - if _, err := db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)", - id, title, id, nullableLyrics(lyrics), - ); err != nil { - t.Fatalf("insert recording: %v", err) - } - - if _, err := db.ExecContext( - "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+ - "VALUES (?, ?, ?, ?, ?)", - id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id, - ); err != nil { - t.Fatalf("insert audio_file: %v", err) - } - - if _, err := db.ExecContext( - "INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)", - id, id, - ); err != nil { - t.Fatalf("insert release_group_recordings: %v", err) - } -} - -func nullableLyrics(l string) any { - if l == "" { - return nil - } - - return l + return fileID } func itoa(v int64) string { @@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) { } h := hits[0] - if h.RecordingID != 1 { - t.Errorf("RecordingID = %d, want 1", h.RecordingID) + if h.AudioFileID != 1 { + t.Errorf("RecordingID = %d, want 1", h.AudioFileID) } if h.Title != "The Sound of Silence" { @@ -191,11 +165,11 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) { // Backfill lyrics — should update both the column and the FTS index. const lyrics = "Yesterday all my troubles seemed so far away" - if err := db.SetRecordingLyrics(1, lyrics); err != nil { + if err := db.SetLyrics(1, lyrics, "lrclib", ""); err != nil { t.Fatalf("SetRecordingLyrics: %v", err) } - stored, err := db.GetRecordingLyrics(1) + stored, err := db.GetLyrics(1) if err != nil { t.Fatalf("GetRecordingLyrics: %v", err) } @@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) { t.Fatalf("SearchLyrics: %v", err) } - if len(hits) != 1 || hits[0].RecordingID != 1 { + if len(hits) != 1 || hits[0].AudioFileID != 1 { t.Fatalf("expected recording 1 after backfill, got %+v", hits) } } @@ -222,7 +196,7 @@ func TestRecordingsMissingLyrics(t *testing.T) { seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000) seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000) - missing, err := db.RecordingsMissingLyrics(50) + missing, err := db.FilesMissingLyrics(50) if err != nil { t.Fatalf("RecordingsMissingLyrics: %v", err) } @@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) { } c := missing[0] - if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" { + if c.AudioFileID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" { t.Errorf("unexpected candidate: %+v", c) } @@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) { } // Single-recording lookup mirrors the batch fields. - one, err := db.RecordingLyricLookup(2) + one, err := db.FileLyricLookup(2) if err != nil { t.Fatalf("RecordingLyricLookup: %v", err) } diff --git a/backend/database/migrations_test.go b/backend/database/migrations_test.go deleted file mode 100644 index b31fb7a..0000000 --- a/backend/database/migrations_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/database/multiartist_test.go b/backend/database/multiartist_test.go new file mode 100644 index 0000000..85f6bd4 --- /dev/null +++ b/backend/database/multiartist_test.go @@ -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)) + } + }) +} diff --git a/backend/database/search.go b/backend/database/search.go index 5b637d6..9ab0351 100644 --- a/backend/database/search.go +++ b/backend/database/search.go @@ -5,6 +5,8 @@ import ( "database/sql" "fmt" "strings" + + "yellowjacket/backend/database/sql/sqlcgen" ) // SearchRow holds a single result from an FTS5 or basename search. @@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error { return nil } -// SearchTrackRow holds a full track result from an FTS5 search, -// matching all 16 columns returned by GetAllTracksWithFullMetadata. -type SearchTrackRow struct { - FilePath string - LengthMilliseconds int64 - Title string - ArtistName string - TrackNumber sql.NullInt64 - DiscNumber sql.NullInt64 - Album string - Genre string - Year int64 - Composer string - FileType string - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 +// trackMetadataColumns is the column list of the track_metadata view, +// in the order sqlc generates TrackMetadatum's fields. The FTS +// searches below cannot be sqlc queries (MATCH is not in its grammar), +// so this is the one place the view's shape is written out by hand. +const trackMetadataColumns = ` + tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name, + tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year, + tm.release_year, tm.composer, tm.file_type, tm.sample_rate, + tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id, + tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid, + tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id` + +// scanTrackMetadata reads track_metadata rows into the generated row +// type, so an FTS hit and an ordinary query produce the same Track. +func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) { + var out []sqlcgen.TrackMetadatum + + for rows.Next() { + var r sqlcgen.TrackMetadatum + + if err := rows.Scan( + &r.ID, &r.FilePath, &r.LengthMilliseconds, &r.Title, &r.ArtistName, + &r.TrackNumber, &r.DiscNumber, &r.Album, &r.Genre, &r.Year, + &r.ReleaseYear, &r.Composer, &r.FileType, &r.SampleRate, + &r.BitDepth, &r.Channels, &r.Bitrate, &r.FileSize, &r.LibraryID, + &r.PlayCount, &r.LastPlayed, &r.CoverArtPath, &r.ArtistMbid, + &r.ReleaseGroupMbid, &r.RecordingMbid, &r.AlbumID, &r.ArtistID, + ); err != nil { + return nil, fmt.Errorf("scan track metadata: %w", err) + } + + out = append(out, r) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate track metadata: %w", err) + } + + return out, nil } -// SearchFTSTracks performs a full-text search and returns full track -// metadata for each match. Unlike SearchFTS (which returns only 5 -// columns), this includes all 16 fields needed for library.Track. +// SearchFTSTracks performs a full-text search and returns whole tracks. +// +// A library id of 0 means every library. There were two of these, one +// per case, each with its own copy of a sixteen-column projection that +// silently dropped the MBIDs and the play count - which is why the +// caller used to pass zeros for them. func (d *DB) SearchFTSTracks( - query string, limit int, -) ([]SearchTrackRow, error) { + query string, libraryID int64, limit int, +) ([]sqlcgen.TrackMetadatum, error) { query = strings.TrimSpace(query) if query == "" { return nil, nil } - ftsQuery := buildFTSQuery(query) - // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. - rows, err := d.db.QueryContext(d.Ctx, ` - SELECT - tm.file_path, - tm.length_milliseconds, - tm.title, - tm.artist_name, - tm.track_number, - tm.disc_number, - tm.album, - tm.genre, - tm.year, - tm.composer, - tm.file_type, - tm.sample_rate, - tm.bit_depth, - tm.channels, - tm.bitrate, - tm.file_size + rows, err := d.reader().QueryContext(d.Ctx, ` + SELECT`+trackMetadataColumns+` FROM search_index si JOIN track_metadata tm ON tm.id = si.rowid WHERE search_index MATCH ? + AND (? = 0 OR tm.library_id = ?) ORDER BY rank LIMIT ? - `, ftsQuery, limit) + `, buildFTSQuery(query), libraryID, libraryID, limit) if err != nil { - return nil, fmt.Errorf( - "FTS track search failed: %w", err, - ) + return nil, fmt.Errorf("FTS track search failed: %w", err) } defer func() { _ = rows.Close() }() - var results []SearchTrackRow - - for rows.Next() { - var r SearchTrackRow - - if err := rows.Scan( - &r.FilePath, - &r.LengthMilliseconds, - &r.Title, - &r.ArtistName, - &r.TrackNumber, - &r.DiscNumber, - &r.Album, - &r.Genre, - &r.Year, - &r.Composer, - &r.FileType, - &r.SampleRate, - &r.BitDepth, - &r.Channels, - &r.Bitrate, - &r.FileSize, - ); err != nil { - return nil, fmt.Errorf( - "could not scan search track row: %w", - err, - ) - } - - results = append(results, r) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf( - "search track row iteration error: %w", - err, - ) - } - - return results, nil + return scanTrackMetadata(rows) } -// SearchFTSTracksByLibrary performs a full-text search scoped to a -// specific library and returns full track metadata for each match. -func (d *DB) SearchFTSTracksByLibrary( - query string, limit int, libraryID int64, -) ([]SearchTrackRow, error) { - query = strings.TrimSpace(query) - if query == "" { - return nil, nil - } - - ftsQuery := buildFTSQuery(query) - - // SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation. - rows, err := d.db.QueryContext(d.Ctx, ` - SELECT - tm.file_path, - tm.length_milliseconds, - tm.title, - tm.artist_name, - tm.track_number, - tm.disc_number, - tm.album, - tm.genre, - tm.year, - tm.composer, - tm.file_type, - tm.sample_rate, - tm.bit_depth, - tm.channels, - tm.bitrate, - tm.file_size - FROM search_index si - JOIN track_metadata tm ON tm.id = si.rowid - WHERE search_index MATCH ? AND tm.library_id = ? - ORDER BY rank - LIMIT ? - `, ftsQuery, libraryID, limit) - if err != nil { - return nil, fmt.Errorf( - "FTS library track search failed: %w", err, - ) - } - - defer func() { _ = rows.Close() }() - - var results []SearchTrackRow - - for rows.Next() { - var r SearchTrackRow - - if err := rows.Scan( - &r.FilePath, - &r.LengthMilliseconds, - &r.Title, - &r.ArtistName, - &r.TrackNumber, - &r.DiscNumber, - &r.Album, - &r.Genre, - &r.Year, - &r.Composer, - &r.FileType, - &r.SampleRate, - &r.BitDepth, - &r.Channels, - &r.Bitrate, - &r.FileSize, - ); err != nil { - return nil, fmt.Errorf( - "could not scan library search track row: %w", - err, - ) - } - - results = append(results, r) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf( - "library search track row iteration error: %w", - err, - ) - } - - return results, nil -} - -// scanSearchRows reads all rows from a query result into a slice. func scanSearchRows( rows interface { Next() bool diff --git a/backend/database/search_test.go b/backend/database/search_test.go index 26018a5..34080d7 100644 --- a/backend/database/search_test.go +++ b/backend/database/search_test.go @@ -3,6 +3,8 @@ package database import ( "fmt" "testing" + + "yellowjacket/backend/database/sql/sqlcgen" ) // seedSearchData inserts ~7 tracks with the full FK chain required for @@ -84,128 +86,44 @@ func seedSearchData(t *testing.T, db *DB) { }, } - // Build unique sets. - artistMap := map[string]int64{} - albumMap := map[string]int64{} - - var artistID, albumID int64 - for _, tr := range tracks { - if _, ok := artistMap[tr.artist]; !ok { - artistID++ - artistMap[tr.artist] = artistID - } - - if _, ok := albumMap[tr.album]; !ok { - albumID++ - albumMap[tr.album] = albumID - } - } - - // Insert artist_credit rows. - for text, id := range artistMap { - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (?, ?)", - id, text, - ) - if err != nil { - t.Fatalf("insert artist_credit %q: %v", text, err) - } - } - - // Insert release_groups. - for name, id := range albumMap { - _, err := db.ExecContext( - "INSERT INTO release_groups (id, name) VALUES (?, ?)", - id, name, - ) - if err != nil { - t.Fatalf("insert release_group %q: %v", name, err) - } - } - - // Insert genres + recording_genres. - genreMap := map[string]int64{} - - var genreID int64 - - for _, tr := range tracks { - if tr.genre == "" { - continue - } - - if _, ok := genreMap[tr.genre]; !ok { - genreID++ - genreMap[tr.genre] = genreID - - _, err := db.ExecContext( - "INSERT INTO genres (id, name) VALUES (?, ?)", - genreID, tr.genre, - ) - if err != nil { - t.Fatalf("insert genre %q: %v", tr.genre, err) - } - } - } - - for _, tr := range tracks { - acID := artistMap[tr.artist] - rgID := albumMap[tr.album] - - // Insert recording. - _, err := db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id, "+ - "track_number, disc_number, year, genre, composer) "+ - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - tr.id, tr.title, acID, tr.trackNum, tr.discNum, - tr.year, tr.genre, tr.composer, - ) - if err != nil { - t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err) - } - - // Insert audio_files. - _, err = db.ExecContext( - "INSERT INTO audio_files (id, file_path, "+ - "length_milliseconds, file_type_id, recording_id, "+ - "sample_rate, bit_depth, channels, bitrate, file_size) "+ - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id, - tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, - ) - if err != nil { - t.Fatalf("insert audio_file %d: %v", tr.id, err) - } - - // Link recording to release_group. - _, err = db.ExecContext( - "INSERT INTO release_group_recordings "+ - "(release_group_id, recording_id, track_number, disc_number) "+ - "VALUES (?, ?, ?, ?)", - rgID, tr.id, tr.trackNum, tr.discNum, - ) - if err != nil { - t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err) - } - - // Insert search_index entry (rowid must match audio_files.id). - if err := db.InsertSearchIndex( - tr.id, tr.filePath, tr.title, tr.artist, tr.album, - ); err != nil { - t.Fatalf("insert search_index for %d: %v", tr.id, err) - } - - // Insert recording_genres link. + var genres []string if tr.genre != "" { - gID := genreMap[tr.genre] + genres = []string{tr.genre} + } - _, err = db.ExecContext( - "INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)", - tr.id, gID, - ) - if err != nil { - t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err) - } + var trackNum, discNum int64 + if tr.trackNum != nil { + trackNum = *tr.trackNum + } + + if tr.discNum != nil { + discNum = *tr.discNum + } + + id := InsertTestTrack(t, db, TestTrack{ + FilePath: tr.filePath, + Title: tr.title, + Artist: tr.artist, + Album: tr.album, + Genres: genres, + TrackNumber: trackNum, + DiscNumber: discNum, + Year: tr.year, + LengthMs: tr.lenMs, + }) + + // The fixtures assert on audio properties and the composer, + // which InsertTestTrack does not carry - they are not part of + // what a seeder should have to know about a track. + if _, err := db.ExecContext( + `UPDATE audio_files + SET file_type_id = ?, sample_rate = ?, bit_depth = ?, + channels = ?, bitrate = ?, file_size = ?, composer = ? + WHERE id = ?`, + tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id, + ); err != nil { + t.Fatalf("set audio properties for %q: %v", tr.filePath, err) } } } @@ -553,7 +471,7 @@ func TestSearchFTSTracks(t *testing.T) { db := NewTestDB(t) seedSearchData(t, db) - results, err := db.SearchFTSTracks("queen", 10) + results, err := db.SearchFTSTracks("queen", 0, 10) if err != nil { t.Fatalf("SearchFTSTracks: %v", err) } @@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) { } // Find the Bohemian Rhapsody result and verify all 16 fields. - var br *SearchTrackRow + var br *sqlcgen.TrackMetadatum for i, r := range results { if r.Title == "Bohemian Rhapsody" { @@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) { db := NewTestDB(t) // Set up minimal FK chain for a single track. - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)", - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)", - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } + InsertTestTrack(t, db, TestTrack{ + FilePath: "/test/track.mp3", + Title: "Test Track", + Artist: "Test Artist", + LengthMs: 180000, + }) // Insert into search index. if err := db.InsertSearchIndex( @@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) { db := NewTestDB(t) - // Seed the full entity graph WITHOUT inserting into search_index. - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)", - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)", - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')", - ) - if err != nil { - t.Fatalf("insert release_group: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)", - ) - if err != nil { - t.Fatalf("insert release_group_recordings: %v", err) - } + // Seed the file WITHOUT putting it in search_index. + InsertTestTrack(t, db, TestTrack{ + FilePath: "/rebuild/track.mp3", + Title: "Rebuild Track", + Artist: "Rebuild Artist", + Album: "Rebuild Album", + LengthMs: 200000, + SkipSearchIndex: true, + }) // Search should return nothing before rebuild. results, err := db.SearchFTS("Rebuild", 10) @@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) { db := NewTestDB(t) // Set up minimal FK chain for a single track at rowid 100. - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) VALUES (100, 'Old Title', 100)", - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) " + - "VALUES (100, '/test/update_cycle.mp3', 200000, 0, 100)", - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } + id := InsertTestTrack(t, db, TestTrack{ + FilePath: "/test/update_cycle.mp3", + Title: "Old Title", + Artist: "Old Artist", + LengthMs: 200000, + SkipSearchIndex: true, + }) // 1. Insert with old metadata. if err := db.InsertSearchIndex( - 100, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album", + id, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album", ); err != nil { t.Fatalf("InsertSearchIndex (old): %v", err) } - // Verify search for "Old Title" returns rowid 100. + // Verify search for "Old Title" finds it. results, err := db.SearchFTS("Old Title", 10) if err != nil { t.Fatalf("SearchFTS(Old Title): %v", err) @@ -926,9 +790,9 @@ func TestSearchIndexUpdateCycle(t *testing.T) { t.Fatal("SearchFTS(Old Title): got 0 results after insert") } - // 2. Delete rowid 100. - if err := db.DeleteSearchIndex(100); err != nil { - t.Fatalf("DeleteSearchIndex(100): %v", err) + // 2. Delete the row. + if err := db.DeleteSearchIndex(id); err != nil { + t.Fatalf("DeleteSearchIndex(%d): %v", id, err) } // Verify "Old Title" no longer found. @@ -944,25 +808,17 @@ func TestSearchIndexUpdateCycle(t *testing.T) { ) } - // 3. Update the recording name in the DB to simulate tag edit. + // 3. Update the file's title in the DB to simulate a tag edit. _, err = db.ExecContext( - "UPDATE recordings SET name = 'New Title' WHERE id = 100", + "UPDATE audio_files SET title = 'New Title' WHERE file_path = '/test/update_cycle.mp3'", ) if err != nil { - t.Fatalf("update recording: %v", err) + t.Fatalf("update title: %v", err) } - // Also add a new artist_credit for the new artist. - _, err = db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (101, 'New Artist')", - ) - if err != nil { - t.Fatalf("insert new artist_credit: %v", err) - } - - // 4. Re-insert rowid 100 with new metadata. + // 4. Re-insert the row with new metadata. if err := db.InsertSearchIndex( - 100, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album", + id, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album", ); err != nil { t.Fatalf("InsertSearchIndex (new): %v", err) } @@ -1079,25 +935,13 @@ func TestSearchIndexSchema(t *testing.T) { t.Fatalf("insert artist: %v", err) } + // The credit tables this used to assert a UNIQUE constraint on are + // gone; a file names its artist directly, and artists are unique by + // name, which is asserted below. _, err = db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)", - ) - if err != nil { - t.Fatalf("first insert artist_credit_artist: %v", err) - } - - // Duplicate insert should fail with UNIQUE constraint. - _, err = db.ExecContext( - "INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)", + "INSERT INTO artists (id, name) VALUES (2, 'Test')", ) if err == nil { - t.Error("duplicate artist_credit_artist insert should fail, got nil error") + t.Error("duplicate artist name should fail, got nil error") } } diff --git a/backend/database/sql/migrations/0001_tagging_items_synthetic.sql b/backend/database/sql/migrations/0001_tagging_items_synthetic.sql deleted file mode 100644 index cdd9e4b..0000000 --- a/backend/database/sql/migrations/0001_tagging_items_synthetic.sql +++ /dev/null @@ -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 != ''; diff --git a/backend/database/sql/migrations/0002_tagging_items_orphan_cleanup.sql b/backend/database/sql/migrations/0002_tagging_items_orphan_cleanup.sql deleted file mode 100644 index 8376f7c..0000000 --- a/backend/database/sql/migrations/0002_tagging_items_orphan_cleanup.sql +++ /dev/null @@ -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 -); diff --git a/backend/database/sql/migrations/0003_tagging_items_album_artist_conflict.sql b/backend/database/sql/migrations/0003_tagging_items_album_artist_conflict.sql deleted file mode 100644 index 8a54ee1..0000000 --- a/backend/database/sql/migrations/0003_tagging_items_album_artist_conflict.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0; diff --git a/backend/database/sql/migrations/0004_queue_source.sql b/backend/database/sql/migrations/0004_queue_source.sql deleted file mode 100644 index 5660268..0000000 --- a/backend/database/sql/migrations/0004_queue_source.sql +++ /dev/null @@ -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 ''; diff --git a/backend/database/sql/migrations/0005_release_groups_pending_release_mbid.sql b/backend/database/sql/migrations/0005_release_groups_pending_release_mbid.sql deleted file mode 100644 index 967d363..0000000 --- a/backend/database/sql/migrations/0005_release_groups_pending_release_mbid.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT; diff --git a/backend/database/sql/migrations/0006_release_group_recordings_total_tracks.sql b/backend/database/sql/migrations/0006_release_group_recordings_total_tracks.sql deleted file mode 100644 index 61660e8..0000000 --- a/backend/database/sql/migrations/0006_release_group_recordings_total_tracks.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER; diff --git a/backend/database/sql/queries/albums.sql b/backend/database/sql/queries/albums.sql new file mode 100644 index 0000000..6ee599c --- /dev/null +++ b/backend/database/sql/queries/albums.sql @@ -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); diff --git a/backend/database/sql/queries/artist_credit.sql b/backend/database/sql/queries/artist_credit.sql deleted file mode 100644 index 17eff1e..0000000 --- a/backend/database/sql/queries/artist_credit.sql +++ /dev/null @@ -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); diff --git a/backend/database/sql/queries/artist_credit_artists.sql b/backend/database/sql/queries/artist_credit_artists.sql deleted file mode 100644 index 3cdacac..0000000 --- a/backend/database/sql/queries/artist_credit_artists.sql +++ /dev/null @@ -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 = ?; - diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql index d3a6530..a68e875 100644 --- a/backend/database/sql/queries/artists.sql +++ b/backend/database/sql/queries/artists.sql @@ -1,67 +1,55 @@ --- name: CreateArtist :one -INSERT INTO artists (name) VALUES (?) +-- Queries over artists. +-- +-- An artist row is reachable two ways: as a file's primary artist +-- (audio_files.artist_id) and as an album's artist (albums.artist_id). +-- Both used to route through artist_credit + artist_credit_artist, +-- which is how "which album artists are in library 2" came to be a +-- five-join subquery inside a three-join query. + +-- name: UpsertArtist :one +INSERT INTO artists (name, mbid) VALUES (?, ?) +ON CONFLICT(name) DO UPDATE SET + mbid = COALESCE(excluded.mbid, artists.mbid) RETURNING *; -- name: GetArtist :one -SELECT * FROM artists -WHERE id = ? LIMIT 1; +SELECT * FROM artists WHERE id = ? LIMIT 1; -- name: GetArtistByName :one -SELECT * FROM artists -WHERE name = ? LIMIT 1; +SELECT * FROM artists WHERE name = ? LIMIT 1; --- name: UpsertArtist :one -INSERT INTO artists (name) VALUES (?) -ON CONFLICT(name) DO UPDATE SET name = excluded.name -RETURNING *; - --- name: UpdateArtist :exec -UPDATE artists -SET name = ? -WHERE id = ?; +-- name: SetArtistMBID :exec +UPDATE artists SET mbid = ? WHERE id = ?; -- name: DeleteArtist :exec -DELETE FROM artists -WHERE id = ?; +DELETE FROM artists WHERE id = ?; -- name: DeleteAllArtists :exec DELETE FROM artists; +-- name: GetUnreferencedArtistIDs :many +-- Artists no file and no album points at any more. +SELECT id FROM artists a +WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id) + AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id); + -- name: GetAllArtists :many -SELECT * FROM artists -ORDER BY name; +SELECT * FROM artists ORDER BY name; -- name: GetAlbumArtists :many SELECT DISTINCT a.id, a.name, a.mbid FROM artists a -JOIN artist_credit_artist aca ON aca.artist_id = a.id -JOIN artist_credit ac ON ac.id = aca.credit_id -JOIN release_groups rg ON rg.album_artist_credit_id = ac.id -ORDER BY a.name; - --- name: GetOrphanedArtistIDs :many --- Artists no longer credited on any recording or release group - left --- behind when a scan's orphan cleanup removes the audio_files that used --- to justify them, since deleting an audio_files row doesn't cascade. -SELECT a.id FROM artists a -WHERE NOT EXISTS ( - SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id -); - --- name: GetAlbumArtistsByLibrary :many -SELECT DISTINCT a.id, a.name, a.mbid -FROM artists a -JOIN artist_credit_artist aca ON aca.artist_id = a.id -JOIN artist_credit ac ON ac.id = aca.credit_id -JOIN release_groups rg ON rg.album_artist_credit_id = ac.id -WHERE a.id IN ( - SELECT DISTINCT aca2.artist_id - FROM artist_credit_artist aca2 - JOIN artist_credit ac2 ON ac2.id = aca2.credit_id - JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id - JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id - JOIN recordings r2 ON r2.id = rgr2.recording_id - JOIN audio_files af2 ON af2.recording_id = r2.id - WHERE af2.library_id = ? +JOIN albums al ON al.artist_id = a.id +WHERE EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.album_id = al.id + AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id) ) ORDER BY a.name; + +-- name: GetArtistByFilePath :one +SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid +FROM audio_files af +LEFT JOIN artists a ON a.id = af.artist_id +WHERE af.file_path = ? +LIMIT 1; diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index 4bbfc78..de88294 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -1,39 +1,60 @@ +-- Queries over audio_files and the track_metadata view above it. +-- +-- Every query that returns "a track" selects from `track_metadata`, +-- which is the one place the projection is defined. The scoped and +-- unscoped variants that used to be written twice are one query now: +-- library_id 0 means "every library", and `(:id = 0 OR library_id = :id)` +-- costs nothing measurable (23 ms vs 21 ms over 26k rows) because these +-- queries scan either way. + +-- --------------------------------------------------------------------- +-- Writes +-- --------------------------------------------------------------------- + -- name: CreateAudioFile :one -INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING *; - --- name: CreateAudioFileWithGroupKey :one INSERT INTO audio_files ( - file_path, length_milliseconds, file_type_id, recording_id, - sample_rate, bit_depth, channels, bitrate, file_size, basename, - library_id, group_key, tag_status, modified_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + file_path, library_id, file_type_id, + length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, + title, artist_credit, artist_id, album_id, + track_number, disc_number, total_tracks, year, composer, comment, + recording_mbid, basename, group_key, modified_at, tag_status +) VALUES ( + ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? +) RETURNING *; --- name: GetAudioFileGroupKey :one -SELECT group_key FROM audio_files -WHERE id = ? LIMIT 1; +-- name: UpdateAudioFileTags :exec +-- A rescan of a file whose mtime moved: the tags are re-read and +-- written over the same row. Under the old schema this created a +-- *new* recording and repointed the file at it, abandoning the old one +-- -- which is where 812 orphaned rows and every phantom "you own this" +-- came from. There is nothing to orphan now. +UPDATE audio_files +SET title = ?, artist_credit = ?, artist_id = ?, album_id = ?, + track_number = ?, disc_number = ?, total_tracks = ?, year = ?, + composer = ?, comment = ?, recording_mbid = ?, + sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, + file_size = ?, length_milliseconds = ?, modified_at = ? +WHERE id = ?; -- name: SetAudioFileGroupKey :exec UPDATE audio_files SET group_key = ? WHERE id = ?; --- name: GetAudioFile :one -SELECT * FROM audio_files -WHERE id = ? LIMIT 1; - --- name: GetAudioFileByPath :one -SELECT * FROM audio_files -WHERE file_path = ? LIMIT 1; - --- name: UpdateAudioFile :exec -UPDATE audio_files -SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ? -WHERE id = ?; - --- name: UpdateAudioFileRecording :exec +-- name: PromoteAudioFileTagStatusIfUntagged :exec +-- A rescan re-reads the tags of a file whose mtime moved, so a file +-- another tagger stamped with MBIDs since import arrives here still +-- carrying the 'untagged' status it was created with (only the insert +-- path sets it). Promote it the same way saveAudioFile does. +-- Guarded on 'untagged' so it cannot overwrite a deliberate +-- 'user_skipped_permanent', and so a file losing its MBIDs is left +-- alone -- demotion is the scan's judgement, not this statement's. UPDATE audio_files -SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ? -WHERE id = ?; +SET tag_status = 'user_confirmed' +WHERE id = ? AND tag_status = 'untagged'; -- name: UpdateAudioFileStat :exec -- Records the on-disk mtime/size without re-reading tags. Used to @@ -43,307 +64,146 @@ UPDATE audio_files SET modified_at = ?, file_size = ? WHERE id = ?; +-- name: SetAudioFileRecordingMBID :exec +UPDATE audio_files SET recording_mbid = ? WHERE id = ?; + +-- name: DeleteAudioFile :exec +DELETE FROM audio_files WHERE id = ?; + +-- name: DeleteAllAudioFiles :exec +DELETE FROM audio_files; + +-- --------------------------------------------------------------------- +-- Reads: the file row itself +-- --------------------------------------------------------------------- + +-- name: GetAudioFile :one +SELECT * FROM audio_files WHERE id = ? LIMIT 1; + +-- name: GetAudioFileByPath :one +SELECT * FROM audio_files WHERE file_path = ? LIMIT 1; + +-- name: GetAudioFileGroupKey :one +SELECT group_key FROM audio_files WHERE id = ? LIMIT 1; + +-- name: GetAllAudioFilePaths :many +SELECT id, file_path FROM audio_files; + +-- name: GetAudioFilesByPaths :many +SELECT id, library_id, file_path, group_key FROM audio_files +WHERE file_path IN (sqlc.slice('paths')); + +-- name: GetRandomAudioFilePath :one +SELECT file_path FROM audio_files ORDER BY RANDOM() LIMIT 1; + +-- name: CountAudioFiles :one +SELECT COUNT(*) AS count FROM audio_files +WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id); + -- name: GetLibraryMaxModifiedAt :one -- Newest recorded mtime in a library, for the startup soft scan. 0 when -- the library is empty or no row has a baseline yet. SELECT CAST(COALESCE(MAX(modified_at), 0) AS INTEGER) FROM audio_files WHERE library_id = ?; --- name: DeleteAudioFile :exec -DELETE FROM audio_files -WHERE id = ?; +-- --------------------------------------------------------------------- +-- Reads: tracks +-- --------------------------------------------------------------------- --- name: CountAudioFiles :one -SELECT count(*) FROM audio_files; +-- name: GetTracks :many +SELECT * FROM track_metadata +WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id); --- name: GetRandomAudioFilePath :one -SELECT file_path FROM audio_files -ORDER BY RANDOM() -LIMIT 1; +-- name: GetTrackByPath :one +SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1; --- name: GetAllAudioFiles :many -SELECT * FROM audio_files; +-- name: GetTracksByAlbum :many +SELECT * FROM track_metadata +WHERE album_id = sqlc.arg(album_id) + AND library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), library_id) +ORDER BY disc_number, track_number; --- name: GetAllAudioFilePaths :many -SELECT id, file_path FROM audio_files; - --- name: GetAudioFilesNeedingMetadata :many -SELECT * FROM audio_files -WHERE recording_id = 0; - --- name: GetAllAudioFilesWithArtist :many -SELECT - af.id, - af.file_path, - af.length_milliseconds, - af.file_type_id, - af.recording_id, - COALESCE(ac.text, '') AS artist_name, - COALESCE(r.name, '') AS title -FROM audio_files af -JOIN recordings r ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id; - --- name: GetTrackMetadataByPath :one -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid -FROM audio_files af -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id -WHERE af.file_path = ? -LIMIT 1; - --- name: GetAllTracksWithFullMetadata :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size, - af.play_count, - af.last_played, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid -FROM audio_files af -JOIN recordings r ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id; - --- name: SearchAudioFilesByBasename :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album -FROM audio_files af -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -WHERE af.basename = ? -LIMIT ?; +-- name: GetTracksByGenre :many +SELECT tm.* FROM track_metadata tm +JOIN file_genres fg ON fg.audio_file_id = tm.id +JOIN genres g ON g.id = fg.genre_id +WHERE g.name = sqlc.arg(genre) + AND tm.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), tm.library_id); -- name: LookupTrackMetaByPaths :many -SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid +SELECT id, file_path, title, artist_name, album, cover_art_path, + artist_mbid, release_group_mbid, recording_mbid FROM track_metadata WHERE file_path IN (sqlc.slice('paths')); --- name: GetAudioFilesByLibrary :many -SELECT * FROM audio_files WHERE library_id = ?; +-- name: SearchTracksByBasename :many +SELECT id, file_path, length_milliseconds, title, artist_name, album +FROM track_metadata +WHERE file_path IN ( + SELECT file_path FROM audio_files WHERE basename = sqlc.arg(basename) +) +LIMIT sqlc.arg(lim); --- name: CountAudioFilesByLibrary :one -SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ?; +-- --------------------------------------------------------------------- +-- Reads: file paths, grouped by whatever the caller asked about +-- --------------------------------------------------------------------- +-- These answer "what can I play" and they all ask audio_files, because +-- that is the only table whose rows are files. Grouped rather than +-- flattened because the caller owns the order. --- name: DeleteAllAudioFiles :exec -DELETE FROM audio_files; - --- name: GetAllTracksWithFullMetadataByLibrary :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size, - af.play_count, - af.last_played, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid -FROM audio_files af -JOIN recordings r ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE af.library_id = ?; - --- name: GetAudioFilesByReleaseGroup :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - rgr.track_number, - rgr.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid -FROM release_group_recordings rgr -JOIN recordings r ON rgr.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE rgr.release_group_id = ? -ORDER BY rgr.disc_number, rgr.track_number; - --- name: GetAudioFilesByReleaseGroupByLibrary :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - rgr.track_number, - rgr.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid -FROM release_group_recordings rgr -JOIN recordings r ON rgr.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE rgr.release_group_id = ? AND af.library_id = ? -ORDER BY rgr.disc_number, rgr.track_number; - --- "Play this artist" and "play these albums" wanted file paths and asked --- for whole track rows to get them, one round trip per album (perf.m2). --- These answer the same question in one query and carry only what the --- caller uses; the release group id comes back so the caller can keep --- its own album ordering. - --- name: GetFilePathsByReleaseGroups :many -SELECT rgr.release_group_id, af.file_path -FROM release_group_recordings rgr -JOIN recordings r ON rgr.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids')) -ORDER BY rgr.disc_number, rgr.track_number; - --- name: GetFilePathsByReleaseGroupsByLibrary :many -SELECT rgr.release_group_id, af.file_path -FROM release_group_recordings rgr -JOIN recordings r ON rgr.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE rgr.release_group_id IN (sqlc.slice('release_group_ids')) - AND af.library_id = ? -ORDER BY rgr.disc_number, rgr.track_number; - --- Same shape again, keyed on recording MBID, for the catalog side. --- An Explore album page knows which of its tracks the user owns only --- as a set of recording MBIDs -- that is exactly how the backend --- decides `inLibrary` (markReleasesInLibrary -> CheckMBIDs) -- and --- MBTrack.LocalID is declared but never written by anything, so there --- is no id to ask by. Grouped by MBID because a recording can have --- more than one file (the duplicate fixtures are precisely that) and --- because the caller owns the order: the tracklist's, not the --- database's. +-- name: GetFilePathsByAlbums :many +-- The library filter is applied in Go rather than here: sqlc numbers a +-- named parameter (?2) but expands a slice into N placeholders, so the +-- two together bind the wrong values - GetFilePathsByAlbums([1,2], 0) +-- read album id 2 as the library id. Returning library_id and +-- filtering the (small) result is the version that cannot be wrong. +SELECT album_id, library_id, file_path FROM audio_files +WHERE album_id IN (sqlc.slice('album_ids')) +ORDER BY disc_number, track_number; -- name: GetFilePathsByRecordingMBIDs :many -SELECT r.mbid AS recording_mbid, af.file_path -FROM recordings r -JOIN audio_files af ON af.recording_id = r.id -WHERE r.mbid IN (sqlc.slice('mbids')) -ORDER BY af.file_path; +-- The ownership question in its only honest form: which of these +-- catalog recordings has a *file* behind it. Asked of audio_files, so +-- a metadata row with no file cannot answer yes. +SELECT recording_mbid, library_id, file_path FROM audio_files +WHERE recording_mbid IN (sqlc.slice('mbids')) +ORDER BY file_path; --- name: GetFilePathsByRecordingMBIDsByLibrary :many -SELECT r.mbid AS recording_mbid, af.file_path -FROM recordings r -JOIN audio_files af ON af.recording_id = r.id -WHERE r.mbid IN (sqlc.slice('mbids')) - AND af.library_id = ? -ORDER BY af.file_path; +-- name: GetFilePathsByGenres :many +SELECT g.name AS genre, af.library_id, af.file_path +FROM audio_files af +JOIN file_genres fg ON fg.audio_file_id = af.id +JOIN genres g ON g.id = fg.genre_id +WHERE g.name IN (sqlc.slice('genres')) +ORDER BY af.disc_number, af.track_number; --- name: GetAudioFilesByPaths :many -SELECT id, library_id, file_path, group_key FROM audio_files -WHERE file_path IN (sqlc.slice('paths')); +-- name: GetFilePathsByArtistMBID :many +SELECT DISTINCT af.file_path +FROM audio_files af +JOIN artists a ON a.id = af.artist_id +WHERE a.mbid = ?; + +-- --------------------------------------------------------------------- +-- Ownership, asked in bulk +-- --------------------------------------------------------------------- + +-- name: OwnedRecordingMBIDs :many +-- Which of these recording MBIDs are actually in the library. This is +-- what marks a catalog tracklist owned; it used to be +-- `SELECT mbid FROM recordings`, which answered yes for 129 tracks in a +-- real library that had no file at all. +SELECT DISTINCT recording_mbid FROM audio_files +WHERE recording_mbid IN (sqlc.slice('mbids')); + +-- name: OwnedAlbumMBIDs :many +SELECT DISTINCT al.mbid FROM albums al +JOIN audio_files af ON af.album_id = al.id +WHERE al.mbid IN (sqlc.slice('mbids')); + +-- name: OwnedArtistMBIDs :many +SELECT DISTINCT a.mbid FROM artists a +JOIN audio_files af ON af.artist_id = a.id +WHERE a.mbid IN (sqlc.slice('mbids')); + +-- name: GetAudioFilesInLibrary :many +SELECT * FROM audio_files WHERE library_id = ?; diff --git a/backend/database/sql/queries/genres.sql b/backend/database/sql/queries/genres.sql index 2feb3cf..325c5da 100644 --- a/backend/database/sql/queries/genres.sql +++ b/backend/database/sql/queries/genres.sql @@ -1,150 +1,50 @@ +-- Queries over genres and file_genres. +-- +-- The track-returning ones live in audio_files.sql with the rest of the +-- track_metadata reads; what is left here is the genre list itself and +-- the link table's writes. + -- name: UpsertGenre :one INSERT INTO genres (name) VALUES (?) -ON CONFLICT(name) DO UPDATE SET name = name +ON CONFLICT(name) DO UPDATE SET name = excluded.name RETURNING *; --- name: CreateRecordingGenre :exec -INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) -VALUES (?, ?); +-- name: LinkFileGenre :exec +INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?); --- name: DeleteRecordingGenres :exec -DELETE FROM recording_genres -WHERE recording_id = ?; +-- name: DeleteFileGenres :exec +DELETE FROM file_genres WHERE audio_file_id = ?; --- name: GetGenresByRecordingID :many -SELECT g.* -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -WHERE rg.recording_id = ?; +-- name: GetGenreNamesByFile :many +SELECT g.name FROM genres g +JOIN file_genres fg ON fg.genre_id = g.id +WHERE fg.audio_file_id = ?; --- name: DeleteAllRecordingGenres :exec -DELETE FROM recording_genres; - --- name: DeleteAllGenres :exec -DELETE FROM genres; - --- name: GetTracksByGenre :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rlg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g2.name, '||') - FROM recording_genres rg2 - JOIN genres g2 ON rg2.genre_id = g2.id - WHERE rg2.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, - MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE g.name = ? -ORDER BY r.name; - --- name: GetTracksByGenreByLibrary :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rlg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g2.name, '||') - FROM recording_genres rg2 - JOIN genres g2 ON rg2.genre_id = g2.id - WHERE rg2.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, - MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE g.name = ? AND af.library_id = ? -ORDER BY r.name; - --- name: CountGenreReferences :one -SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?; +-- name: GetGenreNamesByFilePaths :many +-- Genres for many files at once. The mix builder asked this one file +-- at a time, inside two nested loops -- twelve thousand single-row +-- queries to assemble one mix. +SELECT af.file_path, g.name +FROM audio_files af +JOIN file_genres fg ON fg.audio_file_id = af.id +JOIN genres g ON g.id = fg.genre_id +WHERE af.file_path IN (sqlc.slice('paths')); -- name: DeleteGenre :exec DELETE FROM genres WHERE id = ?; +-- name: DeleteAllGenres :exec +DELETE FROM genres; + +-- name: GetUnusedGenreIDs :many +SELECT id FROM genres g +WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id); + -- name: GetAllGenresWithCounts :many -SELECT g.name, COUNT(rg.recording_id) AS track_count +SELECT g.name, COUNT(fg.audio_file_id) AS track_count FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id +JOIN file_genres fg ON fg.genre_id = g.id +JOIN audio_files af ON af.id = fg.audio_file_id +WHERE af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id) GROUP BY g.id, g.name ORDER BY g.name; - --- name: GetAllGenresWithCountsByLibrary :many -SELECT g.name, COUNT(rg.recording_id) AS track_count -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE af.library_id = ? -GROUP BY g.id, g.name -ORDER BY g.name; - --- Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2): --- one query instead of one per genre, and file paths instead of whole --- track rows, which was 6 MB over the IPC for five genres. - --- name: GetFilePathsByGenres :many -SELECT g.name AS genre_name, af.file_path -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE g.name IN (sqlc.slice('genre_names')) -ORDER BY r.name; - --- name: GetFilePathsByGenresByLibrary :many -SELECT g.name AS genre_name, af.file_path -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE g.name IN (sqlc.slice('genre_names')) - AND af.library_id = ? -ORDER BY r.name; diff --git a/backend/database/sql/queries/home.sql b/backend/database/sql/queries/home.sql index f033510..a9a2b96 100644 --- a/backend/database/sql/queries/home.sql +++ b/backend/database/sql/queries/home.sql @@ -2,16 +2,15 @@ -- -- Every one of these returns album ids and nothing else. The display -- columns (cover art, artist credit, year) already have exactly one --- correct expression of them, in GetAllAlbumsWithDetails, and a second +-- correct expression of them, in GetAlbums, and a second -- copy per shelf would be six more places for that to drift. The home -- service joins the ids back to that one album list in Go. -- name: HomeRecentlyPlayedAlbums :many -- Albums with the most recent play, newest first. SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id WHERE af.last_played IS NOT NULL GROUP BY rg.id ORDER BY MAX(af.last_played) DESC @@ -22,9 +21,8 @@ LIMIT ?; -- stands in for one: it is monotonic and assigned at import, which is -- the same ordering an added_at column would give. SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id ORDER BY MAX(af.id) DESC LIMIT ?; @@ -32,9 +30,8 @@ LIMIT ?; -- name: HomeMostPlayedAlbums :many -- Albums by total plays across their tracks. SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id HAVING SUM(af.play_count) > 0 ORDER BY SUM(af.play_count) DESC @@ -45,9 +42,8 @@ LIMIT ?; -- shelf is a different suggestion each time rather than the same -- alphabetical head of the list forever. SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id HAVING SUM(af.play_count) = 0 ORDER BY RANDOM() @@ -56,9 +52,8 @@ LIMIT ?; -- name: HomeStaleAlbums :many -- Played before, but not for a long while. SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id WHERE af.last_played IS NOT NULL GROUP BY rg.id HAVING MAX(af.last_played) < datetime('now', ?) @@ -67,9 +62,8 @@ LIMIT ?; -- name: HomeRandomAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id ORDER BY RANDOM() LIMIT ?; @@ -78,10 +72,10 @@ LIMIT ?; -- A random sample of albums carrying a genre, so the same genre shelf -- is not the same ten albums every time the page opens. SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id -JOIN genres g ON g.id = rgen.genre_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id +JOIN file_genres fg ON fg.audio_file_id = af.id +JOIN genres g ON g.id = fg.genre_id WHERE g.name = ? GROUP BY rg.id ORDER BY RANDOM() @@ -93,10 +87,10 @@ LIMIT ?; -- album carries is a shelf about that one album. SELECT g.name AS genre, - COUNT(DISTINCT rgr.release_group_id) AS album_count + COUNT(DISTINCT af.album_id) AS album_count FROM genres g -JOIN recording_genres rgen ON rgen.genre_id = g.id -JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id +JOIN file_genres fg ON fg.genre_id = g.id +JOIN audio_files af ON af.id = fg.audio_file_id GROUP BY g.id HAVING album_count >= 3 ORDER BY album_count DESC @@ -106,14 +100,12 @@ LIMIT ?; -- Artists by total plays, as the album-artist credit text the album -- list already displays. SELECT - COALESCE(ac.text, '') AS artist_name, + rg.artist_credit AS artist_name, SUM(af.play_count) AS plays -FROM release_groups rg -JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id -WHERE ac.text <> '' -GROUP BY ac.text +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id +WHERE rg.artist_credit <> '' +GROUP BY rg.artist_credit HAVING plays > 0 ORDER BY plays DESC LIMIT ?; diff --git a/backend/database/sql/queries/mix.sql b/backend/database/sql/queries/mix.sql deleted file mode 100644 index 36a2b30..0000000 --- a/backend/database/sql/queries/mix.sql +++ /dev/null @@ -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; diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql index e2afecd..b604362 100644 --- a/backend/database/sql/queries/playlists.sql +++ b/backend/database/sql/queries/playlists.sql @@ -47,29 +47,18 @@ SELECT pt.playlist_id, pt.audio_file_id, pt.position, - COALESCE(af.file_path, '') AS file_path, - COALESCE(af.length_milliseconds, 0) AS length_milliseconds, - COALESCE(r.name, pt.phantom_title, '') AS title, - COALESCE(ac.text, pt.phantom_artist, '') AS artist, - COALESCE(rg.name, pt.phantom_album, '') AS album, - COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, + COALESCE(tm.file_path, '') AS file_path, + COALESCE(tm.length_milliseconds, 0) AS length_milliseconds, + COALESCE(tm.title, pt.phantom_title, '') AS title, + COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist, + COALESCE(tm.album, pt.phantom_album, '') AS album, + COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid + CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid, + COALESCE(tm.release_group_mbid, '') AS release_group_mbid, + COALESCE(tm.recording_mbid, '') AS recording_mbid FROM playlist_tracks pt -LEFT JOIN audio_files af ON pt.audio_file_id = af.id -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id WHERE pt.playlist_id = ? ORDER BY pt.position; @@ -79,29 +68,18 @@ SELECT pt.playlist_id, pt.audio_file_id, pt.position, - COALESCE(af.file_path, '') AS file_path, - COALESCE(af.length_milliseconds, 0) AS length_milliseconds, - COALESCE(r.name, pt.phantom_title, '') AS title, - COALESCE(ac.text, pt.phantom_artist, '') AS artist, - COALESCE(rg.name, pt.phantom_album, '') AS album, - COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, + COALESCE(tm.file_path, '') AS file_path, + COALESCE(tm.length_milliseconds, 0) AS length_milliseconds, + COALESCE(tm.title, pt.phantom_title, '') AS title, + COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist, + COALESCE(tm.album, pt.phantom_album, '') AS album, + COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid + CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid, + COALESCE(tm.release_group_mbid, '') AS release_group_mbid, + COALESCE(tm.recording_mbid, '') AS recording_mbid FROM playlist_tracks pt -LEFT JOIN audio_files af ON pt.audio_file_id = af.id -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id ORDER BY pt.playlist_id, pt.position; -- name: DeleteAllPlaylistTracks :exec @@ -132,27 +110,8 @@ WHERE playlist_id = ? AND audio_file_id = ( ); -- name: GetTrackPhantomMetadata :one -SELECT - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album, - af.length_milliseconds AS duration_ms, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(ca.file_path, '') AS cover_art_path -FROM audio_files af -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id -WHERE af.id = ?; +-- The display fields a playlist row keeps after its file goes away. +SELECT title, artist_name AS artist, album, + length_milliseconds AS duration_ms, genre, cover_art_path +FROM track_metadata +WHERE id = ?; diff --git a/backend/database/sql/queries/queue.sql b/backend/database/sql/queries/queue.sql index 1f06dc9..58eac27 100644 --- a/backend/database/sql/queries/queue.sql +++ b/backend/database/sql/queries/queue.sql @@ -13,27 +13,12 @@ SET current_position = ? WHERE id = 1; -- name: GetQueueTracks :many -SELECT qt.id, qt.audio_file_id, qt.position, af.file_path, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid +-- The queue's rows, joined to the one track projection. +SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path, + tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path, + tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid FROM queue_tracks qt -JOIN audio_files af ON qt.audio_file_id = af.id -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +JOIN track_metadata tm ON tm.id = qt.audio_file_id ORDER BY qt.position; -- name: GetQueueTrackCount :one diff --git a/backend/database/sql/queries/recordings.sql b/backend/database/sql/queries/recordings.sql deleted file mode 100644 index a7a3167..0000000 --- a/backend/database/sql/queries/recordings.sql +++ /dev/null @@ -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; diff --git a/backend/database/sql/queries/release_group_recordings.sql b/backend/database/sql/queries/release_group_recordings.sql deleted file mode 100644 index b318ba9..0000000 --- a/backend/database/sql/queries/release_group_recordings.sql +++ /dev/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 = ?; diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql deleted file mode 100644 index eb9fba7..0000000 --- a/backend/database/sql/queries/release_groups.sql +++ /dev/null @@ -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; diff --git a/backend/database/sql/queries/tagging_items.sql b/backend/database/sql/queries/tagging_items.sql index 0322b9e..9a6a2fa 100644 --- a/backend/database/sql/queries/tagging_items.sql +++ b/backend/database/sql/queries/tagging_items.sql @@ -87,10 +87,7 @@ LIMIT 1; SELECT ti.group_key FROM tagging_items ti JOIN audio_files af ON af.group_key = ti.group_key -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id -LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id +LEFT JOIN albums rg ON rg.id = af.album_id WHERE ti.synthetic = 0 AND ti.track_count >= 4 AND ( @@ -98,13 +95,23 @@ WHERE ti.synthetic = 0 OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown') ) GROUP BY ti.group_key -HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1 +HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1 AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1; -- name: CountPendingTaggingItems :one -SELECT COUNT(*) FROM tagging_items -WHERE status = 'pending' - AND (CAST(@library_id AS INTEGER) = 0 OR library_id = @library_id); +-- "Needs tagging" is a question about the files, not about the row: +-- every scanned folder gets a tagging_items row (see +-- UpsertTaggingItemOnTrackAdd), including one whose files all arrived +-- carrying a recording MBID. Without the EXISTS a fully MB-tagged +-- library reports its entire album count as pending work. See the +-- same predicate on the three list queries below. +SELECT COUNT(*) FROM tagging_items ti +WHERE ti.status = 'pending' + AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) + AND EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ); -- name: ListPendingTaggingItemsAlphabetical :many SELECT @@ -125,6 +132,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter) AND ti.cleared_at IS NULL + -- Actionable rows must have something to act on: see + -- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are + -- exempt because they are history, not work -- an applied folder is + -- fully tagged by definition and would otherwise vanish from the + -- sidebar's Completed section the instant it succeeded. + AND ( + ti.status IN ('confirmed', 'skipped') + OR EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) + ) ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number LIMIT @row_limit OFFSET @row_offset; @@ -150,6 +169,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id WHERE (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) AND (CAST(@status_filter AS TEXT) = 'all' OR ti.status = @status_filter) AND ti.cleared_at IS NULL + -- See ListPendingTaggingItemsAlphabetical. + AND ( + ti.status IN ('confirmed', 'skipped') + OR EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) + ) ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name) LIMIT @row_limit OFFSET @row_offset; @@ -204,61 +231,53 @@ ORDER BY ti.created_at DESC, ti.group_key LIMIT @row_limit OFFSET @row_offset; -- name: ListAudioFilesInTaggingGroup :many --- album_name/album_artist are the PER-TRACK tags (via each track's --- own release_group link), not the folder-level tagging_items --- values. SplitMixedFolder clusters on these to find sub-albums --- hiding inside a folder full of unrelated tracks. +-- album_name/album_artist are the PER-TRACK tags (each file's own +-- album link), not the folder-level tagging_items values. +-- SplitMixedFolder clusters on these to find sub-albums hiding inside +-- a folder full of unrelated tracks. SELECT af.id, af.file_path, af.basename, af.length_milliseconds, af.tag_status, - COALESCE(r.track_number, 0) AS track_number, - COALESCE(r.disc_number, 0) AS disc_number, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - COALESCE(r.mbid, '') AS recording_mbid, - COALESCE(rg.name, '') AS album_name, - COALESCE(rgac.text, '') AS album_artist + COALESCE(af.track_number, 0) AS track_number, + COALESCE(af.disc_number, 0) AS disc_number, + af.title, + af.artist_credit AS artist_name, + COALESCE(af.recording_mbid, '') AS recording_mbid, + COALESCE(al.name, '') AS album_name, + COALESCE(al.artist_credit, '') AS album_artist FROM audio_files af -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id -LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id -LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id +LEFT JOIN albums al ON al.id = af.album_id WHERE af.group_key = ? -ORDER BY COALESCE(r.disc_number, 0), - COALESCE(r.track_number, 0), +ORDER BY COALESCE(af.disc_number, 0), + COALESCE(af.track_number, 0), af.file_path; --- name: ListLocalReleaseGroupCandidates :many --- Returns one row per (release_group, track) combination for any --- local release_group that has an MBID. Callers group these in Go --- and filter by normalized album-name match. Joined case-insensitive --- on name to pre-filter cheaply; Go does the real normalization. +-- name: ListLocalAlbumCandidates :many +-- One row per (album, track) for any local album carrying an MBID. +-- Callers group these in Go and filter by normalized album-name match; +-- the join is case-insensitive on name to pre-filter cheaply. SELECT - rg.id AS release_group_id, - rg.mbid AS release_group_mbid, - rg.name AS album_name, - COALESCE(rg.year, 0) AS year, - COALESCE(ac.text, '') AS artist_credit, - COALESCE(rgr.track_number, 0) AS track_number, - COALESCE(rgr.disc_number, 0) AS disc_number, - COALESCE(r.name, '') AS track_title, - COALESCE(r.mbid, '') AS recording_mbid, - COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN recordings r ON r.id = rgr.recording_id -LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id -LEFT JOIN audio_files local_af ON local_af.recording_id = r.id -WHERE rg.mbid IS NOT NULL - AND rg.mbid != '' - AND r.mbid IS NOT NULL - AND r.mbid != '' - AND rg.name = ? COLLATE NOCASE -ORDER BY rg.id, rgr.disc_number, rgr.track_number; + al.id AS album_id, + al.mbid AS album_mbid, + al.name AS album_name, + COALESCE(al.year, 0) AS year, + al.artist_credit, + COALESCE(af.track_number, 0) AS track_number, + COALESCE(af.disc_number, 0) AS disc_number, + af.title AS track_title, + COALESCE(af.recording_mbid, '') AS recording_mbid, + af.length_milliseconds +FROM albums al +JOIN audio_files af ON af.album_id = al.id +WHERE al.mbid IS NOT NULL + AND al.mbid != '' + AND af.recording_mbid IS NOT NULL + AND af.recording_mbid != '' + AND al.name = ? COLLATE NOCASE +ORDER BY al.id, af.disc_number, af.track_number; -- name: SetTaggingItemBestMatch :exec UPDATE tagging_items @@ -284,17 +303,16 @@ WHERE group_key = ?; -- name: SetAudioFileTagStatus :exec UPDATE audio_files SET tag_status = ? WHERE id = ?; --- name: SetRecordingMBID :exec -UPDATE recordings SET mbid = ? WHERE id = ?; +-- name: SetFileRecordingMBID :exec +UPDATE audio_files SET recording_mbid = ? WHERE id = ?; --- name: SetReleaseGroupMBID :exec -UPDATE release_groups SET mbid = ? WHERE id = ?; - --- name: GetRecordingReleaseGroupID :one -SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id -FROM release_group_recordings rgr -WHERE rgr.recording_id = ? -LIMIT 1; +-- name: SetFileAlbumMBID :exec +-- The album MBID for the album a file belongs to. Keyed by file +-- because that is what the autotag apply path holds; under the old +-- schema it had to look the release group up through two join tables +-- first (GetRecordingReleaseGroupID), which is gone. +UPDATE albums SET mbid = ? +WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?); -- name: GetNextPendingTaggingItem :one SELECT @@ -315,5 +333,12 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id WHERE ti.status = 'pending' AND (CAST(@library_id AS INTEGER) = 0 OR ti.library_id = @library_id) AND ti.group_key > @after_group_key + -- See CountPendingTaggingItems: the cursor must not stop on a + -- folder the list query no longer shows, or "next" walks folders + -- that are not in the sidebar. + AND EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) ORDER BY ti.group_key LIMIT 1; diff --git a/backend/database/sql/schemas/albums.sql b/backend/database/sql/schemas/albums.sql new file mode 100644 index 0000000..50f4f30 --- /dev/null +++ b/backend/database/sql/schemas/albums.sql @@ -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; diff --git a/backend/database/sql/schemas/artist_credit.sql b/backend/database/sql/schemas/artist_credit.sql deleted file mode 100644 index 1656de0..0000000 --- a/backend/database/sql/schemas/artist_credit.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS artist_credit ( - id INTEGER PRIMARY KEY, - text TEXT NOT NULL UNIQUE -); diff --git a/backend/database/sql/schemas/artist_credit_artist.sql b/backend/database/sql/schemas/artist_credit_artist.sql deleted file mode 100644 index 200a044..0000000 --- a/backend/database/sql/schemas/artist_credit_artist.sql +++ /dev/null @@ -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); diff --git a/backend/database/sql/schemas/artist_images.sql b/backend/database/sql/schemas/artist_images.sql index 3b69083..1659b00 100644 --- a/backend/database/sql/schemas/artist_images.sql +++ b/backend/database/sql/schemas/artist_images.sql @@ -12,8 +12,8 @@ CREATE TABLE IF NOT EXISTS artist_images ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE INDEX IF NOT EXISTS idx_artist_images_mbid - ON artist_images(artist_mbid); +-- No index on artist_mbid alone: the UNIQUE index below has it as its +-- leftmost column. CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source ON artist_images(artist_mbid, source, source_url); diff --git a/backend/database/sql/schemas/artist_metadata.sql b/backend/database/sql/schemas/artist_metadata.sql index 9b86c69..786d352 100644 --- a/backend/database/sql/schemas/artist_metadata.sql +++ b/backend/database/sql/schemas/artist_metadata.sql @@ -12,4 +12,6 @@ CREATE TABLE IF NOT EXISTS artist_metadata ( PRIMARY KEY (mbid, source) ); -CREATE INDEX IF NOT EXISTS idx_artist_metadata_mbid ON artist_metadata(mbid); +-- No index on mbid alone: PRIMARY KEY (mbid, source) already has it as +-- its leftmost column, so a second one costs a write per row and serves +-- no read. diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql index b422c06..876d852 100644 --- a/backend/database/sql/schemas/audio_files.sql +++ b/backend/database/sql/schemas/audio_files.sql @@ -1,34 +1,92 @@ +-- One row per audio file, and the file's tags live on it. +-- +-- This table used to be a stub — path, format, a foreign key — with +-- every tag-derived field one join away in `recordings`, which was in +-- turn linked to an album through `release_group_recordings` and to an +-- artist through `artist_credit` + `artist_credit_artist`. That is +-- MusicBrainz's data model, and it is the right model for MusicBrainz: +-- a recording really can appear on many releases and a credit really +-- can list many artists. +-- +-- It was the wrong model here, and the library said so. Measured on a +-- real 25,966-file library: **no** recording had more than one file, +-- **no** recording belonged to more than one release group, and 3 of +-- 2,823 credits listed more than one artist. Every many-to-many the +-- schema modelled was 1:1 in the data, and the cost of modelling it +-- anyway was a six-way join in every read, a `MIN(release_group_id)` +-- subquery in eleven queries to collapse a fan-out that never happened, +-- a first-credited-artist subquery in nine more to collapse the other +-- one, and — the reason this changed — a whole class of bugs where a +-- `recordings` row **outlived the file that created it**. Retagging a +-- file created a new recording and abandoned the old one, so the same +-- library carried 812 recordings, 216 release groups and 260 artists +-- with no file behind them, and everything that asked "do I own this" +-- by looking for a metadata row got 129 confident yeses for tracks +-- that could not be played. +-- +-- With the tags on the file, ownership is not a rule anyone can forget: +-- the row *is* the file. CREATE TABLE IF NOT EXISTS audio_files ( - id integer PRIMARY KEY, - file_path text NOT NULL UNIQUE, - length_milliseconds int NOT NULL, - file_type_id int NOT NULL, - recording_id int NOT NULL, - sample_rate int NOT NULL DEFAULT 0, - bit_depth int NOT NULL DEFAULT 0, - channels int NOT NULL DEFAULT 0, - bitrate int NOT NULL DEFAULT 0, - file_size int NOT NULL DEFAULT 0, - basename text NOT NULL DEFAULT '', - library_id int NOT NULL DEFAULT 0, - play_count int NOT NULL DEFAULT 0, - last_played datetime, - tag_status TEXT NOT NULL DEFAULT 'untagged' + id INTEGER PRIMARY KEY, + file_path TEXT NOT NULL UNIQUE, + library_id INTEGER NOT NULL DEFAULT 0, + file_type_id INTEGER NOT NULL, + + -- Audio properties, read from the file itself. + length_milliseconds INTEGER NOT NULL, + sample_rate INTEGER NOT NULL DEFAULT 0, + bit_depth INTEGER NOT NULL DEFAULT 0, + channels INTEGER NOT NULL DEFAULT 0, + bitrate INTEGER NOT NULL DEFAULT 0, + file_size INTEGER NOT NULL DEFAULT 0, + + -- Tags. `artist_credit` is the credit as tagged ("A feat. B") and is + -- for display; `artist_id` is the primary artist it resolves to, and + -- is what grouping, browsing and the artist page use. Keeping both + -- is what makes the credit table unnecessary: the string is the only + -- thing that was ever read off it. + title TEXT NOT NULL DEFAULT '', + artist_credit TEXT NOT NULL DEFAULT '', + artist_id INTEGER, + album_id INTEGER, + track_number INTEGER, + disc_number INTEGER, + -- The denominator the tag declared: the 12 in "5/12", per disc. It + -- is what lets "do I have all of this album" be answered from disk + -- instead of from MusicBrainz. NULL means the tag did not say, which + -- is a third state and not the same as zero. + total_tracks INTEGER, + year INTEGER, + composer TEXT NOT NULL DEFAULT '', + comment TEXT NOT NULL DEFAULT '', + recording_mbid TEXT, + + -- Library bookkeeping. + basename TEXT NOT NULL DEFAULT '', + group_key TEXT NOT NULL DEFAULT '', + -- File mtime as a Unix timestamp in seconds, captured at import and + -- compared against the on-disk mtime during a scan to detect files + -- another application retagged in place. + modified_at INTEGER NOT NULL DEFAULT 0, + play_count INTEGER NOT NULL DEFAULT 0, + last_played DATETIME, + tag_status TEXT NOT NULL DEFAULT 'untagged' CHECK(tag_status IN ( 'untagged', 'auto_matched', 'user_confirmed', 'user_skipped_permanent' )), - group_key TEXT NOT NULL DEFAULT '', - -- File mtime as a Unix timestamp in seconds, captured at import. - -- Compared against the on-disk mtime during a scan to detect files - -- another application retagged in place. 0 means "never recorded" - -- (rows predating migration 47) and is treated as not-stale so an - -- upgrade does not re-import the whole library. - modified_at int NOT NULL DEFAULT 0, + FOREIGN KEY(file_type_id) REFERENCES file_types(id), - FOREIGN KEY(recording_id) REFERENCES recordings(id), - FOREIGN KEY(library_id) REFERENCES libraries(id) + FOREIGN KEY(library_id) REFERENCES libraries(id), + FOREIGN KEY(artist_id) REFERENCES artists(id), + FOREIGN KEY(album_id) REFERENCES albums(id) ); +CREATE INDEX IF NOT EXISTS idx_audio_files_album_id + ON audio_files(album_id); + +CREATE INDEX IF NOT EXISTS idx_audio_files_artist_id + ON audio_files(artist_id); + CREATE INDEX IF NOT EXISTS idx_audio_files_basename ON audio_files(basename); @@ -36,10 +94,17 @@ CREATE INDEX IF NOT EXISTS idx_audio_files_group_key ON audio_files(group_key) WHERE group_key != ''; CREATE INDEX IF NOT EXISTS idx_audio_files_library_id - ON audio_files(library_id); + ON audio_files(library_id); -CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id - ON audio_files(recording_id); +-- The ownership question, asked by MBID: "is there a *file* with this +-- recording MBID". Nothing may answer it from a metadata table again. +CREATE INDEX IF NOT EXISTS idx_audio_files_recording_mbid + ON audio_files(recording_mbid) WHERE recording_mbid IS NOT NULL; + +-- Answers "does this tagging group still contain untagged files" in one +-- seek per group. The autotag queue asks it once per row. +CREATE INDEX IF NOT EXISTS idx_audio_files_untagged_group_key + ON audio_files(group_key) WHERE tag_status = 'untagged'; CREATE INDEX IF NOT EXISTS idx_audio_files_tag_status_untagged ON audio_files(library_id) WHERE tag_status = 'untagged'; diff --git a/backend/database/sql/schemas/explore_index.sql b/backend/database/sql/schemas/explore_index.sql index cdb61c5..a5d17da 100644 --- a/backend/database/sql/schemas/explore_index.sql +++ b/backend/database/sql/schemas/explore_index.sql @@ -1,10 +1,25 @@ +-- The downloaded MusicBrainz/ListenBrainz catalog. +-- +-- MusicBrainz ids are stored as their 16 raw bytes and entity types as +-- small integers, which is a size decision: on a real 2,052,200-row +-- catalog those four columns were 220 MB of a 383 MB table and were +-- carried again in every index keyed on them, and the conversion took +-- the table and its four indexes from 677 MB to 389 MB. See +-- backend/explore/mbid.go, which is the only place that encoding is +-- known -- everything above it speaks dashed strings and entity names. +-- +-- The CHECK constraints are what make a mistake loud. SQLite does not +-- coerce between TEXT and BLOB, so a query comparing this column +-- against a 36-character string returns no rows rather than an error; +-- a *write* of one fails here instead, at the insert that made it. CREATE TABLE IF NOT EXISTS explore_index ( id INTEGER PRIMARY KEY AUTOINCREMENT, - entity_type TEXT NOT NULL, - mbid TEXT NOT NULL, + entity_type INTEGER NOT NULL, + mbid BLOB NOT NULL CHECK(length(mbid) = 16), title TEXT NOT NULL, artist_name TEXT NOT NULL, - artist_mbid TEXT NOT NULL, + artist_mbid BLOB NOT NULL + CHECK(length(artist_mbid) IN (0, 16)), aliases TEXT NOT NULL DEFAULT '', -- Popularity signals, derived from the ListenBrainz listens dump. @@ -13,7 +28,8 @@ CREATE TABLE IF NOT EXISTS explore_index ( -- Recording-specific fields. duration INTEGER NOT NULL DEFAULT 0, - caa_release_mbid TEXT NOT NULL DEFAULT '', + caa_release_mbid BLOB NOT NULL DEFAULT x'' + CHECK(length(caa_release_mbid) IN (0, 16)), release_name TEXT NOT NULL DEFAULT '', -- Release-group-specific fields. @@ -21,6 +37,13 @@ CREATE TABLE IF NOT EXISTS explore_index ( secondary_types TEXT NOT NULL DEFAULT '', release_date TEXT NOT NULL DEFAULT '', + -- How many tracks the release group's canonical release has, so + -- "do I have all of this" is answerable offline for an album the + -- library holds no tags for. Zero means the catalog does not say, + -- which is the same third state the local answer has -- and is what + -- every row carries until a central dump build fills it. + total_tracks INTEGER NOT NULL DEFAULT 0, + -- Artist-specific fields. artist_type TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '', @@ -46,13 +69,22 @@ CREATE TABLE IF NOT EXISTS explore_index ( UNIQUE(mbid) ); +-- The exact-match tier's two indexes. +-- +-- Their predicate is the champion set - the popular rows plus whatever +-- the user owns - and matching it to `ExactMatches`' own WHERE clause is +-- what makes them small. They used to say `popularity > 0`, which on a +-- real 2,052,200-row catalog covered 2,046,645 of them: a full index +-- wearing a partial index's clothes, 101 MB for the pair. Narrowed to +-- the set the tier can actually return, they are 3 MB and the query +-- plan is unchanged (measured, on that catalog). CREATE INDEX IF NOT EXISTS idx_explore_artist_lower ON explore_index(LOWER(artist_name)) - WHERE popularity > 0; + WHERE popularity >= 10000 OR in_library = 1; CREATE INDEX IF NOT EXISTS idx_explore_caa_release ON explore_index(caa_release_mbid) - WHERE entity_type = 'release_group' AND caa_release_mbid != ''; + WHERE entity_type = 2 AND caa_release_mbid != x''; CREATE INDEX IF NOT EXISTS idx_explore_index_artist_mbid ON explore_index(artist_mbid, entity_type, popularity DESC); @@ -62,4 +94,4 @@ CREATE INDEX IF NOT EXISTS idx_explore_index_entity_pop CREATE INDEX IF NOT EXISTS idx_explore_title_lower ON explore_index(LOWER(title)) - WHERE popularity > 0; + WHERE popularity >= 10000 OR in_library = 1; diff --git a/backend/database/sql/schemas/file_genres.sql b/backend/database/sql/schemas/file_genres.sql new file mode 100644 index 0000000..c5ae026 --- /dev/null +++ b/backend/database/sql/schemas/file_genres.sql @@ -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); diff --git a/backend/database/sql/schemas/libraries.sql b/backend/database/sql/schemas/libraries.sql index 6c065dd..84044df 100644 --- a/backend/database/sql/schemas/libraries.sql +++ b/backend/database/sql/schemas/libraries.sql @@ -1,15 +1,11 @@ --- One row per "go find me this", from the moment the user asks until --- the files are in the library or the attempt is abandoned. +-- One row per folder the user has added as a music library. -- --- release_mbid / release_group_mbid are the anchor: a request that --- carries one can be matched against a known tracklist at import time, --- which is what makes unattended completion safe. Free-text requests --- (both NULL) are always presented to the user for confirmation. +-- Everything else keyed by library_id means "which of these folders did +-- this come from"; a library_id of 0 in a query means "all of them". -- --- `expected` caches the anchor's tracklist as JSON so ranking and --- import do not have to re-resolve it, and so a request survives the --- explore index being rebuilt underneath it. - +-- autotag_warning_acked records that the user has been told what +-- autotagging will do to the files in this folder, which is a decision +-- they made and not something a rescan can rediscover. CREATE TABLE IF NOT EXISTS libraries ( id INTEGER PRIMARY KEY, diff --git a/backend/database/sql/schemas/lyrics.sql b/backend/database/sql/schemas/lyrics.sql new file mode 100644 index 0000000..278a153 --- /dev/null +++ b/backend/database/sql/schemas/lyrics.sql @@ -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; diff --git a/backend/database/sql/schemas/recording_genres.sql b/backend/database/sql/schemas/recording_genres.sql deleted file mode 100644 index f658af0..0000000 --- a/backend/database/sql/schemas/recording_genres.sql +++ /dev/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); diff --git a/backend/database/sql/schemas/recordings.sql b/backend/database/sql/schemas/recordings.sql deleted file mode 100644 index c372209..0000000 --- a/backend/database/sql/schemas/recordings.sql +++ /dev/null @@ -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; diff --git a/backend/database/sql/schemas/release_group_recordings.sql b/backend/database/sql/schemas/release_group_recordings.sql deleted file mode 100644 index 9b9c3da..0000000 --- a/backend/database/sql/schemas/release_group_recordings.sql +++ /dev/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); diff --git a/backend/database/sql/schemas/release_groups.sql b/backend/database/sql/schemas/release_groups.sql deleted file mode 100644 index ced626c..0000000 --- a/backend/database/sql/schemas/release_groups.sql +++ /dev/null @@ -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; diff --git a/backend/database/sql/schemas/release_to_rg.sql b/backend/database/sql/schemas/release_to_rg.sql index 471d9e7..d5767c5 100644 --- a/backend/database/sql/schemas/release_to_rg.sql +++ b/backend/database/sql/schemas/release_to_rg.sql @@ -1,4 +1,11 @@ +-- Release MBID -> release-group MBID, captured during a dump import. +-- +-- It is empty on an ordinary install and looks droppable for that +-- reason: only a local dump build (`indexbuild`) fills it. The daily +-- incremental refresh reads it to roll per-release listen counts up to +-- the release group they belong to, so an install that has built its +-- own index does need it. CREATE TABLE IF NOT EXISTS release_to_rg ( release_mbid TEXT PRIMARY KEY, rg_mbid TEXT NOT NULL - ) WITHOUT ROWID; +) WITHOUT ROWID; diff --git a/backend/database/sql/schemas/similar_artist_map.sql b/backend/database/sql/schemas/similar_artist_map.sql index 66b94b8..a23e7cf 100644 --- a/backend/database/sql/schemas/similar_artist_map.sql +++ b/backend/database/sql/schemas/similar_artist_map.sql @@ -6,5 +6,5 @@ CREATE TABLE IF NOT EXISTS similar_artist_map ( PRIMARY KEY (source_artist_mbid, similar_artist_mbid) ); -CREATE INDEX IF NOT EXISTS idx_similar_artist_map_source - ON similar_artist_map(source_artist_mbid); +-- No index on source_artist_mbid alone: the PRIMARY KEY has it as its +-- leftmost column. diff --git a/backend/database/sql/schemas/track_metadata.sql b/backend/database/sql/schemas/track_metadata.sql index a94d709..b01048a 100644 --- a/backend/database/sql/schemas/track_metadata.sql +++ b/backend/database/sql/schemas/track_metadata.sql @@ -1,23 +1,42 @@ -CREATE VIEW IF NOT EXISTS track_metadata AS +-- The one definition of "a track, with everything a list needs". +-- +-- A view is a definition, not data, so it is dropped and recreated on +-- every open rather than carrying a migration alongside it: CREATE VIEW +-- IF NOT EXISTS silently keeps an older database on the old definition, +-- and a migration file restating it would be the second description of +-- the schema the migration rules exist to prevent. +-- +-- This projection used to exist **nine times** — four copies in +-- audio_files.sql, two in playlists.sql, two in genres.sql, one in +-- queue.sql — plus this view, which only the raw-SQL search paths used. +-- They had already drifted: this view preferred the album's +-- original_year for `year` and GetAllTracksWithFullMetadata used the +-- track's own, so the same library reported different years on +-- different screens. Every query that wants a track row now selects +-- from here, which is also why there is one row type and one mapper on +-- the Go side instead of nine and a twenty-two-argument function. +DROP VIEW IF EXISTS track_metadata; + +CREATE VIEW track_metadata AS SELECT af.id, af.file_path, af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, + af.title, + af.artist_credit AS artist_name, + af.track_number, + af.disc_number, + COALESCE(al.name, '') AS album, CAST(COALESCE( (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), + FROM file_genres fg + JOIN genres g ON g.id = fg.genre_id + WHERE fg.audio_file_id = af.id), '' ) AS TEXT) AS genre, - COALESCE(rg.original_year, rg.year, r.year, 0) AS year, - COALESCE(rg.year, r.year, 0) AS release_year, - COALESCE(r.composer, '') AS composer, + COALESCE(al.original_year, al.year, af.year, 0) AS year, + COALESCE(al.year, af.year, 0) AS release_year, + af.composer, COALESCE(ft.extension, '') AS file_type, af.sample_rate, af.bit_depth, @@ -28,20 +47,13 @@ CREATE VIEW IF NOT EXISTS track_metadata AS af.play_count, af.last_played, COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid + COALESCE(ar.mbid, '') AS artist_mbid, + COALESCE(al.mbid, '') AS release_group_mbid, + COALESCE(af.recording_mbid, '') AS recording_mbid, + af.album_id, + af.artist_id FROM audio_files af - LEFT JOIN recordings r ON af.recording_id = r.id - LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id - LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id - LEFT JOIN artists a ON a.id = aca.artist_id - LEFT JOIN ( - SELECT recording_id, - MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id - ) rgr ON r.id = rgr.recording_id - LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id - LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id - LEFT JOIN file_types ft ON af.file_type_id = ft.id; + LEFT JOIN albums al ON al.id = af.album_id + LEFT JOIN artists ar ON ar.id = af.artist_id + LEFT JOIN cover_art ca ON ca.id = al.cover_art_id + LEFT JOIN file_types ft ON ft.id = af.file_type_id; diff --git a/backend/database/sql/sqlcgen/albums.sql.go b/backend/database/sql/sqlcgen/albums.sql.go new file mode 100644 index 0000000..709eb20 --- /dev/null +++ b/backend/database/sql/sqlcgen/albums.sql.go @@ -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 +} diff --git a/backend/database/sql/sqlcgen/artist_credit.sql.go b/backend/database/sql/sqlcgen/artist_credit.sql.go deleted file mode 100644 index ae2deb9..0000000 --- a/backend/database/sql/sqlcgen/artist_credit.sql.go +++ /dev/null @@ -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 -} diff --git a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go deleted file mode 100644 index da1ff97..0000000 --- a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go +++ /dev/null @@ -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 -} diff --git a/backend/database/sql/sqlcgen/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go index b4f5f17..9e0a116 100644 --- a/backend/database/sql/sqlcgen/artists.sql.go +++ b/backend/database/sql/sqlcgen/artists.sql.go @@ -7,20 +7,9 @@ package sqlcgen import ( "context" + "database/sql" ) -const createArtist = `-- name: CreateArtist :one -INSERT INTO artists (name) VALUES (?) -RETURNING id, name, mbid -` - -func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) { - row := q.db.QueryRowContext(ctx, createArtist, name) - var i Artist - err := row.Scan(&i.ID, &i.Name, &i.Mbid) - return i, err -} - const deleteAllArtists = `-- name: DeleteAllArtists :exec DELETE FROM artists ` @@ -31,8 +20,7 @@ func (q *Queries) DeleteAllArtists(ctx context.Context) error { } const deleteArtist = `-- name: DeleteArtist :exec -DELETE FROM artists -WHERE id = ? +DELETE FROM artists WHERE id = ? ` func (q *Queries) DeleteArtist(ctx context.Context, id int64) error { @@ -43,56 +31,17 @@ func (q *Queries) DeleteArtist(ctx context.Context, id int64) error { const getAlbumArtists = `-- name: GetAlbumArtists :many SELECT DISTINCT a.id, a.name, a.mbid FROM artists a -JOIN artist_credit_artist aca ON aca.artist_id = a.id -JOIN artist_credit ac ON ac.id = aca.credit_id -JOIN release_groups rg ON rg.album_artist_credit_id = ac.id -ORDER BY a.name -` - -func (q *Queries) GetAlbumArtists(ctx context.Context) ([]Artist, error) { - rows, err := q.db.QueryContext(ctx, getAlbumArtists) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Artist - for rows.Next() { - var i Artist - if err := rows.Scan(&i.ID, &i.Name, &i.Mbid); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getAlbumArtistsByLibrary = `-- name: GetAlbumArtistsByLibrary :many -SELECT DISTINCT a.id, a.name, a.mbid -FROM artists a -JOIN artist_credit_artist aca ON aca.artist_id = a.id -JOIN artist_credit ac ON ac.id = aca.credit_id -JOIN release_groups rg ON rg.album_artist_credit_id = ac.id -WHERE a.id IN ( - SELECT DISTINCT aca2.artist_id - FROM artist_credit_artist aca2 - JOIN artist_credit ac2 ON ac2.id = aca2.credit_id - JOIN release_groups rg2 ON rg2.album_artist_credit_id = ac2.id - JOIN release_group_recordings rgr2 ON rgr2.release_group_id = rg2.id - JOIN recordings r2 ON r2.id = rgr2.recording_id - JOIN audio_files af2 ON af2.recording_id = r2.id - WHERE af2.library_id = ? +JOIN albums al ON al.artist_id = a.id +WHERE EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.album_id = al.id + AND af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id) ) ORDER BY a.name ` -func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) ([]Artist, error) { - rows, err := q.db.QueryContext(ctx, getAlbumArtistsByLibrary, libraryID) +func (q *Queries) GetAlbumArtists(ctx context.Context, libraryID int64) ([]Artist, error) { + rows, err := q.db.QueryContext(ctx, getAlbumArtists, libraryID) if err != nil { return nil, err } @@ -115,8 +64,7 @@ func (q *Queries) GetAlbumArtistsByLibrary(ctx context.Context, libraryID int64) } const getAllArtists = `-- name: GetAllArtists :many -SELECT id, name, mbid FROM artists -ORDER BY name +SELECT id, name, mbid FROM artists ORDER BY name ` func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) { @@ -143,8 +91,7 @@ func (q *Queries) GetAllArtists(ctx context.Context) ([]Artist, error) { } const getArtist = `-- name: GetArtist :one -SELECT id, name, mbid FROM artists -WHERE id = ? LIMIT 1 +SELECT id, name, mbid FROM artists WHERE id = ? LIMIT 1 ` func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) { @@ -154,9 +101,28 @@ func (q *Queries) GetArtist(ctx context.Context, id int64) (Artist, error) { return i, err } +const getArtistByFilePath = `-- name: GetArtistByFilePath :one +SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid +FROM audio_files af +LEFT JOIN artists a ON a.id = af.artist_id +WHERE af.file_path = ? +LIMIT 1 +` + +type GetArtistByFilePathRow struct { + ArtistName string + ArtistMbid string +} + +func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) { + row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath) + var i GetArtistByFilePathRow + err := row.Scan(&i.ArtistName, &i.ArtistMbid) + return i, err +} + const getArtistByName = `-- name: GetArtistByName :one -SELECT id, name, mbid FROM artists -WHERE name = ? LIMIT 1 +SELECT id, name, mbid FROM artists WHERE name = ? LIMIT 1 ` func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, error) { @@ -166,18 +132,15 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err return i, err } -const getOrphanedArtistIDs = `-- name: GetOrphanedArtistIDs :many -SELECT a.id FROM artists a -WHERE NOT EXISTS ( - SELECT 1 FROM artist_credit_artist aca WHERE aca.artist_id = a.id -) +const getUnreferencedArtistIDs = `-- name: GetUnreferencedArtistIDs :many +SELECT id FROM artists a +WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id) + AND NOT EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = a.id) ` -// Artists no longer credited on any recording or release group - left -// behind when a scan's orphan cleanup removes the audio_files that used -// to justify them, since deleting an audio_files row doesn't cascade. -func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) { - rows, err := q.db.QueryContext(ctx, getOrphanedArtistIDs) +// Artists no file and no album points at any more. +func (q *Queries) GetUnreferencedArtistIDs(ctx context.Context) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, getUnreferencedArtistIDs) if err != nil { return nil, err } @@ -199,30 +162,42 @@ func (q *Queries) GetOrphanedArtistIDs(ctx context.Context) ([]int64, error) { return items, nil } -const updateArtist = `-- name: UpdateArtist :exec -UPDATE artists -SET name = ? -WHERE id = ? +const setArtistMBID = `-- name: SetArtistMBID :exec +UPDATE artists SET mbid = ? WHERE id = ? ` -type UpdateArtistParams struct { - Name string +type SetArtistMBIDParams struct { + Mbid sql.NullString ID int64 } -func (q *Queries) UpdateArtist(ctx context.Context, arg UpdateArtistParams) error { - _, err := q.db.ExecContext(ctx, updateArtist, arg.Name, arg.ID) +func (q *Queries) SetArtistMBID(ctx context.Context, arg SetArtistMBIDParams) error { + _, err := q.db.ExecContext(ctx, setArtistMBID, arg.Mbid, arg.ID) return err } const upsertArtist = `-- name: UpsertArtist :one -INSERT INTO artists (name) VALUES (?) -ON CONFLICT(name) DO UPDATE SET name = excluded.name + +INSERT INTO artists (name, mbid) VALUES (?, ?) +ON CONFLICT(name) DO UPDATE SET + mbid = COALESCE(excluded.mbid, artists.mbid) RETURNING id, name, mbid ` -func (q *Queries) UpsertArtist(ctx context.Context, name string) (Artist, error) { - row := q.db.QueryRowContext(ctx, upsertArtist, name) +type UpsertArtistParams struct { + Name string + Mbid sql.NullString +} + +// Queries over artists. +// +// An artist row is reachable two ways: as a file's primary artist +// (audio_files.artist_id) and as an album's artist (albums.artist_id). +// Both used to route through artist_credit + artist_credit_artist, +// which is how "which album artists are in library 2" came to be a +// five-join subquery inside a three-join query. +func (q *Queries) UpsertArtist(ctx context.Context, arg UpsertArtistParams) (Artist, error) { + row := q.db.QueryRowContext(ctx, upsertArtist, arg.Name, arg.Mbid) var i Artist err := row.Scan(&i.ID, &i.Name, &i.Mbid) return i, err diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index 6ca2729..7aeb3ca 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -12,145 +12,130 @@ import ( ) const countAudioFiles = `-- name: CountAudioFiles :one -SELECT count(*) FROM audio_files +SELECT COUNT(*) AS count FROM audio_files +WHERE library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), library_id) ` -func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) { - row := q.db.QueryRowContext(ctx, countAudioFiles) - var count int64 - err := row.Scan(&count) - return count, err -} - -const countAudioFilesByLibrary = `-- name: CountAudioFilesByLibrary :one -SELECT COUNT(*) AS count FROM audio_files WHERE library_id = ? -` - -func (q *Queries) CountAudioFilesByLibrary(ctx context.Context, libraryID int64) (int64, error) { - row := q.db.QueryRowContext(ctx, countAudioFilesByLibrary, libraryID) +func (q *Queries) CountAudioFiles(ctx context.Context, libraryID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countAudioFiles, libraryID) var count int64 err := row.Scan(&count) return count, err } const createAudioFile = `-- 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 id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at + + +INSERT INTO audio_files ( + file_path, library_id, file_type_id, + length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, + title, artist_credit, artist_id, album_id, + track_number, disc_number, total_tracks, year, composer, comment, + recording_mbid, basename, group_key, modified_at, tag_status +) VALUES ( + ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ? +) +RETURNING id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status ` type CreateAudioFileParams struct { FilePath string - LengthMilliseconds int64 + LibraryID int64 FileTypeID int64 - RecordingID int64 + LengthMilliseconds int64 SampleRate int64 BitDepth int64 Channels int64 Bitrate int64 FileSize int64 + Title string + ArtistCredit string + ArtistID sql.NullInt64 + AlbumID sql.NullInt64 + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + TotalTracks sql.NullInt64 + Year sql.NullInt64 + Composer string + Comment string + RecordingMbid sql.NullString Basename string - LibraryID int64 + GroupKey string + ModifiedAt int64 + TagStatus string } +// 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 +// --------------------------------------------------------------------- func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) { row := q.db.QueryRowContext(ctx, createAudioFile, arg.FilePath, - arg.LengthMilliseconds, + arg.LibraryID, arg.FileTypeID, - arg.RecordingID, + arg.LengthMilliseconds, arg.SampleRate, arg.BitDepth, arg.Channels, arg.Bitrate, arg.FileSize, + arg.Title, + arg.ArtistCredit, + arg.ArtistID, + arg.AlbumID, + arg.TrackNumber, + arg.DiscNumber, + arg.TotalTracks, + arg.Year, + arg.Composer, + arg.Comment, + arg.RecordingMbid, arg.Basename, - arg.LibraryID, - ) - var i AudioFile - err := row.Scan( - &i.ID, - &i.FilePath, - &i.LengthMilliseconds, - &i.FileTypeID, - &i.RecordingID, - &i.SampleRate, - &i.BitDepth, - &i.Channels, - &i.Bitrate, - &i.FileSize, - &i.Basename, - &i.LibraryID, - &i.PlayCount, - &i.LastPlayed, - &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, - ) - return i, err -} - -const createAudioFileWithGroupKey = `-- name: CreateAudioFileWithGroupKey :one -INSERT INTO audio_files ( - file_path, length_milliseconds, file_type_id, recording_id, - sample_rate, bit_depth, channels, bitrate, file_size, basename, - library_id, group_key, tag_status, modified_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at -` - -type CreateAudioFileWithGroupKeyParams struct { - FilePath string - LengthMilliseconds int64 - FileTypeID int64 - RecordingID int64 - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 - Basename string - LibraryID int64 - GroupKey string - TagStatus string - ModifiedAt int64 -} - -func (q *Queries) CreateAudioFileWithGroupKey(ctx context.Context, arg CreateAudioFileWithGroupKeyParams) (AudioFile, error) { - row := q.db.QueryRowContext(ctx, createAudioFileWithGroupKey, - arg.FilePath, - arg.LengthMilliseconds, - arg.FileTypeID, - arg.RecordingID, - arg.SampleRate, - arg.BitDepth, - arg.Channels, - arg.Bitrate, - arg.FileSize, - arg.Basename, - arg.LibraryID, arg.GroupKey, - arg.TagStatus, arg.ModifiedAt, + arg.TagStatus, ) var i AudioFile err := row.Scan( &i.ID, &i.FilePath, - &i.LengthMilliseconds, + &i.LibraryID, &i.FileTypeID, - &i.RecordingID, + &i.LengthMilliseconds, &i.SampleRate, &i.BitDepth, &i.Channels, &i.Bitrate, &i.FileSize, + &i.Title, + &i.ArtistCredit, + &i.ArtistID, + &i.AlbumID, + &i.TrackNumber, + &i.DiscNumber, + &i.TotalTracks, + &i.Year, + &i.Composer, + &i.Comment, + &i.RecordingMbid, &i.Basename, - &i.LibraryID, + &i.GroupKey, + &i.ModifiedAt, &i.PlayCount, &i.LastPlayed, &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, ) return i, err } @@ -165,8 +150,7 @@ func (q *Queries) DeleteAllAudioFiles(ctx context.Context) error { } const deleteAudioFile = `-- name: DeleteAudioFile :exec -DELETE FROM audio_files -WHERE id = ? +DELETE FROM audio_files WHERE id = ? ` func (q *Queries) DeleteAudioFile(ctx context.Context, id int64) error { @@ -206,364 +190,51 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa return items, nil } -const getAllAudioFiles = `-- name: GetAllAudioFiles :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files -` - -func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) { - rows, err := q.db.QueryContext(ctx, getAllAudioFiles) - if err != nil { - return nil, err - } - defer rows.Close() - var items []AudioFile - for rows.Next() { - var i AudioFile - if err := rows.Scan( - &i.ID, - &i.FilePath, - &i.LengthMilliseconds, - &i.FileTypeID, - &i.RecordingID, - &i.SampleRate, - &i.BitDepth, - &i.Channels, - &i.Bitrate, - &i.FileSize, - &i.Basename, - &i.LibraryID, - &i.PlayCount, - &i.LastPlayed, - &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, - ); 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 getAllAudioFilesWithArtist = `-- 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 -` - -type GetAllAudioFilesWithArtistRow struct { - ID int64 - FilePath string - LengthMilliseconds int64 - FileTypeID int64 - RecordingID int64 - ArtistName string - Title string -} - -func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudioFilesWithArtistRow, error) { - rows, err := q.db.QueryContext(ctx, getAllAudioFilesWithArtist) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllAudioFilesWithArtistRow - for rows.Next() { - var i GetAllAudioFilesWithArtistRow - if err := rows.Scan( - &i.ID, - &i.FilePath, - &i.LengthMilliseconds, - &i.FileTypeID, - &i.RecordingID, - &i.ArtistName, - &i.Title, - ); 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 getAllTracksWithFullMetadata = `-- 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 -` - -type GetAllTracksWithFullMetadataRow 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 - PlayCount int64 - LastPlayed sql.NullTime - CoverArtPath string - ArtistMbid string - ReleaseGroupMbid string - RecordingMbid string -} - -func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) { - rows, err := q.db.QueryContext(ctx, getAllTracksWithFullMetadata) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllTracksWithFullMetadataRow - for rows.Next() { - var i GetAllTracksWithFullMetadataRow - 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, - &i.PlayCount, - &i.LastPlayed, - &i.CoverArtPath, - &i.ArtistMbid, - &i.ReleaseGroupMbid, - &i.RecordingMbid, - ); 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 getAllTracksWithFullMetadataByLibrary = `-- name: GetAllTracksWithFullMetadataByLibrary :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size, - af.play_count, - af.last_played, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid -FROM audio_files af -JOIN recordings r ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE af.library_id = ? -` - -type GetAllTracksWithFullMetadataByLibraryRow 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 - PlayCount int64 - LastPlayed sql.NullTime - CoverArtPath string - ArtistMbid string - ReleaseGroupMbid string - RecordingMbid string -} - -func (q *Queries) GetAllTracksWithFullMetadataByLibrary(ctx context.Context, libraryID int64) ([]GetAllTracksWithFullMetadataByLibraryRow, error) { - rows, err := q.db.QueryContext(ctx, getAllTracksWithFullMetadataByLibrary, libraryID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAllTracksWithFullMetadataByLibraryRow - for rows.Next() { - var i GetAllTracksWithFullMetadataByLibraryRow - 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, - &i.PlayCount, - &i.LastPlayed, - &i.CoverArtPath, - &i.ArtistMbid, - &i.ReleaseGroupMbid, - &i.RecordingMbid, - ); 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 getAudioFile = `-- name: GetAudioFile :one -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files -WHERE id = ? LIMIT 1 + +SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE id = ? LIMIT 1 ` +// --------------------------------------------------------------------- +// Reads: the file row itself +// --------------------------------------------------------------------- func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error) { row := q.db.QueryRowContext(ctx, getAudioFile, id) var i AudioFile err := row.Scan( &i.ID, &i.FilePath, - &i.LengthMilliseconds, + &i.LibraryID, &i.FileTypeID, - &i.RecordingID, + &i.LengthMilliseconds, &i.SampleRate, &i.BitDepth, &i.Channels, &i.Bitrate, &i.FileSize, + &i.Title, + &i.ArtistCredit, + &i.ArtistID, + &i.AlbumID, + &i.TrackNumber, + &i.DiscNumber, + &i.TotalTracks, + &i.Year, + &i.Composer, + &i.Comment, + &i.RecordingMbid, &i.Basename, - &i.LibraryID, + &i.GroupKey, + &i.ModifiedAt, &i.PlayCount, &i.LastPlayed, &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, ) return i, err } const getAudioFileByPath = `-- name: GetAudioFileByPath :one -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files -WHERE file_path = ? LIMIT 1 +SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE file_path = ? LIMIT 1 ` func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error) { @@ -572,28 +243,37 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi err := row.Scan( &i.ID, &i.FilePath, - &i.LengthMilliseconds, + &i.LibraryID, &i.FileTypeID, - &i.RecordingID, + &i.LengthMilliseconds, &i.SampleRate, &i.BitDepth, &i.Channels, &i.Bitrate, &i.FileSize, + &i.Title, + &i.ArtistCredit, + &i.ArtistID, + &i.AlbumID, + &i.TrackNumber, + &i.DiscNumber, + &i.TotalTracks, + &i.Year, + &i.Composer, + &i.Comment, + &i.RecordingMbid, &i.Basename, - &i.LibraryID, + &i.GroupKey, + &i.ModifiedAt, &i.PlayCount, &i.LastPlayed, &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, ) return i, err } const getAudioFileGroupKey = `-- name: GetAudioFileGroupKey :one -SELECT group_key FROM audio_files -WHERE id = ? LIMIT 1 +SELECT group_key FROM audio_files WHERE id = ? LIMIT 1 ` func (q *Queries) GetAudioFileGroupKey(ctx context.Context, id int64) (string, error) { @@ -603,51 +283,6 @@ func (q *Queries) GetAudioFileGroupKey(ctx context.Context, id int64) (string, e return group_key, err } -const getAudioFilesByLibrary = `-- name: GetAudioFilesByLibrary :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files WHERE library_id = ? -` - -func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) { - rows, err := q.db.QueryContext(ctx, getAudioFilesByLibrary, libraryID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []AudioFile - for rows.Next() { - var i AudioFile - if err := rows.Scan( - &i.ID, - &i.FilePath, - &i.LengthMilliseconds, - &i.FileTypeID, - &i.RecordingID, - &i.SampleRate, - &i.BitDepth, - &i.Channels, - &i.Bitrate, - &i.FileSize, - &i.Basename, - &i.LibraryID, - &i.PlayCount, - &i.LastPlayed, - &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, - ); 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 getAudioFilesByPaths = `-- name: GetAudioFilesByPaths :many SELECT id, library_id, file_path, group_key FROM audio_files WHERE file_path IN (/*SLICE:paths*/?) @@ -698,226 +333,12 @@ func (q *Queries) GetAudioFilesByPaths(ctx context.Context, paths []string) ([]G return items, nil } -const getAudioFilesByReleaseGroup = `-- 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 +const getAudioFilesInLibrary = `-- name: GetAudioFilesInLibrary :many +SELECT id, file_path, library_id, file_type_id, length_milliseconds, sample_rate, bit_depth, channels, bitrate, file_size, title, artist_credit, artist_id, album_id, track_number, disc_number, total_tracks, year, composer, comment, recording_mbid, basename, group_key, modified_at, play_count, last_played, tag_status FROM audio_files WHERE library_id = ? ` -type GetAudioFilesByReleaseGroupRow 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 - ArtistMbid string - ReleaseGroupMbid string - RecordingMbid string -} - -func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupID int64) ([]GetAudioFilesByReleaseGroupRow, error) { - rows, err := q.db.QueryContext(ctx, getAudioFilesByReleaseGroup, releaseGroupID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAudioFilesByReleaseGroupRow - for rows.Next() { - var i GetAudioFilesByReleaseGroupRow - 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, - &i.ArtistMbid, - &i.ReleaseGroupMbid, - &i.RecordingMbid, - ); 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 getAudioFilesByReleaseGroupByLibrary = `-- 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 -` - -type GetAudioFilesByReleaseGroupByLibraryParams struct { - ReleaseGroupID int64 - LibraryID int64 -} - -type GetAudioFilesByReleaseGroupByLibraryRow 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 - ArtistMbid string - ReleaseGroupMbid string - RecordingMbid string -} - -func (q *Queries) GetAudioFilesByReleaseGroupByLibrary(ctx context.Context, arg GetAudioFilesByReleaseGroupByLibraryParams) ([]GetAudioFilesByReleaseGroupByLibraryRow, error) { - rows, err := q.db.QueryContext(ctx, getAudioFilesByReleaseGroupByLibrary, arg.ReleaseGroupID, arg.LibraryID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetAudioFilesByReleaseGroupByLibraryRow - for rows.Next() { - var i GetAudioFilesByReleaseGroupByLibraryRow - 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, - &i.ArtistMbid, - &i.ReleaseGroupMbid, - &i.RecordingMbid, - ); 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 getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename, library_id, play_count, last_played, tag_status, group_key, modified_at FROM audio_files -WHERE recording_id = 0 -` - -func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile, error) { - rows, err := q.db.QueryContext(ctx, getAudioFilesNeedingMetadata) +func (q *Queries) GetAudioFilesInLibrary(ctx context.Context, libraryID int64) ([]AudioFile, error) { + rows, err := q.db.QueryContext(ctx, getAudioFilesInLibrary, libraryID) if err != nil { return nil, err } @@ -928,21 +349,31 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile if err := rows.Scan( &i.ID, &i.FilePath, - &i.LengthMilliseconds, + &i.LibraryID, &i.FileTypeID, - &i.RecordingID, + &i.LengthMilliseconds, &i.SampleRate, &i.BitDepth, &i.Channels, &i.Bitrate, &i.FileSize, + &i.Title, + &i.ArtistCredit, + &i.ArtistID, + &i.AlbumID, + &i.TrackNumber, + &i.DiscNumber, + &i.TotalTracks, + &i.Year, + &i.Composer, + &i.Comment, + &i.RecordingMbid, &i.Basename, - &i.LibraryID, + &i.GroupKey, + &i.ModifiedAt, &i.PlayCount, &i.LastPlayed, &i.TagStatus, - &i.GroupKey, - &i.ModifiedAt, ); err != nil { return nil, err } @@ -957,29 +388,156 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile return items, nil } +const getFilePathsByAlbums = `-- name: GetFilePathsByAlbums :many + +SELECT album_id, library_id, file_path FROM audio_files +WHERE album_id IN (/*SLICE:album_ids*/?) +ORDER BY disc_number, track_number +` + +type GetFilePathsByAlbumsRow struct { + AlbumID sql.NullInt64 + LibraryID int64 + FilePath string +} + +// --------------------------------------------------------------------- +// 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. +// The library filter is applied in Go rather than here: sqlc numbers a +// named parameter (?2) but expands a slice into N placeholders, so the +// two together bind the wrong values - GetFilePathsByAlbums([1,2], 0) +// read album id 2 as the library id. Returning library_id and +// filtering the (small) result is the version that cannot be wrong. +func (q *Queries) GetFilePathsByAlbums(ctx context.Context, albumIds []sql.NullInt64) ([]GetFilePathsByAlbumsRow, error) { + query := getFilePathsByAlbums + var queryParams []interface{} + if len(albumIds) > 0 { + for _, v := range albumIds { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:album_ids*/?", strings.Repeat(",?", len(albumIds))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:album_ids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetFilePathsByAlbumsRow + for rows.Next() { + var i GetFilePathsByAlbumsRow + if err := rows.Scan(&i.AlbumID, &i.LibraryID, &i.FilePath); 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 getFilePathsByArtistMBID = `-- name: GetFilePathsByArtistMBID :many +SELECT DISTINCT af.file_path +FROM audio_files af +JOIN artists a ON a.id = af.artist_id +WHERE a.mbid = ? +` + +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 getFilePathsByGenres = `-- name: GetFilePathsByGenres :many +SELECT g.name AS genre, af.library_id, af.file_path +FROM audio_files af +JOIN file_genres fg ON fg.audio_file_id = af.id +JOIN genres g ON g.id = fg.genre_id +WHERE g.name IN (/*SLICE:genres*/?) +ORDER BY af.disc_number, af.track_number +` + +type GetFilePathsByGenresRow struct { + Genre string + LibraryID int64 + FilePath string +} + +func (q *Queries) GetFilePathsByGenres(ctx context.Context, genres []string) ([]GetFilePathsByGenresRow, error) { + query := getFilePathsByGenres + var queryParams []interface{} + if len(genres) > 0 { + for _, v := range genres { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:genres*/?", strings.Repeat(",?", len(genres))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:genres*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetFilePathsByGenresRow + for rows.Next() { + var i GetFilePathsByGenresRow + if err := rows.Scan(&i.Genre, &i.LibraryID, &i.FilePath); 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 getFilePathsByRecordingMBIDs = `-- name: GetFilePathsByRecordingMBIDs :many - -SELECT r.mbid AS recording_mbid, af.file_path -FROM recordings r -JOIN audio_files af ON af.recording_id = r.id -WHERE r.mbid IN (/*SLICE:mbids*/?) -ORDER BY af.file_path +SELECT recording_mbid, library_id, file_path FROM audio_files +WHERE recording_mbid IN (/*SLICE:mbids*/?) +ORDER BY file_path ` type GetFilePathsByRecordingMBIDsRow struct { RecordingMbid sql.NullString + LibraryID int64 FilePath string } -// 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. +// The ownership question in its only honest form: which of these +// catalog recordings has a *file* behind it. Asked of audio_files, so +// a metadata row with no file cannot answer yes. func (q *Queries) GetFilePathsByRecordingMBIDs(ctx context.Context, mbids []sql.NullString) ([]GetFilePathsByRecordingMBIDsRow, error) { query := getFilePathsByRecordingMBIDs var queryParams []interface{} @@ -999,167 +557,7 @@ func (q *Queries) GetFilePathsByRecordingMBIDs(ctx context.Context, mbids []sql. var items []GetFilePathsByRecordingMBIDsRow for rows.Next() { var i GetFilePathsByRecordingMBIDsRow - if err := rows.Scan(&i.RecordingMbid, &i.FilePath); 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 getFilePathsByRecordingMBIDsByLibrary = `-- name: GetFilePathsByRecordingMBIDsByLibrary :many -SELECT r.mbid AS recording_mbid, af.file_path -FROM recordings r -JOIN audio_files af ON af.recording_id = r.id -WHERE r.mbid IN (/*SLICE:mbids*/?) - AND af.library_id = ? -ORDER BY af.file_path -` - -type GetFilePathsByRecordingMBIDsByLibraryParams struct { - Mbids []sql.NullString - LibraryID int64 -} - -type GetFilePathsByRecordingMBIDsByLibraryRow struct { - RecordingMbid sql.NullString - FilePath string -} - -func (q *Queries) GetFilePathsByRecordingMBIDsByLibrary(ctx context.Context, arg GetFilePathsByRecordingMBIDsByLibraryParams) ([]GetFilePathsByRecordingMBIDsByLibraryRow, error) { - query := getFilePathsByRecordingMBIDsByLibrary - var queryParams []interface{} - if len(arg.Mbids) > 0 { - for _, v := range arg.Mbids { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:mbids*/?", strings.Repeat(",?", len(arg.Mbids))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:mbids*/?", "NULL", 1) - } - queryParams = append(queryParams, arg.LibraryID) - rows, err := q.db.QueryContext(ctx, query, queryParams...) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetFilePathsByRecordingMBIDsByLibraryRow - for rows.Next() { - var i GetFilePathsByRecordingMBIDsByLibraryRow - if err := rows.Scan(&i.RecordingMbid, &i.FilePath); 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 getFilePathsByReleaseGroups = `-- 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 (/*SLICE:release_group_ids*/?) -ORDER BY rgr.disc_number, rgr.track_number -` - -type GetFilePathsByReleaseGroupsRow struct { - ReleaseGroupID int64 - FilePath string -} - -// "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. -func (q *Queries) GetFilePathsByReleaseGroups(ctx context.Context, releaseGroupIds []int64) ([]GetFilePathsByReleaseGroupsRow, error) { - query := getFilePathsByReleaseGroups - var queryParams []interface{} - if len(releaseGroupIds) > 0 { - for _, v := range releaseGroupIds { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:release_group_ids*/?", strings.Repeat(",?", len(releaseGroupIds))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:release_group_ids*/?", "NULL", 1) - } - rows, err := q.db.QueryContext(ctx, query, queryParams...) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetFilePathsByReleaseGroupsRow - for rows.Next() { - var i GetFilePathsByReleaseGroupsRow - if err := rows.Scan(&i.ReleaseGroupID, &i.FilePath); 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 getFilePathsByReleaseGroupsByLibrary = `-- 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 (/*SLICE:release_group_ids*/?) - AND af.library_id = ? -ORDER BY rgr.disc_number, rgr.track_number -` - -type GetFilePathsByReleaseGroupsByLibraryParams struct { - ReleaseGroupIds []int64 - LibraryID int64 -} - -type GetFilePathsByReleaseGroupsByLibraryRow struct { - ReleaseGroupID int64 - FilePath string -} - -func (q *Queries) GetFilePathsByReleaseGroupsByLibrary(ctx context.Context, arg GetFilePathsByReleaseGroupsByLibraryParams) ([]GetFilePathsByReleaseGroupsByLibraryRow, error) { - query := getFilePathsByReleaseGroupsByLibrary - var queryParams []interface{} - if len(arg.ReleaseGroupIds) > 0 { - for _, v := range arg.ReleaseGroupIds { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:release_group_ids*/?", strings.Repeat(",?", len(arg.ReleaseGroupIds))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:release_group_ids*/?", "NULL", 1) - } - queryParams = append(queryParams, arg.LibraryID) - rows, err := q.db.QueryContext(ctx, query, queryParams...) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetFilePathsByReleaseGroupsByLibraryRow - for rows.Next() { - var i GetFilePathsByReleaseGroupsByLibraryRow - if err := rows.Scan(&i.ReleaseGroupID, &i.FilePath); err != nil { + if err := rows.Scan(&i.RecordingMbid, &i.LibraryID, &i.FilePath); err != nil { return nil, err } items = append(items, i) @@ -1188,9 +586,7 @@ func (q *Queries) GetLibraryMaxModifiedAt(ctx context.Context, libraryID int64) } const getRandomAudioFilePath = `-- name: GetRandomAudioFilePath :one -SELECT file_path FROM audio_files -ORDER BY RANDOM() -LIMIT 1 +SELECT file_path FROM audio_files ORDER BY RANDOM() LIMIT 1 ` func (q *Queries) GetRandomAudioFilePath(ctx context.Context) (string, error) { @@ -1200,60 +596,235 @@ func (q *Queries) GetRandomAudioFilePath(ctx context.Context) (string, error) { return file_path, err } -const getTrackMetadataByPath = `-- 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 +const getTrackByPath = `-- name: GetTrackByPath :one +SELECT id, file_path, length_milliseconds, title, artist_name, track_number, disc_number, album, genre, year, release_year, composer, file_type, sample_rate, bit_depth, channels, bitrate, file_size, library_id, play_count, last_played, cover_art_path, artist_mbid, release_group_mbid, recording_mbid, album_id, artist_id FROM track_metadata WHERE file_path = ? LIMIT 1 ` -type GetTrackMetadataByPathRow struct { - FilePath string - LengthMilliseconds int64 - Title string - Artist string - Album string - CoverArtPath string - ArtistMbid string - ReleaseGroupMbid string - RecordingMbid string -} - -func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (GetTrackMetadataByPathRow, error) { - row := q.db.QueryRowContext(ctx, getTrackMetadataByPath, filePath) - var i GetTrackMetadataByPathRow +func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (TrackMetadatum, error) { + row := q.db.QueryRowContext(ctx, getTrackByPath, filePath) + var i TrackMetadatum err := row.Scan( + &i.ID, &i.FilePath, &i.LengthMilliseconds, &i.Title, - &i.Artist, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, &i.Album, + &i.Genre, + &i.Year, + &i.ReleaseYear, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.LibraryID, + &i.PlayCount, + &i.LastPlayed, &i.CoverArtPath, &i.ArtistMbid, &i.ReleaseGroupMbid, &i.RecordingMbid, + &i.AlbumID, + &i.ArtistID, ) return i, err } +const getTracks = `-- name: GetTracks :many + +SELECT id, file_path, length_milliseconds, title, artist_name, track_number, disc_number, album, genre, year, release_year, composer, file_type, sample_rate, bit_depth, channels, bitrate, file_size, library_id, play_count, last_played, cover_art_path, artist_mbid, release_group_mbid, recording_mbid, album_id, artist_id FROM track_metadata +WHERE library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), library_id) +` + +// --------------------------------------------------------------------- +// Reads: tracks +// --------------------------------------------------------------------- +func (q *Queries) GetTracks(ctx context.Context, libraryID int64) ([]TrackMetadatum, error) { + rows, err := q.db.QueryContext(ctx, getTracks, libraryID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TrackMetadatum + for rows.Next() { + var i TrackMetadatum + if err := rows.Scan( + &i.ID, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.ReleaseYear, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.LibraryID, + &i.PlayCount, + &i.LastPlayed, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, + &i.AlbumID, + &i.ArtistID, + ); 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 getTracksByAlbum = `-- name: GetTracksByAlbum :many +SELECT id, file_path, length_milliseconds, title, artist_name, track_number, disc_number, album, genre, year, release_year, composer, file_type, sample_rate, bit_depth, channels, bitrate, file_size, library_id, play_count, last_played, cover_art_path, artist_mbid, release_group_mbid, recording_mbid, album_id, artist_id FROM track_metadata +WHERE album_id = ?1 + AND library_id = COALESCE(NULLIF(CAST(?2 AS INTEGER), 0), library_id) +ORDER BY disc_number, track_number +` + +type GetTracksByAlbumParams struct { + AlbumID sql.NullInt64 + LibraryID int64 +} + +func (q *Queries) GetTracksByAlbum(ctx context.Context, arg GetTracksByAlbumParams) ([]TrackMetadatum, error) { + rows, err := q.db.QueryContext(ctx, getTracksByAlbum, arg.AlbumID, arg.LibraryID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TrackMetadatum + for rows.Next() { + var i TrackMetadatum + if err := rows.Scan( + &i.ID, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.ReleaseYear, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.LibraryID, + &i.PlayCount, + &i.LastPlayed, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, + &i.AlbumID, + &i.ArtistID, + ); 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 +SELECT tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name, tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year, tm.release_year, tm.composer, tm.file_type, tm.sample_rate, tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id, tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id FROM track_metadata tm +JOIN file_genres fg ON fg.audio_file_id = tm.id +JOIN genres g ON g.id = fg.genre_id +WHERE g.name = ?1 + AND tm.library_id = COALESCE(NULLIF(CAST(?2 AS INTEGER), 0), tm.library_id) +` + +type GetTracksByGenreParams struct { + Genre string + LibraryID int64 +} + +func (q *Queries) GetTracksByGenre(ctx context.Context, arg GetTracksByGenreParams) ([]TrackMetadatum, error) { + rows, err := q.db.QueryContext(ctx, getTracksByGenre, arg.Genre, arg.LibraryID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TrackMetadatum + for rows.Next() { + var i TrackMetadatum + if err := rows.Scan( + &i.ID, + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.ReleaseYear, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + &i.LibraryID, + &i.PlayCount, + &i.LastPlayed, + &i.CoverArtPath, + &i.ArtistMbid, + &i.ReleaseGroupMbid, + &i.RecordingMbid, + &i.AlbumID, + &i.ArtistID, + ); 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 lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many -SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid +SELECT id, file_path, title, artist_name, album, cover_art_path, + artist_mbid, release_group_mbid, recording_mbid FROM track_metadata WHERE file_path IN (/*SLICE:paths*/?) ` @@ -1313,53 +884,186 @@ func (q *Queries) LookupTrackMetaByPaths(ctx context.Context, paths []string) ([ return items, nil } -const searchAudioFilesByBasename = `-- 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 ? +const ownedAlbumMBIDs = `-- name: OwnedAlbumMBIDs :many +SELECT DISTINCT al.mbid FROM albums al +JOIN audio_files af ON af.album_id = al.id +WHERE al.mbid IN (/*SLICE:mbids*/?) ` -type SearchAudioFilesByBasenameParams struct { - Basename string - Limit int64 -} - -type SearchAudioFilesByBasenameRow struct { - FilePath string - LengthMilliseconds int64 - Title string - Artist string - Album string -} - -func (q *Queries) SearchAudioFilesByBasename(ctx context.Context, arg SearchAudioFilesByBasenameParams) ([]SearchAudioFilesByBasenameRow, error) { - rows, err := q.db.QueryContext(ctx, searchAudioFilesByBasename, arg.Basename, arg.Limit) +func (q *Queries) OwnedAlbumMBIDs(ctx context.Context, mbids []sql.NullString) ([]sql.NullString, error) { + query := ownedAlbumMBIDs + var queryParams []interface{} + if len(mbids) > 0 { + for _, v := range mbids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:mbids*/?", strings.Repeat(",?", len(mbids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:mbids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } defer rows.Close() - var items []SearchAudioFilesByBasenameRow + var items []sql.NullString for rows.Next() { - var i SearchAudioFilesByBasenameRow + var mbid sql.NullString + if err := rows.Scan(&mbid); err != nil { + return nil, err + } + items = append(items, mbid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ownedArtistMBIDs = `-- name: OwnedArtistMBIDs :many +SELECT DISTINCT a.mbid FROM artists a +JOIN audio_files af ON af.artist_id = a.id +WHERE a.mbid IN (/*SLICE:mbids*/?) +` + +func (q *Queries) OwnedArtistMBIDs(ctx context.Context, mbids []sql.NullString) ([]sql.NullString, error) { + query := ownedArtistMBIDs + var queryParams []interface{} + if len(mbids) > 0 { + for _, v := range mbids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:mbids*/?", strings.Repeat(",?", len(mbids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:mbids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullString + for rows.Next() { + var mbid sql.NullString + if err := rows.Scan(&mbid); err != nil { + return nil, err + } + items = append(items, mbid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ownedRecordingMBIDs = `-- name: OwnedRecordingMBIDs :many + +SELECT DISTINCT recording_mbid FROM audio_files +WHERE recording_mbid IN (/*SLICE:mbids*/?) +` + +// --------------------------------------------------------------------- +// Ownership, asked in bulk +// --------------------------------------------------------------------- +// 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. +func (q *Queries) OwnedRecordingMBIDs(ctx context.Context, mbids []sql.NullString) ([]sql.NullString, error) { + query := ownedRecordingMBIDs + var queryParams []interface{} + if len(mbids) > 0 { + for _, v := range mbids { + queryParams = append(queryParams, v) + } + query = strings.Replace(query, "/*SLICE:mbids*/?", strings.Repeat(",?", len(mbids))[1:], 1) + } else { + query = strings.Replace(query, "/*SLICE:mbids*/?", "NULL", 1) + } + rows, err := q.db.QueryContext(ctx, query, queryParams...) + if err != nil { + return nil, err + } + defer rows.Close() + var items []sql.NullString + for rows.Next() { + var recording_mbid sql.NullString + if err := rows.Scan(&recording_mbid); err != nil { + return nil, err + } + items = append(items, recording_mbid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const promoteAudioFileTagStatusIfUntagged = `-- name: PromoteAudioFileTagStatusIfUntagged :exec +UPDATE audio_files +SET tag_status = 'user_confirmed' +WHERE id = ? AND tag_status = 'untagged' +` + +// A rescan re-reads the tags of a file whose mtime moved, so a file +// another tagger stamped with MBIDs since import arrives here still +// carrying the 'untagged' status it was created with (only the insert +// path sets it). Promote it the same way saveAudioFile does. +// Guarded on 'untagged' so it cannot overwrite a deliberate +// 'user_skipped_permanent', and so a file losing its MBIDs is left +// alone -- demotion is the scan's judgement, not this statement's. +func (q *Queries) PromoteAudioFileTagStatusIfUntagged(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, promoteAudioFileTagStatusIfUntagged, id) + return err +} + +const searchTracksByBasename = `-- name: SearchTracksByBasename :many +SELECT id, file_path, length_milliseconds, title, artist_name, album +FROM track_metadata +WHERE file_path IN ( + SELECT file_path FROM audio_files WHERE basename = ?1 +) +LIMIT ?2 +` + +type SearchTracksByBasenameParams struct { + Basename string + Lim int64 +} + +type SearchTracksByBasenameRow struct { + ID int64 + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + Album string +} + +func (q *Queries) SearchTracksByBasename(ctx context.Context, arg SearchTracksByBasenameParams) ([]SearchTracksByBasenameRow, error) { + rows, err := q.db.QueryContext(ctx, searchTracksByBasename, arg.Basename, arg.Lim) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchTracksByBasenameRow + for rows.Next() { + var i SearchTracksByBasenameRow if err := rows.Scan( + &i.ID, &i.FilePath, &i.LengthMilliseconds, &i.Title, - &i.Artist, + &i.ArtistName, &i.Album, ); err != nil { return nil, err @@ -1389,73 +1093,17 @@ func (q *Queries) SetAudioFileGroupKey(ctx context.Context, arg SetAudioFileGrou return err } -const updateAudioFile = `-- name: UpdateAudioFile :exec -UPDATE audio_files -SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ? -WHERE id = ? +const setAudioFileRecordingMBID = `-- name: SetAudioFileRecordingMBID :exec +UPDATE audio_files SET recording_mbid = ? WHERE id = ? ` -type UpdateAudioFileParams struct { - FilePath string - LengthMilliseconds int64 - FileTypeID int64 - RecordingID int64 - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 - Basename string - ID int64 +type SetAudioFileRecordingMBIDParams struct { + RecordingMbid sql.NullString + ID int64 } -func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams) error { - _, err := q.db.ExecContext(ctx, updateAudioFile, - arg.FilePath, - arg.LengthMilliseconds, - arg.FileTypeID, - arg.RecordingID, - arg.SampleRate, - arg.BitDepth, - arg.Channels, - arg.Bitrate, - arg.FileSize, - arg.Basename, - arg.ID, - ) - return err -} - -const updateAudioFileRecording = `-- name: UpdateAudioFileRecording :exec -UPDATE audio_files -SET recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, length_milliseconds = ?, modified_at = ? -WHERE id = ? -` - -type UpdateAudioFileRecordingParams struct { - RecordingID int64 - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 - LengthMilliseconds int64 - ModifiedAt int64 - ID int64 -} - -func (q *Queries) UpdateAudioFileRecording(ctx context.Context, arg UpdateAudioFileRecordingParams) error { - _, err := q.db.ExecContext(ctx, updateAudioFileRecording, - arg.RecordingID, - arg.SampleRate, - arg.BitDepth, - arg.Channels, - arg.Bitrate, - arg.FileSize, - arg.LengthMilliseconds, - arg.ModifiedAt, - arg.ID, - ) +func (q *Queries) SetAudioFileRecordingMBID(ctx context.Context, arg SetAudioFileRecordingMBIDParams) error { + _, err := q.db.ExecContext(ctx, setAudioFileRecordingMBID, arg.RecordingMbid, arg.ID) return err } @@ -1478,3 +1126,65 @@ func (q *Queries) UpdateAudioFileStat(ctx context.Context, arg UpdateAudioFileSt _, err := q.db.ExecContext(ctx, updateAudioFileStat, arg.ModifiedAt, arg.FileSize, arg.ID) return err } + +const updateAudioFileTags = `-- name: UpdateAudioFileTags :exec +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 = ? +` + +type UpdateAudioFileTagsParams struct { + 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 + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 + LengthMilliseconds int64 + ModifiedAt int64 + ID int64 +} + +// A rescan of a file whose mtime moved: the tags are re-read and +// written over the same row. Under the old schema this created a +// *new* recording and repointed the file at it, abandoning the old one +// -- which is where 812 orphaned rows and every phantom "you own this" +// came from. There is nothing to orphan now. +func (q *Queries) UpdateAudioFileTags(ctx context.Context, arg UpdateAudioFileTagsParams) error { + _, err := q.db.ExecContext(ctx, updateAudioFileTags, + arg.Title, + arg.ArtistCredit, + arg.ArtistID, + arg.AlbumID, + arg.TrackNumber, + arg.DiscNumber, + arg.TotalTracks, + arg.Year, + arg.Composer, + arg.Comment, + arg.RecordingMbid, + arg.SampleRate, + arg.BitDepth, + arg.Channels, + arg.Bitrate, + arg.FileSize, + arg.LengthMilliseconds, + arg.ModifiedAt, + arg.ID, + ) + return err +} diff --git a/backend/database/sql/sqlcgen/genres.sql.go b/backend/database/sql/sqlcgen/genres.sql.go index 6794ddf..b268a12 100644 --- a/backend/database/sql/sqlcgen/genres.sql.go +++ b/backend/database/sql/sqlcgen/genres.sql.go @@ -7,36 +7,9 @@ package sqlcgen import ( "context" - "database/sql" "strings" ) -const countGenreReferences = `-- name: CountGenreReferences :one -SELECT COUNT(*) FROM recording_genres WHERE genre_id = ? -` - -func (q *Queries) CountGenreReferences(ctx context.Context, genreID int64) (int64, error) { - row := q.db.QueryRowContext(ctx, countGenreReferences, genreID) - var count int64 - err := row.Scan(&count) - return count, err -} - -const createRecordingGenre = `-- name: CreateRecordingGenre :exec -INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) -VALUES (?, ?) -` - -type CreateRecordingGenreParams struct { - RecordingID int64 - GenreID int64 -} - -func (q *Queries) CreateRecordingGenre(ctx context.Context, arg CreateRecordingGenreParams) error { - _, err := q.db.ExecContext(ctx, createRecordingGenre, arg.RecordingID, arg.GenreID) - return err -} - const deleteAllGenres = `-- name: DeleteAllGenres :exec DELETE FROM genres ` @@ -46,12 +19,12 @@ func (q *Queries) DeleteAllGenres(ctx context.Context) error { return err } -const deleteAllRecordingGenres = `-- name: DeleteAllRecordingGenres :exec -DELETE FROM recording_genres +const deleteFileGenres = `-- name: DeleteFileGenres :exec +DELETE FROM file_genres WHERE audio_file_id = ? ` -func (q *Queries) DeleteAllRecordingGenres(ctx context.Context) error { - _, err := q.db.ExecContext(ctx, deleteAllRecordingGenres) +func (q *Queries) DeleteFileGenres(ctx context.Context, audioFileID int64) error { + _, err := q.db.ExecContext(ctx, deleteFileGenres, audioFileID) return err } @@ -64,20 +37,12 @@ func (q *Queries) DeleteGenre(ctx context.Context, id int64) error { return err } -const deleteRecordingGenres = `-- name: DeleteRecordingGenres :exec -DELETE FROM recording_genres -WHERE recording_id = ? -` - -func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) error { - _, err := q.db.ExecContext(ctx, deleteRecordingGenres, recordingID) - return err -} - const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many -SELECT g.name, COUNT(rg.recording_id) AS track_count +SELECT g.name, COUNT(fg.audio_file_id) AS track_count FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id +JOIN file_genres fg ON fg.genre_id = g.id +JOIN audio_files af ON af.id = fg.audio_file_id +WHERE af.library_id = COALESCE(NULLIF(CAST(?1 AS INTEGER), 0), af.library_id) GROUP BY g.id, g.name ORDER BY g.name ` @@ -87,8 +52,8 @@ type GetAllGenresWithCountsRow struct { TrackCount int64 } -func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) { - rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts) +func (q *Queries) GetAllGenresWithCounts(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsRow, error) { + rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts, libraryID) if err != nil { return nil, err } @@ -110,35 +75,25 @@ func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWit return items, nil } -const getAllGenresWithCountsByLibrary = `-- name: GetAllGenresWithCountsByLibrary :many -SELECT g.name, COUNT(rg.recording_id) AS track_count -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE af.library_id = ? -GROUP BY g.id, g.name -ORDER BY g.name +const getGenreNamesByFile = `-- name: GetGenreNamesByFile :many +SELECT g.name FROM genres g +JOIN file_genres fg ON fg.genre_id = g.id +WHERE fg.audio_file_id = ? ` -type GetAllGenresWithCountsByLibraryRow struct { - Name string - TrackCount int64 -} - -func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID int64) ([]GetAllGenresWithCountsByLibraryRow, error) { - rows, err := q.db.QueryContext(ctx, getAllGenresWithCountsByLibrary, libraryID) +func (q *Queries) GetGenreNamesByFile(ctx context.Context, audioFileID int64) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getGenreNamesByFile, audioFileID) if err != nil { return nil, err } defer rows.Close() - var items []GetAllGenresWithCountsByLibraryRow + var items []string for rows.Next() { - var i GetAllGenresWithCountsByLibraryRow - if err := rows.Scan(&i.Name, &i.TrackCount); err != nil { + var name string + if err := rows.Scan(&name); err != nil { return nil, err } - items = append(items, i) + items = append(items, name) } if err := rows.Close(); err != nil { return nil, err @@ -149,45 +104,42 @@ func (q *Queries) GetAllGenresWithCountsByLibrary(ctx context.Context, libraryID return items, nil } -const getFilePathsByGenres = `-- name: GetFilePathsByGenres :many - -SELECT g.name AS genre_name, af.file_path -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE g.name IN (/*SLICE:genre_names*/?) -ORDER BY r.name +const getGenreNamesByFilePaths = `-- name: GetGenreNamesByFilePaths :many +SELECT af.file_path, g.name +FROM audio_files af +JOIN file_genres fg ON fg.audio_file_id = af.id +JOIN genres g ON g.id = fg.genre_id +WHERE af.file_path IN (/*SLICE:paths*/?) ` -type GetFilePathsByGenresRow struct { - GenreName string - FilePath string +type GetGenreNamesByFilePathsRow struct { + FilePath string + Name string } -// Same as GetFilePathsByReleaseGroups, for "play these genres" (perf.m2): -// one query instead of one per genre, and file paths instead of whole -// track rows, which was 6 MB over the IPC for five genres. -func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) ([]GetFilePathsByGenresRow, error) { - query := getFilePathsByGenres +// Genres for many files at once. The mix builder asked this one file +// at a time, inside two nested loops -- twelve thousand single-row +// queries to assemble one mix. +func (q *Queries) GetGenreNamesByFilePaths(ctx context.Context, paths []string) ([]GetGenreNamesByFilePathsRow, error) { + query := getGenreNamesByFilePaths var queryParams []interface{} - if len(genreNames) > 0 { - for _, v := range genreNames { + if len(paths) > 0 { + for _, v := range paths { queryParams = append(queryParams, v) } - query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(genreNames))[1:], 1) + query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1) } else { - query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1) + query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1) } rows, err := q.db.QueryContext(ctx, query, queryParams...) if err != nil { return nil, err } defer rows.Close() - var items []GetFilePathsByGenresRow + var items []GetGenreNamesByFilePathsRow for rows.Next() { - var i GetFilePathsByGenresRow - if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil { + var i GetGenreNamesByFilePathsRow + if err := rows.Scan(&i.FilePath, &i.Name); err != nil { return nil, err } items = append(items, i) @@ -201,51 +153,24 @@ func (q *Queries) GetFilePathsByGenres(ctx context.Context, genreNames []string) return items, nil } -const getFilePathsByGenresByLibrary = `-- name: GetFilePathsByGenresByLibrary :many -SELECT g.name AS genre_name, af.file_path -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -WHERE g.name IN (/*SLICE:genre_names*/?) - AND af.library_id = ? -ORDER BY r.name +const getUnusedGenreIDs = `-- name: GetUnusedGenreIDs :many +SELECT id FROM genres g +WHERE NOT EXISTS (SELECT 1 FROM file_genres fg WHERE fg.genre_id = g.id) ` -type GetFilePathsByGenresByLibraryParams struct { - GenreNames []string - LibraryID int64 -} - -type GetFilePathsByGenresByLibraryRow struct { - GenreName string - FilePath string -} - -func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFilePathsByGenresByLibraryParams) ([]GetFilePathsByGenresByLibraryRow, error) { - query := getFilePathsByGenresByLibrary - var queryParams []interface{} - if len(arg.GenreNames) > 0 { - for _, v := range arg.GenreNames { - queryParams = append(queryParams, v) - } - query = strings.Replace(query, "/*SLICE:genre_names*/?", strings.Repeat(",?", len(arg.GenreNames))[1:], 1) - } else { - query = strings.Replace(query, "/*SLICE:genre_names*/?", "NULL", 1) - } - queryParams = append(queryParams, arg.LibraryID) - rows, err := q.db.QueryContext(ctx, query, queryParams...) +func (q *Queries) GetUnusedGenreIDs(ctx context.Context) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, getUnusedGenreIDs) if err != nil { return nil, err } defer rows.Close() - var items []GetFilePathsByGenresByLibraryRow + var items []int64 for rows.Next() { - var i GetFilePathsByGenresByLibraryRow - if err := rows.Scan(&i.GenreName, &i.FilePath); err != nil { + var id int64 + if err := rows.Scan(&id); err != nil { return nil, err } - items = append(items, i) + items = append(items, id) } if err := rows.Close(); err != nil { return nil, err @@ -256,247 +181,32 @@ func (q *Queries) GetFilePathsByGenresByLibrary(ctx context.Context, arg GetFile return items, nil } -const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many -SELECT g.id, g.name -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -WHERE rg.recording_id = ? +const linkFileGenre = `-- name: LinkFileGenre :exec +INSERT OR IGNORE INTO file_genres (audio_file_id, genre_id) VALUES (?, ?) ` -func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) ([]Genre, error) { - rows, err := q.db.QueryContext(ctx, getGenresByRecordingID, recordingID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []Genre - for rows.Next() { - var i Genre - if err := rows.Scan(&i.ID, &i.Name); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +type LinkFileGenreParams struct { + AudioFileID int64 + GenreID int64 } -const getTracksByGenre = `-- name: GetTracksByGenre :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rlg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g2.name, '||') - FROM recording_genres rg2 - JOIN genres g2 ON rg2.genre_id = g2.id - WHERE rg2.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, - MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE g.name = ? -ORDER BY r.name -` - -type GetTracksByGenreRow struct { - FilePath string - LengthMilliseconds int64 - Title string - ArtistName string - TrackNumber sql.NullInt64 - DiscNumber sql.NullInt64 - Album string - Genre string - Year int64 - Composer string - FileType string - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 -} - -func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) { - rows, err := q.db.QueryContext(ctx, getTracksByGenre, name) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTracksByGenreRow - for rows.Next() { - var i GetTracksByGenreRow - if err := rows.Scan( - &i.FilePath, - &i.LengthMilliseconds, - &i.Title, - &i.ArtistName, - &i.TrackNumber, - &i.DiscNumber, - &i.Album, - &i.Genre, - &i.Year, - &i.Composer, - &i.FileType, - &i.SampleRate, - &i.BitDepth, - &i.Channels, - &i.Bitrate, - &i.FileSize, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - -const getTracksByGenreByLibrary = `-- name: GetTracksByGenreByLibrary :many -SELECT - af.file_path, - af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rlg.name, '') AS album, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g2.name, '||') - FROM recording_genres rg2 - JOIN genres g2 ON rg2.genre_id = g2.id - WHERE rg2.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(r.year, 0) AS year, - COALESCE(r.composer, '') AS composer, - COALESCE(ft.extension, '') AS file_type, - af.sample_rate, - af.bit_depth, - af.channels, - af.bitrate, - af.file_size -FROM genres g -JOIN recording_genres rg ON g.id = rg.genre_id -JOIN recordings r ON rg.recording_id = r.id -JOIN audio_files af ON af.recording_id = r.id -JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, - MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id -LEFT JOIN file_types ft ON af.file_type_id = ft.id -WHERE g.name = ? AND af.library_id = ? -ORDER BY r.name -` - -type GetTracksByGenreByLibraryParams struct { - Name string - LibraryID int64 -} - -type GetTracksByGenreByLibraryRow struct { - FilePath string - LengthMilliseconds int64 - Title string - ArtistName string - TrackNumber sql.NullInt64 - DiscNumber sql.NullInt64 - Album string - Genre string - Year int64 - Composer string - FileType string - SampleRate int64 - BitDepth int64 - Channels int64 - Bitrate int64 - FileSize int64 -} - -func (q *Queries) GetTracksByGenreByLibrary(ctx context.Context, arg GetTracksByGenreByLibraryParams) ([]GetTracksByGenreByLibraryRow, error) { - rows, err := q.db.QueryContext(ctx, getTracksByGenreByLibrary, arg.Name, arg.LibraryID) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTracksByGenreByLibraryRow - for rows.Next() { - var i GetTracksByGenreByLibraryRow - if err := rows.Scan( - &i.FilePath, - &i.LengthMilliseconds, - &i.Title, - &i.ArtistName, - &i.TrackNumber, - &i.DiscNumber, - &i.Album, - &i.Genre, - &i.Year, - &i.Composer, - &i.FileType, - &i.SampleRate, - &i.BitDepth, - &i.Channels, - &i.Bitrate, - &i.FileSize, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil +func (q *Queries) LinkFileGenre(ctx context.Context, arg LinkFileGenreParams) error { + _, err := q.db.ExecContext(ctx, linkFileGenre, arg.AudioFileID, arg.GenreID) + return err } const upsertGenre = `-- name: UpsertGenre :one + INSERT INTO genres (name) VALUES (?) -ON CONFLICT(name) DO UPDATE SET name = name +ON CONFLICT(name) DO UPDATE SET name = excluded.name RETURNING id, name ` +// Queries over genres and file_genres. +// +// The track-returning ones live in audio_files.sql with the rest of the +// track_metadata reads; what is left here is the genre list itself and +// the link table's writes. func (q *Queries) UpsertGenre(ctx context.Context, name string) (Genre, error) { row := q.db.QueryRowContext(ctx, upsertGenre, name) var i Genre diff --git a/backend/database/sql/sqlcgen/home.sql.go b/backend/database/sql/sqlcgen/home.sql.go index 75e8385..a4f60aa 100644 --- a/backend/database/sql/sqlcgen/home.sql.go +++ b/backend/database/sql/sqlcgen/home.sql.go @@ -12,10 +12,10 @@ import ( const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id -JOIN genres g ON g.id = rgen.genre_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id +JOIN file_genres fg ON fg.audio_file_id = af.id +JOIN genres g ON g.id = fg.genre_id WHERE g.name = ? GROUP BY rg.id ORDER BY RANDOM() @@ -54,9 +54,8 @@ func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenrePa const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id HAVING SUM(af.play_count) > 0 ORDER BY SUM(af.play_count) DESC @@ -89,9 +88,8 @@ func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int6 const homeRandomAlbums = `-- name: HomeRandomAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id ORDER BY RANDOM() LIMIT ? @@ -122,9 +120,8 @@ func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, e const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id ORDER BY MAX(af.id) DESC LIMIT ? @@ -159,9 +156,8 @@ func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]i const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id WHERE af.last_played IS NOT NULL GROUP BY rg.id ORDER BY MAX(af.last_played) DESC @@ -172,7 +168,7 @@ LIMIT ? // // Every one of these returns album ids and nothing else. The display // columns (cover art, artist credit, year) already have exactly one -// correct expression of them, in GetAllAlbumsWithDetails, and a second +// correct expression of them, in GetAlbums, and a second // copy per shelf would be six more places for that to drift. The home // service joins the ids back to that one album list in Go. // Albums with the most recent play, newest first. @@ -201,9 +197,8 @@ func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([] const homeStaleAlbums = `-- name: HomeStaleAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id WHERE af.last_played IS NOT NULL GROUP BY rg.id HAVING MAX(af.last_played) < datetime('now', ?) @@ -242,14 +237,12 @@ func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams const homeTopArtists = `-- name: HomeTopArtists :many SELECT - COALESCE(ac.text, '') AS artist_name, + rg.artist_credit AS artist_name, SUM(af.play_count) AS plays -FROM release_groups rg -JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id -WHERE ac.text <> '' -GROUP BY ac.text +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id +WHERE rg.artist_credit <> '' +GROUP BY rg.artist_credit HAVING plays > 0 ORDER BY plays DESC LIMIT ? @@ -288,10 +281,10 @@ func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArt const homeTopGenres = `-- name: HomeTopGenres :many SELECT g.name AS genre, - COUNT(DISTINCT rgr.release_group_id) AS album_count + COUNT(DISTINCT af.album_id) AS album_count FROM genres g -JOIN recording_genres rgen ON rgen.genre_id = g.id -JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id +JOIN file_genres fg ON fg.genre_id = g.id +JOIN audio_files af ON af.id = fg.audio_file_id GROUP BY g.id HAVING album_count >= 3 ORDER BY album_count DESC @@ -331,9 +324,8 @@ func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenr const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many SELECT rg.id AS album_id -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN audio_files af ON af.recording_id = rgr.recording_id +FROM albums rg +JOIN audio_files af ON af.album_id = rg.id GROUP BY rg.id HAVING SUM(af.play_count) = 0 ORDER BY RANDOM() diff --git a/backend/database/sql/sqlcgen/mix.sql.go b/backend/database/sql/sqlcgen/mix.sql.go deleted file mode 100644 index 2c7bb2b..0000000 --- a/backend/database/sql/sqlcgen/mix.sql.go +++ /dev/null @@ -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 -} diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index 627ac48..5be6ad8 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -9,23 +9,24 @@ import ( "time" ) +type Album struct { + ID int64 + Name string + ArtistCredit string + ArtistID sql.NullInt64 + Mbid sql.NullString + Year sql.NullInt64 + OriginalYear sql.NullInt64 + CoverArtID sql.NullInt64 + PendingReleaseMbid sql.NullString +} + type Artist struct { ID int64 Name string Mbid sql.NullString } -type ArtistCredit struct { - ID int64 - Text string -} - -type ArtistCreditArtist struct { - ID int64 - ArtistID int64 - CreditID int64 -} - type ArtistEnrichment struct { ArtistMbid string BrowsedAt sql.NullTime @@ -56,21 +57,31 @@ type ArtistMetadatum struct { type AudioFile struct { ID int64 FilePath string - LengthMilliseconds int64 + LibraryID int64 FileTypeID int64 - RecordingID int64 + LengthMilliseconds int64 SampleRate int64 BitDepth int64 Channels int64 Bitrate int64 FileSize int64 + Title string + ArtistCredit string + ArtistID sql.NullInt64 + AlbumID sql.NullInt64 + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + TotalTracks sql.NullInt64 + Year sql.NullInt64 + Composer string + Comment string + RecordingMbid sql.NullString Basename string - LibraryID int64 + GroupKey string + ModifiedAt int64 PlayCount int64 LastPlayed sql.NullTime TagStatus string - GroupKey string - ModifiedAt int64 } type CoverArt struct { @@ -160,20 +171,21 @@ type ExploreChampionFt struct { type ExploreIndex struct { ID int64 - EntityType string - Mbid string + EntityType int64 + Mbid []byte Title string ArtistName string - ArtistMbid string + ArtistMbid []byte Aliases string Popularity int64 ListenerCount int64 Duration int64 - CaaReleaseMbid string + CaaReleaseMbid []byte ReleaseName string PrimaryType string SecondaryTypes string ReleaseDate string + TotalTracks int64 ArtistType string Country string Disambiguation string @@ -197,6 +209,11 @@ type ExploreIndexMetum struct { Value string } +type FileGenre struct { + AudioFileID int64 + GenreID int64 +} + type FileType struct { ID int64 Extension string @@ -231,6 +248,14 @@ type Library struct { AutotagWarningAcked int64 } +type Lyric struct { + AudioFileID int64 + Text string + Source string + RecordingMbid sql.NullString + FetchedAt time.Time +} + type LyricsIndex struct { Lyrics string } @@ -291,48 +316,6 @@ type QueueTrack struct { Position int64 } -type Recording struct { - ID int64 - Name string - ArtistCreditID int64 - TrackNumber sql.NullInt64 - DiscNumber sql.NullInt64 - Year sql.NullInt64 - Genre sql.NullString - Composer sql.NullString - Lyrics sql.NullString - Comment sql.NullString - Mbid sql.NullString -} - -type RecordingGenre struct { - ID int64 - RecordingID int64 - GenreID int64 -} - -type ReleaseGroup struct { - ID int64 - Name string - CoverArtID sql.NullInt64 - AlbumArtistCreditID sql.NullInt64 - Year sql.NullInt64 - TotalTracks sql.NullInt64 - TotalDiscs sql.NullInt64 - Mbid sql.NullString - OriginalYear sql.NullInt64 - PendingReleaseMbid sql.NullString -} - -type ReleaseGroupRecording struct { - ID int64 - ReleaseGroupID int64 - RecordingID int64 - TrackNumber sql.NullInt64 - DiscNumber sql.NullInt64 - TotalTracks sql.NullInt64 -} - type ReleaseToRg struct { ReleaseMbid string RgMbid string @@ -410,4 +393,6 @@ type TrackMetadatum struct { ArtistMbid string ReleaseGroupMbid string RecordingMbid string + AlbumID sql.NullInt64 + ArtistID sql.NullInt64 } diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index 0ed54f4..2bc911b 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -124,29 +124,18 @@ SELECT pt.playlist_id, pt.audio_file_id, pt.position, - COALESCE(af.file_path, '') AS file_path, - COALESCE(af.length_milliseconds, 0) AS length_milliseconds, - COALESCE(r.name, pt.phantom_title, '') AS title, - COALESCE(ac.text, pt.phantom_artist, '') AS artist, - COALESCE(rg.name, pt.phantom_album, '') AS album, - COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, + COALESCE(tm.file_path, '') AS file_path, + COALESCE(tm.length_milliseconds, 0) AS length_milliseconds, + COALESCE(tm.title, pt.phantom_title, '') AS title, + COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist, + COALESCE(tm.album, pt.phantom_album, '') AS album, + COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid + CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid, + COALESCE(tm.release_group_mbid, '') AS release_group_mbid, + COALESCE(tm.recording_mbid, '') AS recording_mbid FROM playlist_tracks pt -LEFT JOIN audio_files af ON pt.audio_file_id = af.id -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id ORDER BY pt.playlist_id, pt.position ` @@ -368,29 +357,18 @@ SELECT pt.playlist_id, pt.audio_file_id, pt.position, - COALESCE(af.file_path, '') AS file_path, - COALESCE(af.length_milliseconds, 0) AS length_milliseconds, - COALESCE(r.name, pt.phantom_title, '') AS title, - COALESCE(ac.text, pt.phantom_artist, '') AS artist, - COALESCE(rg.name, pt.phantom_album, '') AS album, - COALESCE(ca.file_path, pt.phantom_cover_art_path, '') AS cover_art_path, + COALESCE(tm.file_path, '') AS file_path, + COALESCE(tm.length_milliseconds, 0) AS length_milliseconds, + COALESCE(tm.title, pt.phantom_title, '') AS title, + COALESCE(tm.artist_name, pt.phantom_artist, '') AS artist, + COALESCE(tm.album, pt.phantom_album, '') AS album, + COALESCE(NULLIF(tm.cover_art_path, ''), pt.phantom_cover_art_path, '') AS cover_art_path, CASE WHEN pt.audio_file_id IS NULL THEN 1 ELSE 0 END AS is_phantom, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid + CAST(COALESCE(tm.artist_mbid, '') AS TEXT) AS artist_mbid, + COALESCE(tm.release_group_mbid, '') AS release_group_mbid, + COALESCE(tm.recording_mbid, '') AS recording_mbid FROM playlist_tracks pt -LEFT JOIN audio_files af ON pt.audio_file_id = af.id -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +LEFT JOIN track_metadata tm ON tm.id = pt.audio_file_id WHERE pt.playlist_id = ? ORDER BY pt.position ` @@ -451,30 +429,10 @@ func (q *Queries) GetPlaylistTracksWithMetadata(ctx context.Context, playlistID } const getTrackPhantomMetadata = `-- name: GetTrackPhantomMetadata :one -SELECT - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album, - af.length_milliseconds AS duration_ms, - CAST(COALESCE( - (SELECT GROUP_CONCAT(g.name, '||') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id = r.id), - '' - ) AS TEXT) AS genre, - COALESCE(ca.file_path, '') AS cover_art_path -FROM audio_files af -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id -WHERE af.id = ? +SELECT title, artist_name AS artist, album, + length_milliseconds AS duration_ms, genre, cover_art_path +FROM track_metadata +WHERE id = ? ` type GetTrackPhantomMetadataRow struct { @@ -486,6 +444,7 @@ type GetTrackPhantomMetadataRow struct { CoverArtPath string } +// The display fields a playlist row keeps after its file goes away. func (q *Queries) GetTrackPhantomMetadata(ctx context.Context, id int64) (GetTrackPhantomMetadataRow, error) { row := q.db.QueryRowContext(ctx, getTrackPhantomMetadata, id) var i GetTrackPhantomMetadataRow diff --git a/backend/database/sql/sqlcgen/queue.sql.go b/backend/database/sql/sqlcgen/queue.sql.go index 7e044a0..976cafd 100644 --- a/backend/database/sql/sqlcgen/queue.sql.go +++ b/backend/database/sql/sqlcgen/queue.sql.go @@ -61,27 +61,11 @@ func (q *Queries) GetQueueTrackCount(ctx context.Context) (int64, error) { } const getQueueTracks = `-- name: GetQueueTracks :many -SELECT qt.id, qt.audio_file_id, qt.position, af.file_path, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album, - COALESCE(ca.file_path, '') AS cover_art_path, - COALESCE(a.mbid, '') AS artist_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid +SELECT qt.id, qt.audio_file_id, qt.position, tm.file_path, + tm.title, tm.artist_name AS artist, tm.album, tm.cover_art_path, + tm.artist_mbid, tm.release_group_mbid, tm.recording_mbid FROM queue_tracks qt -JOIN audio_files af ON qt.audio_file_id = af.id -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN artist_credit_artist aca ON aca.credit_id = ac.id -LEFT JOIN artists a ON a.id = aca.artist_id -LEFT JOIN ( - SELECT recording_id, MIN(release_group_id) AS release_group_id - FROM release_group_recordings - GROUP BY recording_id -) rgr ON r.id = rgr.recording_id -LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id -LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id +JOIN track_metadata tm ON tm.id = qt.audio_file_id ORDER BY qt.position ` @@ -99,6 +83,7 @@ type GetQueueTracksRow struct { RecordingMbid string } +// The queue's rows, joined to the one track projection. func (q *Queries) GetQueueTracks(ctx context.Context) ([]GetQueueTracksRow, error) { rows, err := q.db.QueryContext(ctx, getQueueTracks) if err != nil { diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go deleted file mode 100644 index 2d1193b..0000000 --- a/backend/database/sql/sqlcgen/recordings.sql.go +++ /dev/null @@ -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 -} diff --git a/backend/database/sql/sqlcgen/release_group_recordings.sql.go b/backend/database/sql/sqlcgen/release_group_recordings.sql.go deleted file mode 100644 index dddb876..0000000 --- a/backend/database/sql/sqlcgen/release_group_recordings.sql.go +++ /dev/null @@ -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 -} diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go deleted file mode 100644 index 87e3f78..0000000 --- a/backend/database/sql/sqlcgen/release_groups.sql.go +++ /dev/null @@ -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 -} diff --git a/backend/database/sql/sqlcgen/tagging_items.sql.go b/backend/database/sql/sqlcgen/tagging_items.sql.go index 0f66d6f..da8425f 100644 --- a/backend/database/sql/sqlcgen/tagging_items.sql.go +++ b/backend/database/sql/sqlcgen/tagging_items.sql.go @@ -25,11 +25,21 @@ func (q *Queries) ClearCompletedTaggingItems(ctx context.Context, libraryID int6 } const countPendingTaggingItems = `-- name: CountPendingTaggingItems :one -SELECT COUNT(*) FROM tagging_items -WHERE status = 'pending' - AND (CAST(?1 AS INTEGER) = 0 OR library_id = ?1) +SELECT COUNT(*) FROM tagging_items ti +WHERE ti.status = 'pending' + AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1) + AND EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) ` +// "Needs tagging" is a question about the files, not about the row: +// every scanned folder gets a tagging_items row (see +// UpsertTaggingItemOnTrackAdd), including one whose files all arrived +// carrying a recording MBID. Without the EXISTS a fully MB-tagged +// library reports its entire album count as pending work. See the +// same predicate on the three list queries below. func (q *Queries) CountPendingTaggingItems(ctx context.Context, libraryID int64) (int64, error) { row := q.db.QueryRowContext(ctx, countPendingTaggingItems, libraryID) var count int64 @@ -77,6 +87,13 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id WHERE ti.status = 'pending' AND (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1) AND ti.group_key > ?2 + -- See CountPendingTaggingItems: the cursor must not stop on a + -- folder the list query no longer shows, or "next" walks folders + -- that are not in the sidebar. + AND EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) ORDER BY ti.group_key LIMIT 1 ` @@ -185,20 +202,6 @@ func (q *Queries) GetPendingFolderDetail(ctx context.Context, groupKey string) ( return i, err } -const getRecordingReleaseGroupID = `-- name: GetRecordingReleaseGroupID :one -SELECT COALESCE(rgr.release_group_id, 0) AS release_group_id -FROM release_group_recordings rgr -WHERE rgr.recording_id = ? -LIMIT 1 -` - -func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID int64) (int64, error) { - row := q.db.QueryRowContext(ctx, getRecordingReleaseGroupID, recordingID) - var release_group_id int64 - err := row.Scan(&release_group_id) - return release_group_id, err -} - const getTaggingItem = `-- name: GetTaggingItem :one SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key, album_artist_conflict FROM tagging_items WHERE group_key = ? @@ -235,22 +238,18 @@ SELECT af.basename, af.length_milliseconds, af.tag_status, - COALESCE(r.track_number, 0) AS track_number, - COALESCE(r.disc_number, 0) AS disc_number, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - COALESCE(r.mbid, '') AS recording_mbid, - COALESCE(rg.name, '') AS album_name, - COALESCE(rgac.text, '') AS album_artist + COALESCE(af.track_number, 0) AS track_number, + COALESCE(af.disc_number, 0) AS disc_number, + af.title, + af.artist_credit AS artist_name, + COALESCE(af.recording_mbid, '') AS recording_mbid, + COALESCE(al.name, '') AS album_name, + COALESCE(al.artist_credit, '') AS album_artist FROM audio_files af -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id -LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id -LEFT JOIN artist_credit rgac ON rg.album_artist_credit_id = rgac.id +LEFT JOIN albums al ON al.id = af.album_id WHERE af.group_key = ? -ORDER BY COALESCE(r.disc_number, 0), - COALESCE(r.track_number, 0), +ORDER BY COALESCE(af.disc_number, 0), + COALESCE(af.track_number, 0), af.file_path ` @@ -269,10 +268,10 @@ type ListAudioFilesInTaggingGroupRow struct { AlbumArtist string } -// album_name/album_artist are the PER-TRACK tags (via each track's -// own release_group link), not the folder-level tagging_items -// values. SplitMixedFolder clusters on these to find sub-albums -// hiding inside a folder full of unrelated tracks. +// album_name/album_artist are the PER-TRACK tags (each file's own +// album link), not the folder-level tagging_items values. +// SplitMixedFolder clusters on these to find sub-albums hiding inside +// a folder full of unrelated tracks. func (q *Queries) ListAudioFilesInTaggingGroup(ctx context.Context, groupKey string) ([]ListAudioFilesInTaggingGroupRow, error) { rows, err := q.db.QueryContext(ctx, listAudioFilesInTaggingGroup, groupKey) if err != nil { @@ -313,10 +312,7 @@ const listLikelyMixedBagGroupKeys = `-- name: ListLikelyMixedBagGroupKeys :many SELECT ti.group_key FROM tagging_items ti JOIN audio_files af ON af.group_key = ti.group_key -LEFT JOIN recordings r ON af.recording_id = r.id -LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id -LEFT JOIN release_group_recordings rgr ON rgr.recording_id = r.id -LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id +LEFT JOIN albums rg ON rg.id = af.album_id WHERE ti.synthetic = 0 AND ti.track_count >= 4 AND ( @@ -324,7 +320,7 @@ WHERE ti.synthetic = 0 OR LOWER(TRIM(ti.album_artist)) IN ('various artists', 'various', 'va', 'v.a.', 'v a', 'unknown') ) GROUP BY ti.group_key -HAVING COUNT(DISTINCT CASE WHEN ac.text != '' THEN LOWER(TRIM(ac.text)) END) > 1 +HAVING COUNT(DISTINCT CASE WHEN af.artist_credit != '' THEN LOWER(TRIM(af.artist_credit)) END) > 1 AND COUNT(DISTINCT CASE WHEN rg.name != '' THEN LOWER(TRIM(rg.name)) END) > 1 ` @@ -359,34 +355,31 @@ func (q *Queries) ListLikelyMixedBagGroupKeys(ctx context.Context) ([]string, er return items, nil } -const listLocalReleaseGroupCandidates = `-- name: ListLocalReleaseGroupCandidates :many +const listLocalAlbumCandidates = `-- name: ListLocalAlbumCandidates :many SELECT - rg.id AS release_group_id, - rg.mbid AS release_group_mbid, - rg.name AS album_name, - COALESCE(rg.year, 0) AS year, - COALESCE(ac.text, '') AS artist_credit, - COALESCE(rgr.track_number, 0) AS track_number, - COALESCE(rgr.disc_number, 0) AS disc_number, - COALESCE(r.name, '') AS track_title, - COALESCE(r.mbid, '') AS recording_mbid, - COALESCE(local_af.length_milliseconds, 0) AS length_milliseconds -FROM release_groups rg -JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id -JOIN recordings r ON r.id = rgr.recording_id -LEFT JOIN artist_credit ac ON rg.album_artist_credit_id = ac.id -LEFT JOIN audio_files local_af ON local_af.recording_id = r.id -WHERE rg.mbid IS NOT NULL - AND rg.mbid != '' - AND r.mbid IS NOT NULL - AND r.mbid != '' - AND rg.name = ? COLLATE NOCASE -ORDER BY rg.id, rgr.disc_number, rgr.track_number + al.id AS album_id, + al.mbid AS album_mbid, + al.name AS album_name, + COALESCE(al.year, 0) AS year, + al.artist_credit, + COALESCE(af.track_number, 0) AS track_number, + COALESCE(af.disc_number, 0) AS disc_number, + af.title AS track_title, + COALESCE(af.recording_mbid, '') AS recording_mbid, + af.length_milliseconds +FROM albums al +JOIN audio_files af ON af.album_id = al.id +WHERE al.mbid IS NOT NULL + AND al.mbid != '' + AND af.recording_mbid IS NOT NULL + AND af.recording_mbid != '' + AND al.name = ? COLLATE NOCASE +ORDER BY al.id, af.disc_number, af.track_number ` -type ListLocalReleaseGroupCandidatesRow struct { - ReleaseGroupID int64 - ReleaseGroupMbid sql.NullString +type ListLocalAlbumCandidatesRow struct { + AlbumID int64 + AlbumMbid sql.NullString AlbumName string Year int64 ArtistCredit string @@ -397,22 +390,21 @@ type ListLocalReleaseGroupCandidatesRow struct { LengthMilliseconds int64 } -// Returns one row per (release_group, track) combination for any -// local release_group that has an MBID. Callers group these in Go -// and filter by normalized album-name match. Joined case-insensitive -// on name to pre-filter cheaply; Go does the real normalization. -func (q *Queries) ListLocalReleaseGroupCandidates(ctx context.Context, name string) ([]ListLocalReleaseGroupCandidatesRow, error) { - rows, err := q.db.QueryContext(ctx, listLocalReleaseGroupCandidates, name) +// One row per (album, track) for any local album carrying an MBID. +// Callers group these in Go and filter by normalized album-name match; +// the join is case-insensitive on name to pre-filter cheaply. +func (q *Queries) ListLocalAlbumCandidates(ctx context.Context, name string) ([]ListLocalAlbumCandidatesRow, error) { + rows, err := q.db.QueryContext(ctx, listLocalAlbumCandidates, name) if err != nil { return nil, err } defer rows.Close() - var items []ListLocalReleaseGroupCandidatesRow + var items []ListLocalAlbumCandidatesRow for rows.Next() { - var i ListLocalReleaseGroupCandidatesRow + var i ListLocalAlbumCandidatesRow if err := rows.Scan( - &i.ReleaseGroupID, - &i.ReleaseGroupMbid, + &i.AlbumID, + &i.AlbumMbid, &i.AlbumName, &i.Year, &i.ArtistCredit, @@ -454,6 +446,18 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1) AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2) AND ti.cleared_at IS NULL + -- Actionable rows must have something to act on: see + -- CountPendingTaggingItems. Reviewed rows (confirmed/skipped) are + -- exempt because they are history, not work -- an applied folder is + -- fully tagged by definition and would otherwise vanish from the + -- sidebar's Completed section the instant it succeeded. + AND ( + ti.status IN ('confirmed', 'skipped') + OR EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) + ) ORDER BY LOWER(ti.album_artist), LOWER(ti.album_name), ti.disc_number LIMIT ?4 OFFSET ?3 ` @@ -628,6 +632,14 @@ LEFT JOIN libraries lb ON lb.id = ti.library_id WHERE (CAST(?1 AS INTEGER) = 0 OR ti.library_id = ?1) AND (CAST(?2 AS TEXT) = 'all' OR ti.status = ?2) AND ti.cleared_at IS NULL + -- See ListPendingTaggingItemsAlphabetical. + AND ( + ti.status IN ('confirmed', 'skipped') + OR EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) + ) ORDER BY ti.score IS NULL, ti.score DESC, LOWER(ti.album_artist), LOWER(ti.album_name) LIMIT ?4 OFFSET ?3 ` @@ -758,31 +770,36 @@ func (q *Queries) SetAudioFileTagStatus(ctx context.Context, arg SetAudioFileTag return err } -const setRecordingMBID = `-- name: SetRecordingMBID :exec -UPDATE recordings SET mbid = ? WHERE id = ? +const setFileAlbumMBID = `-- name: SetFileAlbumMBID :exec +UPDATE albums SET mbid = ? +WHERE albums.id = (SELECT af.album_id FROM audio_files af WHERE af.id = ?) ` -type SetRecordingMBIDParams struct { +type SetFileAlbumMBIDParams struct { Mbid sql.NullString ID int64 } -func (q *Queries) SetRecordingMBID(ctx context.Context, arg SetRecordingMBIDParams) error { - _, err := q.db.ExecContext(ctx, setRecordingMBID, arg.Mbid, arg.ID) +// The album MBID for the album a file belongs to. Keyed by file +// because that is what the autotag apply path holds; under the old +// schema it had to look the release group up through two join tables +// first (GetRecordingReleaseGroupID), which is gone. +func (q *Queries) SetFileAlbumMBID(ctx context.Context, arg SetFileAlbumMBIDParams) error { + _, err := q.db.ExecContext(ctx, setFileAlbumMBID, arg.Mbid, arg.ID) return err } -const setReleaseGroupMBID = `-- name: SetReleaseGroupMBID :exec -UPDATE release_groups SET mbid = ? WHERE id = ? +const setFileRecordingMBID = `-- name: SetFileRecordingMBID :exec +UPDATE audio_files SET recording_mbid = ? WHERE id = ? ` -type SetReleaseGroupMBIDParams struct { - Mbid sql.NullString - ID int64 +type SetFileRecordingMBIDParams struct { + RecordingMbid sql.NullString + ID int64 } -func (q *Queries) SetReleaseGroupMBID(ctx context.Context, arg SetReleaseGroupMBIDParams) error { - _, err := q.db.ExecContext(ctx, setReleaseGroupMBID, arg.Mbid, arg.ID) +func (q *Queries) SetFileRecordingMBID(ctx context.Context, arg SetFileRecordingMBIDParams) error { + _, err := q.db.ExecContext(ctx, setFileRecordingMBID, arg.RecordingMbid, arg.ID) return err } diff --git a/backend/database/tagging_items_test.go b/backend/database/tagging_items_test.go index 9dcc021..6fcd129 100644 --- a/backend/database/tagging_items_test.go +++ b/backend/database/tagging_items_test.go @@ -69,10 +69,7 @@ func TestTagStatusBackfillFromRecordingMBID(t *testing.T) { UPDATE audio_files SET tag_status = 'user_confirmed' WHERE tag_status = 'untagged' - AND recording_id IN ( - SELECT id FROM recordings - WHERE mbid IS NOT NULL AND mbid != '' - ) + AND recording_mbid IS NOT NULL AND recording_mbid != '' `); err != nil { t.Fatalf("backfill: %v", err) } @@ -205,6 +202,12 @@ func TestTaggingItems_ListPendingAndCount(t *testing.T) { seedTaggingItem(t, db, "g2", 0, "Album B", "Artist B", 1, "pending") seedTaggingItem(t, db, "g3", 0, "Album C", "Artist C", 3, "confirmed") + // A pending group is only listed while it still holds untagged + // files — see TestTaggingItems_FullyTaggedGroupIsNotPending. + seedGroupFile(t, db, "g1", "/music/a1.mp3", "untagged") + seedGroupFile(t, db, "g2", "/music/b1.mp3", "untagged") + seedGroupFile(t, db, "g3", "/music/c1.mp3", "user_confirmed") + count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0) if err != nil { t.Fatalf("count: %v", err) @@ -239,6 +242,62 @@ func TestTaggingItems_ListPendingAndCount(t *testing.T) { } } +// TestClearUnreviewedConfirmedTaggingItems mirrors migration 0007 the +// way TestTagStatusBackfillFromRecordingMBID mirrors the tag_status +// backfill: NewTestDB applies migrations to an empty database, so the +// only way to exercise one that rewrites existing rows is to seed the +// shapes and re-issue its statement. Keep the two in step. +func TestClearUnreviewedConfirmedTaggingItems(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + + // Stamped 'confirmed' by the old backfill: never scored, never + // checked, never matched — the app has not touched it. + seedTaggingItem(t, db, "g-backfill", 0, "Bulk", "Artist A", 2, "confirmed") + + // Confirmed by a real apply, which stamps last_checked_at. + seedTaggingItem(t, db, "g-applied", 0, "Applied", "Artist B", 2, "confirmed") + + if _, err := db.ExecContext( + `UPDATE tagging_items SET last_checked_at = CURRENT_TIMESTAMP, score = 0.98 + WHERE group_key = 'g-applied'`, + ); err != nil { + t.Fatalf("mark applied: %v", err) + } + + // A skipped row is not confirmed and must be left alone. + seedTaggingItem(t, db, "g-skipped", 0, "Skipped", "Artist C", 1, "skipped") + + if _, err := db.ExecContext(` + UPDATE tagging_items + SET cleared_at = CURRENT_TIMESTAMP + WHERE status = 'confirmed' + AND cleared_at IS NULL + AND last_checked_at IS NULL + AND score IS NULL + AND (best_match_release_mbid IS NULL OR best_match_release_mbid = '') + `); err != nil { + t.Fatalf("migration: %v", err) + } + + cases := map[string]bool{ + "g-backfill": true, + "g-applied": false, + "g-skipped": false, + } + + for key, wantCleared := range cases { + got := scalarInt(t, db, + `SELECT cleared_at IS NOT NULL FROM tagging_items WHERE group_key = ?`, + key, + ) + if (got == 1) != wantCleared { + t.Errorf("%s cleared = %v, want %v", key, got == 1, wantCleared) + } + } +} + func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) { t.Parallel() @@ -248,9 +307,13 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) { rows, err := db.QueryContext(` EXPLAIN QUERY PLAN - SELECT COUNT(*) FROM tagging_items - WHERE status = 'pending' - AND (CAST(0 AS INTEGER) = 0 OR library_id = 0) + SELECT COUNT(*) FROM tagging_items ti + WHERE ti.status = 'pending' + AND (CAST(0 AS INTEGER) = 0 OR ti.library_id = 0) + AND EXISTS ( + SELECT 1 FROM audio_files af + WHERE af.group_key = ti.group_key AND af.tag_status = 'untagged' + ) `) if err != nil { t.Fatalf("explain: %v", err) @@ -281,6 +344,97 @@ func TestCountPendingTaggingItems_UsesPartialIndex(t *testing.T) { plan.String(), ) } + + // The untagged-files existence check is asked once per candidate + // row, so it has to be a seek. idx_audio_files_tag_status_untagged + // is keyed on library_id and cannot serve it; the group_key one + // can, and covers the query outright. + if !strings.Contains(plan.String(), "idx_audio_files_untagged_group_key") { + t.Errorf( + "untagged-files check does not use idx_audio_files_untagged_group_key:\n%s", + plan.String(), + ) + } +} + +// TestTaggingItems_FullyTaggedGroupIsNotPending pins the rule that +// decides what the autotag review page shows: every scanned folder +// gets a tagging_items row, so "pending" has to mean "still holds +// untagged files" rather than "has a row". Without it a fully +// MB-tagged library queues its entire album count for review — and +// the background prefetch scores every one of them against +// MusicBrainz. +func TestTaggingItems_FullyTaggedGroupIsNotPending(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + + seedTaggingItem(t, db, "g-partial", 0, "Half Tagged", "Artist A", 2, "pending") + seedGroupFile(t, db, "g-partial", "/music/partial-1.mp3", "user_confirmed") + seedGroupFile(t, db, "g-partial", "/music/partial-2.mp3", "untagged") + + seedTaggingItem(t, db, "g-done", 0, "Already Tagged", "Artist B", 2, "pending") + seedGroupFile(t, db, "g-done", "/music/done-1.mp3", "user_confirmed") + seedGroupFile(t, db, "g-done", "/music/done-2.mp3", "user_confirmed") + + // Reviewed rows are history, not work: an applied folder is fully + // tagged by definition and must stay in the Completed section. + seedTaggingItem(t, db, "g-applied", 0, "Applied Here", "Artist C", 1, "confirmed") + seedGroupFile(t, db, "g-applied", "/music/applied-1.mp3", "user_confirmed") + + count, err := db.Queries.CountPendingTaggingItems(db.Ctx, 0) + if err != nil { + t.Fatalf("count: %v", err) + } + + if count != 1 { + t.Errorf("pending count = %d, want 1 (only the part-tagged folder)", count) + } + + items, err := db.Queries.ListPendingTaggingItemsByScore( + db.Ctx, + sqlcgen.ListPendingTaggingItemsByScoreParams{ + LibraryID: 0, + StatusFilter: "all", + RowLimit: 50, + RowOffset: 0, + }, + ) + if err != nil { + t.Fatalf("list by score: %v", err) + } + + listed := make(map[string]bool, len(items)) + for _, it := range items { + listed[it.GroupKey] = true + } + + if !listed["g-partial"] { + t.Error("expected g-partial (one untagged file left) to be listed") + } + + if listed["g-done"] { + t.Error("expected g-done (nothing left to tag) to be filtered out") + } + + if !listed["g-applied"] { + t.Error("expected g-applied (confirmed by a real apply) to stay listed") + } + + next, err := db.Queries.GetNextPendingTaggingItem( + db.Ctx, + sqlcgen.GetNextPendingTaggingItemParams{LibraryID: 0, AfterGroupKey: ""}, + ) + if err != nil { + t.Fatalf("next pending: %v", err) + } + + // The cursor must not stop on a folder the sidebar no longer + // shows: alphabetically g-done sorts before g-partial, so a + // missing predicate here surfaces as "next" landing on nothing. + if next.GroupKey != "g-partial" { + t.Errorf("next pending = %q, want %q", next.GroupKey, "g-partial") + } } // TestListPendingFolders_SampleFilePathUsesIndex guards the folder-list @@ -450,9 +604,7 @@ func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) { // helpers // --------------------------------------------------------------------------- -// seedAF inserts a minimal recording + audio_files pair and returns -// the new audio_files id. All FK-satisfying rows (artist_credit, -// recordings, file_types[0]) are created inline. +// seedAF inserts one file and returns its audio_files id. func seedAF( t *testing.T, db *database.DB, @@ -462,49 +614,32 @@ func seedAF( ) int64 { t.Helper() - ac, err := db.Queries.UpsertArtistCredit(db.Ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } + return database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: filePath, + Title: recordingName, + RecordingMBID: recordingMBID, + DiscNumber: discNumber, + LibraryID: libraryID, + LengthMs: 1000, + }) +} - rec, err := db.Queries.CreateRecordingFull( - db.Ctx, - sqlcgen.CreateRecordingFullParams{ - Name: recordingName, - ArtistCreditID: ac.ID, - }, - ) - if err != nil { - t.Fatalf("create recording: %v", err) - } +// seedGroupFile attaches one file to a tagging group with an explicit +// tag_status - the thing the queue's "is there anything left to tag +// here" predicate reads. +func seedGroupFile( + t *testing.T, + db *database.DB, + groupKey, filePath, tagStatus string, +) { + t.Helper() - if recordingMBID != "" { - if _, err := db.ExecContext( - `UPDATE recordings SET mbid = ? WHERE id = ?`, - recordingMBID, rec.ID, - ); err != nil { - t.Fatalf("set mbid: %v", err) - } - } - - af, err := db.Queries.CreateAudioFile( - db.Ctx, - sqlcgen.CreateAudioFileParams{ - FilePath: filePath, - LengthMilliseconds: 1000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: filePath, - LibraryID: libraryID, - }, - ) - if err != nil { - t.Fatalf("create audio file: %v", err) - } - - _ = discNumber // reserved for callers that want specific disc values - - return af.ID + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: filePath, + Title: filePath, + GroupKey: groupKey, + TagStatus: tagStatus, + }) } func seedTaggingItem( diff --git a/backend/database/testhelper.go b/backend/database/testhelper.go index e8aef3c..d292fb9 100644 --- a/backend/database/testhelper.go +++ b/backend/database/testhelper.go @@ -2,7 +2,10 @@ package database import ( "database/sql" + "fmt" "log/slog" + "path/filepath" + "sync/atomic" "testing" _ "modernc.org/sqlite" // Register sqlite driver. @@ -10,16 +13,30 @@ import ( "yellowjacket/backend/database/sql/sqlcgen" ) -// NewTestDB returns an in-memory SQLite database that mirrors the -// production setup (PRAGMAs + all migrations). The database is -// automatically closed when the test completes via t.Cleanup. +// NewTestDB returns an in-memory SQLite database shaped like the real +// one: a single-writer handle and a separate query-only read pool over +// the same database, built by the same applySchema production uses. +// +// The two handles matter. The test DB used to be one shared connection +// with readDB nil, so `reader()` returned the *writer* — which is how a +// query-shaped write (`INSERT ... RETURNING` through QueryContext) +// passed every test and then failed for a user with "attempt to write a +// readonly database". `TestNoWritesOnTheReadPool` had to walk the source +// tree to catch what a test could not. +// +// A shared-cache in-memory database is what lets two handles see one +// database; the connections are capped the way production caps them. +// It is closed when the test completes via t.Cleanup. func NewTestDB(t *testing.T) *DB { t.Helper() - db, err := sql.Open( - "sqlite", - ":memory:?_busy_timeout=5000&_journal_mode=WAL", + // A per-test name, so parallel tests do not share a database. + dsn := fmt.Sprintf( + "file:testdb%d?mode=memory&cache=shared&_pragma=busy_timeout(5000)", + testDBSeq.Add(1), ) + + db, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("could not open test database: %v", err) } @@ -47,21 +64,32 @@ func NewTestDB(t *testing.T) *DB { t.Fatalf("could not insert test library: %v", err) } - queries := sqlcgen.New(db) + readDB, err := sql.Open("sqlite", dsn+"&_pragma=query_only(true)") + if err != nil { + t.Fatalf("could not open test read pool: %v", err) + } - t.Cleanup(func() { _ = db.Close() }) + readDB.SetMaxOpenConns(readPoolConns) + + t.Cleanup(func() { + _ = readDB.Close() + _ = db.Close() + }) return &DB{ - db: db, - Ctx: ctx, - // The in-memory test DB shares one connection, so reads and - // writes use the same handle; ReadQueries aliases Queries. - Queries: queries, - ReadQueries: queries, + db: db, + readDB: readDB, + Ctx: ctx, + Queries: sqlcgen.New(db), + ReadQueries: sqlcgen.New(readDB), logger: slog.Default(), } } +// testDBSeq names each test database uniquely, so parallel tests do not +// share one through the shared cache. +var testDBSeq atomic.Int64 + // NewTestDBWithLibrary returns a test DB with a library row // pre-inserted. Returns the DB and the library ID. func NewTestDBWithLibrary( @@ -85,3 +113,162 @@ func NewTestDBWithLibrary( return db, lib.ID } + +// TestTrack describes one file to seed into a test database. Zero +// values are fine: only FilePath is required. +type TestTrack struct { + FilePath string + Title string + Artist string + ArtistMBID string + Album string + AlbumArtist string + AlbumMBID string + RecordingMBID string + Genres []string + TrackNumber int64 + DiscNumber int64 + TotalTracks int64 + Year int64 + LengthMs int64 + LibraryID int64 + PlayCount int64 + TagStatus string + GroupKey string + // SkipSearchIndex leaves the file out of the FTS index, for the + // tests that assert on a rebuild putting it there. + SkipSearchIndex bool +} + +// InsertTestTrack seeds one file, with the artist and album its tags +// name, and returns the audio_files id. +// +// There is one of these because there is one shape. Twenty test files +// used to carry their own seeder, each inserting a recording, an artist +// credit, a credit-artist link and a release-group link in the right +// order - which is exactly the ceremony the schema change removed, and +// exactly why every one of those seeders was subtly different. +func InsertTestTrack(t *testing.T, db *DB, tr TestTrack) int64 { + t.Helper() + + if tr.Title == "" { + tr.Title = "Test Track" + } + + if tr.Artist == "" { + tr.Artist = "Test Artist" + } + + if tr.TagStatus == "" { + tr.TagStatus = "untagged" + } + + artist, err := db.Queries.UpsertArtist(db.Ctx, sqlcgen.UpsertArtistParams{ + Name: tr.Artist, + Mbid: nullString(tr.ArtistMBID), + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + + artistID := sql.NullInt64{Int64: artist.ID, Valid: true} + albumID := sql.NullInt64{} + + if tr.Album != "" { + credit := tr.AlbumArtist + if credit == "" { + credit = tr.Artist + } + + album, albErr := db.Queries.UpsertAlbum(db.Ctx, sqlcgen.UpsertAlbumParams{ + Name: tr.Album, + ArtistCredit: credit, + ArtistID: artistID, + Year: nullInt64(tr.Year), + }) + if albErr != nil { + t.Fatalf("seed album: %v", albErr) + } + + if tr.AlbumMBID != "" { + if err := db.Queries.SetAlbumMBID(db.Ctx, sqlcgen.SetAlbumMBIDParams{ + Mbid: nullString(tr.AlbumMBID), + ID: album.ID, + }); err != nil { + t.Fatalf("seed album mbid: %v", err) + } + } + + albumID = sql.NullInt64{Int64: album.ID, Valid: true} + } + + af, err := db.Queries.CreateAudioFile(db.Ctx, sqlcgen.CreateAudioFileParams{ + FilePath: tr.FilePath, + LibraryID: tr.LibraryID, + LengthMilliseconds: tr.LengthMs, + Title: tr.Title, + ArtistCredit: tr.Artist, + ArtistID: artistID, + AlbumID: albumID, + TrackNumber: nullInt64(tr.TrackNumber), + DiscNumber: nullInt64(tr.DiscNumber), + TotalTracks: nullInt64(tr.TotalTracks), + Year: nullInt64(tr.Year), + RecordingMbid: nullString(tr.RecordingMBID), + Basename: filepath.Base(tr.FilePath), + GroupKey: tr.GroupKey, + TagStatus: tr.TagStatus, + }) + if err != nil { + t.Fatalf("seed audio file %q: %v", tr.FilePath, err) + } + + for _, name := range tr.Genres { + g, gErr := db.Queries.UpsertGenre(db.Ctx, name) + if gErr != nil { + t.Fatalf("seed genre %q: %v", name, gErr) + } + + if err := db.Queries.LinkFileGenre(db.Ctx, sqlcgen.LinkFileGenreParams{ + AudioFileID: af.ID, + GenreID: g.ID, + }); err != nil { + t.Fatalf("seed file genre: %v", err) + } + } + + if tr.PlayCount > 0 { + if _, err := db.db.ExecContext(db.Ctx, + "UPDATE audio_files SET play_count = ? WHERE id = ?", + tr.PlayCount, af.ID, + ); err != nil { + t.Fatalf("seed play count: %v", err) + } + } + + if !tr.SkipSearchIndex { + if err := db.InsertSearchIndex( + af.ID, tr.FilePath, tr.Title, tr.Artist, tr.Album, + ); err != nil { + t.Fatalf("seed search index: %v", err) + } + } + + return af.ID +} + +func nullString(v string) sql.NullString { + if v == "" { + return sql.NullString{} + } + + return sql.NullString{String: v, Valid: true} +} + +func nullInt64(v int64) sql.NullInt64 { + if v == 0 { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: v, Valid: true} +} diff --git a/backend/datamap/datamap.go b/backend/datamap/datamap.go index ca29bdf..3978747 100644 --- a/backend/datamap/datamap.go +++ b/backend/datamap/datamap.go @@ -105,13 +105,10 @@ var internalTables = map[string]bool{ // tables is the catalog. Keep it alphabetical. var tables = []Table{ { - Name: "artist_credit", Kind: Owned, Lifetime: Swept, - Note: "Credit strings parsed from file tags. Orphan-swept when " + - "no recording references them.", - }, - { - Name: "artist_credit_artist", Kind: Owned, Lifetime: Swept, - Note: "Join table between credits and artists.", + Name: "albums", Kind: Owned, Lifetime: Swept, + Note: "Albums as named by file tags — the local counterpart of a " + + "catalog release group, which is a different thing and lives " + + "in explore_index. Swept when the last file on one goes.", }, { Name: "artist_enrichment", Kind: Derived, Lifetime: Retained, @@ -138,11 +135,12 @@ var tables = []Table{ }, { Name: "audio_files", Kind: Owned, Lifetime: Swept, - Note: "MIXED KIND. Mostly an owned projection of files on disk, " + - "but play_count, last_played and tag_status are authored and " + - "exist nowhere else. Deleting a row to rebuild it destroys " + - "that authored state — which is why a file rename currently " + - "loses play counts. See the data architecture plan.", + Note: "MIXED KIND. One row per file, carrying its tags: mostly an " + + "owned projection of what is on disk, but play_count, " + + "last_played and tag_status are authored and exist nowhere " + + "else. Deleting a row to rebuild it destroys that authored " + + "state — which is why a file rename currently loses play " + + "counts. See the data architecture plan.", }, { Name: "cover_art", Kind: Owned, Lifetime: Swept, @@ -209,6 +207,15 @@ var tables = []Table{ "rescan clears it \u2014 the only way back for a path removed by " + "mistake.", }, + { + Name: "file_genres", Kind: Owned, Lifetime: Swept, + Note: "Genres per file. The one many-to-many in the local library " + + "that really is one. It cascades with the *file*, but the " + + "genre_id key is NO ACTION, so a genre cannot be deleted " + + "while a link survives — the genre sweep runs after the file " + + "sweep for that reason, and only deletes genres nothing " + + "references.", + }, { Name: "file_types", Kind: Derived, Lifetime: Retained, Note: "Static lookup rows seeded from code, not user data.", @@ -232,6 +239,15 @@ var tables = []Table{ Note: "The directories the user chose. Removed only by explicit " + "user action via RemoveLibrary.", }, + { + Name: "lyrics", Kind: Cache, Lifetime: Cascade, + Note: "MIXED KIND, and it says which: source='tag' is Owned (any " + + "rescan reads it back off the file) and source='lrclib' is " + + "Cache (re-fetching it is network traffic and someone else's " + + "rate limit). These used to be one untyped column on " + + "recordings, in a table classified Owned, so 24,294 rows in a " + + "real library could not say which of the two they were.", + }, { Name: "lyrics_index", Kind: Derived, Lifetime: Retained, FTS: true, Note: "Full-text index over embedded and fetched lyrics. Rebuilt " + @@ -266,32 +282,11 @@ var tables = []Table{ Note: "Queue entries. Cascade with their track; the queue is " + "compacted afterwards.", }, - { - Name: "recording_genres", Kind: Owned, Lifetime: Swept, - Note: "Join table between recordings and genres.", - }, - { - Name: "recordings", Kind: Owned, Lifetime: Swept, - Note: "Tracks as parsed from file tags. Orphan-swept.", - }, - { - Name: "release_group_recordings", Kind: Owned, Lifetime: Swept, - Note: "Join table between release groups and recordings.", - }, - { - Name: "release_groups", Kind: Owned, Lifetime: Swept, - Note: "Albums as parsed from file tags. Orphan-swept.", - }, { Name: "release_to_rg", Kind: Cache, Lifetime: Retained, - Note: "Release to release-group mapping from the dump.", - }, - { - Name: "schema_migrations", Kind: Derived, Lifetime: Retained, - Note: "Bookkeeping for sql/migrations: which numbered files have " + - "run. Safe to lose — replaying an already-applied migration " + - "tolerates its ALTER TABLE ADD COLUMN as a no-op and just " + - "re-records it.", + Note: "Release to release-group mapping, captured during a local " + + "dump import and read by the incremental listen-count " + + "refresh. Empty unless this install built its own index.", }, { Name: "search_clicks", Kind: Authored, Lifetime: Retained, diff --git a/backend/download/service.go b/backend/download/service.go index 18752aa..12f069d 100644 --- a/backend/download/service.go +++ b/backend/download/service.go @@ -486,6 +486,8 @@ func (s *Service) ClearFinished() error { // SetReconciler wires the request-list loop. Optional: without it the // request list still stores and lists requests, it just never acts on // them. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (s *Service) SetReconciler(r *Reconciler) { s.reconciler = r } diff --git a/backend/explore/artifactimport.go b/backend/explore/artifactimport.go index 161b524..8a5d854 100644 --- a/backend/explore/artifactimport.go +++ b/backend/explore/artifactimport.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" _ "modernc.org/sqlite" // SQLite driver for reading the artifact file. @@ -91,9 +92,90 @@ type artifactInfo struct { // TestArtifactColumnsMatchExporter. const artifactCatalogColumns = `entity_type, mbid, title, artist_name, artist_mbid, aliases, popularity, listener_count, duration, caa_release_mbid, - release_name, primary_type, secondary_types, release_date, + release_name, primary_type, secondary_types, release_date, total_tracks, artist_type, country, disambiguation, sort_name, discog_fetched` +// artifactSelectColumns is the same list as read *from an artifact*, +// converting the two columns whose storage this app changed. +// +// The local table stores an MBID as 16 raw bytes and an entity type as a +// small integer, which took the catalog and its indexes from 677 MB to +// 389 MB. A published artifact still carries the text form, and there +// is no reason it should not: converting on the way in costs one +// `unhex` per row on a once-a-month import, and it means a new build +// reads the artifact that is already out there rather than requiring +// one to be rebuilt and re-downloaded first. +// +// An artifact that already carries the compact form is copied straight +// through - `artifactStoresText` decides which, by asking the artifact +// rather than by trusting a version number. +func artifactSelectColumns(text, totals bool) string { + totalTracks := "total_tracks" + if !totals { + // An artifact built before the column existed. Zero is what the + // column means by "the catalog does not say", so an older + // artifact imports as one that declines to answer rather than + // failing to import at all. + totalTracks = "0" + } + + if !text { + return strings.Replace( + artifactCatalogColumns, "total_tracks", totalTracks, 1, + ) + } + + return `CASE entity_type + WHEN 'artist' THEN 1 + WHEN 'release_group' THEN 2 + WHEN 'recording' THEN 3 + ELSE 0 END, + unhex(replace(mbid, '-', '')), + title, artist_name, + CASE WHEN artist_mbid = '' THEN x'' + ELSE unhex(replace(artist_mbid, '-', '')) END, + aliases, popularity, listener_count, duration, + CASE WHEN caa_release_mbid = '' THEN x'' + ELSE unhex(replace(caa_release_mbid, '-', '')) END, + release_name, primary_type, secondary_types, release_date, ` + + totalTracks + `, + artist_type, country, disambiguation, sort_name, discog_fetched` +} + +// artifactHasTotals reports whether the attached artifact carries the +// per-release-group track denominator. An artifact published before +// that column existed is still a perfectly good catalog, so it is asked +// rather than assumed - the same rule, and the same handle, as +// artifactStoresText below. +func (si *SearchIndex) artifactHasTotals() bool { + var n int + + err := si.db.QueryRowWriter( + `SELECT COUNT(*) FROM pragma_table_info('explore_index', 'core') + WHERE name = 'total_tracks'`, + ).Scan(&n) + + return err == nil && n > 0 +} + +// artifactStoresText reports whether the attached artifact carries the +// old text encoding. +func (si *SearchIndex) artifactStoresText() bool { + // The writer, not QueryContext: "core" is attached to that one + // connection and does not exist on the read pool. Asking the wrong + // handle errors, and the fallback would then convert an artifact + // that needs no conversion. + var kind string + + if err := si.db.QueryRowWriter( + "SELECT typeof(mbid) FROM core.explore_index LIMIT 1", + ).Scan(&kind); err != nil { + return true + } + + return kind == "text" +} + // inspectArtifact opens the artifact read-only and reports what it // declares, without touching the live index. Validation happens here so // a bad download is rejected before anything is attached. @@ -263,9 +345,13 @@ func (si *SearchIndex) analyzeIndex() { // is an index range scan and a cancelled import leaves committed work // behind rather than rolling it all back. func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, error) { + selectColumns := artifactSelectColumns( + si.artifactStoresText(), si.artifactHasTotals(), + ) + insertSQL := ` INSERT INTO explore_index (` + artifactCatalogColumns + `) - SELECT ` + artifactCatalogColumns + ` + SELECT ` + selectColumns + ` FROM core.explore_index WHERE mbid > ?` + upsertIndexConflictSQL @@ -273,7 +359,7 @@ func (si *SearchIndex) mergeArtifactRows(ctx context.Context, total int) (int, e // appended only while one exists. insertRangeSQL := ` INSERT INTO explore_index (` + artifactCatalogColumns + `) - SELECT ` + artifactCatalogColumns + ` + SELECT ` + selectColumns + ` FROM core.explore_index WHERE mbid > ? AND mbid <= ?` + upsertIndexConflictSQL diff --git a/backend/explore/artifactimport_test.go b/backend/explore/artifactimport_test.go index 94cdd4f..09564ad 100644 --- a/backend/explore/artifactimport_test.go +++ b/backend/explore/artifactimport_test.go @@ -202,7 +202,7 @@ func TestImportCoreArtifactPreservesLocalData(t *testing.T) { if err := db.QueryRowWriter(` SELECT popularity, duration, in_library, discog_fetched - FROM explore_index WHERE mbid = ?`, recA, + FROM explore_index WHERE mbid = ?`, dbMBID(recA), ).Scan(&popularity, &duration, &inLibrary, &discogFetched); err != nil { t.Fatalf("read merged row: %v", err) } @@ -376,7 +376,7 @@ func TestAddFromCacheNeverStoresMBIDAsName(t *testing.T) { var artistName string if err := db.QueryRowWriter( - `SELECT artist_name FROM explore_index WHERE mbid = ?`, rgA, + `SELECT artist_name FROM explore_index WHERE mbid = ?`, dbMBID(rgA), ).Scan(&artistName); err != nil { t.Fatalf("read release group: %v", err) } @@ -391,7 +391,7 @@ func TestAddFromCacheNeverStoresMBIDAsName(t *testing.T) { }) if err := db.QueryRowWriter( - `SELECT artist_name FROM explore_index WHERE mbid = ?`, rgA, + `SELECT artist_name FROM explore_index WHERE mbid = ?`, dbMBID(rgA), ).Scan(&artistName); err != nil { t.Fatalf("re-read release group: %v", err) } @@ -442,3 +442,158 @@ func TestArtifactColumnsMatchExporter(t *testing.T) { exporter, importer) } } + +// TestImportCoreArtifactAcceptsBothEncodings is the compatibility half +// of the storage change. +// +// The catalog stores an MBID as 16 raw bytes and an entity type as a +// code, which took the table and its indexes from 677 MB to 389 MB. A +// published artifact carries whichever form the exporter that built it +// used, and there is one already out there in the older text form — so +// the importer decides by asking the artifact, not by trusting a +// version number, and both must land identically. +func TestImportCoreArtifactAcceptsBothEncodings(t *testing.T) { + compact := filepath.Join(t.TempDir(), "core-index.db") + + db, err := sql.Open("sqlite", "file:"+compact) + if err != nil { + t.Fatalf("open artifact: %v", err) + } + + if _, err := db.Exec(`CREATE TABLE explore_index ( + entity_type INTEGER NOT NULL, + mbid BLOB NOT NULL, + title TEXT NOT NULL, + artist_name TEXT NOT NULL, + artist_mbid BLOB NOT NULL, + aliases TEXT NOT NULL DEFAULT '', + popularity INTEGER NOT NULL DEFAULT 0, + listener_count INTEGER NOT NULL DEFAULT 0, + duration INTEGER NOT NULL DEFAULT 0, + caa_release_mbid BLOB NOT NULL DEFAULT x'', + release_name TEXT NOT NULL DEFAULT '', + primary_type TEXT NOT NULL DEFAULT '', + secondary_types TEXT NOT NULL DEFAULT '', + release_date TEXT NOT NULL DEFAULT '', + artist_type TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '', + disambiguation TEXT NOT NULL DEFAULT '', + sort_name TEXT NOT NULL DEFAULT '', + discog_fetched INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (mbid) + )`); err != nil { + t.Fatalf("create artifact table: %v", err) + } + + if _, err := db.Exec( + `CREATE TABLE artifact_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, + ); err != nil { + t.Fatalf("create artifact meta: %v", err) + } + + for k, v := range validMeta() { + if _, err := db.Exec( + "INSERT INTO artifact_meta (key, value) VALUES (?, ?)", k, v, + ); err != nil { + t.Fatalf("write artifact meta: %v", err) + } + } + + if _, err := db.Exec(` + INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity) + VALUES (1, ?, 'Artist A', 'Artist A', ?, 5000)`, + mbidBytes(artA), mbidBytes(artA), + ); err != nil { + t.Fatalf("write artifact row: %v", err) + } + + _ = db.Close() + + live := database.NewTestDB(t) + si := NewSearchIndex(live, nil, nil, testLogger()) + + if err := si.importCoreArtifact(context.Background(), compact); err != nil { + t.Fatalf("importCoreArtifact (compact): %v", err) + } + + got := si.LookupArtistByMBID(artA) + if got == nil { + t.Fatal("artist from a compact artifact was not imported") + } + + if got.Title != "Artist A" || got.MBID != artA { + t.Errorf("imported %+v, want Artist A / %s", got, artA) + } +} + +// TestImportCoreArtifactReadsTotalsWhenPresent covers both halves of the +// denominator's arrival: an artifact that carries total_tracks imports +// it, and one built before the column existed still imports at all. +// +// The second half is the one worth a test. Adding a column to the +// importer's SELECT list is how you break every artifact already +// published - "no such column: total_tracks", on a file nobody can +// re-cut retroactively - so the importer asks the artifact what it has, +// the same way it asks which encoding it uses. +func TestImportCoreArtifactReadsTotalsWhenPresent(t *testing.T) { + path := writeTestArtifact(t, validMeta(), []artifactRow{ + {"release_group", rgA, "Big Album", "Solo Star", artA, 5000}, + }) + + // The exporter writes the column; writeTestArtifact builds the older + // shape, so add it here rather than changing every other test's + // fixture to carry a value they do not use. + artifact, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatalf("reopen artifact: %v", err) + } + + for _, stmt := range []string{ + `ALTER TABLE explore_index ADD COLUMN total_tracks INTEGER NOT NULL DEFAULT 0`, + `UPDATE explore_index SET total_tracks = 12`, + } { + if _, err := artifact.Exec(stmt); err != nil { + t.Fatalf("add total_tracks: %v", err) + } + } + + _ = artifact.Close() + + live := database.NewTestDB(t) + si := NewSearchIndex(live, nil, nil, testLogger()) + + if err := si.importCoreArtifact(context.Background(), path); err != nil { + t.Fatalf("importCoreArtifact: %v", err) + } + + rg := si.LookupReleaseGroupByMBID(rgA) + if rg == nil { + t.Fatal("release group was not imported") + } + + if rg.TotalTracks != 12 { + t.Errorf("TotalTracks = %d, want 12", rg.TotalTracks) + } + + // And the shape that predates the column: the same import, from an + // artifact that has no total_tracks at all. + older := writeTestArtifact(t, validMeta(), []artifactRow{ + {"release_group", rgB, "Duet Album", "Solo Star", artA, 4000}, + }) + + live2 := database.NewTestDB(t) + si2 := NewSearchIndex(live2, nil, nil, testLogger()) + + if err := si2.importCoreArtifact(context.Background(), older); err != nil { + t.Fatalf("importCoreArtifact (no total_tracks column): %v", err) + } + + old := si2.LookupReleaseGroupByMBID(rgB) + if old == nil { + t.Fatal("release group from a column-less artifact was not imported") + } + + if old.TotalTracks != 0 { + t.Errorf("TotalTracks = %d, want 0 (the catalog does not say)", old.TotalTracks) + } +} diff --git a/backend/explore/artistenrichment_test.go b/backend/explore/artistenrichment_test.go index b282353..8572efb 100644 --- a/backend/explore/artistenrichment_test.go +++ b/backend/explore/artistenrichment_test.go @@ -12,19 +12,15 @@ import ( func seedIndexArtist(t *testing.T, db *database.DB, mbid string, discogFetched int) { t.Helper() - if _, err := db.ExecContext( - upsertIndexSQL, - "artist", mbid, "Seeded Artist", "Seeded Artist", mbid, "", - 0, 0, - 0, "", "", - "", "", "", - "", "", "", "", - 1, 0, - 0, 0, 0, - discogFetched, - ); err != nil { - t.Fatalf("seed explore_index row for %q: %v", mbid, err) - } + seedIndexResult(t, db, SearchIndexResult{ + EntityType: EntityArtist, + MBID: testMBID(mbid), + Title: "Seeded Artist", + ArtistName: "Seeded Artist", + ArtistMBID: testMBID(mbid), + InLibrary: true, + DiscogFetched: discogFetched == 1, + }) } // TestArtistEnrichmentMarksAreIndependent is the reason these are two diff --git a/backend/explore/discogmark_test.go b/backend/explore/discogmark_test.go new file mode 100644 index 0000000..5e932c9 --- /dev/null +++ b/backend/explore/discogmark_test.go @@ -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") + } + }) +} diff --git a/backend/explore/dumpcatalog.go b/backend/explore/dumpcatalog.go index a6fb1df..6309b7b 100644 --- a/backend/explore/dumpcatalog.go +++ b/backend/explore/dumpcatalog.go @@ -47,6 +47,13 @@ const ( // canonicalProgressRows controls progress reporting during the // canonical CSV scan (~30M rows total). canonicalProgressRows = 2_000_000 + + // maxReleaseTracks caps the per-release track count, so a malformed + // row cannot turn the denominator into nonsense. Well above the + // longest real release, and it is a cap rather than a rejection + // because a box set reporting 999 is still a better answer than one + // reporting nothing. + maxReleaseTracks = 999 ) // Per-artist discography coverage (S2). The global budgets above keep @@ -329,6 +336,18 @@ type canonicalScan struct { releaseToRG map[uuid16]rgTarget artistNames map[uuid16]string + // releaseTracks counts the canonical dump's rows per kept release, + // which is that release's track count: the dump carries one row per + // recording per canonical release. + // + // It is counted *before* the popularity filter below, unlike almost + // everything else here, because a denominator built from the kept + // recordings would say "9" about a twelve-track album whose other + // three are unpopular - which is worse than saying nothing, and is + // exactly the confident lie that kept whole tracklists out of the + // artifact. Bounded by the kept release set, not by MusicBrainz. + releaseTracks map[uuid16]uint16 + // artistTracks accumulates each target artist's top recordings for // S2 coverage; merged into recordings before assembly. artistTracks *perArtistTracks @@ -454,10 +473,11 @@ func (imp *dumpImporter) scanCanonicalDump( defer zr.Close() scan := &canonicalScan{ - releaseInfos: make(map[uuid16]releaseInfo, len(ks.releases)), - releaseToRG: make(map[uuid16]rgTarget, len(ks.releases)), - artistNames: make(map[uuid16]string, len(ks.artists)), - artistTracks: newPerArtistTracks(), + releaseInfos: make(map[uuid16]releaseInfo, len(ks.releases)), + releaseToRG: make(map[uuid16]rgTarget, len(ks.releases)), + artistNames: make(map[uuid16]string, len(ks.artists)), + releaseTracks: make(map[uuid16]uint16, len(ks.releases)), + artistTracks: newPerArtistTracks(), } sawData, sawRedirect := false, false @@ -614,9 +634,17 @@ func (imp *dumpImporter) scanCanonicalData( } } - // Release display info for release-group titling. + // Release display info for release-group titling, and the + // release's track count. Both are for kept releases only, and + // the count is taken here rather than below because every row + // of this dump is one track of its release regardless of how + // often anyone played it. if relOK { if _, kept := ks.releases[relMBID]; kept { + if n := scan.releaseTracks[relMBID]; n < maxReleaseTracks { + scan.releaseTracks[relMBID] = n + 1 + } + if _, seen := scan.releaseInfos[relMBID]; !seen { firstArtist := "" if len(artistMBIDs) > 0 { @@ -1004,6 +1032,7 @@ func (imp *dumpImporter) assembleIndex( ArtistName: info.artistName, ArtistMBID: info.artistMBID, Popularity: int(agg.listens), + TotalTracks: int(scan.releaseTracks[agg.bestRel]), CAAReleaseMBID: formatUUID(agg.canonical[:]), }) diff --git a/backend/explore/dumpimport_test.go b/backend/explore/dumpimport_test.go index c35a498..cbb9321 100644 --- a/backend/explore/dumpimport_test.go +++ b/backend/explore/dumpimport_test.go @@ -277,6 +277,12 @@ func canonicalDataCSV(t *testing.T) []byte { "3", "11", "{" + artA + "," + artB + "}", "Solo Star feat. Guest", relB, "Duet Album", recC, "Duet Song", "x", "1", }, + // Unplayed, so it survives no popularity floor and is not + // indexed -- but it is still a track on Big Album. + { + "4", "10", "{" + artA + "}", "Solo Star", + relA, "Big Album", recD, "Album Filler", "x", "1", + }, } return csvBytes(t, rows) @@ -594,7 +600,7 @@ func TestDumpImportEndToEnd(t *testing.T) { rows, err := db.QueryContext( "SELECT title, popularity FROM explore_index WHERE mbid = ? AND entity_type = ?", - mbid, entityType, + dbMBID(mbid), dbEntityType(entityType), ) if err != nil { t.Fatalf("query: %v", err) @@ -627,11 +633,51 @@ func TestDumpImportEndToEnd(t *testing.T) { assertRow(rgB, "release_group", "Duet Album", 12) assertRow(artA, "artist", "Solo Star", 35) + // The per-release-group denominator: how many tracks the release + // has, counted from the canonical dump *before* the popularity + // filter. Big Album has three, one of which nobody has played and + // which is therefore not indexed as a recording at all -- a + // denominator built from the kept recordings would say two, and + // "you have 2 of 2" about a three-track album is worse than saying + // nothing. + assertTotalTracks := func(mbid string, want int) { + t.Helper() + + var got int + + if err := db.QueryRowWriter( + "SELECT total_tracks FROM explore_index WHERE mbid = ? AND entity_type = 2", + dbMBID(mbid), + ).Scan(&got); err != nil { + t.Fatalf("read total_tracks for %s: %v", mbid, err) + } + + if got != want { + t.Errorf("total_tracks for %s = %d, want %d", mbid, got, want) + } + } + + assertTotalTracks(rgA, 3) + assertTotalTracks(rgB, 1) + + // And the unplayed track is still not an indexed recording. + var fillerRows int + + if err := db.QueryRowWriter( + "SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(recD), + ).Scan(&fillerRows); err != nil { + t.Fatalf("count recD rows: %v", err) + } + + if fillerRows != 0 { + t.Errorf("unplayed track was indexed as a recording (%d rows)", fillerRows) + } + // artB only ever appears in a multi-artist credit: no name is // derivable from the dump, so it must be queued for the API // metadata patch instead of being written nameless. rows, err := db.QueryContext( - "SELECT COUNT(*) FROM explore_index WHERE mbid = ?", artB, + "SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(artB), ) if err != nil { t.Fatalf("query artB: %v", err) @@ -1006,7 +1052,7 @@ func TestListenerCountUpdateDoesNotTouchPopularity(t *testing.T) { } rows, err := db.QueryContext( - "SELECT popularity, listener_count FROM explore_index WHERE mbid = ?", recA, + "SELECT popularity, listener_count FROM explore_index WHERE mbid = ?", dbMBID(recA), ) if err != nil { t.Fatalf("query: %v", err) diff --git a/backend/explore/dumpincremental.go b/backend/explore/dumpincremental.go index 651d3a9..b72130b 100644 --- a/backend/explore/dumpincremental.go +++ b/backend/explore/dumpincremental.go @@ -277,7 +277,10 @@ func (si *SearchIndex) commitListenDeltas( defer func() { _ = tx.Rollback() }() if _, err := tx.Exec( - "CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid TEXT, kind TEXT, delta INTEGER)", + // mbid and kind are stored the way explore_index stores them, + // so the join below is a plain equality rather than a + // conversion per row. + "CREATE TEMP TABLE IF NOT EXISTS incr_delta (mbid BLOB, kind INTEGER, delta INTEGER)", ); err != nil { return fmt.Errorf("incremental temp table: %w", err) } @@ -357,8 +360,10 @@ func insertDeltas(tx *sql.Tx, kind string, deltas map[string]uint32) error { return nil } + code := entityCode(kind) + for mbid, d := range deltas { - rowArgs = append(rowArgs, mbid, kind, int64(d)) + rowArgs = append(rowArgs, mbidBytes(mbid), code, int64(d)) pending++ if pending >= deltaInsertBatch { diff --git a/backend/explore/dumpincremental_test.go b/backend/explore/dumpincremental_test.go index ed298a4..2e3dc44 100644 --- a/backend/explore/dumpincremental_test.go +++ b/backend/explore/dumpincremental_test.go @@ -36,7 +36,7 @@ func popularityOf(t *testing.T, db *database.DB, mbid string) (int, bool) { t.Helper() rows, err := db.QueryContext( - "SELECT popularity FROM explore_index WHERE mbid = ?", mbid, + "SELECT popularity FROM explore_index WHERE mbid = ?", dbMBID(mbid), ) if err != nil { t.Fatalf("query popularity: %v", err) diff --git a/backend/explore/dumppatch.go b/backend/explore/dumppatch.go index 5027fbb..e0489d2 100644 --- a/backend/explore/dumppatch.go +++ b/backend/explore/dumppatch.go @@ -54,7 +54,7 @@ func (imp *dumpImporter) runPatchPasses(ctx context.Context) { func (imp *dumpImporter) patchArtistMetadata(ctx context.Context) { rows, err := imp.si.db.QueryContext(` SELECT mbid FROM explore_index - WHERE entity_type = 'artist' + WHERE entity_type = 1 /* artist */ AND (artist_type = '' OR country = '' OR title = '' OR title = mbid) `) if err != nil { @@ -152,7 +152,7 @@ func (imp *dumpImporter) patchSimilarArtists(ctx context.Context) { for _, s := range similar { _, _ = imp.si.db.ExecContext( "UPDATE explore_index SET is_similar = 1 WHERE artist_mbid = ?", - s.ArtistMBID, + dbMBID(s.ArtistMBID), ) } } @@ -219,7 +219,7 @@ func (imp *dumpImporter) topMBIDs(entityType string, limit int) []string { WHERE entity_type = ? AND listener_count = 0 ORDER BY popularity DESC LIMIT ? - `, entityType, limit) + `, dbEntityType(entityType), limit) if err != nil { return nil } @@ -229,9 +229,9 @@ func (imp *dumpImporter) topMBIDs(entityType string, limit int) []string { var mbids []string for rows.Next() { - var m string + var m dbMBID if err := rows.Scan(&m); err == nil { - mbids = append(mbids, m) + mbids = append(mbids, string(m)) } } @@ -264,7 +264,7 @@ func (si *SearchIndex) updateListenerCounts(updates map[string]PopularityData) i `UPDATE explore_index SET listener_count = ? WHERE mbid = ? AND listener_count < ?`, - data.ListenerCount, strings.ToLower(mbid), data.ListenerCount, + data.ListenerCount, dbMBID(strings.ToLower(mbid)), data.ListenerCount, ) if err != nil { continue diff --git a/backend/explore/encoding_test.go b/backend/explore/encoding_test.go new file mode 100644 index 0000000..242de3b --- /dev/null +++ b/backend/explore/encoding_test.go @@ -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) + } +} diff --git a/backend/explore/eval_harness_test.go b/backend/explore/eval_harness_test.go index 60dee32..7135216 100644 --- a/backend/explore/eval_harness_test.go +++ b/backend/explore/eval_harness_test.go @@ -22,8 +22,8 @@ func seedIndexRow( _, err := db.ExecContext(` INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count) - VALUES (?, ?, ?, ?, '', ?, ?) - `, entityType, mbid, title, artist, popularity, popularity/10) + VALUES (?, ?, ?, ?, x'', ?, ?) + `, dbEntityType(entityType), dbMBID(testMBID(mbid)), title, artist, popularity, popularity/10) if err != nil { t.Fatalf("seed %s/%s: %v", entityType, mbid, err) } @@ -73,12 +73,12 @@ func TestEvalHarnessIndexRanking(t *testing.T) { { Query: "radiohead", Note: "popular exact artist match", - Expect: []eval.Expected{{Type: "artist", MBID: "rh"}}, + Expect: []eval.Expected{{Type: "artist", MBID: testMBID("rh")}}, }, { Query: "the teenagers", Note: "low-popularity exact match must beat high-popularity article match", - Expect: []eval.Expected{{Type: "artist", MBID: "teenagers"}}, + Expect: []eval.Expected{{Type: "artist", MBID: testMBID("teenagers")}}, }, } @@ -123,8 +123,13 @@ func TestExploreFTSDiacriticFolding(t *testing.T) { t.Fatalf("query %q returned no hits", tc.query) } - if hits[0].MBID != tc.wantMBID { - t.Errorf("query %q: top hit = %q, want %q", tc.query, hits[0].MBID, tc.wantMBID) + if hits[0].MBID != testMBID(tc.wantMBID) { + t.Errorf( + "query %q: top hit = %q, want %q", + tc.query, + hits[0].MBID, + testMBID(tc.wantMBID), + ) } }) } @@ -148,3 +153,30 @@ func TestEvalFixtureFileParses(t *testing.T) { } } } + +// seedIndexResult writes one explore_index row through the same +// upsertBatch every other writer uses. +// +// The three seeders that used to bind upsertIndexSQL's parameters by +// hand said, in a comment, that this was on purpose: a schema change +// should break the tests where it breaks the app. It did not - it +// broke them at "missing argument with index 25", one file at a time, +// for a column none of them cares about. Going through the one writer +// keeps the property they wanted (a field written to the wrong column +// still fails here) without three copies of a 24-argument list. +func seedIndexResult(t *testing.T, db *database.DB, r SearchIndexResult) { + t.Helper() + + NewSearchIndex(db, nil, nil, slog.Default()). + upsertBatch([]SearchIndexResult{r}) + + // upsertBatch logs and swallows, which is right for a background + // merge and useless for a fixture, so the row is checked for. + var n int + + if err := db.QueryRowWriter( + "SELECT COUNT(*) FROM explore_index WHERE mbid = ?", dbMBID(r.MBID), + ).Scan(&n); err != nil || n == 0 { + t.Fatalf("seed explore_index row %q: not written (%v)", r.MBID, err) + } +} diff --git a/backend/explore/explore.go b/backend/explore/explore.go index 56b229f..544f181 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -130,12 +130,16 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { // MusicBrainz returns the shared cached MB client so other services // (e.g. autotag) can reuse it without spinning up a second limiter. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (e *Service) MusicBrainz() *MusicBrainzClient { return e.mb } // CAALimiter returns the shared Cover Art Archive rate limiter. // Consumers must respect it for any fresh CAA HTTP GETs. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (e *Service) CAALimiter() *RateLimiter { return e.caaLimiter } @@ -185,6 +189,8 @@ func (e *Service) CoreCatalogImported() bool { // SetJobRegistry wires the background job registry into the search // index so its build reports progress and controls to the frontend. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (e *Service) SetJobRegistry(reg *jobs.Registry) { e.index.SetJobRegistry(reg) } @@ -561,6 +567,7 @@ func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) { FirstReleaseDate: indexed.ReleaseDate, InLibrary: indexed.InLibrary || indexed.LocalReleaseGroupID > 0, LocalID: indexed.LocalReleaseGroupID, + TotalTracks: indexed.TotalTracks, } // Background: fetch full MB data once per RG to fill in fields diff --git a/backend/explore/librarymbid.go b/backend/explore/librarymbid.go index dc23518..380f6cd 100644 --- a/backend/explore/librarymbid.go +++ b/backend/explore/librarymbid.go @@ -18,9 +18,22 @@ func NewLibraryMBIDIndex(db *database.DB) *LibraryMBIDIndex { return &LibraryMBIDIndex{db: db} } -// CheckMBIDs returns which of the given MBIDs exist in the local -// library. The returned map has MBID → entity type ("artist", +// CheckMBIDs returns which of the given MBIDs the library actually +// has a file for. The returned map is MBID -> entity type ("artist", // "release_group", or "recording"). +// +// The "has a file" part is the whole point and is what this used to get +// wrong. It was three `SELECT mbid FROM ` queries, and +// a metadata row could outlive the file that created it - retagging a +// file abandoned its old recording row, which kept the old MBID +// forever. Measured on a real library: 812 orphaned recordings, of +// which 218 carried MBIDs, and 129 catalog rows that this function +// therefore reported as owned. Every one of them rendered as a track +// you have, with a play button that could not work, because playback +// resolves files and this resolved metadata. +// +// Each branch now joins audio_files. An entity is in your library if +// and only if a file says so. func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { if len(mbids) == 0 { return nil @@ -28,16 +41,26 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { result := make(map[string]string, len(mbids)) - // Batch check all MBIDs against each table with a single IN query. - type tableEntity struct { - table string + type entityQuery struct { entityType string + query string } - tables := []tableEntity{ - {"artists", "artist"}, - {"release_groups", "release_group"}, - {"recordings", "recording"}, + queries := []entityQuery{ + {"recording", `SELECT DISTINCT recording_mbid FROM audio_files + WHERE recording_mbid IN (%s)`}, + {"release_group", `SELECT DISTINCT al.mbid FROM albums al + JOIN audio_files af ON af.album_id = al.id + WHERE al.mbid IN (%s)`}, + {"artist", `SELECT DISTINCT a.mbid FROM artists a + WHERE a.mbid IN (%s) AND ( + EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id) + OR EXISTS ( + SELECT 1 FROM albums al + JOIN audio_files af2 ON af2.album_id = al.id + WHERE al.artist_id = a.id + ) + )`}, } // Build a set of MBIDs still unresolved. @@ -48,12 +71,11 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { } } - for _, te := range tables { + for _, eq := range queries { if len(remaining) == 0 { break } - // Build IN clause from remaining MBIDs. placeholders := make([]string, 0, len(remaining)) args := make([]any, 0, len(remaining)) @@ -62,9 +84,10 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { args = append(args, m) } - //nolint:gosec // table name is hardcoded from the tables slice above - query := "SELECT mbid FROM " + te.table + " WHERE mbid IN (" + - strings.Join(placeholders, ",") + ")" + //nolint:gosec // the query text is a constant from the slice above + query := strings.Replace( + eq.query, "%s", strings.Join(placeholders, ","), 1, + ) rows, err := idx.db.QueryContext(query, args...) if err != nil { @@ -74,7 +97,7 @@ func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string { for rows.Next() { var mbid string if err := rows.Scan(&mbid); err == nil { - result[mbid] = te.entityType + result[mbid] = eq.entityType delete(remaining, mbid) } } diff --git a/backend/explore/listenbrainz.go b/backend/explore/listenbrainz.go index fcadd41..725d47b 100644 --- a/backend/explore/listenbrainz.go +++ b/backend/explore/listenbrainz.go @@ -33,6 +33,12 @@ type ListenBrainzClient struct { limiter *RateLimiter cache *Cache logger *slog.Logger + + // baseURL is listenBrainzBaseURL unless a test redirects it. It is + // per-client rather than a package variable — the MB client's + // SetBaseURL shape — so a test that points one client at an + // httptest server does not stop being parallel-safe. + baseURL string } // NewListenBrainzClient creates a ListenBrainz API client. @@ -46,9 +52,15 @@ func NewListenBrainzClient( limiter: limiter, cache: cache, logger: logger, + baseURL: listenBrainzBaseURL, } } +// SetBaseURL redirects this client at another host. Tests only. +func (c *ListenBrainzClient) SetBaseURL(url string) { + c.baseURL = strings.TrimSuffix(url, "/") +} + // TopRecordingsForArtist returns the most-listened recordings for // the artist identified by artistMBID. func (c *ListenBrainzClient) TopRecordingsForArtist( @@ -56,7 +68,7 @@ func (c *ListenBrainzClient) TopRecordingsForArtist( ) ([]LBTopRecording, error) { url := fmt.Sprintf( "%s/1/popularity/top-recordings-for-artist/%s", - listenBrainzBaseURL, + c.baseURL, artistMBID, ) cacheKey := "lb:top-recordings:" + artistMBID @@ -104,7 +116,7 @@ func (c *ListenBrainzClient) TopReleaseGroupsForArtist( ) ([]LBTopReleaseGroup, error) { url := fmt.Sprintf( "%s/1/popularity/top-release-groups-for-artist/%s", - listenBrainzBaseURL, + c.baseURL, artistMBID, ) cacheKey := "lb:top-release-groups:" + artistMBID @@ -234,7 +246,7 @@ func (c *ListenBrainzClient) ArtistPopularity( return nil, nil //nolint:nilnil } - url := listenBrainzBaseURL + "/1/popularity/artist" + url := c.baseURL + "/1/popularity/artist" cacheKey := "lb:pop:artist:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { @@ -265,7 +277,7 @@ func (c *ListenBrainzClient) RecordingPopularity( return nil, nil //nolint:nilnil } - url := listenBrainzBaseURL + "/1/popularity/recording" + url := c.baseURL + "/1/popularity/recording" cacheKey := "lb:pop:recording:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { @@ -296,7 +308,7 @@ func (c *ListenBrainzClient) ReleaseGroupPopularity( return nil, nil //nolint:nilnil } - url := listenBrainzBaseURL + "/1/popularity/release-group" + url := c.baseURL + "/1/popularity/release-group" cacheKey := "lb:pop:release-group:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { @@ -342,7 +354,7 @@ func (c *ListenBrainzClient) BatchArtistMetadata( return nil, nil //nolint:nilnil } - url := listenBrainzBaseURL + "/1/metadata/artist/?artist_mbids=" + strings.Join(mbids, ",") + url := c.baseURL + "/1/metadata/artist/?artist_mbids=" + strings.Join(mbids, ",") cacheKey := "lb:meta:artist:" + hashMBIDs(mbids) if data, ok := c.cache.Get(cacheKey); ok { diff --git a/backend/explore/lyrics.go b/backend/explore/lyrics.go index 2b233b1..274beed 100644 --- a/backend/explore/lyrics.go +++ b/backend/explore/lyrics.go @@ -10,7 +10,7 @@ import ( // LyricsResult is a single lyric-search hit, mapped from the DB layer // into the camelCase shape the frontend consumes. type LyricsResult struct { - RecordingID int64 `json:"recordingId"` + AudioFileID int64 `json:"audioFileId"` FilePath string `json:"filePath"` LengthMs int64 `json:"lengthMs"` Title string `json:"title"` @@ -55,7 +55,7 @@ func (e *Service) SearchLyrics(query string) []LyricsResult { out := make([]LyricsResult, 0, len(hits)) for _, h := range hits { out = append(out, LyricsResult{ - RecordingID: h.RecordingID, + AudioFileID: h.AudioFileID, FilePath: h.FilePath, LengthMs: h.LengthMilliseconds, Title: h.Title, @@ -67,18 +67,18 @@ func (e *Service) SearchLyrics(query string) []LyricsResult { return out } -// GetTrackLyrics returns lyrics for a recording. If the library +// GetTrackLyrics returns lyrics for a file. If the library // already has them (from embedded tags) they're returned as-is; // otherwise it fetches from LRCLIB, persists them (updating the FTS // index), and returns them. Never returns an error to the frontend — // a miss just yields an empty result. -func (e *Service) GetTrackLyrics(recordingID int64) TrackLyrics { - stored, err := e.db.GetRecordingLyrics(recordingID) +func (e *Service) GetTrackLyrics(audioFileID int64) TrackLyrics { + stored, err := e.db.GetLyrics(audioFileID) if err == nil && stored != "" { return TrackLyrics{Plain: stored, Source: "embedded"} } - lookup, err := e.db.RecordingLyricLookup(recordingID) + lookup, err := e.db.FileLyricLookup(audioFileID) if err != nil || lookup == nil { return TrackLyrics{} } @@ -150,7 +150,7 @@ func (e *Service) backfillLibraryLyrics(ctx context.Context) { return } - candidates, err := e.db.RecordingsMissingLyrics(lyricsBackfillBatch) + candidates, err := e.db.FilesMissingLyrics(lyricsBackfillBatch) if err != nil { e.logger.Warn("lyrics backfill: query failed", "err", err) @@ -223,8 +223,12 @@ func (e *Service) fetchAndStoreLyrics( return nil } - if err := e.db.SetRecordingLyrics(c.RecordingID, lyrics.Plain); err != nil { - e.logger.Warn("lyrics store failed", "recordingId", c.RecordingID, "err", err) + // Marked `lrclib` rather than `tag`: these came off the network and + // a rebuild that discards them pays for them again. + if err := e.db.SetLyrics( + c.AudioFileID, lyrics.Plain, "lrclib", c.RecordingMBID, + ); err != nil { + e.logger.Warn("lyrics store failed", "audioFileId", c.AudioFileID, "err", err) return nil } diff --git a/backend/explore/mbid.go b/backend/explore/mbid.go new file mode 100644 index 0000000..6d81ea2 --- /dev/null +++ b/backend/explore/mbid.go @@ -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. diff --git a/backend/explore/mbid_test.go b/backend/explore/mbid_test.go new file mode 100644 index 0000000..e09025a --- /dev/null +++ b/backend/explore/mbid_test.go @@ -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") + } +} diff --git a/backend/explore/mix.go b/backend/explore/mix.go index de9cb9d..7583223 100644 --- a/backend/explore/mix.go +++ b/backend/explore/mix.go @@ -107,12 +107,11 @@ func (e *Service) mixSeedProfile( artistCounts[artist.ArtistMbid]++ artistNames[artist.ArtistMbid] = artist.ArtistName } + } - names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p) - if err == nil { - for _, g := range names { - genres[g] = true - } + for _, names := range e.genresByPath(ctx, seedPaths) { + for _, g := range names { + genres[g] = true } } @@ -148,6 +147,12 @@ func (e *Service) mixCandidates( candidates := map[string]float64{} + // Every candidate path's genres, in one query. This used to be a + // single-row lookup *per candidate* inside two nested loops - + // twenty seed artists by twenty similar artists by thirty paths is + // twelve thousand queries to assemble one mix. + pathGenres := e.genresByPath(ctx, e.similarArtistPaths(ctx, artistCounts)) + for seedArtistMBID, count := range artistCounts { similar, err := e.SimilarArtists(seedArtistMBID) if err != nil { @@ -178,13 +183,11 @@ func (e *Service) mixCandidates( continue } - if names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p); err == nil { - for _, g := range names { - if seedGenres[g] { - weight += mixGenreBoost + for _, g := range pathGenres[p] { + if seedGenres[g] { + weight += mixGenreBoost - break - } + break } } @@ -196,6 +199,64 @@ func (e *Service) mixCandidates( return candidates } +// genresByPath returns the genres of many files in one query. +func (e *Service) genresByPath( + ctx context.Context, paths []string, +) map[string][]string { + out := make(map[string][]string, len(paths)) + + if len(paths) == 0 { + return out + } + + rows, err := e.db.ReadQueries.GetGenreNamesByFilePaths(ctx, paths) + if err != nil { + return out + } + + for _, row := range rows { + out[row.FilePath] = append(out[row.FilePath], row.Name) + } + + return out +} + +// similarArtistPaths collects every owned file by an artist similar to +// one of the seeds, so their genres can be fetched in one go. +func (e *Service) similarArtistPaths( + ctx context.Context, artistCounts map[string]int, +) []string { + var paths []string + + for seedArtistMBID := range artistCounts { + similar, err := e.SimilarArtists(seedArtistMBID) + if err != nil { + continue + } + + if len(similar) > mixSimilarArtistsPerSeed { + similar = similar[:mixSimilarArtistsPerSeed] + } + + for _, sim := range similar { + if sim.ArtistMBID == "" { + continue + } + + p, err := e.db.ReadQueries.GetFilePathsByArtistMBID( + ctx, sql.NullString{String: sim.ArtistMBID, Valid: true}, + ) + if err != nil { + continue + } + + paths = append(paths, p...) + } + } + + return paths +} + // weightedSample picks up to n distinct keys from weights without // replacement, biased toward higher weight (roulette-wheel selection). // A key with zero or negative weight is never picked. diff --git a/backend/explore/mix_test.go b/backend/explore/mix_test.go index 3588512..69947e5 100644 --- a/backend/explore/mix_test.go +++ b/backend/explore/mix_test.go @@ -23,69 +23,14 @@ func seedMixTrack( fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id) - _, err := db.ExecContext( - "INSERT INTO artists (id, name, mbid) VALUES (?, ?, ?) "+ - "ON CONFLICT(name) DO NOTHING", - id, artistName, artistMBID, - ) - if err != nil { - t.Fatalf("insert artist: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (?, ?) "+ - "ON CONFLICT(text) DO NOTHING", - id, artistName, - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - - _, err = db.ExecContext( - "INSERT OR IGNORE INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)", - id, id, - ) - if err != nil { - t.Fatalf("insert artist_credit_artist: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, ?)", - id, fmt.Sprintf("Track %d", id), id, - ) - if err != nil { - t.Fatalf("insert recording: %v", err) - } - - _, err = db.ExecContext( - "INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+ - "VALUES (?, ?, 180000, 0, ?)", - id, fp, id, - ) - if err != nil { - t.Fatalf("insert audio_file: %v", err) - } - - for _, g := range genreNames { - var genreID int64 - - row := db.QueryRowWriter( - "INSERT INTO genres (name) VALUES (?) "+ - "ON CONFLICT(name) DO UPDATE SET name = name RETURNING id", - g, - ) - if err := row.Scan(&genreID); err != nil { - t.Fatalf("upsert genre %q: %v", g, err) - } - - _, err = db.ExecContext( - "INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) VALUES (?, ?)", - id, genreID, - ) - if err != nil { - t.Fatalf("insert recording_genre: %v", err) - } - } + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: fp, + Title: fmt.Sprintf("Track %d", id), + Artist: artistName, + ArtistMBID: artistMBID, + Genres: genreNames, + LengthMs: 180000, + }) return fp } diff --git a/backend/explore/musicbrainz.go b/backend/explore/musicbrainz.go index 83598e7..dac403f 100644 --- a/backend/explore/musicbrainz.go +++ b/backend/explore/musicbrainz.go @@ -13,18 +13,27 @@ import ( "go.uploadedlobster.com/musicbrainzws2" ) +// How long a MusicBrainz answer is kept. +// +// The rule is what the answer is *about*, not how big it is. A search +// is a ranking and shifts; an entity is a fact about a record that was +// published years ago and does not. Entity data used to expire after a +// week, which meant a fully-populated artist page re-fetched itself +// every week forever - on a real install, 251 of 2,930 cached rows were +// already expired and waiting to be paid for again. The bytes are +// already on disk; re-fetching them buys nothing and spends someone +// else's rate limit. const ( // cacheTTLSearch is the TTL for search results (results may shift). cacheTTLSearch = 24 * time.Hour - // cacheTTLEntity is the TTL for lookup/browse results (entity data - // changes rarely). - cacheTTLEntity = 7 * 24 * time.Hour + // cacheTTLEntity is the TTL for lookup/browse results. An artist's + // name, country and relations, a release group's title and date: + // these change on the order of never, and a wrong one is corrected + // by the next catalog artifact rather than by an expiry. + cacheTTLEntity = 365 * 24 * time.Hour // cacheTTLReleases is the TTL for a release group's releases + - // tracklists. This data is effectively immutable once published, so - // it's cached far longer than other entities: it's the local store - // that keeps an album page's cold fetch a once-per-quarter event - // rather than a weekly one. - cacheTTLReleases = 90 * 24 * time.Hour + // tracklists. Effectively immutable once published. + cacheTTLReleases = 365 * 24 * time.Hour ) // MusicBrainzClient wraps the musicbrainzws2 library with a local diff --git a/backend/explore/prune_test.go b/backend/explore/prune_test.go index 9387be7..53f4c72 100644 --- a/backend/explore/prune_test.go +++ b/backend/explore/prune_test.go @@ -5,7 +5,6 @@ import ( "testing" "yellowjacket/backend/database" - "yellowjacket/backend/database/sql/sqlcgen" ) // TestPruneStaleLocalCrossReferences verifies that an explore_index row @@ -21,10 +20,17 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) { db := database.NewTestDB(t) si := NewSearchIndex(db, nil, nil, slog.Default()) - // A library artist that still exists. - artist, err := db.Queries.UpsertArtist(t.Context(), "Still Owned") + // A library artist that still exists - which now means one with a + // file behind it. An artist row on its own is not ownership; that + // was the whole bug. + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/still-owned.mp3", + Artist: "Still Owned", + }) + + artist, err := db.Queries.GetArtistByName(t.Context(), "Still Owned") if err != nil { - t.Fatalf("upsert artist: %v", err) + t.Fatalf("read seeded artist: %v", err) } // Two explore_index artist rows: one pointing at the still-existing @@ -33,19 +39,15 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) { seedArtist := func(mbid, title string, localID int64) { t.Helper() - if _, err := db.ExecContext( - upsertIndexSQL, - "artist", mbid, title, title, mbid, "", - 0, 0, - 0, "", "", - "", "", "", - "", "", "", "", - 1, 0, - localID, 0, 0, - 0, - ); err != nil { - t.Fatalf("seed explore_index row for %q: %v", mbid, err) - } + seedIndexResult(t, db, SearchIndexResult{ + EntityType: EntityArtist, + MBID: testMBID(mbid), + Title: title, + ArtistName: title, + ArtistMBID: testMBID(mbid), + InLibrary: true, + LocalArtistID: localID, + }) } seedArtist("still-owned-mbid", "Still Owned", artist.ID) @@ -53,7 +55,7 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) { si.pruneStaleLocalCrossReferences() - stillOwned := si.LookupArtistByMBID("still-owned-mbid") + stillOwned := si.LookupArtistByMBID(testMBID("still-owned-mbid")) if stillOwned == nil { t.Fatal("expected still-owned artist row to survive pruning") } @@ -67,7 +69,7 @@ func TestPruneStaleLocalCrossReferences(t *testing.T) { ) } - removed := si.LookupArtistByMBID("removed-mbid") + removed := si.LookupArtistByMBID(testMBID("removed-mbid")) if removed == nil { t.Fatal("expected removed-artist row to still exist (only cross-references cleared)") } @@ -90,51 +92,18 @@ func TestUnenrichedLibraryArtistMBIDs_OrdersByOwnedTrackCount(t *testing.T) { db := database.NewTestDB(t) si := NewSearchIndex(db, nil, nil, slog.Default()) - q := db.Queries - ctx := t.Context() seedArtistWithTracks := func(name, mbid string, trackCount int) { t.Helper() - artist, err := q.UpsertArtist(ctx, name) - if err != nil { - t.Fatalf("upsert artist %q: %v", name, err) - } - - _, err = db.ExecContext("UPDATE artists SET mbid = ? WHERE id = ?", mbid, artist.ID) - if err != nil { - t.Fatalf("set mbid for %q: %v", name, err) - } - - ac, err := q.UpsertArtistCredit(ctx, name) - if err != nil { - t.Fatalf("upsert artist credit %q: %v", name, err) - } - - if _, err := q.CreateArtistCreditArtist(ctx, sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: artist.ID, - CreditID: ac.ID, - }); err != nil { - t.Fatalf("link artist credit artist %q: %v", name, err) - } - for i := range trackCount { - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: name, - ArtistCreditID: ac.ID, + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: name + "/" + string(rune('a'+i)) + ".mp3", + Title: name, + Artist: name, + ArtistMBID: mbid, + LengthMs: 180000, }) - if err != nil { - t.Fatalf("create recording for %q: %v", name, err) - } - - if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: name + "/" + string(rune('a'+i)) + ".mp3", - LengthMilliseconds: 180000, - RecordingID: rec.ID, - Basename: string(rune('a'+i)) + ".mp3", - }); err != nil { - t.Fatalf("create audio file for %q: %v", name, err) - } } } diff --git a/backend/explore/searchindex.go b/backend/explore/searchindex.go index 703f751..384bfd5 100644 --- a/backend/explore/searchindex.go +++ b/backend/explore/searchindex.go @@ -2,6 +2,7 @@ package explore import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -146,6 +147,15 @@ type SearchIndexResult struct { SecondaryTypes string `json:"secondaryTypes"` // comma-separated ReleaseDate string `json:"releaseDate"` + // TotalTracks is the canonical release's track count, or 0 for + // "the catalog does not say". It is the denominator the album + // page cannot get from the files when the library holds no tags + // for the album, and it is deliberately only a denominator: the + // tracklist itself is not shipped, because the per-artist track + // budget truncates it and a truncated tracklist is a confident + // lie about which tracks exist. + TotalTracks int `json:"totalTracks"` + // Artist-specific fields (from MB lookup). ArtistType string `json:"artistType"` Country string `json:"country"` @@ -357,8 +367,9 @@ func (si *SearchIndex) EnsureArtistDiscography(ctx context.Context, artistMBID s // discography fetched, so EnsureArtistDiscography can skip the network. func (si *SearchIndex) artistDiscogFetched(mbid string) bool { rows, err := si.db.QueryContext( - "SELECT 1 FROM explore_index WHERE entity_type = 'artist' AND mbid = ? AND discog_fetched = 1 LIMIT 1", - mbid, + "SELECT 1 FROM explore_index WHERE entity_type = 1 /* artist */ "+ + "AND mbid = ? AND discog_fetched = 1 LIMIT 1", + dbMBID(mbid), ) if err != nil { return false @@ -393,11 +404,9 @@ func (si *SearchIndex) unenrichedLibraryArtistMBIDs(limit int) []string { SELECT a.mbid FROM artists a LEFT JOIN explore_index ei - ON ei.entity_type = 'artist' AND ei.mbid = a.mbid + ON ei.entity_type = 1 /* artist */ AND ei.mbid = unhex(replace(a.mbid, '-', '')) LEFT JOIN artist_enrichment ae ON ae.artist_mbid = a.mbid - LEFT JOIN artist_credit_artist aca ON aca.artist_id = a.id - LEFT JOIN recordings r ON r.artist_credit_id = aca.credit_id - LEFT JOIN audio_files af ON af.recording_id = r.id + LEFT JOIN audio_files af ON af.artist_id = a.id WHERE a.mbid IS NOT NULL AND a.mbid != '' AND (ei.id IS NULL OR ei.discog_fetched = 0 OR ae.browsed_at IS NULL) @@ -560,11 +569,17 @@ func (si *SearchIndex) backfillOneArtist( // preferring the index title, then the local library, then the MBID // itself. Used to seed the discography fetch's artist entry. func (si *SearchIndex) artistDisplayName(mbid string) string { - for _, q := range []string{ - "SELECT title FROM explore_index WHERE entity_type = 'artist' AND mbid = ? AND title != '' LIMIT 1", - "SELECT name FROM artists WHERE mbid = ? AND name != '' LIMIT 1", + // The two tables spell an MBID differently: the catalog stores raw + // bytes, the library stores text. Each query brings its own form. + for _, q := range []struct { + sql string + arg any + }{ + {"SELECT title FROM explore_index WHERE entity_type = 1 /* artist */ " + + "AND mbid = ? AND title != '' LIMIT 1", dbMBID(mbid)}, + {"SELECT name FROM artists WHERE mbid = ? AND name != '' LIMIT 1", mbid}, } { - rows, err := si.db.QueryContext(q, mbid) + rows, err := si.db.QueryContext(q.sql, q.arg) if err != nil { continue } @@ -721,17 +736,17 @@ func (si *SearchIndex) refreshStatusCounts() { for rows.Next() { var ( - et string + et dbEntityType count int ) if err := rows.Scan(&et, &count); err == nil { - switch et { - case "artist": + switch string(et) { + case EntityArtist: artists = count - case "recording": + case EntityRecording: recordings = count - case "release_group": + case EntityReleaseGroup: rgs = count } } @@ -878,7 +893,7 @@ func (si *SearchIndex) GetPopularity(mbid string) int { rows, err := si.db.QueryContext( "SELECT popularity FROM explore_index WHERE mbid = ? LIMIT 1", - mbid, + dbMBID(mbid), ) if err != nil { return 0 @@ -917,7 +932,7 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult for i, m := range mbids { placeholders[i] = "?" - args[i] = m + args[i] = dbMBID(m) } query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" + @@ -942,13 +957,15 @@ func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult for rows.Next() { var ( - mbid string + id dbMBID pop int listeners int inLib int ) - if err := rows.Scan(&mbid, &pop, &listeners, &inLib); err == nil { + if err := rows.Scan(&id, &pop, &listeners, &inLib); err == nil { + mbid := string(id) + existing, ok := result.Popularity[mbid] if !ok || pop > existing { result.Popularity[mbid] = pop @@ -979,7 +996,7 @@ func (si *SearchIndex) IsInLibrary(mbid string) bool { rows, err := si.db.QueryContext( "SELECT in_library FROM explore_index WHERE mbid = ? AND in_library = 1 LIMIT 1", - mbid, + dbMBID(mbid), ) if err != nil { return false @@ -999,8 +1016,8 @@ func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult { artist_type, country, disambiguation, sort_name, aliases, in_library, is_similar, COALESCE(local_artist_id, 0) FROM explore_index - WHERE mbid = ? AND entity_type = 'artist' LIMIT 1`, - mbid, + WHERE mbid = ? AND entity_type = 1 /* artist */ LIMIT 1`, + dbMBID(mbid), ) if err != nil { return nil @@ -1013,18 +1030,22 @@ func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult { } r := SearchIndexResult{ - EntityType: "artist", + EntityType: EntityArtist, MBID: mbid, } + var artist dbMBID + if err := rows.Scan( - &r.Title, &r.ArtistName, &r.ArtistMBID, &r.Popularity, &r.ListenerCount, + &r.Title, &r.ArtistName, &artist, &r.Popularity, &r.ListenerCount, &r.ArtistType, &r.Country, &r.Disambiguation, &r.SortName, &r.Aliases, &r.InLibrary, &r.IsSimilar, &r.LocalArtistID, ); err != nil { return nil } + r.ArtistMBID = string(artist) + return &r } @@ -1070,13 +1091,13 @@ func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs( for i, m := range filtered { placeholders[i] = "?" - args[i] = m + args[i] = dbMBID(m) } query := `SELECT caa_release_mbid, mbid FROM explore_index - WHERE entity_type = 'release_group' - AND caa_release_mbid != '' + WHERE entity_type = 2 /* release_group */ + AND caa_release_mbid != x'' AND caa_release_mbid IN (` + strings.Join(placeholders, ",") + `)` rows, err := si.db.QueryContext(query, args...) @@ -1089,9 +1110,9 @@ func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs( out := make(map[string]string, len(filtered)) for rows.Next() { - var caaMBID, rgMBID string + var caaMBID, rgMBID dbMBID if err := rows.Scan(&caaMBID, &rgMBID); err == nil { - out[caaMBID] = rgMBID + out[string(caaMBID)] = string(rgMBID) } } @@ -1102,11 +1123,11 @@ func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs( func (si *SearchIndex) LookupReleaseGroupByMBID(mbid string) *SearchIndexResult { rows, err := si.db.QueryContext( `SELECT title, artist_name, artist_mbid, popularity, listener_count, - primary_type, secondary_types, release_date, + primary_type, secondary_types, release_date, total_tracks, in_library, COALESCE(local_release_group_id, 0), discog_fetched FROM explore_index - WHERE mbid = ? AND entity_type = 'release_group' LIMIT 1`, - mbid, + WHERE mbid = ? AND entity_type = 2 /* release_group */ LIMIT 1`, + dbMBID(mbid), ) if err != nil { return nil @@ -1119,13 +1140,15 @@ func (si *SearchIndex) LookupReleaseGroupByMBID(mbid string) *SearchIndexResult } r := SearchIndexResult{ - EntityType: "release_group", + EntityType: EntityReleaseGroup, MBID: mbid, } + var artist dbMBID + if err := rows.Scan( - &r.Title, &r.ArtistName, &r.ArtistMBID, &r.Popularity, &r.ListenerCount, - &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, + &r.Title, &r.ArtistName, &artist, &r.Popularity, &r.ListenerCount, + &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, &r.TotalTracks, &r.InLibrary, &r.LocalReleaseGroupID, &r.DiscogFetched, ); err != nil { return nil @@ -1166,10 +1189,10 @@ func (si *SearchIndex) TopRecordingsByArtist(artistMBID string, limit int) []Sea duration, caa_release_mbid, release_name, in_library, COALESCE(local_recording_id, 0) FROM explore_index - WHERE artist_mbid = ? AND entity_type = 'recording' + WHERE artist_mbid = ? AND entity_type = 3 /* recording */ ORDER BY popularity DESC LIMIT ?`, - artistMBID, limit, + dbMBID(artistMBID), limit, ) if err != nil { return nil @@ -1180,13 +1203,19 @@ func (si *SearchIndex) TopRecordingsByArtist(artistMBID string, limit int) []Sea var results []SearchIndexResult for rows.Next() { - var r SearchIndexResult + var ( + r SearchIndexResult + id, caa dbMBID + ) + if err := rows.Scan( - &r.MBID, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount, - &r.Duration, &r.CAAReleaseMBID, &r.ReleaseName, + &id, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount, + &r.Duration, &caa, &r.ReleaseName, &r.InLibrary, &r.LocalRecordingID, ); err == nil { - r.EntityType = "recording" + r.MBID = string(id) + r.CAAReleaseMBID = string(caa) + r.EntityType = EntityRecording r.ArtistMBID = artistMBID results = append(results, r) } @@ -1205,10 +1234,10 @@ func (si *SearchIndex) TopReleaseGroupsByArtist(artistMBID string, limit int) [] primary_type, secondary_types, release_date, in_library, COALESCE(local_release_group_id, 0) FROM explore_index - WHERE artist_mbid = ? AND entity_type = 'release_group' + WHERE artist_mbid = ? AND entity_type = 2 /* release_group */ ORDER BY popularity DESC LIMIT ?`, - artistMBID, limit, + dbMBID(artistMBID), limit, ) if err != nil { return nil @@ -1219,13 +1248,18 @@ func (si *SearchIndex) TopReleaseGroupsByArtist(artistMBID string, limit int) [] var results []SearchIndexResult for rows.Next() { - var r SearchIndexResult + var ( + r SearchIndexResult + id dbMBID + ) + if err := rows.Scan( - &r.MBID, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount, + &id, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount, &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, &r.InLibrary, &r.LocalReleaseGroupID, ); err == nil { - r.EntityType = "release_group" + r.MBID = string(id) + r.EntityType = EntityReleaseGroup r.ArtistMBID = artistMBID results = append(results, r) } @@ -1315,29 +1349,21 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex // its partial expression index (idx_explore_title_lower / // idx_explore_artist_lower). Ordering is done in Go below since // an ORDER BY here would also defeat the index seek. + // + // The popularity clause **must match those indexes' predicate** or + // the seek becomes a scan of two million rows. It is the champion + // set: what the user owns, plus what is popular enough to be worth + // an exact-match boost. Anything below the floor is still found by + // the FTS tiers; it just does not jump the queue. rows, err := si.db.QueryContext(` - SELECT entity_type, mbid, title, artist_name, artist_mbid, - popularity, listener_count, duration, primary_type, - secondary_types, release_date, caa_release_mbid, - release_name, artist_type, country, disambiguation, - sort_name, in_library, is_similar, - COALESCE(local_artist_id, 0), - COALESCE(local_release_group_id, 0), - COALESCE(local_recording_id, 0) + SELECT `+indexRowColumns+` FROM explore_index - WHERE LOWER(title) = ? AND popularity > 0 + WHERE LOWER(title) = ? AND (popularity >= ? OR in_library = 1) UNION - SELECT entity_type, mbid, title, artist_name, artist_mbid, - popularity, listener_count, duration, primary_type, - secondary_types, release_date, caa_release_mbid, - release_name, artist_type, country, disambiguation, - sort_name, in_library, is_similar, - COALESCE(local_artist_id, 0), - COALESCE(local_release_group_id, 0), - COALESCE(local_recording_id, 0) + SELECT `+indexRowColumns+` FROM explore_index - WHERE LOWER(artist_name) = ? AND popularity > 0 - `, q, q) + WHERE LOWER(artist_name) = ? AND (popularity >= ? OR in_library = 1) + `, q, championPopThreshold, q, championPopThreshold) if err != nil { return nil } @@ -1349,14 +1375,7 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex for rows.Next() { var r SearchIndexResult - if err := rows.Scan( - &r.EntityType, &r.MBID, &r.Title, &r.ArtistName, &r.ArtistMBID, - &r.Popularity, &r.ListenerCount, &r.Duration, &r.PrimaryType, - &r.SecondaryTypes, &r.ReleaseDate, &r.CAAReleaseMBID, - &r.ReleaseName, &r.ArtistType, &r.Country, &r.Disambiguation, - &r.SortName, &r.InLibrary, &r.IsSimilar, - &r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID, - ); err != nil { + if err := scanIndexRow(rows, &r); err != nil { continue } @@ -1623,14 +1642,7 @@ func (si *SearchIndex) rowsByIDs(ctx context.Context, ids []int64) []SearchIndex } query := ` - SELECT id, entity_type, mbid, title, artist_name, artist_mbid, - popularity, listener_count, duration, primary_type, - secondary_types, release_date, caa_release_mbid, - release_name, artist_type, country, disambiguation, - sort_name, in_library, is_similar, - COALESCE(local_artist_id, 0), - COALESCE(local_release_group_id, 0), - COALESCE(local_recording_id, 0) + SELECT id, ` + indexRowColumns + ` FROM explore_index WHERE id IN (` + strings.Join(placeholders, ",") + `)` @@ -1653,14 +1665,7 @@ func (si *SearchIndex) rowsByIDs(ctx context.Context, ids []int64) []SearchIndex r SearchIndexResult ) - if err := rows.Scan( - &id, &r.EntityType, &r.MBID, &r.Title, &r.ArtistName, &r.ArtistMBID, - &r.Popularity, &r.ListenerCount, &r.Duration, &r.PrimaryType, - &r.SecondaryTypes, &r.ReleaseDate, &r.CAAReleaseMBID, - &r.ReleaseName, &r.ArtistType, &r.Country, &r.Disambiguation, - &r.SortName, &r.InLibrary, &r.IsSimilar, - &r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID, - ); err != nil { + if err := scanIndexRow(rows, &r, &id); err != nil { continue } @@ -1742,15 +1747,7 @@ func (si *SearchIndex) queryFTS( ) []SearchIndexResult { // ftsTable is a trusted in-package constant, never user input. sqlText := fmt.Sprintf(` - SELECT i.entity_type, i.mbid, i.title, i.artist_name, - i.artist_mbid, i.popularity, i.listener_count, - i.duration, i.primary_type, i.secondary_types, i.release_date, - i.caa_release_mbid, i.release_name, - i.artist_type, i.country, i.disambiguation, i.sort_name, - i.in_library, i.is_similar, - COALESCE(i.local_artist_id, 0), - COALESCE(i.local_release_group_id, 0), - COALESCE(i.local_recording_id, 0) + SELECT `+indexRowColumnsFor("i")+` FROM explore_index i JOIN %[1]s f ON f.rowid = i.id WHERE %[1]s MATCH ? @@ -1783,15 +1780,7 @@ func (si *SearchIndex) queryFTS( for rows.Next() { var r SearchIndexResult - if err := rows.Scan( - &r.EntityType, &r.MBID, &r.Title, &r.ArtistName, - &r.ArtistMBID, &r.Popularity, &r.ListenerCount, - &r.Duration, &r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, - &r.CAAReleaseMBID, &r.ReleaseName, - &r.ArtistType, &r.Country, &r.Disambiguation, &r.SortName, - &r.InLibrary, &r.IsSimilar, - &r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID, - ); err != nil { + if err := scanIndexRow(rows, &r); err != nil { si.logger.Warn("search index scan error", "error", err) continue @@ -2051,9 +2040,11 @@ func (si *SearchIndex) indexOneArtist( // concurrently — they use different rate limiters so they // don't block each other. var ( - rgs []SearchIndexResult - recs []SearchIndexResult - wg sync.WaitGroup + rgs []SearchIndexResult + recs []SearchIndexResult + rgErr error + recErr error + wg sync.WaitGroup ) // LB pipeline: top release groups + top recordings. @@ -2062,8 +2053,8 @@ func (si *SearchIndex) indexOneArtist( go func() { defer wg.Done() - rgs = si.fetchTopReleaseGroups(ctx, lb, artist, rgLimit) - recs = si.fetchTopRecordings(ctx, lb, artist, recLimit) + rgs, rgErr = si.fetchTopReleaseGroups(ctx, lb, artist, rgLimit) + recs, recErr = si.fetchTopRecordings(ctx, lb, artist, recLimit) }() // MB pipeline: cache the artist lookup, which is what the details @@ -2090,14 +2081,19 @@ func (si *SearchIndex) indexOneArtist( // Write the artist entry into the index so indexedArtistMBIDs() // recognises this artist as processed on subsequent builds. - // Only mark DiscogFetched=true if at least one of the discography - // fetches actually returned data — a transient API failure should - // allow a retry on the next build, not permanently claim the - // artist as indexed. Also stores aliases and detail fields from - // the now-cached MB rels (populated by the image resolution above) - // for FTS search. + // Also stores aliases and detail fields from the now-cached MB rels + // (populated by the image resolution above) for FTS search. + // + // DiscogFetched records that both LB endpoints were *asked*, not + // that they had anything to say. A transient failure still leaves + // the artist unmarked so the next run retries it — but an artist LB + // has no popularity data for (or none above indexMinPopularity, + // which is most of a long-tail library) answers empty every single + // time, and keying the mark on emptiness made those artists + // permanent candidates: the owned-artist backfill re-ran for them + // on every launch, forever, which is the bug this replaces. if si.artistImg != nil { - gotData := len(rgs) > 0 || len(recs) > 0 + gotData := rgErr == nil && recErr == nil artistEntry := SearchIndexResult{ EntityType: "artist", MBID: artist.ArtistMBID, @@ -2139,10 +2135,10 @@ func (si *SearchIndex) fetchTopReleaseGroups( lb *ListenBrainzClient, artist lbSitewideArtist, maxCount int, -) []SearchIndexResult { +) ([]SearchIndexResult, error) { url := fmt.Sprintf( "%s/1/popularity/top-release-groups-for-artist/%s", - listenBrainzBaseURL, artist.ArtistMBID, + lb.baseURL, artist.ArtistMBID, ) body, err := lb.doGet(ctx, url) @@ -2152,7 +2148,7 @@ func (si *SearchIndex) fetchTopReleaseGroups( "error", err, ) - return nil + return nil, err } var raw []struct { @@ -2173,7 +2169,7 @@ func (si *SearchIndex) fetchTopReleaseGroups( } if err := json.Unmarshal(body, &raw); err != nil { - return nil + return nil, err } limit := maxCount @@ -2209,7 +2205,7 @@ func (si *SearchIndex) fetchTopReleaseGroups( }) } - return results + return results, nil } func (si *SearchIndex) fetchTopRecordings( @@ -2217,20 +2213,20 @@ func (si *SearchIndex) fetchTopRecordings( lb *ListenBrainzClient, artist lbSitewideArtist, maxCount int, -) []SearchIndexResult { +) ([]SearchIndexResult, error) { url := fmt.Sprintf( "%s/1/popularity/top-recordings-for-artist/%s", - listenBrainzBaseURL, artist.ArtistMBID, + lb.baseURL, artist.ArtistMBID, ) body, err := lb.doGet(ctx, url) if err != nil { - return nil + return nil, err } var raw []lbTopRecordingWire if err := json.Unmarshal(body, &raw); err != nil { - return nil + return nil, err } limit := maxCount @@ -2258,7 +2254,7 @@ func (si *SearchIndex) fetchTopRecordings( }) } - return results + return results, nil } // --------------------------------------------------------------------------- @@ -2276,6 +2272,89 @@ func (si *SearchIndex) fetchTopRecordings( // empty values, and numeric fields use "highest wins" for popularity/ // listener_count/duration so older richer data survives refreshes. +// indexRowColumns is the full explore_index projection, and +// scanIndexRow is the only thing that reads it. +// +// There were four copies of this column list and four matching Scan +// calls, which is what makes the storage encoding dangerous: a blob +// column scanned into a string yields sixteen bytes of garbage rather +// than an error, and it would have had to be got right four times. +// dbMBID and dbEntityType do the decoding, and they refuse anything +// that is not what they expect. +var indexRowFields = []string{ + "entity_type", "mbid", "title", "artist_name", "artist_mbid", + "popularity", "listener_count", "duration", "primary_type", + "secondary_types", "release_date", "total_tracks", "caa_release_mbid", + "release_name", "artist_type", "country", "disambiguation", + "sort_name", "in_library", "is_similar", + "local_artist_id", "local_release_group_id", "local_recording_id", +} + +// nullableIndexRowFields are the ones a caller wants zero rather than +// NULL for. +var nullableIndexRowFields = map[string]bool{ + "local_artist_id": true, + "local_release_group_id": true, + "local_recording_id": true, +} + +// indexRowColumns is the projection unqualified; indexRowColumnsFor +// qualifies it with a table alias, for the joins where the other side +// also has a `title`. +var indexRowColumns = indexRowColumnsFor("") + +func indexRowColumnsFor(alias string) string { + if alias != "" { + alias += "." + } + + cols := make([]string, 0, len(indexRowFields)) + + for _, f := range indexRowFields { + if nullableIndexRowFields[f] { + cols = append(cols, "COALESCE("+alias+f+", 0)") + + continue + } + + cols = append(cols, alias+f) + } + + return strings.Join(cols, ", ") +} + +// scanIndexRow reads one indexRowColumns row into a result. +func scanIndexRow(rows *sql.Rows, r *SearchIndexResult, before ...any) error { + var ( + entity dbEntityType + id dbMBID + artist dbMBID + caa dbMBID + ) + + dest := make([]any, 0, len(before)+len(indexRowFields)) + dest = append(dest, before...) + dest = append(dest, + &entity, &id, &r.Title, &r.ArtistName, &artist, + &r.Popularity, &r.ListenerCount, &r.Duration, &r.PrimaryType, + &r.SecondaryTypes, &r.ReleaseDate, &r.TotalTracks, &caa, + &r.ReleaseName, &r.ArtistType, &r.Country, &r.Disambiguation, + &r.SortName, &r.InLibrary, &r.IsSimilar, + &r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID, + ) + + if err := rows.Scan(dest...); err != nil { + return fmt.Errorf("scan index row: %w", err) + } + + r.EntityType = string(entity) + r.MBID = string(id) + r.ArtistMBID = string(artist) + r.CAAReleaseMBID = string(caa) + + return nil +} + // upsertIndexSQL is the single index write statement. It is kept as // a const so assembly can prepare it once per transaction instead of // re-parsing this large upsert for every row. @@ -2284,7 +2363,7 @@ const upsertIndexSQL = ` entity_type, mbid, title, artist_name, artist_mbid, aliases, popularity, listener_count, duration, caa_release_mbid, release_name, - primary_type, secondary_types, release_date, + primary_type, secondary_types, release_date, total_tracks, artist_type, country, disambiguation, sort_name, in_library, is_similar, local_artist_id, local_release_group_id, local_recording_id, @@ -2293,7 +2372,7 @@ const upsertIndexSQL = ` ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0), NULLIF(?, 0), @@ -2311,7 +2390,7 @@ const upsertIndexConflictSQL = ` -- as a name; AddFromCache is the path that used to. title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END, artist_name = CASE WHEN excluded.artist_name != '' THEN excluded.artist_name ELSE artist_name END, - artist_mbid = CASE WHEN excluded.artist_mbid != '' THEN excluded.artist_mbid ELSE artist_mbid END, + artist_mbid = CASE WHEN excluded.artist_mbid != x'' THEN excluded.artist_mbid ELSE artist_mbid END, aliases = CASE WHEN excluded.aliases != '' THEN excluded.aliases ELSE aliases END, -- Highest wins for popularity + listener_count (refreshes can go up). @@ -2320,11 +2399,12 @@ const upsertIndexConflictSQL = ` -- Non-empty wins for all other optional fields (never clobber with empty). duration = CASE WHEN excluded.duration > 0 THEN excluded.duration ELSE duration END, - caa_release_mbid = CASE WHEN excluded.caa_release_mbid != '' THEN excluded.caa_release_mbid ELSE caa_release_mbid END, + caa_release_mbid = CASE WHEN excluded.caa_release_mbid != x'' THEN excluded.caa_release_mbid ELSE caa_release_mbid END, release_name = CASE WHEN excluded.release_name != '' THEN excluded.release_name ELSE release_name END, primary_type = CASE WHEN excluded.primary_type != '' THEN excluded.primary_type ELSE primary_type END, secondary_types = CASE WHEN excluded.secondary_types != '' THEN excluded.secondary_types ELSE secondary_types END, release_date = CASE WHEN excluded.release_date != '' THEN excluded.release_date ELSE release_date END, + total_tracks = CASE WHEN excluded.total_tracks > 0 THEN excluded.total_tracks ELSE total_tracks END, artist_type = CASE WHEN excluded.artist_type != '' THEN excluded.artist_type ELSE artist_type END, country = CASE WHEN excluded.country != '' THEN excluded.country ELSE country END, disambiguation = CASE WHEN excluded.disambiguation != '' THEN excluded.disambiguation ELSE disambiguation END, @@ -2387,10 +2467,11 @@ func (si *SearchIndex) upsertBatch(entries []SearchIndexResult) { } if _, err := stmt.Exec( - e.EntityType, e.MBID, e.Title, e.ArtistName, e.ArtistMBID, e.Aliases, + dbEntityType(e.EntityType), dbMBID(e.MBID), + e.Title, e.ArtistName, dbMBID(e.ArtistMBID), e.Aliases, e.Popularity, e.ListenerCount, - e.Duration, e.CAAReleaseMBID, e.ReleaseName, - e.PrimaryType, e.SecondaryTypes, e.ReleaseDate, + e.Duration, dbMBID(e.CAAReleaseMBID), e.ReleaseName, + e.PrimaryType, e.SecondaryTypes, e.ReleaseDate, e.TotalTracks, e.ArtistType, e.Country, e.Disambiguation, e.SortName, inLib, isSim, e.LocalArtistID, e.LocalReleaseGroupID, e.LocalRecordingID, @@ -2480,42 +2561,48 @@ func (si *SearchIndex) pruneStaleLocalCrossReferences() { type prune struct { entityType string column string - table string + // exists is the test for "this local id still refers to + // something the user owns". It is a file test in every case - + // the version that tested the metadata table left 129 rows in + // a real catalog claiming to be owned by files that were gone. + exists string } for _, p := range []prune{ - {"artist", "local_artist_id", "artists"}, - {"release_group", "local_release_group_id", "release_groups"}, - {"recording", "local_recording_id", "recordings"}, + {"artist", "local_artist_id", ` + SELECT 1 FROM artists a WHERE a.id = explore_index.local_artist_id + 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 + ) + )`}, + {"release_group", "local_release_group_id", ` + SELECT 1 FROM audio_files af + WHERE af.album_id = explore_index.local_release_group_id`}, + {"recording", "local_recording_id", ` + SELECT 1 FROM audio_files af + WHERE af.id = explore_index.local_recording_id`}, } { result, err := si.db.ExecContext( `UPDATE explore_index SET in_library = 0, `+p.column+` = NULL - WHERE entity_type = ? - AND `+p.column+` IS NOT NULL - AND `+p.column+` NOT IN (SELECT id FROM `+p.table+`)`, - p.entityType, + WHERE entity_type = ? AND `+p.column+` IS NOT NULL + AND NOT EXISTS (`+p.exists+`)`, + dbEntityType(p.entityType), ) if err != nil { - si.logger.Warn( - "library sync: prune stale cross-references failed", - "entityType", - p.entityType, - "error", - err, - ) + si.logger.Warn("library sync: prune stale cross-references failed", + "entityType", p.entityType, "error", err) continue } - if n, err := result.RowsAffected(); err == nil && n > 0 { - si.logger.Info( - "library sync: cleared stale cross-references", - "entityType", - p.entityType, - "count", - n, - ) + if n, _ := result.RowsAffected(); n > 0 { + si.logger.Info("library sync: cleared stale cross-references", + "entityType", p.entityType, "count", n) } } } @@ -2527,118 +2614,85 @@ func (si *SearchIndex) pruneStaleLocalCrossReferences() { func (si *SearchIndex) collectLibraryEntities() []SearchIndexResult { var entries []SearchIndexResult - // Artists. - artistRows, err := si.db.QueryContext( - "SELECT id, name, mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''", - ) - if err == nil { - for artistRows.Next() { - var ( - id int64 - name, mbid string - ) - - if err := artistRows.Scan(&id, &name, &mbid); err != nil { - continue - } - - entries = append(entries, SearchIndexResult{ - EntityType: "artist", - MBID: mbid, - Title: name, - ArtistName: name, - ArtistMBID: mbid, - InLibrary: true, - LocalArtistID: id, - }) - } - - _ = artistRows.Close() - } else { - si.logger.Warn("library sync: query artists failed", "error", err) + // Every one of these is gated on a file existing. They used to + // select straight from the metadata tables, so an artist, album or + // recording whose files were gone stayed flagged "in library" in + // the catalog until something noticed - and nothing did. + type entityQuery struct { + kind string + query string } - // Release groups. The correlated subquery picks the primary - // credited artist's MBID (if it has one). - rgRows, err := si.db.QueryContext(` - SELECT rg.id, rg.name, rg.mbid, COALESCE(ac.text, ''), - COALESCE(( - SELECT a.mbid FROM artist_credit_artist aca - JOIN artists a ON a.id = aca.artist_id - WHERE aca.credit_id = rg.album_artist_credit_id - AND a.mbid IS NOT NULL AND a.mbid != '' - LIMIT 1 - ), '') - FROM release_groups rg - LEFT JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id - WHERE rg.mbid IS NOT NULL AND rg.mbid != '' - `) - if err == nil { - for rgRows.Next() { + queries := []entityQuery{ + {"artist", ` + SELECT DISTINCT a.id, a.name, a.mbid, a.name, a.mbid + FROM artists a + WHERE a.mbid IS NOT NULL AND a.mbid != '' + 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 + ) + )`}, + {"release_group", ` + SELECT DISTINCT al.id, al.name, al.mbid, al.artist_credit, + COALESCE(ar.mbid, '') + FROM albums al + JOIN audio_files af ON af.album_id = al.id + LEFT JOIN artists ar ON ar.id = al.artist_id + WHERE al.mbid IS NOT NULL AND al.mbid != ''`}, + {"recording", ` + SELECT af.id, af.title, af.recording_mbid, af.artist_credit, + COALESCE(ar.mbid, '') + FROM audio_files af + LEFT JOIN artists ar ON ar.id = af.artist_id + WHERE af.recording_mbid IS NOT NULL AND af.recording_mbid != ''`}, + } + + for _, eq := range queries { + rows, err := si.db.QueryContext(eq.query) + if err != nil { + si.logger.Warn("library sync: query failed", "kind", eq.kind, "error", err) + + continue + } + + for rows.Next() { var ( id int64 name, mbid, credit, artistMB string ) - if err := rgRows.Scan(&id, &name, &mbid, &credit, &artistMB); err != nil { + if err := rows.Scan(&id, &name, &mbid, &credit, &artistMB); err != nil { continue } - entries = append(entries, SearchIndexResult{ - EntityType: "release_group", - MBID: mbid, - Title: name, - ArtistName: credit, - ArtistMBID: artistMB, - InLibrary: true, - LocalReleaseGroupID: id, - }) - } - - _ = rgRows.Close() - } else { - si.logger.Warn("library sync: query release groups failed", "error", err) - } - - // Recordings. - recRows, err := si.db.QueryContext(` - SELECT r.id, r.name, r.mbid, COALESCE(ac.text, ''), - COALESCE(( - SELECT a.mbid FROM artist_credit_artist aca - JOIN artists a ON a.id = aca.artist_id - WHERE aca.credit_id = r.artist_credit_id - AND a.mbid IS NOT NULL AND a.mbid != '' - LIMIT 1 - ), '') - FROM recordings r - LEFT JOIN artist_credit ac ON ac.id = r.artist_credit_id - WHERE r.mbid IS NOT NULL AND r.mbid != '' - `) - if err == nil { - for recRows.Next() { - var ( - id int64 - name, mbid, credit, artistMB string - ) - - if err := recRows.Scan(&id, &name, &mbid, &credit, &artistMB); err != nil { - continue + entry := SearchIndexResult{ + EntityType: eq.kind, + MBID: mbid, + Title: name, + ArtistName: credit, + ArtistMBID: artistMB, + InLibrary: true, } - entries = append(entries, SearchIndexResult{ - EntityType: "recording", - MBID: mbid, - Title: name, - ArtistName: credit, - ArtistMBID: artistMB, - InLibrary: true, - LocalRecordingID: id, - }) + switch eq.kind { + case "artist": + entry.LocalArtistID = id + case "release_group": + entry.LocalReleaseGroupID = id + case "recording": + // The local id of a "recording" is the file's, which is + // what every caller wants: it is the thing that plays. + entry.LocalRecordingID = id + } + + entries = append(entries, entry) } - _ = recRows.Close() - } else { - si.logger.Warn("library sync: query recordings failed", "error", err) + _ = rows.Close() } return entries @@ -2710,7 +2764,7 @@ func (si *SearchIndex) BackfillPopularity(updates map[string]PopularityData) { WHERE mbid = ?`, data.ListenCount, data.ListenCount, data.ListenerCount, data.ListenerCount, - mbid, + dbMBID(mbid), ) } diff --git a/backend/explore/shelves.go b/backend/explore/shelves.go index 7b24fad..6abd52a 100644 --- a/backend/explore/shelves.go +++ b/backend/explore/shelves.go @@ -332,7 +332,7 @@ func (si *SearchIndex) topByPopularity( // The artist rows *are* the artists, so they partition by their own // mbid; release groups partition by whoever made them. partition := "artist_mbid" - if entityType == "artist" { + if entityType == EntityArtist { partition = "mbid" } @@ -350,14 +350,14 @@ func (si *SearchIndex) topByPopularity( WHERE rank = 1 ORDER BY popularity DESC LIMIT ?`, - entityType, limit+len(skip), + dbEntityType(entityType), limit+len(skip), )) out := make([]SearchIndexResult, 0, limit) for _, row := range rows { key := row.ArtistMBID - if entityType == "artist" { + if entityType == EntityArtist { key = row.MBID } @@ -393,13 +393,13 @@ func (si *SearchIndex) unownedAlbumsBySinglyOwnedArtists( return si.rowsByIDs(ctx, si.shelfIDs( ctx, `SELECT id FROM explore_index - WHERE entity_type = 'release_group' + WHERE entity_type = 2 /* release_group */ AND in_library = 0 AND artist_mbid IN ( SELECT artist_mbid FROM explore_index - WHERE entity_type = 'release_group' + WHERE entity_type = 2 /* release_group */ AND in_library = 1 - AND artist_mbid != '' + AND artist_mbid != x'' GROUP BY artist_mbid HAVING COUNT(*) = 1 ORDER BY MAX(popularity) DESC diff --git a/backend/explore/shelves_test.go b/backend/explore/shelves_test.go index cf42a9c..312a71a 100644 --- a/backend/explore/shelves_test.go +++ b/backend/explore/shelves_test.go @@ -28,24 +28,17 @@ func seedShelfRow( ) { t.Helper() - owned := 0 - if inLibrary { - owned = 1 - } - - if _, err := db.ExecContext( - upsertIndexSQL, - entityType, mbid, title, artistName, artistMBID, "", - popularity, popularity, - 0, "", "", - "Album", "", "", - "", "", "", "", - owned, 0, - 0, 0, 0, - 0, - ); err != nil { - t.Fatalf("seed explore_index row %q: %v", mbid, err) - } + seedIndexResult(t, db, SearchIndexResult{ + EntityType: entityType, + MBID: testMBID(mbid), + Title: title, + ArtistName: artistName, + ArtistMBID: testMBID(artistMBID), + Popularity: popularity, + ListenerCount: popularity, + ReleaseName: "Album", + InLibrary: inLibrary, + }) } func newShelfService(t *testing.T) (*Service, *database.DB) { @@ -271,12 +264,12 @@ func TestShelves_TheSecondRowIsNotTheFirstRowsArtists(t *testing.T) { t.Fatal("no popular-artists shelf") } - if albums.Albums[0].ArtistMBID != "ar-huge" { + if albums.Albums[0].ArtistMBID != testMBID("ar-huge") { t.Fatalf("albums shelf leads with %q, want ar-huge", albums.Albums[0].ArtistMBID) } for _, artist := range artists.Artists { - if artist.MBID == "ar-huge" { + if artist.MBID == testMBID("ar-huge") { t.Fatal("artists shelf repeats the artist the albums shelf just showed") } } diff --git a/backend/explore/testfixtures_test.go b/backend/explore/testfixtures_test.go index 2fccc8e..8dd513a 100644 --- a/backend/explore/testfixtures_test.go +++ b/backend/explore/testfixtures_test.go @@ -17,6 +17,11 @@ const ( recA = "11111111-1111-1111-1111-111111111111" recB = "22222222-2222-2222-2222-222222222222" recC = "33333333-3333-3333-3333-333333333333" + + // recD is on relA and nobody has ever played it, which is the point: + // it must count toward relA's track total without being indexed as a + // recording itself. + recD = "44444444-4444-4444-4444-444444444444" relA = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" relB = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" rgA = "cccccccc-cccc-cccc-cccc-cccccccccccc" diff --git a/backend/explore/types.go b/backend/explore/types.go index c5b184e..a00de9e 100644 --- a/backend/explore/types.go +++ b/backend/explore/types.go @@ -77,6 +77,14 @@ type MBReleaseGroup struct { ListenerCount int `json:"listenerCount"` InLibrary bool `json:"inLibrary"` // true if the user owns this album LocalID int64 `json:"localId,omitempty"` // local release_group row ID + + // TotalTracks is the catalog's track count for this release group, + // or 0 for "the catalog does not say". It answers "how much of + // this album do I have" for an album whose files declared no total + // -- the case GetAlbumCompleteness cannot answer -- and it is not + // filled by the MusicBrainz path below, which has the real + // tracklist and does not need a denominator. + TotalTracks int `json:"totalTracks"` } // MBRelease is a Wails-friendly projection of a MusicBrainz release. diff --git a/backend/home/home.go b/backend/home/home.go index ae8052a..f67c7c0 100644 --- a/backend/home/home.go +++ b/backend/home/home.go @@ -101,7 +101,7 @@ const ( // purpose: the home page needs one list of albums, not the library // package. type Library interface { - GetAllAlbums() ([]library.Album, error) + GetAlbums(libraryID int64) ([]library.Album, error) } // Service builds the home page's shelves. @@ -132,7 +132,7 @@ func (s *Service) GetShelves() ([]Shelf, error) { ctx = context.Background() } - albums, err := s.lib.GetAllAlbums() + albums, err := s.lib.GetAlbums(0) if err != nil { return nil, err } diff --git a/backend/home/home_test.go b/backend/home/home_test.go index 0e0ac42..00a783f 100644 --- a/backend/home/home_test.go +++ b/backend/home/home_test.go @@ -17,7 +17,7 @@ type fakeLibrary struct { err error } -func (f fakeLibrary) GetAllAlbums() ([]library.Album, error) { +func (f fakeLibrary) GetAlbums(int64) ([]library.Album, error) { return f.albums, f.err } @@ -34,46 +34,32 @@ func seed( ) int64 { t.Helper() - exec := func(query string, args ...any) { - t.Helper() - - if _, err := db.ExecContext(query, args...); err != nil { - t.Fatalf("seed %q: %v", query, err) - } + var genres []string + if genre != "" { + genres = []string{genre} } - exec(`INSERT INTO artist_credit (text) VALUES (?) - ON CONFLICT DO NOTHING`, artist) - exec(`INSERT INTO release_groups (name, album_artist_credit_id) - VALUES (?, (SELECT id FROM artist_credit WHERE text = ?))`, - name, artist) - exec(`INSERT INTO recordings (name, artist_credit_id) - VALUES (?, (SELECT id FROM artist_credit WHERE text = ?))`, - name+" track", artist) - exec(`INSERT INTO release_group_recordings (release_group_id, recording_id) - VALUES ((SELECT MAX(id) FROM release_groups), - (SELECT MAX(id) FROM recordings))`) - exec(`INSERT INTO file_types (extension) VALUES ('mp3') - ON CONFLICT DO NOTHING`) - exec(`INSERT INTO audio_files - (file_path, length_milliseconds, file_type_id, recording_id, - play_count, last_played) - VALUES (?, 1000, - (SELECT MAX(id) FROM file_types), - (SELECT MAX(id) FROM recordings), - ?, ?)`, - "/music/"+name+".mp3", playCount, nullable(lastPlayed)) + fileID := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/" + name + ".mp3", + Title: name + " track", + Artist: artist, + Album: name, + Genres: genres, + LengthMs: 1000, + PlayCount: int64(playCount), + }) - if genre != "" { - exec(`INSERT INTO genres (name) VALUES (?) ON CONFLICT DO NOTHING`, genre) - exec(`INSERT INTO recording_genres (recording_id, genre_id) - VALUES ((SELECT MAX(id) FROM recordings), - (SELECT id FROM genres WHERE name = ?))`, genre) + if lastPlayed != "" { + if _, err := db.ExecContext( + "UPDATE audio_files SET last_played = ? WHERE id = ?", lastPlayed, fileID, + ); err != nil { + t.Fatalf("seed last_played: %v", err) + } } var id int64 if err := db.QueryRowWriter( - `SELECT MAX(id) FROM release_groups`, + "SELECT album_id FROM audio_files WHERE id = ?", fileID, ).Scan(&id); err != nil { t.Fatalf("seed: read album id: %v", err) } @@ -81,14 +67,6 @@ func seed( return id } -func nullable(s string) any { - if s == "" { - return nil - } - - return s -} - func shelfKinds(shelves []home.Shelf) []home.Kind { kinds := make([]home.Kind, 0, len(shelves)) for _, s := range shelves { diff --git a/backend/library/completeness_test.go b/backend/library/completeness_test.go index ca40bf6..3d7e601 100644 --- a/backend/library/completeness_test.go +++ b/backend/library/completeness_test.go @@ -1,12 +1,15 @@ package library import ( + "fmt" "testing" + + "yellowjacket/backend/database" ) -// track is one row of release_group_recordings as the scan would write -// it: a position on a disc, and whatever total the file's tag declared -// (0 meaning the tag did not say). +// track is one file as the scan would write it: a position on a disc, +// and whatever total the file's tag declared (0 meaning the tag did not +// say). type track struct { recordingID int disc int @@ -14,58 +17,37 @@ type track struct { total int } -// stageAlbum writes an album's tracks straight into -// release_group_recordings. The completeness query reads only that -// table, so this exercises the arithmetic without standing up a scan. +// stageAlbum writes an album's files straight in. The completeness +// query reads only audio_files now - the totals used to live on a join +// table - so this exercises the arithmetic without standing up a scan. func stageAlbum(t *testing.T, lib *Library, albumID int, tracks []track) { t.Helper() - // The foreign keys are enforced, so the album and its recordings - // have to exist before they can be linked. - if _, err := lib.db.ExecContext( - `INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')`, - ); err != nil { - t.Fatalf("staging artist credit: %v", err) - } - - if _, err := lib.db.ExecContext( - `INSERT INTO release_groups (id, name, album_artist_credit_id) - VALUES (?, ?, 1)`, - albumID, "Test Album", - ); err != nil { - t.Fatalf("staging album: %v", err) - } - for _, tr := range tracks { - if _, err := lib.db.ExecContext( - `INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)`, - tr.recordingID, "Test Track", - ); err != nil { - t.Fatalf("staging recording %d: %v", tr.recordingID, err) - } + database.InsertTestTrack(t, lib.db, database.TestTrack{ + FilePath: fmt.Sprintf("/music/album%d/%d.mp3", albumID, tr.recordingID), + Title: "Test Track", + Artist: "Test Artist", + Album: fmt.Sprintf("Test Album %d", albumID), + TrackNumber: int64(tr.number), + DiscNumber: int64(tr.disc), + TotalTracks: int64(tr.total), + }) + } +} + +// albumIDFor reads back the id stageAlbum's files were filed under. +func albumIDFor(t *testing.T, lib *Library, albumID int) int64 { + t.Helper() + + var id int64 + if err := lib.db.QueryRowWriter( + "SELECT id FROM albums WHERE name = ?", fmt.Sprintf("Test Album %d", albumID), + ).Scan(&id); err != nil { + t.Fatalf("read album id: %v", err) } - for _, tr := range tracks { - var total any - if tr.total > 0 { - total = tr.total - } - - var number any - if tr.number > 0 { - number = tr.number - } - - _, err := lib.db.ExecContext( - `INSERT INTO release_group_recordings - (release_group_id, recording_id, track_number, disc_number, total_tracks) - VALUES (?, ?, ?, ?, ?)`, - albumID, tr.recordingID, number, tr.disc, total, - ) - if err != nil { - t.Fatalf("staging track %d: %v", tr.recordingID, err) - } - } + return id } // disc builds a run of tracks on one disc, each declaring the same @@ -210,7 +192,7 @@ func TestGetAlbumCompleteness(t *testing.T) { stageAlbum(t, lib, albumID, tc.tracks) - got, err := lib.GetAlbumCompleteness(int64(albumID)) + got, err := lib.GetAlbumCompleteness(albumIDFor(t, lib, albumID)) if err != nil { t.Fatalf("GetAlbumCompleteness: %v", err) } diff --git a/backend/library/coverart.go b/backend/library/coverart.go index cc8e6e4..659e6c9 100644 --- a/backend/library/coverart.go +++ b/backend/library/coverart.go @@ -10,7 +10,6 @@ import ( _ "image/png" // Register PNG decoder. "os" "path/filepath" - "strings" "time" "golang.org/x/image/draw" @@ -44,24 +43,22 @@ var thumbnailTiers = []thumbnailTier{ {Suffix: "_lg", MaxSize: 400, Quality: 85}, } -// legacyThumbSuffix is the old single-thumbnail suffix used before the -// multi-tier system. Kept for migration purposes only. -const legacyThumbSuffix = "_thumb" +// largestTier is the tier stored as the cover's canonical file. +func largestTier() thumbnailTier { + return thumbnailTiers[len(thumbnailTiers)-1] +} // CoverArtFileSet returns every file on disk belonging to one cover art -// entry: the original plus each generated size variant, plus the legacy -// _thumb file for databases that predate the multi-tier thumbnails. +// entry: each generated size variant. // -// Only the original is recorded in cover_art.file_path — the variants -// are derived filenames — so any code deleting cover art has to expand -// the set or the thumbnails are orphaned. -func CoverArtFileSet(originalPath string) []string { - dir := filepath.Dir(originalPath) - base := filepath.Base(originalPath) +// Only one of them is recorded in cover_art.file_path — the others are +// derived filenames — so any code deleting cover art has to expand the +// set or the rest are orphaned. +func CoverArtFileSet(coverPath string) []string { + dir := filepath.Dir(coverPath) + base := filepath.Base(coverPath) - paths := make([]string, 0, len(thumbnailTiers)+2) //nolint:mnd - - paths = append(paths, originalPath) + paths := make([]string, 0, len(thumbnailTiers)) for _, tier := range thumbnailTiers { paths = append(paths, filepath.Join( @@ -69,25 +66,7 @@ func CoverArtFileSet(originalPath string) []string { )) } - return append(paths, filepath.Join( - dir, coverart.SizedFilename(base, legacyThumbSuffix), - )) -} - -// isSizedVariant reports whether a filename contains any known size suffix -// (current tiers or legacy). -func isSizedVariant(name string) bool { - if strings.Contains(name, legacyThumbSuffix) { - return true - } - - for _, tier := range thumbnailTiers { - if strings.Contains(name, tier.Suffix) { - return true - } - } - - return false + return paths } // saveCoverArt saves embedded cover art to the cache directory. @@ -121,47 +100,30 @@ func (l *Library) saveCoverArt( ) } - // Generate filename from content hash (deduplication). + // The content hash identifies the cover and dedupes it; the largest + // tier is what gets stored under that name. + // + // The full-resolution image used to be written here too, and it was + // 1,134 MB of a 1.4 GB covers directory on a real library - against + // 110 MB for all three tiers together - with nothing rendering it. + // The bytes are still in the audio file if a bigger one is ever + // needed, which is where these came from. hash := sha256.Sum256(pic.Data) hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars. - ext := pic.Ext - if ext == "" { - ext = extensionFromMIME(pic.MIMEType) - } + filePath := filepath.Join( + coverDir, coverart.SizedFilename(hashStr, largestTier().Suffix), + ) - filename := fmt.Sprintf("%s.%s", hashStr, ext) - filePath := filepath.Join(coverDir, filename) - - // Skip if already exists (same content hash). - // Missing sized variants are handled by - // generateMissingSizedVariants() at the end of a scan. + // Skip if this cover has already been stored (same content hash). if _, err := os.Stat(filePath); err == nil { - l.logger.Debug( - "cover art already exists", "path", filePath, - ) + l.logger.Debug("cover art already stored", "path", filePath) return filePath, nil } - // Write file. - if err := os.WriteFile( - filePath, pic.Data, 0o644, - ); err != nil { - return "", fmt.Errorf( - "could not write cover art: %w", err, - ) - } - - metrics.addCoverArtSave(time.Since(saveStart)) - - l.logger.Debug( - "saved cover art", - "path", filePath, "size", len(pic.Data), - ) - - // Dispatch thumbnail generation to the async worker pool - // if available, otherwise generate inline. + // Dispatch thumbnail generation to the async worker pool if + // available, otherwise generate inline. if thumbChan != nil { thumbChan <- thumbnailWork{ imgData: pic.Data, @@ -169,38 +131,18 @@ func (l *Library) saveCoverArt( hashStr: hashStr, metrics: metrics, } - } else { - if err := l.generateSizedVariantsWithMetrics( - pic.Data, coverDir, hashStr, metrics, - ); err != nil { - l.logger.Warn( - "could not generate sized variants", - "path", filePath, "err", err, - ) - } + } else if err := l.generateSizedVariantsWithMetrics( + pic.Data, coverDir, hashStr, metrics, + ); err != nil { + l.logger.Warn("could not generate sized variants", + "path", filePath, "err", err) } + metrics.addCoverArtSave(time.Since(saveStart)) + return filePath, nil } -// generateSizedVariants creates all thumbnail tiers for the given image data. -// Each tier is saved as {hashStr}{suffix}.jpg in the given directory. -func (l *Library) generateSizedVariants( - imgData []byte, - dir, hashStr string, -) error { - src, _, err := image.Decode(bytes.NewReader(imgData)) - if err != nil { - return fmt.Errorf( - "could not decode image for thumbnails: %w", err, - ) - } - - l.generateTiersFromImage(src, dir, hashStr) - - return nil -} - // generateSizedVariantsWithMetrics is like generateSizedVariants // but records per-tier timing in the provided metrics. func (l *Library) generateSizedVariantsWithMetrics( @@ -257,46 +199,6 @@ func (l *Library) generateSizedVariantsWithMetrics( return nil } -// generateTiersFromImage creates all thumbnail tiers from an -// already-decoded image. -func (l *Library) generateTiersFromImage( - src image.Image, - dir, hashStr string, -) { - bounds := src.Bounds() - srcW := bounds.Dx() - srcH := bounds.Dy() - - for _, tier := range thumbnailTiers { - tierPath := filepath.Join( - dir, - fmt.Sprintf("%s%s.jpg", hashStr, tier.Suffix), - ) - - w, h := fitDimensions(srcW, srcH, tier.MaxSize) - - if err := encodeAndSaveImage( - src, tierPath, w, h, tier.Quality, - ); err != nil { - l.logger.Warn( - "could not generate sized variant", - "tier", tier.Suffix, - "path", tierPath, - "err", err, - ) - - continue - } - - l.logger.Debug( - "saved sized variant", - "tier", tier.Suffix, - "path", tierPath, - "dimensions", fmt.Sprintf("%dx%d", w, h), - ) - } -} - // fitDimensions calculates the output dimensions that fit within maxSize // while preserving the aspect ratio. If the source is already smaller // than maxSize, the original dimensions are returned unchanged. @@ -343,182 +245,3 @@ func encodeAndSaveImage( return nil } - -// generateMissingSizedVariants scans the covers directory, migrates legacy -// _thumb files to _md, and generates any missing sized variants for each -// original cover art file. -func (l *Library) generateMissingSizedVariants() error { - coverDir, err := coverart.CoversDir() - if err != nil { - return fmt.Errorf( - "could not resolve covers directory: %w", err, - ) - } - - entries, err := os.ReadDir(coverDir) - if err != nil { - return fmt.Errorf( - "could not read covers directory: %w", err, - ) - } - - // Build a set of existing filenames for quick lookup. - existing := make(map[string]struct{}, len(entries)) - - for _, entry := range entries { - if !entry.IsDir() { - existing[entry.Name()] = struct{}{} - } - } - - // First pass: migrate legacy _thumb files to _md. - migrated := l.migrateLegacyThumbs( - coverDir, existing, - ) - - // Second pass: generate missing sized variants. - var generated, skipped int - - for _, entry := range entries { - name := entry.Name() - - // Skip directories and any sized variants. - if entry.IsDir() || isSizedVariant(name) { - continue - } - - hashStr := strings.SplitN(name, ".", 2)[0] - - // Check which tiers are missing. - allPresent := true - - for _, tier := range thumbnailTiers { - tierName := fmt.Sprintf( - "%s%s.jpg", hashStr, tier.Suffix, - ) - if _, exists := existing[tierName]; !exists { - allPresent = false - - break - } - } - - if allPresent { - skipped++ - - continue - } - - // Read the original and generate missing tiers. - imgData, err := os.ReadFile( - filepath.Join(coverDir, name), - ) - if err != nil { - l.logger.Warn( - "could not read cover art for variant generation", - "file", name, "err", err, - ) - - continue - } - - if err := l.generateSizedVariants( - imgData, coverDir, hashStr, - ); err != nil { - l.logger.Warn( - "could not generate sized variants", - "file", name, "err", err, - ) - - continue - } - - generated++ - } - - l.logger.Info( - "sized variant generation complete", - "generated", generated, - "skipped", skipped, - "migrated", migrated, - ) - - return nil -} - -// migrateLegacyThumbs renames _thumb.jpg files to _md.jpg. -// Returns the number of files migrated. -func (l *Library) migrateLegacyThumbs( - coverDir string, - existing map[string]struct{}, -) int { - var migrated int - - for name := range existing { - if !strings.Contains(name, legacyThumbSuffix) { - continue - } - - // Derive the _md name from the legacy name. - mdName := strings.Replace( - name, legacyThumbSuffix, "_md", 1, - ) - - oldPath := filepath.Join(coverDir, name) - newPath := filepath.Join(coverDir, mdName) - - // Only rename if _md doesn't already exist. - if _, exists := existing[mdName]; exists { - // Both exist; remove the legacy file. - if err := os.Remove(oldPath); err != nil { - l.logger.Warn( - "could not remove legacy thumbnail", - "file", name, "err", err, - ) - } - - continue - } - - if err := os.Rename(oldPath, newPath); err != nil { - l.logger.Warn( - "could not migrate legacy thumbnail", - "from", name, "to", mdName, "err", err, - ) - - continue - } - - // Update the existing set so subsequent lookups - // see the new name. - delete(existing, name) - existing[mdName] = struct{}{} - - migrated++ - - l.logger.Debug( - "migrated legacy thumbnail", - "from", name, "to", mdName, - ) - } - - return migrated -} - -// extensionFromMIME returns a file extension for common image MIME types. -func extensionFromMIME(mimeType string) string { - switch mimeType { - case "image/jpeg": - return "jpg" - case "image/png": - return "png" - case "image/gif": - return "gif" - case "image/webp": - return "webp" - case "image/bmp": - return "bmp" - default: - return "jpg" // Default to jpg. - } -} diff --git a/backend/library/coverart_storage_test.go b/backend/library/coverart_storage_test.go new file mode 100644 index 0000000..2c2b60d --- /dev/null +++ b/backend/library/coverart_storage_test.go @@ -0,0 +1,90 @@ +package library + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "yellowjacket/backend/coverart" + "yellowjacket/backend/database/sql/sqlcgen" +) + +// TestScan_StoresOnlyCoverTiers pins the size decision: a scan writes +// the three rendered tiers and nothing else. +// +// The full-resolution image used to be written beside them, and on a +// real 2,057-album library that was 1,134 MB of a 1.4 GB covers +// directory against 110 MB for all three tiers together - 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 if a bigger one is ever +// wanted, which is where these came from. +func TestScan_StoresOnlyCoverTiers(t *testing.T) { + // Not parallel: YJ_HOME is process-wide, and this test needs the + // covers directory to itself. + t.Setenv("YJ_HOME", t.TempDir()) + + lib, db := setupTestLibrary(t) + + root, err := filepath.Abs("../../test_data/music_library_test") + if err != nil { + t.Fatalf("resolve fixture path: %v", err) + } + + library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{ + Name: "Fixtures", + Path: root, + }) + if err != nil { + t.Fatalf("create library: %v", err) + } + + lib.scanInternal(library.ID, library.Name, library.Path) + + coversDir, err := coverart.CoversDir() + if err != nil { + t.Fatalf("covers dir: %v", err) + } + + entries, err := os.ReadDir(coversDir) + if err != nil { + t.Fatalf("read covers dir: %v", err) + } + + if len(entries) == 0 { + t.Skip("fixture library produced no cover art; run make testdata") + } + + perTier := map[string]int{} + + for _, entry := range entries { + name := entry.Name() + base := coverart.BaseName(name) + + if base+filepath.Ext(name) == name { + t.Errorf("full-size cover written: %s", name) + + continue + } + + perTier[strings.TrimSuffix(strings.TrimPrefix(name, base), ".jpg")]++ + } + + for _, suffix := range coverart.Suffixes { + if perTier[suffix] == 0 { + t.Errorf("no %s tier written", suffix) + } + } + + // And what the database points at is a file that exists. + var stored string + if err := db.QueryRowWriter( + "SELECT file_path FROM cover_art LIMIT 1", + ).Scan(&stored); err != nil { + t.Fatalf("read cover_art path: %v", err) + } + + if _, err := os.Stat(stored); err != nil { + t.Errorf("cover_art.file_path names a file that is not there: %v", err) + } +} diff --git a/backend/library/crud.go b/backend/library/crud.go index 231b253..a7ddbf4 100644 --- a/backend/library/crud.go +++ b/backend/library/crud.go @@ -42,6 +42,8 @@ type RemovalHooks struct { // SetRemovalHooks provides optional hooks for cross-cutting // orchestration during RemoveLibrary. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (l *Library) SetRemovalHooks(h RemovalHooks) { l.mu.Lock() defer l.mu.Unlock() @@ -168,9 +170,7 @@ func (l *Library) RenameLibrary(id int64, newName string) error { // GetRemovalImpact returns pre-removal counts for the confirmation // dialog. All queries are read-only. func (l *Library) GetRemovalImpact(libraryID int64) (*RemovalImpact, error) { - // SAFETY: Hand-crafted SQL for track count. sqlc query CountAudioFilesByLibrary - // exists but we inline the remaining two for consistency. Parameterized. - trackCount, err := l.db.Queries.CountAudioFilesByLibrary(l.ctx, libraryID) + trackCount, err := l.db.Queries.CountAudioFiles(l.ctx, libraryID) if err != nil { return nil, fmt.Errorf("could not count tracks: %w", err) } @@ -251,40 +251,17 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) { if _, err := tx.ExecContext(l.ctx, ` UPDATE playlist_tracks SET phantom_title = sub.title, - phantom_artist = sub.artist, + phantom_artist = sub.artist_name, phantom_album = sub.album, - phantom_duration_ms = sub.duration, + phantom_duration_ms = sub.length_milliseconds, phantom_genre = sub.genre, phantom_cover_art_path = sub.cover_art_path, phantom_file_path = sub.file_path FROM ( - SELECT - pt.id AS pt_id, - af.file_path AS file_path, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist, - COALESCE(rg.name, '') AS album, - af.length_milliseconds AS duration, - 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 + SELECT pt.id AS pt_id, tm.* FROM playlist_tracks pt - JOIN audio_files af ON pt.audio_file_id = af.id - LEFT JOIN recordings r ON af.recording_id = r.id - LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id - LEFT JOIN ( - 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.library_id = ? + JOIN track_metadata tm ON tm.id = pt.audio_file_id + WHERE tm.library_id = ? ) sub WHERE playlist_tracks.id = sub.pt_id`, id); err != nil { return nil, fmt.Errorf("could not populate phantom metadata: %w", err) @@ -301,97 +278,44 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) { tracksDeleted, _ := result.RowsAffected() - // 7. Delete orphaned recording_genres (must run BEFORE recordings - // because recording_genres.recording_id references recordings.id). - // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. - if _, err := tx.ExecContext(l.ctx, - `DELETE FROM recording_genres WHERE recording_id NOT IN ( - SELECT DISTINCT recording_id FROM audio_files - )`); err != nil { - return nil, fmt.Errorf("could not delete orphaned recording_genres: %w", err) - } - - // 8. Delete orphaned release_group_recordings (must run BEFORE - // recordings because release_group_recordings.recording_id - // references recordings.id). - // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. - if _, err := tx.ExecContext(l.ctx, - `DELETE FROM release_group_recordings WHERE recording_id NOT IN ( - SELECT DISTINCT recording_id FROM audio_files - )`); err != nil { - return nil, fmt.Errorf("could not delete orphaned release_group_recordings: %w", err) - } - - // 9. Delete orphaned recordings (safe now that child tables are cleaned). - // SAFETY: Hand-crafted orphan cleanup SQL. Reference-counting delete - // with NOT IN subquery unsupported by sqlc. No user input. - if _, err := tx.ExecContext(l.ctx, - `DELETE FROM recordings WHERE id NOT IN ( - SELECT DISTINCT recording_id FROM audio_files - )`); err != nil { - return nil, fmt.Errorf("could not delete orphaned recordings: %w", err) - } - - // 10. Delete orphaned release_groups. + // 7. Sweep what the files left behind. This used to be eight + // statements in dependency order, because deleting a file cascaded + // to none of the five metadata tables it had created. file_genres + // cascades now, so what is left is the two tables that genuinely + // outlive a file and the genres nothing references. // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. result, err = tx.ExecContext(l.ctx, - `DELETE FROM release_groups WHERE id NOT IN ( - SELECT DISTINCT release_group_id FROM release_group_recordings + `DELETE FROM albums WHERE id NOT IN ( + SELECT DISTINCT album_id FROM audio_files WHERE album_id IS NOT NULL )`) if err != nil { - return nil, fmt.Errorf("could not delete orphaned release_groups: %w", err) + return nil, fmt.Errorf("could not delete empty albums: %w", err) } albumsRemoved, _ := result.RowsAffected() - // 11. Delete orphaned artist_credit_artists (must run BEFORE - // artist_credit because artist_credit_artist.credit_id references - // artist_credit.id). - // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. - if _, err := tx.ExecContext(l.ctx, - `DELETE FROM artist_credit_artist WHERE credit_id NOT IN ( - SELECT DISTINCT artist_credit_id FROM recordings - ) AND credit_id NOT IN ( - SELECT DISTINCT album_artist_credit_id FROM release_groups - WHERE album_artist_credit_id IS NOT NULL - )`); err != nil { - return nil, fmt.Errorf("could not delete orphaned artist_credit_artists: %w", err) - } - - // 12. Delete orphaned artist_credits (safe now that child table is cleaned). - // SAFETY: Hand-crafted orphan cleanup SQL. Dual-FK reference counting - // (recordings.artist_credit_id + release_groups.album_artist_credit_id) - // unsupported by sqlc. Parameterless. - if _, err := tx.ExecContext(l.ctx, - `DELETE FROM artist_credit WHERE id NOT IN ( - SELECT DISTINCT artist_credit_id FROM recordings - ) AND id NOT IN ( - SELECT DISTINCT album_artist_credit_id FROM release_groups - WHERE album_artist_credit_id IS NOT NULL - )`); err != nil { - return nil, fmt.Errorf("could not delete orphaned artist_credits: %w", err) - } - - // 13. Delete orphaned artists. + // Artists after albums: an artist is unreferenced only once the + // albums pointing at it are gone. // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. result, err = tx.ExecContext(l.ctx, `DELETE FROM artists WHERE id NOT IN ( - SELECT DISTINCT artist_id FROM artist_credit_artist + SELECT DISTINCT artist_id FROM audio_files WHERE artist_id IS NOT NULL + ) AND id NOT IN ( + SELECT DISTINCT artist_id FROM albums WHERE artist_id IS NOT NULL )`) if err != nil { - return nil, fmt.Errorf("could not delete orphaned artists: %w", err) + return nil, fmt.Errorf("could not delete unreferenced artists: %w", err) } artistsRemoved, _ := result.RowsAffected() - // 14. Delete orphaned genres. // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. result, err = tx.ExecContext(l.ctx, `DELETE FROM genres WHERE id NOT IN ( - SELECT DISTINCT genre_id FROM recording_genres + SELECT DISTINCT genre_id FROM file_genres )`) if err != nil { - return nil, fmt.Errorf("could not delete orphaned genres: %w", err) + return nil, fmt.Errorf("could not delete unused genres: %w", err) } genresRemoved, _ := result.RowsAffected() @@ -401,7 +325,7 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) { // Parameterless. rows, err := tx.QueryContext(l.ctx, `SELECT file_path FROM cover_art WHERE id NOT IN ( - SELECT DISTINCT cover_art_id FROM release_groups + SELECT DISTINCT cover_art_id FROM albums WHERE cover_art_id IS NOT NULL )`) if err != nil { @@ -429,7 +353,7 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) { // SAFETY: Hand-crafted orphan cleanup SQL. Parameterless. if _, err := tx.ExecContext(l.ctx, `DELETE FROM cover_art WHERE id NOT IN ( - SELECT DISTINCT cover_art_id FROM release_groups + SELECT DISTINCT cover_art_id FROM albums WHERE cover_art_id IS NOT NULL )`); err != nil { return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err) diff --git a/backend/library/filepaths_test.go b/backend/library/filepaths_test.go index c239b6f..6921501 100644 --- a/backend/library/filepaths_test.go +++ b/backend/library/filepaths_test.go @@ -1,9 +1,9 @@ package library import ( - "database/sql" "testing" + "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" ) @@ -32,22 +32,6 @@ func seedAlbumsAndGenres(t *testing.T, lib *Library) (albumIDs []int64, libraryI t.Fatalf("create other library: %v", err) } - ac, err := q.UpsertArtistCredit(ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - genreIDs := map[string]int64{} - - for _, name := range []string{"Ambient", "Baroque"} { - g, err := q.UpsertGenre(ctx, name) - if err != nil { - t.Fatalf("upsert genre %s: %v", name, err) - } - - genreIDs[name] = g.ID - } - // Two albums; the second lives in the other library so the // library-scoped variants have something to exclude. type spec struct { @@ -66,63 +50,32 @@ func seedAlbumsAndGenres(t *testing.T, lib *Library) (albumIDs []int64, libraryI {"Second", "B1", "/other/b1.mp3", other.ID, 1, 1, []string{"Baroque"}}, } - byAlbum := map[string]int64{} + seen := map[string]bool{} for _, s := range specs { - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: s.track, - ArtistCreditID: ac.ID, + database.InsertTestTrack(t, lib.db, database.TestTrack{ + FilePath: s.path, + Title: s.track, + Artist: "Test Artist", + Album: s.album, + Genres: s.genres, + TrackNumber: s.number, + DiscNumber: s.disc, + LibraryID: s.library, + LengthMs: 1000, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - rgID, ok := byAlbum[s.album] + if !seen[s.album] { + seen[s.album] = true - if !ok { - rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: s.album, - AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true}, - }) - if err != nil { - t.Fatalf("upsert release group: %v", err) + var id int64 + if err := lib.db.QueryRowWriter( + "SELECT id FROM albums WHERE name = ?", s.album, + ).Scan(&id); err != nil { + t.Fatalf("album id for %q: %v", s.album, err) } - rgID = rg.ID - byAlbum[s.album] = rgID - albumIDs = append(albumIDs, rgID) - } - - if _, err := q.CreateReleaseGroupRecording( - ctx, sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: rgID, - RecordingID: rec.ID, - TrackNumber: sql.NullInt64{Int64: s.number, Valid: true}, - DiscNumber: sql.NullInt64{Int64: s.disc, Valid: true}, - }, - ); err != nil { - t.Fatalf("link recording: %v", err) - } - - if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: s.path, - LengthMilliseconds: 1000, - RecordingID: rec.ID, - LibraryID: s.library, - Basename: s.track + ".mp3", - }); err != nil { - t.Fatalf("create audio file: %v", err) - } - - for _, g := range s.genres { - if err := q.CreateRecordingGenre( - ctx, sqlcgen.CreateRecordingGenreParams{ - RecordingID: rec.ID, - GenreID: genreIDs[g], - }, - ); err != nil { - t.Fatalf("link genre: %v", err) - } + albumIDs = append(albumIDs, id) } } @@ -237,9 +190,6 @@ func TestGetFilePathsByGenres_Empty(t *testing.T) { func seedRecordingMBIDs(t *testing.T, lib *Library) (tagged, shared string) { t.Helper() - ctx := lib.ctx - q := lib.db.Queries - tagged = "11111111-1111-1111-1111-111111111111" shared = "22222222-2222-2222-2222-222222222222" @@ -249,22 +199,12 @@ func seedRecordingMBIDs(t *testing.T, lib *Library) (tagged, shared string) { "/other/b1.mp3": shared, } - files, err := q.GetAllAudioFiles(ctx) - if err != nil { - t.Fatalf("get audio files: %v", err) - } - - for _, f := range files { - mbid, ok := byPath[f.FilePath] - if !ok { - continue - } - - if err := q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{ - Mbid: sql.NullString{String: mbid, Valid: true}, - ID: f.RecordingID, - }); err != nil { - t.Fatalf("set recording mbid: %v", err) + for path, mbid := range byPath { + if _, err := lib.db.ExecContext( + "UPDATE audio_files SET recording_mbid = ? WHERE file_path = ?", + mbid, path, + ); err != nil { + t.Fatalf("set recording mbid for %s: %v", path, err) } } diff --git a/backend/library/library.go b/backend/library/library.go index 5b7cd98..4087933 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -33,30 +33,24 @@ import ( // SQLite's fsync cost but increase the blast radius of a failed commit. const scanBatchSize = 300 -// entityCache holds recently resolved database entities so that -// repeated upserts for the same artist/album/cover art within a scan -// can be served from memory instead of hitting the database. +// entityCache holds recently resolved database rows so repeated +// upserts for the same artist/album/cover art within a scan can be +// served from memory instead of hitting the database. // It is only accessed from the single DB-writer goroutine and // therefore needs no synchronisation. type entityCache struct { - artistCredits map[string]sqlcgen.ArtistCredit - artists map[string]sqlcgen.Artist - releaseGroups map[string]sqlcgen.ReleaseGroup - coverArt map[string]sqlcgen.CoverArt - genres map[string]sqlcgen.Genre - // linkedCredits tracks artist-credit-artist links already created - // so we skip the duplicate INSERT. Key is "artistID:creditID". - linkedCredits map[string]struct{} + artists map[string]sqlcgen.Artist + albums map[string]sqlcgen.Album + coverArt map[string]sqlcgen.CoverArt + genres map[string]sqlcgen.Genre } func newEntityCache() *entityCache { return &entityCache{ - artistCredits: make(map[string]sqlcgen.ArtistCredit), - artists: make(map[string]sqlcgen.Artist), - releaseGroups: make(map[string]sqlcgen.ReleaseGroup), - coverArt: make(map[string]sqlcgen.CoverArt), - genres: make(map[string]sqlcgen.Genre), - linkedCredits: make(map[string]struct{}), + artists: make(map[string]sqlcgen.Artist), + albums: make(map[string]sqlcgen.Album), + coverArt: make(map[string]sqlcgen.CoverArt), + genres: make(map[string]sqlcgen.Genre), } } @@ -132,6 +126,8 @@ type Library struct { // SetRescanHooks provides optional hooks for cross-cutting // orchestration during FullRescan. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (l *Library) SetRescanHooks(h RescanHooks) { l.mu.Lock() defer l.mu.Unlock() @@ -141,6 +137,8 @@ func (l *Library) SetRescanHooks(h RescanHooks) { // SetScanHooks provides optional hooks for cross-cutting // orchestration after each library scan. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (l *Library) SetScanHooks(h ScanHooks) { l.mu.Lock() defer l.mu.Unlock() @@ -179,9 +177,13 @@ func NewLibrary( // operation. The caller must call ReleasePipelineLock when done. // If a scan is currently in progress, AcquirePipelineLock blocks // until it completes (and vice versa). +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (l *Library) AcquirePipelineLock() { l.pipelineMu.Lock() } // ReleasePipelineLock releases the pipeline mutex after a tag write. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (l *Library) ReleasePipelineLock() { l.pipelineMu.Unlock() } // ServiceStartup is v3's service lifecycle hook: it runs once the @@ -359,7 +361,7 @@ func (l *Library) scanInternal( // --- Phase 1: load existing files from DB (per-library) --- loadStart := time.Now() - existingFiles, err := l.db.Queries.GetAudioFilesByLibrary( + existingFiles, err := l.db.Queries.GetAudioFilesInLibrary( l.ctx, libraryID, ) if err != nil { @@ -505,7 +507,11 @@ func (l *Library) scanInternal( audioFile, diskModTime, diskSize, ) - if audioFile.RecordingID == 0 || contentChanged { + // A file with no title has never had its tags read + // (the row exists, the metadata pass did not run), + // which is the same "needs metadata" signal the + // recording_id == 0 test used to be. + if audioFile.Title == "" || contentChanged { l.logger.Debug( "file needs metadata update", "path", absoluteFilePath, @@ -979,7 +985,7 @@ func (l *Library) scanInternal( // last owner of — clean those up too, so a swapped-out artist // doesn't leave stale rows behind for the Explore index to // keep pointing at. - l.pruneOrphanedMetadata() + l.pruneEmptyEntities() } // --- Phase 6: repopulate + resolve phantom playlist tracks --- @@ -993,22 +999,6 @@ func (l *Library) scanInternal( l.scanHooks.ResolvePhantoms() } - // --- Phase 7: post-scan variant generation --- - if !cancelled { - variantStart := time.Now() - - if err := l.generateMissingSizedVariants(); err != nil { - l.logger.Warn( - "could not generate missing sized variants", - "err", err, - ) - - metrics.addWarning("", "variant", err) - } - - metrics.PostScanVariants = time.Since(variantStart) - } - // --- Finalize --- metrics.Added = added.Load() metrics.Updated = updated.Load() @@ -1129,18 +1119,22 @@ func (l *Library) flushStatBackfill( ) } -// pruneOrphanedMetadata removes recording/release_group/artist_credit/ -// artist rows left behind once the audio_files rows that justified them -// are gone — deleting an audio_files row doesn't cascade to any of -// these. Runs in dependency order: recordings first (and their -// release_group_recordings/recording_genres rows), then release groups -// left with no recordings, then artist credits left with no -// recordings/release groups, then artists left with no credits. Best -// effort — logs and continues on error rather than failing the scan. -func (l *Library) pruneOrphanedMetadata() { +// pruneEmptyEntities removes albums and artists left with nothing +// pointing at them. +// +// This used to be four sweeps in dependency order - recordings and +// their two link tables, then release groups with no recordings, then +// artist credits, then artists - because deleting an audio_files row +// cascaded to none of them. Two of those tables are gone and the third +// (file_genres) cascades, so what is left is the two tables that +// genuinely outlive a file: an album whose last track was removed, and +// an artist whose last album was. +// +// Best effort: logs and continues rather than failing the scan. +func (l *Library) pruneEmptyEntities() { tx, err := l.db.BeginTx() if err != nil { - l.logger.Warn("could not begin orphaned metadata cleanup transaction", "err", err) + l.logger.Warn("could not begin entity cleanup transaction", "err", err) return } @@ -1149,96 +1143,58 @@ func (l *Library) pruneOrphanedMetadata() { txq := l.db.Queries.WithTx(tx) - recordingIDs, err := txq.GetOrphanedRecordingIDs(l.ctx) + albumIDs, err := txq.GetEmptyAlbumIDs(l.ctx) if err != nil { - l.logger.Warn("could not find orphaned recordings", "err", err) + l.logger.Warn("could not find empty albums", "err", err) return } - for _, id := range recordingIDs { - if err := txq.DeleteReleaseGroupRecordingsByRecording(l.ctx, id); err != nil { - l.logger.Warn( - "could not delete release group links for orphaned recording", - "id", - id, - "err", - err, - ) - } - - if err := txq.DeleteRecordingGenres(l.ctx, id); err != nil { - l.logger.Warn("could not delete genres for orphaned recording", "id", id, "err", err) - } - - if err := txq.DeleteRecording(l.ctx, id); err != nil { - l.logger.Warn("could not delete orphaned recording", "id", id, "err", err) + for _, id := range albumIDs { + if err := txq.DeleteAlbum(l.ctx, id); err != nil { + l.logger.Warn("could not delete empty album", "id", id, "err", err) } } - releaseGroupIDs, err := txq.GetOrphanedReleaseGroupIDs(l.ctx) + // Artists after albums: an artist is unreferenced only once the + // albums pointing at it are gone. + artistIDs, err := txq.GetUnreferencedArtistIDs(l.ctx) if err != nil { - l.logger.Warn("could not find orphaned release groups", "err", err) - - return - } - - for _, id := range releaseGroupIDs { - if err := txq.DeleteReleaseGroup(l.ctx, id); err != nil { - l.logger.Warn("could not delete orphaned release group", "id", id, "err", err) - } - } - - artistCreditIDs, err := txq.GetOrphanedArtistCreditIDs(l.ctx) - if err != nil { - l.logger.Warn("could not find orphaned artist credits", "err", err) - - return - } - - for _, id := range artistCreditIDs { - if err := txq.DeleteArtistCreditArtistByCredit(l.ctx, id); err != nil { - l.logger.Warn( - "could not delete artist links for orphaned artist credit", - "id", - id, - "err", - err, - ) - } - - if err := txq.DeleteArtistCredit(l.ctx, id); err != nil { - l.logger.Warn("could not delete orphaned artist credit", "id", id, "err", err) - } - } - - artistIDs, err := txq.GetOrphanedArtistIDs(l.ctx) - if err != nil { - l.logger.Warn("could not find orphaned artists", "err", err) + l.logger.Warn("could not find unreferenced artists", "err", err) return } for _, id := range artistIDs { if err := txq.DeleteArtist(l.ctx, id); err != nil { - l.logger.Warn("could not delete orphaned artist", "id", id, "err", err) + l.logger.Warn("could not delete unreferenced artist", "id", id, "err", err) } } - if err := tx.Commit(); err != nil { - l.logger.Warn("could not commit orphaned metadata cleanup", "err", err) + genreIDs, err := txq.GetUnusedGenreIDs(l.ctx) + if err != nil { + l.logger.Warn("could not find unused genres", "err", err) return } - if len(recordingIDs) > 0 || len(releaseGroupIDs) > 0 || len(artistCreditIDs) > 0 || - len(artistIDs) > 0 { - l.logger.Info( - "pruned orphaned library metadata", - "recordings", len(recordingIDs), - "releaseGroups", len(releaseGroupIDs), - "artistCredits", len(artistCreditIDs), + for _, id := range genreIDs { + if err := txq.DeleteGenre(l.ctx, id); err != nil { + l.logger.Warn("could not delete unused genre", "id", id, "err", err) + } + } + + if err := tx.Commit(); err != nil { + l.logger.Warn("could not commit entity cleanup", "err", err) + + return + } + + if len(albumIDs) > 0 || len(artistIDs) > 0 || len(genreIDs) > 0 { + l.logger.Info("pruned empty library entities", + "albums", len(albumIDs), "artists", len(artistIDs), + "genres", len(genreIDs), ) } } @@ -1580,13 +1536,9 @@ func (l *Library) saveAudioFile( ), ) - // Process metadata and create related records. - recordingID, err := l.processMetadata( - q, tx, cache, metrics, result, thumbChan, - ) - if err != nil { - return fmt.Errorf("could not process metadata: %w", err) - } + // Resolve the rows this file shares with others: its artist and + // its album. Everything else about it is a column on the file. + entities := l.resolveTagEntities(q, cache, metrics, result, thumbChan) props := result.audioProps if props == nil { @@ -1598,8 +1550,6 @@ func (l *Library) saveAudioFile( tags = &metadata.TrackMetadata{} } - basename := filepath.Base(result.absolutePath) - groupKey := autotag.GroupKey( result.libraryID, result.absolutePath, @@ -1611,27 +1561,44 @@ func (l *Library) saveAudioFile( tagStatus = "user_confirmed" } - af, err := q.CreateAudioFileWithGroupKey( - l.ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{ - FilePath: result.absolutePath, - LengthMilliseconds: result.lengthMillis, + artistCredit := tags.Artist + if artistCredit == "" { + artistCredit = "Unknown Artist" + } + + title := l.getRecordingName(tags, result.absolutePath) + + af, err := q.CreateAudioFile( + l.ctx, sqlcgen.CreateAudioFileParams{ + FilePath: result.absolutePath, + LibraryID: result.libraryID, FileTypeID: int64( slices.Index( metadata.SupportedFileExtensions, result.fileType, ), ), - RecordingID: recordingID, - SampleRate: int64(props.SampleRate), - BitDepth: int64(props.BitDepth), - Channels: int64(props.Channels), - Bitrate: int64(props.Bitrate), - FileSize: props.FileSize, - Basename: basename, - LibraryID: result.libraryID, - GroupKey: groupKey, - TagStatus: tagStatus, - ModifiedAt: result.modTime, + LengthMilliseconds: result.lengthMillis, + SampleRate: int64(props.SampleRate), + BitDepth: int64(props.BitDepth), + Channels: int64(props.Channels), + Bitrate: int64(props.Bitrate), + FileSize: props.FileSize, + Title: title, + ArtistCredit: artistCredit, + ArtistID: entities.artistID, + AlbumID: entities.albumID, + TrackNumber: toNullInt64(tags.TrackNumber), + DiscNumber: toNullInt64(tags.DiscNumber), + TotalTracks: toNullInt64(tags.TotalTracks), + Year: toNullInt64(tags.Year), + Composer: tags.Composer, + Comment: tags.Comment, + RecordingMbid: toNullString(tags.RecordingMBID), + Basename: filepath.Base(result.absolutePath), + GroupKey: groupKey, + ModifiedAt: result.modTime, + TagStatus: tagStatus, }) if err != nil { return fmt.Errorf( @@ -1639,6 +1606,8 @@ func (l *Library) saveAudioFile( ) } + l.linkFileGenres(q, cache, tags.Genre, af.ID) + if err := q.UpsertTaggingItemOnTrackAdd( l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{ GroupKey: groupKey, @@ -1658,21 +1627,12 @@ func (l *Library) saveAudioFile( } // Index in FTS5 search_index. - title := l.getRecordingName(tags, result.absolutePath) - - artistName := tags.Artist - if artistName == "" { - artistName = "Unknown Artist" - } - - album := tags.Album - // SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized. if _, err := tx.ExecContext( l.ctx, `INSERT INTO search_index(rowid, file_path, title, artist, album) VALUES (?, ?, ?, ?, ?)`, - af.ID, result.absolutePath, title, artistName, album, + af.ID, result.absolutePath, title, artistCredit, tags.Album, ); err != nil { l.logger.Warn( "could not index audio file in FTS", @@ -1706,22 +1666,42 @@ func (l *Library) updateAudioFileMetadata( "file-id", result.existingFileID, ) - // Process metadata and create related records. - recordingID, err := l.processMetadata( - q, tx, cache, metrics, result, thumbChan, - ) - if err != nil { - return fmt.Errorf("could not process metadata: %w", err) + tags := result.tags + if tags == nil { + tags = &metadata.TrackMetadata{} } + // Resolve the file's artist and album from the tags as they are + // now. This used to create a *new* recording row and repoint the + // file at it, abandoning the old one - which is where 812 orphaned + // rows and every phantom "you own this" in a real library came + // from. The file's tags are its own columns, so a retag is an + // UPDATE and there is nothing left behind to strand. + entities := l.resolveTagEntities(q, cache, metrics, result, thumbChan) + props := result.audioProps if props == nil { props = &metadata.AudioProperties{} } - if err := q.UpdateAudioFileRecording( - l.ctx, sqlcgen.UpdateAudioFileRecordingParams{ - RecordingID: recordingID, + artistCredit := tags.Artist + if artistCredit == "" { + artistCredit = "Unknown Artist" + } + + if err := q.UpdateAudioFileTags( + l.ctx, sqlcgen.UpdateAudioFileTagsParams{ + Title: l.getRecordingName(tags, result.absolutePath), + ArtistCredit: artistCredit, + ArtistID: entities.artistID, + AlbumID: entities.albumID, + TrackNumber: toNullInt64(tags.TrackNumber), + DiscNumber: toNullInt64(tags.DiscNumber), + TotalTracks: toNullInt64(tags.TotalTracks), + Year: toNullInt64(tags.Year), + Composer: tags.Composer, + Comment: tags.Comment, + RecordingMbid: toNullString(tags.RecordingMBID), SampleRate: int64(props.SampleRate), BitDepth: int64(props.BitDepth), Channels: int64(props.Channels), @@ -1732,18 +1712,39 @@ func (l *Library) updateAudioFileMetadata( ID: result.existingFileID, }); err != nil { return fmt.Errorf( - "could not update audio file recording: %w", err, + "could not update audio file tags: %w", err, ) } + // Genres are relinked wholesale: the tag is the whole truth about + // which genres a file carries, so a genre dropped from the tag has + // to be dropped from the link table too. + if err := q.DeleteFileGenres(l.ctx, result.existingFileID); err != nil { + l.logger.Warn("could not clear file genres", + "path", result.absolutePath, "err", err) + } + + l.linkFileGenres(q, cache, tags.Genre, result.existingFileID) + // Re-index in FTS5 search_index. // With contentless_delete=1 (migration 8), DeleteSearchIndex // now works for individual row removal. For scan updates we // still do delete + reinsert; Phase 16 will use the same // pattern for inline tag edits. - tags := result.tags - if tags == nil { - tags = &metadata.TrackMetadata{} + // A file another tagger stamped with MBIDs since import is only + // discovered here — the insert path is what sets tag_status, so + // without this the file stays 'untagged' forever and its folder + // keeps asking to be tagged. + if tags.RecordingMBID != "" { + if err := q.PromoteAudioFileTagStatusIfUntagged( + l.ctx, result.existingFileID, + ); err != nil { + l.logger.Warn( + "could not promote tag status after metadata update", + "path", result.absolutePath, + "err", err, + ) + } } if err := l.maybeRebindTaggingGroup(q, result, tags); err != nil { @@ -1793,172 +1794,245 @@ func (l *Library) updateAudioFileMetadata( return nil } -// processMetadata creates all related database records for metadata -// and returns the recording ID. It uses the provided queries object -// (which may be transaction-scoped) and the entity cache to avoid -// redundant upserts for repeated artist/album/cover-art values. -// When thumbChan is non-nil, thumbnail generation is dispatched -// asynchronously. -func (l *Library) processMetadata( +// trackEntities are the shared rows a file's tags resolve to: the +// artist and album it belongs to, and the cover art of that album. +// +// This replaced processMetadata, which created a `recordings` row per +// file plus an artist_credit, an artist_credit_artist link and a +// release_group_recordings link, then wrote MBIDs onto three of them +// with raw SQL. A file's tags are columns on the file now, so the only +// rows that still have to be *shared* are the two that genuinely are: +// the album several files belong to, and the artist several albums do. +type trackEntities struct { + artistID sql.NullInt64 + albumID sql.NullInt64 +} + +// resolveTagEntities upserts the artist and album a file's tags name, +// and returns their ids for the file row. +func (l *Library) resolveTagEntities( q *sqlcgen.Queries, - tx *sql.Tx, cache *entityCache, metrics *ScanMetrics, result importResult, thumbChan chan<- thumbnailWork, -) (int64, error) { +) trackEntities { tags := result.tags if tags == nil { tags = &metadata.TrackMetadata{} } - // 1. Handle cover art (if present). - coverArtID := l.processCoverArt( - q, cache, metrics, tags, thumbChan, - ) - - // 2. Get or create the artist credit for the track. The credit - // text is the full tagged string (e.g. "Lana Del Rey ft. Sean - // Lennon") and is kept only for display; the artist *entity* it - // links to is the primary artist, resolved cleanly by primaryArtist - // so featured-artist credits don't fork into their own bogus artist - // rows (all sharing the primary's single MBID). - creditText := tags.Artist - if creditText == "" { - creditText = "Unknown Artist" - } - - artistCredit, err := l.cachedUpsertArtistCredit( - q, cache, creditText, - ) - if err != nil { - return 0, fmt.Errorf( - "could not upsert artist credit: %w", err, - ) - } + coverArtID := l.processCoverArt(q, cache, metrics, tags, thumbChan) + // The track artist. primaryArtist collapses a featured-artist + // credit to the artist the MBIDs actually identify, so "A feat. B" + // does not fork into its own artist row sharing A's MBID. primaryName, primaryMBID := primaryArtist(tags) + artist := l.cachedUpsertArtist(q, cache, primaryName, primaryMBID) - l.cachedLinkArtist(q, cache, metrics, primaryName, artistCredit.ID) - - // 3. Get or create artist credit for album artist. - albumArtistCreditID := l.resolveAlbumArtistCredit( - q, cache, metrics, tags, artistCredit.ID, - ) - - // 4. Get or create release group (album). - releaseGroupID := l.resolveReleaseGroup( - q, cache, tags, albumArtistCreditID, coverArtID, - ) - - // 5. Create recording. - recording, err := q.CreateRecordingFull( - l.ctx, sqlcgen.CreateRecordingFullParams{ - Name: l.getRecordingName( - tags, result.absolutePath, - ), - ArtistCreditID: artistCredit.ID, - TrackNumber: toNullInt64(tags.TrackNumber), - DiscNumber: toNullInt64(tags.DiscNumber), - Year: toNullInt64(tags.Year), - Genre: toNullString(tags.Genre), - Composer: toNullString(tags.Composer), - Lyrics: toNullString(tags.Lyrics), - Comment: toNullString(tags.Comment), - }, - ) - if err != nil { - return 0, fmt.Errorf( - "could not create recording: %w", err, - ) + entities := trackEntities{} + if artist.ID > 0 { + entities.artistID = sql.NullInt64{Int64: artist.ID, Valid: true} } - // 6. Link recording to genres. - l.linkRecordingGenres(q, cache, tags.Genre, recording.ID) + if tags.Album == "" { + return entities + } - // 7. Link recording to release group. - if releaseGroupID.Valid { - _, err = q.CreateReleaseGroupRecording( - l.ctx, - sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: releaseGroupID.Int64, - RecordingID: recording.ID, - TrackNumber: toNullInt64(tags.TrackNumber), - DiscNumber: toNullInt64(tags.DiscNumber), - TotalTracks: toNullInt64(tags.TotalTracks), - }, - ) - if err != nil { - l.logger.Warn( - "could not link recording to release group", - "err", err, - ) + // The album artist, which is the track artist unless the tags say + // otherwise. + albumCredit := tags.AlbumArtist + albumArtistID := entities.artistID + + if albumCredit == "" || albumCredit == tags.Artist { + albumCredit = tags.Artist + } else { + albumArtist := l.cachedUpsertArtist(q, cache, albumCredit, tags.AlbumArtistMBID) + if albumArtist.ID > 0 { + albumArtistID = sql.NullInt64{Int64: albumArtist.ID, Valid: true} } } - // 7. Update MusicBrainz IDs (if present in tags). - if releaseGroupID.Valid { - l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, releaseGroupID.Int64, recording.ID) - } else { - l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, 0, recording.ID) + album := l.cachedUpsertAlbum(q, cache, albumParams{ + name: tags.Album, + credit: albumCredit, + artistID: albumArtistID, + year: toNullInt64(tags.Year), + coverArtID: coverArtID, + }) + if album.ID == 0 { + return entities } - return recording.ID, nil + entities.albumID = sql.NullInt64{Int64: album.ID, Valid: true} + l.stampAlbumMBID(q, cache, album, tags) + + return entities } -// updateMBIDs writes MusicBrainz IDs from audio file tags to the -// corresponding database entities. Uses raw SQL since the sqlc -// queries predate the mbid columns. Skips silently if tags have -// no MBIDs. -func (l *Library) updateMBIDs( - tx *sql.Tx, +// albumParams is what an album upsert needs from a file's tags. +type albumParams struct { + name string + credit string + artistID sql.NullInt64 + year sql.NullInt64 + coverArtID sql.NullInt64 +} + +// cachedUpsertArtist returns the artist row for a name, upserting it +// once per scan. The MBID is written on the way in rather than by a +// separate UPDATE afterwards. +func (l *Library) cachedUpsertArtist( + q *sqlcgen.Queries, cache *entityCache, + name, mbid string, +) sqlcgen.Artist { + if name == "" { + name = "Unknown Artist" + } + + if cached, ok := cache.artists[name]; ok { + if mbid != "" && !cached.Mbid.Valid { + if err := q.SetArtistMBID(l.ctx, sqlcgen.SetArtistMBIDParams{ + Mbid: sql.NullString{String: mbid, Valid: true}, + ID: cached.ID, + }); err == nil { + cached.Mbid = sql.NullString{String: mbid, Valid: true} + cache.artists[name] = cached + } + } + + return cached + } + + artist, err := q.UpsertArtist(l.ctx, sqlcgen.UpsertArtistParams{ + Name: name, + Mbid: toNullString(mbid), + }) + if err != nil { + l.logger.Warn("could not upsert artist", "artist", name, "err", err) + + return sqlcgen.Artist{} + } + + cache.artists[name] = artist + + return artist +} + +// cachedUpsertAlbum returns the album row for (name, credit), upserting +// it once per scan and filling in cover art the first time a file +// carries some. +func (l *Library) cachedUpsertAlbum( + q *sqlcgen.Queries, + cache *entityCache, + p albumParams, +) sqlcgen.Album { + // The key is the album's identity - name and credit - so two + // albums of the same name by different artists do not collide. + cacheKey := p.name + "\x00" + p.credit + + if cached, ok := cache.albums[cacheKey]; ok { + if p.coverArtID.Valid && !cached.CoverArtID.Valid { + if err := q.SetAlbumCoverArt(l.ctx, sqlcgen.SetAlbumCoverArtParams{ + CoverArtID: p.coverArtID, + ID: cached.ID, + }); err != nil { + l.logger.Warn("could not update album cover art", "err", err) + } else { + cached.CoverArtID = p.coverArtID + cache.albums[cacheKey] = cached + } + } + + return cached + } + + album, err := q.UpsertAlbum(l.ctx, sqlcgen.UpsertAlbumParams{ + Name: p.name, + ArtistCredit: p.credit, + ArtistID: p.artistID, + Year: p.year, + CoverArtID: p.coverArtID, + }) + if err != nil { + l.logger.Warn("could not upsert album", "album", p.name, "err", err) + + return sqlcgen.Album{} + } + + cache.albums[cacheKey] = album + + return album +} + +// stampAlbumMBID writes the album's MusicBrainz identity from the tags. +// +// Many taggers write MUSICBRAINZ_ALBUMID (a specific release) but not +// MUSICBRAINZ_RELEASEGROUPID (the abstract release group everything +// else is keyed by) - without the second branch, a genuinely MBID- +// tagged album shows as "library only" forever. A scan cannot afford a +// live MusicBrainz call to resolve release -> release group, so the +// release MBID is stashed for BackfillReleaseGroupMBIDs to resolve in +// the background. +func (l *Library) stampAlbumMBID( + q *sqlcgen.Queries, + cache *entityCache, + album sqlcgen.Album, tags *metadata.TrackMetadata, - artistName string, - artistMBID string, - releaseGroupID int64, - recordingID int64, ) { - // Artist MBID (the primary artist's, resolved by primaryArtist). - if artistMBID != "" { - if artist, ok := cache.artists[artistName]; ok { - _, _ = tx.ExecContext(l.ctx, - "UPDATE artists SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')", - artistMBID, artist.ID, - ) + if album.Mbid.Valid && album.Mbid.String != "" { + return + } + + switch { + case tags.ReleaseGroupMBID != "": + if err := q.SetAlbumMBID(l.ctx, sqlcgen.SetAlbumMBIDParams{ + Mbid: sql.NullString{String: tags.ReleaseGroupMBID, Valid: true}, + ID: album.ID, + }); err != nil { + l.logger.Warn("could not set album mbid", "err", err) + + return + } + + album.Mbid = sql.NullString{String: tags.ReleaseGroupMBID, Valid: true} + cache.albums[album.Name+"\x00"+album.ArtistCredit] = album + case tags.ReleaseMBID != "" && !album.PendingReleaseMbid.Valid: + if err := q.SetAlbumPendingReleaseMBID( + l.ctx, sqlcgen.SetAlbumPendingReleaseMBIDParams{ + PendingReleaseMbid: sql.NullString{String: tags.ReleaseMBID, Valid: true}, + ID: album.ID, + }, + ); err != nil { + l.logger.Warn("could not set pending release mbid", "err", err) } } +} - // Release group MBID. - if tags.ReleaseGroupMBID != "" && releaseGroupID > 0 { - _, _ = tx.ExecContext(l.ctx, - "UPDATE release_groups SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')", - tags.ReleaseGroupMBID, releaseGroupID, - ) - } else if tags.ReleaseMBID != "" && releaseGroupID > 0 { - // Many taggers write MUSICBRAINZ_ALBUMID (a specific release) - // but not MUSICBRAINZ_RELEASEGROUPID (the abstract release - // group everything else on this page is keyed by) — without - // this, a genuinely MBID-tagged album shows as "library only" - // forever. A scan can't afford a live MusicBrainz call to - // resolve release->release-group here, so the release MBID is - // stashed for `explore.Service.BackfillReleaseGroupMBIDs` to - // resolve in the background, the same way discography - // enrichment is deferred out of the scan path. - _, _ = tx.ExecContext(l.ctx, - "UPDATE release_groups SET pending_release_mbid = ? "+ - "WHERE id = ? AND (mbid IS NULL OR mbid = '') "+ - "AND (pending_release_mbid IS NULL OR pending_release_mbid = '')", - tags.ReleaseMBID, releaseGroupID, - ) - } +// linkFileGenres parses the raw genre string and links the file to each +// genre it names. +func (l *Library) linkFileGenres( + q *sqlcgen.Queries, + cache *entityCache, + rawGenre string, + audioFileID int64, +) { + for _, name := range metadata.ParseGenres(rawGenre) { + genre, err := l.cachedUpsertGenre(q, cache, name) + if err != nil { + l.logger.Warn("could not upsert genre", "genre", name, "err", err) - // Recording MBID. - if tags.RecordingMBID != "" && recordingID > 0 { - _, _ = tx.ExecContext(l.ctx, - "UPDATE recordings SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')", - tags.RecordingMBID, recordingID, - ) + continue + } + + if err := q.LinkFileGenre(l.ctx, sqlcgen.LinkFileGenreParams{ + AudioFileID: audioFileID, + GenreID: genre.ID, + }); err != nil { + l.logger.Warn("could not link file to genre", + "genre", name, "audioFileID", audioFileID, "err", err) + } } } @@ -2072,91 +2146,6 @@ func (l *Library) processCoverArt( return sql.NullInt64{Int64: ca.ID, Valid: true} } -// cachedUpsertArtistCredit returns the artist credit for the given -// name, using the cache when possible. -func (l *Library) cachedUpsertArtistCredit( - q *sqlcgen.Queries, - cache *entityCache, - name string, -) (sqlcgen.ArtistCredit, error) { - if cached, ok := cache.artistCredits[name]; ok { - return cached, nil - } - - ac, err := q.UpsertArtistCredit(l.ctx, name) - if err != nil { - return sqlcgen.ArtistCredit{}, err - } - - cache.artistCredits[name] = ac - - return ac, nil -} - -// cachedLinkArtist upserts the artist record and creates the -// artist-credit-artist link, skipping work already done. -// UNIQUE constraint violations are silently ignored (link already -// exists in the database). Other errors are recorded as scan warnings. -func (l *Library) cachedLinkArtist( - q *sqlcgen.Queries, - cache *entityCache, - metrics *ScanMetrics, - name string, - creditID int64, -) { - artist, ok := cache.artists[name] - if !ok { - var err error - - artist, err = q.UpsertArtist(l.ctx, name) - if err != nil { - l.logger.Warn( - "could not upsert artist", "err", err, - ) - - return - } - - cache.artists[name] = artist - } - - linkKey := fmt.Sprintf("%d:%d", artist.ID, creditID) - if _, done := cache.linkedCredits[linkKey]; done { - return - } - - _, err := q.CreateArtistCreditArtist( - l.ctx, - sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: artist.ID, - CreditID: creditID, - }, - ) - if err != nil { - if !database.IsUniqueViolation(err) { - l.logger.Warn( - "could not link artist to credit", - "artist", name, - "creditID", creditID, - "err", err, - ) - - metrics.addWarning( - name, "commit", - fmt.Errorf( - "artist-credit link failed for %q: %w", - name, err, - ), - ) - } - - // UNIQUE violation: link already exists in DB, not an error. - return - } - - cache.linkedCredits[linkKey] = struct{}{} -} - // cachedUpsertGenre returns the genre for the given name, using // the cache when possible. func (l *Library) cachedUpsertGenre( @@ -2178,170 +2167,6 @@ func (l *Library) cachedUpsertGenre( return genre, nil } -// linkRecordingGenres parses the raw genre string, upserts each -// individual genre, and creates the recording-genre associations. -func (l *Library) linkRecordingGenres( - q *sqlcgen.Queries, - cache *entityCache, - rawGenre string, - recordingID int64, -) { - genres := metadata.ParseGenres(rawGenre) - - for _, name := range genres { - genre, err := l.cachedUpsertGenre(q, cache, name) - if err != nil { - l.logger.Warn( - "could not upsert genre", - "genre", name, - "err", err, - ) - - continue - } - - err = q.CreateRecordingGenre( - l.ctx, - sqlcgen.CreateRecordingGenreParams{ - RecordingID: recordingID, - GenreID: genre.ID, - }, - ) - if err != nil { - l.logger.Warn( - "could not link recording to genre", - "genre", name, - "recordingID", recordingID, - "err", err, - ) - } - } -} - -// resolveAlbumArtistCredit returns the album artist credit ID. -// When the AlbumArtist tag is absent or matches the track artist, -// the track artist credit is reused. -func (l *Library) resolveAlbumArtistCredit( - q *sqlcgen.Queries, - cache *entityCache, - metrics *ScanMetrics, - tags *metadata.TrackMetadata, - trackArtistCreditID int64, -) sql.NullInt64 { - if tags.AlbumArtist == "" || tags.AlbumArtist == tags.Artist { - return sql.NullInt64{ - Int64: trackArtistCreditID, Valid: true, - } - } - - albumArtistCredit, err := l.cachedUpsertArtistCredit( - q, cache, tags.AlbumArtist, - ) - if err != nil { - l.logger.Warn( - "could not upsert album artist credit", "err", err, - ) - - return sql.NullInt64{} - } - - l.cachedLinkArtist( - q, cache, metrics, tags.AlbumArtist, albumArtistCredit.ID, - ) - - return sql.NullInt64{ - Int64: albumArtistCredit.ID, Valid: true, - } -} - -// resolveReleaseGroup returns the release group ID for the album, -// using the cache when possible. -func (l *Library) resolveReleaseGroup( - q *sqlcgen.Queries, - cache *entityCache, - tags *metadata.TrackMetadata, - albumArtistCreditID sql.NullInt64, - coverArtID sql.NullInt64, -) sql.NullInt64 { - if tags.Album == "" { - return sql.NullInt64{} - } - - // Build composite cache key: "albumName\x00artistCreditID" - // (or "albumName\x00-1" if no artist). This prevents albums - // with the same name by different artists from colliding. - artistID := int64(-1) - if albumArtistCreditID.Valid { - artistID = albumArtistCreditID.Int64 - } - - cacheKey := fmt.Sprintf("%s\x00%d", tags.Album, artistID) - - // Check cache first. - if cached, ok := cache.releaseGroups[cacheKey]; ok { - // If the cached release group lacks cover art and we now - // have it, update it. - if coverArtID.Valid && !cached.CoverArtID.Valid { - err := q.UpdateReleaseGroupCoverArt( - l.ctx, - sqlcgen.UpdateReleaseGroupCoverArtParams{ - CoverArtID: coverArtID, - ID: cached.ID, - }, - ) - if err != nil { - l.logger.Warn( - "could not update release group cover art", - "err", err, - ) - } else { - cached.CoverArtID = coverArtID - cache.releaseGroups[cacheKey] = cached - } - } - - return sql.NullInt64{Int64: cached.ID, Valid: true} - } - - rg, err := q.UpsertReleaseGroup( - l.ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: tags.Album, - AlbumArtistCreditID: albumArtistCreditID, - Year: toNullInt64(tags.Year), - }, - ) - if err != nil { - l.logger.Warn( - "could not upsert release group", "err", err, - ) - - return sql.NullInt64{} - } - - // Update cover art if this album doesn't have one yet. - if coverArtID.Valid && !rg.CoverArtID.Valid { - err := q.UpdateReleaseGroupCoverArt( - l.ctx, - sqlcgen.UpdateReleaseGroupCoverArtParams{ - CoverArtID: coverArtID, - ID: rg.ID, - }, - ) - if err != nil { - l.logger.Warn( - "could not update release group cover art", - "err", err, - ) - } else { - rg.CoverArtID = coverArtID - } - } - - cache.releaseGroups[cacheKey] = rg - - return sql.NullInt64{Int64: rg.ID, Valid: true} -} - // getRecordingName returns the track title, or falls back to the filename. func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string { if tags.Title != "" { diff --git a/backend/library/metrics.go b/backend/library/metrics.go index 7973801..27b5052 100644 --- a/backend/library/metrics.go +++ b/backend/library/metrics.go @@ -20,7 +20,6 @@ type ScanMetrics struct { ExtractionWallClock time.Duration `json:"extractionWallClock"` DBWritesWallClock time.Duration `json:"dbWritesWallClock"` OrphanCleanup time.Duration `json:"orphanCleanup"` - PostScanVariants time.Duration `json:"postScanVariants"` // Per-format extraction (cumulative across workers). FormatExtraction map[string]int64 `json:"formatExtraction"` @@ -137,7 +136,6 @@ func (m *ScanMetrics) timingBreakdown() string { line(" Medium", m.ThumbnailMedium) line(" Large", m.ThumbnailLarge) line(" Orphan cleanup", m.OrphanCleanup) - line(" Post-scan variants", m.PostScanVariants) return b.String() } diff --git a/backend/library/query.go b/backend/library/query.go index e9ab0a3..b137980 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -15,13 +15,12 @@ import ( "yellowjacket/backend/system" ) -// Sentinel errors for library queries. -var ( - errNoTracksInLibrary = errors.New("no tracks in library") - errNoTracksForAlbum = errors.New("no tracks found for album") -) +// searchTrackLimit bounds an FTS search's result set. +const searchTrackLimit = 500 -// Track represents a playable audio file in the library. +var errNoTracksInLibrary = errors.New("no tracks in library") + +// Track is one audio file with everything a list needs to draw it. type Track struct { TrackName string ArtistName string @@ -50,118 +49,6 @@ type Track struct { CoverArtLarge string } -// genreDelimiter is the separator used by GROUP_CONCAT in the -// GetAllTracksWithFullMetadata query. -const genreDelimiter = "||" - -// splitGenres splits a GROUP_CONCAT genre string into individual -// genre names. An empty string returns nil. -func splitGenres(concatenated string) []string { - if concatenated == "" { - return nil - } - - return strings.Split(concatenated, genreDelimiter) -} - -// mapTrackRow converts raw database column values into a Track. -// This is shared by GetAllTracks, SearchTracks, and GetTracksByGenre -// to avoid tripling the row-mapping code. -func mapTrackRow( - filePath string, - lengthMs int64, - title, artistName string, - trackNumber, discNumber sql.NullInt64, - album, genre string, - year int64, - composer, fileType string, - sampleRate, bitDepth, channels, bitrate, fileSize int64, - playCount int64, - lastPlayed sql.NullTime, - coverArtPath string, - artistMBID, releaseGroupMBID, recordingMBID string, -) Track { - var lastPlayedStr string - if lastPlayed.Valid { - lastPlayedStr = lastPlayed.Time.Format(time.DateTime) - } - - t := Track{ - TrackName: title, - ArtistName: artistName, - TrackLength: strconv.FormatInt(lengthMs, 10), - FilePath: filePath, - TrackNumber: trackNumber.Int64, - DiscNumber: discNumber.Int64, - Album: album, - Genre: splitGenres(genre), - Year: year, - Composer: composer, - FileType: fileType, - SampleRate: sampleRate, - BitDepth: bitDepth, - Channels: channels, - Bitrate: bitrate, - FileSize: fileSize, - PlayCount: playCount, - LastPlayed: lastPlayedStr, - ArtistMBID: artistMBID, - ReleaseGroupMBID: releaseGroupMBID, - RecordingMBID: recordingMBID, - } - - if coverArtPath != "" { - urls := coverart.ResolveURLs(coverArtPath) - t.CoverArtPath = urls.Original - t.CoverArtSmall = urls.Small - t.CoverArtMedium = urls.Medium - t.CoverArtLarge = urls.Large - } - - return t -} - -// TrackMBIDs holds MusicBrainz identifiers for a track, resolved -// from the recording, release group, and artist tables. -type TrackMBIDs struct { - RecordingMBID string `json:"recordingMbid"` - ReleaseGroupMBID string `json:"releaseGroupMbid"` - ArtistMBID string `json:"artistMbid"` -} - -// GetTrackMBIDs returns the MusicBrainz IDs for the track at the -// given file path. Returns empty strings for entities without MBIDs. -func (l *Library) GetTrackMBIDs(filePath string) TrackMBIDs { - rows, err := l.db.QueryContext(` - SELECT - COALESCE(r.mbid, '') AS recording_mbid, - COALESCE(rg.mbid, '') AS release_group_mbid, - 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 - LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id - LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id - WHERE af.file_path = ? - LIMIT 1 - `, filePath) - if err != nil { - return TrackMBIDs{} - } - - defer func() { _ = rows.Close() }() - - var result TrackMBIDs - - if rows.Next() { - _ = rows.Scan(&result.RecordingMBID, &result.ReleaseGroupMBID, &result.ArtistMBID) - } - - return result -} - // Artist represents an artist in the library. type Artist struct { ID int64 @@ -174,11 +61,10 @@ type Artist struct { // Album represents an album for the cover grid display. // -// Year is the album's preferred display year — the release-group's -// original-release-date (MusicBrainz first-release-date) when known, -// falling back to the file-tag year. ReleaseYear is the file-tag -// year of the specific release in the library; for a 2010 remaster -// of a 1973 album, Year=1973 and ReleaseYear=2010. +// Year is the album's preferred display year - MusicBrainz's +// first-release-date when known, falling back to the file-tag year. +// ReleaseYear is the file-tag year of the specific copy in the library; +// for a 2010 remaster of a 1973 album, Year=1973 and ReleaseYear=2010. type Album struct { ID int64 Name string @@ -193,113 +79,130 @@ type Album struct { ReleaseYear int64 } -// GetAllTracks returns an array of track structs of every file in the library. -func (l *Library) GetAllTracks() ([]Track, error) { - rows, err := l.db.ReadQueries.GetAllTracksWithFullMetadata( - l.ctx, - ) - if err != nil { - l.logger.Error( - "could not retrieve audio files", - "error", err, - ) +// genreDelimiter is the separator GROUP_CONCAT uses in track_metadata. +const genreDelimiter = "||" - return nil, err +// splitGenres splits a GROUP_CONCAT genre string into genre names. +func splitGenres(concatenated string) []string { + if concatenated == "" { + return nil } - l.logger.Info("audio file list", "count", len(rows)) + return strings.Split(concatenated, genreDelimiter) +} + +// trackFromRow converts one track_metadata row into a Track. +// +// There is one of these because there is one query shape. It used to +// be a twenty-two argument function called from nine places, one per +// hand-rolled copy of the same projection - each with its own generated +// row struct, which is why the arguments were positional and why two of +// the call sites passed the wrong year. +func trackFromRow(row sqlcgen.TrackMetadatum) Track { + var lastPlayed string + if row.LastPlayed.Valid { + lastPlayed = row.LastPlayed.Time.Format(time.DateTime) + } + + t := Track{ + TrackName: row.Title, + ArtistName: row.ArtistName, + TrackLength: strconv.FormatInt(row.LengthMilliseconds, 10), + FilePath: row.FilePath, + TrackNumber: row.TrackNumber.Int64, + DiscNumber: row.DiscNumber.Int64, + Album: row.Album, + Genre: splitGenres(row.Genre), + Year: row.Year, + Composer: row.Composer, + FileType: row.FileType, + SampleRate: row.SampleRate, + BitDepth: row.BitDepth, + Channels: row.Channels, + Bitrate: row.Bitrate, + FileSize: row.FileSize, + PlayCount: row.PlayCount, + LastPlayed: lastPlayed, + ArtistMBID: row.ArtistMbid, + ReleaseGroupMBID: row.ReleaseGroupMbid, + RecordingMBID: row.RecordingMbid, + } + + if row.CoverArtPath != "" { + urls := coverart.ResolveURLs(row.CoverArtPath) + t.CoverArtPath = urls.Original + t.CoverArtSmall = urls.Small + t.CoverArtMedium = urls.Medium + t.CoverArtLarge = urls.Large + } + + return t +} + +func tracksFromRows(rows []sqlcgen.TrackMetadatum) []Track { + tracks := make([]Track, 0, len(rows)) + for _, row := range rows { + tracks = append(tracks, trackFromRow(row)) + } + + return tracks +} + +// TrackMBIDs are the MusicBrainz ids a file's tags carry. +type TrackMBIDs struct { + RecordingMBID string `json:"recordingMbid"` + ReleaseGroupMBID string `json:"releaseGroupMbid"` + ArtistMBID string `json:"artistMbid"` +} + +// GetTrackMBIDs returns the MusicBrainz ids for one file. +func (l *Library) GetTrackMBIDs(filePath string) TrackMBIDs { + row, err := l.db.ReadQueries.GetTrackByPath(l.ctx, filePath) + if err != nil { + return TrackMBIDs{} + } + + return TrackMBIDs{ + RecordingMBID: row.RecordingMbid, + ReleaseGroupMBID: row.ReleaseGroupMbid, + ArtistMBID: row.ArtistMbid, + } +} + +// GetTracks returns every track in a library, or in all of them when +// libraryID is 0. +// +// The library id is a parameter rather than a second method because the +// two used to be separate queries, separate bindings and a branch at +// every call site - and the scoped form costs nothing (measured: 23 ms +// against 21 ms over 26k rows). +func (l *Library) GetTracks(libraryID int64) ([]Track, error) { + rows, err := l.db.ReadQueries.GetTracks(l.ctx, libraryID) + if err != nil { + l.logger.Error("could not retrieve audio files", "error", err) + + return nil, fmt.Errorf("could not get tracks: %w", err) + } + + l.logger.Info("audio file list", "count", len(rows), "libraryID", libraryID) if len(rows) == 0 { - l.logger.Error("no tracks in library") - return nil, errNoTracksInLibrary } - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - row.PlayCount, - row.LastPlayed, - row.CoverArtPath, - row.ArtistMbid, - row.ReleaseGroupMbid, - row.RecordingMbid, - )) - } - - l.logger.Info("formatted tracks", "count", len(tracks)) - - return tracks, nil + return tracksFromRows(rows), nil } -// searchTrackLimit is the maximum number of results returned by -// a full-text search. -const searchTrackLimit = 200 - -// SearchTracks performs an FTS5 full-text search and returns -// matching tracks with full metadata. -func (l *Library) SearchTracks( - query string, -) ([]Track, error) { - rows, err := l.db.SearchFTSTracks( - query, searchTrackLimit, - ) +// SearchTracks runs the library's FTS index and returns whole tracks. +func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) { + rows, err := l.db.SearchFTSTracks(query, libraryID, searchTrackLimit) if err != nil { - l.logger.Error( - "FTS track search failed", - "query", query, - "error", err, - ) + l.logger.Error("FTS track search failed", "query", query, "error", err) - return nil, fmt.Errorf( - "search tracks failed: %w", err, - ) + return nil, fmt.Errorf("search tracks failed: %w", err) } - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - 0, sql.NullTime{}, - "", - "", "", "", - )) - } - - return tracks, nil + return tracksFromRows(rows), nil } // AlbumCompleteness says how much of an album is present, as the files @@ -320,30 +223,18 @@ type AlbumCompleteness struct { // GetAlbumCompleteness answers "do I have all of this album" from the // tags read at scan time, with no network. // -// The album page used to ask MusicBrainz, because the only track total -// it had was the length of whatever tracklist it was already showing — -// which for a library copy is a tautology. The denominator in a file's -// "5/12" is a real answer and it is already on disk; this is where it -// gets read. -// // Complete is deliberately >= rather than ==: bonus and hidden tracks // routinely put a folder over its declared total, and that is a // complete album, not a broken one. func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error) { - row, err := l.db.ReadQueries.GetAlbumCompleteness(l.ctx, albumID) + row, err := l.db.ReadQueries.GetAlbumCompleteness( + l.ctx, sql.NullInt64{Int64: albumID, Valid: true}, + ) if err != nil { - l.logger.Error("could not read album completeness", - "albumID", albumID, "error", err, - ) - - return AlbumCompleteness{}, fmt.Errorf( - "could not get album completeness: %w", err, - ) + return AlbumCompleteness{}, fmt.Errorf("could not get album completeness: %w", err) } - // A disc whose files all declared nothing leaves the album's total - // unknowable — the discs that did declare cannot stand in for it. - known := row.DiscsUntotalled == 0 && row.Expected > 0 + known := row.Known != 0 && row.Expected > 0 return AlbumCompleteness{ Owned: int(row.Owned), @@ -353,53 +244,75 @@ func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error) }, nil } -// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number. -func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) { - rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroup(l.ctx, albumID) +// GetAlbumTracks returns one album's tracks in disc/track order. +func (l *Library) GetAlbumTracks(albumID, libraryID int64) ([]Track, error) { + rows, err := l.db.ReadQueries.GetTracksByAlbum( + l.ctx, sqlcgen.GetTracksByAlbumParams{ + AlbumID: sql.NullInt64{Int64: albumID, Valid: true}, + LibraryID: libraryID, + }, + ) if err != nil { l.logger.Error("could not retrieve album tracks", "albumID", albumID, "error", err) return nil, fmt.Errorf("could not get album tracks: %w", err) } - if len(rows) == 0 { - return nil, fmt.Errorf("%w %d", errNoTracksForAlbum, albumID) - } - - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - 0, sql.NullTime{}, - "", - row.ArtistMbid, - row.ReleaseGroupMbid, - row.RecordingMbid, - )) - } - - return tracks, nil + return tracksFromRows(rows), nil } -// GetAllAlbums returns all albums with cover art and artist info for the cover grid. -func (l *Library) GetAllAlbums() ([]Album, error) { - rows, err := l.db.ReadQueries.GetAllAlbumsWithDetails(l.ctx) +// GetTracksByGenre returns every track carrying a genre. +func (l *Library) GetTracksByGenre(genre string, libraryID int64) ([]Track, error) { + rows, err := l.db.ReadQueries.GetTracksByGenre( + l.ctx, sqlcgen.GetTracksByGenreParams{Genre: genre, LibraryID: libraryID}, + ) + if err != nil { + l.logger.Error("could not retrieve genre tracks", "genre", genre, "error", err) + + return nil, fmt.Errorf("could not get genre tracks: %w", err) + } + + return tracksFromRows(rows), nil +} + +// albumFromRow builds an Album from either album query's row. Both +// select the same columns, so this takes them one by one rather than +// tying itself to whichever generated struct it was handed. +func albumFromRow( + id int64, name, artistName, artistMBID string, + mbid sql.NullString, coverArtPath string, + year sql.NullInt64, releaseYear int64, +) Album { + album := Album{ + ID: id, + Name: name, + ArtistName: artistName, + ArtistMBID: artistMBID, + ReleaseYear: releaseYear, + } + + if mbid.Valid { + album.MBID = mbid.String + } + + if year.Valid { + album.Year = year.Int64 + } + + if coverArtPath != "" { + urls := coverart.ResolveURLs(coverArtPath) + album.CoverArtPath = urls.Original + album.CoverArtSmall = urls.Small + album.CoverArtMedium = urls.Medium + album.CoverArtLarge = urls.Large + } + + return album +} + +// GetAlbums returns every album, or those with a file in one library. +func (l *Library) GetAlbums(libraryID int64) ([]Album, error) { + rows, err := l.db.ReadQueries.GetAlbums(l.ctx, libraryID) if err != nil { l.logger.Error("could not retrieve albums", "error", err) @@ -409,81 +322,89 @@ func (l *Library) GetAllAlbums() ([]Album, error) { l.logger.Info("album list", "count", len(rows)) albums := make([]Album, 0, len(rows)) - for _, row := range rows { - album := Album{ - ID: row.ID, - Name: row.Name, - ArtistName: row.ArtistName, - ArtistMBID: row.ArtistMbid, - } - - if row.Year.Valid { - album.Year = row.Year.Int64 - } - - album.ReleaseYear = row.ReleaseYear - - if row.Mbid.Valid { - album.MBID = row.Mbid.String - } - - // Convert filesystem path to URL path for the asset handler. - if row.CoverArtPath != "" { - urls := coverart.ResolveURLs(row.CoverArtPath) - album.CoverArtPath = urls.Original - album.CoverArtSmall = urls.Small - album.CoverArtMedium = urls.Medium - album.CoverArtLarge = urls.Large - } - - albums = append(albums, album) + albums = append(albums, albumFromRow( + row.ID, row.Name, row.ArtistName, row.ArtistMbid, + row.Mbid, row.CoverArtPath, row.Year, row.ReleaseYear, + )) } return albums, nil } -// GetAllArtists returns artists that are credited as album artists, ordered by name. -func (l *Library) GetAllArtists() ([]Artist, error) { - rows, err := l.db.ReadQueries.GetAlbumArtists(l.ctx) +// GetAlbumsByArtist returns the albums credited to an artist by name. +func (l *Library) GetAlbumsByArtist(artist string, libraryID int64) ([]Album, error) { + rows, err := l.db.ReadQueries.GetAlbumsByArtistName( + l.ctx, sqlcgen.GetAlbumsByArtistNameParams{Artist: artist, LibraryID: libraryID}, + ) if err != nil { - l.logger.Error( - "could not retrieve artists", - "error", err, - ) + l.logger.Error("could not retrieve artist albums", "artist", artist, "error", err) - return nil, fmt.Errorf( - "could not get artists: %w", - err, - ) + return nil, fmt.Errorf("could not get artist albums: %w", err) } - l.logger.Info("artist list", "count", len(rows)) + albums := make([]Album, 0, len(rows)) + for _, row := range rows { + albums = append(albums, albumFromRow( + row.ID, row.Name, row.ArtistName, row.ArtistMbid, + row.Mbid, row.CoverArtPath, row.Year, row.ReleaseYear, + )) + } + + return albums, nil +} + +// GetArtists returns the album artists in a library. +func (l *Library) GetArtists(libraryID int64) ([]Artist, error) { + rows, err := l.db.ReadQueries.GetAlbumArtists(l.ctx, libraryID) + if err != nil { + l.logger.Error("could not retrieve artists", "error", err) + + return nil, fmt.Errorf("could not get artists: %w", err) + } artists := make([]Artist, 0, len(rows)) - for _, row := range rows { - a := Artist{ - ID: row.ID, - Name: row.Name, - } - + artist := Artist{ID: row.ID, Name: row.Name} if row.Mbid.Valid { - a.MBID = row.Mbid.String + artist.MBID = row.Mbid.String } - artists = append(artists, a) + artists = append(artists, artist) } - // Resolve artist image URLs from the disk cache. l.resolveArtistImages(artists) return artists, nil } -// resolveArtistImages populates ImageSmall/Medium/Large for artists -// that have cached images on disk. Does a bulk MBID lookup from the -// artists table, then checks the artist-images directory for each. +// GenreWithCount is a genre and how many tracks carry it. +type GenreWithCount struct { + Name string + TrackCount int64 +} + +// GetGenres returns every genre with its track count. +func (l *Library) GetGenres(libraryID int64) ([]GenreWithCount, error) { + rows, err := l.db.ReadQueries.GetAllGenresWithCounts(l.ctx, libraryID) + if err != nil { + l.logger.Error("could not retrieve genres", "error", err) + + return nil, fmt.Errorf("could not get genres: %w", err) + } + + genres := make([]GenreWithCount, 0, len(rows)) + for _, row := range rows { + genres = append(genres, GenreWithCount{Name: row.Name, TrackCount: row.TrackCount}) + } + + return genres, nil +} + +// resolveArtistImages fills in the on-disk portrait tiers for artists +// that have one. The directory layout is sharded by the MBID's first +// two characters - explore.ArtistImageDir is its one definition, and a +// caller that reinvents it names a path that has never existed. func (l *Library) resolveArtistImages(artists []Artist) { if len(artists) == 0 { return @@ -496,608 +417,28 @@ func (l *Library) resolveArtistImages(artists []Artist) { baseDir := filepath.Join(dataDir, "artist-images") - // Bulk load name→mbid from the artists table. - rows, err := l.db.QueryContext( - "SELECT name, mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''", - ) - if err != nil { - return - } - - defer func() { _ = rows.Close() }() - - mbidMap := make(map[string]string) - - for rows.Next() { - var name, mbid string - if err := rows.Scan(&name, &mbid); err == nil { - mbidMap[name] = mbid - } - } - for i := range artists { - mbid, ok := mbidMap[artists[i].Name] - if !ok || len(mbid) < 2 { + mbid := artists[i].MBID + if len(mbid) < 2 { continue } dir := filepath.Join(baseDir, mbid[:2], mbid) prefix := "/artist-images/" + mbid[:2] + "/" + mbid + "/" - if _, err := os.Stat(filepath.Join(dir, "primary_sm.jpg")); err == nil { - artists[i].ImageSmall = prefix + "primary_sm.jpg" - } - - if _, err := os.Stat(filepath.Join(dir, "primary_md.jpg")); err == nil { - artists[i].ImageMedium = prefix + "primary_md.jpg" - } - - if _, err := os.Stat(filepath.Join(dir, "primary_lg.jpg")); err == nil { - artists[i].ImageLarge = prefix + "primary_lg.jpg" + for name, dst := range map[string]*string{ + "primary_sm.jpg": &artists[i].ImageSmall, + "primary_md.jpg": &artists[i].ImageMedium, + "primary_lg.jpg": &artists[i].ImageLarge, + } { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + *dst = prefix + name + } } } } -// GetAlbumsByArtist returns all albums where the given artist is the album artist. -func (l *Library) GetAlbumsByArtist( - artistID int64, -) ([]Album, error) { - rows, err := l.db.ReadQueries.GetAlbumsByArtist( - l.ctx, - artistID, - ) - if err != nil { - l.logger.Error( - "could not retrieve albums for artist", - "artistID", artistID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get albums for artist: %w", - err, - ) - } - - l.logger.Info( - "albums for artist", - "artistID", artistID, - "count", len(rows), - ) - - albums := make([]Album, 0, len(rows)) - - for _, row := range rows { - album := Album{ - ID: row.ID, - Name: row.Name, - ArtistName: row.ArtistName, - ArtistMBID: row.ArtistMbid, - } - - if row.Year.Valid { - album.Year = row.Year.Int64 - } - - album.ReleaseYear = row.ReleaseYear - - // Convert filesystem path to URL path for the asset handler. - if row.CoverArtPath != "" { - urls := coverart.ResolveURLs(row.CoverArtPath) - album.CoverArtPath = urls.Original - album.CoverArtSmall = urls.Small - album.CoverArtMedium = urls.Medium - album.CoverArtLarge = urls.Large - } - - albums = append(albums, album) - } - - return albums, nil -} - -// GenreWithCount holds a genre name and its associated track count. -type GenreWithCount struct { - Name string `json:"Name"` - TrackCount int64 `json:"TrackCount"` -} - -// GetTracksByGenre returns all tracks tagged with the given genre. -func (l *Library) GetTracksByGenre( - genreName string, -) ([]Track, error) { - rows, err := l.db.ReadQueries.GetTracksByGenre( - l.ctx, genreName, - ) - if err != nil { - l.logger.Error( - "could not retrieve tracks for genre", - "genre", genreName, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get tracks for genre: %w", err, - ) - } - - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - 0, sql.NullTime{}, - "", - "", "", "", - )) - } - - return tracks, nil -} - -// GetAllGenresWithCounts returns all genres with their track counts. -func (l *Library) GetAllGenresWithCounts() ( - []GenreWithCount, error, -) { - rows, err := l.db.ReadQueries.GetAllGenresWithCounts( - l.ctx, - ) - if err != nil { - l.logger.Error( - "could not retrieve genres with counts", - "error", err, - ) - - return nil, fmt.Errorf( - "could not get genres: %w", err, - ) - } - - genres := make([]GenreWithCount, 0, len(rows)) - - for _, row := range rows { - genres = append(genres, GenreWithCount{ - Name: row.Name, - TrackCount: row.TrackCount, - }) - } - - return genres, nil -} - -// GetAllTracksByLibrary returns tracks scoped to a specific library. -func (l *Library) GetAllTracksByLibrary( - libraryID int64, -) ([]Track, error) { - rows, err := l.db.ReadQueries.GetAllTracksWithFullMetadataByLibrary( - l.ctx, libraryID, - ) - if err != nil { - l.logger.Error( - "could not retrieve tracks for library", - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get tracks for library: %w", err, - ) - } - - l.logger.Info( - "tracks for library", - "libraryID", libraryID, - "count", len(rows), - ) - - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - row.PlayCount, - row.LastPlayed, - row.CoverArtPath, - row.ArtistMbid, - row.ReleaseGroupMbid, - row.RecordingMbid, - )) - } - - return tracks, nil -} - -// GetAllAlbumsByLibrary returns albums that have tracks in the given library. -func (l *Library) GetAllAlbumsByLibrary( - libraryID int64, -) ([]Album, error) { - rows, err := l.db.ReadQueries.GetAllAlbumsWithDetailsByLibrary( - l.ctx, libraryID, - ) - if err != nil { - l.logger.Error( - "could not retrieve albums for library", - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get albums for library: %w", err, - ) - } - - l.logger.Info( - "albums for library", - "libraryID", libraryID, - "count", len(rows), - ) - - albums := make([]Album, 0, len(rows)) - - for _, row := range rows { - album := Album{ - ID: row.ID, - Name: row.Name, - ArtistName: row.ArtistName, - ArtistMBID: row.ArtistMbid, - } - - if row.Year.Valid { - album.Year = row.Year.Int64 - } - - album.ReleaseYear = row.ReleaseYear - - if row.Mbid.Valid { - album.MBID = row.Mbid.String - } - - if row.CoverArtPath != "" { - urls := coverart.ResolveURLs(row.CoverArtPath) - album.CoverArtPath = urls.Original - album.CoverArtSmall = urls.Small - album.CoverArtMedium = urls.Medium - album.CoverArtLarge = urls.Large - } - - albums = append(albums, album) - } - - return albums, nil -} - -// GetAllArtistsByLibrary returns artists that have albums with tracks -// in the given library. -func (l *Library) GetAllArtistsByLibrary( - libraryID int64, -) ([]Artist, error) { - rows, err := l.db.ReadQueries.GetAlbumArtistsByLibrary( - l.ctx, libraryID, - ) - if err != nil { - l.logger.Error( - "could not retrieve artists for library", - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get artists for library: %w", err, - ) - } - - l.logger.Info( - "artists for library", - "libraryID", libraryID, - "count", len(rows), - ) - - artists := make([]Artist, 0, len(rows)) - - for _, row := range rows { - a := Artist{ - ID: row.ID, - Name: row.Name, - } - - if row.Mbid.Valid { - a.MBID = row.Mbid.String - } - - artists = append(artists, a) - } - - l.resolveArtistImages(artists) - - return artists, nil -} - -// GetAlbumsByArtistByLibrary returns albums for the given artist -// that have tracks in the given library. -func (l *Library) GetAlbumsByArtistByLibrary( - artistID, libraryID int64, -) ([]Album, error) { - rows, err := l.db.ReadQueries.GetAlbumsByArtistByLibrary( - l.ctx, sqlcgen.GetAlbumsByArtistByLibraryParams{ - ArtistID: artistID, - LibraryID: libraryID, - }, - ) - if err != nil { - l.logger.Error( - "could not retrieve albums for artist in library", - "artistID", artistID, - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get albums for artist in library: %w", - err, - ) - } - - l.logger.Info( - "albums for artist in library", - "artistID", artistID, - "libraryID", libraryID, - "count", len(rows), - ) - - albums := make([]Album, 0, len(rows)) - - for _, row := range rows { - album := Album{ - ID: row.ID, - Name: row.Name, - ArtistName: row.ArtistName, - ArtistMBID: row.ArtistMbid, - } - - if row.Year.Valid { - album.Year = row.Year.Int64 - } - - album.ReleaseYear = row.ReleaseYear - - if row.CoverArtPath != "" { - urls := coverart.ResolveURLs(row.CoverArtPath) - album.CoverArtPath = urls.Original - album.CoverArtSmall = urls.Small - album.CoverArtMedium = urls.Medium - album.CoverArtLarge = urls.Large - } - - albums = append(albums, album) - } - - return albums, nil -} - -// GetAllGenresWithCountsByLibrary returns genres with track counts -// scoped to the given library. -func (l *Library) GetAllGenresWithCountsByLibrary( - libraryID int64, -) ([]GenreWithCount, error) { - rows, err := l.db.ReadQueries.GetAllGenresWithCountsByLibrary( - l.ctx, libraryID, - ) - if err != nil { - l.logger.Error( - "could not retrieve genres for library", - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get genres for library: %w", err, - ) - } - - genres := make([]GenreWithCount, 0, len(rows)) - - for _, row := range rows { - genres = append(genres, GenreWithCount{ - Name: row.Name, - TrackCount: row.TrackCount, - }) - } - - return genres, nil -} - -// GetTracksByGenreByLibrary returns tracks tagged with the given -// genre, scoped to the given library. -func (l *Library) GetTracksByGenreByLibrary( - genreName string, libraryID int64, -) ([]Track, error) { - rows, err := l.db.ReadQueries.GetTracksByGenreByLibrary( - l.ctx, sqlcgen.GetTracksByGenreByLibraryParams{ - Name: genreName, - LibraryID: libraryID, - }, - ) - if err != nil { - l.logger.Error( - "could not retrieve tracks for genre in library", - "genre", genreName, - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get tracks for genre in library: %w", - err, - ) - } - - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - 0, sql.NullTime{}, - "", - "", "", "", - )) - } - - return tracks, nil -} - -// GetAlbumTracksByLibrary returns tracks for the given album, -// scoped to the given library. -func (l *Library) GetAlbumTracksByLibrary( - albumID, libraryID int64, -) ([]Track, error) { - rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroupByLibrary( - l.ctx, sqlcgen.GetAudioFilesByReleaseGroupByLibraryParams{ - ReleaseGroupID: albumID, - LibraryID: libraryID, - }, - ) - if err != nil { - l.logger.Error( - "could not retrieve album tracks for library", - "albumID", albumID, - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "could not get album tracks for library: %w", - err, - ) - } - - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - 0, sql.NullTime{}, - "", - row.ArtistMbid, - row.ReleaseGroupMbid, - row.RecordingMbid, - )) - } - - return tracks, nil -} - -// SearchTracksByLibrary performs an FTS5 search scoped to a specific -// library and returns matching tracks with full metadata. -func (l *Library) SearchTracksByLibrary( - query string, libraryID int64, -) ([]Track, error) { - rows, err := l.db.SearchFTSTracksByLibrary( - query, searchTrackLimit, libraryID, - ) - if err != nil { - l.logger.Error( - "FTS library track search failed", - "query", query, - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf( - "search tracks by library failed: %w", err, - ) - } - - tracks := make([]Track, 0, len(rows)) - - for _, row := range rows { - tracks = append(tracks, mapTrackRow( - row.FilePath, - row.LengthMilliseconds, - row.Title, - row.ArtistName, - row.TrackNumber, - row.DiscNumber, - row.Album, - row.Genre, - row.Year, - row.Composer, - row.FileType, - row.SampleRate, - row.BitDepth, - row.Channels, - row.Bitrate, - row.FileSize, - 0, sql.NullTime{}, - "", - "", "", "", - )) - } - - return tracks, nil -} - -// Info contains library metadata enriched with track count -// for the frontend settings UI. +// Info is one library and how many files are in it. type Info struct { ID int64 `json:"id"` Name string `json:"name"` @@ -1105,8 +446,7 @@ type Info struct { TrackCount int64 `json:"trackCount"` } -// GetAllLibrariesWithTrackCounts returns all libraries with their -// audio file counts. Typically 1-5 libraries so the loop is trivial. +// GetAllLibrariesWithTrackCounts lists the libraries and their sizes. func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) { libs, err := l.db.ReadQueries.GetAllLibraries(l.ctx) if err != nil { @@ -1116,7 +456,7 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) { result := make([]Info, 0, len(libs)) for _, lib := range libs { - count, countErr := l.db.ReadQueries.CountAudioFilesByLibrary(l.ctx, lib.ID) + count, countErr := l.db.ReadQueries.CountAudioFiles(l.ctx, lib.ID) if countErr != nil { l.logger.Error("could not count tracks for library", "libraryID", lib.ID, "error", countErr) @@ -1135,6 +475,17 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) { return result, nil } +// inLibrary reports whether a row belongs to the requested library. +// A wanted id of 0 means "every library". +// +// The three lookups below filter here rather than in SQL because they +// also take a slice: sqlc expands a slice into N placeholders but +// numbers a named parameter independently, so the two together bind the +// wrong values. See the comment on GetFilePathsByAlbums. +func inLibrary(rowLibraryID, wanted int64) bool { + return wanted == 0 || rowLibraryID == wanted +} + // GetFilePathsByAlbums returns the file paths of every track in the // given albums, grouped by album id. // @@ -1142,8 +493,8 @@ func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) { // resolved paths with one binding call per album, sequentially, and each // asked for whole track rows to read one field off them (perf.m2). This // is that question asked once. The result is grouped rather than -// flattened because the caller owns the ordering — an album list is -// sorted by name, not by id — and because the drag cache stores it per +// flattened because the caller owns the ordering - an album list is +// sorted by name, not by id - and because the drag cache stores it per // album. // // A library id of 0 means "every library", matching the caller's @@ -1157,53 +508,29 @@ func (l *Library) GetFilePathsByAlbums( return paths, nil } - if libraryID > 0 { - rows, err := l.db.ReadQueries.GetFilePathsByReleaseGroupsByLibrary( - l.ctx, sqlcgen.GetFilePathsByReleaseGroupsByLibraryParams{ - ReleaseGroupIds: albumIDs, - LibraryID: libraryID, - }, - ) - if err != nil { - l.logger.Error( - "could not retrieve album file paths for library", - "albums", len(albumIDs), - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf("could not get album file paths: %w", err) - } - - for _, row := range rows { - paths[row.ReleaseGroupID] = append(paths[row.ReleaseGroupID], row.FilePath) - } - - return paths, nil + ids := make([]sql.NullInt64, 0, len(albumIDs)) + for _, id := range albumIDs { + ids = append(ids, sql.NullInt64{Int64: id, Valid: true}) } - rows, err := l.db.ReadQueries.GetFilePathsByReleaseGroups(l.ctx, albumIDs) + rows, err := l.db.ReadQueries.GetFilePathsByAlbums(l.ctx, ids) if err != nil { - l.logger.Error( - "could not retrieve album file paths", - "albums", len(albumIDs), - "error", err, - ) + l.logger.Error("could not retrieve album file paths", + "albums", len(albumIDs), "libraryID", libraryID, "error", err) return nil, fmt.Errorf("could not get album file paths: %w", err) } for _, row := range rows { - paths[row.ReleaseGroupID] = append(paths[row.ReleaseGroupID], row.FilePath) + if row.AlbumID.Valid && inLibrary(row.LibraryID, libraryID) { + paths[row.AlbumID.Int64] = append(paths[row.AlbumID.Int64], row.FilePath) + } } return paths, nil } -// GetFilePathsByGenres returns the file paths of every track tagged with -// the given genres, grouped by genre name. See GetFilePathsByAlbums — -// same finding, same shape, and the caller still owns the de-duplication -// across genres because it owns the order. +// GetFilePathsByGenres returns file paths grouped by genre name. func (l *Library) GetFilePathsByGenres( genreNames []string, libraryID int64, ) (map[string][]string, error) { @@ -1213,64 +540,31 @@ func (l *Library) GetFilePathsByGenres( return paths, nil } - if libraryID > 0 { - rows, err := l.db.ReadQueries.GetFilePathsByGenresByLibrary( - l.ctx, sqlcgen.GetFilePathsByGenresByLibraryParams{ - GenreNames: genreNames, - LibraryID: libraryID, - }, - ) - if err != nil { - l.logger.Error( - "could not retrieve genre file paths for library", - "genres", len(genreNames), - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf("could not get genre file paths: %w", err) - } - - for _, row := range rows { - paths[row.GenreName] = append(paths[row.GenreName], row.FilePath) - } - - return paths, nil - } - rows, err := l.db.ReadQueries.GetFilePathsByGenres(l.ctx, genreNames) if err != nil { - l.logger.Error( - "could not retrieve genre file paths", - "genres", len(genreNames), - "error", err, - ) + l.logger.Error("could not retrieve genre file paths", + "genres", len(genreNames), "libraryID", libraryID, "error", err) return nil, fmt.Errorf("could not get genre file paths: %w", err) } for _, row := range rows { - paths[row.GenreName] = append(paths[row.GenreName], row.FilePath) + if inLibrary(row.LibraryID, libraryID) { + paths[row.Genre] = append(paths[row.Genre], row.FilePath) + } } return paths, nil } -// GetFilePathsByRecordingMBIDs returns the file paths of every track -// whose recording MBID is in mbids, grouped by MBID. +// GetFilePathsByRecordingMBIDs answers "which of these catalog +// recordings do I actually have a file for", grouped by MBID. // -// This is the catalog side of GetFilePathsByAlbums. An Explore album -// page knows what the user owns as a set of recording MBIDs and nothing -// else: that is exactly how the backend decides a track's InLibrary -// flag (markReleasesInLibrary → CheckMBIDs), and MBTrack.LocalID is a -// declared field that nothing writes, so there is no id to ask by. -// -// Grouped rather than flattened for the same two reasons as its -// siblings — the caller owns the order (the tracklist's, not the -// database's), and one recording can have more than one file, which is -// what this app's duplicate detection exists for. -// -// A library id of 0 means "every library". +// It asks audio_files, which is the only table whose rows are files. +// The version of this question that asked the metadata tables said yes +// for 129 tracks in a real library that had no file at all - a +// retagged file left its old recording row behind, the catalog matched +// it, and every action on the row then failed. func (l *Library) GetFilePathsByRecordingMBIDs( mbids []string, libraryID int64, ) (map[string][]string, error) { @@ -1280,10 +574,8 @@ func (l *Library) GetFilePathsByRecordingMBIDs( return paths, nil } - // recordings.mbid is nullable, so sqlc asks for NullStrings. An - // empty MBID would match every untagged recording in the library, - // which is the opposite of the question, so those are dropped here - // rather than passed through as NULL. + // An empty MBID would match every untagged file, which is the + // opposite of the question. keys := make([]sql.NullString, 0, len(mbids)) for _, mbid := range mbids { @@ -1298,74 +590,19 @@ func (l *Library) GetFilePathsByRecordingMBIDs( return paths, nil } - rows, err := l.filePathRowsByMBID(keys, libraryID) - if err != nil { - return nil, err - } - - for _, row := range rows { - if !row.mbid.Valid { - continue - } - - paths[row.mbid.String] = append(paths[row.mbid.String], row.path) - } - - return paths, nil -} - -// filePathRowsByMBID runs the scoped or unscoped query behind -// GetFilePathsByRecordingMBIDs and flattens the two row types into one. -func (l *Library) filePathRowsByMBID( - keys []sql.NullString, libraryID int64, -) ([]mbidFilePath, error) { - if libraryID > 0 { - rows, err := l.db.ReadQueries.GetFilePathsByRecordingMBIDsByLibrary( - l.ctx, sqlcgen.GetFilePathsByRecordingMBIDsByLibraryParams{ - Mbids: keys, - LibraryID: libraryID, - }, - ) - if err != nil { - l.logger.Error( - "could not retrieve recording file paths for library", - "recordings", len(keys), - "libraryID", libraryID, - "error", err, - ) - - return nil, fmt.Errorf("could not get recording file paths: %w", err) - } - - out := make([]mbidFilePath, 0, len(rows)) - for _, row := range rows { - out = append(out, mbidFilePath{mbid: row.RecordingMbid, path: row.FilePath}) - } - - return out, nil - } - rows, err := l.db.ReadQueries.GetFilePathsByRecordingMBIDs(l.ctx, keys) if err != nil { - l.logger.Error( - "could not retrieve recording file paths", - "recordings", len(keys), - "error", err, - ) + l.logger.Error("could not retrieve recording file paths", + "recordings", len(keys), "libraryID", libraryID, "error", err) return nil, fmt.Errorf("could not get recording file paths: %w", err) } - out := make([]mbidFilePath, 0, len(rows)) for _, row := range rows { - out = append(out, mbidFilePath{mbid: row.RecordingMbid, path: row.FilePath}) + if row.RecordingMbid.Valid && inLibrary(row.LibraryID, libraryID) { + paths[row.RecordingMbid.String] = append(paths[row.RecordingMbid.String], row.FilePath) + } } - return out, nil -} - -// mbidFilePath is one row of either GetFilePathsByRecordingMBIDs query. -type mbidFilePath struct { - mbid sql.NullString - path string + return paths, nil } diff --git a/backend/library/removal_test.go b/backend/library/removal_test.go index 498a56c..605eb1a 100644 --- a/backend/library/removal_test.go +++ b/backend/library/removal_test.go @@ -6,6 +6,7 @@ import ( "testing" "yellowjacket/backend/coverart" + "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" ) @@ -30,19 +31,6 @@ func seedRemovableLibrary( t.Fatalf("create library: %v", err) } - ac, err := q.UpsertArtistCredit(ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Test Song", - ArtistCreditID: ac.ID, - }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - if _, err := lib.db.ExecContext( `INSERT INTO cover_art (is_embedded, file_path, mime_type) VALUES (0, ?, 'image/jpeg')`, coverPath, @@ -50,15 +38,14 @@ func seedRemovableLibrary( t.Fatalf("insert cover art: %v", err) } - if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/song.mp3", - LengthMilliseconds: 180000, - RecordingID: rec.ID, - LibraryID: library.ID, - Basename: "song.mp3", - }); err != nil { - t.Fatalf("create audio file: %v", err) - } + database.InsertTestTrack(t, lib.db, database.TestTrack{ + FilePath: "/music/song.mp3", + Title: "Test Song", + Artist: "Test Artist", + Album: "Test Album", + LengthMs: 180000, + LibraryID: library.ID, + }) // Every scanned library gets tagging_items rows, one per album // folder. These FK-reference libraries. @@ -137,9 +124,9 @@ func TestRemoveLibrary_WithTaggingItems(t *testing.T) { for _, table := range []string{ "audio_files", - "recordings", - "artist_credit", + "albums", "artists", + "file_genres", "tagging_items", "tagging_candidates", "cover_art", @@ -158,18 +145,16 @@ func TestRemoveLibrary_DeletesCoverArtVariants(t *testing.T) { lib, _ := setupTestLibrary(t) dir := t.TempDir() - original := filepath.Join(dir, "abc123.jpg") - paths := []string{original} + var paths []string for _, tier := range thumbnailTiers { paths = append(paths, filepath.Join( dir, coverart.SizedFilename("abc123.jpg", tier.Suffix), )) } - paths = append(paths, filepath.Join( - dir, coverart.SizedFilename("abc123.jpg", legacyThumbSuffix), - )) + // The largest tier is what cover_art.file_path names. + cover := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", "_lg")) for _, p := range paths { if err := os.WriteFile(p, []byte("img"), 0o600); err != nil { @@ -177,7 +162,7 @@ func TestRemoveLibrary_DeletesCoverArtVariants(t *testing.T) { } } - library := seedRemovableLibrary(t, lib, original) + library := seedRemovableLibrary(t, lib, cover) if _, err := lib.RemoveLibrary(library.ID); err != nil { t.Fatalf("RemoveLibrary: %v", err) @@ -190,19 +175,17 @@ func TestRemoveLibrary_DeletesCoverArtVariants(t *testing.T) { } } -// CoverArtFileSet must cover the original, every generated tier, and -// the legacy _thumb name. +// CoverArtFileSet must cover every generated tier, from any of them: +// cover_art.file_path names the largest, and the others are derived. func TestCoverArtFileSet(t *testing.T) { t.Parallel() - got := CoverArtFileSet("/covers/abc123.jpg") + got := CoverArtFileSet("/covers/abc123_lg.jpg") want := []string{ - "/covers/abc123.jpg", "/covers/abc123_sm.jpg", "/covers/abc123_md.jpg", "/covers/abc123_lg.jpg", - "/covers/abc123_thumb.jpg", } if len(got) != len(want) { diff --git a/backend/library/remove_tracks.go b/backend/library/remove_tracks.go index 903bf23..a6e3098 100644 --- a/backend/library/remove_tracks.go +++ b/backend/library/remove_tracks.go @@ -142,7 +142,7 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error) // with nothing behind it, and the album list selects from // release_groups rather than from audio_files — so it would keep // rendering an album the user has no tracks of. - l.pruneOrphanedMetadata() + l.pruneEmptyEntities() l.emit(events.TracksRemovedFromLibrary, map[string]any{ "filePaths": filePaths, diff --git a/backend/library/remove_tracks_test.go b/backend/library/remove_tracks_test.go index ec7103b..3921d8e 100644 --- a/backend/library/remove_tracks_test.go +++ b/backend/library/remove_tracks_test.go @@ -169,7 +169,7 @@ func TestRemoveFromLibrary_SoftScanSeesNoChange(t *testing.T) { t.Fatalf("RemoveFromLibrary: %v", err) } - dbCount, err := lib.db.Queries.CountAudioFilesByLibrary(t.Context(), libID) + dbCount, err := lib.db.Queries.CountAudioFiles(t.Context(), libID) if err != nil { t.Fatalf("count rows: %v", err) } diff --git a/backend/library/rescan.go b/backend/library/rescan.go index 9b6ef63..5fd75b1 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -141,23 +141,16 @@ func (l *Library) clearLibraryTables() error { UPDATE playlist_tracks SET phantom_title = COALESCE(phantom_title, ( - SELECT r.name FROM audio_files af - JOIN recordings r ON af.recording_id = r.id - WHERE af.id = playlist_tracks.audio_file_id + SELECT tm.title FROM track_metadata tm + WHERE tm.id = playlist_tracks.audio_file_id )), phantom_artist = COALESCE(phantom_artist, ( - SELECT ac.text FROM audio_files af - JOIN recordings r ON af.recording_id = r.id - JOIN artist_credit ac ON r.artist_credit_id = ac.id - WHERE af.id = playlist_tracks.audio_file_id + SELECT tm.artist_name FROM track_metadata tm + WHERE tm.id = playlist_tracks.audio_file_id )), phantom_album = COALESCE(phantom_album, ( - SELECT rg.name FROM audio_files af - JOIN recordings r ON af.recording_id = r.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 - WHERE af.id = playlist_tracks.audio_file_id - LIMIT 1 + SELECT tm.album FROM track_metadata tm + WHERE tm.id = playlist_tracks.audio_file_id )), phantom_duration_ms = COALESCE(phantom_duration_ms, ( SELECT af.length_milliseconds FROM audio_files af @@ -174,40 +167,16 @@ func (l *Library) clearLibraryTables() error { ) } - if err := txq.DeleteAllRecordingGenres(l.ctx); err != nil { - return fmt.Errorf( - "could not clear recording genres: %w", err, - ) - } - - if err := txq.DeleteAllReleaseGroupRecordings(l.ctx); err != nil { - return fmt.Errorf( - "could not clear release group recordings: %w", err, - ) - } - - if err := txq.DeleteAllArtistCreditArtists(l.ctx); err != nil { - return fmt.Errorf( - "could not clear artist credit artists: %w", err, - ) - } - - // Phase 2: mid-level tables. + // Phase 2: the files. file_genres cascades with them. if err := txq.DeleteAllAudioFiles(l.ctx); err != nil { return fmt.Errorf( "could not clear audio files: %w", err, ) } - if err := txq.DeleteAllReleaseGroups(l.ctx); err != nil { + if err := txq.DeleteAllAlbums(l.ctx); err != nil { return fmt.Errorf( - "could not clear release groups: %w", err, - ) - } - - if err := txq.DeleteAllRecordings(l.ctx); err != nil { - return fmt.Errorf( - "could not clear recordings: %w", err, + "could not clear albums: %w", err, ) } @@ -218,12 +187,6 @@ func (l *Library) clearLibraryTables() error { ) } - if err := txq.DeleteAllArtistCredits(l.ctx); err != nil { - return fmt.Errorf( - "could not clear artist credits: %w", err, - ) - } - if err := txq.DeleteAllArtists(l.ctx); err != nil { return fmt.Errorf( "could not clear artists: %w", err, diff --git a/backend/library/scan_dirdisc_test.go b/backend/library/scan_dirdisc_test.go index 300d211..8d7ea62 100644 --- a/backend/library/scan_dirdisc_test.go +++ b/backend/library/scan_dirdisc_test.go @@ -81,7 +81,7 @@ func scanTestGroupKeys(t *testing.T, lib *Library, root string) map[string]strin t.Fatal("scanInternal returned nil metrics") } - rows, err := lib.db.Queries.GetAudioFilesByLibrary(lib.ctx, library.ID) + rows, err := lib.db.Queries.GetAudioFilesInLibrary(lib.ctx, library.ID) if err != nil { t.Fatalf("list audio files: %v", err) } diff --git a/backend/library/scan_fixtures_test.go b/backend/library/scan_fixtures_test.go new file mode 100644 index 0000000..8f43720 --- /dev/null +++ b/backend/library/scan_fixtures_test.go @@ -0,0 +1,103 @@ +package library + +import ( + "path/filepath" + "testing" + + "yellowjacket/backend/database/sql/sqlcgen" +) + +// TestScan_FixtureLibraryLeavesNothingBehind runs a real scan over the +// generated fixture library and asserts the invariant the file-shaped +// schema exists for: every row is a file's, and nothing outlives one. +// +// The old schema could not state this. A scan wrote a recording, an +// artist credit, a credit-artist link and a release-group link per +// file, all of which survived the file's deletion, and a real library +// accumulated 812 recordings, 216 release groups and 260 artists with +// nothing behind them - which is what made "do I own this" unanswerable. +func TestScan_FixtureLibraryLeavesNothingBehind(t *testing.T) { + t.Parallel() + + lib, db := setupTestLibrary(t) + + root, err := filepath.Abs("../../test_data/music_library_test") + if err != nil { + t.Fatalf("resolve fixture path: %v", err) + } + + if _, err := filepath.Glob(filepath.Join(root, "*")); err != nil { + t.Skipf("fixture library not generated (make testdata): %v", err) + } + + library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{ + Name: "Fixtures", + Path: root, + }) + if err != nil { + t.Fatalf("create library: %v", err) + } + + if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil { + t.Fatal("scanInternal returned nil metrics") + } + + count := func(query string) int64 { + t.Helper() + + var n int64 + if err := db.QueryRowWriter(query).Scan(&n); err != nil { + t.Fatalf("%s: %v", query, err) + } + + return n + } + + files := count("SELECT COUNT(*) FROM audio_files") + if files == 0 { + t.Skip("fixture library is empty; run make testdata") + } + + tracks, err := lib.GetTracks(0) + if err != nil { + t.Fatalf("GetTracks: %v", err) + } + + // One track per file: the projection cannot multiply rows, because + // there is no join table left to multiply them. + if int64(len(tracks)) != files { + t.Errorf("GetTracks returned %d rows for %d files", len(tracks), files) + } + + // Nothing shared outlives what refers to it. + for _, c := range []struct { + what string + query string + }{ + {"albums with no file", `SELECT COUNT(*) FROM albums al + WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.album_id = al.id)`}, + {"artists nothing refers to", `SELECT COUNT(*) 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)`}, + {"genre links with no file", `SELECT COUNT(*) FROM file_genres fg + WHERE NOT EXISTS (SELECT 1 FROM audio_files af WHERE af.id = fg.audio_file_id)`}, + } { + if n := count(c.query); n != 0 { + t.Errorf("%s = %d, want 0", c.what, n) + } + } + + // And the scan actually filed things: albums, artists and genres + // all resolved, with the tags on the files that named them. + if albums, err := lib.GetAlbums(0); err != nil || len(albums) == 0 { + t.Errorf("GetAlbums = %d albums, err %v; want some", len(albums), err) + } + + if artists, err := lib.GetArtists(0); err != nil || len(artists) == 0 { + t.Errorf("GetArtists = %d artists, err %v; want some", len(artists), err) + } + + if genres, err := lib.GetGenres(0); err != nil || len(genres) == 0 { + t.Errorf("GetGenres = %d genres, err %v; want some", len(genres), err) + } +} diff --git a/backend/library/scan_jobs.go b/backend/library/scan_jobs.go index b57c4a7..abcb347 100644 --- a/backend/library/scan_jobs.go +++ b/backend/library/scan_jobs.go @@ -21,6 +21,8 @@ var scanPhaseLabels = map[string]string{ // SetJobRegistry wires the background job registry so scans report // progress, logs, and pause/cancel controls to the frontend. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (l *Library) SetJobRegistry(reg *jobs.Registry) { l.mu.Lock() l.jobs = reg diff --git a/backend/library/scan_queue.go b/backend/library/scan_queue.go index 737958b..2998f49 100644 --- a/backend/library/scan_queue.go +++ b/backend/library/scan_queue.go @@ -149,7 +149,7 @@ func (l *Library) SoftScanAllLibraries() error { continue } - dbCount, countErr := l.db.Queries.CountAudioFilesByLibrary( + dbCount, countErr := l.db.Queries.CountAudioFiles( l.ctx, lib.ID, ) if countErr != nil { diff --git a/backend/library/scan_test.go b/backend/library/scan_test.go index b902292..cc7b423 100644 --- a/backend/library/scan_test.go +++ b/backend/library/scan_test.go @@ -1,9 +1,7 @@ package library import ( - "context" "database/sql" - "fmt" "log/slog" "sync/atomic" "testing" @@ -188,35 +186,28 @@ func TestSplitGenres(t *testing.T) { } } -func TestMapTrackRow(t *testing.T) { +func TestTrackFromRow(t *testing.T) { t.Parallel() - track := mapTrackRow( - "/music/queen/bohemian.flac", // filePath - 180000, // lengthMs - "Bohemian Rhapsody", // title - "Queen", // artistName - sql.NullInt64{Int64: 1, Valid: true}, // trackNumber - sql.NullInt64{Int64: 1, Valid: true}, // discNumber - "A Night at the Opera", // album - "Rock||Progressive Rock", // genre - 1975, // year - "Freddie Mercury", // composer - ".flac", // fileType - 44100, // sampleRate - 16, // bitDepth - 2, // channels - 1411, // bitrate - 35000000, // fileSize - 0, // playCount - sql.NullTime{}, // lastPlayed - "", // coverArtPath - "", // artistMBID - "", // releaseGroupMBID - "", // recordingMBID - ) + track := trackFromRow(sqlcgen.TrackMetadatum{ + FilePath: "/music/queen/bohemian.flac", + LengthMilliseconds: 180000, + Title: "Bohemian Rhapsody", + ArtistName: "Queen", + TrackNumber: sql.NullInt64{Int64: 1, Valid: true}, + DiscNumber: sql.NullInt64{Int64: 1, Valid: true}, + Album: "A Night at the Opera", + Genre: "Rock||Progressive Rock", + Year: 1975, + Composer: "Freddie Mercury", + FileType: ".flac", + SampleRate: 44100, + BitDepth: 16, + Channels: 2, + Bitrate: 1411, + FileSize: 35000000, + }) - // Verify all 16 fields. if track.TrackName != "Bohemian Rhapsody" { t.Errorf("TrackName = %q, want %q", track.TrackName, "Bohemian Rhapsody") } @@ -225,50 +216,19 @@ func TestMapTrackRow(t *testing.T) { t.Errorf("ArtistName = %q, want %q", track.ArtistName, "Queen") } - // TrackLength is string-formatted milliseconds. if track.TrackLength != "180000" { t.Errorf("TrackLength = %q, want %q", track.TrackLength, "180000") } - if track.FilePath != "/music/queen/bohemian.flac" { - t.Errorf("FilePath = %q, want %q", track.FilePath, "/music/queen/bohemian.flac") - } - - if track.TrackNumber != 1 { - t.Errorf("TrackNumber = %d, want %d", track.TrackNumber, 1) - } - - if track.DiscNumber != 1 { - t.Errorf("DiscNumber = %d, want %d", track.DiscNumber, 1) - } - - if track.Album != "A Night at the Opera" { - t.Errorf("Album = %q, want %q", track.Album, "A Night at the Opera") - } - - wantGenres := []string{"Rock", "Progressive Rock"} - if len(track.Genre) != len(wantGenres) { - t.Fatalf("Genre length = %d, want %d", len(track.Genre), len(wantGenres)) - } - - for i, g := range track.Genre { - if g != wantGenres[i] { - t.Errorf("Genre[%d] = %q, want %q", i, g, wantGenres[i]) - } + if len(track.Genre) != 2 || track.Genre[0] != "Rock" || + track.Genre[1] != "Progressive Rock" { + t.Errorf("Genre = %v, want [Rock, Progressive Rock]", track.Genre) } if track.Year != 1975 { t.Errorf("Year = %d, want %d", track.Year, 1975) } - if track.Composer != "Freddie Mercury" { - t.Errorf("Composer = %q, want %q", track.Composer, "Freddie Mercury") - } - - if track.FileType != ".flac" { - t.Errorf("FileType = %q, want %q", track.FileType, ".flac") - } - if track.SampleRate != 44100 { t.Errorf("SampleRate = %d, want %d", track.SampleRate, 44100) } @@ -289,16 +249,12 @@ func TestMapTrackRow(t *testing.T) { t.Errorf("FileSize = %d, want %d", track.FileSize, 35000000) } - // Verify NullInt64 with Valid=false yields 0. - trackNull := mapTrackRow( - "/music/unknown.mp3", 0, "Test", "Artist", - sql.NullInt64{}, sql.NullInt64{}, // invalid (null) - "", "", 0, "", "", 0, 0, 0, 0, 0, - 0, // playCount - sql.NullTime{}, // lastPlayed - "", // coverArtPath - "", "", "", // artistMBID, releaseGroupMBID, recordingMBID - ) + // A NULL track/disc number yields 0, not a panic. + trackNull := trackFromRow(sqlcgen.TrackMetadatum{ + FilePath: "/music/unknown.mp3", + Title: "Test", + ArtistName: "Artist", + }) if trackNull.TrackNumber != 0 { t.Errorf("null TrackNumber = %d, want 0", trackNull.TrackNumber) @@ -307,11 +263,11 @@ func TestMapTrackRow(t *testing.T) { if trackNull.DiscNumber != 0 { t.Errorf("null DiscNumber = %d, want 0", trackNull.DiscNumber) } -} -// --------------------------------------------------------------------------- -// Test helper — constructs a Library backed by an in-memory test DB -// --------------------------------------------------------------------------- + if trackNull.Genre != nil { + t.Errorf("empty Genre = %v, want nil", trackNull.Genre) + } +} func setupTestLibrary(t *testing.T) (*Library, *database.DB) { t.Helper() @@ -335,129 +291,111 @@ func setupTestLibrary(t *testing.T) (*Library, *database.DB) { // Entity cache tests — DB-backed // --------------------------------------------------------------------------- -func TestCachedUpsertArtistCredit(t *testing.T) { +func TestCachedUpsertArtist(t *testing.T) { t.Parallel() lib, _ := setupTestLibrary(t) cache := newEntityCache() q := lib.db.Queries - // First call — hits DB. - ac1, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") - if err != nil { - t.Fatalf("first cachedUpsertArtistCredit: %v", err) + first := lib.cachedUpsertArtist(q, cache, "Queen", "") + if first.ID == 0 { + t.Fatal("expected non-zero artist ID") } - if ac1.ID == 0 { - t.Fatal("expected non-zero ArtistCredit ID") + // Second call is a cache hit and returns the same row. + if second := lib.cachedUpsertArtist(q, cache, "Queen", ""); second.ID != first.ID { + t.Errorf("cache miss: got ID %d, want %d", second.ID, first.ID) } - // Second call — cache hit, same ID. - ac2, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") - if err != nil { - t.Fatalf("second cachedUpsertArtistCredit: %v", err) + if other := lib.cachedUpsertArtist(q, cache, "Beyonce", ""); other.ID == first.ID { + t.Errorf("different name returned same ID %d", other.ID) } - if ac2.ID != ac1.ID { - t.Errorf("cache miss: got ID %d, want %d", ac2.ID, ac1.ID) + if len(cache.artists) != 2 { + t.Errorf("cache entries = %d, want 2", len(cache.artists)) } - // Different name — different ID. - ac3, err := lib.cachedUpsertArtistCredit(q, cache, "Beyoncé") - if err != nil { - t.Fatalf("cachedUpsertArtistCredit(Beyoncé): %v", err) + // An MBID arriving on a later file is written to the cached row - + // the first file of an album often has no MBID and a later one does. + withMBID := lib.cachedUpsertArtist(q, cache, "Queen", "mbid-queen") + if !withMBID.Mbid.Valid || withMBID.Mbid.String != "mbid-queen" { + t.Errorf("artist mbid = %v, want mbid-queen", withMBID.Mbid) } - if ac3.ID == ac1.ID { - t.Errorf("different name returned same ID %d", ac3.ID) - } - - // Cache should have 2 entries. - if len(cache.artistCredits) != 2 { - t.Errorf("cache entries = %d, want 2", len(cache.artistCredits)) + // An empty name is not a missing row: it becomes "Unknown Artist", + // because a file with no artist tag still has to belong somewhere. + unknown := lib.cachedUpsertArtist(q, cache, "", "") + if unknown.Name != "Unknown Artist" { + t.Errorf("empty artist name = %q, want %q", unknown.Name, "Unknown Artist") } } -func TestCachedLinkArtist(t *testing.T) { +func TestCachedUpsertAlbum(t *testing.T) { t.Parallel() lib, _ := setupTestLibrary(t) cache := newEntityCache() q := lib.db.Queries - metrics := newScanMetrics() - // Create an artist credit first. - ac, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) + first := lib.cachedUpsertAlbum(q, cache, albumParams{ + name: "A Night at the Opera", + credit: "Queen", + }) + if first.ID == 0 { + t.Fatal("expected non-zero album ID") } - // First link — creates artist + artist-credit-artist link. - lib.cachedLinkArtist(q, cache, metrics, "Queen", ac.ID) - - if len(cache.artists) != 1 { - t.Errorf("artists cache = %d, want 1", len(cache.artists)) + same := lib.cachedUpsertAlbum(q, cache, albumParams{ + name: "A Night at the Opera", + credit: "Queen", + }) + if same.ID != first.ID { + t.Errorf("cache miss: got ID %d, want %d", same.ID, first.ID) } - if len(cache.linkedCredits) != 1 { - t.Errorf("linkedCredits cache = %d, want 1", len(cache.linkedCredits)) - } - - // Second call with same args — should skip (cache hit). - lib.cachedLinkArtist(q, cache, metrics, "Queen", ac.ID) - - if len(cache.linkedCredits) != 1 { - t.Errorf( - "linkedCredits after duplicate = %d, want 1 (should skip)", - len(cache.linkedCredits), - ) + // Album identity is (name, credit), so the same title by someone + // else is a different album. + other := lib.cachedUpsertAlbum(q, cache, albumParams{ + name: "A Night at the Opera", + credit: "Blind Guardian", + }) + if other.ID == first.ID { + t.Error("same album name by a different artist collapsed into one album") } } -func TestCachedLinkArtist_MultiCredit(t *testing.T) { +func TestCachedUpsertAlbum_FillsCoverArtLater(t *testing.T) { t.Parallel() lib, _ := setupTestLibrary(t) cache := newEntityCache() q := lib.db.Queries - metrics := newScanMetrics() - // Two different artist credits referencing the same artist name. - ac1, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") + album := lib.cachedUpsertAlbum(q, cache, albumParams{name: "Art", credit: "A"}) + + ca, err := q.UpsertCoverArt(lib.ctx, sqlcgen.UpsertCoverArtParams{ + FilePath: "/covers/art.jpg", + MimeType: "image/jpeg", + }) if err != nil { - t.Fatalf("upsert credit 1: %v", err) + t.Fatalf("upsert cover art: %v", err) } - ac2, err := lib.cachedUpsertArtistCredit(q, cache, "Queen feat. David Bowie") - if err != nil { - t.Fatalf("upsert credit 2: %v", err) + // The first file of an album often carries no embedded art and a + // later one does; the album has to pick it up. + withArt := lib.cachedUpsertAlbum(q, cache, albumParams{ + name: "Art", + credit: "A", + coverArtID: sql.NullInt64{Int64: ca.ID, Valid: true}, + }) + + if withArt.ID != album.ID { + t.Fatalf("album ID changed: got %d, want %d", withArt.ID, album.ID) } - // Link "Queen" artist to both credits. - lib.cachedLinkArtist(q, cache, metrics, "Queen", ac1.ID) - lib.cachedLinkArtist(q, cache, metrics, "Queen", ac2.ID) - - // Artist cached once. - if len(cache.artists) != 1 { - t.Errorf("artists cache = %d, want 1 (same artist name)", len(cache.artists)) - } - - // Two distinct linked-credit entries. - if len(cache.linkedCredits) != 2 { - t.Errorf("linkedCredits = %d, want 2", len(cache.linkedCredits)) - } - - // Verify link keys are correct format. - queenArtist := cache.artists["Queen"] - key1 := fmt.Sprintf("%d:%d", queenArtist.ID, ac1.ID) - key2 := fmt.Sprintf("%d:%d", queenArtist.ID, ac2.ID) - - if _, ok := cache.linkedCredits[key1]; !ok { - t.Errorf("missing linked credit key %q", key1) - } - - if _, ok := cache.linkedCredits[key2]; !ok { - t.Errorf("missing linked credit key %q", key2) + if !withArt.CoverArtID.Valid || withArt.CoverArtID.Int64 != ca.ID { + t.Errorf("cover art = %v, want %d", withArt.CoverArtID, ca.ID) } } @@ -468,460 +406,83 @@ func TestCachedUpsertGenre(t *testing.T) { cache := newEntityCache() q := lib.db.Queries - // First call — creates genre. - g1, err := lib.cachedUpsertGenre(q, cache, "Rock") + first, err := lib.cachedUpsertGenre(q, cache, "Rock") if err != nil { - t.Fatalf("first cachedUpsertGenre: %v", err) + t.Fatalf("cachedUpsertGenre: %v", err) } - if g1.ID == 0 { - t.Fatal("expected non-zero Genre ID") - } - - // Second call — cache hit. - g2, err := lib.cachedUpsertGenre(q, cache, "Rock") + second, err := lib.cachedUpsertGenre(q, cache, "Rock") if err != nil { - t.Fatalf("second cachedUpsertGenre: %v", err) + t.Fatalf("cachedUpsertGenre (cached): %v", err) } - if g2.ID != g1.ID { - t.Errorf("cache miss: got ID %d, want %d", g2.ID, g1.ID) - } - - if len(cache.genres) != 1 { - t.Errorf("genre cache entries = %d, want 1", len(cache.genres)) + if second.ID != first.ID { + t.Errorf("cache miss: got ID %d, want %d", second.ID, first.ID) } } -func TestResolveReleaseGroup(t *testing.T) { - t.Parallel() - - lib, _ := setupTestLibrary(t) - cache := newEntityCache() - q := lib.db.Queries - - // Need an album artist credit for the release group. - ac, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - albumArtistCreditID := sql.NullInt64{Int64: ac.ID, Valid: true} - - // First call — no cover art. - tags := &metadata.TrackMetadata{ - Album: "A Night at the Opera", - Year: 1975, - } - - rgID := lib.resolveReleaseGroup(q, cache, tags, albumArtistCreditID, sql.NullInt64{}) - if !rgID.Valid { - t.Fatal("expected valid release group ID") - } - - if rgID.Int64 == 0 { - t.Fatal("expected non-zero release group ID") - } - - // Verify cached. - if len(cache.releaseGroups) != 1 { - t.Errorf("releaseGroups cache = %d, want 1", len(cache.releaseGroups)) - } - - // Second call — same album with cover art → should update cover art on cached entry. - // First, create a cover art record in the DB. - coverArt, err := q.UpsertCoverArt(lib.ctx, sqlcgen.UpsertCoverArtParams{ - IsEmbedded: true, - FilePath: "/covers/opera.jpg", - MimeType: "image/jpeg", - }) - if err != nil { - t.Fatalf("create cover art: %v", err) - } - - coverArtID := sql.NullInt64{Int64: coverArt.ID, Valid: true} - rgID2 := lib.resolveReleaseGroup(q, cache, tags, albumArtistCreditID, coverArtID) - - if rgID2.Int64 != rgID.Int64 { - t.Errorf("cache miss: got ID %d, want %d", rgID2.Int64, rgID.Int64) - } - - // Cover art should be updated on the cached release group. - // Cache key is composite: "albumName\x00artistCreditID". - cacheKey := fmt.Sprintf("%s\x00%d", "A Night at the Opera", ac.ID) - cachedRG := cache.releaseGroups[cacheKey] - - if !cachedRG.CoverArtID.Valid { - t.Error("expected CoverArtID to be set after update") - } - - if cachedRG.CoverArtID.Int64 != coverArt.ID { - t.Errorf("CoverArtID = %d, want %d", cachedRG.CoverArtID.Int64, coverArt.ID) - } - - // Empty album → invalid NullInt64. - emptyTags := &metadata.TrackMetadata{Album: ""} - rgEmpty := lib.resolveReleaseGroup(q, cache, emptyTags, albumArtistCreditID, sql.NullInt64{}) - - if rgEmpty.Valid { - t.Errorf("empty album should return invalid NullInt64, got valid with ID %d", rgEmpty.Int64) - } -} - -func TestResolveReleaseGroup_CacheHit(t *testing.T) { - t.Parallel() - - lib, _ := setupTestLibrary(t) - cache := newEntityCache() - q := lib.db.Queries - - // Pre-populate cache with a known release group. - // Cache key is composite: "albumName\x00artistCreditID" (use -1 for no artist). - cache.releaseGroups[fmt.Sprintf("%s\x00%d", "Cached Album", int64(-1))] = sqlcgen.ReleaseGroup{ - ID: 42, - Name: "Cached Album", - } - - tags := &metadata.TrackMetadata{Album: "Cached Album"} - rgID := lib.resolveReleaseGroup(q, cache, tags, sql.NullInt64{}, sql.NullInt64{}) - - if !rgID.Valid { - t.Fatal("expected valid release group ID from cache") - } - - if rgID.Int64 != 42 { - t.Errorf("resolveReleaseGroup() = %d, want 42 (cached)", rgID.Int64) - } -} - -// --------------------------------------------------------------------------- -// Orphan cleanup test — DB-level -// --------------------------------------------------------------------------- - -func TestOrphanDeletion(t *testing.T) { - t.Parallel() - - _, db := setupTestLibrary(t) - ctx := context.Background() - q := db.Queries - - // Seed an artist credit → recording → audio file chain. - ac, err := q.UpsertArtistCredit(ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Test Song", - ArtistCreditID: ac.ID, - }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/test.mp3", - LengthMilliseconds: 180000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: "test.mp3", - }) - if err != nil { - t.Fatalf("create audio file: %v", err) - } - - // Add FTS search index entry. - if err := db.InsertSearchIndex( - af.ID, "/music/test.mp3", "Test Song", "Test Artist", "", - ); err != nil { - t.Fatalf("insert search index: %v", err) - } - - // Verify the search index entry exists before deletion. - results, err := db.SearchFTS("Test Song", 10) - if err != nil { - t.Fatalf("search before delete: %v", err) - } - - if len(results) != 1 { - t.Fatalf("search results before delete = %d, want 1", len(results)) - } - - // Delete audio file — this is the primary orphan cleanup step. - if err := q.DeleteAudioFile(ctx, af.ID); err != nil { - t.Fatalf("delete audio file: %v", err) - } - - // Verify audio file is gone by attempting to query all audio files. - allFiles, err := q.GetAllAudioFiles(ctx) - if err != nil { - t.Fatalf("get all audio files: %v", err) - } - - if len(allFiles) != 0 { - t.Errorf("audio files after delete = %d, want 0", len(allFiles)) - } - - // DeleteSearchIndex on contentless FTS5 table (content='') is - // expected to error. The production orphan cleanup code in - // library.go logs this as a warning — the search index entries - // become stale but harmless (they reference a non-existent - // audio_file ID, so JOINs return no results). - // ClearSearchIndex (used during full rescan) handles bulk cleanup. - // DeleteSearchIndex on contentless FTS5 is expected to error. - // Not a fatal error — documents the contentless FTS5 limitation. - err = db.DeleteSearchIndex(af.ID) - if err == nil { - t.Log("DeleteSearchIndex succeeded (unexpected for contentless FTS5)") - } -} - -func TestPruneOrphanedMetadata(t *testing.T) { +// TestPruneEmptyEntities is what is left of four orphan-sweep tests. +// +// Three of the tables they covered are gone, and with them the bug they +// were guarding: a file used to create a recording, a credit, a +// credit-artist link and a release-group link, none of which were +// deleted when the file was, so a real library accumulated 812 +// recordings, 216 release groups and 260 artists with nothing behind +// them. Two tables can still be left empty by a removal, and this is +// that. +func TestPruneEmptyEntities(t *testing.T) { t.Parallel() lib, db := setupTestLibrary(t) - ctx := context.Background() - q := db.Queries - // Seed a full chain: artist -> artist_credit -> recording -> audio_file, - // plus a release group crediting the same artist. - artist, err := q.UpsertArtist(ctx, "Orphaned Artist") - if err != nil { - t.Fatalf("upsert artist: %v", err) - } - - ac, err := q.UpsertArtistCredit(ctx, "Orphaned Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - if _, err := q.CreateArtistCreditArtist(ctx, sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: artist.ID, - CreditID: ac.ID, - }); err != nil { - t.Fatalf("link artist credit artist: %v", err) - } - - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Orphaned Song", - ArtistCreditID: ac.ID, + kept := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/kept.mp3", + Title: "Kept", + Artist: "Kept Artist", + Album: "Kept Album", + Genres: []string{"Kept Genre"}, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - rg, err := q.CreateReleaseGroupFull(ctx, sqlcgen.CreateReleaseGroupFullParams{ - Name: "Orphaned Album", - AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true}, + gone := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/gone.mp3", + Title: "Gone", + Artist: "Gone Artist", + Album: "Gone Album", + Genres: []string{"Gone Genre"}, }) - if err != nil { - t.Fatalf("create release group: %v", err) - } - if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: rg.ID, - RecordingID: rec.ID, - }); err != nil { - t.Fatalf("link release group recording: %v", err) - } + _ = kept - af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/orphaned.mp3", - LengthMilliseconds: 180000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: "orphaned.mp3", - }) - if err != nil { - t.Fatalf("create audio file: %v", err) - } - - // Simulate a rescan removing the file: delete the audio_files row - // (what the existing Phase 5 orphan cleanup does), then run the new - // metadata cleanup that's supposed to cascade the rest. - if err := q.DeleteAudioFile(ctx, af.ID); err != nil { + if err := db.Queries.DeleteAudioFile(lib.ctx, gone); err != nil { t.Fatalf("delete audio file: %v", err) } - lib.pruneOrphanedMetadata() + lib.pruneEmptyEntities() - if _, err := q.GetRecording(ctx, rec.ID); err == nil { - t.Error("expected orphaned recording to be deleted") - } + for _, c := range []struct { + table string + name string + want int + }{ + {"albums", "Gone Album", 0}, + {"albums", "Kept Album", 1}, + {"artists", "Gone Artist", 0}, + {"artists", "Kept Artist", 1}, + {"genres", "Gone Genre", 0}, + {"genres", "Kept Genre", 1}, + } { + var n int + if err := db.QueryRowWriter( + "SELECT COUNT(*) FROM "+c.table+" WHERE name = ?", c.name, + ).Scan(&n); err != nil { + t.Fatalf("count %s %q: %v", c.table, c.name, err) + } - if _, err := q.GetReleaseGroup(ctx, rg.ID); err == nil { - t.Error("expected orphaned release group to be deleted") - } - - if _, err := q.GetArtistCredit(ctx, ac.ID); err == nil { - t.Error("expected orphaned artist credit to be deleted") - } - - if _, err := q.GetArtist(ctx, artist.ID); err == nil { - t.Error("expected orphaned artist to be deleted") + if n != c.want { + t.Errorf("%s %q rows = %d, want %d", c.table, c.name, n, c.want) + } } } -// TestPruneOrphanedMetadata_KeepsStillOwnedEntities verifies that pruning -// only removes rows with no remaining audio_files, leaving an artist who -// still owns other tracks untouched. -func TestPruneOrphanedMetadata_KeepsStillOwnedEntities(t *testing.T) { - t.Parallel() - - lib, db := setupTestLibrary(t) - ctx := context.Background() - q := db.Queries - - artist, err := q.UpsertArtist(ctx, "Still Owned Artist") - if err != nil { - t.Fatalf("upsert artist: %v", err) - } - - ac, err := q.UpsertArtistCredit(ctx, "Still Owned Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - if _, err := q.CreateArtistCreditArtist(ctx, sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: artist.ID, - CreditID: ac.ID, - }); err != nil { - t.Fatalf("link artist credit artist: %v", err) - } - - // Two recordings under the same artist credit; only one loses its file. - recGone, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Removed Song", - ArtistCreditID: ac.ID, - }) - if err != nil { - t.Fatalf("create recording (removed): %v", err) - } - - recKept, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Kept Song", - ArtistCreditID: ac.ID, - }) - if err != nil { - t.Fatalf("create recording (kept): %v", err) - } - - afGone, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/gone.mp3", - LengthMilliseconds: 180000, - FileTypeID: 0, - RecordingID: recGone.ID, - Basename: "gone.mp3", - }) - if err != nil { - t.Fatalf("create audio file (gone): %v", err) - } - - if _, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/kept.mp3", - LengthMilliseconds: 180000, - FileTypeID: 0, - RecordingID: recKept.ID, - Basename: "kept.mp3", - }); err != nil { - t.Fatalf("create audio file (kept): %v", err) - } - - if err := q.DeleteAudioFile(ctx, afGone.ID); err != nil { - t.Fatalf("delete audio file: %v", err) - } - - lib.pruneOrphanedMetadata() - - if _, err := q.GetRecording(ctx, recGone.ID); err == nil { - t.Error("expected orphaned recording to be deleted") - } - - if _, err := q.GetRecording(ctx, recKept.ID); err != nil { - t.Errorf("expected still-owned recording to survive, got: %v", err) - } - - if _, err := q.GetArtistCredit(ctx, ac.ID); err != nil { - t.Errorf("expected still-referenced artist credit to survive, got: %v", err) - } - - if _, err := q.GetArtist(ctx, artist.ID); err != nil { - t.Errorf("expected still-referenced artist to survive, got: %v", err) - } -} - -// --------------------------------------------------------------------------- -// Empty/missing metadata tests -// --------------------------------------------------------------------------- - -func TestEntityCache_EmptyFields(t *testing.T) { - t.Parallel() - - lib, _ := setupTestLibrary(t) - cache := newEntityCache() - q := lib.db.Queries - metrics := newScanMetrics() - - // Empty artist credit name — documents behavior (creates "" credit). - ac, err := lib.cachedUpsertArtistCredit(q, cache, "") - if err != nil { - t.Fatalf("cachedUpsertArtistCredit with empty name: %v", err) - } - - if ac.ID == 0 { - t.Error("expected non-zero ID even for empty artist credit name") - } - - // Empty album → resolveReleaseGroup returns invalid NullInt64. - tags := &metadata.TrackMetadata{Album: ""} - rgID := lib.resolveReleaseGroup(q, cache, tags, sql.NullInt64{}, sql.NullInt64{}) - - if rgID.Valid { - t.Errorf("empty album should return invalid NullInt64, got valid ID %d", rgID.Int64) - } - - // resolveAlbumArtistCredit with empty AlbumArtist reuses track artist credit. - trackTags := &metadata.TrackMetadata{ - Artist: "Queen", - AlbumArtist: "", - } - - trackAC, err := lib.cachedUpsertArtistCredit(q, cache, "Queen") - if err != nil { - t.Fatalf("upsert track artist credit: %v", err) - } - - albumACID := lib.resolveAlbumArtistCredit(q, cache, metrics, trackTags, trackAC.ID) - if !albumACID.Valid { - t.Fatal("expected valid album artist credit ID when AlbumArtist is empty") - } - - if albumACID.Int64 != trackAC.ID { - t.Errorf( - "empty AlbumArtist should reuse track credit: got %d, want %d", - albumACID.Int64, trackAC.ID, - ) - } - - // resolveAlbumArtistCredit when AlbumArtist matches Artist also reuses. - sameTags := &metadata.TrackMetadata{ - Artist: "Queen", - AlbumArtist: "Queen", - } - - sameACID := lib.resolveAlbumArtistCredit(q, cache, metrics, sameTags, trackAC.ID) - if sameACID.Int64 != trackAC.ID { - t.Errorf( - "matching AlbumArtist should reuse track credit: got %d, want %d", - sameACID.Int64, trackAC.ID, - ) - } -} - -// --------------------------------------------------------------------------- -// commitBatch + tagging_items bookkeeping (phase 008.3) -// --------------------------------------------------------------------------- - func TestCommitBatch_TaggingItemsBookkeeping(t *testing.T) { t.Parallel() @@ -1146,6 +707,122 @@ func TestCommitBatch_AlbumTagChangeKeepsGroup(t *testing.T) { } } +func TestCommitBatch_RescanPromotesTagStatus(t *testing.T) { + t.Parallel() + + // Only the insert path stamps tag_status, so a file another + // tagger stamped with MBIDs after import used to keep 'untagged' + // for ever — and its folder kept asking to be tagged, since that + // column is what the autotag queue reads. + lib, db := setupTestLibrary(t) + cache := newEntityCache() + metrics := newScanMetrics() + + var added, updated, skipped atomic.Int64 + + const path = "/music/Artist/Album Folder/01.mp3" + + initial := []importResult{ + { + absolutePath: path, + fileType: metadata.MP3, + lengthMillis: 200000, + tags: &metadata.TrackMetadata{ + Title: "Track", Artist: "Artist", AlbumArtist: "Artist", + Album: "Album", + }, + libraryID: 0, + }, + } + + if err := lib.commitBatch( + initial, cache, metrics, &added, &updated, &skipped, nil, + ); err != nil { + t.Fatalf("initial commitBatch: %v", err) + } + + fileID := queryInt(t, db, + `SELECT id FROM audio_files WHERE file_path = ?`, path, + ) + + if got := queryString(t, db, + `SELECT tag_status FROM audio_files WHERE id = ?`, fileID, + ); got != "untagged" { + t.Fatalf("tag_status after import = %q, want %q", got, "untagged") + } + + update := []importResult{ + { + absolutePath: path, + fileType: metadata.MP3, + lengthMillis: 200000, + existingFileID: fileID, + needsUpdate: true, + tags: &metadata.TrackMetadata{ + Title: "Track", Artist: "Artist", AlbumArtist: "Artist", + Album: "Album", + RecordingMBID: "11111111-2222-3333-4444-555555555555", + }, + libraryID: 0, + }, + } + + if err := lib.commitBatch( + update, cache, metrics, &added, &updated, &skipped, nil, + ); err != nil { + t.Fatalf("update commitBatch: %v", err) + } + + if got := queryString(t, db, + `SELECT tag_status FROM audio_files WHERE id = ?`, fileID, + ); got != "user_confirmed" { + t.Errorf("tag_status after rescan = %q, want %q", got, "user_confirmed") + } + + // A deliberate "never ask me about this file again" outranks the + // promotion: the guard is on 'untagged', not on the MBID. + if _, err := db.ExecContext( + `UPDATE audio_files SET tag_status = 'user_skipped_permanent' WHERE id = ?`, + fileID, + ); err != nil { + t.Fatalf("mark skipped: %v", err) + } + + if err := lib.commitBatch( + update, cache, metrics, &added, &updated, &skipped, nil, + ); err != nil { + t.Fatalf("second update commitBatch: %v", err) + } + + if got := queryString(t, db, + `SELECT tag_status FROM audio_files WHERE id = ?`, fileID, + ); got != "user_skipped_permanent" { + t.Errorf("tag_status = %q, want the skip to survive a rescan", got) + } +} + +// queryString is queryInt's text counterpart. +func queryString(t *testing.T, db *database.DB, query string, args ...any) string { + t.Helper() + + rows, err := db.QueryContext(query, args...) + if err != nil { + t.Fatalf("query %q: %v", query, err) + } + + defer func() { _ = rows.Close() }() + + var out string + + if rows.Next() { + if scanErr := rows.Scan(&out); scanErr != nil { + t.Fatalf("scan: %v", scanErr) + } + } + + return out +} + // queryInt runs a single-column scalar query and returns the first // int64 result; fails the test on any error. func queryInt(t *testing.T, db *database.DB, query string, args ...any) int64 { diff --git a/backend/library/staleness_test.go b/backend/library/staleness_test.go index f98dcd3..65415cd 100644 --- a/backend/library/staleness_test.go +++ b/backend/library/staleness_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" ) @@ -179,50 +180,36 @@ func TestFlushStatBackfill(t *testing.T) { ctx := lib.ctx q := db.Queries - ac, err := q.UpsertArtistCredit(ctx, "Test Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Test Song", - ArtistCreditID: ac.ID, + // Seed two rows with no staleness baseline, as an older install + // leaves them. + first := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/first.mp3", + Title: "Test Song", + Artist: "Test Artist", + LengthMs: 180000, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - // Seed two rows with no baseline, as migration 47 leaves them. - first, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/first.mp3", - LengthMilliseconds: 180000, - RecordingID: rec.ID, - Basename: "first.mp3", + second := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/second.mp3", + Title: "Test Song", + Artist: "Test Artist", + LengthMs: 200000, }) + + seeded, err := q.GetAudioFile(ctx, first) if err != nil { - t.Fatalf("create first audio file: %v", err) + t.Fatalf("get seeded file: %v", err) } - second, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: "/music/second.mp3", - LengthMilliseconds: 200000, - RecordingID: rec.ID, - Basename: "second.mp3", - }) - if err != nil { - t.Fatalf("create second audio file: %v", err) - } - - if first.ModifiedAt != 0 { - t.Fatalf("seeded ModifiedAt = %d, want 0", first.ModifiedAt) + if seeded.ModifiedAt != 0 { + t.Fatalf("seeded ModifiedAt = %d, want 0", seeded.ModifiedAt) } lib.flushStatBackfill([]sqlcgen.UpdateAudioFileStatParams{ - {ModifiedAt: 1700000000, FileSize: 4096, ID: first.ID}, - {ModifiedAt: 1700000500, FileSize: 8192, ID: second.ID}, + {ModifiedAt: 1700000000, FileSize: 4096, ID: first}, + {ModifiedAt: 1700000500, FileSize: 8192, ID: second}, }) - got, err := q.GetAudioFile(ctx, first.ID) + got, err := q.GetAudioFile(ctx, first) if err != nil { t.Fatalf("get first audio file: %v", err) } diff --git a/backend/maintenance/maintenance_test.go b/backend/maintenance/maintenance_test.go index d248323..f88d621 100644 --- a/backend/maintenance/maintenance_test.go +++ b/backend/maintenance/maintenance_test.go @@ -3,6 +3,7 @@ package maintenance import ( "context" "errors" + "fmt" "log/slog" "os" "path/filepath" @@ -319,12 +320,14 @@ func TestOrphanedArtistImagesJob(t *testing.T) { } } - // The owned artist is in the library. - if _, err := db.ExecContext( - `INSERT INTO artists (name, mbid) VALUES ('Owned', ?)`, ownedMBID, - ); err != nil { - t.Fatalf("seed artists: %v", err) - } + // The owned artist is in the library - which means a *file* says + // so. An artists row on its own is the phantom the file-shaped + // schema removed, and it is not ownership. + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/owned.mp3", + Artist: "Owned", + ArtistMBID: ownedMBID, + }) old := time.Now().Add(-200 * 24 * time.Hour) @@ -521,3 +524,145 @@ func TestStrayArtistImageFilesJobRefusesEmptyKeepSet(t *testing.T) { t.Error("an empty keep set emptied the directory") } } + +// TestOrphanedArtistImagesJob_EvictsOverBudget pins the ceiling. +// +// Age alone bounds nothing: a browsing session fetches portraits for +// hundreds of artists in an afternoon and every one of them is inside +// the retention window. A real install held art for 5,770 artists in a +// 1,301-artist library. Art for an artist the user owns is outside the +// budget and must survive an eviction that takes everything else. +func TestOrphanedArtistImagesJob_EvictsOverBudget(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + dir := t.TempDir() + + const ownedMBID = "aaaaaaaa-0000-0000-0000-000000000000" + + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/owned.mp3", + Artist: "Owned", + ArtistMBID: ownedMBID, + }) + + // Each artist's art is a quarter of the budget, so four browsed + // artists sit exactly on it and the fifth pushes it over. + blob := make([]byte, browsedArtBudget/4) + + seed := func(mbid string, created time.Time) { + t.Helper() + + artistDir := explore.ArtistImageDir(dir, mbid) + if err := os.MkdirAll(artistDir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", mbid, err) + } + + if err := os.WriteFile( + filepath.Join(artistDir, "primary.jpg"), blob, 0o600, + ); err != nil { + t.Fatalf("write image: %v", err) + } + + if _, err := db.ExecContext( + `INSERT INTO artist_images + (artist_mbid, source, source_url, file_path, created_at) + VALUES (?, 'test', 'http://x', ?, ?)`, + mbid, filepath.Join(artistDir, "primary.jpg"), created, + ); err != nil { + t.Fatalf("seed artist_images for %s: %v", mbid, err) + } + } + + // All inside the retention window, so only the budget can evict. + now := time.Now() + seed(ownedMBID, now) + + browsed := []string{ + "bbbbbbbb-0000-0000-0000-000000000000", + "cccccccc-0000-0000-0000-000000000000", + "dddddddd-0000-0000-0000-000000000000", + "eeeeeeee-0000-0000-0000-000000000000", + "ffffffff-0000-0000-0000-000000000000", + } + + for i, mbid := range browsed { + seed(mbid, now.Add(-time.Duration(len(browsed)-i)*time.Hour)) + } + + result, err := OrphanedArtistImagesJob(db, dir, explore.ArtistImageDir). + Run(context.Background()) + if err != nil { + t.Fatalf("run job: %v", err) + } + + if result.FilesDeleted == 0 { + t.Error("nothing was evicted despite being over budget") + } + + // The oldest browsed artist goes first. + if _, err := os.Stat(explore.ArtistImageDir(dir, browsed[0])); !os.IsNotExist(err) { + t.Error("the least recently fetched art survived the budget pass") + } + + // The owned artist is never in the budget. + if _, err := os.Stat(explore.ArtistImageDir(dir, ownedMBID)); err != nil { + t.Error("artwork for a library artist was evicted by the budget pass") + } + + // And the newest browsed art survives, because eviction stops as + // soon as the rest fits. + if _, err := os.Stat(explore.ArtistImageDir(dir, browsed[len(browsed)-1])); err != nil { + t.Error("eviction did not stop once under budget") + } +} + +// TestExpiredHTTPCacheJob_TrimsToBudget pins the ceiling that makes a +// year-long entity TTL safe: once answers stop expiring, expiry stops +// being a bound and something else has to be. +func TestExpiredHTTPCacheJob_TrimsToBudget(t *testing.T) { + t.Parallel() + + db := database.NewTestDB(t) + + // Six rows of a third of the budget each: two fit, the rest go. + blob := make([]byte, httpCacheBudget/3) + + for i := range 6 { + if _, err := db.ExecContext( + `INSERT INTO http_cache (url_key, response, expires_at) + VALUES (?, ?, ?)`, + fmt.Sprintf("key-%d", i), blob, + time.Now().Add(time.Duration(i+1)*24*time.Hour), + ); err != nil { + t.Fatalf("seed http_cache: %v", err) + } + } + + if _, err := ExpiredHTTPCacheJob(db).Run(context.Background()); err != nil { + t.Fatalf("run job: %v", err) + } + + var total int64 + if err := db.QueryRowWriter( + "SELECT COALESCE(SUM(LENGTH(response)), 0) FROM http_cache", + ).Scan(&total); err != nil { + t.Fatalf("measure http_cache: %v", err) + } + + if total > httpCacheBudget { + t.Errorf("http_cache is %d bytes, over the %d budget", total, httpCacheBudget) + } + + // The longest-lived answers are the ones kept. + var kept string + if err := db.QueryRowWriter( + "SELECT url_key FROM http_cache ORDER BY expires_at DESC LIMIT 1", + ).Scan(&kept); err != nil { + t.Fatalf("read surviving row: %v", err) + } + + if kept != "key-5" { + t.Errorf("kept %q, want the longest-lived row", kept) + } +} diff --git a/backend/maintenance/sweeps.go b/backend/maintenance/sweeps.go index 1f44b0b..049eae4 100644 --- a/backend/maintenance/sweeps.go +++ b/backend/maintenance/sweeps.go @@ -18,9 +18,20 @@ const ( // survives after it was fetched. browsedArtRetention = 90 * 24 * time.Hour + // browsedArtBudget bounds what browsing costs on disk. Age alone + // is not a ceiling: a real install had portraits for 5,770 artists + // in a 1,301-artist library - 1.2 GB - because every artist page + // opened in Explore fetches one and nothing was counting. Art for + // artists the user owns is not in this budget and is never evicted. + browsedArtBudget = 256 << 20 // 256 MB + // proxyCacheRetention is how long an Explore cover-art thumbnail // survives after it was last written. proxyCacheRetention = 30 * 24 * time.Hour + + // httpCacheBudget bounds the response cache. Entity answers are + // kept for a year, so expiry no longer bounds anything. + httpCacheBudget = 128 << 20 // 128 MB ) // Default intervals. These are minimums, not schedules — the runner @@ -30,11 +41,18 @@ const ( dailyInterval = 24 * time.Hour ) -// ExpiredHTTPCacheJob deletes HTTP cache rows past their TTL. +// ExpiredHTTPCacheJob deletes HTTP cache rows past their TTL, then +// enforces a size ceiling on what is left. // // Reads already filter on expires_at, so expired rows are inert — but // nothing was deleting them, so the table grew without bound for the // life of the install. +// +// The ceiling is the other half, and it is what makes a long TTL safe: +// MusicBrainz entity data is cached for a year now, because it does not +// change and re-fetching it spends someone else's rate limit for +// nothing. Expiry therefore stops being a bound, and something has to +// be. func ExpiredHTTPCacheJob(db *database.DB) Job { return Job{ Name: "http-cache-evict", @@ -51,11 +69,56 @@ func ExpiredHTTPCacheJob(db *database.DB) Job { rows, _ := res.RowsAffected() - return Result{RowsDeleted: rows}, nil + trimmed, err := trimHTTPCache(db) + if err != nil { + return Result{RowsDeleted: rows}, err + } + + return Result{RowsDeleted: rows + trimmed}, nil }, } } +// trimHTTPCache evicts the oldest responses until the cache fits +// httpCacheBudget, and returns how many rows it removed. +// +// "Oldest" is by expiry, which orders by fetch time within a TTL class +// and puts the shortest-lived answers first across classes — a search +// result before an entity lookup, which is the right order to lose them +// in. +func trimHTTPCache(db *database.DB) (int64, error) { + var total int64 + + if err := db.QueryRowWriter( + "SELECT COALESCE(SUM(LENGTH(response)), 0) FROM http_cache", + ).Scan(&total); err != nil { + return 0, fmt.Errorf("measure http_cache: %w", err) + } + + if total <= httpCacheBudget { + return 0, nil + } + + res, err := db.ExecContext(` + DELETE FROM http_cache WHERE url_key IN ( + SELECT url_key FROM ( + SELECT url_key, + SUM(LENGTH(response)) OVER ( + ORDER BY expires_at DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS running + FROM http_cache + ) WHERE running > ? + )`, httpCacheBudget) + if err != nil { + return 0, fmt.Errorf("trim http_cache: %w", err) + } + + rows, _ := res.RowsAffected() + + return rows, nil +} + // OrphanedCoverFilesJob removes files from the covers directory that no // cover_art row references. // @@ -196,11 +259,86 @@ func OrphanedArtistImagesJob( result.RowsDeleted += rows } + // Then the ceiling. Age alone bounds nothing: a browsing + // session can fetch hundreds of portraits in an afternoon, + // and all of them are within the retention window. + evicted, err := evictOverBudget(ctx, db, artistImagesDir, dirFor, &result) + if err != nil { + return result, err + } + + if len(evicted) > 0 { + rows, delErr := deleteArtistImageRows(db, evicted) + if delErr != nil { + return result, delErr + } + + result.RowsDeleted += rows + } + return result, nil }, } } +// evictOverBudget removes the least recently fetched non-library artist +// art until what is left fits browsedArtBudget, and returns the MBIDs it +// removed so their rows can go too. +func evictOverBudget( + ctx context.Context, + db *database.DB, + artistImagesDir string, + dirFor func(baseDir, mbid string) string, + result *Result, +) ([]string, error) { + browsed, err := browsedArtistsByAge(db) + if err != nil { + return nil, err + } + + sizes := make([]int64, len(browsed)) + + var total int64 + + for i, mbid := range browsed { + bytes, _ := dirSize(dirFor(artistImagesDir, mbid)) + sizes[i] = bytes + total += bytes + } + + if total <= browsedArtBudget { + return nil, nil + } + + var evicted []string + + // browsed is oldest first, so this drops the least recently wanted. + for i, mbid := range browsed { + if total <= browsedArtBudget { + break + } + + if ctx.Err() != nil { + return evicted, nil + } + + dir := dirFor(artistImagesDir, mbid) + + bytes, files := dirSize(dir) + if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) { + continue + } + + total -= sizes[i] + result.FilesDeleted += files + result.BytesFreed += bytes + + evicted = append(evicted, mbid) + } + + return evicted, nil +} + // StrayArtistImageFilesJob removes downloaded image candidates that // were never the artist's portrait. // @@ -279,6 +417,56 @@ func StrayArtistImageFilesJob(artistImagesDir string, keep map[string]bool) Job // staleArtistMBIDs returns artist MBIDs whose cached artwork may be // evicted: fetched before the cutoff and not an artist in the library. +// ownedArtistMBIDs is the ownership test, asked the way every other one +// is: an artist is the user's if a *file* says so. An artists row on +// its own is not ownership - that was the bug the file-shaped schema +// removed, and this is the same rule one table over. +const ownedArtistMBIDs = ` + SELECT a.mbid FROM artists a + WHERE a.mbid IS NOT NULL AND a.mbid != '' + 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 + ) + )` + +// browsedArtistsByAge lists non-library artists with cached art, oldest +// first, so the budget pass evicts the least recently wanted. +func browsedArtistsByAge(db *database.DB) ([]string, error) { + rows, err := db.QueryContext( + `SELECT artist_mbid FROM artist_images + WHERE artist_mbid NOT IN (` + ownedArtistMBIDs + `) + GROUP BY artist_mbid + ORDER BY MAX(created_at) ASC`, + ) + if err != nil { + return nil, fmt.Errorf("query browsed artist images: %w", err) + } + + defer func() { _ = rows.Close() }() + + var mbids []string + + for rows.Next() { + var mbid string + + if err := rows.Scan(&mbid); err != nil { + return nil, fmt.Errorf("scan artist mbid: %w", err) + } + + mbids = append(mbids, mbid) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate browsed artist images: %w", err) + } + + return mbids, nil +} + func staleArtistMBIDs( db *database.DB, cutoff time.Time, @@ -286,10 +474,7 @@ func staleArtistMBIDs( rows, err := db.QueryContext( `SELECT DISTINCT artist_mbid FROM artist_images WHERE created_at < ? - AND artist_mbid NOT IN ( - SELECT mbid FROM artists - WHERE mbid IS NOT NULL AND mbid != '' - )`, + AND artist_mbid NOT IN (`+ownedArtistMBIDs+`)`, cutoff, ) if err != nil { diff --git a/backend/player/player.go b/backend/player/player.go index 400135c..d35e14b 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -175,6 +175,8 @@ func (p *Player) InitSpeaker() error { // SetPlaybackFinishedHandler sets a callback invoked when a track // finishes naturally. This allows the queue to drive auto-advance // without circular imports. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (p *Player) SetPlaybackFinishedHandler(handler func()) { p.mu.Lock() defer p.mu.Unlock() @@ -185,6 +187,8 @@ func (p *Player) SetPlaybackFinishedHandler(handler func()) { // SetMediaControls provides an OS media controls handler. When set, // the player pushes metadata, playback state, volume, and seek // notifications to the OS media overlay. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (p *Player) SetMediaControls(h mediacontrols.Handler) { p.mu.Lock() defer p.mu.Unlock() @@ -1038,7 +1042,7 @@ func (p *Player) getCurrentTrackInfoLocked() TrackInfo { // Try to get metadata from database. if p.db != nil { - meta, err := p.db.ReadQueries.GetTrackMetadataByPath( + meta, err := p.db.ReadQueries.GetTrackByPath( p.ctx, info.FilePath, ) if err == nil { @@ -1046,7 +1050,7 @@ func (p *Player) getCurrentTrackInfoLocked() TrackInfo { info.Title = meta.Title } - info.Artist = meta.Artist + info.Artist = meta.ArtistName info.Album = meta.Album info.ArtistMBID = meta.ArtistMbid info.ReleaseGroupMBID = meta.ReleaseGroupMbid @@ -1179,7 +1183,7 @@ func (p *Player) buildMediaMetadata( // full path; ResolveURLs converts it to relative HTTP paths // for the frontend, but MPRIS needs the actual file path. if p.db != nil && info.FilePath != "" { - dbMeta, err := p.db.ReadQueries.GetTrackMetadataByPath( + dbMeta, err := p.db.ReadQueries.GetTrackByPath( p.ctx, info.FilePath, ) if err == nil && dbMeta.CoverArtPath != "" { diff --git a/backend/playlist/emit_test.go b/backend/playlist/emit_test.go index 1cf6399..8f133b3 100644 --- a/backend/playlist/emit_test.go +++ b/backend/playlist/emit_test.go @@ -42,35 +42,17 @@ func setupRecordedService( func seedPlaylistTracks(t *testing.T, db *database.DB, count int) []string { t.Helper() - _, err := db.ExecContext( - "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - paths := make([]string, count) for i := range count { - id := i + 1 - paths[i] = fmt.Sprintf("/test/pl-track%d.mp3", id) + paths[i] = fmt.Sprintf("/test/pl-track%d.mp3", i+1) - if _, err := db.ExecContext( - "INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) "+ - "VALUES (?, ?, 1)", - id, fmt.Sprintf("Track %d", id), - ); err != nil { - t.Fatalf("insert recording %d: %v", id, err) - } - - if _, err := db.ExecContext( - "INSERT OR IGNORE INTO audio_files (id, file_path, "+ - "length_milliseconds, file_type_id, recording_id) "+ - "VALUES (?, ?, 180000, 0, ?)", - id, paths[i], id, - ); err != nil { - t.Fatalf("insert audio_file %d: %v", id, err) - } + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: paths[i], + Title: fmt.Sprintf("Track %d", i+1), + Artist: "Test Artist", + LengthMs: 180000, + }) } return paths diff --git a/backend/playlist/match.go b/backend/playlist/match.go index e6f61f4..51d8332 100644 --- a/backend/playlist/match.go +++ b/backend/playlist/match.go @@ -109,12 +109,16 @@ func scoreCandidate( ) dirScore := scorePathDirs(pp, candidatePath) - // If duration is unknown, redistribute its weight to - // filename. + // If duration is unknown, redistribute its weight to filename. + // Either side can be the one that does not know: an M3U8 written + // without EXTINF lines carries no duration, and neither does a + // library file whose length was never read. Scoring a candidate + // out of 0.9 for the *library's* gap made an otherwise exact + // filename match unable to reach the auto-match threshold. fnWeight := weightFilename durWeight := weightDuration - if pp.durationSec == 0 { + if pp.durationSec == 0 || candidateDurationMs == 0 { fnWeight += durWeight durWeight = 0 } diff --git a/backend/playlist/match_test.go b/backend/playlist/match_test.go index 2471019..42ef520 100644 --- a/backend/playlist/match_test.go +++ b/backend/playlist/match_test.go @@ -409,3 +409,122 @@ func stringSliceEqual(a, b []string) bool { return true } + +// TestScoreCandidateUnknownCandidateDuration pins which side may be the +// one that does not know a duration. An M3U8 without EXTINF lines was +// already handled; a *library* file whose length was never read was not, +// so an otherwise exact match was scored out of 0.9 and could not reach +// the auto-match threshold. +func TestScoreCandidateUnknownCandidateDuration(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/old/Music/Artist/01 - Song.mp3", + "Artist - Song", + 240, + ) + + score := scoreCandidate( + pp, + "/new/Music/Artist/01 - Song.mp3", + "Song", + "Artist", + 0, // the library never read this file's length + ) + + if score < autoMatchMinimum { + t.Errorf( + "score = %f, want >= %f for an exact match with no "+ + "candidate duration", + score, autoMatchMinimum, + ) + } +} + +// TestAssignBestFirst covers the rule that one library file cannot +// resolve two phantom tracks, and which phantom gets it when both want +// the same one. +func TestAssignBestFirst(t *testing.T) { + t.Parallel() + + offers := []phantomOffer{ + // The playlist's first phantom wants this file, but only + // just — and the third one is a better answer for it. + { + phantomPath: "/gone/a.mp3", + candidate: CandidateTrack{ + FilePath: "/lib/shared.mp3", Score: 0.86, + }, + }, + { + phantomPath: "/gone/b.mp3", + candidate: CandidateTrack{ + FilePath: "/lib/b.mp3", Score: 0.90, + }, + }, + { + phantomPath: "/gone/c.mp3", + candidate: CandidateTrack{ + FilePath: "/lib/shared.mp3", Score: 0.98, + }, + }, + } + + matches := assignBestFirst(offers) + + if len(matches) != 2 { + t.Fatalf("matches = %d, want 2", len(matches)) + } + + got := make(map[string]string, len(matches)) + for _, m := range matches { + got[m.PhantomPath] = m.Candidate.FilePath + } + + if got["/gone/c.mp3"] != "/lib/shared.mp3" { + t.Errorf( + "the shared file went to %v, want /gone/c.mp3 to have it", + got, + ) + } + + if _, claimed := got["/gone/a.mp3"]; claimed { + t.Error("/gone/a.mp3 took a file a better match had claimed") + } + + if got["/gone/b.mp3"] != "/lib/b.mp3" { + t.Errorf("/gone/b.mp3 matched %q, want /lib/b.mp3", got["/gone/b.mp3"]) + } +} + +// TestAssignBestFirstKeepsOnePerPhantom: a phantom with several +// confident candidates takes its best one and no more. +func TestAssignBestFirstKeepsOnePerPhantom(t *testing.T) { + t.Parallel() + + matches := assignBestFirst([]phantomOffer{ + { + phantomPath: "/gone/a.mp3", + candidate: CandidateTrack{ + FilePath: "/lib/one.mp3", Score: 0.90, + }, + }, + { + phantomPath: "/gone/a.mp3", + candidate: CandidateTrack{ + FilePath: "/lib/two.mp3", Score: 0.95, + }, + }, + }) + + if len(matches) != 1 { + t.Fatalf("matches = %d, want 1", len(matches)) + } + + if matches[0].Candidate.FilePath != "/lib/two.mp3" { + t.Errorf( + "matched %q, want the higher-scoring /lib/two.mp3", + matches[0].Candidate.FilePath, + ) + } +} diff --git a/backend/playlist/phantom_test.go b/backend/playlist/phantom_test.go new file mode 100644 index 0000000..40d2a6a --- /dev/null +++ b/backend/playlist/phantom_test.go @@ -0,0 +1,187 @@ +package playlist + +import ( + "path/filepath" + "testing" +) + +// setupPhantomPlaylist builds a playlist whose M3U8 holds two entries — +// one file the library has and one it does not — with the matching +// playlist_tracks rows: a linked row at position 0 and a phantom at +// position 1. It returns the playlist id, the phantom's absolute path +// and the library path the user would resolve it to. +func setupPhantomPlaylist( + t *testing.T, +) (svc *Service, playlistID int64, phantomAbs, targetAbs string) { + t.Helper() + + svc, db, _ := setupRecordedService(t) + paths := seedPlaylistTracks(t, db, 2) + + libDir := svc.libraryDir.(stubLibraryDir).dir + // seedPlaylistTracks writes absolute paths outside the library root, + // which is fine: an M3U8 entry may be absolute, and resolveM3UPath + // returns an absolute entry unchanged. + targetAbs = paths[1] + phantomAbs = filepath.Join(libDir, "gone", "missing.mp3") + + created, err := svc.CreatePlaylist("Imported") + if err != nil { + t.Fatalf("CreatePlaylist: %v", err) + } + + playlistID = created.ID + + dir, err := svc.playlistsDir() + if err != nil { + t.Fatalf("playlistsDir: %v", err) + } + + if err := writeM3U8(dir, playlistID, "Imported", []m3uEntry{ + {RelativePath: paths[0], DisplayTitle: "Track 1", DurationSec: 180}, + { + RelativePath: phantomAbs, + DisplayTitle: "Some Artist - Missing", + DurationSec: 200, + }, + }); err != nil { + t.Fatalf("writeM3U8: %v", err) + } + + if _, err := db.ExecContext( + `INSERT INTO playlist_tracks (playlist_id, audio_file_id, position) + VALUES (?, 1, 0)`, + playlistID, + ); err != nil { + t.Fatalf("insert linked track: %v", err) + } + + if _, err := db.ExecContext( + `INSERT INTO playlist_tracks + (playlist_id, position, phantom_title, phantom_file_path) + VALUES (?, 1, 'Some Artist - Missing', ?)`, + playlistID, phantomAbs, + ); err != nil { + t.Fatalf("insert phantom track: %v", err) + } + + return svc, playlistID, phantomAbs, targetAbs +} + +// countPlaylistRows reports how many playlist_tracks rows the playlist +// has, and how many of them are still phantoms. +func countPlaylistRows( + t *testing.T, svc *Service, playlistID int64, +) (total, phantoms int) { + t.Helper() + + rows, err := svc.db.QueryContext( + `SELECT COUNT(*), + COALESCE(SUM(audio_file_id IS NULL), 0) + FROM playlist_tracks WHERE playlist_id = ?`, + playlistID, + ) + if err != nil { + t.Fatalf("count playlist_tracks: %v", err) + } + + defer func() { _ = rows.Close() }() + + if !rows.Next() { + t.Fatal("count playlist_tracks: no row") + } + + if err := rows.Scan(&total, &phantoms); err != nil { + t.Fatalf("scan count: %v", err) + } + + return total, phantoms +} + +// TestResolvePhantomTracksFillsTheRowItAlreadyHas is the regression for +// a manually resolved phantom appearing twice: the resolution used to +// append a *new* playlist_tracks row and leave the phantom row behind, +// so the playlist held two rows for one M3U8 line — and the resolved +// one sat at the end of the playlist rather than where the track was. +func TestResolvePhantomTracksFillsTheRowItAlreadyHas(t *testing.T) { + t.Parallel() + + svc, playlistID, phantomAbs, targetAbs := setupPhantomPlaylist(t) + + if err := svc.ResolvePhantomTracks( + playlistID, map[string]string{phantomAbs: targetAbs}, + ); err != nil { + t.Fatalf("ResolvePhantomTracks: %v", err) + } + + total, phantoms := countPlaylistRows(t, svc, playlistID) + if total != 2 { + t.Errorf("playlist_tracks rows = %d, want 2", total) + } + + if phantoms != 0 { + t.Errorf("phantom rows left = %d, want 0", phantoms) + } + + // The track stays where it was in the playlist. + tracks, err := svc.GetPlaylistTracks(playlistID) + if err != nil { + t.Fatalf("GetPlaylistTracks: %v", err) + } + + if len(tracks) != 2 { + t.Fatalf("tracks = %d, want 2", len(tracks)) + } + + if tracks[1].FilePath != targetAbs { + t.Errorf( + "resolved track at position 1 = %q, want %q", + tracks[1].FilePath, targetAbs, + ) + } + + if tracks[1].Phantom { + t.Error("resolved track is still marked phantom") + } +} + +// TestResolvePhantomTracksRefusesTwoPhantomsForOneFile pins the rule +// FindPhantomMatches already applies on the auto-match path: one library +// file cannot stand in for two phantom tracks, or resolving adds it to +// the playlist twice. +func TestResolvePhantomTracksRefusesTwoPhantomsForOneFile(t *testing.T) { + t.Parallel() + + svc, playlistID, phantomAbs, targetAbs := setupPhantomPlaylist(t) + + secondPhantom := filepath.Join( + svc.libraryDir.(stubLibraryDir).dir, "gone", "missing-2.mp3", + ) + + if _, err := svc.db.ExecContext( + `INSERT INTO playlist_tracks + (playlist_id, position, phantom_title, phantom_file_path) + VALUES (?, 2, 'Some Artist - Missing 2', ?)`, + playlistID, secondPhantom, + ); err != nil { + t.Fatalf("insert second phantom: %v", err) + } + + if err := svc.ResolvePhantomTracks(playlistID, map[string]string{ + phantomAbs: targetAbs, + secondPhantom: targetAbs, + }); err != nil { + t.Fatalf("ResolvePhantomTracks: %v", err) + } + + total, phantoms := countPlaylistRows(t, svc, playlistID) + if total != 3 { + t.Errorf("playlist_tracks rows = %d, want 3", total) + } + + // One of the two phantoms is resolved; the other is left for the + // user to point somewhere else. + if phantoms != 1 { + t.Errorf("phantom rows left = %d, want 1", phantoms) + } +} diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 526381f..fef27a6 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -2,6 +2,7 @@ package playlist import ( + "cmp" "context" "database/sql" "errors" @@ -154,6 +155,8 @@ func NewService( // SetFavoritesConfig sets the provider used to read and write // the default-playlist configuration. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (s *Service) SetFavoritesConfig( provider FavoritesConfigProvider, ) { @@ -1845,6 +1848,142 @@ type phantomTrackRow struct { phantomFilePath string } +// phantomRowSet is a playlist's unresolved rows plus the ones a +// resolution pass has already consumed, so two matches cannot land on +// the same row. +type phantomRowSet struct { + rows []phantomTrackRow + taken map[int64]struct{} +} + +// loadPhantomRows reads the playlist's phantom rows — the tracks whose +// file was missing when the M3U8 was imported. +func (s *Service) loadPhantomRows(playlistID int64) *phantomRowSet { + set := &phantomRowSet{taken: map[int64]struct{}{}} + + // SAFETY: Hand-crafted SELECT for phantom tracks with position and + // phantom_file_path. Parameterized by playlist ID. + rows, err := s.db.QueryContext( + `SELECT id, position, COALESCE(phantom_file_path, '') + FROM playlist_tracks + WHERE playlist_id = ? AND audio_file_id IS NULL`, + playlistID, + ) + if err != nil { + s.logger.Warn( + "could not query phantom tracks", + "playlistId", playlistID, + "err", err, + ) + + return set + } + + defer func() { + if closeErr := rows.Close(); closeErr != nil { + s.logger.Warn( + "could not close phantom track rows", + "err", closeErr, + ) + } + }() + + for rows.Next() { + var pt phantomTrackRow + if err := rows.Scan( + &pt.id, &pt.position, &pt.phantomFilePath, + ); err != nil { + continue + } + + set.rows = append(set.rows, pt) + } + + return set +} + +// takePhantomRow finds the phantom row standing for phantomAbs and +// marks it consumed. It matches on phantom_file_path first and falls +// back to the row sitting at that entry's index in the M3U8, which is +// the same two-step resolvePlaylistPhantoms uses: rows imported before +// migration 7 carry no phantom_file_path at all. +func takePhantomRow( + set *phantomRowSet, + phantomAbs string, + entries []m3uEntry, + libraryRoots []string, +) (int64, bool) { + _, idx := findM3UEntry(entries, phantomAbs, libraryRoots) + + return takePhantomRowAt(set, phantomAbs, idx) +} + +// takePhantomRowAt is takePhantomRow for a caller that already knows +// the entry's index. A negative index means "no positional fallback": +// positions are non-negative, so it simply never matches. +func takePhantomRowAt( + set *phantomRowSet, phantomAbs string, index int, +) (int64, bool) { + for _, pt := range set.rows { + if _, done := set.taken[pt.id]; done { + continue + } + + if pt.phantomFilePath != "" && + pt.phantomFilePath == phantomAbs { + set.taken[pt.id] = struct{}{} + + return pt.id, true + } + } + + for _, pt := range set.rows { + if _, done := set.taken[pt.id]; done { + continue + } + + if pt.position == int64(index) { + set.taken[pt.id] = struct{}{} + + return pt.id, true + } + } + + return 0, false +} + +// fillPhantomRow points a phantom playlist_tracks row at a real audio +// file and drops the placeholder metadata it was displaying. The row +// keeps its position, which is why resolving a phantom does not move +// the track to the end of the playlist. +func (s *Service) fillPhantomRow( + playlistTrackID, audioFileID int64, +) error { + // SAFETY: Hand-crafted UPDATE to resolve a phantom playlist track. + // Sets audio_file_id and clears all phantom metadata columns. + // Parameterized by ID. + if _, err := s.db.ExecContext( + `UPDATE playlist_tracks SET + audio_file_id = ?, + phantom_title = NULL, + phantom_artist = NULL, + phantom_album = NULL, + phantom_duration_ms = NULL, + phantom_genre = NULL, + phantom_cover_art_path = NULL, + phantom_file_path = NULL + WHERE id = ?`, + audioFileID, playlistTrackID, + ); err != nil { + return fmt.Errorf( + "could not resolve phantom track %d: %w", + playlistTrackID, err, + ) + } + + return nil +} + // resolvePlaylistPhantoms resolves phantom tracks for a single // playlist by reading its M3U8 file and matching entries against // the audio_files table. Returns the number of resolved tracks. @@ -1873,52 +2012,12 @@ func (s *Service) resolvePlaylistPhantoms( } // Load phantom tracks for this playlist. - // SAFETY: Hand-crafted SELECT for phantom tracks with - // position and phantom_file_path. No user input. - ptRows, err := s.db.QueryContext( - `SELECT id, position, COALESCE(phantom_file_path, '') - FROM playlist_tracks - WHERE playlist_id = ? AND audio_file_id IS NULL`, - playlistID, - ) - if err != nil { - s.logger.Warn( - "could not query phantom tracks", - "playlistId", playlistID, - "err", err, - ) + phantoms := s.loadPhantomRows(playlistID) + if len(phantoms.rows) == 0 { return 0 } - var phantoms []phantomTrackRow - - for ptRows.Next() { - var pt phantomTrackRow - if err := ptRows.Scan( - &pt.id, &pt.position, &pt.phantomFilePath, - ); err != nil { - continue - } - - phantoms = append(phantoms, pt) - } - - if err := ptRows.Close(); err != nil { - s.logger.Warn( - "could not close phantom track rows", - "err", err, - ) - } - - if len(phantoms) == 0 { - return 0 - } - - // Build a set of already-resolved phantom IDs to avoid - // double-matching. - resolvedIDs := make(map[int64]struct{}) - var resolved int // For each M3U8 entry, resolve its path and try to match @@ -1933,63 +2032,17 @@ func (s *Service) resolvePlaylistPhantoms( continue } - // Find the phantom that corresponds to this entry. - // Priority 1: match by phantom_file_path (exact). - // Priority 2: match by position (M3U8 index). - matchIdx := -1 - - for j, pt := range phantoms { - if _, done := resolvedIDs[pt.id]; done { - continue - } - - if pt.phantomFilePath != "" && - pt.phantomFilePath == absPath { - matchIdx = j - - break - } - } - - if matchIdx == -1 { - for j, pt := range phantoms { - if _, done := resolvedIDs[pt.id]; done { - continue - } - - if pt.position == int64(i) { - matchIdx = j - - break - } - } - } - - if matchIdx == -1 { + // Find the phantom that corresponds to this entry: + // phantom_file_path first, then this entry's index. + ptID, found := takePhantomRowAt(phantoms, absPath, i) + if !found { continue } - pt := phantoms[matchIdx] - - // SAFETY: Hand-crafted UPDATE to resolve a phantom - // playlist track. Sets audio_file_id and clears all - // phantom metadata columns. Parameterized by ID. - if _, err := s.db.ExecContext( - `UPDATE playlist_tracks SET - audio_file_id = ?, - phantom_title = NULL, - phantom_artist = NULL, - phantom_album = NULL, - phantom_duration_ms = NULL, - phantom_genre = NULL, - phantom_cover_art_path = NULL, - phantom_file_path = NULL - WHERE id = ?`, - audioFileID, pt.id, - ); err != nil { + if err := s.fillPhantomRow(ptID, audioFileID); err != nil { s.logger.Warn( "could not resolve phantom track", - "playlistTrackId", pt.id, + "playlistTrackId", ptID, "audioFileId", audioFileID, "err", err, ) @@ -1997,7 +2050,6 @@ func (s *Service) resolvePlaylistPhantoms( continue } - resolvedIDs[pt.id] = struct{}{} resolved++ } @@ -2053,52 +2105,110 @@ func (s *Service) FindPhantomMatches( entryByPath[absPath] = e } - // Track which candidates have been claimed by auto-match - // so we don't assign the same candidate to two phantoms. - claimed := make(map[string]struct{}) - - var result PhantomSearchResult + // Every pairing confident enough to apply without asking. + var offers []phantomOffer for _, phantomPath := range phantomPaths { entry := entryByPath[phantomPath] - candidates := s.searchCandidates( - phantomPath, entry, - ) - - matched := false - - for _, c := range candidates { - if _, taken := claimed[c.FilePath]; taken { - continue - } - - if c.Score >= autoMatchMinimum { - result.AutoMatched = append( - result.AutoMatched, - PhantomMatch{ - PhantomPath: phantomPath, - PhantomTitle: entry.DisplayTitle, - Candidate: c, - }, - ) - - claimed[c.FilePath] = struct{}{} - matched = true + for _, c := range s.searchCandidates(phantomPath, entry) { + if c.Score < autoMatchMinimum { + // searchCandidates sorts by score, so nothing + // below this one qualifies either. break } - } - if !matched { - result.Unmatched = append( - result.Unmatched, phantomPath, - ) + offers = append(offers, phantomOffer{ + phantomPath: phantomPath, + phantomTitle: entry.DisplayTitle, + candidate: c, + }) } } + var result PhantomSearchResult + + result.AutoMatched = assignBestFirst(offers) + + matched := make(map[string]struct{}, len(result.AutoMatched)) + for _, m := range result.AutoMatched { + matched[m.PhantomPath] = struct{}{} + } + + // Reported in the order the playlist has them, not the order they + // were matched in. + for _, phantomPath := range phantomPaths { + if _, done := matched[phantomPath]; done { + continue + } + + result.Unmatched = append( + result.Unmatched, phantomPath, + ) + } + return result, nil } +// phantomOffer is one candidate a phantom track could be resolved to, +// carrying enough to become a PhantomMatch. +type phantomOffer struct { + phantomPath string + phantomTitle string + candidate CandidateTrack +} + +// assignBestFirst pairs phantoms with candidates highest score first, +// at most one candidate per phantom and one phantom per candidate. +// +// A candidate can only stand in for one phantom — two would put that +// file in the playlist twice. Taking the offers in *phantom* order, +// which is what this replaced, let an early phantom claim a candidate +// that was a better answer for a later one, so which of two similar +// tracks got the good match depended on the order they happen to sit in +// the playlist. +func assignBestFirst(offers []phantomOffer) []PhantomMatch { + if len(offers) == 0 { + return nil + } + + ordered := make([]phantomOffer, len(offers)) + copy(ordered, offers) + + slices.SortStableFunc( + ordered, + func(a, b phantomOffer) int { + return cmp.Compare(b.candidate.Score, a.candidate.Score) + }, + ) + + var matches []PhantomMatch + + claimedCandidates := make(map[string]struct{}, len(ordered)) + matchedPhantoms := make(map[string]struct{}, len(ordered)) + + for _, o := range ordered { + if _, taken := claimedCandidates[o.candidate.FilePath]; taken { + continue + } + + if _, done := matchedPhantoms[o.phantomPath]; done { + continue + } + + matches = append(matches, PhantomMatch{ + PhantomPath: o.phantomPath, + PhantomTitle: o.phantomTitle, + Candidate: o.candidate, + }) + + claimedCandidates[o.candidate.FilePath] = struct{}{} + matchedPhantoms[o.phantomPath] = struct{}{} + } + + return matches +} + // GetPhantomCandidates returns scored candidate matches for a // single phantom track. func (s *Service) GetPhantomCandidates( @@ -2219,14 +2329,38 @@ func (s *Service) ResolvePhantomTracks( ) } + // The phantom rows this is about to fill in, so a resolution + // updates the row the user pointed at rather than appending a + // second one beside it. + phantoms := s.loadPhantomRows(playlistID) + // Build M3U path replacements and insert DB rows. pathReplacements := make( map[string]string, len(matches), ) - var resolved int + // Two phantoms resolving to one file would put that file in the + // playlist twice, which is what FindPhantomMatches' claimed set + // already refuses to do on the auto-match path. + claimed := make(map[string]struct{}, len(matches)) + + var ( + resolved int + appended int + ) for phantomAbs, resolvedAbs := range matches { + if _, taken := claimed[resolvedAbs]; taken { + s.logger.Warn( + "Two phantom tracks resolved to the same file", + "playlistId", playlistID, + "phantomPath", phantomAbs, + "resolvedPath", resolvedAbs, + ) + + continue + } + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( s.db.Ctx, resolvedAbs, ) @@ -2241,29 +2375,54 @@ func (s *Service) ResolvePhantomTracks( continue } - _, addErr := s.db.Queries.AddPlaylistTrack( - s.db.Ctx, - sqlcgen.AddPlaylistTrackParams{ - PlaylistID: playlistID, - AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true}, - Position: nextPos + int64(resolved), - }, + ptID, found := takePhantomRow( + phantoms, phantomAbs, parsed.Entries, libraryRoots, ) - if addErr != nil { - s.logger.Warn( - "Could not add resolved track", - "playlistId", playlistID, - "path", resolvedAbs, - "err", addErr, - ) - continue + switch { + case found: + if updateErr := s.fillPhantomRow( + ptID, audioFile.ID, + ); updateErr != nil { + s.logger.Warn( + "Could not resolve phantom row", + "playlistId", playlistID, + "playlistTrackId", ptID, + "path", resolvedAbs, + "err", updateErr, + ) + + continue + } + default: + // No phantom row for this path — the M3U8 and the + // database disagree, so fall back to appending. + if _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlistID, + AudioFileID: sql.NullInt64{Int64: audioFile.ID, Valid: true}, + Position: nextPos + int64(appended), + }, + ); addErr != nil { + s.logger.Warn( + "Could not add resolved track", + "playlistId", playlistID, + "path", resolvedAbs, + "err", addErr, + ) + + continue + } + + appended++ } newRel := toRelativePathMultiRoot( resolvedAbs, libraryRoots, ) pathReplacements[phantomAbs] = newRel + claimed[resolvedAbs] = struct{}{} resolved++ } @@ -2390,11 +2549,11 @@ func (s *Service) searchCandidates( var combined []database.SearchRow // 1. Exact basename match via indexed column. - bnRows, err := s.db.Queries.SearchAudioFilesByBasename( + bnRows, err := s.db.Queries.SearchTracksByBasename( s.db.Ctx, - sqlcgen.SearchAudioFilesByBasenameParams{ + sqlcgen.SearchTracksByBasenameParams{ Basename: basename, - Limit: int64(maxCandidates), + Lim: int64(maxCandidates), }, ) if err != nil { @@ -2416,7 +2575,7 @@ func (s *Service) searchCandidates( FilePath: r.FilePath, LengthMilliseconds: r.LengthMilliseconds, Title: r.Title, - Artist: r.Artist, + Artist: r.ArtistName, Album: r.Album, }) } diff --git a/backend/playlist/smart_test.go b/backend/playlist/smart_test.go index 1ac4de2..8e23a9e 100644 --- a/backend/playlist/smart_test.go +++ b/backend/playlist/smart_test.go @@ -51,116 +51,16 @@ func seedSmartTestTracks(t *testing.T, db *database.DB) { }, } - // Build unique sets for artist_credit and release_groups. - artistMap := map[string]int64{} - albumMap := map[string]int64{} - - var artistID, albumID int64 - for _, tr := range tracks { - if _, ok := artistMap[tr.artist]; !ok { - artistID++ - artistMap[tr.artist] = artistID - } - - if _, ok := albumMap[tr.album]; !ok { - albumID++ - albumMap[tr.album] = albumID - } - } - - // Insert artist_credit rows. - for text, id := range artistMap { - _, err := db.ExecContext( - "INSERT INTO artist_credit (id, text) VALUES (?, ?)", - id, text, - ) - if err != nil { - t.Fatalf("insert artist_credit %q: %v", text, err) - } - } - - // Insert release_groups. - for name, id := range albumMap { - _, err := db.ExecContext( - "INSERT INTO release_groups (id, name) VALUES (?, ?)", - id, name, - ) - if err != nil { - t.Fatalf("insert release_group %q: %v", name, err) - } - } - - // Insert genres. - genreMap := map[string]int64{} - - var genreID int64 - - for _, tr := range tracks { - 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) - } - } - } - - // Insert tracks with full FK chain. - 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, year) "+ - "VALUES (?, ?, ?, ?)", - tr.id, tr.title, acID, tr.year, - ) - if err != nil { - t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err) - } - - // Insert audio_file. - _, 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 (?, ?, ?, 0, ?, 44100, 16, 2, 320000, 5000000)", - tr.id, tr.filePath, tr.lenMs, tr.id, - ) - 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) VALUES (?, ?)", - rgID, tr.id, - ) - if err != nil { - t.Fatalf("insert release_group_recordings %d→%d: %v", - rgID, tr.id, err) - } - - // Insert recording_genres link. - gID := genreMap[tr.genre] - - _, err = db.ExecContext( - "INSERT INTO recording_genres "+ - "(recording_id, genre_id) VALUES (?, ?)", - tr.id, gID, - ) - if err != nil { - t.Fatalf("insert recording_genres %d→%d: %v", - tr.id, gID, err) - } + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: tr.filePath, + Title: tr.title, + Artist: tr.artist, + Album: tr.album, + Genres: []string{tr.genre}, + Year: tr.year, + LengthMs: tr.lenMs, + }) } } @@ -467,27 +367,18 @@ func TestSmartPlaylistEvaluateNonSmartPlaylist(t *testing.T) { svc := newTestService(t, db) // Create a regular playlist via direct SQL. - // SAFETY: Test-only insert for regular playlist. - rows, err := db.QueryContext( - `INSERT INTO playlists (name) VALUES (?) - RETURNING id`, + // An INSERT ... RETURNING is a write, so it needs the writer: + // QueryContext routes to the query-only read pool. + var regularID int64 + if err := db.QueryRowWriter( + `INSERT INTO playlists (name) VALUES (?) RETURNING id`, "Regular PL", - ) - if err != nil { + ).Scan(®ularID); err != nil { t.Fatalf("insert regular playlist: %v", err) } - var regularID int64 - if rows.Next() { - if err := rows.Scan(®ularID); err != nil { - t.Fatalf("scan regular playlist id: %v", err) - } - } - - _ = rows.Close() - // Evaluate should fail — not a smart playlist. - _, err = svc.EvaluateSmartPlaylist(regularID) + _, err := svc.EvaluateSmartPlaylist(regularID) if err == nil { t.Fatal("expected error evaluating non-smart playlist, got nil") } @@ -512,25 +403,16 @@ func TestSmartPlaylistUpdateNonSmartPlaylist(t *testing.T) { db := database.NewTestDB(t) svc := newTestService(t, db) - // Create a regular playlist. - rows, err := db.QueryContext( - `INSERT INTO playlists (name) VALUES (?) - RETURNING id`, + // Create a regular playlist. An INSERT ... RETURNING is a write, + // so it needs the writer: QueryContext routes to the read pool. + var regularID int64 + if err := db.QueryRowWriter( + `INSERT INTO playlists (name) VALUES (?) RETURNING id`, "Regular PL", - ) - if err != nil { + ).Scan(®ularID); err != nil { t.Fatalf("insert regular playlist: %v", err) } - var regularID int64 - if rows.Next() { - if err := rows.Scan(®ularID); err != nil { - t.Fatalf("scan regular playlist id: %v", err) - } - } - - _ = rows.Close() - rulesJSON := makeRulesJSON(t, smartplaylist.RuleSet{ Rules: []smartplaylist.Rule{ {Field: "title", Operator: "contains", Value: "test"}, @@ -538,7 +420,7 @@ func TestSmartPlaylistUpdateNonSmartPlaylist(t *testing.T) { }) // Update should fail — not a smart playlist. - err = svc.UpdateSmartPlaylistRules(regularID, rulesJSON) + err := svc.UpdateSmartPlaylistRules(regularID, rulesJSON) if err == nil { t.Fatal("expected error updating non-smart playlist, got nil") } @@ -765,27 +647,18 @@ func TestSmartPlaylistGetRulesRegularPlaylist(t *testing.T) { svc := newTestService(t, db) // Create a regular playlist via direct SQL. - // SAFETY: Test-only insert for regular playlist. - rows, err := db.QueryContext( - `INSERT INTO playlists (name) VALUES (?) - RETURNING id`, + // An INSERT ... RETURNING is a write, so it needs the writer: + // QueryContext routes to the query-only read pool. + var regularID int64 + if err := db.QueryRowWriter( + `INSERT INTO playlists (name) VALUES (?) RETURNING id`, "Regular PL For GetRules", - ) - if err != nil { + ).Scan(®ularID); err != nil { t.Fatalf("insert regular playlist: %v", err) } - var regularID int64 - if rows.Next() { - if err := rows.Scan(®ularID); err != nil { - t.Fatalf("scan regular playlist id: %v", err) - } - } - - _ = rows.Close() - // GetSmartPlaylistRules should fail — not a smart playlist. - _, err = svc.GetSmartPlaylistRules(regularID) + _, err := svc.GetSmartPlaylistRules(regularID) if err == nil { t.Fatal( "expected error for regular playlist, got nil", diff --git a/backend/queue/queue.go b/backend/queue/queue.go index 29af07d..00ab9c2 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -217,6 +217,8 @@ func (q *Queue) ServiceStartup( } // SetPlayer provides the queue with a reference to the player for auto-advance. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (q *Queue) SetPlayer(player TrackLoader) { q.player = player } @@ -224,6 +226,8 @@ func (q *Queue) SetPlayer(player TrackLoader) { // SetFallbackSource provides the queue with what to auto-play, if // anything, once it runs out. A nil source (the default) leaves // today's behavior: the queue just goes idle. +// +//wails:ignore // internal wiring, not part of the app's IPC surface. func (q *Queue) SetFallbackSource(fs FallbackSource) { q.fallbackSource = fs } diff --git a/backend/queue/queue_test.go b/backend/queue/queue_test.go index e959fc4..11c89bb 100644 --- a/backend/queue/queue_test.go +++ b/backend/queue/queue_test.go @@ -44,39 +44,22 @@ func setupTestQueue(t *testing.T) (*Queue, *database.DB) { func seedAudioFiles(t *testing.T, db *database.DB, count int) []string { t.Helper() - // Shared artist credit. - _, err := db.ExecContext( - "INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')", - ) - if err != nil { - t.Fatalf("insert artist_credit: %v", err) - } - paths := make([]string, count) for i := range count { - recID := i + 1 - afID := i + 1 - fp := fmt.Sprintf("/test/track%d.mp3", i+1) - paths[i] = fp + paths[i] = fmt.Sprintf("/test/track%d.mp3", i+1) - _, err := db.ExecContext( - "INSERT OR IGNORE INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)", - recID, fmt.Sprintf("Track %d", i+1), - ) - if err != nil { - t.Fatalf("insert recording %d: %v", recID, err) + // Some tests seed overlapping ranges to build a fallback set. + if _, err := db.Queries.GetAudioFileByPath(db.Ctx, paths[i]); err == nil { + continue } - _, err = db.ExecContext( - "INSERT OR IGNORE INTO audio_files (id, file_path, "+ - "length_milliseconds, file_type_id, recording_id) "+ - "VALUES (?, ?, 180000, 0, ?)", - afID, fp, recID, - ) - if err != nil { - t.Fatalf("insert audio_file %d: %v", afID, err) - } + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: paths[i], + Title: fmt.Sprintf("Track %d", i+1), + Artist: "Test Artist", + LengthMs: 180000, + }) } return paths diff --git a/backend/smartplaylist/smartplaylist.go b/backend/smartplaylist/smartplaylist.go index d1044a4..f3030d5 100644 --- a/backend/smartplaylist/smartplaylist.go +++ b/backend/smartplaylist/smartplaylist.go @@ -203,18 +203,17 @@ func validateOperator(op string, isNumeric bool) error { } // buildGenreCondition generates a subquery condition against -// recording_genres JOIN genres for every supported text operator. -// The outer query is expected to expose the `recording_id` column of -// the audio file (aliased through the smart playlist query), which is -// compared against recording_genres.recording_id. +// file_genres JOIN genres for every supported text operator. The outer +// query exposes the audio file's `id`, which is what file_genres is +// keyed by. func buildGenreCondition(rule Rule) (string, []any, error) { - inHead := `af.recording_id IN ( - SELECT rg_sub.recording_id FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id + inHead := `af.id IN ( + SELECT fg.audio_file_id FROM file_genres fg + JOIN genres g ON fg.genre_id = g.id WHERE ` - notInHead := `af.recording_id NOT IN ( - SELECT rg_sub.recording_id FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id + notInHead := `af.id NOT IN ( + SELECT fg.audio_file_id FROM file_genres fg + JOIN genres g ON fg.genre_id = g.id WHERE ` switch rule.Operator { @@ -538,7 +537,7 @@ func parseBetweenValue( // so the runtime cost is equivalent to querying the underlying tables // directly. const leanTrackQuery = `SELECT - af.recording_id, + af.id, af.file_path, af.length_milliseconds, af.title, @@ -559,26 +558,21 @@ const leanTrackQuery = `SELECT FROM ( SELECT af.id, - af.recording_id, af.file_path, af.length_milliseconds, - COALESCE(r.name, '') AS title, - COALESCE(ac.text, '') AS artist_name, - r.track_number, - r.disc_number, - COALESCE(rg.name, '') AS album, + af.title, + af.artist_credit AS artist_name, + af.track_number, + af.disc_number, + COALESCE(al.name, '') AS album, -- Two year fields, matching the canonical track_metadata view: - -- year — the album's original (first-release) year, + -- year - the album's original (first-release) year, -- the default users filter on. A 1977 album owned -- as a 2010s reissue still filters as 1977. - -- release_year — the year of the specific release in the library - -- (the file/release-group tag), e.g. 2013 for that - -- reissue. - -- Both fall back through rg.year → r.year so a track without full - -- MusicBrainz data still gets a sensible year. - COALESCE(rg.original_year, rg.year, r.year, 0) AS year, - COALESCE(rg.year, r.year, 0) AS release_year, - COALESCE(r.composer, '') AS composer, + -- release_year - the year of the specific copy in the library. + COALESCE(al.original_year, al.year, af.year, 0) AS year, + COALESCE(al.year, af.year, 0) AS release_year, + af.composer, COALESCE(ft.extension, '') AS file_type, af.sample_rate, af.bit_depth, @@ -589,16 +583,8 @@ FROM ( af.play_count, af.last_played 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 file_types ft ON af.file_type_id = ft.id + LEFT JOIN albums al ON al.id = af.album_id + LEFT JOIN file_types ft ON ft.id = af.file_type_id ) af` // Evaluate runs the rule set against the library and returns matching @@ -679,7 +665,7 @@ func Evaluate( ) } - tracks, recordingIDs, err := scanTracks(rows) + tracks, fileIDs, err := scanTracks(rows) _ = rows.Close() @@ -689,17 +675,17 @@ func Evaluate( mainDuration := time.Since(mainStart) - // Batch-load genres for every matched recording_id in one query + // Batch-load genres for every matched file id in one query // instead of the per-row correlated subquery the view used. genreStart := time.Now() - genresByRecording, err := fetchGenres(db, recordingIDs) + genresByFile, err := fetchGenres(db, fileIDs) if err != nil { return nil, err } - for i, rid := range recordingIDs { - if g, ok := genresByRecording[rid]; ok { + for i, rid := range fileIDs { + if g, ok := genresByFile[rid]; ok { tracks[i].Genre = splitGenres(g) } } @@ -712,13 +698,13 @@ func Evaluate( // and cover-art join over the whole library before WHERE/LIMIT. artStart := time.Now() - artworkByRecording, err := fetchArtwork(db, recordingIDs) + artworkByFile, err := fetchArtwork(db, fileIDs) if err != nil { return nil, err } - for i, rid := range recordingIDs { - art, ok := artworkByRecording[rid] + for i, rid := range fileIDs { + art, ok := artworkByFile[rid] if !ok { continue } @@ -771,16 +757,16 @@ func Evaluate( // scanTracks reads all rows from a lean-query result into parallel // slices: the Track values (minus genres, which are attached later) -// and the recording_id for each, used for the batched genre fetch. +// and the audio file id for each, used for the batched genre fetch. func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { var ( - tracks []library.Track - recordingIDs []int64 + tracks []library.Track + fileIDs []int64 ) for rows.Next() { var ( - recordingID sql.NullInt64 + fileID sql.NullInt64 filePath string lengthMs int64 title string @@ -801,7 +787,7 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { ) if err := rows.Scan( - &recordingID, &filePath, &lengthMs, &title, &artistName, + &fileID, &filePath, &lengthMs, &title, &artistName, &trackNumber, &discNumber, &album, &year, &composer, &fileType, &sampleRate, &bitDepth, &channels, @@ -834,7 +820,7 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { } tracks = append(tracks, track) - recordingIDs = append(recordingIDs, recordingID.Int64) + fileIDs = append(fileIDs, fileID.Int64) } if err := rows.Err(); err != nil { @@ -843,12 +829,12 @@ func scanTracks(rows *sql.Rows) ([]library.Track, []int64, error) { ) } - return tracks, recordingIDs, nil + return tracks, fileIDs, nil } // fetchGenres batch-loads the GROUP_CONCAT-joined genre string for -// every recording_id in ids using a single IN-list query. Returns a -// map from recording_id to the concatenated genre string. +// every file id in ids using a single IN-list query. Returns a +// map from file id to the concatenated genre string. func fetchGenres( db *database.DB, ids []int64, ) (map[int64]string, error) { @@ -888,13 +874,13 @@ func fetchGenres( // SAFETY: placeholders are static "?" tokens; every value is // parameterized. - query := `SELECT rg_sub.recording_id, + query := `SELECT fg.audio_file_id, GROUP_CONCAT(g.name, '` + genreDelimiter + `') - FROM recording_genres rg_sub - JOIN genres g ON rg_sub.genre_id = g.id - WHERE rg_sub.recording_id IN (` + + FROM file_genres fg + JOIN genres g ON fg.genre_id = g.id + WHERE fg.audio_file_id IN (` + strings.Join(placeholders, ", ") + `) - GROUP BY rg_sub.recording_id` + GROUP BY fg.audio_file_id` rows, err := db.QueryContext(query, args...) if err != nil { @@ -933,7 +919,7 @@ func fetchGenres( // trackArtwork holds the presentation-only cover-art path and // MusicBrainz identifiers attached to a matched track after the main -// filter query, keyed by recording_id. +// filter query, keyed by audio file id. type trackArtwork struct { coverArtPath string artistMBID string @@ -942,7 +928,7 @@ type trackArtwork struct { } // fetchArtwork batch-loads cover-art paths and MusicBrainz IDs for the -// given recording_ids in a single IN-list query. These fields drive +// given file ids in a single IN-list query. These fields drive // track-row styling only, so scoping them to the matched result set // keeps the cost proportional to results rather than library size. func fetchArtwork( @@ -982,37 +968,22 @@ func fetchArtwork( inList := strings.Join(placeholders, ", ") - // A recording's artist credit can name several artists; the old - // correlated subquery picked one via LIMIT 1. GROUP BY r.id with - // MIN() reproduces a single stable value without multiplying rows. // SAFETY: placeholders are static "?" tokens; every value is - // parameterized. The IN list is bound twice (subquery + outer). - query := `SELECT r.id, - COALESCE(MIN(ca.file_path), '') AS cover_art_path, - COALESCE(MIN(a.mbid), '') AS artist_mbid, - COALESCE(MIN(rg.mbid), '') AS release_group_mbid, - COALESCE(r.mbid, '') AS recording_mbid - FROM recordings r - 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 - WHERE recording_id IN (` + inList + `) - 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 r.id IN (` + inList + `) - GROUP BY r.id` + // parameterized. + query := `SELECT af.id, + COALESCE(ca.file_path, '') AS cover_art_path, + COALESCE(ar.mbid, '') AS artist_mbid, + COALESCE(al.mbid, '') AS release_group_mbid, + COALESCE(af.recording_mbid, '') AS recording_mbid + FROM audio_files af + LEFT JOIN artists ar ON ar.id = af.artist_id + LEFT JOIN albums al ON al.id = af.album_id + LEFT JOIN cover_art ca ON ca.id = al.cover_art_id + WHERE af.id IN (` + inList + `)` - args := make([]any, 0, len(unique)*2) - for range 2 { - for _, id := range unique { - args = append(args, id) - } + args := make([]any, 0, len(unique)) + for _, id := range unique { + args = append(args, id) } rows, err := db.QueryContext(query, args...) diff --git a/backend/smartplaylist/smartplaylist_test.go b/backend/smartplaylist/smartplaylist_test.go index 6b550ca..5c2efa0 100644 --- a/backend/smartplaylist/smartplaylist_test.go +++ b/backend/smartplaylist/smartplaylist_test.go @@ -127,126 +127,36 @@ func seedSmartPlaylistData(t *testing.T, db *database.DB) { }, } - // Build unique sets for artist_credit and release_groups. - artistMap := map[string]int64{} - albumMap := map[string]int64{} - - var artistID, albumID int64 - for _, tr := range tracks { - if _, ok := artistMap[tr.artist]; !ok { - artistID++ - artistMap[tr.artist] = artistID + var trackNum, discNum int64 + if tr.trackNum != nil { + trackNum = *tr.trackNum } - 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. - genreMap := map[string]int64{} - - var genreID int64 - - for _, tr := range tracks { - for _, g := range tr.genres { - if _, ok := genreMap[g]; !ok { - genreID++ - genreMap[g] = genreID - - _, err := db.ExecContext( - "INSERT INTO genres (id, name) VALUES (?, ?)", - genreID, g, - ) - if err != nil { - t.Fatalf("insert genre %q: %v", g, err) - } - } - } - } - - // Insert tracks with full FK chain. - 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, composer) "+ - "VALUES (?, ?, ?, ?, ?, ?, ?)", - tr.id, tr.title, acID, tr.trackNum, tr.discNum, - tr.year, tr.composer, - ) - if err != nil { - t.Fatalf("insert recording %d %q: %v", - tr.id, tr.title, err) + if tr.discNum != nil { + discNum = *tr.discNum } - // 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) - } + id := database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: tr.filePath, + Title: tr.title, + Artist: tr.artist, + Album: tr.album, + Genres: tr.genres, + TrackNumber: trackNum, + DiscNumber: discNum, + Year: tr.year, + LengthMs: tr.lenMs, + }) - // 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 recording_genres links (supports multi-genre). - for _, g := range tr.genres { - gID := genreMap[g] - - _, err = db.ExecContext( - "INSERT INTO recording_genres "+ - "(recording_id, genre_id) VALUES (?, ?)", - tr.id, gID, - ) - if err != nil { - t.Fatalf( - "insert recording_genres %d→%d: %v", - tr.id, gID, err, - ) - } + 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) } } } @@ -537,8 +447,8 @@ func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) { ) } - if !strings.Contains(clause, "recording_genres") { - t.Errorf("genre 'is' should reference recording_genres: %q", + if !strings.Contains(clause, "file_genres") { + t.Errorf("genre 'is' should reference file_genres: %q", clause) } @@ -565,9 +475,9 @@ func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) { t.Errorf("genre 'is_not' should use NOT IN: %q", clause) } - if !strings.Contains(clause, "recording_genres") { + if !strings.Contains(clause, "file_genres") { t.Errorf( - "genre 'is_not' should reference recording_genres: %q", + "genre 'is_not' should reference file_genres: %q", clause, ) } @@ -590,9 +500,9 @@ func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if !strings.Contains(clause, "recording_genres") { + if !strings.Contains(clause, "file_genres") { t.Errorf( - "genre 'is_any_of' should reference recording_genres: %q", + "genre 'is_any_of' should reference file_genres: %q", clause, ) } @@ -620,11 +530,11 @@ func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) { } // Since the smart playlist query no longer projects a concatenated - // genre column, "contains" filters genres via recording_genres + // genre column, "contains" filters genres via file_genres // with g.name LIKE applied to individual genre rows. - if !strings.Contains(clause, "recording_genres") { + if !strings.Contains(clause, "file_genres") { t.Errorf( - "genre 'contains' should use recording_genres subquery: %q", + "genre 'contains' should use file_genres subquery: %q", clause, ) } @@ -677,16 +587,16 @@ func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) { } // Genre text ops combine via AND across subqueries against - // recording_genres; the exact SQL shape is asserted elsewhere. + // file_genres; the exact SQL shape is asserted elsewhere. if !strings.Contains(clause, " AND ") { t.Errorf("clause should combine rules with AND: %q", clause) } - if !strings.Contains(clause, "af.recording_id IN") { + if !strings.Contains(clause, "af.id IN") { t.Errorf("clause should include positive IN subquery: %q", clause) } - if !strings.Contains(clause, "af.recording_id NOT IN") { + if !strings.Contains(clause, "af.id NOT IN") { t.Errorf("clause should include NOT IN subquery: %q", clause) } @@ -822,34 +732,31 @@ func TestEvaluate_ArtworkEnrichment(t *testing.T) { db := database.NewTestDB(t) - // Minimal FK chain: cover_art → release_group(mbid) → - // release_group_recordings → recording(mbid) → audio_file, plus - // artist_credit → artist_credit_artist → artist(mbid). - exec := func(query string, args ...any) { - t.Helper() + // One file, fully identified: cover art on its album, MBIDs on the + // album, the artist and the file itself. + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/bohemian.mp3", + Title: "Bohemian Rhapsody", + Artist: "Queen", + ArtistMBID: "artist-mbid-1", + Album: "A Night at the Opera", + AlbumMBID: "rg-mbid-1", + RecordingMBID: "rec-mbid-1", + LengthMs: 354000, + }) - if _, err := db.ExecContext(query, args...); err != nil { - t.Fatalf("seed %q: %v", query, err) - } + if _, err := db.ExecContext( + "INSERT INTO cover_art (id, file_path, mime_type) " + + "VALUES (1, '/covers/abc123.jpg', 'image/jpeg')", + ); err != nil { + t.Fatalf("seed cover art: %v", err) } - // file_types are pre-seeded by the schema (id 0 = .mp3). - exec("INSERT INTO cover_art (id, file_path, mime_type) " + - "VALUES (1, '/covers/abc123.jpg', 'image/jpeg')") - exec("INSERT INTO artists (id, name, mbid) " + - "VALUES (1, 'Queen', 'artist-mbid-1')") - exec("INSERT INTO artist_credit (id, text) VALUES (1, 'Queen')") - exec("INSERT INTO artist_credit_artist (credit_id, artist_id) " + - "VALUES (1, 1)") - exec("INSERT INTO release_groups (id, name, cover_art_id, mbid) " + - "VALUES (1, 'A Night at the Opera', 1, 'rg-mbid-1')") - exec("INSERT INTO recordings (id, name, artist_credit_id, mbid) " + - "VALUES (1, 'Bohemian Rhapsody', 1, 'rec-mbid-1')") - exec("INSERT INTO release_group_recordings " + - "(release_group_id, recording_id) VALUES (1, 1)") - exec("INSERT INTO audio_files (id, file_path, " + - "length_milliseconds, recording_id, file_type_id) " + - "VALUES (1, '/music/bohemian.mp3', 354000, 1, 0)") + if _, err := db.ExecContext( + "UPDATE albums SET cover_art_id = 1 WHERE name = 'A Night at the Opera'", + ); err != nil { + t.Fatalf("attach cover art: %v", err) + } tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ @@ -864,31 +771,22 @@ func TestEvaluate_ArtworkEnrichment(t *testing.T) { t.Fatalf("got %d tracks, want 1", len(tracks)) } - tr := tracks[0] + got := tracks[0] - if tr.ArtistMBID != "artist-mbid-1" { - t.Errorf("ArtistMBID = %q, want artist-mbid-1", tr.ArtistMBID) + if got.CoverArtPath != coverart.ResolveURLs("/covers/abc123.jpg").Original { + t.Errorf("cover art = %q, want the resolved original", got.CoverArtPath) } - if tr.ReleaseGroupMBID != "rg-mbid-1" { - t.Errorf("ReleaseGroupMBID = %q, want rg-mbid-1", - tr.ReleaseGroupMBID) + if got.ArtistMBID != "artist-mbid-1" { + t.Errorf("artist mbid = %q, want artist-mbid-1", got.ArtistMBID) } - if tr.RecordingMBID != "rec-mbid-1" { - t.Errorf("RecordingMBID = %q, want rec-mbid-1", - tr.RecordingMBID) + if got.ReleaseGroupMBID != "rg-mbid-1" { + t.Errorf("release group mbid = %q, want rg-mbid-1", got.ReleaseGroupMBID) } - wantURLs := coverart.ResolveURLs("/covers/abc123.jpg") - if tr.CoverArtPath != wantURLs.Original { - t.Errorf("CoverArtPath = %q, want %q", - tr.CoverArtPath, wantURLs.Original) - } - - if tr.CoverArtSmall != wantURLs.Small { - t.Errorf("CoverArtSmall = %q, want %q", - tr.CoverArtSmall, wantURLs.Small) + if got.RecordingMBID != "rec-mbid-1" { + t.Errorf("recording mbid = %q, want rec-mbid-1", got.RecordingMBID) } } @@ -1710,38 +1608,24 @@ func TestEvaluate_YearUsesOriginalReleaseYear(t *testing.T) { db := database.NewTestDB(t) - // One track: a 1977 album the user owns as a 2013 reissue. The file - // tag / recording year is 2013, but the release group's original - // (first-release) year is 1977. - exec := func(query string, args ...any) { - t.Helper() + // One track: a 1977 album the user owns as a 2013 reissue. The + // file's own year is 2013; the album's original-release year is + // 1977, and that is what a year filter means. + database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: "/music/b52s/rock_lobster.mp3", + Title: "Rock Lobster", + Artist: "The B-52's", + Album: "Reissue Compilation", + Year: 2013, + LengthMs: 300000, + }) - if _, err := db.ExecContext(query, args...); err != nil { - t.Fatalf("exec %q: %v", query, err) - } + if _, err := db.ExecContext( + "UPDATE albums SET year = 2013, original_year = 1977", + ); err != nil { + t.Fatalf("set album years: %v", err) } - exec("INSERT INTO artist_credit (id, text) VALUES (1, ?)", "The B-52's") - exec( - "INSERT INTO release_groups (id, name, year, original_year) "+ - "VALUES (1, ?, 2013, 1977)", - "Reissue Compilation", - ) - exec( - "INSERT INTO recordings (id, name, artist_credit_id, year) "+ - "VALUES (1, ?, 1, 2013)", - "Rock Lobster", - ) - exec( - "INSERT INTO audio_files (id, file_path, length_milliseconds, "+ - "file_type_id, recording_id) VALUES (1, ?, 300000, 1, 1)", - "/music/b52s/rock_lobster.mp3", - ) - exec( - "INSERT INTO release_group_recordings " + - "(release_group_id, recording_id) VALUES (1, 1)", - ) - // A "2010s" filter must NOT match — the album is originally from 1977. tracks, err := Evaluate(db, RuleSet{ Rules: []Rule{ diff --git a/backend/tagwriter/dbsync.go b/backend/tagwriter/dbsync.go index 9030c9f..68e57ce 100644 --- a/backend/tagwriter/dbsync.go +++ b/backend/tagwriter/dbsync.go @@ -22,21 +22,26 @@ import ( "yellowjacket/backend/metadata" ) -// dbSyncParams holds the context needed by syncDatabase to update -// all database entities after a successful file tag write. +// dbSyncParams holds the context needed by syncDatabase to update the +// database after a successful file tag write. type dbSyncParams struct { - audioFileID int64 - recordingID int64 - filePath string - changes TagChanges - oldRecording sqlcgen.Recording - oldRGLinks []sqlcgen.ReleaseGroupRecording + audioFileID int64 + filePath string + changes TagChanges + oldFile sqlcgen.AudioFile } -// syncDatabase runs all database updates for a tag write inside a -// single transaction: entity upsert-and-relink, FTS5 update, and -// orphan cleanup. If anything fails the entire transaction is -// rolled back, leaving the database at its previous state. +// syncDatabase runs the database updates for a tag write inside a +// single transaction: the file's own tag columns, its album and artist +// links, its genres, its cover art, and the FTS row. If anything fails +// the whole transaction rolls back. +// +// This used to be four times longer, and three of the four parts were +// bookkeeping for tables that no longer exist: relinking a recording to +// a new artist_credit, unlinking and relinking release_group_recordings, +// and then three orphan sweeps to delete whichever of those rows the +// relink had stranded. Tags live on the file now, so changing a tag is +// an UPDATE, and nothing can be stranded by one. func syncDatabase( ctx context.Context, logger *slog.Logger, @@ -51,288 +56,182 @@ func syncDatabase( defer func() { _ = tx.Rollback() }() // no-op after commit txq := db.Queries.WithTx(tx) + old := params.oldFile // ------------------------------------------------------------------ - // Track old entity IDs for orphan cleanup after relinking. + // 1. Resolve the artist, if it changed. // ------------------------------------------------------------------ - oldArtistCreditID := params.oldRecording.ArtistCreditID + artistCredit := old.ArtistCredit + artistID := old.ArtistID - oldRGIDs := make([]int64, 0, len(params.oldRGLinks)) - for _, link := range params.oldRGLinks { - oldRGIDs = append(oldRGIDs, link.ReleaseGroupID) - } - - // newArtistCreditID starts as old; overwritten if artist changes. - newArtistCreditID := oldArtistCreditID - - // ------------------------------------------------------------------ - // 1. Handle artist change. - // ------------------------------------------------------------------ if v, ok := params.changes[FieldArtist].(string); ok { - newAC, acErr := txq.UpsertArtistCredit(ctx, v) - if acErr != nil { - return fmt.Errorf("upsert artist credit: %w", acErr) - } + artistCredit = v - newArtistCreditID = newAC.ID - - newArtist, artErr := txq.UpsertArtist(ctx, v) + artist, artErr := txq.UpsertArtist(ctx, sqlcgen.UpsertArtistParams{Name: v}) if artErr != nil { return fmt.Errorf("upsert artist: %w", artErr) } - // Link artist → credit (INSERT OR IGNORE handles dupes). - if _, linkErr := txq.CreateArtistCreditArtist(ctx, - sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: newArtist.ID, - CreditID: newAC.ID, - }, - ); linkErr != nil && !database.IsUniqueViolation(linkErr) { - return fmt.Errorf("link artist to credit: %w", linkErr) - } + artistID = sql.NullInt64{Int64: artist.ID, Valid: true} } // ------------------------------------------------------------------ - // 2. Handle album change. + // 2. Resolve the album, if it changed. // ------------------------------------------------------------------ + albumID := old.AlbumID + if newAlbumName, ok := params.changes[FieldAlbum].(string); ok { - // Determine album-artist credit ID. - albumArtistCreditID := sql.NullInt64{ - Int64: newArtistCreditID, Valid: true, - } + albumCredit := artistCredit + albumArtistID := artistID if aav, aaOK := params.changes[FieldAlbumArtist].(string); aaOK && aav != "" { - aaCredit, aaErr := txq.UpsertArtistCredit(ctx, aav) + albumCredit = aav + + aaArtist, aaErr := txq.UpsertArtist(ctx, sqlcgen.UpsertArtistParams{Name: aav}) if aaErr != nil { - return fmt.Errorf("upsert album artist credit: %w", aaErr) + return fmt.Errorf("upsert album artist: %w", aaErr) } - albumArtistCreditID = sql.NullInt64{ - Int64: aaCredit.ID, Valid: true, - } - - aaArtist, aaArtErr := txq.UpsertArtist(ctx, aav) - if aaArtErr != nil { - return fmt.Errorf("upsert album artist: %w", aaArtErr) - } - - if _, aaLinkErr := txq.CreateArtistCreditArtist(ctx, - sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: aaArtist.ID, - CreditID: aaCredit.ID, - }, - ); aaLinkErr != nil && !database.IsUniqueViolation(aaLinkErr) { - return fmt.Errorf("link album artist to credit: %w", aaLinkErr) - } + albumArtistID = sql.NullInt64{Int64: aaArtist.ID, Valid: true} } - // Determine year value. - yearVal := params.oldRecording.Year + year := old.Year if yv, yOK := asInt(params.changes[FieldYear]); yOK { - yearVal = toNullInt64(yv) + year = toNullInt64(yv) } - // Upsert new release group. - newRG, rgErr := txq.UpsertReleaseGroup(ctx, - sqlcgen.UpsertReleaseGroupParams{ - Name: newAlbumName, - AlbumArtistCreditID: albumArtistCreditID, - Year: yearVal, - }, - ) - if rgErr != nil { - return fmt.Errorf("upsert release group: %w", rgErr) + album, albErr := txq.UpsertAlbum(ctx, sqlcgen.UpsertAlbumParams{ + Name: newAlbumName, + ArtistCredit: albumCredit, + ArtistID: albumArtistID, + Year: year, + }) + if albErr != nil { + return fmt.Errorf("upsert album: %w", albErr) } - // Unlink old release_group_recordings. - for _, oldLink := range params.oldRGLinks { - if unlinkErr := txq.DeleteReleaseGroupRecordingByFK(ctx, - sqlcgen.DeleteReleaseGroupRecordingByFKParams{ - ReleaseGroupID: oldLink.ReleaseGroupID, - RecordingID: params.recordingID, - }, - ); unlinkErr != nil { - return fmt.Errorf("unlink old rg recording: %w", unlinkErr) - } - } - - // Determine track/disc numbers. - trackNum := params.oldRecording.TrackNumber - if tn, tnOK := asInt(params.changes[FieldTrackNumber]); tnOK { - trackNum = toNullInt64(tn) - } - - discNum := params.oldRecording.DiscNumber - if dn, dnOK := asInt(params.changes[FieldDiscNumber]); dnOK { - discNum = toNullInt64(dn) - } - - // Create new link. - if _, linkErr := txq.CreateReleaseGroupRecording(ctx, - sqlcgen.CreateReleaseGroupRecordingParams{ - ReleaseGroupID: newRG.ID, - RecordingID: params.recordingID, - TrackNumber: trackNum, - DiscNumber: discNum, - }, - ); linkErr != nil { - return fmt.Errorf("create rg recording link: %w", linkErr) - } + albumID = sql.NullInt64{Int64: album.ID, Valid: true} } // ------------------------------------------------------------------ - // 3. Handle genre change. + // 3. Genres, if they changed. // ------------------------------------------------------------------ if newGenre, ok := params.changes[FieldGenre].(string); ok { - // Delete all existing recording_genres for this recording. - if delErr := txq.DeleteRecordingGenres(ctx, params.recordingID); delErr != nil { - return fmt.Errorf("delete recording genres: %w", delErr) + if delErr := txq.DeleteFileGenres(ctx, params.audioFileID); delErr != nil { + return fmt.Errorf("delete file genres: %w", delErr) } - // Parse and link new genres. - genres := metadata.ParseGenres(newGenre) - for _, gName := range genres { + for _, gName := range metadata.ParseGenres(newGenre) { g, gErr := txq.UpsertGenre(ctx, gName) if gErr != nil { return fmt.Errorf("upsert genre %q: %w", gName, gErr) } - if rgErr := txq.CreateRecordingGenre(ctx, - sqlcgen.CreateRecordingGenreParams{ - RecordingID: params.recordingID, - GenreID: g.ID, - }, - ); rgErr != nil { - return fmt.Errorf("create recording genre: %w", rgErr) + if linkErr := txq.LinkFileGenre(ctx, sqlcgen.LinkFileGenreParams{ + AudioFileID: params.audioFileID, + GenreID: g.ID, + }); linkErr != nil { + return fmt.Errorf("link file genre: %w", linkErr) } } } // ------------------------------------------------------------------ - // 4. Handle cover art change — save image to covers cache, - // upsert cover_art row, and update release_groups.cover_art_id. + // 4. Cover art, if it changed. It belongs to the album, so a file + // with no album has nowhere to put it. // ------------------------------------------------------------------ - if _, hasCoverArt := params.changes[FieldCoverArt]; hasCoverArt { - coverArtData, isBytes := asBytes(params.changes[FieldCoverArt]) + if _, hasCoverArt := params.changes[FieldCoverArt]; hasCoverArt && albumID.Valid { + coverArtID := sql.NullInt64{} - if isBytes && len(coverArtData) > 0 { - // Save to covers dir and upsert DB row. - newCoverArtID, caErr := saveCoverArtAndSync( - ctx, logger, txq, coverArtData, - ) + if data, isBytes := asBytes(params.changes[FieldCoverArt]); isBytes && len(data) > 0 { + newID, caErr := saveCoverArtAndSync(ctx, logger, txq, data) if caErr != nil { logger.Warn("cover art sync failed", "err", caErr) } else { - // Update all release groups linked to this recording. - for _, rgLink := range params.oldRGLinks { - if upErr := txq.UpdateReleaseGroupCoverArt(ctx, - sqlcgen.UpdateReleaseGroupCoverArtParams{ - CoverArtID: sql.NullInt64{Int64: newCoverArtID, Valid: true}, - ID: rgLink.ReleaseGroupID, - }, - ); upErr != nil { - logger.Warn("update rg cover art failed", - "err", upErr, - "releaseGroupID", rgLink.ReleaseGroupID) - } - } - } - } else { - // Clear: set cover_art_id to NULL on all linked release groups. - for _, rgLink := range params.oldRGLinks { - if upErr := txq.UpdateReleaseGroupCoverArt(ctx, - sqlcgen.UpdateReleaseGroupCoverArtParams{ - CoverArtID: sql.NullInt64{}, - ID: rgLink.ReleaseGroupID, - }, - ); upErr != nil { - logger.Warn("clear rg cover art failed", - "err", upErr, - "releaseGroupID", rgLink.ReleaseGroupID) - } + coverArtID = sql.NullInt64{Int64: newID, Valid: true} } } + + if upErr := txq.SetAlbumCoverArt(ctx, sqlcgen.SetAlbumCoverArtParams{ + CoverArtID: coverArtID, + ID: albumID.Int64, + }); upErr != nil { + logger.Warn("update album cover art failed", "err", upErr, "albumID", albumID.Int64) + } } // ------------------------------------------------------------------ - // 5. Update recording with all changed fields. + // 5. The file's own tag columns. // ------------------------------------------------------------------ - rec := params.oldRecording - - newName := rec.Name + title := old.Title if v, ok := params.changes[FieldTitle].(string); ok { - newName = v + title = v } - newYear := rec.Year + year := old.Year if v, ok := asInt(params.changes[FieldYear]); ok { - newYear = toNullInt64(v) + year = toNullInt64(v) } - newTrackNum := rec.TrackNumber + trackNum := old.TrackNumber if v, ok := asInt(params.changes[FieldTrackNumber]); ok { - newTrackNum = toNullInt64(v) + trackNum = toNullInt64(v) } - newDiscNum := rec.DiscNumber + discNum := old.DiscNumber if v, ok := asInt(params.changes[FieldDiscNumber]); ok { - newDiscNum = toNullInt64(v) + discNum = toNullInt64(v) } - newGenreStr := rec.Genre - if v, ok := params.changes[FieldGenre].(string); ok { - newGenreStr = toNullString(v) - } - - newComposer := rec.Composer + composer := old.Composer if v, ok := params.changes[FieldComposer].(string); ok { - newComposer = toNullString(v) + composer = v } - if updErr := txq.UpdateRecordingFull(ctx, sqlcgen.UpdateRecordingFullParams{ - Name: newName, - ArtistCreditID: newArtistCreditID, - TrackNumber: newTrackNum, - DiscNumber: newDiscNum, - Year: newYear, - Genre: newGenreStr, - Composer: newComposer, - Lyrics: rec.Lyrics, - Comment: rec.Comment, - ID: params.recordingID, + // Writing tags rewrites the file, changing its mtime and possibly + // its size. Recording the new values keeps the scan from mistaking + // YellowJacket's own edit for an external one and re-importing. + modifiedAt, fileSize := old.ModifiedAt, old.FileSize + + if info, statErr := os.Stat(params.filePath); statErr != nil { + logger.Warn("could not stat file after tag write", + "path", params.filePath, "err", statErr) + } else { + modifiedAt, fileSize = info.ModTime().Unix(), info.Size() + } + + if updErr := txq.UpdateAudioFileTags(ctx, sqlcgen.UpdateAudioFileTagsParams{ + Title: title, + ArtistCredit: artistCredit, + ArtistID: artistID, + AlbumID: albumID, + TrackNumber: trackNum, + DiscNumber: discNum, + TotalTracks: old.TotalTracks, + Year: year, + Composer: composer, + Comment: old.Comment, + RecordingMbid: old.RecordingMbid, + SampleRate: old.SampleRate, + BitDepth: old.BitDepth, + Channels: old.Channels, + Bitrate: old.Bitrate, + FileSize: fileSize, + LengthMilliseconds: old.LengthMilliseconds, + ModifiedAt: modifiedAt, + ID: params.audioFileID, }); updErr != nil { - return fmt.Errorf("update recording: %w", updErr) + return fmt.Errorf("update audio file tags: %w", updErr) } // ------------------------------------------------------------------ - // 6. Update FTS5 search index (within the transaction). + // 6. The FTS row. // ------------------------------------------------------------------ - newTitle := newName + album := "" - newArtist := params.changes[FieldArtist] - artistStr := "" - - if newArtist != nil { - artistStr, _ = newArtist.(string) - } - - if artistStr == "" { - // Look up current artist credit text from the old recording - // if the artist hasn't changed. - ac, acErr := txq.GetArtistCredit(ctx, newArtistCreditID) - if acErr == nil { - artistStr = ac.Text - } - } - - newAlbum := "" - if v, ok := params.changes[FieldAlbum].(string); ok { - newAlbum = v - } else if len(params.oldRGLinks) > 0 { - // Look up current album from release groups if unchanged. - rg, rgErr := txq.GetReleaseGroup(ctx, params.oldRGLinks[0].ReleaseGroupID) - if rgErr == nil { - newAlbum = rg.Name + if albumID.Valid { + if row, albErr := txq.GetAlbum(ctx, albumID.Int64); albErr == nil { + album = row.Name } } @@ -349,97 +248,14 @@ func syncDatabase( if _, ftsInsErr := tx.ExecContext(ctx, `INSERT INTO search_index(rowid, file_path, title, artist, album) VALUES (?, ?, ?, ?, ?)`, - params.audioFileID, params.filePath, newTitle, artistStr, newAlbum, + params.audioFileID, params.filePath, title, artistCredit, album, ); ftsInsErr != nil { logger.Warn("FTS5 insert failed", "err", ftsInsErr, "audioFileID", params.audioFileID) } - // ------------------------------------------------------------------ - // 7. Orphan cleanup (within same transaction). - // ------------------------------------------------------------------ - - // 7a. Artist credit orphan cleanup. - if newArtistCreditID != oldArtistCreditID { - refCount, refErr := txq.CountArtistCreditReferences(ctx, oldArtistCreditID) - if refErr != nil { - logger.Warn("count artist credit refs failed", "err", refErr) - } else if refCount == 0 { - // Delete artist_credit_artist entries for the orphaned credit, - // then the credit itself. - // SAFETY: Hand-crafted DELETE for orphan artist_credit_artist rows. - // Parameterized credit_id. No sqlc query exists for this specific - // delete-by-credit pattern. - if _, acaErr := tx.ExecContext(ctx, - "DELETE FROM artist_credit_artist WHERE credit_id = ?", - oldArtistCreditID, - ); acaErr != nil { - logger.Warn("delete orphan aca failed", "err", acaErr) - } - - if delErr := txq.DeleteArtistCredit(ctx, oldArtistCreditID); delErr != nil { - logger.Warn("delete orphan artist credit failed", "err", delErr) - } - } - } - - // 7b. Release group orphan cleanup. - if _, albumChanged := params.changes[FieldAlbum]; albumChanged { - for _, oldRGID := range oldRGIDs { - rgCount, rgErr := txq.CountReleaseGroupRecordings(ctx, oldRGID) - if rgErr != nil { - logger.Warn("count rg recordings failed", "err", rgErr, - "releaseGroupID", oldRGID) - - continue - } - - if rgCount == 0 { - if delErr := txq.DeleteReleaseGroup(ctx, oldRGID); delErr != nil { - logger.Warn("delete orphan release group failed", - "err", delErr, "releaseGroupID", oldRGID) - } - } - } - } - - // 7c. Genre orphan cleanup — delete genres with no remaining - // recording_genres references. This is safe because genres - // are only referenced via recording_genres. - if _, genreChanged := params.changes[FieldGenre]; genreChanged { - // SAFETY: Hand-crafted DELETE for orphan genres. No user input. - // Matches the global orphan pattern from library/crud.go. - if _, gErr := tx.ExecContext(ctx, - `DELETE FROM genres WHERE id NOT IN - (SELECT DISTINCT genre_id FROM recording_genres)`, - ); gErr != nil { - logger.Warn("genre orphan cleanup failed", "err", gErr) - } - } - - // ------------------------------------------------------------------ - // 7d. Re-baseline the staleness fields. Writing tags rewrites the - // file, changing its mtime and possibly its size. Recording the - // new values here keeps the scan from mistaking YellowJacket's - // own edit for an external one and re-importing the track. - // ------------------------------------------------------------------ - if info, statErr := os.Stat(params.filePath); statErr != nil { - logger.Warn("could not stat file after tag write", - "path", params.filePath, "err", statErr) - } else if updErr := txq.UpdateAudioFileStat(ctx, - sqlcgen.UpdateAudioFileStatParams{ - ModifiedAt: info.ModTime().Unix(), - FileSize: info.Size(), - ID: params.audioFileID, - }, - ); updErr != nil { - logger.Warn("could not update file stat after tag write", - "path", params.filePath, "err", updErr) - } - - // ------------------------------------------------------------------ - // 8. Commit. - // ------------------------------------------------------------------ + // Albums and artists left empty by this write are swept by the + // library's own cleanup, which is one query each now. return tx.Commit() } @@ -452,15 +268,6 @@ func toNullInt64(v int) sql.NullInt64 { return sql.NullInt64{Int64: int64(v), Valid: true} } -// toNullString converts a string to sql.NullString, treating empty as null. -func toNullString(v string) sql.NullString { - if v == "" { - return sql.NullString{} - } - - return sql.NullString{String: v, Valid: true} -} - // saveCoverArtAndSync saves cover art bytes to the covers cache // directory (with content-hash deduplication), generates sized // thumbnails, upserts a cover_art DB row, and returns the row ID. @@ -479,27 +286,18 @@ func saveCoverArtAndSync( return 0, fmt.Errorf("create covers dir: %w", err) } - // Content-hash filename (same scheme as library/coverart.go). + // Content-hash filename (same scheme as library/coverart.go): the + // tiers are the only thing stored, and the largest is the cover's + // canonical path. The full-resolution bytes stay in the file the + // user just wrote them to. hash := sha256.Sum256(data) hashStr := hex.EncodeToString(hash[:8]) mime := detectMIME(data) - ext := "jpg" - if mime == "image/png" { - ext = "png" - } + filePath := filepath.Join( + coverDir, coverart.SizedFilename(hashStr, largestTierSuffix), + ) - filename := fmt.Sprintf("%s.%s", hashStr, ext) - filePath := filepath.Join(coverDir, filename) - - // Write original if not already present. - if _, statErr := os.Stat(filePath); statErr != nil { - if writeErr := os.WriteFile(filePath, data, 0o644); writeErr != nil { - return 0, fmt.Errorf("write cover art: %w", writeErr) - } - } - - // Generate sized variants (thumbnails). generateSizedVariants(logger, data, coverDir, hashStr) // Upsert cover_art DB row. @@ -522,6 +320,9 @@ type thumbnailTier struct { quality int } +// largestTierSuffix is the tier stored as a cover's canonical path. +const largestTierSuffix = "_lg" + // thumbnailTiers matches the tiers in library/coverart.go. var thumbnailTiers = []thumbnailTier{ {suffix: "_sm", maxSize: 100, quality: 75}, diff --git a/backend/tagwriter/pipeline.go b/backend/tagwriter/pipeline.go index 658f116..689f668 100644 --- a/backend/tagwriter/pipeline.go +++ b/backend/tagwriter/pipeline.go @@ -118,16 +118,6 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { return fmt.Errorf("get audio file %d: %w", trackID, err) } - recording, err := tw.db.Queries.GetRecording(ctx, audioFile.RecordingID) - if err != nil { - return fmt.Errorf("get recording %d: %w", audioFile.RecordingID, err) - } - - rgLinks, err := tw.db.Queries.GetRecordingReleaseGroups(ctx, recording.ID) - if err != nil { - return fmt.Errorf("get recording rg links: %w", err) - } - // 2. Detect format. format, err := DetectFormat(audioFile.FilePath) if err != nil { @@ -165,12 +155,10 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { // 6. Sync database. if syncErr := syncDatabase(ctx, tw.logger, tw.db, dbSyncParams{ - audioFileID: audioFile.ID, - recordingID: recording.ID, - filePath: audioFile.FilePath, - changes: changes, - oldRecording: recording, - oldRGLinks: rgLinks, + audioFileID: audioFile.ID, + filePath: audioFile.FilePath, + changes: changes, + oldFile: audioFile, }); syncErr != nil { tw.logger.Error("db sync failed after successful file write", "err", syncErr, diff --git a/backend/tagwriter/pipeline_test.go b/backend/tagwriter/pipeline_test.go index cec854a..f59a32f 100644 --- a/backend/tagwriter/pipeline_test.go +++ b/backend/tagwriter/pipeline_test.go @@ -2,12 +2,9 @@ package tagwriter import ( "context" - "database/sql" - "path/filepath" "testing" "yellowjacket/backend/database" - "yellowjacket/backend/database/sql/sqlcgen" ) // --- Mock implementations --- @@ -58,110 +55,17 @@ func seedTestTrack( ) int64 { t.Helper() - ctx := context.Background() - q := db.Queries - - // Artist credit. - ac, err := q.UpsertArtistCredit(ctx, "Old Artist") - if err != nil { - t.Fatalf("upsert artist credit: %v", err) - } - - artist, err := q.UpsertArtist(ctx, "Old Artist") - if err != nil { - t.Fatalf("upsert artist: %v", err) - } - - if _, err := q.CreateArtistCreditArtist(ctx, - sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: artist.ID, - CreditID: ac.ID, - }, - ); err != nil { - t.Fatalf("create aca: %v", err) - } - - // Recording. - rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{ - Name: "Old Title", - ArtistCreditID: ac.ID, - TrackNumber: sql.NullInt64{Int64: 1, Valid: true}, - DiscNumber: sql.NullInt64{Int64: 1, Valid: true}, - Year: sql.NullInt64{Int64: 2020, Valid: true}, - Genre: sql.NullString{String: "Rock", Valid: true}, - Composer: sql.NullString{String: "Old Composer", Valid: true}, + return database.InsertTestTrack(t, db, database.TestTrack{ + FilePath: filePath, + Title: "Old Title", + Artist: "Old Artist", + Album: "Old Album", + Genres: []string{"Rock"}, + TrackNumber: 1, + DiscNumber: 1, + Year: 2020, + LengthMs: 180000, }) - if err != nil { - t.Fatalf("create recording: %v", err) - } - - // Release group. - rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: "Old Album", - AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true}, - Year: sql.NullInt64{Int64: 2020, 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: 1, Valid: true}, - DiscNumber: sql.NullInt64{Int64: 1, Valid: true}, - }, - ); err != nil { - t.Fatalf("create rg recording: %v", err) - } - - // Genre. - genre, err := q.UpsertGenre(ctx, "Rock") - if err != nil { - t.Fatalf("upsert genre: %v", err) - } - - if err := q.CreateRecordingGenre(ctx, sqlcgen.CreateRecordingGenreParams{ - RecordingID: rec.ID, - GenreID: genre.ID, - }); err != nil { - t.Fatalf("create recording genre: %v", err) - } - - // Audio file. - af, err := q.CreateAudioFile(ctx, sqlcgen.CreateAudioFileParams{ - FilePath: filePath, - LengthMilliseconds: 180000, - FileTypeID: 0, - RecordingID: rec.ID, - Basename: filepath.Base(filePath), - LibraryID: 0, - }) - if err != nil { - t.Fatalf("create audio file: %v", err) - } - - // Seed FTS5 search index. - // SAFETY: FTS5 INSERT for test setup. All values parameterized. - tx, err := db.BeginTx() - if err != nil { - t.Fatalf("begin tx: %v", err) - } - - if _, err := tx.ExecContext(ctx, - `INSERT INTO search_index(rowid, file_path, title, artist, album) - VALUES (?, ?, ?, ?, ?)`, - af.ID, filePath, "Old Title", "Old Artist", "Old Album", - ); err != nil { - t.Fatalf("insert search index: %v", err) - } - - if err := tx.Commit(); err != nil { - t.Fatalf("commit: %v", err) - } - - return af.ID } // createPipelineTestMP3 creates a minimal MP3 file for pipeline tests @@ -253,20 +157,10 @@ func TestWriteTrackTags_OrphanCleanup(t *testing.T) { // Get old artist credit ID before change. ctx := context.Background() - af, err := db.Queries.GetAudioFile(ctx, trackID) - if err != nil { - t.Fatalf("get audio file: %v", err) - } - - oldRec, err := db.Queries.GetRecording(ctx, af.RecordingID) - if err != nil { - t.Fatalf("get recording: %v", err) - } - - oldACID := oldRec.ArtistCreditID - - // Change both artist and album so the old credit loses all - // references (recordings AND release_groups.album_artist_credit_id). + // Change both artist and album. Under the old schema this + // relinked a recording to a new credit and left the previous + // credit, its artist link and its release group behind, which is + // what three orphan sweeps in syncDatabase existed to clean up. if err := tw.WriteTrackTags(trackID, TagChanges{ FieldArtist: "New Artist", FieldAlbum: "New Album", @@ -274,37 +168,26 @@ func TestWriteTrackTags_OrphanCleanup(t *testing.T) { t.Fatalf("WriteTrackTags: %v", err) } - // Verify old artist credit is orphaned and deleted. - refCount, err := db.Queries.CountArtistCreditReferences(ctx, oldACID) + af, err := db.Queries.GetAudioFile(ctx, trackID) if err != nil { - t.Fatalf("count refs: %v", err) + t.Fatalf("get audio file: %v", err) } - if refCount != 0 { - // The old AC should have 0 references. Check if it still exists. - _, acErr := db.Queries.GetArtistCredit(ctx, oldACID) - if acErr == nil { - t.Errorf( - "expected old artist credit %d to be deleted (orphan), "+ - "but it still exists with %d refs", - oldACID, refCount, - ) - } + if af.ArtistCredit != "New Artist" { + t.Errorf("artist credit: got %q, want %q", af.ArtistCredit, "New Artist") } - // Verify new recording has new artist credit. - newRec, err := db.Queries.GetRecording(ctx, af.RecordingID) + if !af.AlbumID.Valid { + t.Fatal("file has no album after the write") + } + + album, err := db.Queries.GetAlbum(ctx, af.AlbumID.Int64) if err != nil { - t.Fatalf("get new recording: %v", err) + t.Fatalf("get album: %v", err) } - newAC, err := db.Queries.GetArtistCredit(ctx, newRec.ArtistCreditID) - if err != nil { - t.Fatalf("get new artist credit: %v", err) - } - - if newAC.Text != "New Artist" { - t.Errorf("new artist credit: got %q, want %q", newAC.Text, "New Artist") + if album.Name != "New Album" { + t.Errorf("album name: got %q, want %q", album.Name, "New Album") } } @@ -332,13 +215,8 @@ func TestWriteTrackTags_GenreRelink(t *testing.T) { ctx := context.Background() - af, err := db.Queries.GetAudioFile(ctx, trackID) - if err != nil { - t.Fatalf("get audio file: %v", err) - } - // Verify new genres are linked. - genres, err := db.Queries.GetGenresByRecordingID(ctx, af.RecordingID) + genres, err := db.Queries.GetGenreNamesByFile(ctx, trackID) if err != nil { t.Fatalf("get genres: %v", err) } @@ -349,7 +227,7 @@ func TestWriteTrackTags_GenreRelink(t *testing.T) { genreNames := make(map[string]bool) for _, g := range genres { - genreNames[g.Name] = true + genreNames[g] = true } if !genreNames["Jazz"] { @@ -360,15 +238,10 @@ func TestWriteTrackTags_GenreRelink(t *testing.T) { t.Error("expected Blues genre to be linked") } - // Verify old "Rock" genre is deleted (orphaned). - oldRockRefs, err := db.Queries.CountGenreReferences(ctx, 1) // ID 1 from seeding - if err != nil { - // Genre might not exist anymore, which is expected. - return - } - - if oldRockRefs > 0 { - t.Logf("Rock genre still has %d refs (may be expected if reused)", oldRockRefs) + // The old "Rock" link is gone: genres are relinked wholesale, so a + // genre dropped from the tag is dropped from the file. + if genreNames["Rock"] { + t.Error("expected the old Rock link to be replaced") } } @@ -399,65 +272,48 @@ func TestWriteTrackTags_DBSync(t *testing.T) { ctx := context.Background() - // Verify recording updated. + // Verify the file's own tag columns. af, err := db.Queries.GetAudioFile(ctx, trackID) if err != nil { t.Fatalf("get audio file: %v", err) } - rec, err := db.Queries.GetRecording(ctx, af.RecordingID) + if af.Title != "New Title" { + t.Errorf("title: got %q, want %q", af.Title, "New Title") + } + + if af.Year.Int64 != 2025 { + t.Errorf("year: got %d, want 2025", af.Year.Int64) + } + + if af.Composer != "New Composer" { + t.Errorf("composer: got %q, want %q", af.Composer, "New Composer") + } + + if af.ArtistCredit != "New Artist" { + t.Errorf("artist credit: got %q, want %q", af.ArtistCredit, "New Artist") + } + + if !af.AlbumID.Valid { + t.Fatal("file has no album after the write") + } + + album, err := db.Queries.GetAlbum(ctx, af.AlbumID.Int64) if err != nil { - t.Fatalf("get recording: %v", err) + t.Fatalf("get album: %v", err) } - if rec.Name != "New Title" { - t.Errorf("recording name: got %q, want %q", rec.Name, "New Title") - } - - if rec.Year.Int64 != 2025 { - t.Errorf("year: got %d, want 2025", rec.Year.Int64) - } - - if rec.Composer.String != "New Composer" { - t.Errorf("composer: got %q, want %q", rec.Composer.String, "New Composer") - } - - // Verify new artist credit. - ac, err := db.Queries.GetArtistCredit(ctx, rec.ArtistCreditID) - if err != nil { - t.Fatalf("get artist credit: %v", err) - } - - if ac.Text != "New Artist" { - t.Errorf("artist credit: got %q, want %q", ac.Text, "New Artist") - } - - // Verify new release group linked. - rgLinks, err := db.Queries.GetRecordingReleaseGroups(ctx, rec.ID) - if err != nil { - t.Fatalf("get rg links: %v", err) - } - - if len(rgLinks) != 1 { - t.Fatalf("expected 1 rg link, got %d", len(rgLinks)) - } - - rg, err := db.Queries.GetReleaseGroup(ctx, rgLinks[0].ReleaseGroupID) - if err != nil { - t.Fatalf("get release group: %v", err) - } - - if rg.Name != "New Album" { - t.Errorf("release group name: got %q, want %q", rg.Name, "New Album") + if album.Name != "New Album" { + t.Errorf("album name: got %q, want %q", album.Name, "New Album") } // Verify genre re-linked. - genres, err := db.Queries.GetGenresByRecordingID(ctx, rec.ID) + genres, err := db.Queries.GetGenreNamesByFile(ctx, trackID) if err != nil { t.Fatalf("get genres: %v", err) } - if len(genres) != 1 || genres[0].Name != "Electronic" { + if len(genres) != 1 || genres[0] != "Electronic" { t.Errorf("genres: got %v, want [Electronic]", genres) } diff --git a/backend/testctl/dbstate_dev_test.go b/backend/testctl/dbstate_dev_test.go index fcb3b40..ba936bd 100644 --- a/backend/testctl/dbstate_dev_test.go +++ b/backend/testctl/dbstate_dev_test.go @@ -47,26 +47,13 @@ func TestRestoreRoundTrip(t *testing.T) { t.Fatalf("seed library: %v", err) } - if _, err := d.DB.ExecContext( - `INSERT INTO artist_credit (id, text) VALUES (1, 'Fixture Artist')`, - ); err != nil { - t.Fatalf("seed artist credit: %v", err) - } - - if _, err := d.DB.ExecContext( - `INSERT INTO recordings (id, name, artist_credit_id) - VALUES (1, 'A', 1)`, - ); err != nil { - t.Fatalf("seed recording: %v", err) - } - - if _, err := d.DB.ExecContext( - `INSERT INTO audio_files - (file_path, length_milliseconds, file_type_id, recording_id, library_id) - VALUES ('/music/a.mp3', 2000, 1, 1, 1)`, - ); err != nil { - t.Fatalf("seed track: %v", err) - } + database.InsertTestTrack(t, d.DB, database.TestTrack{ + FilePath: "/music/a.mp3", + Title: "A", + Artist: "Fixture Artist", + LengthMs: 2000, + LibraryID: 1, + }) snapReq := httptest.NewRequest(http.MethodPost, "/__test/db/snapshot?name=unit", nil) diff --git a/backend/testctl/handlers_dev.go b/backend/testctl/handlers_dev.go index f5ddba9..077c46a 100644 --- a/backend/testctl/handlers_dev.go +++ b/backend/testctl/handlers_dev.go @@ -30,12 +30,24 @@ func handleHealth(d Deps, _ *http.Request) (any, error) { counts := map[string]int64{} + // The catalog is asked whether it has rows, not how many. A real + // one is ~1.1M rows over ~400 MB, and a cold `COUNT(*)` on it is a + // full scan off disk: **65 seconds** on the first call after a seed + // is extracted, then 7 ms once the page cache is warm. That is a + // health endpoint every spec gates on, so the first spec to run + // timed out and the rest passed - which reads as one flaky spec. + // + // This is the same rule the app itself follows for this table + // (`GetIndexStatus().TotalRows` is stale and `IsReady()` is set + // once, so the shelves ask `SELECT 1 ... LIMIT 1`). Nothing wants + // the exact number: the only caller asks whether it is > 0. for table, query := range map[string]string{ - "tracks": "SELECT COUNT(*) FROM audio_files", - "libraries": "SELECT COUNT(*) FROM libraries", - "playlists": "SELECT COUNT(*) FROM playlists", - "queueTracks": "SELECT COUNT(*) FROM queue_tracks", - "exploreIndex": "SELECT COUNT(*) FROM explore_index", + "tracks": "SELECT COUNT(*) FROM audio_files", + "libraries": "SELECT COUNT(*) FROM libraries", + "playlists": "SELECT COUNT(*) FROM playlists", + "queueTracks": "SELECT COUNT(*) FROM queue_tracks", + "exploreIndex": "SELECT COUNT(*) FROM " + + "(SELECT 1 FROM explore_index LIMIT 1)", } { var n int64 if err := d.DB.QueryRowWriter(query).Scan(&n); err != nil { diff --git a/cmd/indexexport/main.go b/cmd/indexexport/main.go index 89c14ff..e97c667 100644 --- a/cmd/indexexport/main.go +++ b/cmd/indexexport/main.go @@ -40,7 +40,7 @@ import ( // recomputed locally by PopulateLocalCrossReferences after import. const catalogColumns = `entity_type, mbid, title, artist_name, artist_mbid, aliases, popularity, listener_count, duration, caa_release_mbid, - release_name, primary_type, secondary_types, release_date, + release_name, primary_type, secondary_types, release_date, total_tracks, artist_type, country, disambiguation, sort_name, discog_fetched` var errNoHome = errors.New( @@ -139,21 +139,27 @@ func run(out string, artists, perArtistRGs, perArtistRecs int) error { // the importing client's own trigger rebuilds its FTS on insert. func createSchema(db *sql.DB) error { stmts := []string{ + // Column types mirror the app's own explore_index, because the + // export is a straight copy: MBIDs as 16 raw bytes, entity + // types as codes. An importer that meets the older text form + // converts it (artifactSelectColumns), so this is a size change + // and not a compatibility break. `CREATE TABLE core.explore_index ( - entity_type TEXT NOT NULL, - mbid TEXT NOT NULL, + entity_type INTEGER NOT NULL, + mbid BLOB NOT NULL, title TEXT NOT NULL, artist_name TEXT NOT NULL, - artist_mbid 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 TEXT NOT NULL DEFAULT '', + 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 '', + total_tracks INTEGER NOT NULL DEFAULT 0, artist_type TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '', disambiguation TEXT NOT NULL DEFAULT '', @@ -186,7 +192,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error { if _, err := db.Exec(` CREATE TEMP TABLE core_artists AS SELECT mbid FROM main.explore_index - WHERE entity_type = 'artist' + WHERE entity_type = 1 /* artist */ ORDER BY popularity DESC LIMIT ?`, artists, ); err != nil { @@ -197,7 +203,7 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error { INSERT INTO core.explore_index (`+catalogColumns+`) SELECT `+catalogColumns+` FROM main.explore_index - WHERE entity_type = 'artist' + WHERE entity_type = 1 /* artist */ AND mbid IN (SELECT mbid FROM core_artists)`) if err != nil { return err @@ -205,13 +211,18 @@ func copyRows(db *sql.DB, artists, perArtistRGs, perArtistRecs int) error { fmt.Printf(" artists: %d\n", copied) + // The entity codes are the catalog's storage form; see + // backend/explore/mbid.go. The exporter copies the local index's + // encoding through unchanged, so the artifact carries it too - and + // the importer accepts either, so an artifact built before this + // still imports. for _, sel := range []struct { label string - entity string + entity int limit int }{ - {"release groups", "release_group", perArtistRGs}, - {"recordings", "recording", perArtistRecs}, + {"release groups", 2 /* release_group */, perArtistRGs}, + {"recordings", 3 /* recording */, perArtistRecs}, } { // The window is over artist_mbid so each artist contributes at // most `limit` rows, ranked by their own listen counts. diff --git a/e2e/specs/explore-shelves.spec.ts b/e2e/specs/explore-shelves.spec.ts index 03aace6..40aff8d 100644 --- a/e2e/specs/explore-shelves.spec.ts +++ b/e2e/specs/explore-shelves.spec.ts @@ -139,14 +139,25 @@ test.describe('Explore before anyone has typed', () => { async function stageCatalogIfEmpty(app: Page): Promise { if ((await catalogRows(app)) > 0) return; + // The catalog stores an MBID as its 16 raw bytes and an entity type + // as a small integer, so a staged row has to be spelled the way the + // app spells one: a real UUID, converted at the boundary, and a code + // rather than the word. `'e2e-ar-a'` is 8 characters and fails + // `CHECK(length(mbid) = 16)` -- which `INSERT OR IGNORE` then + // swallows, so the staging step looked exactly like a staging step + // and the page had nothing to draw. That is the same fault this + // helper's own comment below describes, one layer down. + const ARTIST = 1; + const RELEASE_GROUP = 2; + const rows = [ - ['artist', 'e2e-ar-a', 'Staged Alpha', 'Staged Alpha', 'e2e-ar-a', 9000], - ['artist', 'e2e-ar-b', 'Staged Beta', 'Staged Beta', 'e2e-ar-b', 500], - ['artist', 'e2e-ar-c', 'Staged Gamma', 'Staged Gamma', 'e2e-ar-c', 300], - ['release_group', 'e2e-rg-a1', 'Alpha One', 'Staged Alpha', 'e2e-ar-a', 8000], - ['release_group', 'e2e-rg-a2', 'Alpha Two', 'Staged Alpha', 'e2e-ar-a', 7000], - ['release_group', 'e2e-rg-a3', 'Alpha Three', 'Staged Alpha', 'e2e-ar-a', 6000], - ['release_group', 'e2e-rg-b1', 'Beta One', 'Staged Beta', 'e2e-ar-b', 400], + [ARTIST, uuid('ar-a'), 'Staged Alpha', 'Staged Alpha', uuid('ar-a'), 9000], + [ARTIST, uuid('ar-b'), 'Staged Beta', 'Staged Beta', uuid('ar-b'), 500], + [ARTIST, uuid('ar-c'), 'Staged Gamma', 'Staged Gamma', uuid('ar-c'), 300], + [RELEASE_GROUP, uuid('rg-a1'), 'Alpha One', 'Staged Alpha', uuid('ar-a'), 8000], + [RELEASE_GROUP, uuid('rg-a2'), 'Alpha Two', 'Staged Alpha', uuid('ar-a'), 7000], + [RELEASE_GROUP, uuid('rg-a3'), 'Alpha Three', 'Staged Alpha', uuid('ar-a'), 6000], + [RELEASE_GROUP, uuid('rg-b1'), 'Beta One', 'Staged Beta', uuid('ar-b'), 400], ]; for (const row of rows) { @@ -158,7 +169,8 @@ async function stageCatalogIfEmpty(app: Page): Promise { sql: `INSERT OR IGNORE INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count, primary_type) - VALUES (?, ?, ?, ?, ?, ?, ?, 'Album')`, + VALUES (?, unhex(replace(?, '-', '')), ?, ?, + unhex(replace(?, '-', '')), ?, ?, 'Album')`, args: [...args, 10], }), }); @@ -171,12 +183,52 @@ async function stageCatalogIfEmpty(app: Page): Promise { // and the staging step looked exactly like a staging step. A setup // whose failure is not checked is not setup. expect(result.status, `staging failed: ${result.body}`).toBe(200); + + // …and `OR IGNORE` means a 200 is not a write. A CHECK the row + // violates is *ignored*, not reported, so the count below is the + // only thing that can tell staging from silence. + expect( + (JSON.parse(result.body) as { rowsAffected?: number }).rowsAffected, + `staged nothing: ${result.body}`, + ).toBe(1); } expect(await catalogRows(app)).toBeGreaterThan(0); } -/** How many catalog rows this environment has. CI has none. */ +/** + * A stable, valid MBID from a short label. + * + * The column is `CHECK(length(mbid) = 16)` after `unhex`, so a fixture + * id has to be a real UUID rather than a readable string -- the same + * trade the Go fixtures make with `testMBID()`, and for the same + * reason: a readable id that cannot be stored is not readable, it is + * absent. + */ +function uuid(label: string): string { + const hex = [...label] + .map((c) => c.charCodeAt(0).toString(16).padStart(2, '0')) + .join('') + .padEnd(32, '0') + .slice(0, 32); + + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join('-'); +} + +/** + * Whether this environment has a catalog at all. CI has none. + * + * `exploreIndex` is deliberately 0-or-1 rather than a row count: a real + * catalog is ~1.1M rows, and a cold `COUNT(*)` over it took 65 seconds + * on the first call after a seed was extracted -- which timed out + * whichever spec ran first and looked like flake. + */ async function catalogRows(app: Page): Promise { const health = await app.evaluate(async () => { const res = await fetch('/__test/health'); diff --git a/e2e/specs/queue-reorder.spec.ts b/e2e/specs/queue-reorder.spec.ts index 828d910..cd37cf7 100644 --- a/e2e/specs/queue-reorder.spec.ts +++ b/e2e/specs/queue-reorder.spec.ts @@ -31,9 +31,12 @@ async function order(app: Page): Promise { async function queueFourAndOpen(app: Page): Promise { const paths: string[] = await app.evaluate(async () => { + // One argument, and 0 means every library: the scoped and + // unscoped list queries collapsed into one when the schema did + // (plan 013 R3), so `GetTracks()` no longer exists to call. const tracks = await window.__yjEvents.call( - 'library.Library.GetAllTracks', - [], + 'library.Library.GetTracks', + [0], 10_000, ); diff --git a/e2e/specs/reduced-motion.spec.ts b/e2e/specs/reduced-motion.spec.ts index a4d9b52..8eacb70 100644 --- a/e2e/specs/reduced-motion.spec.ts +++ b/e2e/specs/reduced-motion.spec.ts @@ -57,9 +57,12 @@ async function playTheLongOne(app: Page): Promise { }); const paths: string[] = await app.evaluate(async (needle) => { + // One argument, and 0 means every library: the scoped and + // unscoped list queries collapsed into one when the schema did + // (plan 013 R3), so `GetTracks()` no longer exists to call. const tracks = await window.__yjEvents.call( - 'library.Library.GetAllTracks', - [], + 'library.Library.GetTracks', + [0], 10_000, ); diff --git a/e2e/specs/requested-badge.spec.ts b/e2e/specs/requested-badge.spec.ts index e2dd718..3d3520d 100644 --- a/e2e/specs/requested-badge.spec.ts +++ b/e2e/specs/requested-badge.spec.ts @@ -23,8 +23,16 @@ import { test, expect, callBinding } from '../support/fixtures.js'; */ /** A release group that exists whether or not this environment has a - * catalog — CI's `YJ_CORE_INDEX_URL` is deliberately dead. */ -const MBID = 'e2e-rg-badge-0001'; + * catalog — CI's `YJ_CORE_INDEX_URL` is deliberately dead. + * + * It is a real UUID because the catalog stores an MBID as its 16 raw + * bytes under `CHECK(length(mbid) = 16)`. A readable id fails that + * check, `INSERT OR IGNORE` swallows the failure, and the staging step + * below reports 200 having written nothing. */ +const MBID = 'e2ebad9e-0001-4000-8000-000000000001'; + +/** The staged album's artist, for the same reason. */ +const ARTIST_MBID = 'e2ebad9e-0002-4000-8000-000000000002'; const TITLE = 'Requested Album'; const ARTIST = 'Badge Artist'; @@ -45,18 +53,29 @@ test.describe('the requested badge', () => { sql: `INSERT OR IGNORE INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid, popularity, listener_count, primary_type) - VALUES ('release_group', ?, ?, ?, 'e2e-ar-badge', 10, 10, 'Album')`, - args: [row.mbid, row.title, row.artist], + VALUES (2 /* release_group */, + unhex(replace(?, '-', '')), ?, ?, + unhex(replace(?, '-', '')), + 10, 10, 'Album')`, + args: [row.mbid, row.title, row.artist, row.artistMbid], }), }); return { status: r.status, body: await r.text() }; }, - { mbid: MBID, title: TITLE, artist: ARTIST }, + { mbid: MBID, title: TITLE, artist: ARTIST, artistMbid: ARTIST_MBID }, ); - // A setup step whose failure is not checked is not setup. + // A setup step whose failure is not checked is not setup — and with + // `OR IGNORE` a 200 is not a write either: a CHECK the row violates + // is ignored rather than reported. `rowsAffected` is 0 on a second + // run against the same backend, so what is asserted is that the + // statement did not error. expect(res.status, `staging failed: ${res.body}`).toBe(200); + expect( + (JSON.parse(res.body) as { error?: string }).error, + `staging failed: ${res.body}`, + ).toBeUndefined(); // A previous run that died between adding and removing would leave // this album requested, and the first assertion here is that it is diff --git a/frontend/bindings/yellowjacket/backend/autotagservice/service.ts b/frontend/bindings/yellowjacket/backend/autotagservice/service.ts index 8b0159a..d43281b 100644 --- a/frontend/bindings/yellowjacket/backend/autotagservice/service.ts +++ b/frontend/bindings/yellowjacket/backend/autotagservice/service.ts @@ -11,10 +11,6 @@ // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as jobs$0 from "../jobs/models.js"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as $models from "./models.js"; @@ -197,21 +193,6 @@ export function SelectSearchCandidate(groupKey: string, kind: string, mbid: stri return $Call.ByID(2540832563, groupKey, kind, mbid); } -/** - * SetJobRegistry wires the background job registry so an apply reports - * progress and offers a cancel like every other long-running operation. - * - * Before this, apply was a bare goroutine whose progress lived in a - * component field that navigation discarded, with no cancel and no - * record of where it stopped (errors.C3). Everything routed through the - * registry gets progress, cancel and the global indicator for free; the - * three subsystems that lacked them were the three that were not - * registered. - */ -export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise { - return $Call.ByID(3755409662, reg); -} - /** * Skip marks the current group as skipped — it stays in the * queue but renders in the "Skipped" section at the bottom of diff --git a/frontend/bindings/yellowjacket/backend/download/index.ts b/frontend/bindings/yellowjacket/backend/download/index.ts index 9cbc1ec..7992699 100644 --- a/frontend/bindings/yellowjacket/backend/download/index.ts +++ b/frontend/bindings/yellowjacket/backend/download/index.ts @@ -29,7 +29,6 @@ export type { Field, MatchScore, QualityScore, - Reconciler, Request, RequestInput, SearchRequest, diff --git a/frontend/bindings/yellowjacket/backend/download/models.ts b/frontend/bindings/yellowjacket/backend/download/models.ts index 7092151..4f2a904 100644 --- a/frontend/bindings/yellowjacket/backend/download/models.ts +++ b/frontend/bindings/yellowjacket/backend/download/models.ts @@ -498,12 +498,6 @@ export interface QualityScore { "mixed": boolean; } -/** - * Reconciler works the request list. - */ -export interface Reconciler { -} - /** * Request is one row of the durable request list. */ diff --git a/frontend/bindings/yellowjacket/backend/download/service.ts b/frontend/bindings/yellowjacket/backend/download/service.ts index 41aa704..2bcd055 100644 --- a/frontend/bindings/yellowjacket/backend/download/service.ts +++ b/frontend/bindings/yellowjacket/backend/download/service.ts @@ -163,15 +163,6 @@ export function SetPreferences(prefs: $models.AutoDownloadPrefs): $CancellablePr return $Call.ByID(3789717356, prefs); } -/** - * SetReconciler wires the request-list loop. Optional: without it the - * request list still stores and lists requests, it just never acts on - * them. - */ -export function SetReconciler(r: $models.Reconciler | null): $CancellablePromise { - return $Call.ByID(2390832784, r); -} - /** * StartDownload searches for a release and either auto-picks a clear * winner or returns ranked candidates for the user to choose from. diff --git a/frontend/bindings/yellowjacket/backend/explore/index.ts b/frontend/bindings/yellowjacket/backend/explore/index.ts index ebbacfc..7c49266 100644 --- a/frontend/bindings/yellowjacket/backend/explore/index.ts +++ b/frontend/bindings/yellowjacket/backend/explore/index.ts @@ -23,8 +23,6 @@ export type { MBReleaseGroup, MBSearchResult, MBTrack, - MusicBrainzClient, - RateLimiter, Shelf, ShelfPage, ThumbnailRequest, diff --git a/frontend/bindings/yellowjacket/backend/explore/models.ts b/frontend/bindings/yellowjacket/backend/explore/models.ts index 74d22ef..3693835 100644 --- a/frontend/bindings/yellowjacket/backend/explore/models.ts +++ b/frontend/bindings/yellowjacket/backend/explore/models.ts @@ -86,7 +86,7 @@ export interface LBTopReleaseGroup { * into the camelCase shape the frontend consumes. */ export interface LyricsResult { - "recordingId": number; + "audioFileId": number; "filePath": string; "lengthMs": number; "title": string; @@ -228,6 +228,16 @@ export interface MBReleaseGroup { * local release_group row ID */ "localId"?: number; + + /** + * TotalTracks is the catalog's track count for this release group, + * or 0 for "the catalog does not say". It answers "how much of + * this album do I have" for an album whose files declared no total + * -- the case GetAlbumCompleteness cannot answer -- and it is not + * filled by the MusicBrainz path below, which has the real + * tracklist and does not need a denominator. + */ + "totalTracks": number; } /** @@ -254,38 +264,6 @@ export interface MBTrack { "localId"?: number; } -/** - * MusicBrainzClient wraps the musicbrainzws2 library with a local - * response cache. Every API call checks the cache first and stores - * successful responses for future hits. - * - * A proactive rate limiter gates all outgoing requests at 1 req/sec - * to avoid triggering MusicBrainz 429 responses. The underlying - * musicbrainzws2.Client still retries on 429 as a safety net, but - * the limiter should prevent most rate-limit hits. - */ -export interface MusicBrainzClient { -} - -/** - * RateLimiter enforces a maximum request rate using a token bucket. - * MusicBrainz requires ≤1 request per second and rejects ALL - * requests (not just excess) when the rate is exceeded, so callers - * block proactively via Wait rather than retrying reactively. - * - * A limiter may carry a second, slower **background lane** (see - * WithBackgroundLane). A caller marked by WithBackgroundPriority is - * paced by that lane *and* yields to interactive callers: while any - * interactive Wait is outstanding, background waits do not take a - * token at all. This is what keeps a multi-thousand-request backfill - * from putting the album page the user is looking at right now behind - * hours of queued work. - * - * RateLimiter is safe for concurrent use. - */ -export interface RateLimiter { -} - /** * Shelf is one horizontal row on the Explore page. * diff --git a/frontend/bindings/yellowjacket/backend/explore/service.ts b/frontend/bindings/yellowjacket/backend/explore/service.ts index 9dd4aab..b04bcdc 100644 --- a/frontend/bindings/yellowjacket/backend/explore/service.ts +++ b/frontend/bindings/yellowjacket/backend/explore/service.ts @@ -17,9 +17,6 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as time$0 from "../../../time/models.js"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as jobs$0 from "../jobs/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports @@ -93,14 +90,6 @@ export function BrowseReleases(releaseGroupMBID: string): $CancellablePromise<$m return $Call.ByID(2551207897, releaseGroupMBID); } -/** - * CAALimiter returns the shared Cover Art Archive rate limiter. - * Consumers must respect it for any fresh CAA HTTP GETs. - */ -export function CAALimiter(): $CancellablePromise<$models.RateLimiter | null> { - return $Call.ByID(1239092428); -} - /** * CheckLibraryMBIDs returns which of the given MBIDs exist in the * local music library. Returns a map of MBID → entity type @@ -293,14 +282,14 @@ export function GetThumbnails(requests: $models.ThumbnailRequest[] | null): $Can } /** - * GetTrackLyrics returns lyrics for a recording. If the library + * GetTrackLyrics returns lyrics for a file. If the library * already has them (from embedded tags) they're returned as-is; * otherwise it fetches from LRCLIB, persists them (updating the FTS * index), and returns them. Never returns an error to the frontend — * a miss just yields an empty result. */ -export function GetTrackLyrics(recordingID: number): $CancellablePromise<$models.TrackLyrics> { - return $Call.ByID(1131284622, recordingID); +export function GetTrackLyrics(audioFileID: number): $CancellablePromise<$models.TrackLyrics> { + return $Call.ByID(1131284622, audioFileID); } /** @@ -396,14 +385,6 @@ export function LookupReleaseGroup(mbid: string): $CancellablePromise<$models.MB return $Call.ByID(2946174711, mbid); } -/** - * MusicBrainz returns the shared cached MB client so other services - * (e.g. autotag) can reuse it without spinning up a second limiter. - */ -export function MusicBrainz(): $CancellablePromise<$models.MusicBrainzClient | null> { - return $Call.ByID(3453528034); -} - /** * PopulateLocalCrossReferences updates the local_*_id columns on * explore_index after a library scan. @@ -542,14 +523,6 @@ export function SetAlbumComplete(fn: $models.AlbumCompleteFunc): $CancellablePro return $Call.ByID(942474493, fn); } -/** - * SetJobRegistry wires the background job registry into the search - * index so its build reports progress and controls to the frontend. - */ -export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise { - return $Call.ByID(4291900709, reg); -} - /** * SimilarArtists returns artists similar to the given artist MBID. */ diff --git a/frontend/bindings/yellowjacket/backend/jobs/index.ts b/frontend/bindings/yellowjacket/backend/jobs/index.ts index 667a2e0..c23c37d 100644 --- a/frontend/bindings/yellowjacket/backend/jobs/index.ts +++ b/frontend/bindings/yellowjacket/backend/jobs/index.ts @@ -16,7 +16,6 @@ export type { Caps, Job, LogEntry, - Registry, Stage, Stat } from "./models.js"; diff --git a/frontend/bindings/yellowjacket/backend/jobs/models.ts b/frontend/bindings/yellowjacket/backend/jobs/models.ts index 6bbbb7f..287fceb 100644 --- a/frontend/bindings/yellowjacket/backend/jobs/models.ts +++ b/frontend/bindings/yellowjacket/backend/jobs/models.ts @@ -103,13 +103,6 @@ export interface LogEntry { "detail"?: string; } -/** - * Registry owns every known job and pushes coalesced snapshots to the - * frontend. It is safe for concurrent use. - */ -export interface Registry { -} - /** * Stage is one named sub-step of a multi-stage job, such as an index * build tier. Jobs with a single linear phase leave Stages empty. diff --git a/frontend/bindings/yellowjacket/backend/library/index.ts b/frontend/bindings/yellowjacket/backend/library/index.ts index 04d14f2..d18a806 100644 --- a/frontend/bindings/yellowjacket/backend/library/index.ts +++ b/frontend/bindings/yellowjacket/backend/library/index.ts @@ -12,12 +12,9 @@ export type { Artist, GenreWithCount, Info, - RemovalHooks, RemovalImpact, RemovalResult, RemovalSummary, - RescanHooks, - ScanHooks, ScanMetrics, ScanWarning, Track, diff --git a/frontend/bindings/yellowjacket/backend/library/library.ts b/frontend/bindings/yellowjacket/backend/library/library.ts index f5157ca..7d15a11 100644 --- a/frontend/bindings/yellowjacket/backend/library/library.ts +++ b/frontend/bindings/yellowjacket/backend/library/library.ts @@ -13,24 +13,11 @@ import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wails // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as sqlcgen$0 from "../database/sql/sqlcgen/models.js"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as jobs$0 from "../jobs/models.js"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as $models from "./models.js"; -/** - * AcquirePipelineLock acquires the pipeline mutex for a tag write - * operation. The caller must call ReleasePipelineLock when done. - * If a scan is currently in progress, AcquirePipelineLock blocks - * until it completes (and vice versa). - */ -export function AcquirePipelineLock(): $CancellablePromise { - return $Call.ByID(2056761494); -} - /** * AddLibrary creates a new library from a directory path, emits a * LibraryAdded event, and starts an asynchronous scan. @@ -82,12 +69,6 @@ export function FullRescan(): $CancellablePromise<$models.ScanMetrics | null> { * GetAlbumCompleteness answers "do I have all of this album" from the * tags read at scan time, with no network. * - * The album page used to ask MusicBrainz, because the only track total - * it had was the length of whatever tracklist it was already showing — - * which for a library copy is a tautology. The denominator in a file's - * "5/12" is a real answer and it is already on disk; this is where it - * gets read. - * * Complete is deliberately >= rather than ==: bonus and hidden tracks * routinely put a folder over its declared total, and that is a * complete album, not a broken one. @@ -97,99 +78,38 @@ export function GetAlbumCompleteness(albumID: number): $CancellablePromise<$mode } /** - * GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number. + * GetAlbumTracks returns one album's tracks in disc/track order. */ -export function GetAlbumTracks(albumID: number): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(300451334, albumID); +export function GetAlbumTracks(albumID: number, libraryID: number): $CancellablePromise<$models.Track[] | null> { + return $Call.ByID(300451334, albumID, libraryID); } /** - * GetAlbumTracksByLibrary returns tracks for the given album, - * scoped to the given library. + * GetAlbums returns every album, or those with a file in one library. */ -export function GetAlbumTracksByLibrary(albumID: number, libraryID: number): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(3304485554, albumID, libraryID); +export function GetAlbums(libraryID: number): $CancellablePromise<$models.Album[] | null> { + return $Call.ByID(2870789667, libraryID); } /** - * GetAlbumsByArtist returns all albums where the given artist is the album artist. + * GetAlbumsByArtist returns the albums credited to an artist by name. */ -export function GetAlbumsByArtist(artistID: number): $CancellablePromise<$models.Album[] | null> { - return $Call.ByID(1456840721, artistID); +export function GetAlbumsByArtist(artist: string, libraryID: number): $CancellablePromise<$models.Album[] | null> { + return $Call.ByID(1456840721, artist, libraryID); } /** - * GetAlbumsByArtistByLibrary returns albums for the given artist - * that have tracks in the given library. - */ -export function GetAlbumsByArtistByLibrary(artistID: number, libraryID: number): $CancellablePromise<$models.Album[] | null> { - return $Call.ByID(2809291, artistID, libraryID); -} - -/** - * GetAllAlbums returns all albums with cover art and artist info for the cover grid. - */ -export function GetAllAlbums(): $CancellablePromise<$models.Album[] | null> { - return $Call.ByID(2015458954); -} - -/** - * GetAllAlbumsByLibrary returns albums that have tracks in the given library. - */ -export function GetAllAlbumsByLibrary(libraryID: number): $CancellablePromise<$models.Album[] | null> { - return $Call.ByID(4023050470, libraryID); -} - -/** - * GetAllArtists returns artists that are credited as album artists, ordered by name. - */ -export function GetAllArtists(): $CancellablePromise<$models.Artist[] | null> { - return $Call.ByID(2529088294); -} - -/** - * GetAllArtistsByLibrary returns artists that have albums with tracks - * in the given library. - */ -export function GetAllArtistsByLibrary(libraryID: number): $CancellablePromise<$models.Artist[] | null> { - return $Call.ByID(1170594642, libraryID); -} - -/** - * GetAllGenresWithCounts returns all genres with their track counts. - */ -export function GetAllGenresWithCounts(): $CancellablePromise<$models.GenreWithCount[] | null> { - return $Call.ByID(602231298); -} - -/** - * GetAllGenresWithCountsByLibrary returns genres with track counts - * scoped to the given library. - */ -export function GetAllGenresWithCountsByLibrary(libraryID: number): $CancellablePromise<$models.GenreWithCount[] | null> { - return $Call.ByID(772684334, libraryID); -} - -/** - * GetAllLibrariesWithTrackCounts returns all libraries with their - * audio file counts. Typically 1-5 libraries so the loop is trivial. + * GetAllLibrariesWithTrackCounts lists the libraries and their sizes. */ export function GetAllLibrariesWithTrackCounts(): $CancellablePromise<$models.Info[] | null> { return $Call.ByID(3420301148); } /** - * GetAllTracks returns an array of track structs of every file in the library. + * GetArtists returns the album artists in a library. */ -export function GetAllTracks(): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(2991050010); -} - -/** - * GetAllTracksByLibrary returns tracks scoped to a specific library. - */ -export function GetAllTracksByLibrary(libraryID: number): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(3882999766, libraryID); +export function GetArtists(libraryID: number): $CancellablePromise<$models.Artist[] | null> { + return $Call.ByID(2231116965, libraryID); } /** @@ -200,8 +120,8 @@ export function GetAllTracksByLibrary(libraryID: number): $CancellablePromise<$m * resolved paths with one binding call per album, sequentially, and each * asked for whole track rows to read one field off them (perf.m2). This * is that question asked once. The result is grouped rather than - * flattened because the caller owns the ordering — an album list is - * sorted by name, not by id — and because the drag cache stores it per + * flattened because the caller owns the ordering - an album list is + * sorted by name, not by id - and because the drag cache stores it per * album. * * A library id of 0 means "every library", matching the caller's @@ -212,36 +132,33 @@ export function GetFilePathsByAlbums(albumIDs: number[] | null, libraryID: numbe } /** - * GetFilePathsByGenres returns the file paths of every track tagged with - * the given genres, grouped by genre name. See GetFilePathsByAlbums — - * same finding, same shape, and the caller still owns the de-duplication - * across genres because it owns the order. + * GetFilePathsByGenres returns file paths grouped by genre name. */ export function GetFilePathsByGenres(genreNames: string[] | null, libraryID: number): $CancellablePromise<{ [_ in string]?: string[] | null } | null> { return $Call.ByID(1180707302, genreNames, libraryID); } /** - * GetFilePathsByRecordingMBIDs returns the file paths of every track - * whose recording MBID is in mbids, grouped by MBID. + * GetFilePathsByRecordingMBIDs answers "which of these catalog + * recordings do I actually have a file for", grouped by MBID. * - * This is the catalog side of GetFilePathsByAlbums. An Explore album - * page knows what the user owns as a set of recording MBIDs and nothing - * else: that is exactly how the backend decides a track's InLibrary - * flag (markReleasesInLibrary → CheckMBIDs), and MBTrack.LocalID is a - * declared field that nothing writes, so there is no id to ask by. - * - * Grouped rather than flattened for the same two reasons as its - * siblings — the caller owns the order (the tracklist's, not the - * database's), and one recording can have more than one file, which is - * what this app's duplicate detection exists for. - * - * A library id of 0 means "every library". + * It asks audio_files, which is the only table whose rows are files. + * The version of this question that asked the metadata tables said yes + * for 129 tracks in a real library that had no file at all - a + * retagged file left its old recording row behind, the catalog matched + * it, and every action on the row then failed. */ export function GetFilePathsByRecordingMBIDs(mbids: string[] | null, libraryID: number): $CancellablePromise<{ [_ in string]?: string[] | null } | null> { return $Call.ByID(2789061644, mbids, libraryID); } +/** + * GetGenres returns every genre with its track count. + */ +export function GetGenres(libraryID: number): $CancellablePromise<$models.GenreWithCount[] | null> { + return $Call.ByID(2817241511, libraryID); +} + /** * GetRemovalImpact returns pre-removal counts for the confirmation * dialog. All queries are read-only. @@ -259,26 +176,30 @@ export function GetScanQueueLength(): $CancellablePromise { } /** - * GetTrackMBIDs returns the MusicBrainz IDs for the track at the - * given file path. Returns empty strings for entities without MBIDs. + * GetTrackMBIDs returns the MusicBrainz ids for one file. */ export function GetTrackMBIDs(filePath: string): $CancellablePromise<$models.TrackMBIDs> { return $Call.ByID(56752473, filePath); } /** - * GetTracksByGenre returns all tracks tagged with the given genre. + * GetTracks returns every track in a library, or in all of them when + * libraryID is 0. + * + * The library id is a parameter rather than a second method because the + * two used to be separate queries, separate bindings and a branch at + * every call site - and the scoped form costs nothing (measured: 23 ms + * against 21 ms over 26k rows). */ -export function GetTracksByGenre(genreName: string): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(1674220245, genreName); +export function GetTracks(libraryID: number): $CancellablePromise<$models.Track[] | null> { + return $Call.ByID(933082923, libraryID); } /** - * GetTracksByGenreByLibrary returns tracks tagged with the given - * genre, scoped to the given library. + * GetTracksByGenre returns every track carrying a genre. */ -export function GetTracksByGenreByLibrary(genreName: string, libraryID: number): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(4240564783, genreName, libraryID); +export function GetTracksByGenre(genre: string, libraryID: number): $CancellablePromise<$models.Track[] | null> { + return $Call.ByID(1674220245, genre, libraryID); } /** @@ -318,13 +239,6 @@ export function QueuedLibraryNames(): $CancellablePromise { return $Call.ByID(2036951097); } -/** - * ReleasePipelineLock releases the pipeline mutex after a tag write. - */ -export function ReleasePipelineLock(): $CancellablePromise { - return $Call.ByID(2053843147); -} - /** * RemoveFromLibrary deletes the database rows for the given file paths * and records each path as excluded, so the next scan does not import @@ -392,51 +306,10 @@ export function ScanLibrary(id: number): $CancellablePromise { } /** - * SearchTracks performs an FTS5 full-text search and returns - * matching tracks with full metadata. + * SearchTracks runs the library's FTS index and returns whole tracks. */ -export function SearchTracks(query: string): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(3848515709, query); -} - -/** - * SearchTracksByLibrary performs an FTS5 search scoped to a specific - * library and returns matching tracks with full metadata. - */ -export function SearchTracksByLibrary(query: string, libraryID: number): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(152384327, query, libraryID); -} - -/** - * SetJobRegistry wires the background job registry so scans report - * progress, logs, and pause/cancel controls to the frontend. - */ -export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise { - return $Call.ByID(4271773525, reg); -} - -/** - * SetRemovalHooks provides optional hooks for cross-cutting - * orchestration during RemoveLibrary. - */ -export function SetRemovalHooks(h: $models.RemovalHooks): $CancellablePromise { - return $Call.ByID(3190933207, h); -} - -/** - * SetRescanHooks provides optional hooks for cross-cutting - * orchestration during FullRescan. - */ -export function SetRescanHooks(h: $models.RescanHooks): $CancellablePromise { - return $Call.ByID(1944064333, h); -} - -/** - * SetScanHooks provides optional hooks for cross-cutting - * orchestration after each library scan. - */ -export function SetScanHooks(h: $models.ScanHooks): $CancellablePromise { - return $Call.ByID(520513414, h); +export function SearchTracks(query: string, libraryID: number): $CancellablePromise<$models.Track[] | null> { + return $Call.ByID(3848515709, query, libraryID); } /** diff --git a/frontend/bindings/yellowjacket/backend/library/models.ts b/frontend/bindings/yellowjacket/backend/library/models.ts index 3f11eca..f2c9987 100644 --- a/frontend/bindings/yellowjacket/backend/library/models.ts +++ b/frontend/bindings/yellowjacket/backend/library/models.ts @@ -8,11 +8,10 @@ import * as time$0 from "../../../time/models.js"; /** * Album represents an album for the cover grid display. * - * Year is the album's preferred display year — the release-group's - * original-release-date (MusicBrainz first-release-date) when known, - * falling back to the file-tag year. ReleaseYear is the file-tag - * year of the specific release in the library; for a 2010 remaster - * of a 1973 album, Year=1973 and ReleaseYear=2010. + * Year is the album's preferred display year - MusicBrainz's + * first-release-date when known, falling back to the file-tag year. + * ReleaseYear is the file-tag year of the specific copy in the library; + * for a 2010 remaster of a 1973 album, Year=1973 and ReleaseYear=2010. */ export interface Album { "ID": number; @@ -58,7 +57,7 @@ export interface Artist { } /** - * GenreWithCount holds a genre name and its associated track count. + * GenreWithCount is a genre and how many tracks carry it. */ export interface GenreWithCount { "Name": string; @@ -66,8 +65,7 @@ export interface GenreWithCount { } /** - * Info contains library metadata enriched with track count - * for the frontend settings UI. + * Info is one library and how many files are in it. */ export interface Info { "id": number; @@ -76,29 +74,6 @@ export interface Info { "trackCount": number; } -/** - * RemovalHooks contains callbacks invoked during library removal. - * These break circular dependencies between the library, player, - * and queue packages. - */ -export interface RemovalHooks { - /** - * StopPlayback stops the currently-playing track. - */ - "StopPlayback": any; - - /** - * CompactQueue reloads queue state after cascade deletes. - */ - "CompactQueue": any; - - /** - * PostRemove runs after the removal commits, for cross-cutting - * invalidation (e.g. clearing library-sync "ready" markers). - */ - "PostRemove": any; -} - /** * RemovalImpact contains pre-removal counts for the confirmation dialog. */ @@ -136,53 +111,6 @@ export interface RemovalSummary { "queueItemsRemoved": number; } -/** - * RescanHooks holds optional callbacks that run before and after - * the library-clear-and-scan phase of a full rescan. The app - * layer sets these to coordinate cross-cutting concerns (e.g. - * clearing the queue, restoring playlists) without the library - * needing to know about those packages. - */ -export interface RescanHooks { - /** - * PreClear runs before library data is wiped - * (e.g. clear queue and stop playback). - */ - "PreClear": any; - - /** - * PostScan runs after the scan completes - * (e.g. restore playlists from M3U8 files). - */ - "PostScan": any; -} - -/** - * ScanHooks contains callbacks invoked after a library scan - * completes. The app layer wires these so the library package - * does not depend on the playlist package directly. - */ -export interface ScanHooks { - /** - * RepopulatePlaylists re-imports tracks for playlists that - * lost their playlist_tracks rows (e.g., from a pre-fix - * FullRescan). Runs before ResolvePhantoms. - */ - "RepopulatePlaylists": any; - - /** - * ResolvePhantoms re-links phantom playlist tracks whose - * files now exist in the library after scanning. - */ - "ResolvePhantoms": any; - - /** - * OnAllScansComplete runs after ALL queued scans finish - * (queue drained). - */ - "OnAllScansComplete": any; -} - /** * ScanMetrics holds timing and count data collected during a library scan. * Worker-pool fields are protected by a mutex; DB-writer fields are @@ -198,7 +126,6 @@ export interface ScanMetrics { "extractionWallClock": time$0.Duration; "dbWritesWallClock": time$0.Duration; "orphanCleanup": time$0.Duration; - "postScanVariants": time$0.Duration; /** * Per-format extraction (cumulative across workers). @@ -274,7 +201,7 @@ export interface ScanWarning { } /** - * Track represents a playable audio file in the library. + * Track is one audio file with everything a list needs to draw it. */ export interface Track { "TrackName": string; @@ -305,8 +232,7 @@ export interface Track { } /** - * TrackMBIDs holds MusicBrainz identifiers for a track, resolved - * from the recording, release group, and artist tables. + * TrackMBIDs are the MusicBrainz ids a file's tags carry. */ export interface TrackMBIDs { "recordingMbid": string; diff --git a/frontend/bindings/yellowjacket/backend/mediacontrols/index.ts b/frontend/bindings/yellowjacket/backend/mediacontrols/index.ts deleted file mode 100644 index dd70b92..0000000 --- a/frontend/bindings/yellowjacket/backend/mediacontrols/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export type { - Handler -} from "./models.js"; diff --git a/frontend/bindings/yellowjacket/backend/mediacontrols/models.ts b/frontend/bindings/yellowjacket/backend/mediacontrols/models.ts deleted file mode 100644 index 935ac3c..0000000 --- a/frontend/bindings/yellowjacket/backend/mediacontrols/models.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -/** - * Handler manages the OS media control integration. - */ -export type Handler = any; diff --git a/frontend/bindings/yellowjacket/backend/player/player.ts b/frontend/bindings/yellowjacket/backend/player/player.ts index 4b96a0e..aa95f8e 100644 --- a/frontend/bindings/yellowjacket/backend/player/player.ts +++ b/frontend/bindings/yellowjacket/backend/player/player.ts @@ -14,10 +14,6 @@ // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as mediacontrols$0 from "../mediacontrols/models.js"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as $models from "./models.js"; @@ -138,24 +134,6 @@ export function Seek(targetSeconds: number): $CancellablePromise { return $Call.ByID(829056707, targetSeconds); } -/** - * SetMediaControls provides an OS media controls handler. When set, - * the player pushes metadata, playback state, volume, and seek - * notifications to the OS media overlay. - */ -export function SetMediaControls(h: mediacontrols$0.Handler): $CancellablePromise { - return $Call.ByID(1896286877, h); -} - -/** - * SetPlaybackFinishedHandler sets a callback invoked when a track - * finishes naturally. This allows the queue to drive auto-advance - * without circular imports. - */ -export function SetPlaybackFinishedHandler(handler: any): $CancellablePromise { - return $Call.ByID(2023377546, handler); -} - /** * SetVolume sets the playback volume (0-100), emits a * VolumeChanged event, and persists the new level. diff --git a/frontend/bindings/yellowjacket/backend/playlist/index.ts b/frontend/bindings/yellowjacket/backend/playlist/index.ts index a91e6ce..3aee51a 100644 --- a/frontend/bindings/yellowjacket/backend/playlist/index.ts +++ b/frontend/bindings/yellowjacket/backend/playlist/index.ts @@ -10,7 +10,6 @@ export type { CandidateTrack, DuplicateCheckResult, DuplicateTrackInfo, - FavoritesConfigProvider, PhantomMatch, PhantomSearchResult, Summary, diff --git a/frontend/bindings/yellowjacket/backend/playlist/models.ts b/frontend/bindings/yellowjacket/backend/playlist/models.ts index b42bd03..0969f3b 100644 --- a/frontend/bindings/yellowjacket/backend/playlist/models.ts +++ b/frontend/bindings/yellowjacket/backend/playlist/models.ts @@ -35,12 +35,6 @@ export interface DuplicateTrackInfo { "Duration": string; } -/** - * FavoritesConfigProvider is a narrow interface for reading and - * writing the default-playlist configuration. - */ -export type FavoritesConfigProvider = any; - /** * PhantomMatch represents a high-confidence pairing of a phantom * track to a library track. diff --git a/frontend/bindings/yellowjacket/backend/playlist/service.ts b/frontend/bindings/yellowjacket/backend/playlist/service.ts index cac9f7d..d3cde05 100644 --- a/frontend/bindings/yellowjacket/backend/playlist/service.ts +++ b/frontend/bindings/yellowjacket/backend/playlist/service.ts @@ -309,14 +309,6 @@ export function SearchLibrary(query: string): $CancellablePromise<$models.Candid return $Call.ByID(3912116995, query); } -/** - * SetFavoritesConfig sets the provider used to read and write - * the default-playlist configuration. - */ -export function SetFavoritesConfig(provider: $models.FavoritesConfigProvider): $CancellablePromise { - return $Call.ByID(2117418507, provider); -} - /** * ToggleDefaultPlaylistTrack adds or removes a single track * from the default playlist. Returns true if the track is now diff --git a/frontend/bindings/yellowjacket/backend/queue/index.ts b/frontend/bindings/yellowjacket/backend/queue/index.ts index 308c4e6..2134e4a 100644 --- a/frontend/bindings/yellowjacket/backend/queue/index.ts +++ b/frontend/bindings/yellowjacket/backend/queue/index.ts @@ -11,9 +11,7 @@ export { } from "./models.js"; export type { - FallbackSource, Source, State, - Track, - TrackLoader + Track } from "./models.js"; diff --git a/frontend/bindings/yellowjacket/backend/queue/models.ts b/frontend/bindings/yellowjacket/backend/queue/models.ts index 8c45745..87a8ca7 100644 --- a/frontend/bindings/yellowjacket/backend/queue/models.ts +++ b/frontend/bindings/yellowjacket/backend/queue/models.ts @@ -1,14 +1,6 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT -/** - * FallbackSource resolves what should auto-play, if anything, once the - * queue is exhausted. Implemented outside this package (see app.go) so - * the queue does not need to know about config, playlists or - * similarity data. - */ -export type FallbackSource = any; - /** * RepeatMode represents the queue repeat behavior. */ @@ -65,8 +57,3 @@ export interface Track { "releaseGroupMbid": string; "recordingMbid": string; } - -/** - * TrackLoader is the interface the queue uses to tell the player to load a file. - */ -export type TrackLoader = any; diff --git a/frontend/bindings/yellowjacket/backend/queue/queue.ts b/frontend/bindings/yellowjacket/backend/queue/queue.ts index 873ec28..ee9bc23 100644 --- a/frontend/bindings/yellowjacket/backend/queue/queue.ts +++ b/frontend/bindings/yellowjacket/backend/queue/queue.ts @@ -180,22 +180,6 @@ export function SaveState(): $CancellablePromise { return $Call.ByID(2913531465); } -/** - * SetFallbackSource provides the queue with what to auto-play, if - * anything, once it runs out. A nil source (the default) leaves - * today's behavior: the queue just goes idle. - */ -export function SetFallbackSource(fs: $models.FallbackSource): $CancellablePromise { - return $Call.ByID(1344903240, fs); -} - -/** - * SetPlayer provides the queue with a reference to the player for auto-advance. - */ -export function SetPlayer(player: $models.TrackLoader): $CancellablePromise { - return $Call.ByID(2806250342, player); -} - /** * SetQueue replaces the entire queue with new tracks and starts playing. * When shuffleStart is true and shuffle mode is active, a random first diff --git a/frontend/src/components/artist-details/artist-details.ts b/frontend/src/components/artist-details/artist-details.ts index 0a8039f..f360224 100644 --- a/frontend/src/components/artist-details/artist-details.ts +++ b/frontend/src/components/artist-details/artist-details.ts @@ -250,7 +250,7 @@ export class ArtistDetails extends LitElement { try { const albums = await this.libraryCtrl.getAlbumsByArtist( - this.artistId, + this.artistName, ); const result = albums ?? []; diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index 23bc1b9..dba2319 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -12,7 +12,6 @@ import type { import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { GetAlbumsByArtist, - GetAlbumsByArtistByLibrary, GetFilePathsByAlbums, } from '@go/library/library.js'; import * as library from '@go/library/models.js'; @@ -1057,12 +1056,7 @@ export class ArtistsView this.libraryCtrl.selectedLibraryId; const albums = await list( - libId !== null - ? GetAlbumsByArtistByLibrary( - artist.ID, - libId, - ) - : GetAlbumsByArtist(artist.ID), + GetAlbumsByArtist(artist.Name, libId ?? 0), ); const byAlbum = await dict( diff --git a/frontend/src/components/autotag-view/autotag-view.ts b/frontend/src/components/autotag-view/autotag-view.ts index 2d4db90..405bfd3 100644 --- a/frontend/src/components/autotag-view/autotag-view.ts +++ b/frontend/src/components/autotag-view/autotag-view.ts @@ -1034,6 +1034,39 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { font-size: 0.9rem; } + /* The "nothing to tag" state. A finished queue is the + normal resting state of this page on a tagged library, + not a failure, so it gets a settled look rather than + the bare sentence the other .empty slots use. */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + text-align: center; + padding: 4rem 1.5rem; + color: var(--yj-text-secondary, #b3b3b3); + } + + .empty-state wa-icon { + font-size: 2.5rem; + color: var(--yj-text-tertiary, #888); + } + + .empty-state h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; + color: var(--yj-text-primary, #f1f3f5); + } + + .empty-state p { + margin: 0; + max-width: 34ch; + font-size: 0.9rem; + line-height: 1.5; + } + .error { background: rgba(200, 90, 90, 0.15); color: #f99; @@ -2918,6 +2951,33 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { `; } + /** The queue came back with nothing in it — every folder the + * scan found already carries tags. This is the resting state + * of the page on a tagged library, so it says so in words + * rather than leaving the skeleton up: an endless shimmer reads + * as a page that is still working. */ + private renderEmptyQueue(): TemplateResult { + const filtered = this.currentLibraryFilter !== null; + + return html` +
+
+ +

Nothing to tag

+

+ ${filtered + ? html`No untagged files in the selected library. + Switch the library filter, or add new music + and it will appear here after the next scan.` + : html`No untagged files. Add new music to your + library and it will appear here after the + next scan.`} +

+
+
+ `; + } + private renderMain() { // A scoring error on the selected folder surfaces as an error, // not an endless skeleton. @@ -2939,14 +2999,29 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) { } if (!this.current) { + // A folder list that failed to load is not an empty + // queue. Without this the one message the page cannot + // honestly show — "there is nothing to tag" — is exactly + // what a failed ListPendingFolders renders. + if (this.folders.length === 0 && this.errorMessage) { + return html` +
+
+ ${this.errorMessage} + +
+
+ `; + } + + if (this.folders.length === 0) { + return this.renderEmptyQueue(); + } + return html`
- ${this.folders.length === 0 - ? (this.currentLibraryFilter !== null - ? 'No pending folders in the selected library. Switch the library filter or scan to find untagged albums.' - : 'No pending folders. Untagged albums appear here after a library scan.') - : 'Pick a folder from the list on the left to review.'} + Pick a folder from the list on the left to review.
`; diff --git a/frontend/src/components/cover-grid/album-selection.ts b/frontend/src/components/cover-grid/album-selection.ts index 6e6175c..1cd66c5 100644 --- a/frontend/src/components/cover-grid/album-selection.ts +++ b/frontend/src/components/cover-grid/album-selection.ts @@ -1,6 +1,5 @@ import { GetAlbumTracks, - GetAlbumTracksByLibrary, GetFilePathsByAlbums, } from '@go/library/library.js'; import { libraryStore } from '@store/library-store'; @@ -59,11 +58,7 @@ export class AlbumSelectionManager { const libId = libraryStore.getSelectedLibraryId(); - return list( - libId !== null - ? GetAlbumTracksByLibrary(albumId, libId) - : GetAlbumTracks(albumId), - ); + return list(GetAlbumTracks(albumId, libId ?? 0)); } /** diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 4dbf4b9..32fe657 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -13,7 +13,6 @@ import type { import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { GetAlbumTracks, - GetAlbumTracksByLibrary, } from '@go/library/library.js'; import * as library from '@go/library/models.js'; import { LibraryController } from '@store/controllers/library-controller'; @@ -935,12 +934,7 @@ export class CoverGrid this.libraryCtrl.selectedLibraryId; const tracks = await list( - libId !== null - ? GetAlbumTracksByLibrary( - album.ID, - libId, - ) - : GetAlbumTracks(album.ID), + GetAlbumTracks(album.ID, libId ?? 0), ); if (this.expandedAlbumId === album.ID) { @@ -1494,6 +1488,33 @@ export class CoverGrid switch (action) { case 'play': + // One track row is a position in the expanded album, so + // it queues that album from there - the same thing + // double-clicking the row does. Anything else (several + // rows, or an album card) is already an explicit choice + // of exactly what to play. + if ( + this.contextMenuTarget.kind === 'track' && + filePaths.length === 1 + ) { + const start = this.expandedTracks.findIndex( + (t) => t.FilePath === filePaths[0], + ); + + if (start >= 0) { + queueStore.setQueue( + this.expandedTracks.map( + (t) => t.FilePath, + ), + start, + false, + source, + ); + + break; + } + } + queueStore.setQueue(filePaths, 0, true, source); break; case 'add-to-queue': diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index e5da042..6682375 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -10,7 +10,6 @@ import { import { GetAlbumTracks, GetAlbumCompleteness, - GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs, } from '@go/library/library.js'; import * as library from '@go/library/models.js'; @@ -47,7 +46,10 @@ import type { ContextMenuHost } from '@utils/context-menu-controller.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js'; import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js'; -import { dict, dictByName } from '@utils/binding'; +import { dictByName } from '@utils/binding'; +import type { TrackDetails } from '@components/track-details/track-details.js'; +import { showTrackDetailsForPath } from '@utils/track-details-opener.js'; +import '@components/playlist-picker/playlist-picker.js'; /** * The region the album header's own failures are rendered in. @@ -199,6 +201,35 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { */ @state() private localTracks: MBTrack[] = []; + /** + * The file behind each displayed track, resolved once when the + * tracklist settles rather than per click. + * + * This is the page's one answer to "do I own this". It used to be + * asked three different ways — a local album id, the backend's + * cross-reference, a cached MBID match, or *any* track flagged + * inLibrary — none of which is "there is a file", and then answered + * a fourth way at the moment the user clicked something. So a row + * could render owned, offer Play, and fail; on a real library 129 + * catalog rows were in exactly that state. + * + * A path here means the track plays. Nothing else on this page is + * allowed to mean it. + */ + @state() private filePaths = new Map(); + + /** + * Which MBIDs have been *asked* about, which is not the same as + * which resolved. + * + * A track the library does not have never lands in `filePaths`, so + * a guard keyed on the answer asks about it again on every render — + * an unbounded query loop for exactly the tracks the user does not + * own. This is not `@state`: it records work done, and changing it + * must not schedule a render. + */ + private askedFor = new Set(); + /** Open state of the "find this album" dialog. */ @state() private pickerOpen = false; @@ -231,17 +262,20 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { @query('#track-context-menu') private contextMenuPopup!: WaPopup; + @query('#playlist-submenu') + private playlistSubmenuPopup?: WaPopup; + + @query('track-details') + private trackDetailsDialog?: TrackDetails; + // -- ContextMenuHost interface -- - // No playlist submenu on this page — every action here resolves a - // single track's file lazily by MBID, and the submenu exists for a - // caller that already has file paths in hand. getContextMenuPopup(): WaPopup | undefined { return this.contextMenuPopup; } getPlaylistSubmenuPopup(): WaPopup | undefined { - return undefined; + return this.playlistSubmenuPopup; } onContextMenuClose(): void { @@ -763,6 +797,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { private hasScrolledToHighlight = false; override updated() { + // Whatever is displayed needs its files known, and the + // tracklist can change from four directions - the local + // hydrate, the catalog browse, the cluster build, the version + // dropdown. Asking here covers all of them; resolveFilePaths + // returns immediately once every displayed MBID is in the map, + // so this settles after one pass. + void this.resolveFilePaths(); + if ( (this.highlightTrackMBID || this.highlightTrackTitle) && !this.hasScrolledToHighlight && @@ -860,6 +902,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { this.versionEntries = []; this.selectedVersionKey = ''; this.localTracks = []; + this.filePaths = new Map(); + this.askedFor = new Set(); // Local-only album (no MBID) — populate entirely from library. if (!mbid && this.localAlbumId) { @@ -935,7 +979,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { /** * Hydrate album info and tracklist from the local library store. - * Uses GetAlbumTracks(album.ID) — same local DB call as cover-grid. + * Uses GetAlbumTracks(album.ID, libraryStore.libraryFilter()) — same local DB call as cover-grid. * Returns true if a tracklist was populated from local data. */ /** @@ -971,7 +1015,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { // Fetch tracks via the same local DB call the cover-grid uses. let tracks: Awaited>; try { - tracks = await GetAlbumTracks(this.localAlbumId); + tracks = await GetAlbumTracks(this.localAlbumId, libraryStore.libraryFilter()); } catch { this.loadingReleases = false; return; @@ -1007,6 +1051,10 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { private mapLocalTracks( tracks: Awaited>, ): MBTrack[] { + // The rows carry the file paths; this is where they stop being + // thrown away. + this.rememberLocalPaths(tracks); + const mapped: MBTrack[] = (tracks ?? []).map((t) => ({ mbid: t.RecordingMBID || '', title: t.TrackName, @@ -1055,7 +1103,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { */ private async loadLocalTracks(albumId: number): Promise { try { - const tracks = await GetAlbumTracks(albumId); + const tracks = await GetAlbumTracks(albumId, libraryStore.libraryFilter()); this.localTracks = this.mapLocalTracks(tracks); } catch { @@ -1065,7 +1113,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { // A catalog fetch may have already built the version list // without a "Your Library" entry to point at — rebuild now // that there's local data to match against it. - if (this.releases.length > 0) this.buildClusters(); + // + // Unconditionally, including when the catalog returned nothing: + // `buildVersionEntries` synthesises the library entry *from* + // these tracks, so the no-releases case is exactly the one that + // needs this. Guarded on `releases.length` before, an album the + // catalog could not answer for showed "No release data + // available" over a tracklist it was holding in memory. + this.buildClusters(); } private async hydrateFromLibrary(mbid: string): Promise { @@ -1114,7 +1169,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { // Fetch tracks via the same local DB call the cover-grid uses. let tracks: Awaited>; try { - tracks = await GetAlbumTracks(libraryAlbum.ID); + tracks = await GetAlbumTracks(libraryAlbum.ID, libraryStore.libraryFilter()); } catch { return false; } @@ -1749,8 +1804,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { // Guarded on `known` rather than on "fewer tracks than the // cluster", which would swap in a catalog tracklist for // every album whose tags simply never declared a total. - const incomplete = this.completeness?.known - && !this.completeness.complete; + const answer = this.completenessAnswer(); + const incomplete = answer?.known && !answer.complete; if (incomplete) { const fullRelease = this.findLibraryCluster(clusters); @@ -1759,7 +1814,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { return { key: 'synthetic:library', label: 'Your Library', - sublabel: `${this.completeness?.owned ?? 0} of ${this.completeness?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`, + sublabel: `${answer?.owned ?? 0} of ${answer?.expected ?? 0} tracks · ${this.clusterLabel(fullRelease)}`, group: 'aggregate', syntheticKind: 'library', tracks: fullRelease.representative.tracks ?? [], @@ -1948,75 +2003,183 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { * the album may be on the request list, which the button directly * below this badge has reported as "Wanted" all along. */ + /** + * How much of this album is here, from whichever side can say. + * + * The files answer first: `GetAlbumCompleteness` reads the "5/12" + * totals off the tags, which is exact and costs no network. A great + * deal of any library declares no total at all, and for those the + * *catalog* carries one — a per-release-group track count in + * `explore_index`, shipped in the artifact for the price of about + * two bytes a row. + * + * The numerator stays the local one either way: how many distinct + * track numbers are on disk. Only the denominator is borrowed, and + * only when the tags have none — a catalog total is a statement + * about the canonical release, and the files' own total, where they + * declare one, is a statement about the release the user actually + * has. + * + * Zero still means "the catalog does not say", so an album neither + * side can total stays `known: false` and wears no ring. + */ + private completenessAnswer(): library.AlbumCompleteness | null { + const local = this.completeness; + + if (local?.known) return local; + + const expected = this.releaseGroup?.totalTracks ?? 0; + if (expected <= 0 || !local) return local; + + return { + ...local, + expected, + known: true, + complete: local.owned >= expected, + }; + } + /** * What the badge beside the album title shows. * * `albumLibraryStatus()` answers "is any of this yours", which is * the right question for a tick and the wrong one for a ring. The - * ring needs a denominator, and it only exists when the files - * declared one — so an owned album with untotalled tags keeps the - * plain tick rather than wearing an arc drawn from a guess. + * ring needs a denominator, and an album neither the files nor the + * catalog can total keeps the plain tick rather than wearing an arc + * drawn from a guess. */ private albumBadgeStatus(): LibraryStatus { const owned = this.albumLibraryStatus(); if (owned !== 'in-library') return owned; - const c = this.completeness; + const c = this.completenessAnswer(); if (c?.known && !c.complete) return 'partial'; return 'in-library'; } + /** + * How a displayed track is identified in `filePaths`. + * + * A recording MBID where there is one, and disc/track/title where + * there is not — a library-only album's tracks are synthesised from + * the files' own tags and may carry no MBID at all, which is the + * case an MBID-keyed lookup silently misses. + */ + private static trackKey(t: MBTrack): string { + if (t.mbid) return t.mbid; + + return `${t.discNumber || 1}:${t.position}:${t.title.toLowerCase()}`; + } + + /** The file behind a displayed track, or '' if the user has none. */ + private filePathFor(t: MBTrack): string { + return this.filePaths.get(ExploreAlbumDetails.trackKey(t)) ?? ''; + } + + /** + * Record the files behind the local album's own tracks. + * + * These cost nothing: `GetAlbumTracks` already returned the paths, + * and this is the one place they were being thrown away. + */ + private rememberLocalPaths( + rows: Awaited>, + ): void { + const paths = new Map(this.filePaths); + + for (const row of rows ?? []) { + if (!row.FilePath) continue; + + const key = ExploreAlbumDetails.trackKey({ + mbid: row.RecordingMBID || '', + title: row.TrackName, + position: row.TrackNumber || 0, + discNumber: row.DiscNumber || 1, + } as MBTrack); + + paths.set(key, row.FilePath); + } + + this.filePaths = paths; + } + + /** + * Resolve the catalog tracklist's files in one query. + * + * Called when the displayed tracklist changes rather than when a + * user clicks: the answer decides what the rows look like and which + * menu items exist, so it has to be known before either is drawn. + */ + private async resolveFilePaths(): Promise { + const tracks = this.currentVersion()?.tracks ?? []; + const wanted = tracks + .map((t) => t.mbid) + .filter((mbid) => mbid && !this.askedFor.has(mbid)); + + if (wanted.length === 0) return; + + for (const mbid of wanted) this.askedFor.add(mbid); + + try { + const byMBID = await dictByName( + GetFilePathsByRecordingMBIDs(wanted, libraryStore.libraryFilter()), + ); + + const paths = new Map(this.filePaths); + + for (const [mbid, forMBID] of Object.entries(byMBID)) { + const first = forMBID?.[0]; + if (first) paths.set(mbid, first); + } + + this.filePaths = paths; + } catch (error) { + // A failure here means the page cannot say what is owned, so + // it says nothing rather than guessing: rows stay dimmed and + // the actions that need a file stay absent. + console.error('Could not resolve library files for this album:', error); + } + } + + /** + * Whether any of this album is the user's. + * + * One question, asked once: does any displayed track have a file. + * It used to be four claims of decreasing confidence OR'd into a + * single tick — a local album id, the backend's cross-reference, a + * cached MBID match, and finally *any* track flagged `inLibrary` — + * none of which is "there is a file", which is why the badge could + * say yes about an album whose every action failed. + * + * When it is not owned the answer is not automatically "no": the + * album may be on the request list, which the button below the + * badge has reported as "Wanted" all along. + */ private albumLibraryStatus(): LibraryStatus { - if (this.localAlbumId > 0) return 'in-library'; + if (this.ownership().owned > 0) return 'in-library'; - if (this.releaseGroup?.inLibrary) return 'in-library'; - - const mbid = this.releaseGroupMBID; - if (mbid) { - const cachedAlbums = libraryStore.cachedAlbums; - if (cachedAlbums) { - for (const a of cachedAlbums) { - if (a.MBID === mbid) return 'in-library'; - } - } - } - - const current = this.currentVersion(); - if (current) { - for (const t of current.tracks) { - if (t.inLibrary) return 'in-library'; - } - } - - // None of the five ownership claims held, so the badge falls - // through to the one thing this page already knew and never - // said: whether the album is on the request list. The button - // below it has read "Wanted" all along. return libraryStatusFor(false, this.releaseGroupMBID); } /** * How much of the shown release the user actually has. * - * The tick beside the title is a yes/no answer to "is any of this - * mine", and four of its five branches can be true when one track - * of forty matches. That is fine for a badge and useless for a - * button: "Play" that plays one track of a forty-track release is - * worse than no Play button, so the header asks this instead. + * Counted off the tracklist being displayed, by how many of its + * tracks resolved to a file. That is the only claim on this page + * that is not an inference: a path means the track plays. * - * It is counted off the *tracklist being displayed*, which is the - * one thing on this page that is not an inference — each track's - * `inLibrary` is set by the backend from its recording MBID - * (`markReleasesInLibrary`), the same key the file paths are - * fetched by. + * It is what the header's buttons key off, because "Play" that + * plays one track of a forty-track release is worse than no Play + * button — and it is now also what the badge above them uses, so + * the two can no longer disagree. */ private ownership(): { owned: number; total: number } { const tracks = this.currentVersion()?.tracks ?? []; return { - owned: tracks.filter((t) => t.inLibrary).length, + owned: tracks.filter((t) => this.filePathFor(t) !== '').length, total: tracks.length, }; } @@ -2064,6 +2227,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { ${this.renderVersionSelector()} ${this.renderTracklist()} + `; } @@ -2130,8 +2294,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { ${this.albumName} void this.playOwned(false)} + @click=${() => this.playOwned(false)} > ${playLabel} @@ -2190,7 +2354,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { size="small" appearance="outlined" data-testid="album-shuffle" - @click=${() => void this.playOwned(true)} + @click=${() => this.playOwned(true)} > Shuffle album @@ -2199,7 +2363,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { size="small" appearance="outlined" data-testid="album-queue" - @click=${() => void this.queueOwned()} + @click=${() => this.queueOwned()} > Add to queue @@ -2240,163 +2404,103 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { return { type: 'album', id: this.localAlbumId, label: this.albumName }; } - private async ownedFilePaths(): Promise { - const libraryID = libraryStore.getSelectedLibraryId() ?? 0; - - // The local album id is the better key whenever there is one: - // it needs no MBIDs at all, and a library-only album has none — - // its tracks are synthesised from `GetAlbumTracks` with - // `mbid: RecordingMBID || ''`, so an untagged library resolves - // to an empty set and the Play button silently does nothing. - // That is exactly what the first version of this did. - if (this.localAlbumId > 0) { - const byAlbum = await dict( - GetFilePathsByAlbums([this.localAlbumId], libraryID), - ); - - return byAlbum[this.localAlbumId] ?? []; - } - - // Catalog-only: the page knows what is owned as recording MBIDs - // and nothing else — which is how the backend decided each - // track's `inLibrary` in the first place. - const tracks = this.currentVersion()?.tracks ?? []; - const mbids = tracks - .filter((t) => t.inLibrary && t.mbid) - .map((t) => t.mbid); - - if (mbids.length === 0) return []; - - const byMBID = await dictByName( - GetFilePathsByRecordingMBIDs(mbids, libraryID), - ); - - // Walked in tracklist order rather than flattened, because the - // grouping is what lets the caller keep its own order. A - // recording with more than one file is a duplicate; play the - // first and leave the rest to the feature that exists for them. + /** + * The files behind the displayed tracklist, in its order. + * + * No query: the paths were resolved when the tracklist settled. + * This used to be two different lookups chosen by a branch - by + * local album id, or by recording MBID for a catalog-only album - + * and the second silently returned nothing for an untagged library, + * because those tracks carry no MBID at all. + */ + private ownedFilePaths(): string[] { const paths: string[] = []; - for (const mbid of mbids) { - const first = byMBID[mbid]?.[0]; + for (const track of this.currentVersion()?.tracks ?? []) { + const path = this.filePathFor(track); - if (first) paths.push(first); + // A recording with more than one file is a duplicate; the + // map holds the first and the rest are the duplicate + // feature's business. + if (path) paths.push(path); } return paths; } /** Play what the user owns of this release, optionally shuffled. */ - private async playOwned(shuffle: boolean): Promise { - try { - const paths = await this.ownedFilePaths(); + private playOwned(shuffle: boolean): void { + const paths = this.ownedFilePaths(); - if (paths.length === 0) { - notificationStore.inline(ExploreAlbumRegion, { - text: 'None of these tracks could be found in your library.', - }); + // The button is only rendered when there is something to play, + // so an empty set here is not a state the user can reach. + if (paths.length === 0) return; - return; - } - - // `shuffleStart` only picks a random first track when - // shuffle mode is *already* on — it does not turn it on — - // so the mode has to be set before the queue, not after. - if (shuffle && !queueStore.getState().shuffleMode) { - queueStore.toggleShuffle(); - } - - queueStore.setQueue(paths, 0, shuffle, this.queueSource()); - } catch (error) { - console.error('Could not play album:', error); - notificationStore.inline(ExploreAlbumRegion, { - text: describeError(error, 'Could not play this album.'), - }); + // `shuffleStart` only picks a random first track when shuffle + // mode is *already* on — it does not turn it on — so the mode + // has to be set before the queue, not after. + if (shuffle && !queueStore.getState().shuffleMode) { + queueStore.toggleShuffle(); } + + queueStore.setQueue(paths, 0, shuffle, this.queueSource()); } /** Append what the user owns of this release to the queue. */ - private async queueOwned(): Promise { - try { - const paths = await this.ownedFilePaths(); + private queueOwned(): void { + const paths = this.ownedFilePaths(); - if (paths.length === 0) { - notificationStore.inline(ExploreAlbumRegion, { - text: 'None of these tracks could be found in your library.', - }); + if (paths.length === 0) return; - return; - } - - queueStore.addTracksToQueue(paths); - } catch (error) { - console.error('Could not queue album:', error); - notificationStore.inline(ExploreAlbumRegion, { - text: describeError( - error, - 'Could not add this album to the queue.', - ), - }); - } + queueStore.addTracksToQueue(paths); } /** - * File path for one owned track, resolved by recording MBID — the - * same key the backend used to mark it `inLibrary` in the first - * place. Unlike `ownedFilePaths()` this does not special-case - * `localAlbumId`: a single track's own MBID is enough, and every - * `MBTrack` carries one regardless of how the album itself was - * matched. + * Play an owned track *in the context of the release it is on*: + * the whole owned tracklist is queued and playback starts at that + * track. Activating a row is a position in an album, not a request + * to throw the album away - "Add to Queue" and "Play Next" are what + * a caller reaches for when it wants the one track. */ - private async trackFilePath(track: MBTrack): Promise { - if (!track.inLibrary || !track.mbid) return null; + private playTrack(track: MBTrack): void { + const path = this.filePathFor(track); - const libraryID = libraryStore.getSelectedLibraryId() ?? 0; - const byMBID = await dictByName( - GetFilePathsByRecordingMBIDs([track.mbid], libraryID), - ); + // Every path into this is gated on the row having a file: the + // row is not activatable without one and the menu offers + // nothing that needs one. There is no "could not be found in + // your library" any more, because the page no longer offers an + // action it cannot perform. + if (!path) return; - return byMBID[track.mbid]?.[0] ?? null; - } - - /** Play a single owned track now. A no-op for a track not in the library. */ - private async playTrack(track: MBTrack): Promise { - try { - const path = await this.trackFilePath(track); - - if (!path) { - notificationStore.inline(ExploreAlbumRegion, { - text: 'This track could not be found in your library.', - }); - - return; - } + const paths = this.ownedFilePaths(); + const start = paths.indexOf(path); + // `start` is only -1 if the row is not in the version currently + // displayed, which no gesture on this page can produce; playing + // the one track is the honest answer to it either way. + if (start < 0) { queueStore.setQueue([path], 0, false, this.queueSource()); - } catch (error) { - console.error('Could not play track:', error); - notificationStore.inline(ExploreAlbumRegion, { - text: describeError(error, 'Could not play this track.'), - }); + + return; } + + queueStore.setQueue(paths, start, false, this.queueSource()); } - private async queueTrackNext(track: MBTrack): Promise { - const path = await this.trackFilePath(track); + private queueTrackNext(track: MBTrack): void { + const path = this.filePathFor(track); if (path) queueStore.playNext(path); } - private async addTrackToQueue(track: MBTrack): Promise { - const path = await this.trackFilePath(track); + private addTrackToQueue(track: MBTrack): void { + const path = this.filePathFor(track); if (path) queueStore.addToQueue(path); } private onTrackRowDblClick(track: MBTrack): void { - if (!track.inLibrary) return; - - void this.playTrack(track); + this.playTrack(track); } private onTrackRowKeydown(e: KeyboardEvent, track: MBTrack): void { @@ -2408,9 +2512,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { return; } - if ((e.key === 'Enter' || e.key === ' ') && track.inLibrary) { + if ((e.key === 'Enter' || e.key === ' ') && this.filePathFor(track)) { e.preventDefault(); - void this.playTrack(track); + this.playTrack(track); } } @@ -2422,30 +2526,81 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { this.ctxMenu.openAt(e.clientX, e.clientY); } - private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void { + private onContextMenuAction( + action: 'play' | 'add-to-queue' | 'play-next' | 'track-details', + ): void { const track = this.ctxMenuTrack; this.ctxMenu.close(); - if (!track || !track.inLibrary) return; + if (!track || !this.filePathFor(track)) return; switch (action) { case 'play': - void this.playTrack(track); + this.playTrack(track); break; case 'add-to-queue': - void this.addTrackToQueue(track); + this.addTrackToQueue(track); break; case 'play-next': - void this.queueTrackNext(track); + this.queueTrackNext(track); break; + case 'track-details': + void this.openTrackDetails(track); + break; + } + } + + /** + * Open the "Add to Playlist" submenu for the track the menu is on. + * + * No await and no guards: the file was resolved when the tracklist + * settled, so the submenu opens or the item was never rendered. + * This used to resolve on demand, which meant a hover could report + * a failure for a menu the user was passing through. + */ + private openPlaylistSubmenu(): void { + const track = this.ctxMenuTrack; + if (!track) return; + + const path = this.filePathFor(track); + if (!path) return; + + this.ctxMenu.clearSubmenuCloseTimer(); + void this.ctxMenu.showPlaylistSubmenu([path]); + } + + /** + * The details dialog for an owned track. + * + * It needs the library's own `Track`, which this page never has — + * its rows are the catalog's — so the file path is the way in, and + * the shared opener turns it back into a track. + */ + private async openTrackDetails(track: MBTrack): Promise { + const path = this.filePathFor(track); + if (!path) return; + + const outcome = await showTrackDetailsForPath( + () => this.trackDetailsDialog, + path, + () => void this.openTrackDetails(track), + ); + + // The file exists but the library store does not know it: a + // rescan removed it since the page loaded, which is the one + // case the resolved map cannot rule out. + if (outcome === 'not-in-library') { + notificationStore.inline(ExploreAlbumRegion, { + text: 'This track is no longer in your library.', + }); } } /** * Explore's tracks carry a recording MBID whether or not the user * owns them — this is the one context-menu action that works on a - * track the library doesn't have, since it needs no file at all. + * track the library does not have, since it needs no file at all. */ private viewTrackOnMusicBrainz(): void { const track = this.ctxMenuTrack; @@ -2612,7 +2767,12 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { * to `unavailable`. */ private catalogScope(): CatalogScope { - if (!this.releaseGroupMBID) return 'library'; + // A library-only album says nothing: the header names it, the + // badge says it is yours, and the tracklist is the files' + // own — there is nothing absent for a notice to warn about. + // The artist page keeps its 'library' state because there a + // missing catalog means missing *sections*. + if (!this.releaseGroupMBID) return 'catalog'; if (this.catalogReleasesLoaded) return 'catalog'; // A complete, MBID-matched album is not missing anything the @@ -2919,20 +3079,27 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { ` : nothing} - ${discTracks.map( - (track) => html` + ${discTracks.map((track) => { + // A row is owned if a file is behind it. + // It used to be the backend's inLibrary + // flag, which was set from a metadata + // row and could be true for a track + // that could not be played. + const owned = this.filePathFor(track) !== ''; + + return html`
this.onTrackRowDblClick(track)} @@ -2952,7 +3119,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { track.length, )} - ${track.inLibrary + ${owned ? nothing : html` `}
- `, - )} + `; + })} `; })} @@ -2992,23 +3159,55 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { ${this.ctxMenu.contextMenuOpen && track ? html` -
+
${this.unmatched.map( - (path) => { + (path, index) => { const isSelected = this .selectedPhantom === @@ -1105,10 +1353,26 @@ export class PhantomResolver extends LitElement { : ''} ${isMatched ? 'matched' : ''}" + role="option" + tabindex=${isSelected || + (!this.selectedPhantom && + index === 0) + ? '0' + : '-1'} + aria-selected=${isSelected + ? 'true' + : 'false'} @click=${() => this.handlePhantomClick( path, )} + @keydown=${( + e: KeyboardEvent, + ) => + this.handlePhantomKeydown( + e, + path, + )} title=${path} > ${isMatched @@ -1188,7 +1452,7 @@ export class PhantomResolver extends LitElement { found. Try searching below.
`} - ${this.searchResults.length > 0 + ${this.extraSearchResults.length > 0 ? html`
- ${this.searchResults.map( + ${this.extraSearchResults.map( (c) => this.renderCandidateItem( c, @@ -1205,6 +1469,13 @@ export class PhantomResolver extends LitElement { )} ` : nothing} + ${this.searchResults.length > 0 && + this.extraSearchResults.length === 0 + ? html`
+ Every match for that search is already + listed above. +
` + : nothing} ${this.searching ? html`
- this.handleCandidateDblClick( - c, - )} + class="candidate-item ${claimed ? 'claimed' : ''}" + role="button" + tabindex="0" + aria-disabled=${claimed ? 'true' : 'false'} + aria-label=${`Match with ${title}${ + meta ? `, ${meta}` : '' + }${claimed ? ' (already used)' : ''}`} + @dblclick=${() => this.chooseCandidate(c)} + @keydown=${(e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + + e.preventDefault(); + this.chooseCandidate(c); + }} title=${c.FilePath} >
diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts index c3ac7b8..1c646aa 100644 --- a/frontend/src/components/playlist-details/playlist-details.ts +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -450,7 +450,19 @@ export class PlaylistDetails switch (action) { case 'play': - queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName }); + // One row is a position in the playlist, so it queues + // the playlist from there - the same thing + // double-clicking the row does. Several rows are an + // explicit choice of *those* tracks and become the + // queue on their own. + if (filePaths.length === 1) { + this.handleTrackDblClick( + this.selection.getSelectedIndices()[0]!, + ); + } else { + queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName }); + } + break; case 'add-to-queue': queueStore.addTracksToQueue(filePaths); diff --git a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts index d3f4e48..1882f06 100644 --- a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts +++ b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts @@ -919,7 +919,19 @@ export class SmartPlaylistDetails switch (action) { case 'play': - queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName }); + // One row is a position in the playlist, so it queues + // the playlist from there - the same thing + // double-clicking the row does. Several rows are an + // explicit choice of *those* tracks and become the + // queue on their own. + if (filePaths.length === 1) { + this.handleTrackDblClick( + this.selection.getSelectedIndices()[0]!, + ); + } else { + queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName }); + } + break; case 'add-to-queue': queueStore.addTracksToQueue(filePaths); diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 08c8a2f..04fac93 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -1242,12 +1242,32 @@ export class TrackList * has existed in the defaults and in Settings since it was written * and has never had anything on the other end of it. */ private handleShortcutPlay = (): void => { - const filePaths = this.selection.getSelectedKeysOrdered(); + this.playSelection(this.selection.getSelectedKeysOrdered()); + }; + /** + * "Play" means the same thing from the menu and from Enter, and it + * asks how much the user selected. One row is a position in the + * list - it queues the list from there, exactly as double-clicking + * does. Several rows are an explicit choice of *those* tracks, so + * they become the queue on their own (and `shuffleStart` applies, + * since no one row was named as the place to start). + */ + private playSelection(filePaths: string[]): void { if (filePaths.length === 0) return; + if (filePaths.length === 1) { + const index = this.displayIndexOf(filePaths[0]!); + + if (index >= 0) { + this.playFromRow(index); + + return; + } + } + queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource); - }; + } override willUpdate( changed: Map, @@ -1544,7 +1564,7 @@ export class TrackList private onDelegatedDblClick = (e: MouseEvent) => { const hit = this.resolveTrackFromEvent(e); - if (hit) this.onTrackRowDblClick(hit.track); + if (hit) this.onTrackRowDblClick(hit.track, hit.index); }; private onDelegatedContextMenu = (e: MouseEvent) => { @@ -1574,9 +1594,45 @@ export class TrackList this.selection.handleItemClick(e, track.FilePath, index); } - private onTrackRowDblClick(track: library.Track) { + private onTrackRowDblClick(_track: library.Track, index: number) { this.selection.clear(); - queueStore.setQueue([track.FilePath], 0, false, this.effectiveQueueSource); + this.playFromRow(index); + } + + /** + * Activating one row plays the list that row is in, from that row - + * the library, the artist or the genre the user is looking at, not + * a queue of one. The paths come from `cachedSortedTracks`, so it + * is the list as *displayed*: whatever the current sort, search and + * library filter have made of it, which is the only order the user + * can see and therefore the only one they can mean. + */ + private playFromRow(index: number) { + const filePaths = this.cachedSortedTracks.map( + (t) => t.FilePath, + ); + + if (filePaths.length === 0 || index < 0) return; + + queueStore.setQueue( + filePaths, + index, + false, + this.effectiveQueueSource, + ); + } + + /** + * Where a "play this" command lands in the displayed list, or -1. + * + * Selection keys are file paths, which survive the re-sorts and + * refetches an index does not - so the index is looked up at the + * moment it is used rather than remembered. + */ + private displayIndexOf(filePath: string): number { + return this.cachedSortedTracks.findIndex( + (t) => t.FilePath === filePath, + ); } private onTrackContextMenu(e: MouseEvent, track: library.Track) { @@ -1648,7 +1704,7 @@ export class TrackList switch (action) { case 'play': - queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource); + this.playSelection(filePaths); break; case 'add-to-queue': queueStore.addTracksToQueue(filePaths); diff --git a/frontend/src/store/controllers/library-controller.ts b/frontend/src/store/controllers/library-controller.ts index 7d48f64..dc00c3d 100644 --- a/frontend/src/store/controllers/library-controller.ts +++ b/frontend/src/store/controllers/library-controller.ts @@ -72,9 +72,9 @@ export class LibraryController implements ReactiveController { } async getAlbumsByArtist( - artistID: number, + artist: string, ): Promise { - return libraryStore.getAlbumsByArtist(artistID); + return libraryStore.getAlbumsByArtist(artist); } getAlbumsByArtistNameCached( diff --git a/frontend/src/store/library-store.ts b/frontend/src/store/library-store.ts index 4ab7d88..72ef0d0 100644 --- a/frontend/src/store/library-store.ts +++ b/frontend/src/store/library-store.ts @@ -1,15 +1,10 @@ import { EventsOn } from '@runtime/runtime'; import { - GetAllTracks, - GetAllAlbums, - GetAllArtists, - GetAllGenresWithCounts, + GetTracks, + GetAlbums, + GetArtists, + GetGenres, GetAlbumsByArtist, - GetAllTracksByLibrary, - GetAllAlbumsByLibrary, - GetAllArtistsByLibrary, - GetAllGenresWithCountsByLibrary, - GetAlbumsByArtistByLibrary, GetAllLibrariesWithTrackCounts, } from '@go/library/library.js'; import type * as library from '@go/library/models.js'; @@ -228,11 +223,9 @@ class LibraryStore { if (pending) return pending; - const id = this.selectedLibraryIdValue; - return this.track( 'tracks', - list(id !== null ? GetAllTracksByLibrary(id) : GetAllTracks()), + list(GetTracks(this.libraryFilter())), (tracks) => { this.tracks = tracks; }, @@ -249,11 +242,9 @@ class LibraryStore { if (pending) return pending; - const id = this.selectedLibraryIdValue; - return this.track( 'albums', - list(id !== null ? GetAllAlbumsByLibrary(id) : GetAllAlbums()), + list(GetAlbums(this.libraryFilter())), (albums) => { this.albums = albums; }, @@ -270,11 +261,9 @@ class LibraryStore { if (pending) return pending; - const id = this.selectedLibraryIdValue; - return this.track( 'artists', - list(id !== null ? GetAllArtistsByLibrary(id) : GetAllArtists()), + list(GetArtists(this.libraryFilter())), (artists) => { this.artists = artists; }, @@ -291,15 +280,9 @@ class LibraryStore { if (pending) return pending; - const id = this.selectedLibraryIdValue; - return this.track( 'genres', - list( - id !== null - ? GetAllGenresWithCountsByLibrary(id) - : GetAllGenresWithCounts(), - ), + list(GetGenres(this.libraryFilter())), (genres) => { this.genres = genres; }, @@ -308,15 +291,21 @@ class LibraryStore { } async getAlbumsByArtist( - artistID: number, + artist: string, ): Promise { - const id = this.selectedLibraryIdValue; + return list(GetAlbumsByArtist(artist, this.libraryFilter())); + } - return list( - id !== null - ? GetAlbumsByArtistByLibrary(artistID, id) - : GetAlbumsByArtist(artistID), - ); + /** + * The library id every backend query takes, where 0 means "all of + * them". + * + * Each of these used to be two bindings and a branch here, because + * the backend had a scoped and an unscoped query for every list. + * One query answers both now, so the branch is a `?? 0`. + */ + libraryFilter(): number { + return this.selectedLibraryIdValue ?? 0; } /** diff --git a/frontend/src/utils/queue-source-link.ts b/frontend/src/utils/queue-source-link.ts index b651b8d..b7444f3 100644 --- a/frontend/src/utils/queue-source-link.ts +++ b/frontend/src/utils/queue-source-link.ts @@ -8,8 +8,17 @@ * the destinations and attributes differ per source type, and there is * no MBID/local-id fallback dance to share — a queue source always * carries a local id (`tracks` is the one exception, needing none). + * + * An album is the one source that needs more than its local id. + * `explore-album-details` is a *catalog* page and decides what it is + * showing from `release-group-mbid` alone — with only a local id it + * says "library only" about an album that is perfectly well tagged, + * which is not what the same album opened from the albums grid says. + * So the MBID is read off the library row here, exactly as + * `cover-grid` reads it off the card it navigates from. */ +import { libraryStore } from '../store/library-store'; import type { QueueSource } from '../store/queue-store'; /** Fire a navigate event from the clicked element. */ @@ -75,6 +84,26 @@ export function describeQueueSource(source: QueueSource): string | null { return `Playing from ${source.label}`; } +/** + * The release-group MBID of a library album, or '' when it has none. + * + * Reads the album cache synchronously when it is warm — the albums + * view populates it, and so does anything else that has asked for the + * collection — and only awaits a fetch when nothing has yet. + */ +function albumMBID(id: number): string | Promise { + const find = (albums: readonly { ID: number; MBID: string }[]): string => + albums.find((a) => a.ID === id)?.MBID ?? ''; + + const cached = libraryStore.cachedAlbums; + if (cached) return find(cached); + + return libraryStore + .getAlbums() + .then(find) + .catch(() => ''); +} + /** Navigate to the collection a queue was built from. */ export function navigateToQueueSource( target: EventTarget, @@ -83,5 +112,25 @@ export function navigateToQueueSource( const buildDetail = SOURCE_NAVIGATE_DETAIL[source.type]; if (!buildDetail) return; - navigate(target, buildDetail(source)); + const detail = buildDetail(source); + + if (source.type !== 'album') { + navigate(target, detail); + + return; + } + + const mbid = albumMBID(source.id); + + if (typeof mbid === 'string') { + if (mbid) detail.releaseGroupMBID = mbid; + navigate(target, detail); + + return; + } + + void mbid.then((resolved) => { + if (resolved) detail.releaseGroupMBID = resolved; + navigate(target, detail); + }); } diff --git a/frontend/src/utils/track-details-opener.ts b/frontend/src/utils/track-details-opener.ts new file mode 100644 index 0000000..bb21e5a --- /dev/null +++ b/frontend/src/utils/track-details-opener.ts @@ -0,0 +1,77 @@ +/** + * Open `` for a file path. + * + * The five library-side hosts already hold the `library.Track` the + * dialog wants — they render it. Explore's rows do not: a tracklist row + * is an `MBTrack`/`LBTopRecording` from the catalog, and all it can say + * about the library is *which file is behind it*. So the path is the + * one key both sides share, and turning it back into a track is the + * work this does. + * + * `libraryStore.getTracks()` is awaited rather than + * `getCachedTracks()`-and-bail (which is what `queue-panel` does): + * Explore is reachable without ever opening the library views, so a + * cold cache is ordinary here rather than a symptom, and silently doing + * nothing on a menu item the user just clicked is not an option. The + * fetch is the store's own, shared with every other reader. + */ + +import type * as library from '@go/library/models.js'; +import type { + CoverArtUrls, + TrackDetails, +} from '@components/track-details/track-details.js'; +import { libraryStore } from '@store/library-store.js'; +import { loadTrackDetails } from '@utils/lazy-track-details.js'; +import { tracksByFilePath } from '@utils/track-index.js'; + +/** The cover art the dialog shows, or nothing when the track has none. */ +function coverArtOf(track: library.Track): CoverArtUrls | undefined { + return track.CoverArtPath + ? { + coverArtPath: track.CoverArtPath, + coverArtSmall: track.CoverArtSmall, + coverArtMedium: track.CoverArtMedium, + coverArtLarge: track.CoverArtLarge, + } + : undefined; +} + +/** + * What became of the attempt. + * + * `chunk-failed` is separate from `not-in-library` because + * `loadTrackDetails` has already told the user about it — a caller that + * treated the two alike would report a missing track over the top of a + * notification saying the dialog itself could not be fetched. + */ +export type TrackDetailsOutcome = 'shown' | 'not-in-library' | 'chunk-failed'; + +/** + * Show the details dialog for the library track at `filePath`. + * + * @param dialog A getter, not the element: `@query` resolves an + * un-upgraded `` before the chunk lands, + * and only the read *after* `loadTrackDetails` is + * guaranteed to have `show()` on it. + * @param retry Re-runs the action that wanted the dialog, offered to + * the user if the chunk could not be fetched. + */ +export async function showTrackDetailsForPath( + dialog: () => TrackDetails | undefined, + filePath: string, + retry: () => void, +): Promise { + const tracks = await libraryStore.getTracks(); + const track = tracksByFilePath(tracks).get(filePath); + + if (!track) return 'not-in-library'; + + const ready = await loadTrackDetails(retry); + + if (!ready) return 'chunk-failed'; + + dialog()?.show(track, coverArtOf(track)); + + return 'shown'; +} diff --git a/frontend/test/components/album-actions.test.ts b/frontend/test/components/album-actions.test.ts index b0e1a15..cb2e8e7 100644 --- a/frontend/test/components/album-actions.test.ts +++ b/frontend/test/components/album-actions.test.ts @@ -18,7 +18,7 @@ import { describe, expect, it, beforeEach } from 'vitest'; import type { LitElement } from 'lit'; import '@components/explore-album-details/explore-album-details'; -import { stub, flush, resetHarness, calls } from '@test/support/harness'; +import { stub, flush, resetHarness, calls, lastArgs } from '@test/support/harness'; import { fixture, shadow, shadowAll, text } from '@test/support/render'; type Version = { @@ -52,6 +52,12 @@ function track(n: number, owned: boolean) { * The component builds its versions from fetched releases; this reaches * past that and sets the state the header actually reads, which is the * only part under test here. + * + * Owning a track means the library has a *file* for it — the page + * resolves the displayed tracklist's paths once and every action, badge + * and dimmed row reads that one answer. So the fixture says which + * tracks have files rather than setting an `inLibrary` flag, which is + * what used to be able to claim ownership of something unplayable. */ async function withVersion( owned: number, @@ -61,11 +67,20 @@ async function withVersion( albumName: 'Glass Harbour', }); + const tracks = Array.from({ length: total }, (_, i) => track(i + 1, i < owned)); + + const paths: Record = {}; + for (const t of tracks.filter((t) => t.inLibrary)) { + paths[t.mbid] = [`/music/${t.mbid}.mp3`]; + } + + stub('library.Library.GetFilePathsByRecordingMBIDs', paths); + const version: Version = { key: 'v1', label: '2019', sublabel: `${total} tracks`, - tracks: Array.from({ length: total }, (_, i) => track(i + 1, i < owned)), + tracks, }; Object.assign(el, { @@ -125,23 +140,28 @@ describe('the album header’s primary action', () => { expect(shadow(el, '[data-testid="album-queue"]')).toBeNull(); }); - it('asks for the owned tracks’ paths once, by the key it owns them by', async () => { - // `perf.m2`'s rule: ask for what the caller uses, once. The caller - // here uses file paths and knows its tracks only as recording - // MBIDs — `MBTrack.localId` is declared and never written by - // anything in the backend. + it('asks for the tracklist’s paths once, on load, and not on click', async () => { + // `perf.m2`'s rule — ask for what the caller uses, once — and the + // ownership rule with it. The page asks about the *whole* displayed + // tracklist when it settles, because whether a track is owned is + // that query's answer and not something to be inferred first. Every + // action, badge and dimmed row then reads the one result, so a + // click asks nothing and cannot fail. const el = await withVersion(7, 12); + const onLoad = calls('library.Library.GetFilePathsByRecordingMBIDs'); + + expect(onLoad).toHaveLength(1); + expect(onLoad[0]!.args[0]).toHaveLength(12); + // No empty MBID — an empty string matches every untagged recording + // in the library. + expect(onLoad[0]!.args[0]).not.toContain(''); + shadow(el, '[data-testid="album-play"]')!.click(); await flush(); - const asked = calls('library.Library.GetFilePathsByRecordingMBIDs'); - - expect(asked).toHaveLength(1); - // Only the owned ones, and no empty MBID — an empty string matches - // every untagged recording in the library. - expect(asked[0]!.args[0]).toHaveLength(7); - expect(asked[0]!.args[0]).not.toContain(''); + expect(calls('library.Library.GetFilePathsByRecordingMBIDs')).toHaveLength(1); + expect(lastArgs('queue.Queue.SetQueue')?.[0]).toHaveLength(7); }); }); diff --git a/frontend/test/components/album-catalog-scope.test.ts b/frontend/test/components/album-catalog-scope.test.ts index 5052f76..8d8242b 100644 --- a/frontend/test/components/album-catalog-scope.test.ts +++ b/frontend/test/components/album-catalog-scope.test.ts @@ -164,8 +164,17 @@ describe('an album the library already holds in full', () => { stub('explore.Service.GetThumbnail', ''); stub('library.Library.GetAllLibrariesWithTrackCounts', []); stub('library.Library.GetFilePathsByRecordingMBIDs', {}); + // A local album's rows carry their file paths, and that is what + // "the library holds this" means now — the page records them as it + // maps the tracks, so nothing has to be asked again later. stub('library.Library.GetAlbumTracks', [ - { TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' }, + { + TrackName: 'Track 1', + TrackNumber: 1, + DiscNumber: 1, + TrackLength: '3:20', + FilePath: '/music/glass-harbour/01.mp3', + }, ]); }); @@ -253,4 +262,72 @@ describe('an album the library already holds in full', () => { const badge = shadow(el, 'library-status-indicator'); expect(badge?.getAttribute('status')).toBe('partial'); }); + + /** + * The denominator the files could not supply. + * + * A great deal of any library declares no track total at all, and + * "unknown" is a third state that must render as neither complete nor + * incomplete — so an album like this used to wear a plain tick no + * matter how much of it was missing. The catalog carries a per- + * release-group total in the artifact for about two bytes a row, and + * that is what fills the gap: the numerator stays local (how many + * distinct track numbers are on disk), only the denominator is + * borrowed. + */ + it('borrows the catalog total when the tags declared none', async () => { + stub('explore.Service.LookupReleaseGroup', { + mbid: MBID, + title: 'Glass Harbour', + artistCredit: 'Tideline', + totalTracks: 12, + }); + stub('library.Library.GetAlbumCompleteness', { + owned: 9, + expected: 0, + known: false, + complete: false, + }); + + const el = await fixture('explore-album-details', { + releaseGroupMBID: MBID, + localAlbumId: 7, + albumName: 'Glass Harbour', + }); + + await flush(); + await el.updateComplete; + + const badge = shadow(el, 'library-status-indicator'); + expect(badge?.getAttribute('status')).toBe('partial'); + expect((badge as unknown as { expected: number }).expected).toBe(12); + expect((badge as unknown as { owned: number }).owned).toBe(9); + }); + + /** + * And when neither side can total it, nothing is invented: zero means + * "the catalog does not say", which is the same third state the local + * answer has, so the badge stays a plain tick. + */ + it('draws no ring when neither the tags nor the catalog say', async () => { + stub('library.Library.GetAlbumCompleteness', { + owned: 9, + expected: 0, + known: false, + complete: false, + }); + + const el = await fixture('explore-album-details', { + releaseGroupMBID: MBID, + localAlbumId: 7, + albumName: 'Glass Harbour', + }); + + await flush(); + await el.updateComplete; + + expect( + shadow(el, 'library-status-indicator')?.getAttribute('status'), + ).toBe('in-library'); + }); }); diff --git a/frontend/test/components/album-dropdown.test.ts b/frontend/test/components/album-dropdown.test.ts index f85687c..8931287 100644 --- a/frontend/test/components/album-dropdown.test.ts +++ b/frontend/test/components/album-dropdown.test.ts @@ -81,10 +81,10 @@ async function expandFirstCard(el: LitElement): Promise { describe('the album dropdown', () => { beforeEach(() => { resetHarness(); - stub('library.Library.GetAllAlbums', ALBUMS); - stub('library.Library.GetAllTracks', []); + stub('library.Library.GetAlbums', ALBUMS); + stub('library.Library.GetTracks', []); + stub('library.Library.GetAlbumTracks', TRACKS); stub('library.Library.GetAlbumTracks', TRACKS); - stub('library.Library.GetAlbumTracksByLibrary', TRACKS); emit(Events.LibraryScanComplete); }); @@ -154,8 +154,8 @@ describe('the album dropdown', () => { describe('the albums grid scrolls', () => { beforeEach(() => { resetHarness(); - stub('library.Library.GetAllAlbums', ALBUMS); - stub('library.Library.GetAllTracks', []); + stub('library.Library.GetAlbums', ALBUMS); + stub('library.Library.GetTracks', []); emit(Events.LibraryScanComplete); }); diff --git a/frontend/test/components/album-versions.test.ts b/frontend/test/components/album-versions.test.ts index 1bee7a7..8c8065d 100644 --- a/frontend/test/components/album-versions.test.ts +++ b/frontend/test/components/album-versions.test.ts @@ -210,6 +210,17 @@ describe('an album the library holds part of', () => { }); it('draws the whole release, with the missing tracks dimmed', async () => { + // Ownership is a *file*, not the catalog row's `inLibrary` flag — + // nine of the twelve recordings resolve to a path, so three rows + // dim. Stating it as flags is what let the page claim an album it + // could not play a note of. + stub( + 'library.Library.GetFilePathsByRecordingMBIDs', + Object.fromEntries( + Array.from({ length: 9 }, (_, i) => [`rec-${i + 1}`, [`/music/0${i + 1}.mp3`]]), + ), + ); + const el = await albumWith( [release('rel-1', '2019-04-01', 12, 9)], { owned: 9, expected: 12, known: true, complete: false }, diff --git a/frontend/test/components/aria-tail.test.ts b/frontend/test/components/aria-tail.test.ts index 19a51ee..3d14a14 100644 --- a/frontend/test/components/aria-tail.test.ts +++ b/frontend/test/components/aria-tail.test.ts @@ -76,8 +76,8 @@ describe('the track list says how it is sorted', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetAllTracks', TRACKS); - stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); @@ -128,11 +128,11 @@ describe('the track list has a voice for its own state', () => { beforeEach(() => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetAlbums', []); }); it('announces the result of a search that matches nothing', async () => { - stub('library.Library.GetAllTracks', TRACKS); + stub('library.Library.GetTracks', TRACKS); emit(Events.LibraryScanComplete); const el = await fixture('track-list'); @@ -161,10 +161,10 @@ describe('a selectable grid is a listbox, not a row of buttons', () => { beforeEach(() => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetAllArtists', ARTISTS); - stub('library.Library.GetAllGenresWithCounts', GENRES); - stub('library.Library.GetAllTracks', []); - stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetArtists', ARTISTS); + stub('library.Library.GetGenres', GENRES); + stub('library.Library.GetTracks', []); + stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); @@ -197,8 +197,8 @@ describe('a clipped value is readable somewhere', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetAllTracks', TRACKS); - stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); @@ -240,8 +240,8 @@ describe('the playing row is more than a colour', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetAllTracks', TRACKS); - stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); diff --git a/frontend/test/components/card-grid-repaint.test.ts b/frontend/test/components/card-grid-repaint.test.ts index a79e1de..0894daa 100644 --- a/frontend/test/components/card-grid-repaint.test.ts +++ b/frontend/test/components/card-grid-repaint.test.ts @@ -56,10 +56,10 @@ async function settle(el: LitElement): Promise { describe('a card grid shows its selection', () => { beforeEach(() => { resetHarness(); - stub('library.Library.GetAllArtists', ARTISTS); - stub('library.Library.GetAllGenresWithCounts', GENRES); - stub('library.Library.GetAllTracks', []); - stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetArtists', ARTISTS); + stub('library.Library.GetGenres', GENRES); + stub('library.Library.GetTracks', []); + stub('library.Library.GetAlbums', []); // The views read through LibraryController, whose cache is only // primed by a scan-complete; without it they render nothing and the // assertion below fails for the wrong reason. diff --git a/frontend/test/components/chrome.test.ts b/frontend/test/components/chrome.test.ts index 4c3dee6..6a8dec5 100644 --- a/frontend/test/components/chrome.test.ts +++ b/frontend/test/components/chrome.test.ts @@ -128,7 +128,7 @@ describe('', () => { select?.dispatchEvent(new Event('change')); await flush(); - expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]); + expect(lastArgs('library.Library.GetTracks')).toEqual([8]); }); it('picks up a library added while it was on screen', async () => { diff --git a/frontend/test/components/empty-states.test.ts b/frontend/test/components/empty-states.test.ts index 3702c12..46fab5b 100644 --- a/frontend/test/components/empty-states.test.ts +++ b/frontend/test/components/empty-states.test.ts @@ -15,10 +15,10 @@ import { fixture, shadow, text } from '@test/support/render'; /** Drop the library store's cache so the list has to fetch. */ async function emptyLibrary(): Promise { resetHarness(); - stub('library.Library.GetAllTracks', []); - stub('library.Library.GetAllAlbums', []); - stub('library.Library.GetAllArtists', []); - stub('library.Library.GetAllGenresWithCounts', []); + stub('library.Library.GetTracks', []); + stub('library.Library.GetAlbums', []); + stub('library.Library.GetArtists', []); + stub('library.Library.GetGenres', []); emit(Events.LibraryScanComplete); await flush(); } @@ -40,7 +40,7 @@ describe(' empty, loading and failed', () => { }); it('says the query failed, and offers to try again', async () => { - stubFailure('library.Library.GetAllTracks', 'sql: database is locked'); + stubFailure('library.Library.GetTracks', 'sql: database is locked'); emit(Events.LibraryScanComplete); await flush(); diff --git a/frontend/test/components/explore-track-details.test.ts b/frontend/test/components/explore-track-details.test.ts new file mode 100644 index 0000000..aab73a9 --- /dev/null +++ b/frontend/test/components/explore-track-details.test.ts @@ -0,0 +1,244 @@ +/** + * "Track Details" and "Add to Playlist" on Explore's owned tracks. + * + * The library-side lists have had this item for as long as the dialog + * has existed; Explore's tracklists — the album page's and the artist + * page's top tracks — had Play, Add to Queue and Play Next and stopped + * there. The reason it is worth a test rather than being one more + * `` is that the two sides hold different things: a + * library row *is* a `library.Track`, and an Explore row is the + * catalog's, which can name a file only through its recording MBID. + * + * So what this pins is the join. Both items appear only for a track the + * user owns (an unowned one has no file, and both are about a file): + * Track Details resolves MBID → path → the library's own track and + * hands *that* to the dialog, and Add to Playlist resolves the same + * path and hands it to the shared picker. + */ +import type { LitElement } from 'lit'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import '@components/explore-album-details/explore-album-details'; +import { emit, flush, stub } from '@test/support/harness'; +import { Events } from '../../src/events'; +import { fixture, shadow, shadowAll } from '@test/support/render'; +import { showTrackDetailsForPath } from '@utils/track-details-opener'; +import type { TrackDetails } from '@components/track-details/track-details'; + +const ALBUM_TRACKS = 'library.Library.GetAlbumTracks'; +const COMPLETENESS = 'library.Library.GetAlbumCompleteness'; +const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs'; +const ALL_TRACKS = 'library.Library.GetTracks'; +const LOOKUP_RG = 'explore.Service.LookupReleaseGroup'; +const BROWSE_RELEASES = 'explore.Service.BrowseReleases'; + +const PATH = '/music/an-album/01.flac'; +const MBID = 'rec-1'; + +/** One row as `GetAlbumTracks` returns it. */ +const albumTrack = { + ID: 1, + FilePath: PATH, + TrackName: 'A Song', + TrackNumber: 1, + DiscNumber: 1, + TrackLength: '3:00', + RecordingMBID: MBID, +}; + +/** The same track as the library's own model, which is what the dialog wants. */ +const libraryTrack = { + ID: 1, + FilePath: PATH, + Title: 'A Song', + Artist: 'An Artist', + Album: 'An Album', + CoverArtPath: '/covers/a.jpg', + CoverArtSmall: '/covers/a-64.jpg', + CoverArtMedium: '/covers/a-256.jpg', + CoverArtLarge: '/covers/a-512.jpg', +}; + +/** + * Mount the album page as a library-only album, which is the cheapest + * route to a rendered tracklist: no MBID means it hydrates entirely + * from `GetAlbumTracks` and asks the catalog nothing. + */ +async function albumPage() { + return fixture('explore-album-details', { localAlbumId: 7 }); +} + +/** Open the context menu on the first track row and return its items. */ +async function openTrackMenu(el: LitElement) { + const row = shadow(el, '.track-row'); + + expect(row, 'a track row is rendered').not.toBeNull(); + + row!.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true }), + ); + await flush(); + await el.updateComplete; + + return shadowAll(el, '.context-menu-panel wa-dropdown-item'); +} + +/** The track the page's `` was opened on, once it has one. */ +async function dialogTrack( + el: LitElement, + attempts = 100, +): Promise<{ FilePath: string } | null> { + for (let i = 0; i < attempts; i += 1) { + const dialog = shadow(el, 'track-details') as unknown as { + track?: { FilePath: string } | null; + } | null; + + if (dialog?.track) return dialog.track; + + await flush(); + } + + return null; +} + +/** The file paths handed to the playlist picker, once it is mounted. */ +async function pickerPaths( + el: LitElement, + attempts = 100, +): Promise { + for (let i = 0; i < attempts; i += 1) { + const picker = shadow(el, 'playlist-picker') as unknown as { + filePaths?: string[]; + } | null; + + if (picker?.filePaths?.length) return picker.filePaths; + + await flush(); + } + + return null; +} + +const labels = (items: Element[]) => + items.map((i) => (i.textContent ?? '').trim()); + +describe('Explore track details', () => { + beforeEach(() => { + stub(ALBUM_TRACKS, [albumTrack]); + stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 }); + stub(FILE_PATHS, { [MBID]: [PATH] }); + stub(ALL_TRACKS, [libraryTrack]); + }); + + /** + * `libraryStore` fetches at import and caches the empty list the + * shared setup stubs, for the life of the browser session — so a test + * that wants tracks in it has to say so. A scan-complete event is how + * the app itself invalidates that cache. + */ + async function primeLibrary() { + emit(Events.LibraryScanComplete); + await flush(); + } + + it('offers Track Details on an owned track', async () => { + const el = await albumPage(); + const items = await openTrackMenu(el); + + expect(labels(items)).toContain('Track Details'); + }); + + it('does not offer it on a track the library does not have', async () => { + // A catalog album the user owns nothing of: the tracklist renders + // from the browse, and every row is unowned. + stub(ALBUM_TRACKS, []); + stub(LOOKUP_RG, { + mbid: 'rg-1', + title: 'An Album', + artistCredit: 'An Artist', + firstReleaseDate: '1994', + primaryType: 'Album', + }); + stub(BROWSE_RELEASES, [ + { + mbid: 'rel-1', + title: 'An Album', + date: '1994', + country: 'GB', + tracks: [ + { + mbid: 'rec-2', + title: 'Another Song', + position: 1, + length: 180000, + discNumber: 1, + inLibrary: false, + }, + ], + }, + ]); + + const el = await fixture('explore-album-details', { + releaseGroupMBID: 'rg-1', + }); + const items = await openTrackMenu(el); + + expect(labels(items)).not.toContain('Track Details'); + expect( + labels(items).some((l) => l.startsWith('Add to Playlist')), + 'nothing to add to a playlist when there is no file', + ).toBe(false); + // The one item a track nobody owns still has, which is what makes + // the assertion above about the gate rather than about an empty + // menu that never opened. + expect(labels(items)).toContain('View on MusicBrainz'); + }); + + it('opens the dialog on the library track behind the row', async () => { + await primeLibrary(); + + const el = await albumPage(); + const items = await openTrackMenu(el); + const details = labels(items).indexOf('Track Details'); + + items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + // Polled rather than counted: the opener is three awaits deep — the + // path lookup, the store's tracks, and the dynamic `import()` of + // the dialog chunk — and a chunk fetch is the one of the three + // whose cost depends on what else the suite is doing. + const shown = await dialogTrack(el); + + expect(shown?.FilePath).toBe(PATH); + }); + + it('opens the playlist submenu on the row’s own file', async () => { + const el = await albumPage(); + const items = await openTrackMenu(el); + // Its label carries the submenu arrow, so match the prefix. + const add = labels(items).findIndex((l) => l.startsWith('Add to Playlist')); + + expect(add, 'the submenu item is in the menu').toBeGreaterThan(-1); + + items[add]!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + // Resolved by MBID at the moment the submenu opens, so the picker + // is not there on the first tick the way a library list's is. + const picker = await pickerPaths(el); + + expect(picker).toEqual([PATH]); + }); + + it('reports a path with no library track rather than opening empty', async () => { + stub(ALL_TRACKS, []); + await primeLibrary(); + + const outcome = await showTrackDetailsForPath( + () => undefined, + PATH, + () => undefined, + ); + + expect(outcome).toBe('not-in-library'); + }); +}); diff --git a/frontend/test/components/home-view.test.ts b/frontend/test/components/home-view.test.ts index d8442d3..e2179e1 100644 --- a/frontend/test/components/home-view.test.ts +++ b/frontend/test/components/home-view.test.ts @@ -132,7 +132,7 @@ describe('home view', () => { await new Promise((r) => setTimeout(r, 0)); expect(seen).toEqual([]); - expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]); + expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1, 0]); expect(lastArgs('queue.Queue.SetQueue')).toEqual([ ['/music/1.mp3', '/music/2.mp3'], 0, diff --git a/frontend/test/components/keyboard-reach.test.ts b/frontend/test/components/keyboard-reach.test.ts index 650bd9b..8770081 100644 --- a/frontend/test/components/keyboard-reach.test.ts +++ b/frontend/test/components/keyboard-reach.test.ts @@ -67,7 +67,7 @@ describe(' when closed', () => { describe(' roving tabindex', () => { beforeEach(() => { - stub('library.Library.GetAllTracks', TRACKS); + stub('library.Library.GetTracks', TRACKS); }); it('offers exactly one tab stop, however many rows there are', async () => { diff --git a/frontend/test/components/library-status.test.ts b/frontend/test/components/library-status.test.ts index d5483d4..317b2e1 100644 --- a/frontend/test/components/library-status.test.ts +++ b/frontend/test/components/library-status.test.ts @@ -132,8 +132,8 @@ describe(' badges', () => { stub('explore.Service.GetThumbnail', ''); stub('explore.Service.GetArtistImageURL', ''); stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' }); - stub('library.Library.GetAllAlbums', []); - stub('library.Library.GetAllTracks', []); + stub('library.Library.GetAlbums', []); + stub('library.Library.GetTracks', []); await withRequests([]); }); diff --git a/frontend/test/components/phantom-resolver.test.ts b/frontend/test/components/phantom-resolver.test.ts new file mode 100644 index 0000000..8c608c2 --- /dev/null +++ b/frontend/test/components/phantom-resolver.test.ts @@ -0,0 +1,201 @@ +/** + * The phantom resolver showed the same library track twice. + * + * Its right-hand panel renders two lists one under the other — the + * scored candidates and the library search results — and a search for + * the obvious title returns exactly what scoring already found. So the + * track appeared once with a score and once without, and double-clicking + * either did the same thing. + * + * The second half is what a match *means*: one library file cannot stand + * in for two unmatched tracks, or applying adds it to the playlist + * twice. `FindPhantomMatches` has always claimed candidates on the + * auto-match path; the manual path had no such rule. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { LitElement } from 'lit'; + +import '@components/phantom-resolver/phantom-resolver'; +import { flush, stub } from '@test/support/harness'; +import { fixture } from '@test/support/render'; + +interface Candidate { + FilePath: string; + Title: string; + Artist: string; + Album: string; + Duration: string; + Score: number; +} + +function candidate( + path: string, + title: string, + score = 0.5, +): Candidate { + return { + FilePath: path, + Title: title, + Artist: 'An Artist', + Album: 'An Album', + Duration: '200000', + Score: score, + }; +} + +const PHANTOM_A = '/music/gone/one.mp3'; +const PHANTOM_B = '/music/gone/two.mp3'; + +/** Mount the dialog with two unmatched tracks and no auto-matches. */ +async function open( + candidates: Candidate[], + searchResults: Candidate[] = [], +): Promise }> { + stub('playlist.Service.FindPhantomMatches', { + AutoMatched: [], + Unmatched: [PHANTOM_A, PHANTOM_B], + }); + stub('playlist.Service.GetPhantomCandidates', candidates); + stub('playlist.Service.SearchLibrary', searchResults); + + const el = await fixture< + LitElement & { show(id: number, tracks: unknown[]): void } + >('phantom-resolver'); + + el.show(1, [ + { FilePath: PHANTOM_A, Title: 'One', Phantom: true }, + { FilePath: PHANTOM_B, Title: 'Two', Phantom: true }, + ]); + + await flush(); + await el.updateComplete; + await flush(); + await el.updateComplete; + + return el; +} + +function candidateRows(el: HTMLElement): HTMLElement[] { + return [ + ...(el.shadowRoot?.querySelectorAll('.candidate-item') ?? []), + ]; +} + +/** The dialog renders the file path as each row's `title`. */ +function rowPaths(el: HTMLElement): string[] { + return candidateRows(el).map((r) => r.getAttribute('title') ?? ''); +} + +describe('the phantom resolver', () => { + beforeEach(() => { + stub('playlist.Service.ResolvePhantomTracks', null); + stub('playlist.Service.RemovePhantomTracks', null); + }); + + it('lists a search result the candidates already show only once', async () => { + const shared = candidate('/music/have/one.mp3', 'One', 0.7); + const el = await open([shared], [shared, candidate('/music/have/x.mp3', 'X', 0)]); + + // Type into the search box and let the debounce fire. + const input = el.shadowRoot?.querySelector( + '.search-input', + ); + + expect(input, 'the search box is rendered').toBeTruthy(); + + input!.value = 'one'; + input!.dispatchEvent(new InputEvent('input', { bubbles: true })); + + await new Promise((r) => setTimeout(r, 500)); + await flush(); + await el.updateComplete; + + const paths = rowPaths(el); + + expect(paths.filter((p) => p === shared.FilePath)).toHaveLength(1); + expect(paths).toContain('/music/have/x.mp3'); + }); + + it('matches a candidate from the keyboard, not only a double-click', async () => { + const el = await open([candidate('/music/have/one.mp3', 'One', 0.7)]); + const row = candidateRows(el)[0]; + + expect(row, 'a candidate row is rendered').toBeTruthy(); + expect(row!.getAttribute('role')).toBe('button'); + expect(row!.getAttribute('tabindex')).toBe('0'); + + row!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + await el.updateComplete; + + // Matching the first phantom advances to the second, whose row + // shows the check mark for the one just confirmed. + const matched = el.shadowRoot?.querySelectorAll('.phantom-item.matched'); + + expect(matched).toHaveLength(1); + }); + + it('will not spend one library file on two unmatched tracks', async () => { + const only = candidate('/music/have/one.mp3', 'One', 0.7); + const el = await open([only]); + + candidateRows(el)[0]!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + await el.updateComplete; + await flush(); + await el.updateComplete; + + // The second phantom is selected now and offered the same file, + // which is already standing in for the first. + const row = candidateRows(el)[0]; + + expect(row!.classList.contains('claimed')).toBe(true); + expect(row!.getAttribute('aria-disabled')).toBe('true'); + + row!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + await el.updateComplete; + + // Still one confirmed match, not two. + expect( + el.shadowRoot?.querySelectorAll('.phantom-item.matched'), + ).toHaveLength(1); + }); + + it('gives the auto-match disclosure a keyboard-reachable control', async () => { + stub('playlist.Service.FindPhantomMatches', { + AutoMatched: [ + { + PhantomPath: PHANTOM_A, + PhantomTitle: 'One', + Candidate: candidate('/music/have/one.mp3', 'One', 0.95), + }, + ], + Unmatched: [PHANTOM_B], + }); + stub('playlist.Service.GetPhantomCandidates', []); + + const el = await fixture< + LitElement & { show(id: number, tracks: unknown[]): void } + >('phantom-resolver'); + + el.show(1, [{ FilePath: PHANTOM_A, Title: 'One', Phantom: true }]); + await flush(); + await el.updateComplete; + + const header = el.shadowRoot?.querySelector('.auto-match-header'); + + expect(header?.tagName).toBe('BUTTON'); + expect(header?.getAttribute('aria-expanded')).toBe('false'); + + // aria-controls has to name an element that is in the DOM, so the + // list renders collapsed rather than not at all. + const controls = header?.getAttribute('aria-controls'); + + expect(controls).toBeTruthy(); + expect(el.shadowRoot?.getElementById(controls!)).toBeTruthy(); + }); +}); diff --git a/frontend/test/components/play-in-context.test.ts b/frontend/test/components/play-in-context.test.ts new file mode 100644 index 0000000..457b3aa --- /dev/null +++ b/frontend/test/components/play-in-context.test.ts @@ -0,0 +1,271 @@ +/** + * Playing a track plays the list it is in. + * + * Double-clicking a row — or picking Play from its context menu — used + * to call `SetQueue([thatOnePath], 0)` on the album page, in the track + * list and in the two playlist views' menus: the queue became one + * track, the rest of the album was discarded, and playback stopped at + * the end of it. What a player means by activating a row is "start + * here", and the here is a position in the list on screen. + * + * So the queue is the *displayed* list and `startIndex` is the row. + * Two rules ride along and are what these tests are mostly for: + * + * - The album page's list is the tracks it has files for, so the index + * is into that and not into the tracklist that includes the dimmed + * rows — off-by-however-many-you-do-not-own is silent, it just plays + * the wrong song. + * - A menu asks how much is selected. One row means "from here"; an + * explicit multi-row selection means play exactly those, which is + * the one case where a queue of the selection is what was asked for. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import type { LitElement } from 'lit'; + +import '@components/explore-album-details/explore-album-details'; +import '@components/track-list/track-list'; +import '@components/playlist-details/playlist-details'; +import { stub, flush, resetHarness, lastArgs } from '@test/support/harness'; +import { fixture, shadowAll } from '@test/support/render'; + +/** What `Queue.SetQueue` was last asked to play, and from where. */ +function queued(): { paths: string[]; startIndex: number } { + const args = lastArgs('queue.Queue.SetQueue'); + + if (!args) throw new Error('nothing was queued'); + + return { + paths: args[0] as string[], + startIndex: args[1] as number, + }; +} + +function dblclick(el: Element): void { + el.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true, composed: true }), + ); +} + +// ===================================================================== +// The album page +// ===================================================================== + +function albumTrack(n: number, owned: boolean) { + return { + position: n, + discNumber: 1, + title: `Track ${n}`, + length: 200000, + mbid: `mbid-${n}`, + inLibrary: owned, + }; +} + +/** + * A release on the page without the network, owned every `nth` track. + * The fixture says which tracks have *files*, because that is the one + * question the page asks about ownership. + */ +async function album(total: number, ownedMbids: string[]): Promise { + const el = await fixture('explore-album-details', { + albumName: 'Glass Harbour', + }); + + const tracks = Array.from({ length: total }, (_, i) => + albumTrack(i + 1, ownedMbids.includes(`mbid-${i + 1}`)), + ); + + const paths: Record = {}; + for (const mbid of ownedMbids) paths[mbid] = [`/music/${mbid}.mp3`]; + + stub('library.Library.GetFilePathsByRecordingMBIDs', paths); + + Object.assign(el, { + versionEntries: [ + { key: 'v1', label: '2019', sublabel: `${total} tracks`, tracks }, + ], + selectedVersionKey: 'v1', + loadingReleases: false, + loadingInfo: false, + }); + el.requestUpdate(); + await flush(); + await el.updateComplete; + + return el; +} + +describe('double-clicking a track on the album page', () => { + beforeEach(() => { + resetHarness(); + stub('library.Library.GetFilePathsByAlbums', {}); + stub('library.Library.GetAlbumTracks', []); + stub('library.Library.GetAllLibrariesWithTrackCounts', []); + }); + + it('queues the album and starts on that track', async () => { + const el = await album(6, ['mbid-1', 'mbid-2', 'mbid-3', 'mbid-4', 'mbid-5', 'mbid-6']); + + dblclick(shadowAll(el, '.track-row')[2]!); + await flush(); + + expect(queued()).toEqual({ + paths: [1, 2, 3, 4, 5, 6].map((n) => `/music/mbid-${n}.mp3`), + startIndex: 2, + }); + }); + + it('indexes into what is owned, not into the rows on screen', async () => { + // Tracks 2, 5 and 6 have files; the other three rows are dimmed and + // are not in the queue at all. Row 5 is therefore the *second* + // thing that will play, and an index taken from the row would start + // this album past its end. + const el = await album(6, ['mbid-2', 'mbid-5', 'mbid-6']); + + dblclick(shadowAll(el, '.track-row')[4]!); + await flush(); + + expect(queued()).toEqual({ + paths: ['/music/mbid-2.mp3', '/music/mbid-5.mp3', '/music/mbid-6.mp3'], + startIndex: 1, + }); + }); + + it('does nothing at all on a row with no file behind it', async () => { + const el = await album(6, ['mbid-2']); + + dblclick(shadowAll(el, '.track-row')[0]!); + await flush(); + + expect(lastArgs('queue.Queue.SetQueue')).toBeUndefined(); + }); +}); + +// ===================================================================== +// The track list +// ===================================================================== + +const LIST = Array.from({ length: 12 }, (_, i) => ({ + FilePath: `/music/track-${i}.mp3`, + TrackName: `Track ${i}`, + ArtistName: 'An Artist', + Album: 'An Album', + Duration: 180, +})) as never[]; + +describe('double-clicking a row in the track list', () => { + let el: LitElement; + + beforeEach(async () => { + resetHarness(); + localStorage.removeItem('track-list-column-widths'); + + el = await fixture('track-list', { externalTracks: LIST }); + el.style.display = 'block'; + el.style.height = '600px'; + await flush(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 60)); + }); + + it('queues the list as displayed and starts on that row', async () => { + const rows = shadowAll(el, '.track-row'); + const row = rows.find((r) => r.getAttribute('data-index') === '3'); + + dblclick(row!); + await flush(); + + const { paths, startIndex } = queued(); + + expect([paths.length, paths[startIndex]]).toEqual([ + 12, + '/music/track-3.mp3', + ]); + }); +}); + +// ===================================================================== +// A playlist's context menu +// ===================================================================== + +function playlistTracks(n: number) { + return Array.from({ length: n }, (_, i) => ({ + ID: i + 1, + FilePath: `/music/track-${i}.mp3`, + Title: `Track ${i}`, + Artist: 'An Artist', + Album: 'An Album', + Duration: 180000, + Phantom: false, + })); +} + +describe('Play from a playlist row’s context menu', () => { + let el: LitElement; + + beforeEach(async () => { + resetHarness(); + stub('playlist.Service.GetPlaylistTracks', playlistTracks(8)); + stub('playlist.Service.GetAllPlaylists', []); + + el = await fixture('playlist-details', { + playlistId: 1, + playlistName: 'A playlist', + }); + el.style.display = 'block'; + el.style.height = '600px'; + await flush(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 60)); + }); + + /** Right-click a row, then click the menu's Play item. */ + async function playFromMenu(index: number): Promise { + const row = shadowAll(el, '.track-item').find( + (r) => r.getAttribute('data-index') === String(index), + ); + + row!.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, composed: true }), + ); + await el.updateComplete; + + const items = shadowAll(el, 'wa-dropdown-item'); + const play = items.find((i) => i.textContent?.trim().startsWith('Play') && + !i.textContent.includes('Next')); + + play!.click(); + await flush(); + } + + it('starts the playlist from the row that was clicked', async () => { + await playFromMenu(5); + + const { paths, startIndex } = queued(); + + expect([paths.length, startIndex]).toEqual([8, 5]); + }); + + it('plays only the selection when several rows are selected', async () => { + // The one case where a queue of the selection is what was asked + // for: the user said which tracks, not where to start. + const rows = shadowAll(el, '.track-item'); + const click = (i: number, modifiers: MouseEventInit) => + rows + .find((r) => r.getAttribute('data-index') === String(i))! + .dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true, ...modifiers }), + ); + + click(1, {}); + click(4, { ctrlKey: true }); + await el.updateComplete; + + await playFromMenu(4); + + expect(queued().paths).toEqual([ + '/music/track-1.mp3', + '/music/track-4.mp3', + ]); + }); +}); diff --git a/frontend/test/components/smoke.test.ts b/frontend/test/components/smoke.test.ts index c9368a7..c9125b7 100644 --- a/frontend/test/components/smoke.test.ts +++ b/frontend/test/components/smoke.test.ts @@ -117,10 +117,10 @@ const TAGS = [ */ function stubEmptyBackend(): void { const emptyLists = [ - 'library.Library.GetAllTracks', - 'library.Library.GetAllAlbums', - 'library.Library.GetAllArtists', - 'library.Library.GetAllGenresWithCounts', + 'library.Library.GetTracks', + 'library.Library.GetAlbums', + 'library.Library.GetArtists', + 'library.Library.GetGenres', 'library.Library.GetAllLibrariesWithTrackCounts', 'playlist.Service.GetAllPlaylists', 'playlist.Service.GetAllPlaylistsWithTracks', diff --git a/frontend/test/components/view-lifecycle.test.ts b/frontend/test/components/view-lifecycle.test.ts index 71d7155..6351f85 100644 --- a/frontend/test/components/view-lifecycle.test.ts +++ b/frontend/test/components/view-lifecycle.test.ts @@ -233,10 +233,10 @@ const CACHED_VIEWS = [ * binding resolves undefined, which is not what Go sends. */ function stubEmptyBackend(): void { for (const path of [ - 'library.Library.GetAllTracks', - 'library.Library.GetAllAlbums', - 'library.Library.GetAllArtists', - 'library.Library.GetAllGenresWithCounts', + 'library.Library.GetTracks', + 'library.Library.GetAlbums', + 'library.Library.GetArtists', + 'library.Library.GetGenres', 'library.Library.GetAllLibrariesWithTrackCounts', 'playlist.Service.GetAllPlaylists', 'playlist.Service.GetAllPlaylistsWithTracks', diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts index b7f37ce..6850897 100644 --- a/frontend/test/setup.ts +++ b/frontend/test/setup.ts @@ -35,10 +35,10 @@ const importTimeDefaults: Array<[string, unknown]> = [ // libraryStore and playlistStore fetch eagerly at import. Left // unstubbed they would cache `undefined` — not the empty list Go // sends — and every consumer would then crash on `.length`. - ['library.Library.GetAllTracks', []], - ['library.Library.GetAllAlbums', []], - ['library.Library.GetAllArtists', []], - ['library.Library.GetAllGenresWithCounts', []], + ['library.Library.GetTracks', []], + ['library.Library.GetAlbums', []], + ['library.Library.GetArtists', []], + ['library.Library.GetGenres', []], ['library.Library.GetAllLibrariesWithTrackCounts', []], ['playlist.Service.GetAllPlaylistsWithTracks', []], ]; diff --git a/frontend/test/stores/library-store.test.ts b/frontend/test/stores/library-store.test.ts index 854be49..2430135 100644 --- a/frontend/test/stores/library-store.test.ts +++ b/frontend/test/stores/library-store.test.ts @@ -33,16 +33,16 @@ const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }]; /** Stub every read binding the store can reach. Unstubbed bindings * resolve undefined, which the store would cache as if it were data. */ function stubReads(): void { - stub('library.Library.GetAllTracks', TRACKS); - stub('library.Library.GetAllAlbums', ALBUMS); - stub('library.Library.GetAllArtists', ARTISTS); - stub('library.Library.GetAllGenresWithCounts', GENRES); - stub('library.Library.GetAllTracksByLibrary', TRACKS); - stub('library.Library.GetAllAlbumsByLibrary', ALBUMS); - stub('library.Library.GetAllArtistsByLibrary', ARTISTS); - stub('library.Library.GetAllGenresWithCountsByLibrary', GENRES); + stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetAlbums', ALBUMS); + stub('library.Library.GetArtists', ARTISTS); + stub('library.Library.GetGenres', GENRES); + stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetAlbums', ALBUMS); + stub('library.Library.GetArtists', ARTISTS); + stub('library.Library.GetGenres', GENRES); + stub('library.Library.GetAlbumsByArtist', ALBUMS); stub('library.Library.GetAlbumsByArtist', ALBUMS); - stub('library.Library.GetAlbumsByArtistByLibrary', ALBUMS); stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES); } @@ -69,7 +69,7 @@ describe('library store: caching', () => { it('serves a second read from cache without touching the backend', async () => { await libraryStore.getTracks(); - expect(calls('library.Library.GetAllTracks')).toHaveLength(0); + expect(calls('library.Library.GetTracks')).toHaveLength(0); }); it('deduplicates concurrent first reads into one backend call', async () => { @@ -79,7 +79,7 @@ describe('library store: caching', () => { libraryStore.getArtists(), ]); - expect([a, b, calls('library.Library.GetAllArtists').length]).toEqual([ + expect([a, b, calls('library.Library.GetArtists').length]).toEqual([ ARTISTS, ARTISTS, 1, @@ -100,10 +100,10 @@ describe('library store: caching', () => { await flush(); expect(calls().map((c) => c.path).sort()).toEqual([ - 'library.Library.GetAllAlbums', - 'library.Library.GetAllArtists', - 'library.Library.GetAllGenresWithCounts', - 'library.Library.GetAllTracks', + 'library.Library.GetAlbums', + 'library.Library.GetArtists', + 'library.Library.GetGenres', + 'library.Library.GetTracks', ]); }); @@ -111,7 +111,7 @@ describe('library store: caching', () => { emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' }); await flush(); - expect(calls('library.Library.GetAllTracks')).toHaveLength(1); + expect(calls('library.Library.GetTracks')).toHaveLength(1); }); /* @@ -192,7 +192,7 @@ describe('library store: caching', () => { }); it('does not refetch the tracks', () => { - expect(calls('library.Library.GetAllTracks')).toHaveLength(0); + expect(calls('library.Library.GetTracks')).toHaveLength(0); }); it('splices the removed track out in place', () => { @@ -204,9 +204,9 @@ describe('library store: caching', () => { it('reloads the summaries, whose counts changed', () => { expect( [ - 'library.Library.GetAllAlbums', - 'library.Library.GetAllArtists', - 'library.Library.GetAllGenresWithCounts', + 'library.Library.GetAlbums', + 'library.Library.GetArtists', + 'library.Library.GetGenres', ].map((path) => calls(path).length), ).toEqual([1, 1, 1]); }); @@ -258,7 +258,7 @@ describe('library store: library filter', () => { libraryStore.setSelectedLibrary(7); await flush(); - expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([7]); + expect(lastArgs('library.Library.GetTracks')).toEqual([7]); }); it('ignores a redundant selection instead of invalidating', async () => { @@ -284,10 +284,10 @@ describe('library store: library filter', () => { it('scopes an artist drill-down to the selected library', async () => { libraryStore.setSelectedLibrary(8); - await libraryStore.getAlbumsByArtist(3); + await libraryStore.getAlbumsByArtist('Artist'); - expect(lastArgs('library.Library.GetAlbumsByArtistByLibrary')).toEqual([ - 3, 8, + expect(lastArgs('library.Library.GetAlbumsByArtist')).toEqual([ + 'Artist', 8, ]); }); }); @@ -309,7 +309,7 @@ describe('library store: a fetch that is overtaken', () => { // Only the track fetch is held open; the other three settle at once, // so the test is about the overtaking and nothing else. stub( - 'library.Library.GetAllTracksByLibrary', + 'library.Library.GetTracks', (id: number) => new Promise((resolve) => { pending.push({ id, resolve }); @@ -331,7 +331,7 @@ describe('library store: a fetch that is overtaken', () => { }); it('settles the waiters when the fetch they are waiting on fails', async () => { - stubFailure('library.Library.GetAllTracks', 'sql: database is locked'); + stubFailure('library.Library.GetTracks', 'sql: database is locked'); // Invalidation drops the cache and starts the fetch that fails. emit(Events.LibraryScanComplete); @@ -447,7 +447,7 @@ describe('library store: albums by artist name', () => { }); it('filters the album cache by artist name', () => { - stub('library.Library.GetAllAlbums', [...ALBUMS, ...OTHER_ALBUMS]); + stub('library.Library.GetAlbums', [...ALBUMS, ...OTHER_ALBUMS]); expect(libraryStore.getAlbumsByArtistNameCached('Artist')).toEqual(ALBUMS); }); diff --git a/frontend/test/utils/queue-source-link.test.ts b/frontend/test/utils/queue-source-link.test.ts index 641e23b..bd20bbf 100644 --- a/frontend/test/utils/queue-source-link.test.ts +++ b/frontend/test/utils/queue-source-link.test.ts @@ -1,11 +1,32 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describeQueueSource, isQueueSourceNavigable, navigateToQueueSource, } from '@utils/queue-source-link'; +import { libraryStore } from '@store/library-store'; import type { QueueSource } from '@store/queue-store'; +import { Events } from '../../src/events'; +import { emit, flush, stub } from '@test/support/harness'; + +/** + * The albums the library cache holds for these tests: one tagged, one + * not, since which of the two an album is decides whether the album + * page opens on the catalog or says it is library-only. + */ +const ALBUMS = [ + { ID: 7, Name: 'Scary Monsters', ArtistName: 'David Bowie', MBID: 'rg-7' }, + { ID: 8, Name: 'Untagged', ArtistName: 'Nobody', MBID: '' }, +]; + +/** Drop the cache and refill it, the way a scan completing does. */ +async function warmAlbumCache(): Promise { + stub('library.Library.GetAlbums', ALBUMS); + emit(Events.LibraryScanComplete); + await flush(); + await libraryStore.getAlbums(); +} describe('describeQueueSource', () => { it('returns null for an empty source', () => { @@ -40,6 +61,10 @@ describe('isQueueSourceNavigable', () => { }); describe('navigateToQueueSource', () => { + beforeEach(async () => { + await warmAlbumCache(); + }); + function fireOn(source: QueueSource): unknown { const target = document.createElement('div'); let detail: unknown; @@ -53,10 +78,29 @@ describe('navigateToQueueSource', () => { return detail; } - it('builds the album navigate detail', () => { - expect(fireOn({ type: 'album', id: 7, label: 'Scary Monsters' })).toEqual( - { view: 'explore-album-details', localAlbumId: 7, albumName: 'Scary Monsters' }, - ); + it('builds the album navigate detail, carrying the release group MBID', () => { + expect(fireOn({ type: 'album', id: 7, label: 'Scary Monsters' })).toEqual({ + view: 'explore-album-details', + localAlbumId: 7, + albumName: 'Scary Monsters', + releaseGroupMBID: 'rg-7', + }); + }); + + it('omits the MBID for an untagged album, which really is library-only', () => { + expect(fireOn({ type: 'album', id: 8, label: 'Untagged' })).toEqual({ + view: 'explore-album-details', + localAlbumId: 8, + albumName: 'Untagged', + }); + }); + + it('omits the MBID for an album the cache does not know', () => { + expect(fireOn({ type: 'album', id: 99, label: 'Gone' })).toEqual({ + view: 'explore-album-details', + localAlbumId: 99, + albumName: 'Gone', + }); }); it('builds the playlist navigate detail', () => { diff --git a/scripts/bindings-check.sh b/scripts/bindings-check.sh index 6f8025f..ce5afb3 100755 --- a/scripts/bindings-check.sh +++ b/scripts/bindings-check.sh @@ -22,8 +22,13 @@ cd "$(dirname "$0")/.." TARGET="frontend/bindings" -if [ -n "$(git status --porcelain -- "$TARGET")" ]; then - echo "bindings-check: $TARGET has uncommitted changes; stage or stash them first" >&2 +# Only *unstaged* work is in the way. Generating overwrites the working +# tree, so an unstaged edit here would be destroyed; a staged one is +# precisely what this is being asked about, and rejecting it made the +# hook unsatisfiable — every bindings change is staged by the time the +# commit that carries it runs this. +if ! git diff --quiet -- "$TARGET"; then + echo "bindings-check: $TARGET has unstaged changes; stage or stash them first" >&2 git status --short -- "$TARGET" >&2 exit 1 fi @@ -37,11 +42,12 @@ if ! git diff --quiet -- "$TARGET"; then exit 1 fi -# An added or removed *file* is a rename or a new service, which a diff -# of tracked paths alone does not see. -if [ -n "$(git status --porcelain -- "$TARGET")" ]; then - echo "bindings-check: $TARGET gained or lost files." >&2 - git status --short -- "$TARGET" >&2 +# A *new* file is a new service or a rename, which a diff of tracked +# paths cannot see. (A deleted one it can: the diff above compares the +# working tree against the index, where the file still is.) +if [ -n "$(git ls-files --others --exclude-standard -- "$TARGET")" ]; then + echo "bindings-check: $TARGET gained files." >&2 + git ls-files --others --exclude-standard -- "$TARGET" >&2 exit 1 fi