From d09875f9308e7ed6f7555d8a6f804093ca3e3423 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Sat, 24 Oct 2026 06:30:37 +0200 Subject: [PATCH 1/4] feat: split joined artist credits by decision, and a needs attention page Implements RFC-015: a per-library decision splits a credit such as "Ashreveal, Ashish Swargiary" into the artists it names, or keeps it whole. Every scan applies it again from the stored first-step credits, no album or track id moves, and favourites follow the parts through the journal. The "Needs attention" page lists the credits to decide and the desktop's track categories the server can compute. Signed-off-by: InstaZDLL --- CLAUDE.md | 4 +- Cargo.toml | 4 + docs/rfcs/RFC-015-artist-credit-splitting.md | 4 +- .../20261024020000_artist_credit_decision.sql | 64 + src/api/attention.rs | 126 ++ src/api/mod.rs | 22 + src/catalog.rs | 322 ++++- src/credit_split.rs | 411 +++++++ src/lib.rs | 15 + src/services/attention.rs | 352 ++++++ src/services/credit_decisions.rs | 751 ++++++++++++ src/services/mod.rs | 6 + src/tags.rs | 6 + tests/credits.rs | 1072 +++++++++++++++++ webapp/e2e/studio-nocturne.spec.ts | 245 +++- webapp/src/api/attention.ts | 107 ++ webapp/src/api/index.ts | 19 + webapp/src/api/queries.ts | 51 + webapp/src/components/artist-split.tsx | 74 ++ webapp/src/lib/credit-fragments.test.ts | 33 + webapp/src/lib/credit-fragments.ts | 55 + webapp/src/main.tsx | 35 +- webapp/src/pages/artist-page.tsx | 7 + webapp/src/pages/attention-page.tsx | 443 +++++++ webapp/src/state/i18n.tsx | 97 ++ webapp/src/styles/styles.css | 140 +++ 26 files changed, 4399 insertions(+), 66 deletions(-) create mode 100644 migrations-v2/20261024020000_artist_credit_decision.sql create mode 100644 src/api/attention.rs create mode 100644 src/credit_split.rs create mode 100644 src/services/attention.rs create mode 100644 src/services/credit_decisions.rs create mode 100644 tests/credits.rs create mode 100644 webapp/src/api/attention.ts create mode 100644 webapp/src/components/artist-split.tsx create mode 100644 webapp/src/lib/credit-fragments.test.ts create mode 100644 webapp/src/lib/credit-fragments.ts create mode 100644 webapp/src/pages/attention-page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 439af2e2..8bea50c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ bun run build # webapp first, then cargo — the order Tests need no service container: `test_app()` builds a `Config` over a `TempDir` and the suite drives the same router `main` serves. FFmpeg and ffprobe must be on `PATH`. -The integration suite is eighteen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says. +The integration suite is nineteen targets under `tests/`, one per subject — `auth`, `scanner`, `identity`, `catalog`, `subsonic_contract`, `subsonic_browse`, `subsonic_fields`, `subsonic_methods`, `native_api`, `sync`, `media`, `service`, `cli`, `scrobbling`, `enrichment`, `credits`, `uploads`, `canvas`, `migrations` — nearly all of them sharing `tests/support/mod.rs`. `migrations` is the exception and deliberately so: every other target starts from `test_app()`, which builds its database from nothing, and that is exactly the path an upgrade does not take. It seeds a database at each **released** schema, boots the server on it, and reads the rows back through the services — and it is where anything else that needs a database *behind* this binary belongs, such as proving that a key change reads without migrating and copies the pair aside before it does. `autotests = false`, so each is a target only because `Cargo.toml` declares it: a new file under `tests/` is compiled by nothing until a `[[test]]` entry names it, and the count above is worth checking against that list rather than trusted. `support/mod.rs` is the exception that proves it — shared module code, declared nowhere, a target never. File a new test by the surface it exercises, and check that by what it calls rather than by what its name says. `cargo build` works without a client build — `build.rs` embeds a placeholder page when `webapp/dist` is absent. A real `vite build` overwrites it. @@ -60,7 +60,7 @@ The integration suite is eighteen targets under `tests/`, one per subject — `a - **404 blurs everything.** A resource that is missing, and one that belongs to another account, answer identically. `ServiceError::Forbidden` maps onto 404 for that reason. Never confirm existence to someone not entitled to it. - **SQLite discipline.** WAL, foreign keys, `busy_timeout`. Every mutation takes the process-wide writer gate (`db.writer_guard()`); libraries may scan concurrently but never open independent writers. `NULL` sorts first in SQLite — order explicitly with `NULLS LAST` wherever a missing tag would otherwise jump the queue. - **Migrations are immutable once merged.** They are embedded at compile time and checksummed, so editing an applied migration makes the server refuse to start. Schema changes are new dated files under `migrations-v2/`. -- **Identifiers and time.** Public ids are UUIDs; timestamps are Unix epoch milliseconds. Album and artist ids are *derived* UUIDs (version 8, RFC 9562 §5.8): the identity engine in `src/pid.rs` hashes the tags that name the entity, under the configurable spec `WAVEFLOW_PID_ALBUM`/`WAVEFLOW_PID_ARTIST`, so the same files answer the same ids on a fresh install. Track ids stay drawn at random — six tables cascade off them, and a scan matches a track by path, then by content hash, then by the relocation hint in `track.pid`. That hint is the track spec evaluated over the file's tags, and it stays a hint: it is asked only when the first two have nothing to say — a file both moved and re-encoded — and it moves nothing unless it names exactly one track whose file has genuinely disappeared. Changing `WAVEFLOW_PID_TRACK` drops every stored hint on the next boot: a hint written under one spec must never be compared against a lookup computed under another, because two specs can evaluate to the same string and the hint would then hand a new file an unrelated track's identity. The next ordinary scan writes them back from its skip path, so clearing them costs no rescan — and a track spec change still re-identifies nothing. Changing a spec re-identifies everything it governs: the next boot notices, schedules a full rescan of every library, and album favourites do not survive it. +- **Identifiers and time.** Public ids are UUIDs; timestamps are Unix epoch milliseconds. Album and artist ids are *derived* UUIDs (version 8, RFC 9562 §5.8): the identity engine in `src/pid.rs` hashes the tags that name the entity, under the configurable spec `WAVEFLOW_PID_ALBUM`/`WAVEFLOW_PID_ARTIST`, so the same files answer the same ids on a fresh install. Track ids stay drawn at random — six tables cascade off them, and a scan matches a track by path, then by content hash, then by the relocation hint in `track.pid`. That hint is the track spec evaluated over the file's tags, and it stays a hint: it is asked only when the first two have nothing to say — a file both moved and re-encoded — and it moves nothing unless it names exactly one track whose file has genuinely disappeared. Changing `WAVEFLOW_PID_TRACK` drops every stored hint on the next boot: a hint written under one spec must never be compared against a lookup computed under another, because two specs can evaluate to the same string and the hint would then hand a new file an unrelated track's identity. The next ordinary scan writes them back from its skip path, so clearing them costs no rescan — and a track spec change still re-identifies nothing. Changing a spec re-identifies everything it governs: the next boot notices, schedules a full rescan of every library, and album favourites do not survive it. A credit decision (RFC-015) is narrower: splitting a joined credit such as `Zedd, Alessia Cara` re-identifies only the artists it names. The album display string and the track hint read the tag mapper's names, before any decision, so no album or track id moves, and favourites on the joined artist are carried onto its parts through the journal. - **Credentials.** Web passwords use Argon2id. Access, refresh, API and OAuth codes are stored as SHA-256 hashes only. The dedicated Subsonic password is encrypted with ChaCha20-Poly1305 under the instance key. Back up `data/waveflow.db` and `data/instance.key` together — encrypted values cannot be recovered with one without the other. - **Browser playback uses stream tickets** (`src/stream_ticket.rs`). `