From fcde7ea33dd9692bc9298294b0cdaca983deda91 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:34:44 +0100 Subject: [PATCH 1/5] refactor(storage): move database and catalog into module directories Signed-off-by: InstaZDLL --- src/{catalog.rs => catalog/mod.rs} | 0 src/{database.rs => database/mod.rs} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{catalog.rs => catalog/mod.rs} (100%) rename src/{database.rs => database/mod.rs} (100%) diff --git a/src/catalog.rs b/src/catalog/mod.rs similarity index 100% rename from src/catalog.rs rename to src/catalog/mod.rs diff --git a/src/database.rs b/src/database/mod.rs similarity index 100% rename from src/database.rs rename to src/database/mod.rs From 219aa7fe09dc67cbcdf29fc0c844e2042a8123c8 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:52:46 +0100 Subject: [PATCH 2/5] refactor(storage): split the database module by domain Signed-off-by: InstaZDLL --- src/database/accounts.rs | 146 ++ src/database/keys.rs | 297 +++ src/database/libraries.rs | 289 +++ src/database/metadata.rs | 453 +++++ src/database/mod.rs | 2624 +------------------------- src/database/sessions.rs | 592 ++++++ src/database/subsonic_credentials.rs | 176 ++ src/database/sync_operations.rs | 90 + src/database/tests.rs | 575 ++++++ 9 files changed, 2669 insertions(+), 2573 deletions(-) create mode 100644 src/database/accounts.rs create mode 100644 src/database/keys.rs create mode 100644 src/database/libraries.rs create mode 100644 src/database/metadata.rs create mode 100644 src/database/sessions.rs create mode 100644 src/database/subsonic_credentials.rs create mode 100644 src/database/sync_operations.rs create mode 100644 src/database/tests.rs diff --git a/src/database/accounts.rs b/src/database/accounts.rs new file mode 100644 index 00000000..037ed8b0 --- /dev/null +++ b/src/database/accounts.rs @@ -0,0 +1,146 @@ +//! Accounts: the first administrator, creating an account and finding one. + +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{insert_audit, parse_role, parse_uuid, Database}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum AccountRole { + Admin, + User, +} + +impl AccountRole { + pub fn as_str(self) -> &'static str { + match self { + Self::Admin => "admin", + Self::User => "user", + } + } +} + +impl FromStr for AccountRole { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "admin" => Ok(Self::Admin), + "user" => Ok(Self::User), + _ => anyhow::bail!("unknown account role: {value}"), + } + } +} + +#[derive(Debug, Clone)] +pub struct AccountRecord { + pub id: Uuid, + pub username: String, + pub password_hash: String, + pub role: AccountRole, + pub disabled: bool, +} + +impl Database { + pub async fn setup_required(&self) -> Result { + let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM account") + .fetch_one(&self.pool) + .await?; + Ok(count == 0) + } + + pub async fn bootstrap_admin( + &self, + username: &str, + password_hash: &str, + now_ms: i64, + ) -> Result, sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let id = Uuid::new_v4(); + let inserted = sqlx::query( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + SELECT ?, ?, ?, 'admin', ?, ? WHERE NOT EXISTS (SELECT 1 FROM account)", + ) + .bind(id.to_string()) + .bind(username.trim()) + .bind(password_hash) + .bind(now_ms) + .bind(now_ms) + .execute(&mut *tx) + .await? + .rows_affected() + == 1; + if inserted { + insert_audit(&mut tx, Some(id), "instance.bootstrapped", Some(id), now_ms).await?; + } + tx.commit().await?; + Ok(inserted.then_some(id)) + } + + pub async fn create_account( + &self, + username: &str, + password_hash: &str, + role: AccountRole, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(username.trim()) + .bind(password_hash) + .bind(role.as_str()) + .bind(now_ms) + .bind(now_ms) + .execute(&mut *tx) + .await?; + insert_audit(&mut tx, Some(id), "account.created", Some(id), now_ms).await?; + tx.commit().await?; + Ok(id) + } + + pub async fn account_by_username( + &self, + username: &str, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT id, username, password_hash, role, disabled \ + FROM account WHERE username = ? COLLATE NOCASE", + ) + .bind(username.trim()) + .fetch_optional(&self.pool) + .await?; + row.map(account_from_row).transpose() + } + + pub async fn account_by_id(&self, id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT id, username, password_hash, role, disabled FROM account WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&self.pool) + .await?; + row.map(account_from_row).transpose() + } +} + +fn account_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(AccountRecord { + id: parse_uuid(row.try_get("id")?)?, + username: row.try_get("username")?, + password_hash: row.try_get("password_hash")?, + role: parse_role(row.try_get("role")?)?, + disabled: row.try_get::("disabled")? != 0, + }) +} diff --git a/src/database/keys.rs b/src/database/keys.rs new file mode 100644 index 00000000..eb4ff9ed --- /dev/null +++ b/src/database/keys.rs @@ -0,0 +1,297 @@ +//! The instance key: binding a database to it, reading what it protects, and +//! moving everything it governs onto a new one. + +use std::path::Path; + +use sqlx::{ + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, + Connection, Row, SqliteConnection, +}; +use uuid::Uuid; + +use super::Database; + +/// What an instance key protects, counted from the database. +/// +/// Four numbers rather than a sentence, because the sentence is what the +/// documentation already carries and it is not what an operator needs at the +/// moment of deciding. The third is the one nobody expects: discarding the +/// scrobbling links discards the listens queued behind them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct KeyMaterial { + pub subsonic_credentials: i64, + pub scrobble_links: i64, + pub queued_listens: i64, + pub shares: i64, +} + +/// What to do with the values the previous key protected. +/// +/// Two variants, and two commands above them rather than one command and a +/// flag: one of these preserves exactly what the other destroys, and that is +/// not a detail of a single operation. +pub enum Rekey<'a> { + /// The old key is in hand. Every ciphertext is read and written back. + Reencrypt(&'a crate::security::SecretBox), + /// The old key is gone. Nothing can be read, so the rows go. + Discard, +} + +impl Database { + pub async fn bind_instance_key( + &self, + fingerprint: &[u8], + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + sqlx::query( + "INSERT INTO instance_metadata (singleton, key_fingerprint, created_at) \ + VALUES (1, ?, ?) ON CONFLICT (singleton) DO NOTHING", + ) + .bind(fingerprint) + .bind(now_ms) + .execute(&mut *tx) + .await?; + let stored: Vec = + sqlx::query_scalar("SELECT key_fingerprint FROM instance_metadata WHERE singleton=1") + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(stored == fingerprint) + } + + pub async fn check_file_instance_key(path: &Path, fingerprint: &[u8]) -> anyhow::Result { + let options = SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + let stored = sqlx::query_scalar::<_, Vec>( + "SELECT key_fingerprint FROM instance_metadata WHERE singleton=1", + ) + .fetch_optional(&pool) + .await?; + pool.close().await; + Ok(stored.is_some_and(|stored| stored == fingerprint)) + } + + /// The fingerprint this database was bound to, if it has been bound. + /// + /// Absent on a database no server has ever opened. Present and different + /// is what aborts a boot, and what tells the two key commands apart. + pub async fn stored_key_fingerprint( + connection: &mut SqliteConnection, + ) -> anyhow::Result>> { + Ok(sqlx::query_scalar::<_, Vec>( + "SELECT key_fingerprint FROM instance_metadata WHERE singleton=1", + ) + .fetch_optional(&mut *connection) + .await?) + } + + /// What the instance key protects, counted rather than described. + /// + /// The operator is about to be told what a key change costs, and a count + /// read from their own database is the only version of that worth + /// printing. + pub async fn key_material(connection: &mut SqliteConnection) -> anyhow::Result { + let count = |sql: &'static str| sqlx::query_scalar::<_, i64>(sql); + Ok(KeyMaterial { + subsonic_credentials: count("SELECT COUNT(*) FROM subsonic_credential") + .fetch_one(&mut *connection) + .await?, + scrobble_links: count("SELECT COUNT(*) FROM scrobble_link") + .fetch_one(&mut *connection) + .await?, + // Every row of the outbox hangs off a link by `ON DELETE CASCADE`, + // so discarding the links discards these with them. Counted + // because an operator told "two scrobbling links" would not guess + // that it also means a few hundred listens nobody has sent yet. + queued_listens: count("SELECT COUNT(*) FROM scrobble_outbox") + .fetch_one(&mut *connection) + .await?, + shares: count("SELECT COUNT(*) FROM share") + .fetch_one(&mut *connection) + .await?, + }) + } + + /// Moves every value the instance key governs onto a new key. + /// + /// One transaction, so a database half under one key and half under + /// another is not a state this can leave behind. The caller writes the new + /// key file only after this returns, for the same reason: a key that + /// arrived before the commit would name a database that never changed. + /// + /// Shares are rewritten whichever plan runs, and they are the reason this + /// is one function rather than two. A share token is *derived* from the + /// key rather than stored ([`crate::security::SecretBox::derive_share_token`]), + /// so after a key change the stored hash is one the new key can never + /// produce: the share is not merely re-addressed, it is unreachable, and + /// its owner is shown a URL that answers 404. Recomputing the hash is what + /// makes "shares survive, with a new URL" true rather than hoped for. + pub async fn rekey( + connection: &mut SqliteConnection, + plan: Rekey<'_>, + new_key: &crate::security::SecretBox, + new_fingerprint: &[u8], + now_ms: i64, + ) -> anyhow::Result { + // Asked for here rather than taken on trust from the caller. It is a + // connection flag, `migrate_on` turns it off, and the delete below is + // the one statement whose correctness depends on it — a cascade that + // did not fire leaves no error, only rows. A no-op inside a + // transaction, so it has to come before the `begin`. + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *connection) + .await + .map_err(|error| anyhow::anyhow!("could not enforce foreign keys: {error}"))?; + let enforced = sqlx::query_scalar::<_, i64>("PRAGMA foreign_keys") + .fetch_one(&mut *connection) + .await?; + if enforced != 1 { + anyhow::bail!("foreign keys are not enforced; nothing has been changed"); + } + let mut tx = connection.begin().await?; + let mut moved = KeyMaterial::default(); + match plan { + Rekey::Reencrypt(old_key) => { + let credentials = sqlx::query( + "SELECT user_id, password_nonce, password_ciphertext FROM subsonic_credential", + ) + .fetch_all(&mut *tx) + .await?; + for row in credentials { + let user_id: String = row.try_get("user_id")?; + let plaintext = old_key + .decrypt( + row.try_get::, _>("password_nonce")?.as_slice(), + row.try_get::, _>("password_ciphertext")?.as_slice(), + ) + .map_err(|_| { + anyhow::anyhow!( + "a stored Subsonic password does not open with this key; \ + nothing has been changed" + ) + })?; + let sealed = new_key.encrypt(&plaintext)?; + sqlx::query( + "UPDATE subsonic_credential \ + SET password_nonce=?, password_ciphertext=?, updated_at=? \ + WHERE user_id=?", + ) + .bind(sealed.nonce.as_slice()) + .bind(&sealed.ciphertext) + .bind(now_ms) + .bind(&user_id) + .execute(&mut *tx) + .await?; + moved.subsonic_credentials += 1; + } + + // The `unlinked` generations included: they keep their + // ciphertext, and a past authorisation left unreadable would + // be a row this server can no longer account for. + let links = sqlx::query( + "SELECT id, credential_nonce, credential_ciphertext FROM scrobble_link", + ) + .fetch_all(&mut *tx) + .await?; + for row in links { + let id: String = row.try_get("id")?; + let plaintext = old_key + .decrypt( + row.try_get::, _>("credential_nonce")?.as_slice(), + row.try_get::, _>("credential_ciphertext")? + .as_slice(), + ) + .map_err(|_| { + anyhow::anyhow!( + "a stored scrobbling secret does not open with this key; \ + nothing has been changed" + ) + })?; + let sealed = new_key.encrypt(&plaintext)?; + sqlx::query( + "UPDATE scrobble_link \ + SET credential_nonce=?, credential_ciphertext=?, updated_at=? \ + WHERE id=?", + ) + .bind(sealed.nonce.as_slice()) + .bind(&sealed.ciphertext) + .bind(now_ms) + .bind(&id) + .execute(&mut *tx) + .await?; + moved.scrobble_links += 1; + } + } + Rekey::Discard => { + // The outbox goes with the links, by `ON DELETE CASCADE` + // rather than by a statement here — which only holds with + // enforcement on, and `migrate_on` leaves it off. Counted + // before the delete and asserted against afterwards, because + // a cascade that silently did not fire would leave the number + // printed to the operator as the only trace of it. + moved.queued_listens = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM scrobble_outbox \ + WHERE link_id IN (SELECT id FROM scrobble_link)", + ) + .fetch_one(&mut *tx) + .await?; + moved.subsonic_credentials = sqlx::query("DELETE FROM subsonic_credential") + .execute(&mut *tx) + .await? + .rows_affected() as i64; + moved.scrobble_links = sqlx::query("DELETE FROM scrobble_link") + .execute(&mut *tx) + .await? + .rows_affected() as i64; + let orphans = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM scrobble_outbox") + .fetch_one(&mut *tx) + .await?; + if orphans > 0 { + anyhow::bail!( + "{orphans} queued listen(s) outlived the links they belong to; \ + foreign keys were not enforced and nothing has been changed" + ); + } + } + } + + for id in sqlx::query_scalar::<_, String>("SELECT id FROM share") + .fetch_all(&mut *tx) + .await? + { + let share = Uuid::parse_str(&id) + .map_err(|_| anyhow::anyhow!("a share carries an identifier that is not a UUID"))?; + let token = new_key.derive_share_token(share); + // `updated_at` is left alone on purpose. The share did not change; + // the key that names it did, and bumping the column would hand + // every synchronising client a modification with no journal entry + // behind it. + sqlx::query("UPDATE share SET token_hash=? WHERE id=?") + .bind(crate::security::token_hash(&token).as_slice()) + .bind(&id) + .execute(&mut *tx) + .await?; + moved.shares += 1; + } + + sqlx::query( + "INSERT INTO instance_metadata (singleton, key_fingerprint, created_at) \ + VALUES (1, ?, ?) \ + ON CONFLICT (singleton) DO UPDATE SET key_fingerprint = excluded.key_fingerprint", + ) + .bind(new_fingerprint) + .bind(now_ms) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(moved) + } +} diff --git a/src/database/libraries.rs b/src/database/libraries.rs new file mode 100644 index 00000000..07d09ad1 --- /dev/null +++ b/src/database/libraries.rs @@ -0,0 +1,289 @@ +//! Libraries: their visibility and member roles, creating one, its members, +//! and the operator's switches for uploads and canvases. + +use std::{path::Path, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::{insert_audit, Database}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LibraryVisibility { + Private, + Shared, +} + +impl LibraryVisibility { + pub fn as_str(self) -> &'static str { + match self { + Self::Private => "private", + Self::Shared => "shared", + } + } +} + +impl FromStr for LibraryVisibility { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "private" => Ok(Self::Private), + "shared" => Ok(Self::Shared), + _ => anyhow::bail!("visibility must be private or shared"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LibraryRole { + Owner, + Manager, + Listener, +} + +impl LibraryRole { + /// Whether the role may spend the owner's disk on a rescan. + /// + /// The same rule the `scan_job` insert enforces in SQL, named once here + /// so the Subsonic fan-out over every reachable library agrees with it + /// instead of discovering the refusal one insert at a time. + pub fn may_scan(self) -> bool { + matches!(self, Self::Owner | Self::Manager) + } + + /// Whether the role may correct a track's tags. + /// + /// The same two roles as [`Self::may_scan`], and not by coincidence: a + /// correction is written once and read by every member, so it asks the same + /// trust as spending the owner's disk. Named apart because the two rules + /// answer different questions and one of them may move. + pub fn may_write_metadata(self) -> bool { + matches!(self, Self::Owner | Self::Manager) + } + + /// Whether the role may hand the library a new file. + /// + /// The same two roles again, and deliberately so: a rescan spends the + /// owner's disk, and so does receiving a file — permanently, this time. + /// It is never sufficient on its own. `library.accepts_uploads` decides + /// where, and a role that may upload into an opted-in library may upload + /// into no other. + pub fn may_upload(self) -> bool { + matches!(self, Self::Owner | Self::Manager) + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Owner => "owner", + Self::Manager => "manager", + Self::Listener => "listener", + } + } +} + +impl FromStr for LibraryRole { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "owner" => Ok(Self::Owner), + "manager" => Ok(Self::Manager), + "listener" => Ok(Self::Listener), + _ => anyhow::bail!("library role must be owner, manager or listener"), + } + } +} + +impl Database { + pub async fn create_library( + &self, + owner_id: Uuid, + name: &str, + root_path: &Path, + visibility: LibraryVisibility, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let id = Uuid::new_v4(); + let root_path = root_path.to_string_lossy(); + sqlx::query( + "INSERT INTO library \ + (id, owner_user_id, name, root_path, visibility, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(owner_id.to_string()) + .bind(name.trim()) + .bind(root_path.as_ref()) + .bind(visibility.as_str()) + .bind(now_ms) + .bind(now_ms) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO library_member (library_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)", + ) + .bind(id.to_string()) + .bind(owner_id.to_string()) + .bind(now_ms) + .execute(&mut *tx) + .await?; + insert_audit(&mut tx, Some(owner_id), "library.created", Some(id), now_ms).await?; + tx.commit().await?; + Ok(id) + } + + pub async fn add_library_member( + &self, + actor_id: Uuid, + library_id: Uuid, + user_id: Uuid, + role: LibraryRole, + now_ms: i64, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + sqlx::query( + "INSERT INTO library_member (library_id, user_id, role, created_at) VALUES (?, ?, ?, ?) \ + ON CONFLICT (library_id, user_id) DO UPDATE SET role = excluded.role", + ) + .bind(library_id.to_string()) + .bind(user_id.to_string()) + .bind(role.as_str()) + .bind(now_ms) + .execute(&mut *tx) + .await?; + insert_audit( + &mut tx, + Some(actor_id), + "library.member_set", + Some(library_id), + now_ms, + ) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn remove_library_member( + &self, + actor_id: Uuid, + library_id: Uuid, + user_id: Uuid, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let removed = sqlx::query( + "DELETE FROM library_member \ + WHERE library_id=? AND user_id=? AND role!='owner'", + ) + .bind(library_id.to_string()) + .bind(user_id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected() + == 1; + if removed { + insert_audit( + &mut tx, + Some(actor_id), + "library.member_removed", + Some(library_id), + now_ms, + ) + .await?; + } + tx.commit().await?; + Ok(removed) + } + + /// Opens or closes a library to receiving files. + /// + /// Audited, like every other change to what a library permits. The role a + /// member holds says who may upload; this says whether the library takes + /// anything at all, and it is the operator's call rather than a member's — + /// which is why it arrives through the CLI and not through the API. + pub async fn set_library_accepts_uploads( + &self, + actor_id: Uuid, + library_id: Uuid, + accepts: bool, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let changed = sqlx::query("UPDATE library SET accepts_uploads=? WHERE id=?") + .bind(i64::from(accepts)) + .bind(library_id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected() + == 1; + if changed { + insert_audit( + &mut tx, + Some(actor_id), + if accepts { + "library.uploads_opened" + } else { + "library.uploads_closed" + }, + Some(library_id), + now_ms, + ) + .await?; + } + tx.commit().await?; + Ok(changed) + } + + /// Opens or closes a library to receiving canvases. + /// + /// A second door rather than the same one, and the reasoning is in + /// `20260830010000_library_accepts_canvas.sql`: a read-only server that + /// refuses to grow in audio may still want a few hundred kilobytes of video + /// loop, and that is the most common installation there is. + /// + /// Audited and CLI-only, exactly like the upload flag. The role a member + /// holds says who may place one; this says whether the library takes any at + /// all, and that is the operator's call rather than a member's. + pub async fn set_library_accepts_canvas( + &self, + actor_id: Uuid, + library_id: Uuid, + accepts: bool, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let changed = sqlx::query("UPDATE library SET accepts_canvas=? WHERE id=?") + .bind(i64::from(accepts)) + .bind(library_id.to_string()) + .execute(&mut *tx) + .await? + .rows_affected() + == 1; + if changed { + insert_audit( + &mut tx, + Some(actor_id), + if accepts { + "library.canvas_opened" + } else { + "library.canvas_closed" + }, + Some(library_id), + now_ms, + ) + .await?; + } + tx.commit().await?; + Ok(changed) + } +} diff --git a/src/database/metadata.rs b/src/database/metadata.rs new file mode 100644 index 00000000..320ae663 --- /dev/null +++ b/src/database/metadata.rs @@ -0,0 +1,453 @@ +//! The provider caches: artist metadata and neighbours (RFC-011), the +//! portraits and banners linked from them (RFC-014), and lyrics lookups +//! (RFC-024). + +use sqlx::Row; +use uuid::Uuid; + +use super::Database; + +/// RFC-011's cache, which [`crate::enrichment`] fills and reads. +/// +/// No user id and no tenancy anywhere in it: what a provider says about a name +/// is the same whoever asks, and nothing here is account data (decision 4). +/// Tenancy applies where a cached name meets the catalogue, which is the +/// reader's query, not these. +impl Database { + /// Every provider's row for one canonical name, in no particular order. + /// + /// A row from a provider this build does not know — written by a later + /// version the instance was downgraded from — is passed over rather than + /// refused: it is a fact about a provider nobody configured here. + pub async fn metadata_artist_rows( + &self, + name_canonical: &str, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT provider, outcome, bio_summary, bio_full, image_hash, fanart_hash, \ + fetched_at, expires_at \ + FROM metadata_artist WHERE name_canonical = ?", + ) + .bind(name_canonical) + .fetch_all(&self.pool) + .await?; + let mut cached = Vec::with_capacity(rows.len()); + for row in rows { + let provider: String = row.try_get("provider")?; + let Some(provider) = crate::enrichment::Provider::parse(&provider) else { + continue; + }; + let outcome: String = row.try_get("outcome")?; + let outcome = crate::enrichment::Outcome::parse(&outcome).ok_or_else(|| { + sqlx::Error::Decode(format!("unknown enrichment outcome {outcome:?}").into()) + })?; + cached.push(crate::enrichment::CachedArtist { + provider, + outcome, + bio_summary: row.try_get("bio_summary")?, + bio_full: row.try_get("bio_full")?, + image_hash: row.try_get("image_hash")?, + fanart_hash: row.try_get("fanart_hash")?, + fetched_at: row.try_get("fetched_at")?, + expires_at: row.try_get("expires_at")?, + }); + } + Ok(cached) + } + + /// One provider's ranked neighbours for one canonical name, best first. + pub async fn metadata_artist_similar( + &self, + provider: crate::enrichment::Provider, + name_canonical: &str, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT similar_name, similar_canonical, score FROM metadata_artist_similar \ + WHERE provider = ? AND name_canonical = ? ORDER BY rank", + ) + .bind(provider.as_str()) + .bind(name_canonical) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(crate::enrichment::CachedSimilar { + name: row.try_get("similar_name")?, + name_canonical: row.try_get("similar_canonical")?, + score: row.try_get("score")?, + }) + }) + .collect() + } + + /// Links the portrait `hash` to every artist called `name_canonical` that + /// does not show it yet, emitting one artist event per row it moved. + /// + /// Read first without the writer gate, since this runs on the read path + /// and almost always finds nothing to do. The artwork row is checked in + /// the same statement as the link: the sweeper may have taken it since + /// the cache row was read. + pub(crate) async fn link_artist_portrait( + &self, + name_canonical: &str, + hash: &str, + ) -> Result<(), sqlx::Error> { + let unlinked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM artist \ + WHERE canonical_name = ? AND artwork_hash IS NOT ?)", + ) + .bind(name_canonical) + .bind(hash) + .fetch_one(&self.pool) + .await?; + if !unlinked { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + link_portrait_in( + &mut tx, + name_canonical, + hash, + crate::authentication::now_ms(), + ) + .await?; + tx.commit().await + } + + /// Links the banner `hash` to every artist called `name_canonical` that + /// does not show it yet — [`Self::link_artist_portrait`], for the fanart + /// of RFC-014. + pub(crate) async fn link_artist_fanart( + &self, + name_canonical: &str, + hash: &str, + ) -> Result<(), sqlx::Error> { + let unlinked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM artist \ + WHERE canonical_name = ? AND fanart_hash IS NOT ?)", + ) + .bind(name_canonical) + .bind(hash) + .fetch_one(&self.pool) + .await?; + if !unlinked { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + link_fanart_in( + &mut tx, + name_canonical, + Some(hash), + crate::authentication::now_ms(), + ) + .await?; + tx.commit().await + } + + /// What one lyrics provider said for one lookup key (RFC-024 decision + /// 4), if it said anything. + pub async fn lyrics_lookup_row( + &self, + provider: &str, + lookup_key: &str, + ) -> Result, sqlx::Error> { + let Some(row) = sqlx::query( + "SELECT outcome, synced_lyrics, plain_lyrics, fetched_at, expires_at \ + FROM lyrics_lookup WHERE provider = ? AND lookup_key = ?", + ) + .bind(provider) + .bind(lookup_key) + .fetch_optional(&self.pool) + .await? + else { + return Ok(None); + }; + let outcome: String = row.try_get("outcome")?; + let outcome = crate::lrclib::LyricsOutcome::parse(&outcome).ok_or_else(|| { + sqlx::Error::Decode(format!("unknown lyrics outcome {outcome:?}").into()) + })?; + Ok(Some(crate::lrclib::CachedLyrics { + outcome, + synced: row.try_get("synced_lyrics")?, + plain: row.try_get("plain_lyrics")?, + fetched_at: row.try_get("fetched_at")?, + expires_at: row.try_get("expires_at")?, + })) + } + + /// Records what a lyrics provider answered, replacing what it said + /// before. Taken under the writer gate, after the request is over. + pub(crate) async fn write_lyrics_lookup( + &self, + provider: &str, + lookup_key: &str, + answer: &crate::lrclib::CachedLyrics, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + sqlx::query( + "INSERT INTO lyrics_lookup \ + (provider, lookup_key, outcome, synced_lyrics, plain_lyrics, fetched_at, expires_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT (provider, lookup_key) DO UPDATE SET \ + outcome = excluded.outcome, synced_lyrics = excluded.synced_lyrics, \ + plain_lyrics = excluded.plain_lyrics, fetched_at = excluded.fetched_at, \ + expires_at = excluded.expires_at", + ) + .bind(provider) + .bind(lookup_key) + .bind(answer.outcome.as_str()) + .bind(answer.synced.as_deref()) + .bind(answer.plain.as_deref()) + .bind(answer.fetched_at) + .bind(answer.expires_at) + .execute(&mut *tx) + .await?; + tx.commit().await + } + + /// Records what a provider answered: its row, its whole neighbour list, + /// and its portrait — the artwork row and every artist it belongs to — + /// in one transaction. + /// + /// One transaction is what keeps the sweeper from ever seeing the artwork + /// row unreferenced: it deletes under this same gate, so it runs wholly + /// before or wholly after. The writer gate is taken here and nowhere + /// earlier — the requests that produced `answer` are over, and the + /// portrait's file is already in the store, so no network or file I/O + /// ever runs under it. + pub(crate) async fn write_metadata_artist( + &self, + provider: crate::enrichment::Provider, + name_canonical: &str, + answer: &crate::enrichment::ArtistAnswer, + fetched_at: i64, + expires_at: i64, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + // What this provider's row named before, read before it is replaced: + // a refresh without the banner unlinks it (RFC-014 decision 3). + let previous_fanart: Option = sqlx::query_scalar( + "SELECT fanart_hash FROM metadata_artist WHERE provider = ? AND name_canonical = ?", + ) + .bind(provider.as_str()) + .bind(name_canonical) + .fetch_optional(&mut *tx) + .await? + .flatten(); + for image in [&answer.image, &answer.fanart].into_iter().flatten() { + sqlx::query( + "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ + VALUES (?, ?, ?, ?, ?) ON CONFLICT (hash) DO NOTHING", + ) + .bind(&image.hash) + .bind(image.format) + .bind(provider.as_str()) + .bind(image.byte_size) + .bind(fetched_at) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "INSERT INTO metadata_artist \ + (provider, name_canonical, outcome, bio_summary, bio_full, image_hash, \ + fanart_hash, fetched_at, expires_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT (provider, name_canonical) DO UPDATE SET \ + outcome = excluded.outcome, bio_summary = excluded.bio_summary, \ + bio_full = excluded.bio_full, image_hash = excluded.image_hash, \ + fanart_hash = excluded.fanart_hash, \ + fetched_at = excluded.fetched_at, expires_at = excluded.expires_at", + ) + .bind(provider.as_str()) + .bind(name_canonical) + .bind(answer.outcome().as_str()) + .bind(answer.bio_summary.as_deref()) + .bind(answer.bio_full.as_deref()) + .bind(answer.image.as_ref().map(|image| image.hash.as_str())) + .bind(answer.fanart.as_ref().map(|fanart| fanart.hash.as_str())) + .bind(fetched_at) + .bind(expires_at) + .execute(&mut *tx) + .await?; + if let Some(image) = &answer.image { + link_portrait_in(&mut tx, name_canonical, &image.hash, fetched_at).await?; + } + match (&answer.fanart, previous_fanart) { + (Some(fanart), _) => { + link_fanart_in(&mut tx, name_canonical, Some(&fanart.hash), fetched_at).await?; + } + // A miss, or a hit that no longer carries the fanart: the rows + // still showing the one this provider named go back to none. + (None, Some(previous)) => { + unlink_fanart_in(&mut tx, name_canonical, &previous, fetched_at).await?; + } + (None, None) => {} + } + sqlx::query( + "DELETE FROM metadata_artist_similar WHERE provider = ? AND name_canonical = ?", + ) + .bind(provider.as_str()) + .bind(name_canonical) + .execute(&mut *tx) + .await?; + for (rank, similar) in answer.similar.iter().enumerate() { + sqlx::query( + "INSERT INTO metadata_artist_similar \ + (provider, name_canonical, rank, similar_name, similar_canonical, score) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(provider.as_str()) + .bind(name_canonical) + .bind(rank as i64) + .bind(&similar.name) + .bind(&similar.name_canonical) + .bind(similar.score) + .execute(&mut *tx) + .await?; + } + tx.commit().await + } +} + +/// Points every artist called `name_canonical` at the portrait `hash`, and +/// records one artist event for each row that actually moved — RFC-007 +/// decision 6's rule for albums, which an artist now follows: a refresh that +/// fetched the same picture changes nothing and says nothing. +/// +/// Artist rows are per library, and so are events, so each moved row carries +/// its own library. A row whose artwork row is gone is not linked: the cache +/// may name a hash the sweeper has since taken. Nor is a hash the cache no +/// longer names: a read that saw the old row, then repairs after a fill +/// replaced it, would put the old portrait back and announce it. +async fn link_portrait_in( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + name_canonical: &str, + hash: &str, + now: i64, +) -> Result<(), sqlx::Error> { + let moved = sqlx::query( + "UPDATE artist SET artwork_hash = ?, updated_at = ? \ + WHERE canonical_name = ? AND artwork_hash IS NOT ? \ + AND EXISTS (SELECT 1 FROM artwork WHERE artwork.hash = ?) \ + AND EXISTS (SELECT 1 FROM metadata_artist \ + WHERE name_canonical = ? AND image_hash = ?) \ + RETURNING id, library_id", + ) + .bind(hash) + .bind(now) + .bind(name_canonical) + .bind(hash) + .bind(hash) + .bind(name_canonical) + .bind(hash) + .fetch_all(&mut **tx) + .await?; + for row in moved { + let id: String = row.try_get("id")?; + let library_id: String = row.try_get("library_id")?; + let parse = + |value: &str| Uuid::parse_str(value).map_err(|error| sqlx::Error::Decode(error.into())); + crate::catalog::record_library_event( + tx, + crate::catalog::LibraryChange { + library_id: parse(&library_id)?, + entity_type: "artist", + entity_id: parse(&id)?, + action: "upsert", + payload: serde_json::json!({}), + changed_at: now, + origin_device_id: None, + }, + ) + .await?; + } + Ok(()) +} + +/// Points every artist called `name_canonical` at the banner `hash` +/// (RFC-014 decision 3), as [`link_portrait_in`] does the portrait: only rows +/// that move, one artist event each, and only while the artwork row exists and +/// a cache row still names it. +async fn link_fanart_in( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + name_canonical: &str, + hash: Option<&str>, + now: i64, +) -> Result<(), sqlx::Error> { + let Some(hash) = hash else { + return Ok(()); + }; + let moved = sqlx::query( + "UPDATE artist SET fanart_hash = ?, updated_at = ? \ + WHERE canonical_name = ? AND fanart_hash IS NOT ? \ + AND EXISTS (SELECT 1 FROM artwork WHERE artwork.hash = ?) \ + AND EXISTS (SELECT 1 FROM metadata_artist \ + WHERE name_canonical = ? AND fanart_hash = ?) \ + RETURNING id, library_id", + ) + .bind(hash) + .bind(now) + .bind(name_canonical) + .bind(hash) + .bind(hash) + .bind(name_canonical) + .bind(hash) + .fetch_all(&mut **tx) + .await?; + record_artist_moves(tx, moved, now).await +} + +/// Takes the banner `previous` off every artist called `name_canonical` that +/// still shows it: the provider no longer offers it (RFC-014 decision 3). Only +/// that hash — a row already showing a newer one is not this refresh's to +/// clear. +async fn unlink_fanart_in( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + name_canonical: &str, + previous: &str, + now: i64, +) -> Result<(), sqlx::Error> { + let moved = sqlx::query( + "UPDATE artist SET fanart_hash = NULL, updated_at = ? \ + WHERE canonical_name = ? AND fanart_hash = ? \ + RETURNING id, library_id", + ) + .bind(now) + .bind(name_canonical) + .bind(previous) + .fetch_all(&mut **tx) + .await?; + record_artist_moves(tx, moved, now).await +} + +/// One artist event per moved row, in the row's own library. +async fn record_artist_moves( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + moved: Vec, + now: i64, +) -> Result<(), sqlx::Error> { + for row in moved { + let id: String = row.try_get("id")?; + let library_id: String = row.try_get("library_id")?; + let parse = + |value: &str| Uuid::parse_str(value).map_err(|error| sqlx::Error::Decode(error.into())); + crate::catalog::record_library_event( + tx, + crate::catalog::LibraryChange { + library_id: parse(&library_id)?, + entity_type: "artist", + entity_id: parse(&id)?, + action: "upsert", + payload: serde_json::json!({}), + changed_at: now, + origin_device_id: None, + }, + ) + .await?; + } + Ok(()) +} diff --git a/src/database/mod.rs b/src/database/mod.rs index c0f01fe5..26cd18da 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -2,18 +2,39 @@ use std::{path::Path, str::FromStr, sync::Arc}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha384}; use sqlx::{ migrate::Migrator, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, - Connection, Row, SqliteConnection, SqlitePool, + Connection, SqliteConnection, SqlitePool, }; use tokio::sync::{Mutex, OwnedMutexGuard}; -use utoipa::ToSchema; use uuid::Uuid; -use crate::{config::Config, security::EncryptedSecret}; +use crate::config::Config; + +mod accounts; +mod keys; +mod libraries; +mod metadata; +mod sessions; +mod subsonic_credentials; +mod sync_operations; + +pub use accounts::{AccountRecord, AccountRole}; +pub use keys::{KeyMaterial, Rekey}; +pub use libraries::{LibraryRole, LibraryVisibility}; +pub use sessions::{ + AccessRecord, ApiTokenRecord, AuthorizationRecord, NewApiToken, NewAuthorization, NewSession, + SessionRecord, +}; +pub use subsonic_credentials::SubsonicCredentialStatus; +pub(crate) use sync_operations::SyncOperationReservation; +// Nothing outside names it today — callers reach it through the `Replayed` +// variant — but `crate::database::SyncOperationReplayRecord` was its path, +// and a split that moves code does not take paths away. +#[allow(unused_imports)] +pub(crate) use sync_operations::SyncOperationReplayRecord; pub static MIGRATOR: Migrator = sqlx::migrate!("./migrations-v2"); @@ -33,31 +54,6 @@ pub enum SchemaState { /// This binary carries migrations the database has not run. Behind(usize), } -/// What an instance key protects, counted from the database. -/// -/// Four numbers rather than a sentence, because the sentence is what the -/// documentation already carries and it is not what an operator needs at the -/// moment of deciding. The third is the one nobody expects: discarding the -/// scrobbling links discards the listens queued behind them. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct KeyMaterial { - pub subsonic_credentials: i64, - pub scrobble_links: i64, - pub queued_listens: i64, - pub shares: i64, -} - -/// What to do with the values the previous key protected. -/// -/// Two variants, and two commands above them rather than one command and a -/// flag: one of these preserves exactly what the other destroys, and that is -/// not a detail of a single operation. -pub enum Rekey<'a> { - /// The old key is in hand. Every ciphertext is read and written back. - Reencrypt(&'a crate::security::SecretBox), - /// The old key is gone. Nothing can be read, so the rows go. - Discard, -} #[derive(Clone)] pub struct Database { @@ -72,298 +68,7 @@ pub struct Database { pid: crate::pid::PidSpecs, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum AccountRole { - Admin, - User, -} - -impl AccountRole { - pub fn as_str(self) -> &'static str { - match self { - Self::Admin => "admin", - Self::User => "user", - } - } -} - -impl FromStr for AccountRole { - type Err = anyhow::Error; - - fn from_str(value: &str) -> Result { - match value { - "admin" => Ok(Self::Admin), - "user" => Ok(Self::User), - _ => anyhow::bail!("unknown account role: {value}"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum LibraryVisibility { - Private, - Shared, -} - -impl LibraryVisibility { - pub fn as_str(self) -> &'static str { - match self { - Self::Private => "private", - Self::Shared => "shared", - } - } -} - -impl FromStr for LibraryVisibility { - type Err = anyhow::Error; - - fn from_str(value: &str) -> Result { - match value { - "private" => Ok(Self::Private), - "shared" => Ok(Self::Shared), - _ => anyhow::bail!("visibility must be private or shared"), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "snake_case")] -pub enum LibraryRole { - Owner, - Manager, - Listener, -} - -impl LibraryRole { - /// Whether the role may spend the owner's disk on a rescan. - /// - /// The same rule the `scan_job` insert enforces in SQL, named once here - /// so the Subsonic fan-out over every reachable library agrees with it - /// instead of discovering the refusal one insert at a time. - pub fn may_scan(self) -> bool { - matches!(self, Self::Owner | Self::Manager) - } - - /// Whether the role may correct a track's tags. - /// - /// The same two roles as [`Self::may_scan`], and not by coincidence: a - /// correction is written once and read by every member, so it asks the same - /// trust as spending the owner's disk. Named apart because the two rules - /// answer different questions and one of them may move. - pub fn may_write_metadata(self) -> bool { - matches!(self, Self::Owner | Self::Manager) - } - - /// Whether the role may hand the library a new file. - /// - /// The same two roles again, and deliberately so: a rescan spends the - /// owner's disk, and so does receiving a file — permanently, this time. - /// It is never sufficient on its own. `library.accepts_uploads` decides - /// where, and a role that may upload into an opted-in library may upload - /// into no other. - pub fn may_upload(self) -> bool { - matches!(self, Self::Owner | Self::Manager) - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Owner => "owner", - Self::Manager => "manager", - Self::Listener => "listener", - } - } -} - -impl FromStr for LibraryRole { - type Err = anyhow::Error; - - fn from_str(value: &str) -> Result { - match value { - "owner" => Ok(Self::Owner), - "manager" => Ok(Self::Manager), - "listener" => Ok(Self::Listener), - _ => anyhow::bail!("library role must be owner, manager or listener"), - } - } -} - -/// Whether an account holds a dedicated Subsonic credential, and since when. -/// -/// **The password is not in this shape and cannot be put into it**, because -/// the query behind it never selects the columns that hold one. A shape that -/// does not carry a secret cannot leak one by a later careless edit of the -/// handler. -#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] -pub struct SubsonicCredentialStatus { - /// When this account's credential was first set. - pub created_at: i64, - /// When it last changed — the same instant a rotation reports back. - pub updated_at: i64, -} - -/// One issued API token, without its secret. -#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] -pub struct ApiTokenRecord { - pub id: Uuid, - pub name: String, - pub scopes: Vec, - pub expires_at: Option, - pub created_at: i64, - pub last_used_at: Option, - pub revoked_at: Option, -} - -#[derive(Debug, Clone)] -pub struct AccountRecord { - pub id: Uuid, - pub username: String, - pub password_hash: String, - pub role: AccountRole, - pub disabled: bool, -} - -#[derive(Debug, Clone)] -pub struct SessionRecord { - pub id: Uuid, - pub user_id: Uuid, - pub device_id: Uuid, - pub username: String, - pub role: AccountRole, - pub refresh_expires_at: i64, - /// The scopes this session was issued under. Empty for a password login - /// and for a grant made by an unscoped credential; a narrowed token that - /// authorized a device leaves its narrowing here. - pub scopes: Vec, -} - -#[derive(Debug, Clone)] -/// One API token to write, gathered rather than passed as seven arguments. -/// -/// `actor_id` is who registered it, which the audit entry records and which -/// is not always the owner: an administrator provisioning a service account is -/// the actor, and the account is the subject. -pub struct NewApiToken<'a> { - pub actor_id: Uuid, - pub user_id: Uuid, - pub name: &'a str, - pub token_hash: &'a [u8], - pub scopes: &'a [String], - pub expires_at: Option, -} - -pub struct AccessRecord { - pub user_id: Uuid, - pub username: String, - pub role: AccountRole, - /// The scopes of the API token this request arrived on. Empty for a - /// session or an OAuth grant, which carry the account's full authority, - /// and for a token issued without any. - pub scopes: Vec, - /// When the API token this request arrived on stops working, if it is one - /// and if it has a deadline. - /// - /// `None` for a session, which is a different kind of credential: it is - /// short-lived and refreshes, and authorising something longer-lived than - /// itself is part of what it is for. `None` for a token issued without an - /// expiry too — there is simply nothing to bound by. - pub credential_expires_at: Option, -} - -#[derive(Debug, Clone)] -pub struct NewSession<'a> { - pub id: Uuid, - pub user_id: Uuid, - pub device_id: Uuid, - pub access_token_hash: &'a [u8], - pub refresh_token_hash: &'a [u8], - pub access_expires_at: i64, - pub refresh_expires_at: i64, - pub now_ms: i64, - /// Carried from the credential that issued this session, so it can never - /// be broader than what asked for it. - pub scopes: &'a [String], -} - -#[derive(Debug, Clone)] -pub struct NewAuthorization<'a> { - pub code_hash: [u8; 32], - pub user_id: Uuid, - pub client_id: &'a str, - pub redirect_uri: &'a str, - pub code_challenge: &'a str, - pub device_name: &'a str, - pub now_ms: i64, - pub expires_at: i64, - /// The scopes of the credential that authorized this grant. - pub scopes: &'a [String], -} - -#[derive(Debug, Clone)] -pub struct AuthorizationRecord { - pub user_id: Uuid, - pub client_id: String, - pub redirect_uri: String, - pub code_challenge: String, - pub device_name: String, - /// Handed to the session this grant is redeemed for. - pub scopes: Vec, -} - -#[derive(Debug)] -pub(crate) struct SyncOperationReplayRecord { - pub result_entity_id: Option, - pub event_cursor: i64, - pub intent_hash: Option>, - pub entity_type: String, -} - -#[derive(Debug)] -pub(crate) enum SyncOperationReservation { - InvalidOriginDevice, - New, - Incomplete, - Replayed(SyncOperationReplayRecord), -} - impl Database { - pub async fn setup_required(&self) -> Result { - let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM account") - .fetch_one(&self.pool) - .await?; - Ok(count == 0) - } - - pub async fn bootstrap_admin( - &self, - username: &str, - password_hash: &str, - now_ms: i64, - ) -> Result, sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let id = Uuid::new_v4(); - let inserted = sqlx::query( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - SELECT ?, ?, ?, 'admin', ?, ? WHERE NOT EXISTS (SELECT 1 FROM account)", - ) - .bind(id.to_string()) - .bind(username.trim()) - .bind(password_hash) - .bind(now_ms) - .bind(now_ms) - .execute(&mut *tx) - .await? - .rows_affected() - == 1; - if inserted { - insert_audit(&mut tx, Some(id), "instance.bootstrapped", Some(id), now_ms).await?; - } - tx.commit().await?; - Ok(inserted.then_some(id)) - } - pub async fn open(config: &Config) -> anyhow::Result { tokio::fs::create_dir_all(&config.data_dir).await?; let options = SqliteConnectOptions::new() @@ -541,29 +246,6 @@ impl Database { Ok(result == "ok") } - pub async fn bind_instance_key( - &self, - fingerprint: &[u8], - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - sqlx::query( - "INSERT INTO instance_metadata (singleton, key_fingerprint, created_at) \ - VALUES (1, ?, ?) ON CONFLICT (singleton) DO NOTHING", - ) - .bind(fingerprint) - .bind(now_ms) - .execute(&mut *tx) - .await?; - let stored: Vec = - sqlx::query_scalar("SELECT key_fingerprint FROM instance_metadata WHERE singleton=1") - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - Ok(stored == fingerprint) - } - pub async fn backup_to(&self, destination: &Path) -> anyhow::Result<()> { if destination.exists() { anyhow::bail!("backup database already exists: {}", destination.display()); @@ -599,24 +281,6 @@ impl Database { Ok(result == "ok") } - pub async fn check_file_instance_key(path: &Path, fingerprint: &[u8]) -> anyhow::Result { - let options = SqliteConnectOptions::new() - .filename(path) - .read_only(true) - .foreign_keys(true); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(options) - .await?; - let stored = sqlx::query_scalar::<_, Vec>( - "SELECT key_fingerprint FROM instance_metadata WHERE singleton=1", - ) - .fetch_optional(&pool) - .await?; - pool.close().await; - Ok(stored.is_some_and(|stored| stored == fingerprint)) - } - /// Opens the database for an offline operation, refusing to share it. /// /// `locking_mode=EXCLUSIVE` followed at once by `BEGIN EXCLUSIVE` is the @@ -700,222 +364,6 @@ impl Database { }) } - /// The fingerprint this database was bound to, if it has been bound. - /// - /// Absent on a database no server has ever opened. Present and different - /// is what aborts a boot, and what tells the two key commands apart. - pub async fn stored_key_fingerprint( - connection: &mut SqliteConnection, - ) -> anyhow::Result>> { - Ok(sqlx::query_scalar::<_, Vec>( - "SELECT key_fingerprint FROM instance_metadata WHERE singleton=1", - ) - .fetch_optional(&mut *connection) - .await?) - } - - /// What the instance key protects, counted rather than described. - /// - /// The operator is about to be told what a key change costs, and a count - /// read from their own database is the only version of that worth - /// printing. - pub async fn key_material(connection: &mut SqliteConnection) -> anyhow::Result { - let count = |sql: &'static str| sqlx::query_scalar::<_, i64>(sql); - Ok(KeyMaterial { - subsonic_credentials: count("SELECT COUNT(*) FROM subsonic_credential") - .fetch_one(&mut *connection) - .await?, - scrobble_links: count("SELECT COUNT(*) FROM scrobble_link") - .fetch_one(&mut *connection) - .await?, - // Every row of the outbox hangs off a link by `ON DELETE CASCADE`, - // so discarding the links discards these with them. Counted - // because an operator told "two scrobbling links" would not guess - // that it also means a few hundred listens nobody has sent yet. - queued_listens: count("SELECT COUNT(*) FROM scrobble_outbox") - .fetch_one(&mut *connection) - .await?, - shares: count("SELECT COUNT(*) FROM share") - .fetch_one(&mut *connection) - .await?, - }) - } - - /// Moves every value the instance key governs onto a new key. - /// - /// One transaction, so a database half under one key and half under - /// another is not a state this can leave behind. The caller writes the new - /// key file only after this returns, for the same reason: a key that - /// arrived before the commit would name a database that never changed. - /// - /// Shares are rewritten whichever plan runs, and they are the reason this - /// is one function rather than two. A share token is *derived* from the - /// key rather than stored ([`crate::security::SecretBox::derive_share_token`]), - /// so after a key change the stored hash is one the new key can never - /// produce: the share is not merely re-addressed, it is unreachable, and - /// its owner is shown a URL that answers 404. Recomputing the hash is what - /// makes "shares survive, with a new URL" true rather than hoped for. - pub async fn rekey( - connection: &mut SqliteConnection, - plan: Rekey<'_>, - new_key: &crate::security::SecretBox, - new_fingerprint: &[u8], - now_ms: i64, - ) -> anyhow::Result { - // Asked for here rather than taken on trust from the caller. It is a - // connection flag, `migrate_on` turns it off, and the delete below is - // the one statement whose correctness depends on it — a cascade that - // did not fire leaves no error, only rows. A no-op inside a - // transaction, so it has to come before the `begin`. - sqlx::query("PRAGMA foreign_keys = ON") - .execute(&mut *connection) - .await - .map_err(|error| anyhow::anyhow!("could not enforce foreign keys: {error}"))?; - let enforced = sqlx::query_scalar::<_, i64>("PRAGMA foreign_keys") - .fetch_one(&mut *connection) - .await?; - if enforced != 1 { - anyhow::bail!("foreign keys are not enforced; nothing has been changed"); - } - let mut tx = connection.begin().await?; - let mut moved = KeyMaterial::default(); - match plan { - Rekey::Reencrypt(old_key) => { - let credentials = sqlx::query( - "SELECT user_id, password_nonce, password_ciphertext FROM subsonic_credential", - ) - .fetch_all(&mut *tx) - .await?; - for row in credentials { - let user_id: String = row.try_get("user_id")?; - let plaintext = old_key - .decrypt( - row.try_get::, _>("password_nonce")?.as_slice(), - row.try_get::, _>("password_ciphertext")?.as_slice(), - ) - .map_err(|_| { - anyhow::anyhow!( - "a stored Subsonic password does not open with this key; \ - nothing has been changed" - ) - })?; - let sealed = new_key.encrypt(&plaintext)?; - sqlx::query( - "UPDATE subsonic_credential \ - SET password_nonce=?, password_ciphertext=?, updated_at=? \ - WHERE user_id=?", - ) - .bind(sealed.nonce.as_slice()) - .bind(&sealed.ciphertext) - .bind(now_ms) - .bind(&user_id) - .execute(&mut *tx) - .await?; - moved.subsonic_credentials += 1; - } - - // The `unlinked` generations included: they keep their - // ciphertext, and a past authorisation left unreadable would - // be a row this server can no longer account for. - let links = sqlx::query( - "SELECT id, credential_nonce, credential_ciphertext FROM scrobble_link", - ) - .fetch_all(&mut *tx) - .await?; - for row in links { - let id: String = row.try_get("id")?; - let plaintext = old_key - .decrypt( - row.try_get::, _>("credential_nonce")?.as_slice(), - row.try_get::, _>("credential_ciphertext")? - .as_slice(), - ) - .map_err(|_| { - anyhow::anyhow!( - "a stored scrobbling secret does not open with this key; \ - nothing has been changed" - ) - })?; - let sealed = new_key.encrypt(&plaintext)?; - sqlx::query( - "UPDATE scrobble_link \ - SET credential_nonce=?, credential_ciphertext=?, updated_at=? \ - WHERE id=?", - ) - .bind(sealed.nonce.as_slice()) - .bind(&sealed.ciphertext) - .bind(now_ms) - .bind(&id) - .execute(&mut *tx) - .await?; - moved.scrobble_links += 1; - } - } - Rekey::Discard => { - // The outbox goes with the links, by `ON DELETE CASCADE` - // rather than by a statement here — which only holds with - // enforcement on, and `migrate_on` leaves it off. Counted - // before the delete and asserted against afterwards, because - // a cascade that silently did not fire would leave the number - // printed to the operator as the only trace of it. - moved.queued_listens = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM scrobble_outbox \ - WHERE link_id IN (SELECT id FROM scrobble_link)", - ) - .fetch_one(&mut *tx) - .await?; - moved.subsonic_credentials = sqlx::query("DELETE FROM subsonic_credential") - .execute(&mut *tx) - .await? - .rows_affected() as i64; - moved.scrobble_links = sqlx::query("DELETE FROM scrobble_link") - .execute(&mut *tx) - .await? - .rows_affected() as i64; - let orphans = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM scrobble_outbox") - .fetch_one(&mut *tx) - .await?; - if orphans > 0 { - anyhow::bail!( - "{orphans} queued listen(s) outlived the links they belong to; \ - foreign keys were not enforced and nothing has been changed" - ); - } - } - } - - for id in sqlx::query_scalar::<_, String>("SELECT id FROM share") - .fetch_all(&mut *tx) - .await? - { - let share = Uuid::parse_str(&id) - .map_err(|_| anyhow::anyhow!("a share carries an identifier that is not a UUID"))?; - let token = new_key.derive_share_token(share); - // `updated_at` is left alone on purpose. The share did not change; - // the key that names it did, and bumping the column would hand - // every synchronising client a modification with no journal entry - // behind it. - sqlx::query("UPDATE share SET token_hash=? WHERE id=?") - .bind(crate::security::token_hash(&token).as_slice()) - .bind(&id) - .execute(&mut *tx) - .await?; - moved.shares += 1; - } - - sqlx::query( - "INSERT INTO instance_metadata (singleton, key_fingerprint, created_at) \ - VALUES (1, ?, ?) \ - ON CONFLICT (singleton) DO UPDATE SET key_fingerprint = excluded.key_fingerprint", - ) - .bind(new_fingerprint) - .bind(now_ms) - .execute(&mut *tx) - .await?; - tx.commit().await?; - Ok(moved) - } - pub fn pool(&self) -> &SqlitePool { &self.pool } @@ -950,2004 +398,34 @@ impl Database { ) -> Result, sqlx::Error> { self.pool.begin_with("BEGIN IMMEDIATE").await } +} - #[allow(clippy::too_many_arguments)] - pub(crate) async fn reserve_sync_operation( - &self, - _writer_guard: &OwnedMutexGuard<()>, - connection: &mut SqliteConnection, - user_id: Uuid, - operation_id: Uuid, - origin_device_id: Option, - intent_hash: &[u8], - created_at: i64, - ) -> Result { - if let Some(device_id) = origin_device_id { - let owned = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM device \ - WHERE id=? AND user_id=? AND revoked_at IS NULL)", - ) - .bind(device_id.to_string()) - .bind(user_id.to_string()) - .fetch_one(&mut *connection) - .await?; - if !owned { - return Ok(SyncOperationReservation::InvalidOriginDevice); - } - } - - let inserted = sqlx::query( - "INSERT INTO sync_operation \ - (user_id, operation_id, origin_device_id, intent_hash, created_at) VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT (user_id, operation_id) DO NOTHING", - ) - .bind(user_id.to_string()) - .bind(operation_id.to_string()) - .bind(origin_device_id.map(|id| id.to_string())) - .bind(intent_hash) - .bind(created_at) - .execute(&mut *connection) - .await? - .rows_affected(); - if inserted == 1 { - return Ok(SyncOperationReservation::New); - } - - let row = sqlx::query( - "SELECT so.result_entity_id, so.event_cursor, so.intent_hash, se.entity_type \ - FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor AND se.user_id=so.user_id \ - WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", - ) - .bind(user_id.to_string()) - .bind(operation_id.to_string()) - .fetch_optional(&mut *connection) - .await?; - let Some(row) = row else { - return Ok(SyncOperationReservation::Incomplete); - }; - Ok(SyncOperationReservation::Replayed( - SyncOperationReplayRecord { - result_entity_id: row.try_get("result_entity_id")?, - event_cursor: row.try_get("event_cursor")?, - intent_hash: row.try_get("intent_hash")?, - entity_type: row.try_get("entity_type")?, - }, - )) - } - - pub async fn create_account( - &self, - username: &str, - password_hash: &str, - role: AccountRole, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(id.to_string()) - .bind(username.trim()) - .bind(password_hash) - .bind(role.as_str()) - .bind(now_ms) - .bind(now_ms) - .execute(&mut *tx) - .await?; - insert_audit(&mut tx, Some(id), "account.created", Some(id), now_ms).await?; - tx.commit().await?; - Ok(id) - } - - pub async fn account_by_username( - &self, - username: &str, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT id, username, password_hash, role, disabled \ - FROM account WHERE username = ? COLLATE NOCASE", - ) - .bind(username.trim()) - .fetch_optional(&self.pool) - .await?; - row.map(account_from_row).transpose() - } - - pub async fn account_by_id(&self, id: Uuid) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT id, username, password_hash, role, disabled FROM account WHERE id = ?", - ) - .bind(id.to_string()) - .fetch_optional(&self.pool) - .await?; - row.map(account_from_row).transpose() - } - - pub async fn create_authorization( - &self, - grant: NewAuthorization<'_>, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - sqlx::query( - "INSERT INTO oauth_authorization \ - (code_hash, user_id, client_id, redirect_uri, code_challenge, device_name, \ - created_at, expires_at, scopes_json) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(grant.code_hash.as_slice()) - .bind(grant.user_id.to_string()) - .bind(grant.client_id) - .bind(grant.redirect_uri) - .bind(grant.code_challenge) - .bind(grant.device_name) - .bind(grant.now_ms) - .bind(grant.expires_at) - .bind(encode_scopes(grant.scopes)?) - .execute(&mut *tx) - .await?; - // Delegating access to another application is exactly the kind of event - // the audit trail exists for, alongside account and credential changes. - insert_audit( - &mut tx, - Some(grant.user_id), - "oauth.authorization.granted", - Some(grant.user_id), - grant.now_ms, - ) - .await?; - tx.commit().await?; - Ok(()) - } - - /// Claims a grant for redemption, atomically marking it used. - /// - /// The `redeemed_at IS NULL` predicate inside the UPDATE is what makes a - /// code single-use: two concurrent redemptions cannot both match, so a - /// stolen code cannot be replayed alongside the legitimate exchange. - /// - /// A code is spent by the first presentation whatever its outcome, so a - /// failed PKCE check burns it rather than leaving it open to further - /// attempts. That forecloses guessing a verifier, and matches OAuth 2.1's - /// rule that a code presented more than once must be revoked. The cost is - /// that a client which botches its own exchange must restart the flow. - pub async fn redeem_authorization( - &self, - code_hash: &[u8], - now_ms: i64, - ) -> Result, sqlx::Error> { - let _writer = self.writer_guard().await; - let row = sqlx::query( - "UPDATE oauth_authorization SET redeemed_at = ? \ - WHERE code_hash = ? AND redeemed_at IS NULL AND expires_at > ? \ - RETURNING user_id, client_id, redirect_uri, code_challenge, device_name, \ - scopes_json", - ) - .bind(now_ms) - .bind(code_hash) - .bind(now_ms) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(AuthorizationRecord { - user_id: parse_uuid(row.try_get("user_id")?)?, - client_id: row.try_get("client_id")?, - redirect_uri: row.try_get("redirect_uri")?, - code_challenge: row.try_get("code_challenge")?, - device_name: row.try_get("device_name")?, - scopes: decode_scopes(row.try_get("scopes_json")?)?, - }) - }) - .transpose() - } - - /// Sweeps expired grants hourly for the life of the process. - /// - /// Rows are kept after redemption so a replay is recognised rather than - /// looking unknown, so nothing else ever deletes them; without this the - /// table only grows. - pub fn spawn_authorization_pruning(&self) { - let db = self.clone(); - tokio::spawn(async move { - let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); - loop { - ticker.tick().await; - match db - .prune_authorizations(crate::authentication::now_ms()) - .await - { - Ok(0) => {} - Ok(removed) => tracing::debug!(removed, "pruned expired authorization codes"), - Err(error) => { - tracing::warn!(error = %error, "could not prune authorization codes") - } - } - } - }); - } - - /// Drops grants that can no longer be redeemed. - pub async fn prune_authorizations(&self, now_ms: i64) -> Result { - let _writer = self.writer_guard().await; - let result = sqlx::query("DELETE FROM oauth_authorization WHERE expires_at <= ?") - .bind(now_ms) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - pub async fn create_device( - &self, - user_id: Uuid, - name: &str, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO device (id, user_id, name, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)", - ) - .bind(id.to_string()) - .bind(user_id.to_string()) - .bind(name.trim()) - .bind(now_ms) - .bind(now_ms) - .execute(&self.pool) - .await?; - Ok(id) - } - - pub async fn create_session(&self, session: NewSession<'_>) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - sqlx::query( - "INSERT INTO session \ - (id, user_id, device_id, access_token_hash, refresh_token_hash, access_expires_at, \ - refresh_expires_at, created_at, last_used_at, scopes_json) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(session.id.to_string()) - .bind(session.user_id.to_string()) - .bind(session.device_id.to_string()) - .bind(session.access_token_hash) - .bind(session.refresh_token_hash) - .bind(session.access_expires_at) - .bind(session.refresh_expires_at) - .bind(session.now_ms) - .bind(session.now_ms) - .bind(encode_scopes(session.scopes)?) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn session_by_refresh_hash( - &self, - refresh_hash: &[u8], - now_ms: i64, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT s.id, s.user_id, s.device_id, a.username, a.role, s.refresh_expires_at, \ - s.scopes_json \ - FROM session s JOIN account a ON a.id = s.user_id \ - WHERE s.refresh_token_hash = ? AND s.revoked_at IS NULL \ - AND s.refresh_expires_at > ? AND a.disabled = 0", - ) - .bind(refresh_hash) - .bind(now_ms) - .fetch_optional(&self.pool) - .await?; - row.map(session_from_row).transpose() - } - - pub async fn account_by_access_hash( - &self, - access_hash: &[u8], - now_ms: i64, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT a.id, a.username, a.role, s.scopes_json FROM session s \ - JOIN account a ON a.id = s.user_id \ - JOIN device d ON d.id = s.device_id \ - WHERE s.access_token_hash = ? AND s.revoked_at IS NULL \ - AND s.access_expires_at > ? AND a.disabled = 0 AND d.revoked_at IS NULL", - ) - .bind(access_hash) - .bind(now_ms) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(AccessRecord { - user_id: parse_uuid(row.try_get("id")?)?, - username: row.try_get("username")?, - role: parse_role(row.try_get("role")?)?, - scopes: decode_scopes(row.try_get("scopes_json")?)?, - // A session, not an API token: see the field. - credential_expires_at: None, - }) - }) - .transpose() - } +fn parse_uuid(value: String) -> Result { + Uuid::parse_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) +} - pub async fn account_by_api_token_hash( - &self, - token_hash: &[u8], - now_ms: i64, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT a.id, a.username, a.role, t.scopes_json, t.last_used_at, \ - t.expires_at FROM api_token t \ - JOIN account a ON a.id = t.user_id \ - WHERE t.token_hash = ? AND t.revoked_at IS NULL \ - AND (t.expires_at IS NULL OR t.expires_at > ?) AND a.disabled = 0", - ) - .bind(token_hash) - .bind(now_ms) - .fetch_optional(&self.pool) - .await?; - let Some(row) = row else { - return Ok(None); - }; - let last_used_at: Option = row.try_get("last_used_at")?; - let account = AccessRecord { - user_id: parse_uuid(row.try_get("id")?)?, - username: row.try_get("username")?, - role: parse_role(row.try_get("role")?)?, - scopes: decode_scopes(row.try_get("scopes_json")?)?, - credential_expires_at: row.try_get("expires_at")?, - }; - if last_used_at.is_none_or(|last_used| last_used <= now_ms.saturating_sub(60_000)) { - let _writer = self.writer_guard().await; - sqlx::query( - "UPDATE api_token SET last_used_at = ? WHERE token_hash = ? \ - AND revoked_at IS NULL AND (last_used_at IS NULL OR last_used_at <= ?)", - ) - .bind(now_ms) - .bind(token_hash) - .bind(now_ms.saturating_sub(60_000)) - .execute(&self.pool) - .await?; - } - Ok(Some(account)) - } +fn parse_role(value: String) -> Result { + AccountRole::from_str(&value).map_err(|error| sqlx::Error::Decode(error.into_boxed_dyn_error())) +} - #[allow(clippy::too_many_arguments)] - pub async fn rotate_session( - &self, - session_id: Uuid, - expected_refresh_hash: &[u8], - access_hash: &[u8], - refresh_hash: &[u8], - access_expires_at: i64, - refresh_expires_at: i64, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let result = sqlx::query( - "UPDATE session SET access_token_hash = ?, refresh_token_hash = ?, \ - access_expires_at = ?, refresh_expires_at = ?, last_used_at = ? \ - WHERE id = ? AND refresh_token_hash = ? AND revoked_at IS NULL \ - AND refresh_expires_at > ?", - ) - .bind(access_hash) - .bind(refresh_hash) - .bind(access_expires_at) - .bind(refresh_expires_at) - .bind(now_ms) - .bind(session_id.to_string()) - .bind(expected_refresh_hash) - .bind(now_ms) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() == 1) - } - - pub async fn revoke_session_by_access_hash( - &self, - access_hash: &[u8], - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let result = sqlx::query( - "UPDATE session SET revoked_at = ? \ - WHERE access_token_hash = ? AND revoked_at IS NULL", - ) - .bind(now_ms) - .bind(access_hash) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() == 1) - } - - pub async fn revoke_session_by_refresh_hash( - &self, - refresh_hash: &[u8], - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let result = sqlx::query( - "UPDATE session SET revoked_at = ? \ - WHERE refresh_token_hash = ? AND revoked_at IS NULL", - ) - .bind(now_ms) - .bind(refresh_hash) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() == 1) - } - - /// Issues a token and returns its record. - /// - /// `RETURNING` rather than an insert followed by a read: the caller wants - /// the row it just wrote, and looking it up afterwards meant listing every - /// token the account holds to find one whose id was already known. - pub async fn create_api_token( - &self, - token: NewApiToken<'_>, - now_ms: i64, - ) -> Result { - let NewApiToken { - actor_id, - user_id, - name, - token_hash, - scopes, - expires_at, - } = token; - let _writer = self.writer_guard().await; - let id = Uuid::new_v4(); - // `expires_at` has been a column, and honoured by the lookup, since - // the table existed — and never written. Nothing minted a bounded - // token, so every one of them outlived whatever asked for it. - let mut tx = self.begin_write(&_writer).await?; - let row = sqlx::query( - "INSERT INTO api_token \ - (id, user_id, name, token_hash, scopes_json, expires_at, created_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?) \ - RETURNING id, name, scopes_json, expires_at, created_at, last_used_at, revoked_at", - ) - .bind(id.to_string()) - .bind(user_id.to_string()) - .bind(name.trim()) - .bind(token_hash) - .bind(encode_scopes(scopes)?) - .bind(expires_at) - .bind(now_ms) - .fetch_one(&mut *tx) - .await?; - // In the same transaction as the row it describes, the way the - // revocation beside it already is. Revoking a credential left an audit - // entry and registering one left none, which is the wrong half to - // record: it became an act an account can perform for itself without - // an administrator anywhere, so it is exactly the event an operator - // would want to find afterwards. - insert_audit( - &mut tx, - Some(actor_id), - "api_token.created", - Some(id), - now_ms, - ) - .await?; - tx.commit().await?; - api_token_from_row(row) - } - - pub async fn revoke_api_token_by_hash( - &self, - token_hash: &[u8], - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let result = sqlx::query( - "UPDATE api_token SET revoked_at = ? \ - WHERE token_hash = ? AND revoked_at IS NULL", - ) - .bind(now_ms) - .bind(token_hash) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() == 1) - } - - /// The tokens issued to one account, newest first, secrets excluded. - /// - /// `token_hash` is never projected. The secret exists once, in the creation - /// response; a listing that could return it would make every read of this - /// route as sensitive as issuing a new one. - pub async fn api_tokens_for_user( - &self, - user_id: Uuid, - ) -> Result, sqlx::Error> { - sqlx::query( - "SELECT id, name, scopes_json, expires_at, created_at, last_used_at, revoked_at \ - FROM api_token WHERE user_id = ? ORDER BY created_at DESC, id", - ) - .bind(user_id.to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(api_token_from_row) - .collect() - } - - /// Revokes one token of one account. - /// - /// The account is part of the condition rather than checked beforehand, so - /// an administrator cannot revoke a token by naming the wrong owner, and a - /// token already revoked answers the same as one that never existed: the - /// caller asked for it to stop working, and it does not. - pub async fn revoke_api_token( - &self, - actor_id: Uuid, - user_id: Uuid, - token_id: Uuid, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let result = sqlx::query( - "UPDATE api_token SET revoked_at = ? \ - WHERE id = ? AND user_id = ? AND revoked_at IS NULL", - ) - .bind(now_ms) - .bind(token_id.to_string()) - .bind(user_id.to_string()) - .execute(&mut *tx) - .await?; - let revoked = result.rows_affected() == 1; - if revoked { - insert_audit( - &mut tx, - Some(actor_id), - "api_token.revoked", - Some(token_id), - now_ms, - ) - .await?; - } - tx.commit().await?; - Ok(revoked) - } - - pub async fn create_library( - &self, - owner_id: Uuid, - name: &str, - root_path: &Path, - visibility: LibraryVisibility, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let id = Uuid::new_v4(); - let root_path = root_path.to_string_lossy(); - sqlx::query( - "INSERT INTO library \ - (id, owner_user_id, name, root_path, visibility, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(id.to_string()) - .bind(owner_id.to_string()) - .bind(name.trim()) - .bind(root_path.as_ref()) - .bind(visibility.as_str()) - .bind(now_ms) - .bind(now_ms) - .execute(&mut *tx) - .await?; - sqlx::query( - "INSERT INTO library_member (library_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)", - ) - .bind(id.to_string()) - .bind(owner_id.to_string()) - .bind(now_ms) - .execute(&mut *tx) - .await?; - insert_audit(&mut tx, Some(owner_id), "library.created", Some(id), now_ms).await?; - tx.commit().await?; - Ok(id) - } - - pub async fn add_library_member( - &self, - actor_id: Uuid, - library_id: Uuid, - user_id: Uuid, - role: LibraryRole, - now_ms: i64, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - sqlx::query( - "INSERT INTO library_member (library_id, user_id, role, created_at) VALUES (?, ?, ?, ?) \ - ON CONFLICT (library_id, user_id) DO UPDATE SET role = excluded.role", - ) - .bind(library_id.to_string()) - .bind(user_id.to_string()) - .bind(role.as_str()) - .bind(now_ms) - .execute(&mut *tx) - .await?; - insert_audit( - &mut tx, - Some(actor_id), - "library.member_set", - Some(library_id), - now_ms, - ) - .await?; - tx.commit().await?; - Ok(()) - } - - pub async fn remove_library_member( - &self, - actor_id: Uuid, - library_id: Uuid, - user_id: Uuid, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let removed = sqlx::query( - "DELETE FROM library_member \ - WHERE library_id=? AND user_id=? AND role!='owner'", - ) - .bind(library_id.to_string()) - .bind(user_id.to_string()) - .execute(&mut *tx) - .await? - .rows_affected() - == 1; - if removed { - insert_audit( - &mut tx, - Some(actor_id), - "library.member_removed", - Some(library_id), - now_ms, - ) - .await?; - } - tx.commit().await?; - Ok(removed) - } - - /// Opens or closes a library to receiving files. - /// - /// Audited, like every other change to what a library permits. The role a - /// member holds says who may upload; this says whether the library takes - /// anything at all, and it is the operator's call rather than a member's — - /// which is why it arrives through the CLI and not through the API. - pub async fn set_library_accepts_uploads( - &self, - actor_id: Uuid, - library_id: Uuid, - accepts: bool, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let changed = sqlx::query("UPDATE library SET accepts_uploads=? WHERE id=?") - .bind(i64::from(accepts)) - .bind(library_id.to_string()) - .execute(&mut *tx) - .await? - .rows_affected() - == 1; - if changed { - insert_audit( - &mut tx, - Some(actor_id), - if accepts { - "library.uploads_opened" - } else { - "library.uploads_closed" - }, - Some(library_id), - now_ms, - ) - .await?; - } - tx.commit().await?; - Ok(changed) - } - - /// Opens or closes a library to receiving canvases. - /// - /// A second door rather than the same one, and the reasoning is in - /// `20260830010000_library_accepts_canvas.sql`: a read-only server that - /// refuses to grow in audio may still want a few hundred kilobytes of video - /// loop, and that is the most common installation there is. - /// - /// Audited and CLI-only, exactly like the upload flag. The role a member - /// holds says who may place one; this says whether the library takes any at - /// all, and that is the operator's call rather than a member's. - pub async fn set_library_accepts_canvas( - &self, - actor_id: Uuid, - library_id: Uuid, - accepts: bool, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let changed = sqlx::query("UPDATE library SET accepts_canvas=? WHERE id=?") - .bind(i64::from(accepts)) - .bind(library_id.to_string()) - .execute(&mut *tx) - .await? - .rows_affected() - == 1; - if changed { - insert_audit( - &mut tx, - Some(actor_id), - if accepts { - "library.canvas_opened" - } else { - "library.canvas_closed" - }, - Some(library_id), - now_ms, - ) - .await?; - } - tx.commit().await?; - Ok(changed) - } - - pub async fn set_subsonic_credential( - &self, - actor_id: Uuid, - user_id: Uuid, - secret: &EncryptedSecret, - api_key_hash: &[u8], - now_ms: i64, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - sqlx::query( - "INSERT INTO subsonic_credential \ - (user_id, password_nonce, password_ciphertext, api_key_hash, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?) \ - ON CONFLICT (user_id) DO UPDATE SET \ - password_nonce = excluded.password_nonce, \ - password_ciphertext = excluded.password_ciphertext, \ - api_key_hash = excluded.api_key_hash, updated_at = excluded.updated_at", - ) - .bind(user_id.to_string()) - .bind(secret.nonce.as_slice()) - .bind(&secret.ciphertext) - .bind(api_key_hash) - .bind(now_ms) - .bind(now_ms) - .execute(&mut *tx) - .await?; - insert_audit( - &mut tx, - Some(actor_id), - "subsonic.credential_set", - Some(user_id), - now_ms, - ) - .await?; - tx.commit().await?; - Ok(()) - } - - pub async fn revoke_subsonic_credential( - &self, - actor_id: Uuid, - user_id: Uuid, - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let result = sqlx::query("DELETE FROM subsonic_credential WHERE user_id = ?") - .bind(user_id.to_string()) - .execute(&mut *tx) - .await?; - let revoked = result.rows_affected() == 1; - // Only when something was removed. The entry was unconditional, and - // the service answers 404 after writing one, so the trail recorded - // revocations that had not happened. That was cosmetic while an - // administrator was the only caller; the self-scoped route makes it - // reachable by any account, in a loop, which turns a wrong line in a - // history into a way to fill one. `rotate_subsonic_credential` guards - // the same way, for the same reason. - if revoked { - insert_audit( - &mut tx, - Some(actor_id), - "subsonic.credential_revoked", - Some(user_id), - now_ms, - ) - .await?; - } - tx.commit().await?; - Ok(revoked) - } - - /// Whether this account holds a Subsonic credential, and since when. - /// - /// The projection is the two timestamps and nothing else. The password - /// columns are not read, so no edit of a caller can forward what this - /// never fetched. - pub async fn subsonic_credential_status( - &self, - user_id: Uuid, - ) -> Result, sqlx::Error> { - sqlx::query_as::<_, (i64, i64)>( - "SELECT created_at, updated_at FROM subsonic_credential WHERE user_id = ?", - ) - .bind(user_id.to_string()) - .fetch_optional(&self.pool) - .await - .map(|row| { - row.map(|(created_at, updated_at)| SubsonicCredentialStatus { - created_at, - updated_at, - }) - }) - } - - /// Replaces the password and the API key of a credential that already - /// exists, and answers whether one did. - /// - /// **An `UPDATE`, where `set_subsonic_credential` upserts, and that is the - /// whole difference between the two.** This one cannot bring a credential - /// into being: an account holding none cannot use the Subsonic facade at - /// all, so creating one does not narrow a door already open — it opens - /// one, which is the operator's decision and not this path's. Checking - /// first and then upserting would be the same decision taken racily; - /// `WHERE user_id = ?` takes it in the statement, where two callers cannot - /// interleave. - /// - /// The API key moves with the password. Leaving it would rotate half a - /// credential: a leaked `wfsk_` key authenticates on its own, so a - /// rotation that kept it would answer "my key has leaked" with a new - /// password and the same working key. - pub async fn rotate_subsonic_credential( - &self, - actor_id: Uuid, - user_id: Uuid, - secret: &EncryptedSecret, - api_key_hash: &[u8], - now_ms: i64, - ) -> Result { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let result = sqlx::query( - "UPDATE subsonic_credential SET \ - password_nonce = ?, password_ciphertext = ?, api_key_hash = ?, updated_at = ? \ - WHERE user_id = ?", - ) - .bind(secret.nonce.as_slice()) - .bind(&secret.ciphertext) - .bind(api_key_hash) - .bind(now_ms) - .bind(user_id.to_string()) - .execute(&mut *tx) - .await?; - let rotated = result.rows_affected() == 1; - // Only when something changed. An audit trail that records attempts - // the database refused would read as a history of rotations that never - // happened. - if rotated { - insert_audit( - &mut tx, - Some(actor_id), - "subsonic.credential_rotated", - Some(user_id), - now_ms, - ) - .await?; - } - tx.commit().await?; - Ok(rotated) - } -} - -fn account_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(AccountRecord { - id: parse_uuid(row.try_get("id")?)?, - username: row.try_get("username")?, - password_hash: row.try_get("password_hash")?, - role: parse_role(row.try_get("role")?)?, - disabled: row.try_get::("disabled")? != 0, - }) -} - -fn session_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(SessionRecord { - id: parse_uuid(row.try_get("id")?)?, - user_id: parse_uuid(row.try_get("user_id")?)?, - device_id: parse_uuid(row.try_get("device_id")?)?, - username: row.try_get("username")?, - role: parse_role(row.try_get("role")?)?, - refresh_expires_at: row.try_get("refresh_expires_at")?, - scopes: decode_scopes(row.try_get("scopes_json")?)?, - }) -} - -/// Scopes are stored as a JSON array so a credential's limit travels as one -/// column, on the three tables that can carry one: `api_token`, and now -/// `oauth_authorization` and `session`. -fn encode_scopes(scopes: &[String]) -> Result { - serde_json::to_string(scopes).map_err(|error| sqlx::Error::Encode(error.into())) -} - -/// Counterpart of [`encode_scopes`]. An unreadable list is a decode error -/// rather than an empty one: silently reading a corrupt limit as "no limit" -/// would widen a credential exactly where it must not. -fn decode_scopes(value: &str) -> Result, sqlx::Error> { - serde_json::from_str(value).map_err(|error| sqlx::Error::Decode(error.into())) -} - -fn parse_uuid(value: String) -> Result { - Uuid::parse_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) -} - -fn parse_role(value: String) -> Result { - AccountRole::from_str(&value).map_err(|error| sqlx::Error::Decode(error.into_boxed_dyn_error())) -} - -fn api_token_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(ApiTokenRecord { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - scopes: decode_scopes(row.try_get("scopes_json")?)?, - expires_at: row.try_get("expires_at")?, - created_at: row.try_get("created_at")?, - last_used_at: row.try_get("last_used_at")?, - revoked_at: row.try_get("revoked_at")?, - }) -} - -async fn insert_audit( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - actor_id: Option, - kind: &str, - subject_id: Option, - now_ms: i64, -) -> Result<(), sqlx::Error> { - sqlx::query( - "INSERT INTO audit_event (actor_user_id, kind, subject_id, occurred_at) VALUES (?, ?, ?, ?)", - ) - .bind(actor_id.map(|id| id.to_string())) - .bind(kind) - .bind(subject_id.map(|id| id.to_string())) - .bind(now_ms) - .execute(&mut **tx) - .await?; - Ok(()) -} - -/// RFC-011's cache, which [`crate::enrichment`] fills and reads. -/// -/// No user id and no tenancy anywhere in it: what a provider says about a name -/// is the same whoever asks, and nothing here is account data (decision 4). -/// Tenancy applies where a cached name meets the catalogue, which is the -/// reader's query, not these. -impl Database { - /// Every provider's row for one canonical name, in no particular order. - /// - /// A row from a provider this build does not know — written by a later - /// version the instance was downgraded from — is passed over rather than - /// refused: it is a fact about a provider nobody configured here. - pub async fn metadata_artist_rows( - &self, - name_canonical: &str, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT provider, outcome, bio_summary, bio_full, image_hash, fanart_hash, \ - fetched_at, expires_at \ - FROM metadata_artist WHERE name_canonical = ?", - ) - .bind(name_canonical) - .fetch_all(&self.pool) - .await?; - let mut cached = Vec::with_capacity(rows.len()); - for row in rows { - let provider: String = row.try_get("provider")?; - let Some(provider) = crate::enrichment::Provider::parse(&provider) else { - continue; - }; - let outcome: String = row.try_get("outcome")?; - let outcome = crate::enrichment::Outcome::parse(&outcome).ok_or_else(|| { - sqlx::Error::Decode(format!("unknown enrichment outcome {outcome:?}").into()) - })?; - cached.push(crate::enrichment::CachedArtist { - provider, - outcome, - bio_summary: row.try_get("bio_summary")?, - bio_full: row.try_get("bio_full")?, - image_hash: row.try_get("image_hash")?, - fanart_hash: row.try_get("fanart_hash")?, - fetched_at: row.try_get("fetched_at")?, - expires_at: row.try_get("expires_at")?, - }); - } - Ok(cached) - } - - /// One provider's ranked neighbours for one canonical name, best first. - pub async fn metadata_artist_similar( - &self, - provider: crate::enrichment::Provider, - name_canonical: &str, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT similar_name, similar_canonical, score FROM metadata_artist_similar \ - WHERE provider = ? AND name_canonical = ? ORDER BY rank", - ) - .bind(provider.as_str()) - .bind(name_canonical) - .fetch_all(&self.pool) - .await?; - rows.into_iter() - .map(|row| { - Ok(crate::enrichment::CachedSimilar { - name: row.try_get("similar_name")?, - name_canonical: row.try_get("similar_canonical")?, - score: row.try_get("score")?, - }) - }) - .collect() - } - - /// Links the portrait `hash` to every artist called `name_canonical` that - /// does not show it yet, emitting one artist event per row it moved. - /// - /// Read first without the writer gate, since this runs on the read path - /// and almost always finds nothing to do. The artwork row is checked in - /// the same statement as the link: the sweeper may have taken it since - /// the cache row was read. - pub(crate) async fn link_artist_portrait( - &self, - name_canonical: &str, - hash: &str, - ) -> Result<(), sqlx::Error> { - let unlinked: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM artist \ - WHERE canonical_name = ? AND artwork_hash IS NOT ?)", - ) - .bind(name_canonical) - .bind(hash) - .fetch_one(&self.pool) - .await?; - if !unlinked { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - link_portrait_in( - &mut tx, - name_canonical, - hash, - crate::authentication::now_ms(), - ) - .await?; - tx.commit().await - } - - /// Links the banner `hash` to every artist called `name_canonical` that - /// does not show it yet — [`Self::link_artist_portrait`], for the fanart - /// of RFC-014. - pub(crate) async fn link_artist_fanart( - &self, - name_canonical: &str, - hash: &str, - ) -> Result<(), sqlx::Error> { - let unlinked: bool = sqlx::query_scalar( - "SELECT EXISTS (SELECT 1 FROM artist \ - WHERE canonical_name = ? AND fanart_hash IS NOT ?)", - ) - .bind(name_canonical) - .bind(hash) - .fetch_one(&self.pool) - .await?; - if !unlinked { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - link_fanart_in( - &mut tx, - name_canonical, - Some(hash), - crate::authentication::now_ms(), - ) - .await?; - tx.commit().await - } - - /// What one lyrics provider said for one lookup key (RFC-024 decision - /// 4), if it said anything. - pub async fn lyrics_lookup_row( - &self, - provider: &str, - lookup_key: &str, - ) -> Result, sqlx::Error> { - let Some(row) = sqlx::query( - "SELECT outcome, synced_lyrics, plain_lyrics, fetched_at, expires_at \ - FROM lyrics_lookup WHERE provider = ? AND lookup_key = ?", - ) - .bind(provider) - .bind(lookup_key) - .fetch_optional(&self.pool) - .await? - else { - return Ok(None); - }; - let outcome: String = row.try_get("outcome")?; - let outcome = crate::lrclib::LyricsOutcome::parse(&outcome).ok_or_else(|| { - sqlx::Error::Decode(format!("unknown lyrics outcome {outcome:?}").into()) - })?; - Ok(Some(crate::lrclib::CachedLyrics { - outcome, - synced: row.try_get("synced_lyrics")?, - plain: row.try_get("plain_lyrics")?, - fetched_at: row.try_get("fetched_at")?, - expires_at: row.try_get("expires_at")?, - })) - } - - /// Records what a lyrics provider answered, replacing what it said - /// before. Taken under the writer gate, after the request is over. - pub(crate) async fn write_lyrics_lookup( - &self, - provider: &str, - lookup_key: &str, - answer: &crate::lrclib::CachedLyrics, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - sqlx::query( - "INSERT INTO lyrics_lookup \ - (provider, lookup_key, outcome, synced_lyrics, plain_lyrics, fetched_at, expires_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT (provider, lookup_key) DO UPDATE SET \ - outcome = excluded.outcome, synced_lyrics = excluded.synced_lyrics, \ - plain_lyrics = excluded.plain_lyrics, fetched_at = excluded.fetched_at, \ - expires_at = excluded.expires_at", - ) - .bind(provider) - .bind(lookup_key) - .bind(answer.outcome.as_str()) - .bind(answer.synced.as_deref()) - .bind(answer.plain.as_deref()) - .bind(answer.fetched_at) - .bind(answer.expires_at) - .execute(&mut *tx) - .await?; - tx.commit().await - } - - /// Records what a provider answered: its row, its whole neighbour list, - /// and its portrait — the artwork row and every artist it belongs to — - /// in one transaction. - /// - /// One transaction is what keeps the sweeper from ever seeing the artwork - /// row unreferenced: it deletes under this same gate, so it runs wholly - /// before or wholly after. The writer gate is taken here and nowhere - /// earlier — the requests that produced `answer` are over, and the - /// portrait's file is already in the store, so no network or file I/O - /// ever runs under it. - pub(crate) async fn write_metadata_artist( - &self, - provider: crate::enrichment::Provider, - name_canonical: &str, - answer: &crate::enrichment::ArtistAnswer, - fetched_at: i64, - expires_at: i64, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - // What this provider's row named before, read before it is replaced: - // a refresh without the banner unlinks it (RFC-014 decision 3). - let previous_fanart: Option = sqlx::query_scalar( - "SELECT fanart_hash FROM metadata_artist WHERE provider = ? AND name_canonical = ?", - ) - .bind(provider.as_str()) - .bind(name_canonical) - .fetch_optional(&mut *tx) - .await? - .flatten(); - for image in [&answer.image, &answer.fanart].into_iter().flatten() { - sqlx::query( - "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ - VALUES (?, ?, ?, ?, ?) ON CONFLICT (hash) DO NOTHING", - ) - .bind(&image.hash) - .bind(image.format) - .bind(provider.as_str()) - .bind(image.byte_size) - .bind(fetched_at) - .execute(&mut *tx) - .await?; - } - sqlx::query( - "INSERT INTO metadata_artist \ - (provider, name_canonical, outcome, bio_summary, bio_full, image_hash, \ - fanart_hash, fetched_at, expires_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT (provider, name_canonical) DO UPDATE SET \ - outcome = excluded.outcome, bio_summary = excluded.bio_summary, \ - bio_full = excluded.bio_full, image_hash = excluded.image_hash, \ - fanart_hash = excluded.fanart_hash, \ - fetched_at = excluded.fetched_at, expires_at = excluded.expires_at", - ) - .bind(provider.as_str()) - .bind(name_canonical) - .bind(answer.outcome().as_str()) - .bind(answer.bio_summary.as_deref()) - .bind(answer.bio_full.as_deref()) - .bind(answer.image.as_ref().map(|image| image.hash.as_str())) - .bind(answer.fanart.as_ref().map(|fanart| fanart.hash.as_str())) - .bind(fetched_at) - .bind(expires_at) - .execute(&mut *tx) - .await?; - if let Some(image) = &answer.image { - link_portrait_in(&mut tx, name_canonical, &image.hash, fetched_at).await?; - } - match (&answer.fanart, previous_fanart) { - (Some(fanart), _) => { - link_fanart_in(&mut tx, name_canonical, Some(&fanart.hash), fetched_at).await?; - } - // A miss, or a hit that no longer carries the fanart: the rows - // still showing the one this provider named go back to none. - (None, Some(previous)) => { - unlink_fanart_in(&mut tx, name_canonical, &previous, fetched_at).await?; - } - (None, None) => {} - } - sqlx::query( - "DELETE FROM metadata_artist_similar WHERE provider = ? AND name_canonical = ?", - ) - .bind(provider.as_str()) - .bind(name_canonical) - .execute(&mut *tx) - .await?; - for (rank, similar) in answer.similar.iter().enumerate() { - sqlx::query( - "INSERT INTO metadata_artist_similar \ - (provider, name_canonical, rank, similar_name, similar_canonical, score) \ - VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(provider.as_str()) - .bind(name_canonical) - .bind(rank as i64) - .bind(&similar.name) - .bind(&similar.name_canonical) - .bind(similar.score) - .execute(&mut *tx) - .await?; - } - tx.commit().await - } -} - -/// Points every artist called `name_canonical` at the portrait `hash`, and -/// records one artist event for each row that actually moved — RFC-007 -/// decision 6's rule for albums, which an artist now follows: a refresh that -/// fetched the same picture changes nothing and says nothing. -/// -/// Artist rows are per library, and so are events, so each moved row carries -/// its own library. A row whose artwork row is gone is not linked: the cache -/// may name a hash the sweeper has since taken. Nor is a hash the cache no -/// longer names: a read that saw the old row, then repairs after a fill -/// replaced it, would put the old portrait back and announce it. -async fn link_portrait_in( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - name_canonical: &str, - hash: &str, - now: i64, -) -> Result<(), sqlx::Error> { - let moved = sqlx::query( - "UPDATE artist SET artwork_hash = ?, updated_at = ? \ - WHERE canonical_name = ? AND artwork_hash IS NOT ? \ - AND EXISTS (SELECT 1 FROM artwork WHERE artwork.hash = ?) \ - AND EXISTS (SELECT 1 FROM metadata_artist \ - WHERE name_canonical = ? AND image_hash = ?) \ - RETURNING id, library_id", - ) - .bind(hash) - .bind(now) - .bind(name_canonical) - .bind(hash) - .bind(hash) - .bind(name_canonical) - .bind(hash) - .fetch_all(&mut **tx) - .await?; - for row in moved { - let id: String = row.try_get("id")?; - let library_id: String = row.try_get("library_id")?; - let parse = - |value: &str| Uuid::parse_str(value).map_err(|error| sqlx::Error::Decode(error.into())); - crate::catalog::record_library_event( - tx, - crate::catalog::LibraryChange { - library_id: parse(&library_id)?, - entity_type: "artist", - entity_id: parse(&id)?, - action: "upsert", - payload: serde_json::json!({}), - changed_at: now, - origin_device_id: None, - }, - ) - .await?; - } - Ok(()) -} - -/// Points every artist called `name_canonical` at the banner `hash` -/// (RFC-014 decision 3), as [`link_portrait_in`] does the portrait: only rows -/// that move, one artist event each, and only while the artwork row exists and -/// a cache row still names it. -async fn link_fanart_in( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - name_canonical: &str, - hash: Option<&str>, - now: i64, -) -> Result<(), sqlx::Error> { - let Some(hash) = hash else { - return Ok(()); - }; - let moved = sqlx::query( - "UPDATE artist SET fanart_hash = ?, updated_at = ? \ - WHERE canonical_name = ? AND fanart_hash IS NOT ? \ - AND EXISTS (SELECT 1 FROM artwork WHERE artwork.hash = ?) \ - AND EXISTS (SELECT 1 FROM metadata_artist \ - WHERE name_canonical = ? AND fanart_hash = ?) \ - RETURNING id, library_id", - ) - .bind(hash) - .bind(now) - .bind(name_canonical) - .bind(hash) - .bind(hash) - .bind(name_canonical) - .bind(hash) - .fetch_all(&mut **tx) - .await?; - record_artist_moves(tx, moved, now).await -} - -/// Takes the banner `previous` off every artist called `name_canonical` that -/// still shows it: the provider no longer offers it (RFC-014 decision 3). Only -/// that hash — a row already showing a newer one is not this refresh's to -/// clear. -async fn unlink_fanart_in( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - name_canonical: &str, - previous: &str, - now: i64, -) -> Result<(), sqlx::Error> { - let moved = sqlx::query( - "UPDATE artist SET fanart_hash = NULL, updated_at = ? \ - WHERE canonical_name = ? AND fanart_hash = ? \ - RETURNING id, library_id", - ) - .bind(now) - .bind(name_canonical) - .bind(previous) - .fetch_all(&mut **tx) - .await?; - record_artist_moves(tx, moved, now).await -} - -/// One artist event per moved row, in the row's own library. -async fn record_artist_moves( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - moved: Vec, - now: i64, -) -> Result<(), sqlx::Error> { - for row in moved { - let id: String = row.try_get("id")?; - let library_id: String = row.try_get("library_id")?; - let parse = - |value: &str| Uuid::parse_str(value).map_err(|error| sqlx::Error::Decode(error.into())); - crate::catalog::record_library_event( - tx, - crate::catalog::LibraryChange { - library_id: parse(&library_id)?, - entity_type: "artist", - entity_id: parse(&id)?, - action: "upsert", - payload: serde_json::json!({}), - changed_at: now, - origin_device_id: None, - }, - ) - .await?; - } - Ok(()) -} +async fn insert_audit( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + actor_id: Option, + kind: &str, + subject_id: Option, + now_ms: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO audit_event (actor_user_id, kind, subject_id, occurred_at) VALUES (?, ?, ?, ?)", + ) + .bind(actor_id.map(|id| id.to_string())) + .bind(kind) + .bind(subject_id.map(|id| id.to_string())) + .bind(now_ms) + .execute(&mut **tx) + .await?; + Ok(()) +} #[cfg(test)] -mod tests { - use super::*; - - async fn migrated_database() -> (tempfile::TempDir, Database) { - let temp = tempfile::tempdir().expect("temporary directory"); - let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) - .await - .expect("open database"); - database.migrate().await.expect("initial migration"); - (temp, database) - } - - /// Every other migration test starts from an empty database, and an empty - /// database is the one case a table rebuild cannot fail on: dropping the - /// old parent cascades into nothing and restricts nothing. The rebuild in - /// `20260823010000_participants` therefore passed the whole suite and then - /// refused to open the first database that held a single track. - /// - /// So this one fills the catalogue in first, at the schema version just - /// before the rebuild, and migrates from there. - /// - /// It is pinned to that one boundary on purpose — it was written for that - /// defect. The general question, whether a database at a *released* - /// schema still upgrades with its rows and still answers through the - /// services, is the `migrations` integration target (#223); add a seed - /// row there rather than here when a new table has to survive an upgrade. - #[tokio::test] - async fn a_populated_database_survives_a_table_rebuild() { - use sqlx::migrate::Migrate; - - /// The last migration before `artist` and `album` are rebuilt. - const BEFORE_THE_REBUILD: i64 = 20260823000000; - - let temp = tempfile::tempdir().expect("temporary directory"); - let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) - .await - .expect("open database"); - - let mut connection = database.pool.acquire().await.expect("connection"); - connection - .ensure_migrations_table(&MIGRATOR.table_name) - .await - .expect("migrations table"); - for migration in MIGRATOR - .iter() - .filter(|migration| migration.version <= BEFORE_THE_REBUILD) - { - connection - .apply(&MIGRATOR.table_name, migration) - .await - .expect("earlier migration"); - } - - // A track, its album and its artist, plus the credit relating them: - // one row in each table the rebuild touches or cascades from. The - // identifiers are UUIDs because every real row's is: a fixture shaped - // unlike production is one a production-shaped bug can pass through. - const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000001"; - const LIBRARY: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000002"; - const ARTIST: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000003"; - const ALBUM: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000004"; - const TRACK: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000005"; - let digest = "0".repeat(64); - for (statement, binds) in [ - ( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - VALUES (?, 'owner', 'x', 'admin', 0, 0)", - vec![ACCOUNT], - ), - ( - "INSERT INTO library (id, owner_user_id, name, root_path, visibility, \ - created_at, updated_at) \ - VALUES (?, ?, 'Library', '/music', 'private', 0, 0)", - vec![LIBRARY, ACCOUNT], - ), - ( - "INSERT INTO artist (id, library_id, name, canonical_name, created_at, \ - updated_at) \ - VALUES (?, ?, 'Nova Kern', 'nova kern', 0, 0)", - vec![ARTIST, LIBRARY], - ), - ( - "INSERT INTO album (id, library_id, title, canonical_title, identity_key, \ - album_artist_id, album_artist_name, created_at, updated_at) \ - VALUES (?, ?, 'Convergence', 'convergence', 'key', ?, 'Nova Kern', 0, 0)", - vec![ALBUM, LIBRARY, ARTIST], - ), - ( - "INSERT INTO track (id, library_id, album_id, relative_path, file_size, \ - file_modified_at, quick_hash, full_hash, title, \ - duration_ms, created_at, updated_at) \ - VALUES (?, ?, ?, 'a.flac', 1, 0, ?, ?, 'Zenith', 1000, 0, 0)", - vec![TRACK, LIBRARY, ALBUM, digest.as_str(), digest.as_str()], - ), - ( - "INSERT INTO track_artist (track_id, artist_id, library_id, position) \ - VALUES (?, ?, ?, 0)", - vec![TRACK, ARTIST, LIBRARY], - ), - ] { - let mut query = sqlx::query(statement); - for bind in binds { - query = query.bind(bind.to_owned()); - } - query.execute(&mut *connection).await.expect("seed row"); - } - drop(connection); - - database - .migrate() - .await - .expect("a database with a catalogue migrates"); - - // The rebuild has to carry its rows, not merely survive: an implicit - // `DELETE FROM` before a drop empties children silently, and a - // migration that lost the credits would still report success. - let mut connection = database.pool.acquire().await.expect("connection"); - for (table, query) in [ - ("artist", "SELECT COUNT(*) FROM artist"), - ("album", "SELECT COUNT(*) FROM album"), - ("track", "SELECT COUNT(*) FROM track"), - ( - "track_participant", - "SELECT COUNT(*) FROM track_participant", - ), - ] { - let count: i64 = sqlx::query_scalar(query) - .fetch_one(&mut *connection) - .await - .expect("count"); - assert_eq!(count, 1, "{table} lost its rows to the rebuild"); - } - let role: String = - sqlx::query_scalar("SELECT role FROM track_participant WHERE track_id = '6f1d1b6e-0f1a-4a3d-9c21-000000000005'") - .fetch_one(&mut *connection) - .await - .expect("carried credit"); - assert_eq!(role, "artist", "the carried credit kept its meaning"); - - // And enforcement, suspended for the rebuild, is answered for. - let violations = sqlx::query("PRAGMA foreign_key_check") - .fetch_all(&mut *connection) - .await - .expect("foreign key check"); - assert!( - violations.is_empty(), - "the rebuilt schema left {} broken reference(s)", - violations.len() - ); - } - - fn crlf_checksum(version: i64) -> Vec { - let migration = MIGRATOR - .iter() - .find(|migration| migration.version == version) - .expect("known migration"); - Sha384::digest(migration.sql.as_str().replace('\n', "\r\n").as_bytes()).to_vec() - } - - #[tokio::test] - async fn migrate_accepts_checksums_from_legacy_windows_line_endings() { - let (_temp, database) = migrated_database().await; - let versions = [20260806000000_i64, 20260806120000_i64]; - - let writer = database.writer_guard().await; - for version in versions { - let legacy_checksum = crlf_checksum(version); - let current_checksum = MIGRATOR - .iter() - .find(|migration| migration.version == version) - .expect("known migration") - .checksum - .as_ref(); - assert_ne!(legacy_checksum, current_checksum); - sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") - .bind(legacy_checksum) - .bind(version) - .execute(&database.pool) - .await - .expect("install legacy checksum"); - } - drop(writer); - - database.migrate().await.expect("normalize then migrate"); - - for version in versions { - let expected = MIGRATOR - .iter() - .find(|migration| migration.version == version) - .expect("known migration") - .checksum - .as_ref(); - let stored: Vec = - sqlx::query_scalar("SELECT checksum FROM _sqlx_migrations WHERE version = ?") - .bind(version) - .fetch_one(&database.pool) - .await - .expect("read normalized checksum"); - assert_eq!(stored, expected); - } - } - - #[tokio::test] - async fn migrate_still_rejects_an_unrelated_checksum_mismatch() { - let (_temp, database) = migrated_database().await; - let version = 20260806000000_i64; - let writer = database.writer_guard().await; - sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") - .bind(vec![0_u8; 48]) - .bind(version) - .execute(&database.pool) - .await - .expect("install invalid checksum"); - drop(writer); - - let error = database - .migrate() - .await - .expect_err("unrelated checksum must remain invalid"); - assert!( - error - .to_string() - .contains("previously applied but has been modified"), - "unexpected error: {error:#}" - ); - } - - /// A retry queued before `retried_at` existed still counts as the joker - /// spent, once the column arrives. - /// - /// Retried entries exist on every database linked since #191, and until - /// `20260914000000` the only trace of one was a second row naming it - /// through `retry_of`. Adding the column empty would leave every one of - /// those originals unanswered again — the migration would reintroduce, on - /// the only servers that have anything to reintroduce it to, exactly the - /// defect it exists to correct. Reading it on a database populated at the - /// schema version before the column is what says otherwise; a rereading of - /// the SQL is not. - #[tokio::test] - async fn a_retry_queued_before_the_column_counts_as_the_joker_spent() { - use sqlx::migrate::Migrate; - - /// The last migration before `scrobble_outbox.retried_at`. - const BEFORE_THE_COLUMN: i64 = 20260913000000; - - /// When the ambiguous entry was last written, and when its retry was - /// queued. Distinct on purpose: the backfill takes the instant the - /// joker was spent, which is the retry's, and a test where the two - /// agreed could not tell one from the other. - const SETTLED_AT: i64 = 1_700_000_111_000; - const RETRIED_AT: i64 = 1_700_000_555_000; - - let temp = tempfile::tempdir().expect("temporary directory"); - let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) - .await - .expect("open database"); - - let mut connection = database.pool.acquire().await.expect("connection"); - connection - .ensure_migrations_table(&MIGRATOR.table_name) - .await - .expect("migrations table"); - for migration in MIGRATOR - .iter() - .filter(|migration| migration.version <= BEFORE_THE_COLUMN) - { - connection - .apply(&MIGRATOR.table_name, migration) - .await - .expect("earlier migration"); - } - - // An account and one authorisation, inserted as rows rather than - // through the services: this test is about what the migration does to - // a shape, and `hash_password` on a literal is what the repository's - // code scanning refuses. - const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000011"; - const LINK: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000012"; - for (statement, binds) in [ - ( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - VALUES (?, 'listener', 'this-account-never-authenticates', 'user', 0, 0)", - vec![ACCOUNT], - ), - ( - "INSERT INTO scrobble_link (id, user_id, provider, status, credential_nonce, \ - credential_ciphertext, created_at, updated_at) \ - VALUES (?, ?, 'listenbrainz', 'active', x'000000000000000000000000', \ - x'00', 0, 0)", - vec![LINK, ACCOUNT], - ), - ] { - let mut query = sqlx::query(statement); - for bind in binds { - query = query.bind(bind.to_owned()); - } - query.execute(&mut *connection).await.expect("seed row"); - } - - // Three rows in the shape #191 leaves behind: an ambiguous entry whose - // joker was spent, the retry that spent it, and a second ambiguous - // entry nobody has answered. - const ANSWERED: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000013"; - const RETRY: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000014"; - const UNANSWERED: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000015"; - for (public_id, state, created_at, updated_at) in [ - (ANSWERED, "uncertain", SETTLED_AT, SETTLED_AT), - (RETRY, "sent", RETRIED_AT, RETRIED_AT), - (UNANSWERED, "uncertain", SETTLED_AT, SETTLED_AT), - ] { - sqlx::query( - "INSERT INTO scrobble_outbox (public_id, link_id, played_at, title, \ - artists_json, state, attempts, next_attempt_at, created_at, updated_at) \ - VALUES (?, ?, 0, 'Zenith', '[\"Nova Kern\"]', ?, 1, 0, ?, ?)", - ) - .bind(public_id) - .bind(LINK) - .bind(state) - .bind(created_at) - .bind(updated_at) - .execute(&mut *connection) - .await - .expect("seed outbox row"); - } - // The link between them is written by lookup rather than by assuming - // the first row took rowid 1: `retry_of` speaks in rowids, and a - // fixture that guessed one would pass for a reason unrelated to the - // migration. - sqlx::query( - "UPDATE scrobble_outbox \ - SET retry_of = (SELECT id FROM scrobble_outbox WHERE public_id = ?) \ - WHERE public_id = ?", - ) - .bind(ANSWERED) - .bind(RETRY) - .execute(&mut *connection) - .await - .expect("point the retry at what it retried"); - drop(connection); - - database - .migrate() - .await - .expect("a database with a queue migrates"); - - let mut connection = database.pool.acquire().await.expect("connection"); - let answered: Option = - sqlx::query_scalar("SELECT retried_at FROM scrobble_outbox WHERE public_id = ?") - .bind(ANSWERED) - .fetch_one(&mut *connection) - .await - .expect("the answered entry"); - assert_eq!( - answered, - Some(RETRIED_AT), - "an entry retried before the column must carry the instant its retry was queued" - ); - - let unanswered: Option = - sqlx::query_scalar("SELECT retried_at FROM scrobble_outbox WHERE public_id = ?") - .bind(UNANSWERED) - .fetch_one(&mut *connection) - .await - .expect("the unanswered entry"); - assert_eq!( - unanswered, None, - "an entry nobody answered must still be asking" - ); - - let retry: Option = - sqlx::query_scalar("SELECT retried_at FROM scrobble_outbox WHERE public_id = ?") - .bind(RETRY) - .fetch_one(&mut *connection) - .await - .expect("the retry"); - assert_eq!( - retry, None, - "the retry spent nobody's joker; it is the one that was spent" - ); - } - - /// Rows that finished before the column knew when they finished. - /// - /// The purge counts from `terminal_at`, so a row written before - /// `20260914010000` would carry none and never be eligible: on a server - /// that has been running for months, retention would arrive and apply to - /// nothing it was added for. They take their `updated_at`, which is the - /// write that made them terminal — true because every `UPDATE` - /// `scrobble_outbox` knows requires a non-terminal starting state, so a - /// finished row's last write *is* its transition. - /// - /// `uncertain` and `pending` take none, and that is half the assertion: - /// an entry still asking a person must not become purgeable by a migration. - #[tokio::test] - async fn rows_that_finished_before_the_column_are_dated_by_their_last_write() { - use sqlx::migrate::Migrate; - - /// The last migration before `scrobble_outbox.terminal_at`. - const BEFORE_THE_COLUMN: i64 = 20260914000000; - - let temp = tempfile::tempdir().expect("temporary directory"); - let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) - .await - .expect("open database"); - - let mut connection = database.pool.acquire().await.expect("connection"); - connection - .ensure_migrations_table(&MIGRATOR.table_name) - .await - .expect("migrations table"); - for migration in MIGRATOR - .iter() - .filter(|migration| migration.version <= BEFORE_THE_COLUMN) - { - connection - .apply(&MIGRATOR.table_name, migration) - .await - .expect("earlier migration"); - } - - const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000021"; - const LINK: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000022"; - for (statement, binds) in [ - ( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - VALUES (?, 'archivist', 'this-account-never-authenticates', 'user', 0, 0)", - vec![ACCOUNT], - ), - ( - "INSERT INTO scrobble_link (id, user_id, provider, status, credential_nonce, \ - credential_ciphertext, created_at, updated_at) \ - VALUES (?, ?, 'listenbrainz', 'active', x'000000000000000000000000', \ - x'00', 0, 0)", - vec![LINK, ACCOUNT], - ), - ] { - let mut query = sqlx::query(statement); - for bind in binds { - query = query.bind(bind.to_owned()); - } - query.execute(&mut *connection).await.expect("seed row"); - } - - // Every state the queue knows, each with its own `updated_at`, so an - // assertion cannot pass by reading a neighbour's instant. `created_at` - // is deliberately a different value throughout: a backfill that read - // *it* would date a listen from when it was queued rather than from - // when it stopped moving, which is the distinction the column exists - // for. - const QUEUED_AT: i64 = 1_700_000_000_000; - let expected = [ - ("sent", 1_700_000_001_000_i64), - ("rejected", 1_700_000_002_000), - ("abandoned", 1_700_000_003_000), - ("cancelled", 1_700_000_004_000), - ("discarded", 1_700_000_005_000), - ]; - let untouched = [ - ("uncertain", 1_700_000_006_000_i64), - ("pending", 1_700_000_007_000), - ]; - for (nth, (state, updated_at)) in expected.iter().chain(untouched.iter()).enumerate() { - sqlx::query( - "INSERT INTO scrobble_outbox (public_id, link_id, played_at, title, \ - artists_json, state, attempts, next_attempt_at, created_at, updated_at) \ - VALUES (?, ?, 0, 'Zenith', '[\"Nova Kern\"]', ?, 1, 0, ?, ?)", - ) - .bind(format!("6f1d1b6e-0f1a-4a3d-9c21-0000000003{:02}", nth)) - .bind(LINK) - .bind(*state) - .bind(QUEUED_AT) - .bind(*updated_at) - .execute(&mut *connection) - .await - .expect("seed outbox row"); - } - drop(connection); - - database - .migrate() - .await - .expect("a database with a finished queue migrates"); - - let mut connection = database.pool.acquire().await.expect("connection"); - for (state, updated_at) in expected { - let terminal_at: Option = - sqlx::query_scalar("SELECT terminal_at FROM scrobble_outbox WHERE state = ?") - .bind(state) - .fetch_one(&mut *connection) - .await - .expect("the finished row"); - assert_eq!( - terminal_at, - Some(updated_at), - "a {state} row must be dated by the write that finished it" - ); - } - for (state, _) in untouched { - let terminal_at: Option = - sqlx::query_scalar("SELECT terminal_at FROM scrobble_outbox WHERE state = ?") - .bind(state) - .fetch_one(&mut *connection) - .await - .expect("the unfinished row"); - assert_eq!( - terminal_at, None, - "a {state} row has not finished and must not become purgeable" - ); - } - } - - /// A read takes the cache row without the gate, so a fill can replace and - /// link a newer portrait before the read's repair runs. That repair must - /// not put the hash it read back over the newer one, nor announce it. - #[tokio::test] - async fn a_repair_does_not_restore_a_portrait_the_cache_replaced() { - let (_temp, database) = migrated_database().await; - const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000021"; - const LIBRARY: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000022"; - const ARTIST: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000023"; - let read = "a".repeat(64); - let replaced = "b".repeat(64); - for (statement, binds) in [ - ( - "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ - VALUES (?, 'owner', 'this-account-never-authenticates', 'admin', 0, 0)", - vec![ACCOUNT], - ), - ( - "INSERT INTO library (id, owner_user_id, name, root_path, visibility, \ - created_at, updated_at) \ - VALUES (?, ?, 'Library', '/music', 'private', 0, 0)", - vec![LIBRARY, ACCOUNT], - ), - ( - "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ - VALUES (?, 'jpg', 'deezer', 1, 0), (?, 'jpg', 'deezer', 1, 0)", - vec![read.as_str(), replaced.as_str()], - ), - ( - "INSERT INTO artist (id, library_id, name, canonical_name, artwork_hash, \ - created_at, updated_at) \ - VALUES (?, ?, 'Nova Kern', 'nova kern', ?, 0, 0)", - vec![ARTIST, LIBRARY, replaced.as_str()], - ), - ( - "INSERT INTO metadata_artist (provider, name_canonical, outcome, image_hash, \ - fetched_at, expires_at) \ - VALUES ('deezer', 'nova kern', 'hit', ?, 0, 0)", - vec![replaced.as_str()], - ), - ] { - let mut query = sqlx::query(statement); - for bind in binds { - query = query.bind(bind.to_owned()); - } - query.execute(&database.pool).await.expect("seed row"); - } - - database - .link_artist_portrait("nova kern", &read) - .await - .expect("repair"); - - let linked: String = sqlx::query_scalar("SELECT artwork_hash FROM artist WHERE id = ?") - .bind(ARTIST) - .fetch_one(&database.pool) - .await - .expect("artist row"); - assert!(linked == replaced, "the newer portrait was replaced"); - let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM library_event") - .fetch_one(&database.pool) - .await - .expect("event count"); - assert_eq!(events, 0); - } -} +mod tests; diff --git a/src/database/sessions.rs b/src/database/sessions.rs new file mode 100644 index 00000000..bfba20b7 --- /dev/null +++ b/src/database/sessions.rs @@ -0,0 +1,592 @@ +//! The credentials an account signs in with: OAuth authorizations, devices, +//! sessions and API tokens. + +use sqlx::Row; +use uuid::Uuid; + +use super::{insert_audit, parse_role, parse_uuid, AccountRole, Database}; + +/// One issued API token, without its secret. +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct ApiTokenRecord { + pub id: Uuid, + pub name: String, + pub scopes: Vec, + pub expires_at: Option, + pub created_at: i64, + pub last_used_at: Option, + pub revoked_at: Option, +} + +#[derive(Debug, Clone)] +pub struct SessionRecord { + pub id: Uuid, + pub user_id: Uuid, + pub device_id: Uuid, + pub username: String, + pub role: AccountRole, + pub refresh_expires_at: i64, + /// The scopes this session was issued under. Empty for a password login + /// and for a grant made by an unscoped credential; a narrowed token that + /// authorized a device leaves its narrowing here. + pub scopes: Vec, +} + +#[derive(Debug, Clone)] +/// One API token to write, gathered rather than passed as seven arguments. +/// +/// `actor_id` is who registered it, which the audit entry records and which +/// is not always the owner: an administrator provisioning a service account is +/// the actor, and the account is the subject. +pub struct NewApiToken<'a> { + pub actor_id: Uuid, + pub user_id: Uuid, + pub name: &'a str, + pub token_hash: &'a [u8], + pub scopes: &'a [String], + pub expires_at: Option, +} + +pub struct AccessRecord { + pub user_id: Uuid, + pub username: String, + pub role: AccountRole, + /// The scopes of the API token this request arrived on. Empty for a + /// session or an OAuth grant, which carry the account's full authority, + /// and for a token issued without any. + pub scopes: Vec, + /// When the API token this request arrived on stops working, if it is one + /// and if it has a deadline. + /// + /// `None` for a session, which is a different kind of credential: it is + /// short-lived and refreshes, and authorising something longer-lived than + /// itself is part of what it is for. `None` for a token issued without an + /// expiry too — there is simply nothing to bound by. + pub credential_expires_at: Option, +} + +#[derive(Debug, Clone)] +pub struct NewSession<'a> { + pub id: Uuid, + pub user_id: Uuid, + pub device_id: Uuid, + pub access_token_hash: &'a [u8], + pub refresh_token_hash: &'a [u8], + pub access_expires_at: i64, + pub refresh_expires_at: i64, + pub now_ms: i64, + /// Carried from the credential that issued this session, so it can never + /// be broader than what asked for it. + pub scopes: &'a [String], +} + +#[derive(Debug, Clone)] +pub struct NewAuthorization<'a> { + pub code_hash: [u8; 32], + pub user_id: Uuid, + pub client_id: &'a str, + pub redirect_uri: &'a str, + pub code_challenge: &'a str, + pub device_name: &'a str, + pub now_ms: i64, + pub expires_at: i64, + /// The scopes of the credential that authorized this grant. + pub scopes: &'a [String], +} + +#[derive(Debug, Clone)] +pub struct AuthorizationRecord { + pub user_id: Uuid, + pub client_id: String, + pub redirect_uri: String, + pub code_challenge: String, + pub device_name: String, + /// Handed to the session this grant is redeemed for. + pub scopes: Vec, +} + +impl Database { + pub async fn create_authorization( + &self, + grant: NewAuthorization<'_>, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + sqlx::query( + "INSERT INTO oauth_authorization \ + (code_hash, user_id, client_id, redirect_uri, code_challenge, device_name, \ + created_at, expires_at, scopes_json) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(grant.code_hash.as_slice()) + .bind(grant.user_id.to_string()) + .bind(grant.client_id) + .bind(grant.redirect_uri) + .bind(grant.code_challenge) + .bind(grant.device_name) + .bind(grant.now_ms) + .bind(grant.expires_at) + .bind(encode_scopes(grant.scopes)?) + .execute(&mut *tx) + .await?; + // Delegating access to another application is exactly the kind of event + // the audit trail exists for, alongside account and credential changes. + insert_audit( + &mut tx, + Some(grant.user_id), + "oauth.authorization.granted", + Some(grant.user_id), + grant.now_ms, + ) + .await?; + tx.commit().await?; + Ok(()) + } + + /// Claims a grant for redemption, atomically marking it used. + /// + /// The `redeemed_at IS NULL` predicate inside the UPDATE is what makes a + /// code single-use: two concurrent redemptions cannot both match, so a + /// stolen code cannot be replayed alongside the legitimate exchange. + /// + /// A code is spent by the first presentation whatever its outcome, so a + /// failed PKCE check burns it rather than leaving it open to further + /// attempts. That forecloses guessing a verifier, and matches OAuth 2.1's + /// rule that a code presented more than once must be revoked. The cost is + /// that a client which botches its own exchange must restart the flow. + pub async fn redeem_authorization( + &self, + code_hash: &[u8], + now_ms: i64, + ) -> Result, sqlx::Error> { + let _writer = self.writer_guard().await; + let row = sqlx::query( + "UPDATE oauth_authorization SET redeemed_at = ? \ + WHERE code_hash = ? AND redeemed_at IS NULL AND expires_at > ? \ + RETURNING user_id, client_id, redirect_uri, code_challenge, device_name, \ + scopes_json", + ) + .bind(now_ms) + .bind(code_hash) + .bind(now_ms) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(AuthorizationRecord { + user_id: parse_uuid(row.try_get("user_id")?)?, + client_id: row.try_get("client_id")?, + redirect_uri: row.try_get("redirect_uri")?, + code_challenge: row.try_get("code_challenge")?, + device_name: row.try_get("device_name")?, + scopes: decode_scopes(row.try_get("scopes_json")?)?, + }) + }) + .transpose() + } + + /// Sweeps expired grants hourly for the life of the process. + /// + /// Rows are kept after redemption so a replay is recognised rather than + /// looking unknown, so nothing else ever deletes them; without this the + /// table only grows. + pub fn spawn_authorization_pruning(&self) { + let db = self.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); + loop { + ticker.tick().await; + match db + .prune_authorizations(crate::authentication::now_ms()) + .await + { + Ok(0) => {} + Ok(removed) => tracing::debug!(removed, "pruned expired authorization codes"), + Err(error) => { + tracing::warn!(error = %error, "could not prune authorization codes") + } + } + } + }); + } + + /// Drops grants that can no longer be redeemed. + pub async fn prune_authorizations(&self, now_ms: i64) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query("DELETE FROM oauth_authorization WHERE expires_at <= ?") + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + pub async fn create_device( + &self, + user_id: Uuid, + name: &str, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO device (id, user_id, name, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .bind(name.trim()) + .bind(now_ms) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(id) + } + + pub async fn create_session(&self, session: NewSession<'_>) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + sqlx::query( + "INSERT INTO session \ + (id, user_id, device_id, access_token_hash, refresh_token_hash, access_expires_at, \ + refresh_expires_at, created_at, last_used_at, scopes_json) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(session.id.to_string()) + .bind(session.user_id.to_string()) + .bind(session.device_id.to_string()) + .bind(session.access_token_hash) + .bind(session.refresh_token_hash) + .bind(session.access_expires_at) + .bind(session.refresh_expires_at) + .bind(session.now_ms) + .bind(session.now_ms) + .bind(encode_scopes(session.scopes)?) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn session_by_refresh_hash( + &self, + refresh_hash: &[u8], + now_ms: i64, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT s.id, s.user_id, s.device_id, a.username, a.role, s.refresh_expires_at, \ + s.scopes_json \ + FROM session s JOIN account a ON a.id = s.user_id \ + WHERE s.refresh_token_hash = ? AND s.revoked_at IS NULL \ + AND s.refresh_expires_at > ? AND a.disabled = 0", + ) + .bind(refresh_hash) + .bind(now_ms) + .fetch_optional(&self.pool) + .await?; + row.map(session_from_row).transpose() + } + + pub async fn account_by_access_hash( + &self, + access_hash: &[u8], + now_ms: i64, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT a.id, a.username, a.role, s.scopes_json FROM session s \ + JOIN account a ON a.id = s.user_id \ + JOIN device d ON d.id = s.device_id \ + WHERE s.access_token_hash = ? AND s.revoked_at IS NULL \ + AND s.access_expires_at > ? AND a.disabled = 0 AND d.revoked_at IS NULL", + ) + .bind(access_hash) + .bind(now_ms) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(AccessRecord { + user_id: parse_uuid(row.try_get("id")?)?, + username: row.try_get("username")?, + role: parse_role(row.try_get("role")?)?, + scopes: decode_scopes(row.try_get("scopes_json")?)?, + // A session, not an API token: see the field. + credential_expires_at: None, + }) + }) + .transpose() + } + + pub async fn account_by_api_token_hash( + &self, + token_hash: &[u8], + now_ms: i64, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT a.id, a.username, a.role, t.scopes_json, t.last_used_at, \ + t.expires_at FROM api_token t \ + JOIN account a ON a.id = t.user_id \ + WHERE t.token_hash = ? AND t.revoked_at IS NULL \ + AND (t.expires_at IS NULL OR t.expires_at > ?) AND a.disabled = 0", + ) + .bind(token_hash) + .bind(now_ms) + .fetch_optional(&self.pool) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let last_used_at: Option = row.try_get("last_used_at")?; + let account = AccessRecord { + user_id: parse_uuid(row.try_get("id")?)?, + username: row.try_get("username")?, + role: parse_role(row.try_get("role")?)?, + scopes: decode_scopes(row.try_get("scopes_json")?)?, + credential_expires_at: row.try_get("expires_at")?, + }; + if last_used_at.is_none_or(|last_used| last_used <= now_ms.saturating_sub(60_000)) { + let _writer = self.writer_guard().await; + sqlx::query( + "UPDATE api_token SET last_used_at = ? WHERE token_hash = ? \ + AND revoked_at IS NULL AND (last_used_at IS NULL OR last_used_at <= ?)", + ) + .bind(now_ms) + .bind(token_hash) + .bind(now_ms.saturating_sub(60_000)) + .execute(&self.pool) + .await?; + } + Ok(Some(account)) + } + + #[allow(clippy::too_many_arguments)] + pub async fn rotate_session( + &self, + session_id: Uuid, + expected_refresh_hash: &[u8], + access_hash: &[u8], + refresh_hash: &[u8], + access_expires_at: i64, + refresh_expires_at: i64, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query( + "UPDATE session SET access_token_hash = ?, refresh_token_hash = ?, \ + access_expires_at = ?, refresh_expires_at = ?, last_used_at = ? \ + WHERE id = ? AND refresh_token_hash = ? AND revoked_at IS NULL \ + AND refresh_expires_at > ?", + ) + .bind(access_hash) + .bind(refresh_hash) + .bind(access_expires_at) + .bind(refresh_expires_at) + .bind(now_ms) + .bind(session_id.to_string()) + .bind(expected_refresh_hash) + .bind(now_ms) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + + pub async fn revoke_session_by_access_hash( + &self, + access_hash: &[u8], + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query( + "UPDATE session SET revoked_at = ? \ + WHERE access_token_hash = ? AND revoked_at IS NULL", + ) + .bind(now_ms) + .bind(access_hash) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + + pub async fn revoke_session_by_refresh_hash( + &self, + refresh_hash: &[u8], + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query( + "UPDATE session SET revoked_at = ? \ + WHERE refresh_token_hash = ? AND revoked_at IS NULL", + ) + .bind(now_ms) + .bind(refresh_hash) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + + /// Issues a token and returns its record. + /// + /// `RETURNING` rather than an insert followed by a read: the caller wants + /// the row it just wrote, and looking it up afterwards meant listing every + /// token the account holds to find one whose id was already known. + pub async fn create_api_token( + &self, + token: NewApiToken<'_>, + now_ms: i64, + ) -> Result { + let NewApiToken { + actor_id, + user_id, + name, + token_hash, + scopes, + expires_at, + } = token; + let _writer = self.writer_guard().await; + let id = Uuid::new_v4(); + // `expires_at` has been a column, and honoured by the lookup, since + // the table existed — and never written. Nothing minted a bounded + // token, so every one of them outlived whatever asked for it. + let mut tx = self.begin_write(&_writer).await?; + let row = sqlx::query( + "INSERT INTO api_token \ + (id, user_id, name, token_hash, scopes_json, expires_at, created_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + RETURNING id, name, scopes_json, expires_at, created_at, last_used_at, revoked_at", + ) + .bind(id.to_string()) + .bind(user_id.to_string()) + .bind(name.trim()) + .bind(token_hash) + .bind(encode_scopes(scopes)?) + .bind(expires_at) + .bind(now_ms) + .fetch_one(&mut *tx) + .await?; + // In the same transaction as the row it describes, the way the + // revocation beside it already is. Revoking a credential left an audit + // entry and registering one left none, which is the wrong half to + // record: it became an act an account can perform for itself without + // an administrator anywhere, so it is exactly the event an operator + // would want to find afterwards. + insert_audit( + &mut tx, + Some(actor_id), + "api_token.created", + Some(id), + now_ms, + ) + .await?; + tx.commit().await?; + api_token_from_row(row) + } + + pub async fn revoke_api_token_by_hash( + &self, + token_hash: &[u8], + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query( + "UPDATE api_token SET revoked_at = ? \ + WHERE token_hash = ? AND revoked_at IS NULL", + ) + .bind(now_ms) + .bind(token_hash) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + + /// The tokens issued to one account, newest first, secrets excluded. + /// + /// `token_hash` is never projected. The secret exists once, in the creation + /// response; a listing that could return it would make every read of this + /// route as sensitive as issuing a new one. + pub async fn api_tokens_for_user( + &self, + user_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query( + "SELECT id, name, scopes_json, expires_at, created_at, last_used_at, revoked_at \ + FROM api_token WHERE user_id = ? ORDER BY created_at DESC, id", + ) + .bind(user_id.to_string()) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(api_token_from_row) + .collect() + } + + /// Revokes one token of one account. + /// + /// The account is part of the condition rather than checked beforehand, so + /// an administrator cannot revoke a token by naming the wrong owner, and a + /// token already revoked answers the same as one that never existed: the + /// caller asked for it to stop working, and it does not. + pub async fn revoke_api_token( + &self, + actor_id: Uuid, + user_id: Uuid, + token_id: Uuid, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let result = sqlx::query( + "UPDATE api_token SET revoked_at = ? \ + WHERE id = ? AND user_id = ? AND revoked_at IS NULL", + ) + .bind(now_ms) + .bind(token_id.to_string()) + .bind(user_id.to_string()) + .execute(&mut *tx) + .await?; + let revoked = result.rows_affected() == 1; + if revoked { + insert_audit( + &mut tx, + Some(actor_id), + "api_token.revoked", + Some(token_id), + now_ms, + ) + .await?; + } + tx.commit().await?; + Ok(revoked) + } +} + +fn session_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(SessionRecord { + id: parse_uuid(row.try_get("id")?)?, + user_id: parse_uuid(row.try_get("user_id")?)?, + device_id: parse_uuid(row.try_get("device_id")?)?, + username: row.try_get("username")?, + role: parse_role(row.try_get("role")?)?, + refresh_expires_at: row.try_get("refresh_expires_at")?, + scopes: decode_scopes(row.try_get("scopes_json")?)?, + }) +} + +/// Scopes are stored as a JSON array so a credential's limit travels as one +/// column, on the three tables that can carry one: `api_token`, and now +/// `oauth_authorization` and `session`. +fn encode_scopes(scopes: &[String]) -> Result { + serde_json::to_string(scopes).map_err(|error| sqlx::Error::Encode(error.into())) +} + +/// Counterpart of [`encode_scopes`]. An unreadable list is a decode error +/// rather than an empty one: silently reading a corrupt limit as "no limit" +/// would widen a credential exactly where it must not. +fn decode_scopes(value: &str) -> Result, sqlx::Error> { + serde_json::from_str(value).map_err(|error| sqlx::Error::Decode(error.into())) +} + +fn api_token_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(ApiTokenRecord { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + scopes: decode_scopes(row.try_get("scopes_json")?)?, + expires_at: row.try_get("expires_at")?, + created_at: row.try_get("created_at")?, + last_used_at: row.try_get("last_used_at")?, + revoked_at: row.try_get("revoked_at")?, + }) +} diff --git a/src/database/subsonic_credentials.rs b/src/database/subsonic_credentials.rs new file mode 100644 index 00000000..9307c53e --- /dev/null +++ b/src/database/subsonic_credentials.rs @@ -0,0 +1,176 @@ +//! The dedicated Subsonic credential: setting, rotating, revoking it, and +//! saying whether an account holds one. + +use uuid::Uuid; + +use crate::security::EncryptedSecret; + +use super::{insert_audit, Database}; + +/// Whether an account holds a dedicated Subsonic credential, and since when. +/// +/// **The password is not in this shape and cannot be put into it**, because +/// the query behind it never selects the columns that hold one. A shape that +/// does not carry a secret cannot leak one by a later careless edit of the +/// handler. +#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] +pub struct SubsonicCredentialStatus { + /// When this account's credential was first set. + pub created_at: i64, + /// When it last changed — the same instant a rotation reports back. + pub updated_at: i64, +} + +impl Database { + pub async fn set_subsonic_credential( + &self, + actor_id: Uuid, + user_id: Uuid, + secret: &EncryptedSecret, + api_key_hash: &[u8], + now_ms: i64, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + sqlx::query( + "INSERT INTO subsonic_credential \ + (user_id, password_nonce, password_ciphertext, api_key_hash, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?) \ + ON CONFLICT (user_id) DO UPDATE SET \ + password_nonce = excluded.password_nonce, \ + password_ciphertext = excluded.password_ciphertext, \ + api_key_hash = excluded.api_key_hash, updated_at = excluded.updated_at", + ) + .bind(user_id.to_string()) + .bind(secret.nonce.as_slice()) + .bind(&secret.ciphertext) + .bind(api_key_hash) + .bind(now_ms) + .bind(now_ms) + .execute(&mut *tx) + .await?; + insert_audit( + &mut tx, + Some(actor_id), + "subsonic.credential_set", + Some(user_id), + now_ms, + ) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn revoke_subsonic_credential( + &self, + actor_id: Uuid, + user_id: Uuid, + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let result = sqlx::query("DELETE FROM subsonic_credential WHERE user_id = ?") + .bind(user_id.to_string()) + .execute(&mut *tx) + .await?; + let revoked = result.rows_affected() == 1; + // Only when something was removed. The entry was unconditional, and + // the service answers 404 after writing one, so the trail recorded + // revocations that had not happened. That was cosmetic while an + // administrator was the only caller; the self-scoped route makes it + // reachable by any account, in a loop, which turns a wrong line in a + // history into a way to fill one. `rotate_subsonic_credential` guards + // the same way, for the same reason. + if revoked { + insert_audit( + &mut tx, + Some(actor_id), + "subsonic.credential_revoked", + Some(user_id), + now_ms, + ) + .await?; + } + tx.commit().await?; + Ok(revoked) + } + + /// Whether this account holds a Subsonic credential, and since when. + /// + /// The projection is the two timestamps and nothing else. The password + /// columns are not read, so no edit of a caller can forward what this + /// never fetched. + pub async fn subsonic_credential_status( + &self, + user_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, (i64, i64)>( + "SELECT created_at, updated_at FROM subsonic_credential WHERE user_id = ?", + ) + .bind(user_id.to_string()) + .fetch_optional(&self.pool) + .await + .map(|row| { + row.map(|(created_at, updated_at)| SubsonicCredentialStatus { + created_at, + updated_at, + }) + }) + } + + /// Replaces the password and the API key of a credential that already + /// exists, and answers whether one did. + /// + /// **An `UPDATE`, where `set_subsonic_credential` upserts, and that is the + /// whole difference between the two.** This one cannot bring a credential + /// into being: an account holding none cannot use the Subsonic facade at + /// all, so creating one does not narrow a door already open — it opens + /// one, which is the operator's decision and not this path's. Checking + /// first and then upserting would be the same decision taken racily; + /// `WHERE user_id = ?` takes it in the statement, where two callers cannot + /// interleave. + /// + /// The API key moves with the password. Leaving it would rotate half a + /// credential: a leaked `wfsk_` key authenticates on its own, so a + /// rotation that kept it would answer "my key has leaked" with a new + /// password and the same working key. + pub async fn rotate_subsonic_credential( + &self, + actor_id: Uuid, + user_id: Uuid, + secret: &EncryptedSecret, + api_key_hash: &[u8], + now_ms: i64, + ) -> Result { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let result = sqlx::query( + "UPDATE subsonic_credential SET \ + password_nonce = ?, password_ciphertext = ?, api_key_hash = ?, updated_at = ? \ + WHERE user_id = ?", + ) + .bind(secret.nonce.as_slice()) + .bind(&secret.ciphertext) + .bind(api_key_hash) + .bind(now_ms) + .bind(user_id.to_string()) + .execute(&mut *tx) + .await?; + let rotated = result.rows_affected() == 1; + // Only when something changed. An audit trail that records attempts + // the database refused would read as a history of rotations that never + // happened. + if rotated { + insert_audit( + &mut tx, + Some(actor_id), + "subsonic.credential_rotated", + Some(user_id), + now_ms, + ) + .await?; + } + tx.commit().await?; + Ok(rotated) + } +} diff --git a/src/database/sync_operations.rs b/src/database/sync_operations.rs new file mode 100644 index 00000000..93676fc2 --- /dev/null +++ b/src/database/sync_operations.rs @@ -0,0 +1,90 @@ +//! The idempotency ledger of client sync operations: reserving an operation +//! id, and recognising one already applied. + +use sqlx::{Row, SqliteConnection}; +use tokio::sync::OwnedMutexGuard; +use uuid::Uuid; + +use super::Database; + +#[derive(Debug)] +pub(crate) struct SyncOperationReplayRecord { + pub result_entity_id: Option, + pub event_cursor: i64, + pub intent_hash: Option>, + pub entity_type: String, +} + +#[derive(Debug)] +pub(crate) enum SyncOperationReservation { + InvalidOriginDevice, + New, + Incomplete, + Replayed(SyncOperationReplayRecord), +} + +impl Database { + #[allow(clippy::too_many_arguments)] + pub(crate) async fn reserve_sync_operation( + &self, + _writer_guard: &OwnedMutexGuard<()>, + connection: &mut SqliteConnection, + user_id: Uuid, + operation_id: Uuid, + origin_device_id: Option, + intent_hash: &[u8], + created_at: i64, + ) -> Result { + if let Some(device_id) = origin_device_id { + let owned = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM device \ + WHERE id=? AND user_id=? AND revoked_at IS NULL)", + ) + .bind(device_id.to_string()) + .bind(user_id.to_string()) + .fetch_one(&mut *connection) + .await?; + if !owned { + return Ok(SyncOperationReservation::InvalidOriginDevice); + } + } + + let inserted = sqlx::query( + "INSERT INTO sync_operation \ + (user_id, operation_id, origin_device_id, intent_hash, created_at) VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT (user_id, operation_id) DO NOTHING", + ) + .bind(user_id.to_string()) + .bind(operation_id.to_string()) + .bind(origin_device_id.map(|id| id.to_string())) + .bind(intent_hash) + .bind(created_at) + .execute(&mut *connection) + .await? + .rows_affected(); + if inserted == 1 { + return Ok(SyncOperationReservation::New); + } + + let row = sqlx::query( + "SELECT so.result_entity_id, so.event_cursor, so.intent_hash, se.entity_type \ + FROM sync_operation so JOIN sync_event se ON se.cursor=so.event_cursor AND se.user_id=so.user_id \ + WHERE so.user_id=? AND so.operation_id=? AND so.applied_at IS NOT NULL", + ) + .bind(user_id.to_string()) + .bind(operation_id.to_string()) + .fetch_optional(&mut *connection) + .await?; + let Some(row) = row else { + return Ok(SyncOperationReservation::Incomplete); + }; + Ok(SyncOperationReservation::Replayed( + SyncOperationReplayRecord { + result_entity_id: row.try_get("result_entity_id")?, + event_cursor: row.try_get("event_cursor")?, + intent_hash: row.try_get("intent_hash")?, + entity_type: row.try_get("entity_type")?, + }, + )) + } +} diff --git a/src/database/tests.rs b/src/database/tests.rs new file mode 100644 index 00000000..eb551de1 --- /dev/null +++ b/src/database/tests.rs @@ -0,0 +1,575 @@ +use super::*; + +async fn migrated_database() -> (tempfile::TempDir, Database) { + let temp = tempfile::tempdir().expect("temporary directory"); + let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) + .await + .expect("open database"); + database.migrate().await.expect("initial migration"); + (temp, database) +} + +/// Every other migration test starts from an empty database, and an empty +/// database is the one case a table rebuild cannot fail on: dropping the +/// old parent cascades into nothing and restricts nothing. The rebuild in +/// `20260823010000_participants` therefore passed the whole suite and then +/// refused to open the first database that held a single track. +/// +/// So this one fills the catalogue in first, at the schema version just +/// before the rebuild, and migrates from there. +/// +/// It is pinned to that one boundary on purpose — it was written for that +/// defect. The general question, whether a database at a *released* +/// schema still upgrades with its rows and still answers through the +/// services, is the `migrations` integration target (#223); add a seed +/// row there rather than here when a new table has to survive an upgrade. +#[tokio::test] +async fn a_populated_database_survives_a_table_rebuild() { + use sqlx::migrate::Migrate; + + /// The last migration before `artist` and `album` are rebuilt. + const BEFORE_THE_REBUILD: i64 = 20260823000000; + + let temp = tempfile::tempdir().expect("temporary directory"); + let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) + .await + .expect("open database"); + + let mut connection = database.pool.acquire().await.expect("connection"); + connection + .ensure_migrations_table(&MIGRATOR.table_name) + .await + .expect("migrations table"); + for migration in MIGRATOR + .iter() + .filter(|migration| migration.version <= BEFORE_THE_REBUILD) + { + connection + .apply(&MIGRATOR.table_name, migration) + .await + .expect("earlier migration"); + } + + // A track, its album and its artist, plus the credit relating them: + // one row in each table the rebuild touches or cascades from. The + // identifiers are UUIDs because every real row's is: a fixture shaped + // unlike production is one a production-shaped bug can pass through. + const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000001"; + const LIBRARY: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000002"; + const ARTIST: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000003"; + const ALBUM: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000004"; + const TRACK: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000005"; + let digest = "0".repeat(64); + for (statement, binds) in [ + ( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + VALUES (?, 'owner', 'x', 'admin', 0, 0)", + vec![ACCOUNT], + ), + ( + "INSERT INTO library (id, owner_user_id, name, root_path, visibility, \ + created_at, updated_at) \ + VALUES (?, ?, 'Library', '/music', 'private', 0, 0)", + vec![LIBRARY, ACCOUNT], + ), + ( + "INSERT INTO artist (id, library_id, name, canonical_name, created_at, \ + updated_at) \ + VALUES (?, ?, 'Nova Kern', 'nova kern', 0, 0)", + vec![ARTIST, LIBRARY], + ), + ( + "INSERT INTO album (id, library_id, title, canonical_title, identity_key, \ + album_artist_id, album_artist_name, created_at, updated_at) \ + VALUES (?, ?, 'Convergence', 'convergence', 'key', ?, 'Nova Kern', 0, 0)", + vec![ALBUM, LIBRARY, ARTIST], + ), + ( + "INSERT INTO track (id, library_id, album_id, relative_path, file_size, \ + file_modified_at, quick_hash, full_hash, title, \ + duration_ms, created_at, updated_at) \ + VALUES (?, ?, ?, 'a.flac', 1, 0, ?, ?, 'Zenith', 1000, 0, 0)", + vec![TRACK, LIBRARY, ALBUM, digest.as_str(), digest.as_str()], + ), + ( + "INSERT INTO track_artist (track_id, artist_id, library_id, position) \ + VALUES (?, ?, ?, 0)", + vec![TRACK, ARTIST, LIBRARY], + ), + ] { + let mut query = sqlx::query(statement); + for bind in binds { + query = query.bind(bind.to_owned()); + } + query.execute(&mut *connection).await.expect("seed row"); + } + drop(connection); + + database + .migrate() + .await + .expect("a database with a catalogue migrates"); + + // The rebuild has to carry its rows, not merely survive: an implicit + // `DELETE FROM` before a drop empties children silently, and a + // migration that lost the credits would still report success. + let mut connection = database.pool.acquire().await.expect("connection"); + for (table, query) in [ + ("artist", "SELECT COUNT(*) FROM artist"), + ("album", "SELECT COUNT(*) FROM album"), + ("track", "SELECT COUNT(*) FROM track"), + ( + "track_participant", + "SELECT COUNT(*) FROM track_participant", + ), + ] { + let count: i64 = sqlx::query_scalar(query) + .fetch_one(&mut *connection) + .await + .expect("count"); + assert_eq!(count, 1, "{table} lost its rows to the rebuild"); + } + let role: String = + sqlx::query_scalar("SELECT role FROM track_participant WHERE track_id = '6f1d1b6e-0f1a-4a3d-9c21-000000000005'") + .fetch_one(&mut *connection) + .await + .expect("carried credit"); + assert_eq!(role, "artist", "the carried credit kept its meaning"); + + // And enforcement, suspended for the rebuild, is answered for. + let violations = sqlx::query("PRAGMA foreign_key_check") + .fetch_all(&mut *connection) + .await + .expect("foreign key check"); + assert!( + violations.is_empty(), + "the rebuilt schema left {} broken reference(s)", + violations.len() + ); +} + +fn crlf_checksum(version: i64) -> Vec { + let migration = MIGRATOR + .iter() + .find(|migration| migration.version == version) + .expect("known migration"); + Sha384::digest(migration.sql.as_str().replace('\n', "\r\n").as_bytes()).to_vec() +} + +#[tokio::test] +async fn migrate_accepts_checksums_from_legacy_windows_line_endings() { + let (_temp, database) = migrated_database().await; + let versions = [20260806000000_i64, 20260806120000_i64]; + + let writer = database.writer_guard().await; + for version in versions { + let legacy_checksum = crlf_checksum(version); + let current_checksum = MIGRATOR + .iter() + .find(|migration| migration.version == version) + .expect("known migration") + .checksum + .as_ref(); + assert_ne!(legacy_checksum, current_checksum); + sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") + .bind(legacy_checksum) + .bind(version) + .execute(&database.pool) + .await + .expect("install legacy checksum"); + } + drop(writer); + + database.migrate().await.expect("normalize then migrate"); + + for version in versions { + let expected = MIGRATOR + .iter() + .find(|migration| migration.version == version) + .expect("known migration") + .checksum + .as_ref(); + let stored: Vec = + sqlx::query_scalar("SELECT checksum FROM _sqlx_migrations WHERE version = ?") + .bind(version) + .fetch_one(&database.pool) + .await + .expect("read normalized checksum"); + assert_eq!(stored, expected); + } +} + +#[tokio::test] +async fn migrate_still_rejects_an_unrelated_checksum_mismatch() { + let (_temp, database) = migrated_database().await; + let version = 20260806000000_i64; + let writer = database.writer_guard().await; + sqlx::query("UPDATE _sqlx_migrations SET checksum = ? WHERE version = ?") + .bind(vec![0_u8; 48]) + .bind(version) + .execute(&database.pool) + .await + .expect("install invalid checksum"); + drop(writer); + + let error = database + .migrate() + .await + .expect_err("unrelated checksum must remain invalid"); + assert!( + error + .to_string() + .contains("previously applied but has been modified"), + "unexpected error: {error:#}" + ); +} + +/// A retry queued before `retried_at` existed still counts as the joker +/// spent, once the column arrives. +/// +/// Retried entries exist on every database linked since #191, and until +/// `20260914000000` the only trace of one was a second row naming it +/// through `retry_of`. Adding the column empty would leave every one of +/// those originals unanswered again — the migration would reintroduce, on +/// the only servers that have anything to reintroduce it to, exactly the +/// defect it exists to correct. Reading it on a database populated at the +/// schema version before the column is what says otherwise; a rereading of +/// the SQL is not. +#[tokio::test] +async fn a_retry_queued_before_the_column_counts_as_the_joker_spent() { + use sqlx::migrate::Migrate; + + /// The last migration before `scrobble_outbox.retried_at`. + const BEFORE_THE_COLUMN: i64 = 20260913000000; + + /// When the ambiguous entry was last written, and when its retry was + /// queued. Distinct on purpose: the backfill takes the instant the + /// joker was spent, which is the retry's, and a test where the two + /// agreed could not tell one from the other. + const SETTLED_AT: i64 = 1_700_000_111_000; + const RETRIED_AT: i64 = 1_700_000_555_000; + + let temp = tempfile::tempdir().expect("temporary directory"); + let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) + .await + .expect("open database"); + + let mut connection = database.pool.acquire().await.expect("connection"); + connection + .ensure_migrations_table(&MIGRATOR.table_name) + .await + .expect("migrations table"); + for migration in MIGRATOR + .iter() + .filter(|migration| migration.version <= BEFORE_THE_COLUMN) + { + connection + .apply(&MIGRATOR.table_name, migration) + .await + .expect("earlier migration"); + } + + // An account and one authorisation, inserted as rows rather than + // through the services: this test is about what the migration does to + // a shape, and `hash_password` on a literal is what the repository's + // code scanning refuses. + const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000011"; + const LINK: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000012"; + for (statement, binds) in [ + ( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + VALUES (?, 'listener', 'this-account-never-authenticates', 'user', 0, 0)", + vec![ACCOUNT], + ), + ( + "INSERT INTO scrobble_link (id, user_id, provider, status, credential_nonce, \ + credential_ciphertext, created_at, updated_at) \ + VALUES (?, ?, 'listenbrainz', 'active', x'000000000000000000000000', \ + x'00', 0, 0)", + vec![LINK, ACCOUNT], + ), + ] { + let mut query = sqlx::query(statement); + for bind in binds { + query = query.bind(bind.to_owned()); + } + query.execute(&mut *connection).await.expect("seed row"); + } + + // Three rows in the shape #191 leaves behind: an ambiguous entry whose + // joker was spent, the retry that spent it, and a second ambiguous + // entry nobody has answered. + const ANSWERED: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000013"; + const RETRY: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000014"; + const UNANSWERED: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000015"; + for (public_id, state, created_at, updated_at) in [ + (ANSWERED, "uncertain", SETTLED_AT, SETTLED_AT), + (RETRY, "sent", RETRIED_AT, RETRIED_AT), + (UNANSWERED, "uncertain", SETTLED_AT, SETTLED_AT), + ] { + sqlx::query( + "INSERT INTO scrobble_outbox (public_id, link_id, played_at, title, \ + artists_json, state, attempts, next_attempt_at, created_at, updated_at) \ + VALUES (?, ?, 0, 'Zenith', '[\"Nova Kern\"]', ?, 1, 0, ?, ?)", + ) + .bind(public_id) + .bind(LINK) + .bind(state) + .bind(created_at) + .bind(updated_at) + .execute(&mut *connection) + .await + .expect("seed outbox row"); + } + // The link between them is written by lookup rather than by assuming + // the first row took rowid 1: `retry_of` speaks in rowids, and a + // fixture that guessed one would pass for a reason unrelated to the + // migration. + sqlx::query( + "UPDATE scrobble_outbox \ + SET retry_of = (SELECT id FROM scrobble_outbox WHERE public_id = ?) \ + WHERE public_id = ?", + ) + .bind(ANSWERED) + .bind(RETRY) + .execute(&mut *connection) + .await + .expect("point the retry at what it retried"); + drop(connection); + + database + .migrate() + .await + .expect("a database with a queue migrates"); + + let mut connection = database.pool.acquire().await.expect("connection"); + let answered: Option = + sqlx::query_scalar("SELECT retried_at FROM scrobble_outbox WHERE public_id = ?") + .bind(ANSWERED) + .fetch_one(&mut *connection) + .await + .expect("the answered entry"); + assert_eq!( + answered, + Some(RETRIED_AT), + "an entry retried before the column must carry the instant its retry was queued" + ); + + let unanswered: Option = + sqlx::query_scalar("SELECT retried_at FROM scrobble_outbox WHERE public_id = ?") + .bind(UNANSWERED) + .fetch_one(&mut *connection) + .await + .expect("the unanswered entry"); + assert_eq!( + unanswered, None, + "an entry nobody answered must still be asking" + ); + + let retry: Option = + sqlx::query_scalar("SELECT retried_at FROM scrobble_outbox WHERE public_id = ?") + .bind(RETRY) + .fetch_one(&mut *connection) + .await + .expect("the retry"); + assert_eq!( + retry, None, + "the retry spent nobody's joker; it is the one that was spent" + ); +} + +/// Rows that finished before the column knew when they finished. +/// +/// The purge counts from `terminal_at`, so a row written before +/// `20260914010000` would carry none and never be eligible: on a server +/// that has been running for months, retention would arrive and apply to +/// nothing it was added for. They take their `updated_at`, which is the +/// write that made them terminal — true because every `UPDATE` +/// `scrobble_outbox` knows requires a non-terminal starting state, so a +/// finished row's last write *is* its transition. +/// +/// `uncertain` and `pending` take none, and that is half the assertion: +/// an entry still asking a person must not become purgeable by a migration. +#[tokio::test] +async fn rows_that_finished_before_the_column_are_dated_by_their_last_write() { + use sqlx::migrate::Migrate; + + /// The last migration before `scrobble_outbox.terminal_at`. + const BEFORE_THE_COLUMN: i64 = 20260914000000; + + let temp = tempfile::tempdir().expect("temporary directory"); + let database = Database::open(&Config::for_data_dir(temp.path().join("data"))) + .await + .expect("open database"); + + let mut connection = database.pool.acquire().await.expect("connection"); + connection + .ensure_migrations_table(&MIGRATOR.table_name) + .await + .expect("migrations table"); + for migration in MIGRATOR + .iter() + .filter(|migration| migration.version <= BEFORE_THE_COLUMN) + { + connection + .apply(&MIGRATOR.table_name, migration) + .await + .expect("earlier migration"); + } + + const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000021"; + const LINK: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000022"; + for (statement, binds) in [ + ( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + VALUES (?, 'archivist', 'this-account-never-authenticates', 'user', 0, 0)", + vec![ACCOUNT], + ), + ( + "INSERT INTO scrobble_link (id, user_id, provider, status, credential_nonce, \ + credential_ciphertext, created_at, updated_at) \ + VALUES (?, ?, 'listenbrainz', 'active', x'000000000000000000000000', \ + x'00', 0, 0)", + vec![LINK, ACCOUNT], + ), + ] { + let mut query = sqlx::query(statement); + for bind in binds { + query = query.bind(bind.to_owned()); + } + query.execute(&mut *connection).await.expect("seed row"); + } + + // Every state the queue knows, each with its own `updated_at`, so an + // assertion cannot pass by reading a neighbour's instant. `created_at` + // is deliberately a different value throughout: a backfill that read + // *it* would date a listen from when it was queued rather than from + // when it stopped moving, which is the distinction the column exists + // for. + const QUEUED_AT: i64 = 1_700_000_000_000; + let expected = [ + ("sent", 1_700_000_001_000_i64), + ("rejected", 1_700_000_002_000), + ("abandoned", 1_700_000_003_000), + ("cancelled", 1_700_000_004_000), + ("discarded", 1_700_000_005_000), + ]; + let untouched = [ + ("uncertain", 1_700_000_006_000_i64), + ("pending", 1_700_000_007_000), + ]; + for (nth, (state, updated_at)) in expected.iter().chain(untouched.iter()).enumerate() { + sqlx::query( + "INSERT INTO scrobble_outbox (public_id, link_id, played_at, title, \ + artists_json, state, attempts, next_attempt_at, created_at, updated_at) \ + VALUES (?, ?, 0, 'Zenith', '[\"Nova Kern\"]', ?, 1, 0, ?, ?)", + ) + .bind(format!("6f1d1b6e-0f1a-4a3d-9c21-0000000003{:02}", nth)) + .bind(LINK) + .bind(*state) + .bind(QUEUED_AT) + .bind(*updated_at) + .execute(&mut *connection) + .await + .expect("seed outbox row"); + } + drop(connection); + + database + .migrate() + .await + .expect("a database with a finished queue migrates"); + + let mut connection = database.pool.acquire().await.expect("connection"); + for (state, updated_at) in expected { + let terminal_at: Option = + sqlx::query_scalar("SELECT terminal_at FROM scrobble_outbox WHERE state = ?") + .bind(state) + .fetch_one(&mut *connection) + .await + .expect("the finished row"); + assert_eq!( + terminal_at, + Some(updated_at), + "a {state} row must be dated by the write that finished it" + ); + } + for (state, _) in untouched { + let terminal_at: Option = + sqlx::query_scalar("SELECT terminal_at FROM scrobble_outbox WHERE state = ?") + .bind(state) + .fetch_one(&mut *connection) + .await + .expect("the unfinished row"); + assert_eq!( + terminal_at, None, + "a {state} row has not finished and must not become purgeable" + ); + } +} + +/// A read takes the cache row without the gate, so a fill can replace and +/// link a newer portrait before the read's repair runs. That repair must +/// not put the hash it read back over the newer one, nor announce it. +#[tokio::test] +async fn a_repair_does_not_restore_a_portrait_the_cache_replaced() { + let (_temp, database) = migrated_database().await; + const ACCOUNT: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000021"; + const LIBRARY: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000022"; + const ARTIST: &str = "6f1d1b6e-0f1a-4a3d-9c21-000000000023"; + let read = "a".repeat(64); + let replaced = "b".repeat(64); + for (statement, binds) in [ + ( + "INSERT INTO account (id, username, password_hash, role, created_at, updated_at) \ + VALUES (?, 'owner', 'this-account-never-authenticates', 'admin', 0, 0)", + vec![ACCOUNT], + ), + ( + "INSERT INTO library (id, owner_user_id, name, root_path, visibility, \ + created_at, updated_at) \ + VALUES (?, ?, 'Library', '/music', 'private', 0, 0)", + vec![LIBRARY, ACCOUNT], + ), + ( + "INSERT INTO artwork (hash, format, source, byte_size, created_at) \ + VALUES (?, 'jpg', 'deezer', 1, 0), (?, 'jpg', 'deezer', 1, 0)", + vec![read.as_str(), replaced.as_str()], + ), + ( + "INSERT INTO artist (id, library_id, name, canonical_name, artwork_hash, \ + created_at, updated_at) \ + VALUES (?, ?, 'Nova Kern', 'nova kern', ?, 0, 0)", + vec![ARTIST, LIBRARY, replaced.as_str()], + ), + ( + "INSERT INTO metadata_artist (provider, name_canonical, outcome, image_hash, \ + fetched_at, expires_at) \ + VALUES ('deezer', 'nova kern', 'hit', ?, 0, 0)", + vec![replaced.as_str()], + ), + ] { + let mut query = sqlx::query(statement); + for bind in binds { + query = query.bind(bind.to_owned()); + } + query.execute(&database.pool).await.expect("seed row"); + } + + database + .link_artist_portrait("nova kern", &read) + .await + .expect("repair"); + + let linked: String = sqlx::query_scalar("SELECT artwork_hash FROM artist WHERE id = ?") + .bind(ARTIST) + .fetch_one(&database.pool) + .await + .expect("artist row"); + assert!(linked == replaced, "the newer portrait was replaced"); + let events: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM library_event") + .fetch_one(&database.pool) + .await + .expect("event count"); + assert_eq!(events, 0); +} From 8fbd3f1ec69cac2909c92560db84cbc2959ee7a2 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 09:52:48 +0100 Subject: [PATCH 3/5] refactor(storage): split the catalog module by domain Signed-off-by: InstaZDLL --- src/catalog/apply.rs | 665 ++++++++ src/catalog/availability.rs | 233 +++ src/catalog/credits.rs | 280 ++++ src/catalog/derivations.rs | 249 +++ src/catalog/identity.rs | 465 ++++++ src/catalog/libraries.rs | 244 +++ src/catalog/mod.rs | 2886 +---------------------------------- src/catalog/overrides.rs | 122 ++ src/catalog/scan_jobs.rs | 437 ++++++ src/catalog/tests.rs | 27 + src/catalog/tracks.rs | 239 +++ 11 files changed, 2990 insertions(+), 2857 deletions(-) create mode 100644 src/catalog/apply.rs create mode 100644 src/catalog/availability.rs create mode 100644 src/catalog/credits.rs create mode 100644 src/catalog/derivations.rs create mode 100644 src/catalog/identity.rs create mode 100644 src/catalog/libraries.rs create mode 100644 src/catalog/overrides.rs create mode 100644 src/catalog/scan_jobs.rs create mode 100644 src/catalog/tests.rs create mode 100644 src/catalog/tracks.rs diff --git a/src/catalog/apply.rs b/src/catalog/apply.rs new file mode 100644 index 00000000..c77d4d4c --- /dev/null +++ b/src/catalog/apply.rs @@ -0,0 +1,665 @@ +//! Writing a scanned file into the catalogue: the track row and everything it +//! hangs off — artwork, artists, genres, album, lyrics, credits and events. + +use sqlx::{Sqlite, Transaction}; +use uuid::Uuid; + +use crate::{authentication::now_ms, database::Database}; + +use super::{ + credit_decisions, + credits::{credit_names, with_album_artist_sorts}, + display_of, + identity::{album_artist_display, track_credits, TrackIdentity}, + parse_uuid, record_library_event, track_override_lists, track_pid, write_track_credits, + LibraryChange, +}; + +#[derive(Debug, Clone)] +pub struct ArtworkInput { + pub hash: String, + pub format: String, + pub source: String, + pub byte_size: i64, +} + +#[derive(Debug, Clone)] +pub struct CatalogTrackInput { + pub relative_path: String, + pub file_size: i64, + pub modified_at: i64, + pub quick_hash: String, + pub full_hash: String, + pub title: String, + pub artist: Option, + /// The multi-valued spellings of the two artist tags, when a file used + /// them. They win over the singular ones and are never cut: a tagger + /// writing `ARTISTS` has already said where the boundaries are. + pub artists: Vec, + pub album_artists: Vec, + /// Every other role the file names, as it spelled them. + pub roles: Vec<(crate::tags::Role, Vec)>, + /// Performer credits, already split into instrument and name. + pub performer_pairs: Vec<(String, String)>, + pub album: Option, + pub album_artist: Option, + pub is_compilation: bool, + pub genre: Option, + pub year: Option, + pub track_number: Option, + pub disc_number: Option, + pub duration_ms: i64, + pub bitrate: Option, + pub sample_rate: Option, + pub channels: Option, + pub bit_depth: Option, + pub codec: Option, + pub musical_key: Option, + pub tag_rating: Option, + /// The MusicBrainz recording identifier: the performance, which is what a + /// song's `musicBrainzId` means. Release and artist are separate entities + /// and are kept separate. + pub musicbrainz_recording_id: Option, + pub musicbrainz_release_id: Option, + pub musicbrainz_artist_id: Option, + /// Decibel gains and linear peaks, as tagged. + pub replay_gain_track_gain: Option, + pub replay_gain_track_peak: Option, + pub replay_gain_album_gain: Option, + pub replay_gain_album_peak: Option, + pub bpm: Option, + pub sort_title: Option, + /// Sort forms of the album title and of the two artist credits, as + /// tagged. Multi-valued credits carry them joined by the same separator. + pub sort_album: Option, + pub sort_album_artist: Option, + pub sort_artist: Option, + pub comment: Option, + /// Multi-valued, split like `artist` and `genre`. + pub isrc: Option, + pub moods: Option, + /// Normalised to `explicit` or `clean`; any other tag value is no value. + pub explicit_status: Option, + /// The two release dates, as the file spelled them. Kept as written and + /// taken apart only at the wire, where OpenSubsonic wants year, month and + /// day as separate numbers: a tag that names only a year must not be + /// reported as the first of January. + pub original_release_date: Option, + pub release_date: Option, + /// Multi-valued, split like `moods`. + pub release_types: Option, + pub record_labels: Option, + /// The title of the disc this track sits on. Track-level because an album + /// has one per disc, which is the whole point of the field. + pub disc_subtitle: Option, + pub artwork: Option, + pub lyrics_hash: String, + pub lyrics: Vec, +} + +#[derive(Debug, Clone)] +pub struct CatalogApply { + pub input: CatalogTrackInput, + pub existing_id: Option, + pub moved: bool, + /// The device that asked for this, when a client did. + /// + /// `None` for a scan, which is nobody's request in particular, and also for + /// a client that named no device — the header is optional, and a client + /// that would rather hear its own changes back simply omits it. + pub origin_device_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyOutcome { + Added, + Updated, + Moved, +} + +impl Database { + pub async fn apply_catalog_track( + &self, + library_id: Uuid, + scan_id: Uuid, + input: &CatalogTrackInput, + existing_id: Option, + moved: bool, + ) -> Result { + let mut outcomes = self + .apply_catalog_tracks( + library_id, + scan_id, + &[CatalogApply { + input: input.clone(), + existing_id, + moved, + origin_device_id: None, + }], + ) + .await?; + Ok(outcomes.pop().expect("single-item catalogue batch")) + } + + pub async fn apply_catalog_tracks( + &self, + library_id: Uuid, + scan_id: Uuid, + applies: &[CatalogApply], + ) -> Result, sqlx::Error> { + if applies.is_empty() { + return Ok(Vec::new()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + // Read once per batch, inside the transaction that writes it (RFC-015): + // a decision taken while a scan runs is either before this batch, + // which applies it, or after it, in which case the decision's own pass + // finds what this batch wrote. + let decisions = credit_decisions(&mut tx, library_id).await?; + let now = now_ms(); + let mut outcomes = Vec::with_capacity(applies.len()); + for apply in applies { + outcomes.push( + Self::apply_catalog_track_in_transaction( + &mut tx, + self.pid(), + library_id, + Some(scan_id), + apply, + &decisions, + now, + ) + .await?, + ); + } + tx.commit().await?; + Ok(outcomes) + } + + #[allow(clippy::too_many_arguments)] + /// Reachable from the domain services because clearing a correction has to + /// land in the same transaction that removes it: re-deriving afterwards + /// would leave a window where the correction is gone and the rows it wrote + /// are not. + /// Writes one track into the catalogue. + /// + /// `scan_id` is optional, and `None` has a precise meaning: **this row did + /// not come from a scan**. It is not "the scan is unknown". A received file + /// is applied through this same path so that what lands is by construction + /// what a scan would have written — but no scan walked for it, and saying + /// otherwise would put a fact in the scan history that never happened. + /// + /// The helpers above take a real id because they are the scanner's, and a + /// scan always has one. This primitive does not: applying to the catalogue + /// is not intrinsically a scanner operation, and the signature is where + /// that stops being implied. + /// + /// [`Database::mark_unseen_unavailable`] is the other half of this + /// meaning: a row that never came from a scan must not be swept as one a + /// scan failed to find. + pub(crate) async fn apply_catalog_track_in_transaction( + tx: &mut Transaction<'_, Sqlite>, + pid: &crate::pid::PidSpecs, + library_id: Uuid, + scan_id: Option, + apply: &CatalogApply, + decisions: &crate::credit_split::Decisions, + now: i64, + ) -> Result { + let input = &apply.input; + let existing_id = apply.existing_id; + // Credits are derived here rather than by the scanner because this is + // where identity is written: the test suite applies catalogue rows + // directly, and reading the tag values one way there and another way + // in production is how the two drift apart. + // + // These are the first step's credits (RFC-015): what the tags say, + // before any decision. They are stored as they are, and everything an + // identity hangs off — the album's display string, the relocation + // hint — reads them rather than what the decisions make of them, so a + // split never moves an album id. + let file_credits = with_album_artist_sorts(track_credits(input)); + let moved = apply.moved; + let track_id = existing_id.unwrap_or_else(Uuid::new_v4); + // A track that already exists may carry corrections, and a scan must + // not undo them. Only its own `artist` credits are displaced: a + // composer or a conductor comes from the file and nobody corrected it. + let overrides = track_override_lists(tx, track_id).await?; + // The library's decisions, read by the caller inside this transaction. + let credits = crate::credit_split::resolve( + &file_credits, + decisions, + overrides.artists.as_deref(), + |name| pid.artist_id(library_id, name.trim()), + ); + // The display strings follow the correction too, or a track would read + // one way in a listing and another in its own row. + // An empty correction says the track credits nobody, which is absence + // rather than an empty string: `Some("")` would render as a blank name + // where `None` renders as none at all. + let artist_display = display_of(overrides.artists.as_deref(), input.artist.as_deref()); + let genre_display = display_of(overrides.genres.as_deref(), input.genre.as_deref()); + let artwork_hash = upsert_artwork(tx, input.artwork.as_ref(), now).await?; + // The credits arrive already split, already paired with their sort + // forms and already carrying the album-artist fallback — that is the + // tag mapper's whole job, and it applies the reference's rules rather + // than this file's old `;`-only cut. What is left here is turning + // names into rows. + let artist_names: Vec = credit_names(&credits, crate::tags::Role::Artist); + let mut artist_ids = Vec::with_capacity(artist_names.len()); + for artist in &artist_names { + artist_ids.push(upsert_artist(tx, pid, library_id, artist, now).await?); + } + let album_artist_names: Vec = + credit_names(&credits, crate::tags::Role::AlbumArtist); + // The first step's names, not the decisions': the display string is + // what the album's identity hashes (RFC-015 decision 4). + let album_artist = album_artist_display(input, &file_credits); + let mut album_artist_ids = Vec::with_capacity(album_artist_names.len()); + for name in &album_artist_names { + album_artist_ids.push(upsert_artist(tx, pid, library_id, name, now).await?); + } + // The album still names one artist in the frozen `artistId` field, and + // that is the first credit. The rest reach clients through the album's + // participants rather than through this column. + let album_artist_id = album_artist_ids.first().copied(); + let album = upsert_album( + tx, + pid, + library_id, + input, + album_artist.as_deref(), + album_artist_id, + artwork_hash.as_deref(), + now, + ) + .await?; + let album_id = album.map(|(id, _)| id); + // RFC-007 left open whether an album needs its own event or whether a + // client can derive one from the tracks. It cannot, and the desktop + // named the case: its incremental mirror skips an album whose + // `song_count` has not moved, so an album that was merely *retagged* — + // a release date learned, a cover found, an album artist corrected — + // is invisible to that walk. The only way to catch it without an event + // is to refetch every album, which is exactly the saving the walk + // exists for. + // + // Emitted only when the row was really written, so a twelve-track album + // produces one event and not twelve. It carries the origin device of + // the track that caused it, for the same reason the track's own event + // does: the client that just wrote must not read its own change back as + // a discovery. + if let Some((id, _)) = album.filter(|(_, changed)| *changed) { + record_library_event( + tx, + LibraryChange { + library_id, + entity_type: "album", + entity_id: id, + action: "upsert", + // Nothing to carry. The track's event carries `full_hash` + // because no other surface exposes it; everything about an + // album is on the album, which the client refetches on + // seeing this. + payload: serde_json::json!({}), + changed_at: now, + origin_device_id: apply.origin_device_id, + }, + ) + .await?; + } + let genres = overrides + .genres + .clone() + .unwrap_or_else(|| split_values(genre_display.as_deref())); + let mut genre_ids = Vec::with_capacity(genres.len()); + for genre in &genres { + genre_ids.push(upsert_genre(tx, library_id, genre, now).await?); + } + + sqlx::query( + "INSERT INTO track (id, library_id, album_id, artwork_hash, relative_path, file_size, \ + file_modified_at, quick_hash, full_hash, title, album_title, artist_display, genre_display, \ + year, track_number, disc_number, duration_ms, bitrate, sample_rate, channels, bit_depth, \ + codec, musical_key, tag_rating, musicbrainz_recording_id, musicbrainz_release_id, \ + musicbrainz_artist_id, replay_gain_track_gain, replay_gain_track_peak, \ + replay_gain_album_gain, replay_gain_album_peak, bpm, sort_title, sort_album, \ + comment, isrc, \ + moods, explicit_status, \ + lyrics_hash, disc_subtitle, pid, is_available, last_seen_scan_id, \ + created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ + ?, ?, 1, ?, ?, ?) \ + ON CONFLICT (id) DO UPDATE SET album_id=excluded.album_id, artwork_hash=excluded.artwork_hash, \ + relative_path=excluded.relative_path, file_size=excluded.file_size, \ + file_modified_at=excluded.file_modified_at, quick_hash=excluded.quick_hash, \ + full_hash=excluded.full_hash, title=excluded.title, album_title=excluded.album_title, \ + artist_display=excluded.artist_display, genre_display=excluded.genre_display, year=excluded.year, \ + track_number=excluded.track_number, disc_number=excluded.disc_number, duration_ms=excluded.duration_ms, \ + bitrate=excluded.bitrate, sample_rate=excluded.sample_rate, channels=excluded.channels, \ + bit_depth=excluded.bit_depth, codec=excluded.codec, musical_key=excluded.musical_key, \ + tag_rating=excluded.tag_rating, \ + musicbrainz_recording_id=excluded.musicbrainz_recording_id, \ + musicbrainz_release_id=excluded.musicbrainz_release_id, \ + musicbrainz_artist_id=excluded.musicbrainz_artist_id, \ + replay_gain_track_gain=excluded.replay_gain_track_gain, \ + replay_gain_track_peak=excluded.replay_gain_track_peak, \ + replay_gain_album_gain=excluded.replay_gain_album_gain, \ + replay_gain_album_peak=excluded.replay_gain_album_peak, \ + bpm=excluded.bpm, sort_title=excluded.sort_title, sort_album=excluded.sort_album, \ + comment=excluded.comment, \ + isrc=excluded.isrc, moods=excluded.moods, \ + explicit_status=excluded.explicit_status, \ + lyrics_hash=excluded.lyrics_hash, disc_subtitle=excluded.disc_subtitle, \ + pid=excluded.pid, \ + is_available=1, unavailable_since=NULL, purged_at=NULL, \ + last_seen_scan_id=excluded.last_seen_scan_id, \ + updated_at=excluded.updated_at", + ) + .bind(track_id.to_string()).bind(library_id.to_string()) + .bind(album_id.map(|id| id.to_string())).bind(artwork_hash.as_deref()) + .bind(&input.relative_path).bind(input.file_size).bind(input.modified_at) + .bind(&input.quick_hash).bind(&input.full_hash).bind(&input.title) + .bind(input.album.as_deref()).bind(artist_display.as_deref()).bind(genre_display.as_deref()) + .bind(input.year).bind(input.track_number).bind(input.disc_number).bind(input.duration_ms) + .bind(input.bitrate).bind(input.sample_rate).bind(input.channels).bind(input.bit_depth) + .bind(input.codec.as_deref()).bind(input.musical_key.as_deref()).bind(input.tag_rating) + .bind(input.musicbrainz_recording_id.as_deref()) + .bind(input.musicbrainz_release_id.as_deref()) + .bind(input.musicbrainz_artist_id.as_deref()) + .bind(input.replay_gain_track_gain).bind(input.replay_gain_track_peak) + .bind(input.replay_gain_album_gain).bind(input.replay_gain_album_peak) + .bind(input.bpm).bind(input.sort_title.as_deref()).bind(input.sort_album.as_deref()) + .bind(input.comment.as_deref()) + .bind(input.isrc.as_deref()) + .bind(input.moods.as_deref()).bind(input.explicit_status.as_deref()) + .bind(&input.lyrics_hash) + .bind(input.disc_subtitle.as_deref()) + .bind(track_pid(pid, library_id, input).to_string()) + .bind(scan_id.map(|id| id.to_string())).bind(now).bind(now) + .execute(&mut **tx).await?; + + sqlx::query("DELETE FROM track_lyrics WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + for (position, lyrics) in input.lyrics.iter().enumerate() { + sqlx::query( + "INSERT INTO track_lyrics \ + (track_id, library_id, position, source, lang, synced, content) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(track_id.to_string()) + .bind(library_id.to_string()) + .bind(position as i64) + .bind(lyrics.source) + .bind(&lyrics.lang) + .bind(i64::from(lyrics.synced)) + .bind(&lyrics.content) + .execute(&mut **tx) + .await?; + } + + write_track_credits(tx, library_id, track_id, &file_credits).await?; + // Every credit the file names, not just its artists. The delete comes + // first for the reason the reference gives: an artist who was both an + // album artist and a composer, and is now only a composer, has to lose + // the row that said otherwise. + sqlx::query("DELETE FROM track_participant WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + for credit in &credits { + let artist_id = match credit.role { + crate::tags::Role::Artist => artist_ids + .get(credit.position) + .copied() + .expect("every artist credit was upserted above"), + crate::tags::Role::AlbumArtist => album_artist_ids + .get(credit.position) + .copied() + .expect("every album-artist credit was upserted above"), + _ => upsert_artist(tx, pid, library_id, &credit.name, now).await?, + }; + let sort_name = credit.sort_name.clone(); + // A file can credit the same artist twice under one role — two + // spellings folding onto one row — and the second insert would + // then collide on the key. The first credit wins, as it does in + // tag order. + sqlx::query( + "INSERT INTO track_participant \ + (track_id, artist_id, library_id, role, sub_role, position, sort_name) \ + VALUES (?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT (track_id, artist_id, role, sub_role) DO NOTHING", + ) + .bind(track_id.to_string()) + .bind(artist_id.to_string()) + .bind(library_id.to_string()) + .bind(credit.role.as_str()) + .bind(&credit.sub_role) + .bind(credit.position as i64) + .bind(sort_name) + .execute(&mut **tx) + .await?; + } + sqlx::query("DELETE FROM track_genre WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + for genre_id in &genre_ids { + sqlx::query( + "INSERT INTO track_genre (track_id, genre_id, library_id) VALUES (?, ?, ?)", + ) + .bind(track_id.to_string()) + .bind(genre_id.to_string()) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + } + sqlx::query("DELETE FROM track_fts WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + // Every participant's name, not just the track artist's. The artist + // index lists only artists an album is credited to, and the argument + // for that is that a composer stays reachable by search — which is + // only true if the search can see the composer's name. + let searchable = { + let mut names: Vec<&str> = credits.iter().map(|credit| credit.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + // The effective display string, not the file's. Pushing + // `input.artist` here would leave a corrected track findable under + // the very name it was corrected away from — the same failure the + // title had, one column over. + if let Some(display) = artist_display.as_deref() { + names.push(display); + } + names.join(" ") + }; + sqlx::query("INSERT INTO track_fts (track_id, library_id, title, album, artists, genres) VALUES (?, ?, ?, ?, ?, ?)") + .bind(track_id.to_string()).bind(library_id.to_string()) + .bind(overrides.title.as_deref().unwrap_or(input.title.as_str())) + .bind(input.album.as_deref()).bind(&searchable).bind(genre_display.as_deref()) + .execute(&mut **tx).await?; + // The hash travels with the event because nothing else carries it. A + // client that confirmed a link against this track has no other way to + // learn the file was retagged outside the API — the track keeps its id + // while its bytes move, and the scan is the only witness. + record_library_event( + tx, + LibraryChange { + library_id, + entity_type: "track", + entity_id: track_id, + action: "upsert", + payload: serde_json::json!({ "full_hash": input.full_hash }), + changed_at: now, + origin_device_id: apply.origin_device_id, + }, + ) + .await?; + Ok(if existing_id.is_none() { + ApplyOutcome::Added + } else if moved { + ApplyOutcome::Moved + } else { + ApplyOutcome::Updated + }) + } +} + +async fn upsert_artwork( + tx: &mut Transaction<'_, Sqlite>, + artwork: Option<&ArtworkInput>, + now: i64, +) -> Result, sqlx::Error> { + let Some(artwork) = artwork else { + return Ok(None); + }; + sqlx::query("INSERT INTO artwork (hash, format, source, byte_size, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (hash) DO NOTHING") + .bind(&artwork.hash).bind(&artwork.format).bind(&artwork.source).bind(artwork.byte_size).bind(now) + .execute(&mut **tx).await?; + Ok(Some(artwork.hash.clone())) +} + +/// Upserts one artist row, whose id is the identity of its name. +/// +/// The name reaches the row twice: as written, which is what a client renders, +/// and folded through `canonical_name`, which is what search matches on. The +/// id is neither — it is the identity hash, so two spellings that differ by a +/// real character are two artists, as they are in the reference. +pub(super) async fn upsert_artist( + tx: &mut Transaction<'_, Sqlite>, + pid: &crate::pid::PidSpecs, + library: Uuid, + name: &str, + now: i64, +) -> Result { + let name = name.trim(); + let id = pid.artist_id(library, name); + let canonical = waveflow_core::scanner::canonical_name(name); + sqlx::query( + "INSERT INTO artist (id, library_id, name, canonical_name, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?) \ + ON CONFLICT (id) DO UPDATE SET name=excluded.name, \ + canonical_name=excluded.canonical_name, updated_at=excluded.updated_at", + ) + .bind(id.to_string()) + .bind(library.to_string()) + .bind(name) + .bind(canonical) + .bind(now) + .bind(now) + .execute(&mut **tx) + .await?; + Ok(id) +} + +pub(super) async fn upsert_genre( + tx: &mut Transaction<'_, Sqlite>, + library: Uuid, + name: &str, + now: i64, +) -> Result { + let id = Uuid::new_v4(); + let canonical = waveflow_core::scanner::canonical_name(name); + let value: String = sqlx::query_scalar("INSERT INTO genre (id, library_id, name, canonical_name, created_at) VALUES (?, ?, ?, ?, ?) \ + ON CONFLICT (library_id, canonical_name) DO UPDATE SET name=excluded.name RETURNING id") + .bind(id.to_string()).bind(library.to_string()).bind(name.trim()).bind(canonical).bind(now) + .fetch_one(&mut **tx).await?; + parse_uuid(value) +} + +#[allow(clippy::too_many_arguments)] +/// Upserts one album row, whose id is the identity of the tags that name it. +/// +/// The identity key this replaces embedded the row id of the album's first +/// credited artist, which is what made "Live" by `A; B` and "Live" by `A; C` +/// answer the same key — and what needed a special case to tell them apart. +/// The engine hashes the credit as written, so the two are simply different +/// and nothing has to notice. +async fn upsert_album( + tx: &mut Transaction<'_, Sqlite>, + pid: &crate::pid::PidSpecs, + library: Uuid, + input: &CatalogTrackInput, + album_artist: Option<&str>, + album_artist_id: Option, + artwork: Option<&str>, + now: i64, +) -> Result, sqlx::Error> { + let Some(title) = input.album.as_deref().filter(|s| !s.trim().is_empty()) else { + return Ok(None); + }; + let canonical = waveflow_core::scanner::canonical_name(title); + let id = pid.album_id( + library, + &TrackIdentity::new(input, album_artist.unwrap_or_default()), + ); + let written = sqlx::query( + "INSERT INTO album (id, library_id, title, canonical_title, album_artist_id, \ + album_artist_name, is_compilation, year, artwork_hash, \ + original_release_date, release_date, release_types, record_labels, \ + created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + ON CONFLICT (id) DO UPDATE SET title=excluded.title, \ + canonical_title=excluded.canonical_title, \ + album_artist_id=excluded.album_artist_id, \ + album_artist_name=excluded.album_artist_name, \ + is_compilation=excluded.is_compilation, \ + year=COALESCE(excluded.year, album.year), \ + artwork_hash=COALESCE(excluded.artwork_hash, album.artwork_hash), \ + original_release_date=COALESCE(excluded.original_release_date, album.original_release_date), \ + release_date=COALESCE(excluded.release_date, album.release_date), \ + release_types=COALESCE(excluded.release_types, album.release_types), \ + record_labels=COALESCE(excluded.record_labels, album.record_labels), \ + updated_at=excluded.updated_at \ + WHERE album.title IS NOT excluded.title \ + OR album.canonical_title IS NOT excluded.canonical_title \ + OR album.album_artist_id IS NOT excluded.album_artist_id \ + OR album.album_artist_name IS NOT excluded.album_artist_name \ + OR album.is_compilation IS NOT excluded.is_compilation \ + OR album.year IS NOT COALESCE(excluded.year, album.year) \ + OR album.artwork_hash IS NOT COALESCE(excluded.artwork_hash, album.artwork_hash) \ + OR album.original_release_date \ + IS NOT COALESCE(excluded.original_release_date, album.original_release_date) \ + OR album.release_date IS NOT COALESCE(excluded.release_date, album.release_date) \ + OR album.release_types IS NOT COALESCE(excluded.release_types, album.release_types) \ + OR album.record_labels IS NOT COALESCE(excluded.record_labels, album.record_labels) \ + RETURNING id", + ) + .bind(id.to_string()) + .bind(library.to_string()) + .bind(title.trim()) + .bind(canonical) + .bind(album_artist_id.map(|id| id.to_string())) + .bind(album_artist) + .bind(i64::from(input.is_compilation)) + .bind(input.year) + .bind(artwork) + .bind(input.original_release_date.as_deref()) + .bind(input.release_date.as_deref()) + .bind(input.release_types.as_deref()) + .bind(input.record_labels.as_deref()) + .bind(now) + .bind(now) + .fetch_optional(&mut **tx) + .await?; + // A row came back only if one was written: inserted, or updated because the + // `WHERE` above found a difference. Twelve tracks of one album run this + // twelve times and the first is the only one that writes, which is what + // makes the event below one per album rather than one per track. + Ok(Some((id, written.is_some()))) +} + +fn split_values(raw: Option<&str>) -> Vec { + raw.into_iter() + .flat_map(|value| value.split(';')) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect() +} diff --git a/src/catalog/availability.rs b/src/catalog/availability.rs new file mode 100644 index 00000000..2466a6d1 --- /dev/null +++ b/src/catalog/availability.rs @@ -0,0 +1,233 @@ +//! Which tracks a scan found: the facts an incremental scan compares before +//! reading a file, sidecar signatures, the seen stamps, and the sweep that +//! marks the unseen unavailable. + +use sqlx::Row; +use uuid::Uuid; + +use crate::{authentication::now_ms, database::Database}; + +use super::{parse_uuid, record_library_event, LibraryChange}; + +/// What an incremental scan compares before reading a file (RFC-018 +/// decision 1): enough to skip it without opening it. +#[derive(Debug, Clone)] +pub struct SkipFacts { + pub id: Uuid, + pub file_size: i64, + pub modified_at: i64, + pub available: bool, + /// The relocation hint is present; a row from before the column still + /// needs its one extraction. + pub has_pid: bool, + /// `None` until a scan has computed it, which never matches. + pub sidecar_signature: Option, +} + +impl Database { + pub async fn mark_track_seen(&self, track_id: Uuid, scan_id: Uuid) -> Result<(), sqlx::Error> { + self.mark_tracks_seen(&[track_id], scan_id).await + } + + /// Every track of the library by its path, with what the pre-check compares. + /// Read once per scan: a row per track, small, and one query instead of one + /// per file. + pub async fn track_skip_facts( + &self, + library_id: Uuid, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT id, relative_path, file_size, file_modified_at, is_available, pid IS NOT NULL AS has_pid, sidecar_signature FROM track WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .fetch_all(self.pool()) + .await?; + let mut facts = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let id: String = row.try_get("id")?; + facts.insert( + row.try_get("relative_path")?, + SkipFacts { + id: Uuid::parse_str(&id).map_err(|error| sqlx::Error::Decode(error.into()))?, + file_size: row.try_get("file_size")?, + modified_at: row.try_get("file_modified_at")?, + available: row.try_get::("is_available")? != 0, + has_pid: row.try_get::("has_pid")? != 0, + sidecar_signature: row.try_get("sidecar_signature")?, + }, + ); + } + Ok(facts) + } + + /// Records the sidecar signature of files a scan read, by path, so the next + /// scan can skip them (RFC-018 decision 1). + pub async fn set_sidecar_signatures( + &self, + library_id: Uuid, + signatures: &[(String, String)], + ) -> Result<(), sqlx::Error> { + if signatures.is_empty() { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + for (relative_path, signature) in signatures { + sqlx::query( + "UPDATE track SET sidecar_signature = ? WHERE library_id = ? AND relative_path = ?", + ) + .bind(signature) + .bind(library_id.to_string()) + .bind(relative_path) + .execute(&mut *tx) + .await?; + } + tx.commit().await + } + + /// Stamps as seen by this scan the tracks under folders the scan could not + /// read, without touching their availability, so the pass that marks the + /// unseen missing leaves them as they are (RFC-018 decision 3). + pub async fn mark_tracks_under_seen( + &self, + library_id: Uuid, + folders: &[String], + scan_id: Uuid, + ) -> Result<(), sqlx::Error> { + if folders.is_empty() { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + for folder in folders { + // A prefix compared as text rather than with LIKE: a folder name + // holding `%` or `_` must not widen the match. + sqlx::query( + "UPDATE track SET last_seen_scan_id = ? WHERE library_id = ? AND (relative_path = ? OR substr(relative_path, 1, length(?) + 1) = ? || '/')", + ) + .bind(scan_id.to_string()) + .bind(library_id.to_string()) + .bind(folder) + .bind(folder) + .bind(folder) + .execute(&mut *tx) + .await?; + } + tx.commit().await + } + + pub async fn mark_tracks_seen( + &self, + track_ids: &[Uuid], + scan_id: Uuid, + ) -> Result<(), sqlx::Error> { + if track_ids.is_empty() { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let now = now_ms(); + for track_id in track_ids { + sqlx::query( + // A file found again takes back a purge still within its + // grace window: the track is there, so nothing is to go. + "UPDATE track SET is_available = 1, purged_at = NULL, last_seen_scan_id = ?, \ + updated_at = ? WHERE id = ?", + ) + .bind(scan_id.to_string()) + .bind(now) + .bind(track_id.to_string()) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + pub async fn mark_unseen_unavailable( + &self, + library_id: Uuid, + scan_id: Uuid, + ) -> Result { + self.mark_unseen_unavailable_within(library_id, scan_id, None) + .await + } + + /// [`Self::mark_unseen_unavailable`], for a scan that walked only + /// `scope`: a track outside the folders it walked was not looked for, so + /// it cannot have been missed (RFC-018 decision 3, which a scoped scan + /// keeps). + pub async fn mark_unseen_unavailable_within( + &self, + library_id: Uuid, + scan_id: Uuid, + scope: Option<&[String]>, + ) -> Result { + let scope = scope + .map(|folders| serde_json::to_string(folders).expect("a list of strings serializes")); + let _writer = self.writer_guard().await; + let now = now_ms(); + let mut tx = self.begin_write(&_writer).await?; + // `RETURNING` rather than a count: the feed needs to name what + // disappeared, and a number cannot. + // Two ways a track can be one this scan did not find, and they are not + // the same fact. + // + // A track another scan stamped is the ordinary case: the walk covered + // the library and this one was not in it, so its file is gone. + // + // A track no scan ever stamped is the other, and it now has a meaning — + // `apply_catalog_track_in_transaction` writes NULL for a row that did + // not come from a scan, which is what a received file is. Sweeping it + // for not having been walked over would mark a file that is on disk as + // gone, and announce a deletion to every client seconds after + // announcing its arrival: a scan running when the file lands started + // its walk before the file existed. + // + // So an unstamped track is only swept when it predates this scan, which + // is the case where the walk genuinely should have found it. The first + // scan to walk past a received file stamps it, and it rejoins the + // ordinary case for good. + let gone: Vec = sqlx::query_scalar( + "UPDATE track SET is_available = 0, unavailable_since = ?, updated_at = ? \ + WHERE library_id = ? AND is_available = 1 \ + AND ((last_seen_scan_id IS NOT NULL AND last_seen_scan_id <> ?) \ + OR (last_seen_scan_id IS NULL \ + AND created_at < COALESCE( \ + (SELECT started_at FROM scan_job WHERE id = ?), ?))) \ + AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(?) folder \ + WHERE folder.value = '' OR track.relative_path = folder.value \ + OR substr(track.relative_path, 1, length(folder.value) + 1) \ + = folder.value || '/')) \ + RETURNING id", + ) + .bind(now) + .bind(now) + .bind(library_id.to_string()) + .bind(scan_id.to_string()) + .bind(scan_id.to_string()) + .bind(now) + .bind(scope.as_deref()) + .bind(scope.as_deref()) + .fetch_all(&mut *tx) + .await?; + for id in &gone { + record_library_event( + &mut tx, + LibraryChange { + library_id, + entity_type: "track", + entity_id: parse_uuid(id.clone())?, + action: "delete", + payload: serde_json::json!({}), + changed_at: now, + // A scan noticing a file is gone is nobody's request. + origin_device_id: None, + }, + ) + .await?; + } + tx.commit().await?; + Ok(gone.len() as i64) + } +} diff --git a/src/catalog/credits.rs b/src/catalog/credits.rs new file mode 100644 index 00000000..1e15c1ed --- /dev/null +++ b/src/catalog/credits.rs @@ -0,0 +1,280 @@ +//! Track credits and the library's credit decisions (RFC-015): storing a +//! track's first-step credits, and recomputing its participants from them. + +use sqlx::{Row, Sqlite, Transaction}; +use uuid::Uuid; + +use super::{apply::upsert_artist, parse_uuid, track_override_lists}; + +/// The credits of one role, in tag order. +/// +/// The two lists derived from it are read in lockstep — a name and the sort +/// form paired with it — so they have to come from one ordering. +fn credits_in_role( + credits: &[crate::tags::Credit], + role: crate::tags::Role, +) -> Vec<&crate::tags::Credit> { + let mut named: Vec<&crate::tags::Credit> = credits + .iter() + .filter(|credit| credit.role == role) + .collect(); + named.sort_by_key(|credit| credit.position); + named +} + +/// The sort form of each artist credit *on this track*. A credit the track +/// tag says nothing about falls back to the album artist tag when the two name +/// the same artist — ALBUMARTISTSORT is the more commonly written of the two, +/// and matching on the name rather than on position means this cannot file one +/// artist under another's sort form. +/// +/// Applied to the first step's credits, before they are stored, so +/// `track_credit` holds the sort form a participant gets and a decision splits +/// the same one a scan would. +pub(super) fn with_album_artist_sorts( + mut credits: Vec, +) -> Vec { + let album: Vec<(String, Option)> = + credits_in_role(&credits, crate::tags::Role::AlbumArtist) + .iter() + .map(|credit| (credit.name.clone(), credit.sort_name.clone())) + .collect(); + for credit in credits + .iter_mut() + .filter(|credit| credit.role == crate::tags::Role::Artist && credit.sort_name.is_none()) + { + credit.sort_name = album + .iter() + .find(|(name, _)| *name == credit.name) + .and_then(|(_, sort)| sort.clone()); + } + credits +} + +/// A library's credit decisions (RFC-015), by key. +/// +/// A row whose parts no longer decode is read as no decision rather than +/// failing the scan that reads it: the one writer stores a `Vec`, and +/// a credit left whole is what the library had before anyone decided. +pub(crate) async fn credit_decisions( + tx: &mut Transaction<'_, Sqlite>, + library_id: Uuid, +) -> Result { + let rows = sqlx::query( + "SELECT credit_key, decision, parts FROM artist_credit_decision WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .fetch_all(&mut **tx) + .await?; + let mut decisions = crate::credit_split::Decisions::new(); + for row in rows { + let key: String = row.try_get("credit_key")?; + let decision = match row.try_get::<&str, _>("decision")? { + "keep" => crate::credit_split::CreditDecision::Keep, + _ => { + let parts: Option = row.try_get("parts")?; + match parts.and_then(|parts| serde_json::from_str::>(&parts).ok()) { + Some(parts) => crate::credit_split::CreditDecision::Split(parts), + None => continue, + } + } + }; + decisions.insert(key, decision); + } + Ok(decisions) +} + +/// Stores a track's first-step credits, replacing what it held. +pub(crate) async fn write_track_credits( + tx: &mut Transaction<'_, Sqlite>, + library_id: Uuid, + track_id: Uuid, + credits: &[crate::tags::Credit], +) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM track_credit WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + for credit in credits { + // `OR IGNORE`: a position is unique per role by construction, and the + // one thing that could repeat it — two groups of one role — the tag + // mapper already merges. Failing a whole scan batch on it would cost + // more than the duplicate credit it would protect. + sqlx::query( + "INSERT OR IGNORE INTO track_credit (track_id, library_id, role, sub_role, position, name, sort_name) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(track_id.to_string()) + .bind(library_id.to_string()) + .bind(credit.role.as_str()) + .bind(&credit.sub_role) + .bind(credit.position as i64) + .bind(credit.name.trim()) + .bind(credit.sort_name.as_deref()) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +/// A track's stored first-step credits, in role and position order. +pub(crate) async fn read_track_credits( + tx: &mut Transaction<'_, Sqlite>, + track_id: Uuid, +) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT role, sub_role, position, name, sort_name FROM track_credit WHERE track_id = ? ORDER BY role, position", + ) + .bind(track_id.to_string()) + .fetch_all(&mut **tx) + .await?; + let mut credits = Vec::with_capacity(rows.len()); + for row in rows { + // A role this binary does not know was written by a newer one; it is + // left out rather than guessed, as `track_participant` leaves it. + let Some(role) = crate::tags::Role::parse(row.try_get::<&str, _>("role")?) else { + continue; + }; + credits.push(crate::tags::Credit { + role, + sub_role: row.try_get("sub_role")?, + name: row.try_get("name")?, + sort_name: row.try_get("sort_name")?, + position: row.try_get::("position")?.max(0) as usize, + }); + } + Ok(credits) +} + +/// What recomputing one track's participants did. +#[derive(Debug, Clone)] +pub(crate) struct Recomputed { + pub changed: bool, + pub album_id: Option, + pub full_hash: String, +} + +/// Recomputes one track's participants from its stored credits and the +/// library's decisions (RFC-015 decision 5), with no file read. +/// +/// The same function a scan calls, over the same inputs, so a decision taken +/// now and a rescan later end in identical rows. Nothing is written when the +/// result is what the track already holds: a pass over a track the decision +/// does not change touches no row, records no event and leaves no artist +/// behind. +pub(crate) async fn recompute_track_participants( + tx: &mut Transaction<'_, Sqlite>, + pid: &crate::pid::PidSpecs, + library_id: Uuid, + track_id: Uuid, + decisions: &crate::credit_split::Decisions, + now: i64, +) -> Result { + let row = sqlx::query("SELECT album_id, full_hash, artist_display FROM track WHERE id = ?") + .bind(track_id.to_string()) + .fetch_one(&mut **tx) + .await?; + let album_id = row + .try_get::, _>("album_id")? + .map(parse_uuid) + .transpose()?; + let full_hash: String = row.try_get("full_hash")?; + let artist_display: Option = row.try_get("artist_display")?; + + let file_credits = read_track_credits(tx, track_id).await?; + let overrides = track_override_lists(tx, track_id).await?; + let credits = crate::credit_split::resolve( + &file_credits, + decisions, + overrides.artists.as_deref(), + |name| pid.artist_id(library_id, name.trim()), + ); + let mut desired: Vec<(String, String, String, i64, Option)> = credits + .iter() + .map(|credit| { + ( + credit.role.as_str().to_owned(), + pid.artist_id(library_id, credit.name.trim()).to_string(), + credit.sub_role.clone(), + credit.position as i64, + credit.sort_name.clone(), + ) + }) + .collect(); + desired.sort_by(|left, right| (&left.0, left.3).cmp(&(&right.0, right.3))); + let current: Vec<(String, String, String, i64, Option)> = sqlx::query_as( + "SELECT role, artist_id, sub_role, position, sort_name FROM track_participant WHERE track_id = ? ORDER BY role, position", + ) + .bind(track_id.to_string()) + .fetch_all(&mut **tx) + .await?; + if current == desired { + return Ok(Recomputed { + changed: false, + album_id, + full_hash, + }); + } + + sqlx::query("DELETE FROM track_participant WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + let mut first_album_artist = None; + for credit in &credits { + let artist_id = upsert_artist(tx, pid, library_id, &credit.name, now).await?; + if credit.role == crate::tags::Role::AlbumArtist && first_album_artist.is_none() { + first_album_artist = Some(artist_id); + } + sqlx::query( + "INSERT INTO track_participant (track_id, artist_id, library_id, role, sub_role, position, sort_name) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (track_id, artist_id, role, sub_role) DO NOTHING", + ) + .bind(track_id.to_string()) + .bind(artist_id.to_string()) + .bind(library_id.to_string()) + .bind(credit.role.as_str()) + .bind(&credit.sub_role) + .bind(credit.position as i64) + .bind(credit.sort_name.as_deref()) + .execute(&mut **tx) + .await?; + } + // The album hangs off its first credited artist, as the scan's upsert + // writes it; the display string, and with it the album's id, stay what + // the file said. + if let (Some(album), Some(artist)) = (album_id, first_album_artist) { + sqlx::query("UPDATE album SET album_artist_id = ?, updated_at = ? WHERE id = ?") + .bind(artist.to_string()) + .bind(now) + .bind(album.to_string()) + .execute(&mut **tx) + .await?; + } + // The search index holds every credited name, as the scan writes it, so + // a split track is found under each part and no longer under the whole. + let mut names: Vec<&str> = credits.iter().map(|credit| credit.name.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + if let Some(display) = artist_display.as_deref() { + names.push(display); + } + sqlx::query("UPDATE track_fts SET artists = ? WHERE track_id = ?") + .bind(names.join(" ")) + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + Ok(Recomputed { + changed: true, + album_id, + full_hash, + }) +} + +pub(super) fn credit_names( + credits: &[crate::tags::Credit], + role: crate::tags::Role, +) -> Vec { + credits_in_role(credits, role) + .iter() + .map(|credit| credit.name.clone()) + .collect() +} diff --git a/src/catalog/derivations.rs b/src/catalog/derivations.rs new file mode 100644 index 00000000..427dc7b2 --- /dev/null +++ b/src/catalog/derivations.rs @@ -0,0 +1,249 @@ +//! What the catalogue derives from its own contents after a scan: sort names, +//! MusicBrainz identifiers, album credits, artist counts and the artist index. + +use sqlx::{Sqlite, Transaction}; +use uuid::Uuid; + +use crate::database::Database; + +impl Database { + /// Recomputes the derived MusicBrainz identifier of every album and artist + /// in one library from the tracks that currently back them. + /// + /// Tracks of one album routinely disagree: a release id is written per file + /// by whatever tagger touched it, and a library assembled over years holds + /// files tagged against different releases of the same record. The album's + /// identifier is therefore the one most of its available tracks agree on, + /// not the one the last file scanned happened to carry. + /// + /// Ties are broken by the earliest disc and track number, zero-padded into + /// a single sortable key so `MIN` compares the pair rather than each half + /// independently, and finally by the identifier itself. A tie means the + /// tags genuinely split the album down the middle; what matters is that two + /// scans of unchanged files answer the same thing, so nothing flaps. + /// + /// An artist takes the identifier from the tracks it is the *primary* + /// credit of (`track_artist.position = 0`), because `musicbrainz_artist_id` + /// is one value on a track that may credit several artists, and only the + /// first credit is the one the tag is about. + /// + /// Albums and artists whose tracks carry no identifier are set back to + /// `NULL` rather than left alone: this runs after every scan, so a tag + /// removed from the files has to disappear from the catalogue too. + /// Re-derives `album.sort_name` and `artist.sort_name` from the tags the + /// library's available tracks still carry. + /// + /// Sibling of [`Self::consolidate_catalog_derivations`] and run beside it, for + /// the same reason and by the same rule: a value derived from tags has to + /// follow the tags, including when they go away. Writing the sort name + /// during the per-track upsert could not do that — the row is rewritten + /// once per track of every album the artist appears on, so a file carrying + /// no tag had to be prevented from erasing what a sibling supplied, and + /// that preservation outlived the tag itself. + /// + /// The majority wins, ties broken deterministically, so two scans of + /// unchanged files answer the same thing. An album takes the sort title + /// its tracks agree on; an artist takes the sort form its credits agree + /// on, from `track_artist` where the joined tag was already paired with + /// the joined credit. An album artist credited on none of the tracks + /// therefore has no sort form of its own — the catalogue holds no tag + /// that is about it alone. + pub async fn consolidate_sort_names(&self, library_id: Uuid) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + Self::consolidate_sort_names_in(&mut tx, library_id).await?; + tx.commit().await?; + Ok(()) + } + + /// [`Self::consolidate_sort_names`] inside a transaction the caller holds, + /// under the writer gate it already took: a credit decision re-derives in + /// the transaction that records it, so the two land or fail together. + pub(crate) async fn consolidate_sort_names_in( + tx: &mut Transaction<'_, Sqlite>, + library_id: Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE album SET sort_name = ( \ + SELECT t.sort_album FROM track t \ + WHERE t.album_id = album.id AND t.is_available = 1 \ + AND t.sort_album IS NOT NULL \ + GROUP BY t.sort_album \ + ORDER BY COUNT(*) DESC, t.sort_album \ + LIMIT 1) \ + WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE artist SET sort_name = ( \ + SELECT tp.sort_name FROM track_participant tp \ + JOIN track t ON t.id = tp.track_id \ + WHERE tp.artist_id = artist.id AND t.is_available = 1 \ + AND tp.sort_name IS NOT NULL \ + GROUP BY tp.sort_name \ + ORDER BY COUNT(*) DESC, tp.sort_name \ + LIMIT 1) \ + WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + Ok(()) + } + + /// Everything the catalogue derives from its own contents, after a scan. + /// + /// It was named for the MusicBrainz identifiers alone, which stopped being + /// true: it also rebuilds each album's credits from its tracks', recounts + /// what every artist has to show for each capacity, and drops the artist + /// rows nothing credits any more. All of it is a majority or a union over + /// the tracks that are still available, so all of it has to run after + /// `mark_unseen_unavailable` — a file that vanished must stop voting first. + /// + /// A test that builds a catalogue by applying tracks directly runs this + /// too, for the same reason the scanner does: without it the album has no + /// credits and no artist has a count. + pub async fn consolidate_catalog_derivations( + &self, + library_id: Uuid, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + Self::consolidate_catalog_derivations_in(&mut tx, library_id).await?; + tx.commit().await?; + Ok(()) + } + + /// [`Self::consolidate_catalog_derivations`] inside a transaction the caller + /// holds, under the writer gate it already took, for the same reason as + /// [`Self::consolidate_sort_names_in`]. + pub(crate) async fn consolidate_catalog_derivations_in( + tx: &mut Transaction<'_, Sqlite>, + library_id: Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE album SET musicbrainz_id = ( \ + SELECT t.musicbrainz_release_id FROM track t \ + WHERE t.album_id = album.id AND t.is_available = 1 \ + AND t.musicbrainz_release_id IS NOT NULL \ + GROUP BY t.musicbrainz_release_id \ + ORDER BY COUNT(*) DESC, \ + MIN(printf('%06d%06d', COALESCE(t.disc_number, 1), \ + COALESCE(t.track_number, 0))), \ + t.musicbrainz_release_id \ + LIMIT 1) \ + WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE artist SET musicbrainz_id = ( \ + SELECT t.musicbrainz_artist_id FROM track t \ + JOIN track_participant tp ON tp.track_id = t.id AND tp.role = 'artist' AND tp.position = 0 \ + WHERE tp.artist_id = artist.id AND t.is_available = 1 \ + AND t.musicbrainz_artist_id IS NOT NULL \ + GROUP BY t.musicbrainz_artist_id \ + ORDER BY COUNT(*) DESC, t.musicbrainz_artist_id \ + LIMIT 1) \ + WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + // The album's own credits, derived here rather than written per track. + // A track-by-track write would be a last-writer-wins race between + // files of one album that disagree; deriving after availability is + // settled makes the answer the same whatever order the scan ran in. + sqlx::query("DELETE FROM album_participant WHERE library_id = ?") + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO album_participant \ + (album_id, artist_id, library_id, role, sub_role, position) \ + SELECT t.album_id, tp.artist_id, t.library_id, tp.role, tp.sub_role, \ + ROW_NUMBER() OVER ( \ + PARTITION BY t.album_id, tp.role \ + ORDER BY MIN(tp.position), tp.sub_role, tp.artist_id) - 1 \ + FROM track t \ + JOIN track_participant tp ON tp.track_id = t.id \ + WHERE t.library_id = ? AND t.is_available = 1 AND t.album_id IS NOT NULL \ + GROUP BY t.album_id, tp.artist_id, tp.role, tp.sub_role", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + // What each artist has to show for each capacity. `albumCount` used to + // be a count over the album's single artist column, which could only + // ever see the first credit; the roles an artist appears in are now + // simply the keys of this table. + sqlx::query("DELETE FROM artist_role_stats WHERE library_id = ?") + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO artist_role_stats \ + (artist_id, library_id, role, album_count, song_count) \ + SELECT tp.artist_id, t.library_id, tp.role, \ + COUNT(DISTINCT t.album_id), COUNT(DISTINCT t.id) \ + FROM track_participant tp \ + JOIN track t ON t.id = tp.track_id \ + WHERE t.library_id = ? AND t.is_available = 1 \ + GROUP BY tp.artist_id, t.library_id, tp.role", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + // An album no track depends on is no longer in the catalogue. This + // matters most on the scan that follows the identity migration: the + // rows kept by the migration hold their old identifiers, the rescan + // mints derived ones and moves every track across, and without this + // the library would answer with each album twice — once full, once + // empty. It runs before the artist sweep because `album_artist_id` + // restricts on delete, so a ghost album would hold its artist hostage. + sqlx::query( + "DELETE FROM album WHERE library_id = ? \ + AND NOT EXISTS (SELECT 1 FROM track t WHERE t.album_id = album.id)", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + // An artist row outlives the tag that created it: rename a credit and + // the old name stays in the index forever. An artist credited on + // nothing is no longer part of the catalogue, so it goes with the same + // scan that made it unreferenced. Both relations are checked, because + // an album artist need not be credited on any of the album's tracks. + sqlx::query( + "DELETE FROM artist WHERE library_id = ? \ + AND NOT EXISTS (SELECT 1 FROM track_participant tp WHERE tp.artist_id = artist.id) \ + AND NOT EXISTS (SELECT 1 FROM album_participant ap WHERE ap.artist_id = artist.id) \ + AND NOT EXISTS (SELECT 1 FROM album al WHERE al.album_artist_id = artist.id)", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + // The artist search index, rewritten whole rather than kept in step + // row by row. An artist row is touched once per track of every album + // it appears on, and a rename would leave the old name searchable + // forever unless something swept it — the same reasoning that made + // the sort names a pass instead of a write. Last in this + // transaction because it indexes the artists the sweep above left + // standing, under the sort names settled before this pass ran. + sqlx::query("DELETE FROM artist_fts WHERE library_id = ?") + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO artist_fts (artist_id, library_id, name, sort_name) \ + SELECT id, library_id, name, COALESCE(sort_name, '') \ + FROM artist WHERE library_id = ?", + ) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + Ok(()) + } +} diff --git a/src/catalog/identity.rs b/src/catalog/identity.rs new file mode 100644 index 00000000..3a944859 --- /dev/null +++ b/src/catalog/identity.rs @@ -0,0 +1,465 @@ +//! Catalogue identity: noticing a changed identifier spec, carrying artist +//! user data across it, matching a file to an existing track, and the +//! relocation hint a track spec evaluates to. + +use sqlx::{Row, Sqlite, Transaction}; +use uuid::Uuid; + +use crate::database::Database; + +use super::{credits::credit_names, parse_uuid, CatalogTrackInput}; + +#[derive(Debug, Clone)] +pub struct ExistingTrack { + pub id: Uuid, + pub relative_path: String, + pub file_size: i64, + pub modified_at: i64, + pub quick_hash: String, + pub full_hash: String, + pub lyrics_hash: Option, + pub available: bool, + /// `None` on a row written before the relocation hint existed, which is + /// what the backfill in the scanner's skip path watches for. + pub pid: Option, +} + +impl Database { + /// Notices that this instance derives identifiers differently from the run + /// that built the catalogue, and asks for a full rescan when it does. + /// + /// No file timestamp can reveal this: the files have not changed, the rule + /// reading them has. Comparing the persisted spec against the configured + /// one is the only way the difference is visible at all. + /// + /// Returns how many libraries were marked, so a caller can say so. + pub async fn reconcile_catalog_identity( + &self, + specs: &crate::pid::PidSpecs, + ) -> Result { + // `pid.track` is handled apart, and never by re-identifying anything: + // a track's id is its path, then its content hash, then the relocation + // hint — six tables cascade off it, so moving track ids would delete + // playlists and play history rather than orphan them. + // + // What a changed track spec does invalidate is every stored hint. A + // lookup compares a hint computed under the new spec against values + // written under the old one, and those two are not separate namespaces: + // two different specs can evaluate to the same string — `title` reading + // "intro" and `album` reading "intro" — and the hint would then hand a + // new file the identity, and the favourites, of an unrelated track. So + // the stale values go, and the scanner's backfill puts fresh ones back + // on the next ordinary scan. No full rescan is charged for it. + self.clear_track_hints_if_spec_changed(specs).await?; + let active = [ + ("pid.album", specs.album.source()), + ("pid.artist", specs.artist.source()), + ]; + let mut changed = Vec::new(); + for (key, value) in active { + match self.server_property(key).await? { + // A catalogue built before the property existed is not + // evidence of a different rule, only of an older server. The + // first scan writes what it used, and later boots compare. + None => {} + Some(stored) if stored == value => {} + Some(stored) => changed.push((key, stored, value)), + } + } + if changed.is_empty() { + return Ok(0); + } + for (key, stored, active) in &changed { + tracing::warn!( + setting = key, + %stored, + %active, + "catalogue identity setting changed since the last scan" + ); + } + // Before the rescan, while the artist rows still hold the identifiers + // their favourites name. + if changed.iter().any(|(key, _, _)| *key == "pid.artist") { + let moved = self.remap_artist_user_data(specs).await?; + if moved > 0 { + tracing::warn!( + rows = moved, + "favourites and ratings moved onto the artist identifiers the new rule derives" + ); + } + } + let libraries = self.request_full_scan_everywhere().await?; + tracing::warn!( + libraries, + "every library will be rescanned in full so its identifiers follow the new rule" + ); + Ok(libraries) + } + + /// Drops every relocation hint when the track spec no longer matches the + /// one the catalogue's hints were written under. + /// + /// Deliberately not paired with a full rescan: nothing is re-identified, + /// and the scanner refills a missing hint from its skip path, so one + /// ordinary scan restores what this clears. `pid IS NOT NULL` keeps a boot + /// that changes nothing from writing anything, and keeps the repeat boots + /// before that first scan free. + async fn clear_track_hints_if_spec_changed( + &self, + specs: &crate::pid::PidSpecs, + ) -> Result<(), sqlx::Error> { + let active = specs.track.source(); + // A catalogue built before the property existed is not evidence of a + // different rule, only of an older server — the same reading the album + // and artist specs get below. + match self.server_property("pid.track").await? { + None => return Ok(()), + Some(stored) if stored == active => return Ok(()), + Some(stored) => { + tracing::warn!( + setting = "pid.track", + %stored, + %active, + "track spec changed; dropping the relocation hints written under the old one" + ); + } + } + let _writer = self.writer_guard().await; + let cleared = sqlx::query("UPDATE track SET pid = NULL WHERE pid IS NOT NULL") + .execute(self.pool()) + .await? + .rows_affected(); + if cleared > 0 { + tracing::warn!( + cleared, + "relocation hints dropped; the next scan writes them again under the new spec" + ); + } + Ok(()) + } + + /// Carries artist favourites and ratings across a change of artist spec. + /// + /// `user_star` and `user_rating` hold an untyped identifier with no foreign + /// key, so a rescan that re-derives artist ids leaves their rows pointing at + /// identifiers nothing answers for — invisible rather than wrong, since every + /// projection resolves through an `EXISTS`, but lost all the same. The old + /// identifier and the name that produced it are both still on the `artist` + /// row at this point, which is the only moment the two can be paired. + /// + /// Albums are not remapped and cannot be: their spec reads `albumversion` + /// and `releasedate`, which live on the files rather than on the album row, + /// so there is nothing here to derive the new identifier from. + /// + /// **Moved in two phases, through a namespace no identifier can occupy.** + /// One artist's new identifier can be another's old one, and moving them one + /// at a time would then carry the first artist's favourite onto the second's + /// row — a result that depends on the order the rows came back in, which is + /// no result at all. Staging every row first and landing them afterwards + /// makes the outcome the same whatever that order was. Both phases run in + /// one transaction, so a staged value is never visible to anything. + /// + /// `UPDATE OR IGNORE` then `DELETE` at each phase: a coarser spec can fold + /// two artists onto one identifier, and the second row would collide on + /// `(user_id, entity_type, entity_id)`. Losing the duplicate is right — the + /// user already stars what it would have become. + async fn remap_artist_user_data( + &self, + specs: &crate::pid::PidSpecs, + ) -> Result { + let rows = sqlx::query("SELECT id, library_id, name FROM artist") + .fetch_all(self.pool()) + .await?; + let mut moves = Vec::new(); + for row in rows { + let old: String = row.try_get("id")?; + let library_id = parse_uuid(row.try_get("library_id")?)?; + let name: String = row.try_get("name")?; + let new = specs.artist_id(library_id, &name).to_string(); + if new != old { + // A UUID holds no colon, so nothing that reaches these columns + // by any other route can be mistaken for a staged value. + moves.push((old, format!("{REMAP_STAGE}{new}"), new)); + } + } + if moves.is_empty() { + return Ok(0); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let mut moved = 0; + for (from, to) in moves + .iter() + .map(|(old, staged, _)| (old, staged)) + .chain(moves.iter().map(|(_, staged, new)| (staged, new))) + { + let landed = move_artist_user_data(&mut tx, from, to).await?; + // Counted on the second pass only, where a row reaches the + // identifier it will actually be read under. + if from.starts_with(REMAP_STAGE) { + moved += landed; + } + } + tx.commit().await?; + Ok(moved) + } + + pub async fn existing_track_by_path( + &self, + library_id: Uuid, + relative_path: &str, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT id, relative_path, file_size, file_modified_at, quick_hash, full_hash, lyrics_hash, is_available, pid \ + FROM track WHERE library_id = ? AND relative_path = ?", + ) + .bind(library_id.to_string()) + .bind(relative_path) + .fetch_optional(self.pool()) + .await?; + row.map(existing_track_from_row).transpose() + } + + pub async fn relocation_candidates( + &self, + library_id: Uuid, + full_hash: &str, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT id, relative_path, file_size, file_modified_at, quick_hash, full_hash, lyrics_hash, is_available, pid \ + FROM track WHERE library_id = ? AND full_hash = ?", + ) + .bind(library_id.to_string()) + .bind(full_hash) + .fetch_all(self.pool()) + .await?; + rows.into_iter().map(existing_track_from_row).collect() + } + + /// Tracks carrying one relocation hint. + /// + /// Only rows that already answered a scan under the current spec have a + /// `pid` at all, and the column is nullable, so this never widens to + /// "every track whose hint was never computed". + pub async fn relocation_candidates_by_pid( + &self, + library_id: Uuid, + pid: Uuid, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT id, relative_path, file_size, file_modified_at, quick_hash, full_hash, lyrics_hash, is_available, pid \ + FROM track WHERE library_id = ? AND pid = ?", + ) + .bind(library_id.to_string()) + .bind(pid.to_string()) + .fetch_all(self.pool()) + .await?; + rows.into_iter().map(existing_track_from_row).collect() + } + + /// Gives a hint to rows that predate the column. + /// + /// An ordinary scan skips a file it has already seen unchanged, so without + /// this an existing catalogue would carry no hints at all until someone + /// asked for a full scan — and the feature would be inert on exactly the + /// installations that have favourites worth keeping. `pid IS NULL` in the + /// predicate makes it a one-time cost: the second scan finds nothing to do. + pub async fn backfill_track_pids(&self, hints: &[(Uuid, Uuid)]) -> Result<(), sqlx::Error> { + if hints.is_empty() { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + for (track_id, pid) in hints { + sqlx::query("UPDATE track SET pid = ? WHERE id = ? AND pid IS NULL") + .bind(pid.to_string()) + .bind(track_id.to_string()) + .execute(&mut *tx) + .await?; + } + tx.commit().await + } +} + +/// A scanned file, seen through the identity engine. +/// +/// The album artist is the resolved display string rather than the raw tag, +/// because that is what the reference hashes: the fallback to `Various +/// Artists` or to the track's own artists is part of what makes two files the +/// same album. +/// +/// The disc and track numbers are rendered once here rather than looked up as +/// tags, and they matter: without them the default track spec would identify +/// two tracks of one album by title alone, and an album holding two takes of +/// the same song would lose one. +/// +/// A spec naming a tag the scanner does not read resolves empty, and an empty +/// attribute simply lets the next group answer. That is the fall-through the +/// grammar is built around, not a gap. +pub(super) struct TrackIdentity<'a> { + input: &'a CatalogTrackInput, + album_artist: &'a str, + folder: &'a str, + disc_number: String, + track_number: String, +} + +impl<'a> TrackIdentity<'a> { + pub(super) fn new(input: &'a CatalogTrackInput, album_artist: &'a str) -> Self { + Self { + input, + album_artist, + folder: input + .relative_path + .rsplit_once(['/', '\\']) + .map(|(folder, _)| folder) + .unwrap_or(""), + disc_number: input.disc_number.map(|v| v.to_string()).unwrap_or_default(), + track_number: input + .track_number + .map(|v| v.to_string()) + .unwrap_or_default(), + } + } +} + +/// The credits a file's tags imply. +/// +/// Derived here rather than by the scanner because this is where identity is +/// written — and now also where the relocation hint is, which has to hash the +/// same values the row is built from. +pub(super) fn track_credits(input: &CatalogTrackInput) -> Vec { + crate::tags::credits(&crate::tags::RawCredits { + artist: input.artist.iter().cloned().collect(), + artists: input.artists.clone(), + album_artist: input.album_artist.iter().cloned().collect(), + album_artists: input.album_artists.clone(), + sort_artist: input.sort_artist.clone(), + sort_album_artist: input.sort_album_artist.clone(), + is_compilation: input.is_compilation, + roles: input.roles.clone(), + performer_pairs: input.performer_pairs.clone(), + }) +} + +/// The album keeps the joined string the file wrote as its display name, and +/// hangs off the first credited artist. +pub(super) fn album_artist_display( + input: &CatalogTrackInput, + credits: &[crate::tags::Credit], +) -> Option { + let names = credit_names(credits, crate::tags::Role::AlbumArtist); + input + .album_artist + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .or_else(|| (names.len() == 1).then(|| names[0].clone())) + .or_else(|| (!names.is_empty()).then(|| names.join("; "))) +} + +/// The relocation hint for one file: the track spec evaluated over its tags. +/// +/// Never an identity. Track ids stay drawn at random — six tables cascade off +/// them, so moving one would delete play history rather than orphan it. This +/// only tells a scan that the file it is looking at is probably the one whose +/// path disappeared, when neither the path nor the content hash can say so. +pub fn track_pid( + specs: &crate::pid::PidSpecs, + library_id: Uuid, + input: &CatalogTrackInput, +) -> Uuid { + let credits = track_credits(input); + let album_artist = album_artist_display(input, &credits).unwrap_or_default(); + specs.track_id(library_id, &TrackIdentity::new(input, &album_artist)) +} + +impl crate::pid::PidSource for TrackIdentity<'_> { + fn tag(&self, name: &str) -> Option<&str> { + let value = match name { + "musicbrainz_albumid" => self.input.musicbrainz_release_id.as_deref(), + "musicbrainz_trackid" => self.input.musicbrainz_recording_id.as_deref(), + "musicbrainz_artistid" => self.input.musicbrainz_artist_id.as_deref(), + "albumartist" => self.input.album_artist.as_deref(), + "artist" => self.input.artist.as_deref(), + "genre" => self.input.genre.as_deref(), + "discnumber" => Some(self.disc_number.as_str()), + "tracknumber" => Some(self.track_number.as_str()), + "isrc" => self.input.isrc.as_deref(), + _ => None, + }; + value.filter(|value| !value.is_empty()) + } + + fn folder(&self) -> &str { + self.folder + } + + fn album_artist_display(&self) -> &str { + self.album_artist + } + + fn title(&self) -> &str { + &self.input.title + } + + fn album(&self) -> &str { + self.input.album.as_deref().unwrap_or_default() + } +} + +/// The namespace a remap stages through. A UUID holds no colon, so a staged +/// value cannot be mistaken for an identifier and no identifier for it. +const REMAP_STAGE: &str = "pid-remap:"; + +/// Moves one artist identifier onto another, in both tables that hold one. +/// +/// Spelled out per table rather than looped: sqlx takes static SQL only, which +/// is what keeps every query in this crate injection-proof by construction. +async fn move_artist_user_data( + tx: &mut Transaction<'_, Sqlite>, + from: &str, + to: &str, +) -> Result { + let mut moved = sqlx::query( + "UPDATE OR IGNORE user_star SET entity_id = ? \ + WHERE entity_type = 'artist' AND entity_id = ?", + ) + .bind(to) + .bind(from) + .execute(&mut **tx) + .await? + .rows_affected(); + sqlx::query("DELETE FROM user_star WHERE entity_type = 'artist' AND entity_id = ?") + .bind(from) + .execute(&mut **tx) + .await?; + moved += sqlx::query( + "UPDATE OR IGNORE user_rating SET entity_id = ? \ + WHERE entity_type = 'artist' AND entity_id = ?", + ) + .bind(to) + .bind(from) + .execute(&mut **tx) + .await? + .rows_affected(); + sqlx::query("DELETE FROM user_rating WHERE entity_type = 'artist' AND entity_id = ?") + .bind(from) + .execute(&mut **tx) + .await?; + Ok(moved) +} + +fn existing_track_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(ExistingTrack { + id: parse_uuid(row.try_get("id")?)?, + relative_path: row.try_get("relative_path")?, + file_size: row.try_get("file_size")?, + modified_at: row.try_get("file_modified_at")?, + quick_hash: row.try_get("quick_hash")?, + full_hash: row.try_get("full_hash")?, + lyrics_hash: row.try_get("lyrics_hash")?, + available: row.try_get::("is_available")? != 0, + pid: row.try_get("pid")?, + }) +} diff --git a/src/catalog/libraries.rs b/src/catalog/libraries.rs new file mode 100644 index 00000000..be00ad33 --- /dev/null +++ b/src/catalog/libraries.rs @@ -0,0 +1,244 @@ +//! Libraries as the catalogue reads them: which an account reaches, how each +//! is walked and watched, the instance-wide settings, and full-scan requests. + +use std::{path::PathBuf, str::FromStr}; + +use serde::Serialize; +use sqlx::Row; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{authentication::now_ms, database::Database}; + +use super::parse_uuid; + +#[derive(Debug, Clone)] +pub struct LibraryRecord { + pub id: Uuid, + pub name: String, + pub root_path: PathBuf, +} + +/// A library and the one setting that changes how it is walked. +#[derive(Debug, Clone)] +pub struct LibraryScanSettings { + pub library: LibraryRecord, + /// Follow symbolic links that stay inside the root (RFC-021 decision 3). + pub follow_links: bool, + /// Whether the watcher observes this library (RFC-021 decision 6, and the + /// per-library switch it left open). + pub watch: bool, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct LibraryAccess { + pub id: Uuid, + pub name: String, + pub visibility: crate::database::LibraryVisibility, + pub role: crate::database::LibraryRole, + pub last_scan_started_at: Option, + pub last_scan_completed_at: Option, + /// Whether the library takes files at all. The operator's decision, set + /// through the CLI and never through the API, and shown to every member: + /// the role says who may upload, this says whether anyone can. A client + /// that knows it offers an upload only where the server would take one, + /// instead of hashing a whole file to be told `library_closed`. + pub accepts_uploads: bool, + /// Whether the library takes canvases. A door of its own and not the upload + /// one (RFC-009 decision 5): a read-only library that refuses to grow in + /// audio may still take a few hundred kilobytes of loop, so a client that + /// read `accepts_uploads` for both would hide the canvas exactly where the + /// flag was split to allow it. + pub accepts_canvas: bool, + /// Whether the walk follows symbolic links that stay inside the library + /// (RFC-021 decision 3). An administrator's setting, shown to every + /// member because it explains what the library holds. + pub follow_links: bool, +} + +impl Database { + pub async fn libraries_for_user( + &self, + user_id: Uuid, + ) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT l.id, l.name, l.visibility, m.role, l.last_scan_started_at, \ + l.last_scan_completed_at, l.accepts_uploads, l.accepts_canvas, \ + l.follow_links \ + FROM library l JOIN library_member m ON m.library_id=l.id \ + WHERE m.user_id=? ORDER BY l.name COLLATE NOCASE, l.id", + ) + .bind(user_id.to_string()) + .fetch_all(self.pool()) + .await?; + rows.into_iter() + .map(|row| { + Ok(LibraryAccess { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + visibility: crate::database::LibraryVisibility::from_str( + row.try_get::<&str, _>("visibility")?, + ) + .map_err(|error| sqlx::Error::Decode(error.into()))?, + role: crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) + .map_err(|error| sqlx::Error::Decode(error.into()))?, + last_scan_started_at: row.try_get("last_scan_started_at")?, + last_scan_completed_at: row.try_get("last_scan_completed_at")?, + accepts_uploads: row.try_get::("accepts_uploads")? != 0, + accepts_canvas: row.try_get::("accepts_canvas")? != 0, + follow_links: row.try_get::("follow_links")? != 0, + }) + }) + .collect() + } + + pub async fn all_libraries(&self) -> Result, sqlx::Error> { + let rows = sqlx::query("SELECT id, name, root_path FROM library ORDER BY created_at") + .fetch_all(self.pool()) + .await?; + rows.into_iter().map(library_from_row).collect() + } + + /// One library as a scan needs it, read when the scan starts rather than + /// when it was queued: its root may have moved and its links setting + /// changed in between, and it may be gone (RFC-021 decision 5). + pub async fn library_scan_settings( + &self, + library_id: Uuid, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT id, name, root_path, follow_links, watch FROM library WHERE id = ?", + ) + .bind(library_id.to_string()) + .fetch_optional(self.pool()) + .await?; + row.map(|row| { + let follow_links = row.try_get::("follow_links")? != 0; + let watch = row.try_get::("watch")? != 0; + Ok(LibraryScanSettings { + library: library_from_row(row)?, + follow_links, + watch, + }) + }) + .transpose() + } + + /// The libraries the watcher observes: those whose `watch` switch is on, + /// and the links setting each is walked with, which decides whether a + /// watched change may be scanned folder by folder. + pub async fn watched_libraries(&self) -> Result, sqlx::Error> { + let rows = sqlx::query( + "SELECT id, name, root_path, follow_links FROM library WHERE watch = 1 \ + ORDER BY created_at", + ) + .fetch_all(self.pool()) + .await?; + rows.into_iter() + .map(|row| { + let follow_links = row.try_get::("follow_links")? != 0; + Ok((library_from_row(row)?, follow_links)) + }) + .collect() + } + + /// Reads one instance-wide setting, or `None` when it was never written. + /// + /// The store exists so the server can notice that it is configured + /// differently from the run that built the catalogue. Anything a scan + /// derives from configuration belongs here: on a mismatch the catalogue is + /// stale in a way no file timestamp can reveal. + pub async fn server_property(&self, key: &str) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT value FROM server_property WHERE key = ?") + .bind(key) + .fetch_optional(self.pool()) + .await + } + + pub async fn set_server_property(&self, key: &str, value: &str) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + sqlx::query( + "INSERT INTO server_property (key, value, updated_at) VALUES (?, ?, ?) \ + ON CONFLICT (key) DO UPDATE SET value = excluded.value, \ + updated_at = excluded.updated_at", + ) + .bind(key) + .bind(value) + .bind(now_ms()) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Asks every library to rescan every file, skipping nothing. + /// + /// Returns how many libraries were marked. One already carrying the request + /// keeps its original timestamp: the request is a state, not a counter, and + /// re-stamping it would say a later run asked for something the earlier one + /// has not yet delivered. + pub async fn request_full_scan_everywhere(&self) -> Result { + let _writer = self.writer_guard().await; + let result = sqlx::query( + "UPDATE library SET full_scan_requested_at = ?, updated_at = ? \ + WHERE full_scan_requested_at IS NULL", + ) + .bind(now_ms()) + .bind(now_ms()) + .execute(self.pool()) + .await?; + Ok(result.rows_affected()) + } + + pub async fn request_full_scan(&self, library_id: Uuid) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + sqlx::query( + "UPDATE library SET full_scan_requested_at = ?, updated_at = ? \ + WHERE id = ? AND full_scan_requested_at IS NULL", + ) + .bind(now_ms()) + .bind(now_ms()) + .bind(library_id.to_string()) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Whether the next scan of this library has to read every file. + /// + /// Read at the start of every run rather than passed in by the caller: a + /// scan that was interrupted left the request standing, and the run that + /// picks up after it has to honour the request it never saw made. + pub async fn full_scan_requested(&self, library_id: Uuid) -> Result { + let requested: Option> = + sqlx::query_scalar("SELECT full_scan_requested_at FROM library WHERE id = ?") + .bind(library_id.to_string()) + .fetch_optional(self.pool()) + .await?; + Ok(matches!(requested, Some(Some(_)))) + } + + pub async fn library_for_user( + &self, + user_id: Uuid, + library_id: Uuid, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT l.id, l.name, l.root_path FROM library l \ + JOIN library_member m ON m.library_id = l.id \ + WHERE l.id = ? AND m.user_id = ?", + ) + .bind(library_id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(self.pool()) + .await?; + row.map(library_from_row).transpose() + } +} + +fn library_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(LibraryRecord { + id: parse_uuid(row.try_get("id")?)?, + name: row.try_get("name")?, + root_path: PathBuf::from(row.try_get::("root_path")?), + }) +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index f58bef08..0f811b3a 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,2481 +1,36 @@ //! Tenant-scoped catalogue repository used by scanner and HTTP surfaces. -use std::{path::PathBuf, str::FromStr}; +use std::str::FromStr; -use serde::Serialize; -use sqlx::{Row, Sqlite, Transaction}; -use utoipa::ToSchema; +use sqlx::{Sqlite, Transaction}; use uuid::Uuid; -use crate::{authentication::now_ms, database::Database}; - -#[derive(Debug, Clone)] -pub struct LibraryRecord { - pub id: Uuid, - pub name: String, - pub root_path: PathBuf, -} - -/// A library and the one setting that changes how it is walked. -#[derive(Debug, Clone)] -pub struct LibraryScanSettings { - pub library: LibraryRecord, - /// Follow symbolic links that stay inside the root (RFC-021 decision 3). - pub follow_links: bool, - /// Whether the watcher observes this library (RFC-021 decision 6, and the - /// per-library switch it left open). - pub watch: bool, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct LibraryAccess { - pub id: Uuid, - pub name: String, - pub visibility: crate::database::LibraryVisibility, - pub role: crate::database::LibraryRole, - pub last_scan_started_at: Option, - pub last_scan_completed_at: Option, - /// Whether the library takes files at all. The operator's decision, set - /// through the CLI and never through the API, and shown to every member: - /// the role says who may upload, this says whether anyone can. A client - /// that knows it offers an upload only where the server would take one, - /// instead of hashing a whole file to be told `library_closed`. - pub accepts_uploads: bool, - /// Whether the library takes canvases. A door of its own and not the upload - /// one (RFC-009 decision 5): a read-only library that refuses to grow in - /// audio may still take a few hundred kilobytes of loop, so a client that - /// read `accepts_uploads` for both would hide the canvas exactly where the - /// flag was split to allow it. - pub accepts_canvas: bool, - /// Whether the walk follows symbolic links that stay inside the library - /// (RFC-021 decision 3). An administrator's setting, shown to every - /// member because it explains what the library holds. - pub follow_links: bool, -} - -/// What an incremental scan compares before reading a file (RFC-018 -/// decision 1): enough to skip it without opening it. -#[derive(Debug, Clone)] -pub struct SkipFacts { - pub id: Uuid, - pub file_size: i64, - pub modified_at: i64, - pub available: bool, - /// The relocation hint is present; a row from before the column still - /// needs its one extraction. - pub has_pid: bool, - /// `None` until a scan has computed it, which never matches. - pub sidecar_signature: Option, -} - -#[derive(Debug, Clone)] -pub struct ExistingTrack { - pub id: Uuid, - pub relative_path: String, - pub file_size: i64, - pub modified_at: i64, - pub quick_hash: String, - pub full_hash: String, - pub lyrics_hash: Option, - pub available: bool, - /// `None` on a row written before the relocation hint existed, which is - /// what the backfill in the scanner's skip path watches for. - pub pid: Option, -} - -#[derive(Debug, Clone)] -pub struct StreamTrack { - pub id: Uuid, - pub library_id: Uuid, - pub library_root: PathBuf, - pub relative_path: String, - pub full_hash: String, - pub codec: Option, - pub bitrate: Option, - pub duration_ms: i64, - pub available: bool, - /// Whether, and how far, a link in `relative_path` may be followed when - /// the file is opened (RFC-021, links outside the root). - pub link_policy: crate::link_roots::LinkPolicy, - /// The track's ReplayGain tags, for a transcode asked to apply them. - pub replay_gain: crate::replay_gain::ReplayGainTags, -} - -#[derive(Debug, Clone)] -pub struct ArtworkInput { - pub hash: String, - pub format: String, - pub source: String, - pub byte_size: i64, -} - -#[derive(Debug, Clone)] -pub struct CatalogTrackInput { - pub relative_path: String, - pub file_size: i64, - pub modified_at: i64, - pub quick_hash: String, - pub full_hash: String, - pub title: String, - pub artist: Option, - /// The multi-valued spellings of the two artist tags, when a file used - /// them. They win over the singular ones and are never cut: a tagger - /// writing `ARTISTS` has already said where the boundaries are. - pub artists: Vec, - pub album_artists: Vec, - /// Every other role the file names, as it spelled them. - pub roles: Vec<(crate::tags::Role, Vec)>, - /// Performer credits, already split into instrument and name. - pub performer_pairs: Vec<(String, String)>, - pub album: Option, - pub album_artist: Option, - pub is_compilation: bool, - pub genre: Option, - pub year: Option, - pub track_number: Option, - pub disc_number: Option, - pub duration_ms: i64, - pub bitrate: Option, - pub sample_rate: Option, - pub channels: Option, - pub bit_depth: Option, - pub codec: Option, - pub musical_key: Option, - pub tag_rating: Option, - /// The MusicBrainz recording identifier: the performance, which is what a - /// song's `musicBrainzId` means. Release and artist are separate entities - /// and are kept separate. - pub musicbrainz_recording_id: Option, - pub musicbrainz_release_id: Option, - pub musicbrainz_artist_id: Option, - /// Decibel gains and linear peaks, as tagged. - pub replay_gain_track_gain: Option, - pub replay_gain_track_peak: Option, - pub replay_gain_album_gain: Option, - pub replay_gain_album_peak: Option, - pub bpm: Option, - pub sort_title: Option, - /// Sort forms of the album title and of the two artist credits, as - /// tagged. Multi-valued credits carry them joined by the same separator. - pub sort_album: Option, - pub sort_album_artist: Option, - pub sort_artist: Option, - pub comment: Option, - /// Multi-valued, split like `artist` and `genre`. - pub isrc: Option, - pub moods: Option, - /// Normalised to `explicit` or `clean`; any other tag value is no value. - pub explicit_status: Option, - /// The two release dates, as the file spelled them. Kept as written and - /// taken apart only at the wire, where OpenSubsonic wants year, month and - /// day as separate numbers: a tag that names only a year must not be - /// reported as the first of January. - pub original_release_date: Option, - pub release_date: Option, - /// Multi-valued, split like `moods`. - pub release_types: Option, - pub record_labels: Option, - /// The title of the disc this track sits on. Track-level because an album - /// has one per disc, which is the whole point of the field. - pub disc_subtitle: Option, - pub artwork: Option, - pub lyrics_hash: String, - pub lyrics: Vec, -} - -#[derive(Debug, Clone)] -pub struct CatalogApply { - pub input: CatalogTrackInput, - pub existing_id: Option, - pub moved: bool, - /// The device that asked for this, when a client did. - /// - /// `None` for a scan, which is nobody's request in particular, and also for - /// a client that named no device — the header is optional, and a client - /// that would rather hear its own changes back simply omits it. - pub origin_device_id: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ApplyOutcome { - Added, - Updated, - Moved, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct ScanJobRecord { - pub id: Uuid, - pub library_id: Uuid, - pub status: String, - pub total_files: i64, - pub processed_files: i64, - pub added: i64, - pub updated: i64, - pub moved: i64, - pub skipped: i64, - pub unavailable: i64, - pub errors: i64, - /// Entries the walk left out by an exclusion or an ignore file, a folder - /// counting once whatever it holds (RFC-021 decision 2). - pub excluded: i64, - /// Symbolic links the walk did not follow, for whatever reason - /// (RFC-021 decision 3). - pub skipped_links: i64, - pub current_path: Option, - pub message: Option, -} - -#[derive(Debug, Clone, Serialize, ToSchema)] -pub struct TrackRecord { - pub id: Uuid, - pub library_id: Uuid, - pub relative_path: String, - pub title: String, - pub album: Option, - pub artist: Option, - /// Primary credited artist, matching the first artist in `artist`. - pub artist_id: Option, - pub genre: Option, - pub duration_ms: i64, - pub codec: Option, - pub artwork_hash: Option, - /// Content fingerprint: BLAKE3, unkeyed, hexadecimal, over the whole file. - /// See `services::SongItem::full_hash` for the contract. - pub full_hash: String, - pub available: bool, -} - -impl Database { - pub async fn libraries_for_user( - &self, - user_id: Uuid, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT l.id, l.name, l.visibility, m.role, l.last_scan_started_at, \ - l.last_scan_completed_at, l.accepts_uploads, l.accepts_canvas, \ - l.follow_links \ - FROM library l JOIN library_member m ON m.library_id=l.id \ - WHERE m.user_id=? ORDER BY l.name COLLATE NOCASE, l.id", - ) - .bind(user_id.to_string()) - .fetch_all(self.pool()) - .await?; - rows.into_iter() - .map(|row| { - Ok(LibraryAccess { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - visibility: crate::database::LibraryVisibility::from_str( - row.try_get::<&str, _>("visibility")?, - ) - .map_err(|error| sqlx::Error::Decode(error.into()))?, - role: crate::database::LibraryRole::from_str(row.try_get::<&str, _>("role")?) - .map_err(|error| sqlx::Error::Decode(error.into()))?, - last_scan_started_at: row.try_get("last_scan_started_at")?, - last_scan_completed_at: row.try_get("last_scan_completed_at")?, - accepts_uploads: row.try_get::("accepts_uploads")? != 0, - accepts_canvas: row.try_get::("accepts_canvas")? != 0, - follow_links: row.try_get::("follow_links")? != 0, - }) - }) - .collect() - } - - pub async fn all_libraries(&self) -> Result, sqlx::Error> { - let rows = sqlx::query("SELECT id, name, root_path FROM library ORDER BY created_at") - .fetch_all(self.pool()) - .await?; - rows.into_iter().map(library_from_row).collect() - } - - /// One library as a scan needs it, read when the scan starts rather than - /// when it was queued: its root may have moved and its links setting - /// changed in between, and it may be gone (RFC-021 decision 5). - pub async fn library_scan_settings( - &self, - library_id: Uuid, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT id, name, root_path, follow_links, watch FROM library WHERE id = ?", - ) - .bind(library_id.to_string()) - .fetch_optional(self.pool()) - .await?; - row.map(|row| { - let follow_links = row.try_get::("follow_links")? != 0; - let watch = row.try_get::("watch")? != 0; - Ok(LibraryScanSettings { - library: library_from_row(row)?, - follow_links, - watch, - }) - }) - .transpose() - } - - /// The libraries the watcher observes: those whose `watch` switch is on, - /// and the links setting each is walked with, which decides whether a - /// watched change may be scanned folder by folder. - pub async fn watched_libraries(&self) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT id, name, root_path, follow_links FROM library WHERE watch = 1 \ - ORDER BY created_at", - ) - .fetch_all(self.pool()) - .await?; - rows.into_iter() - .map(|row| { - let follow_links = row.try_get::("follow_links")? != 0; - Ok((library_from_row(row)?, follow_links)) - }) - .collect() - } - - /// Whether any library the user can see is being scanned, and how many - /// available tracks they can currently reach. - /// - /// Both halves of the Subsonic `scanStatus` shape, resolved in one - /// round trip and scoped to the caller: `count` is what *this* account - /// can see, not what the instance holds. - pub async fn scan_progress_for_user(&self, user_id: Uuid) -> Result<(bool, i64), sqlx::Error> { - let row = sqlx::query( - "SELECT EXISTS (SELECT 1 FROM scan_job sj \ - JOIN library_member m ON m.library_id=sj.library_id \ - WHERE m.user_id=? AND sj.status IN ('queued', 'running')) AS scanning, \ - (SELECT COUNT(*) FROM track t \ - JOIN library_member m ON m.library_id=t.library_id \ - WHERE m.user_id=? AND t.is_available=1) AS scanned", - ) - .bind(user_id.to_string()) - .bind(user_id.to_string()) - .fetch_one(self.pool()) - .await?; - Ok(( - row.try_get::("scanning")? != 0, - row.try_get("scanned")?, - )) - } - - /// Reads one instance-wide setting, or `None` when it was never written. - /// - /// The store exists so the server can notice that it is configured - /// differently from the run that built the catalogue. Anything a scan - /// derives from configuration belongs here: on a mismatch the catalogue is - /// stale in a way no file timestamp can reveal. - pub async fn server_property(&self, key: &str) -> Result, sqlx::Error> { - sqlx::query_scalar("SELECT value FROM server_property WHERE key = ?") - .bind(key) - .fetch_optional(self.pool()) - .await - } - - pub async fn set_server_property(&self, key: &str, value: &str) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - sqlx::query( - "INSERT INTO server_property (key, value, updated_at) VALUES (?, ?, ?) \ - ON CONFLICT (key) DO UPDATE SET value = excluded.value, \ - updated_at = excluded.updated_at", - ) - .bind(key) - .bind(value) - .bind(now_ms()) - .execute(self.pool()) - .await?; - Ok(()) - } - - /// Asks every library to rescan every file, skipping nothing. - /// - /// Returns how many libraries were marked. One already carrying the request - /// keeps its original timestamp: the request is a state, not a counter, and - /// re-stamping it would say a later run asked for something the earlier one - /// has not yet delivered. - pub async fn request_full_scan_everywhere(&self) -> Result { - let _writer = self.writer_guard().await; - let result = sqlx::query( - "UPDATE library SET full_scan_requested_at = ?, updated_at = ? \ - WHERE full_scan_requested_at IS NULL", - ) - .bind(now_ms()) - .bind(now_ms()) - .execute(self.pool()) - .await?; - Ok(result.rows_affected()) - } - - pub async fn request_full_scan(&self, library_id: Uuid) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - sqlx::query( - "UPDATE library SET full_scan_requested_at = ?, updated_at = ? \ - WHERE id = ? AND full_scan_requested_at IS NULL", - ) - .bind(now_ms()) - .bind(now_ms()) - .bind(library_id.to_string()) - .execute(self.pool()) - .await?; - Ok(()) - } - - /// Whether the next scan of this library has to read every file. - /// - /// Read at the start of every run rather than passed in by the caller: a - /// scan that was interrupted left the request standing, and the run that - /// picks up after it has to honour the request it never saw made. - pub async fn full_scan_requested(&self, library_id: Uuid) -> Result { - let requested: Option> = - sqlx::query_scalar("SELECT full_scan_requested_at FROM library WHERE id = ?") - .bind(library_id.to_string()) - .fetch_optional(self.pool()) - .await?; - Ok(matches!(requested, Some(Some(_)))) - } - - /// Notices that this instance derives identifiers differently from the run - /// that built the catalogue, and asks for a full rescan when it does. - /// - /// No file timestamp can reveal this: the files have not changed, the rule - /// reading them has. Comparing the persisted spec against the configured - /// one is the only way the difference is visible at all. - /// - /// Returns how many libraries were marked, so a caller can say so. - pub async fn reconcile_catalog_identity( - &self, - specs: &crate::pid::PidSpecs, - ) -> Result { - // `pid.track` is handled apart, and never by re-identifying anything: - // a track's id is its path, then its content hash, then the relocation - // hint — six tables cascade off it, so moving track ids would delete - // playlists and play history rather than orphan them. - // - // What a changed track spec does invalidate is every stored hint. A - // lookup compares a hint computed under the new spec against values - // written under the old one, and those two are not separate namespaces: - // two different specs can evaluate to the same string — `title` reading - // "intro" and `album` reading "intro" — and the hint would then hand a - // new file the identity, and the favourites, of an unrelated track. So - // the stale values go, and the scanner's backfill puts fresh ones back - // on the next ordinary scan. No full rescan is charged for it. - self.clear_track_hints_if_spec_changed(specs).await?; - let active = [ - ("pid.album", specs.album.source()), - ("pid.artist", specs.artist.source()), - ]; - let mut changed = Vec::new(); - for (key, value) in active { - match self.server_property(key).await? { - // A catalogue built before the property existed is not - // evidence of a different rule, only of an older server. The - // first scan writes what it used, and later boots compare. - None => {} - Some(stored) if stored == value => {} - Some(stored) => changed.push((key, stored, value)), - } - } - if changed.is_empty() { - return Ok(0); - } - for (key, stored, active) in &changed { - tracing::warn!( - setting = key, - %stored, - %active, - "catalogue identity setting changed since the last scan" - ); - } - // Before the rescan, while the artist rows still hold the identifiers - // their favourites name. - if changed.iter().any(|(key, _, _)| *key == "pid.artist") { - let moved = self.remap_artist_user_data(specs).await?; - if moved > 0 { - tracing::warn!( - rows = moved, - "favourites and ratings moved onto the artist identifiers the new rule derives" - ); - } - } - let libraries = self.request_full_scan_everywhere().await?; - tracing::warn!( - libraries, - "every library will be rescanned in full so its identifiers follow the new rule" - ); - Ok(libraries) - } - - /// Drops every relocation hint when the track spec no longer matches the - /// one the catalogue's hints were written under. - /// - /// Deliberately not paired with a full rescan: nothing is re-identified, - /// and the scanner refills a missing hint from its skip path, so one - /// ordinary scan restores what this clears. `pid IS NOT NULL` keeps a boot - /// that changes nothing from writing anything, and keeps the repeat boots - /// before that first scan free. - async fn clear_track_hints_if_spec_changed( - &self, - specs: &crate::pid::PidSpecs, - ) -> Result<(), sqlx::Error> { - let active = specs.track.source(); - // A catalogue built before the property existed is not evidence of a - // different rule, only of an older server — the same reading the album - // and artist specs get below. - match self.server_property("pid.track").await? { - None => return Ok(()), - Some(stored) if stored == active => return Ok(()), - Some(stored) => { - tracing::warn!( - setting = "pid.track", - %stored, - %active, - "track spec changed; dropping the relocation hints written under the old one" - ); - } - } - let _writer = self.writer_guard().await; - let cleared = sqlx::query("UPDATE track SET pid = NULL WHERE pid IS NOT NULL") - .execute(self.pool()) - .await? - .rows_affected(); - if cleared > 0 { - tracing::warn!( - cleared, - "relocation hints dropped; the next scan writes them again under the new spec" - ); - } - Ok(()) - } - - /// Carries artist favourites and ratings across a change of artist spec. - /// - /// `user_star` and `user_rating` hold an untyped identifier with no foreign - /// key, so a rescan that re-derives artist ids leaves their rows pointing at - /// identifiers nothing answers for — invisible rather than wrong, since every - /// projection resolves through an `EXISTS`, but lost all the same. The old - /// identifier and the name that produced it are both still on the `artist` - /// row at this point, which is the only moment the two can be paired. - /// - /// Albums are not remapped and cannot be: their spec reads `albumversion` - /// and `releasedate`, which live on the files rather than on the album row, - /// so there is nothing here to derive the new identifier from. - /// - /// **Moved in two phases, through a namespace no identifier can occupy.** - /// One artist's new identifier can be another's old one, and moving them one - /// at a time would then carry the first artist's favourite onto the second's - /// row — a result that depends on the order the rows came back in, which is - /// no result at all. Staging every row first and landing them afterwards - /// makes the outcome the same whatever that order was. Both phases run in - /// one transaction, so a staged value is never visible to anything. - /// - /// `UPDATE OR IGNORE` then `DELETE` at each phase: a coarser spec can fold - /// two artists onto one identifier, and the second row would collide on - /// `(user_id, entity_type, entity_id)`. Losing the duplicate is right — the - /// user already stars what it would have become. - async fn remap_artist_user_data( - &self, - specs: &crate::pid::PidSpecs, - ) -> Result { - let rows = sqlx::query("SELECT id, library_id, name FROM artist") - .fetch_all(self.pool()) - .await?; - let mut moves = Vec::new(); - for row in rows { - let old: String = row.try_get("id")?; - let library_id = parse_uuid(row.try_get("library_id")?)?; - let name: String = row.try_get("name")?; - let new = specs.artist_id(library_id, &name).to_string(); - if new != old { - // A UUID holds no colon, so nothing that reaches these columns - // by any other route can be mistaken for a staged value. - moves.push((old, format!("{REMAP_STAGE}{new}"), new)); - } - } - if moves.is_empty() { - return Ok(0); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let mut moved = 0; - for (from, to) in moves - .iter() - .map(|(old, staged, _)| (old, staged)) - .chain(moves.iter().map(|(_, staged, new)| (staged, new))) - { - let landed = move_artist_user_data(&mut tx, from, to).await?; - // Counted on the second pass only, where a row reaches the - // identifier it will actually be read under. - if from.starts_with(REMAP_STAGE) { - moved += landed; - } - } - tx.commit().await?; - Ok(moved) - } - - pub async fn library_for_user( - &self, - user_id: Uuid, - library_id: Uuid, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT l.id, l.name, l.root_path FROM library l \ - JOIN library_member m ON m.library_id = l.id \ - WHERE l.id = ? AND m.user_id = ?", - ) - .bind(library_id.to_string()) - .bind(user_id.to_string()) - .fetch_optional(self.pool()) - .await?; - row.map(library_from_row).transpose() - } - - pub async fn create_scan_job( - &self, - library_id: Uuid, - requested_by: Option, - trigger: &str, - ) -> Result { - let _writer = self.writer_guard().await; - let id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at) \ - VALUES (?, ?, ?, ?, 'queued', ?)", - ) - .bind(id.to_string()) - .bind(library_id.to_string()) - .bind(requested_by.map(|id| id.to_string())) - .bind(trigger) - .bind(now_ms()) - .execute(self.pool()) - .await?; - Ok(id) - } - - /// A job that walks only `scope`, folders relative to the library root: - /// what the watcher queues once a library's changes settle and it can - /// name where they were (RFC-021, what decision 6 left open). - pub async fn create_scoped_scan_job( - &self, - library_id: Uuid, - trigger: &str, - scope: &[String], - ) -> Result { - let _writer = self.writer_guard().await; - let id = Uuid::new_v4(); - let scope = serde_json::to_string(scope).expect("a list of strings serializes"); - sqlx::query( - "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at, \ - scope_json) \ - VALUES (?, ?, NULL, ?, 'queued', ?, ?)", - ) - .bind(id.to_string()) - .bind(library_id.to_string()) - .bind(trigger) - .bind(now_ms()) - .bind(scope) - .execute(self.pool()) - .await?; - Ok(id) - } - - /// The folders a job walks, or `None` for the whole library. A scope that - /// cannot be read is the whole library: walking more than asked is always - /// safe, and walking less is not. - pub async fn scan_scope(&self, scan_id: Uuid) -> Result>, sqlx::Error> { - let scope: Option> = - sqlx::query_scalar("SELECT scope_json FROM scan_job WHERE id = ?") - .bind(scan_id.to_string()) - .fetch_optional(self.pool()) - .await?; - Ok(scope - .flatten() - .and_then(|scope| serde_json::from_str::>(&scope).ok())) - } - - /// Every job still `queued`, oldest first, with its library: what a - /// previous run committed and never started. - pub async fn queued_scan_jobs(&self) -> Result, sqlx::Error> { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT id, library_id FROM scan_job WHERE status = 'queued' ORDER BY created_at, id", - ) - .fetch_all(self.pool()) - .await?; - rows.into_iter() - .map(|(id, library)| Ok((parse_uuid(id)?, parse_uuid(library)?))) - .collect() - } - - /// A queued job, in a transaction the caller holds under the writer gate: - /// for a change that owes a scan and must not commit without it (RFC-021 - /// decision 5). - pub(crate) async fn insert_scan_job_in( - tx: &mut Transaction<'_, Sqlite>, - library_id: Uuid, - requested_by: Option, - trigger: &str, - ) -> Result { - let id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at) \ - VALUES (?, ?, ?, ?, 'queued', ?)", - ) - .bind(id.to_string()) - .bind(library_id.to_string()) - .bind(requested_by.map(|id| id.to_string())) - .bind(trigger) - .bind(now_ms()) - .execute(&mut **tx) - .await?; - Ok(id) - } - - /// Creates a scan job only if the user is entitled to one, testing that - /// inside the insert rather than before it. - /// - /// A caller that checked entitlement with a separate read leaves a window - /// where a revocation lands between the check and the insert, queuing - /// work for a library the requester can no longer reach. Making the - /// `library_member` row a condition of the statement closes it: there is - /// no moment at which the job exists without the grant that justified it. - /// - /// Membership alone is not that grant. A scan walks the owner's files and - /// competes for the writer gate, so it is reserved to `owner` and - /// `manager`: a `listener` is entitled to read the catalogue, not to spend - /// someone else's disk on it. [`crate::database::LibraryRole::may_scan`] - /// names the same rule for callers that have the row in hand. - /// - /// Returns `None` when the user is not, or no longer, entitled. Callers - /// map that onto the answer a missing library gets, so a refusal never - /// confirms that the library exists. - pub async fn create_scan_job_for_user( - &self, - user_id: Uuid, - library_id: Uuid, - trigger: &str, - ) -> Result, sqlx::Error> { - let _writer = self.writer_guard().await; - let id = Uuid::new_v4(); - let inserted = sqlx::query( - "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at) \ - SELECT ?, ?, ?, ?, 'queued', ? WHERE EXISTS \ - (SELECT 1 FROM library_member WHERE library_id=? AND user_id=? \ - AND role IN ('owner', 'manager'))", - ) - .bind(id.to_string()) - .bind(library_id.to_string()) - .bind(user_id.to_string()) - .bind(trigger) - .bind(now_ms()) - .bind(library_id.to_string()) - .bind(user_id.to_string()) - .execute(self.pool()) - .await?; - Ok((inserted.rows_affected() > 0).then_some(id)) - } - - pub async fn start_scan_job( - &self, - scan_id: Uuid, - total: i64, - full: bool, - ) -> Result<(), sqlx::Error> { - self.start_walked_scan_job(scan_id, total, full, 0, 0).await - } - - /// Marks a job running, with what its walk found: how many files it will - /// look at, and what it left out (RFC-021 decision 7). - pub async fn start_walked_scan_job( - &self, - scan_id: Uuid, - total: i64, - full: bool, - excluded: i64, - skipped_links: i64, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let now = now_ms(); - sqlx::query( - "UPDATE scan_job SET status = 'running', total_files = ?, full_scan = ?, \ - excluded = ?, skipped_links = ?, started_at = ? WHERE id = ?", - ) - .bind(total) - .bind(i64::from(full)) - .bind(excluded) - .bind(skipped_links) - .bind(now) - .bind(scan_id.to_string()) - .execute(self.pool()) - .await?; - sqlx::query( - "UPDATE library SET last_scan_started_at = ?, updated_at = ? \ - WHERE id = (SELECT library_id FROM scan_job WHERE id = ?)", - ) - .bind(now) - .bind(now) - .bind(scan_id.to_string()) - .execute(self.pool()) - .await?; - Ok(()) - } - - #[allow(clippy::too_many_arguments)] - pub async fn update_scan_job( - &self, - scan_id: Uuid, - processed: i64, - added: i64, - updated: i64, - moved: i64, - skipped: i64, - errors: i64, - current_path: Option<&str>, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - sqlx::query( - "UPDATE scan_job SET processed_files = ?, added = ?, updated = ?, moved = ?, \ - skipped = ?, errors = ?, current_path = ? WHERE id = ?", - ) - .bind(processed) - .bind(added) - .bind(updated) - .bind(moved) - .bind(skipped) - .bind(errors) - .bind(current_path) - .bind(scan_id.to_string()) - .execute(self.pool()) - .await?; - Ok(()) - } - - /// Recomputes the derived MusicBrainz identifier of every album and artist - /// in one library from the tracks that currently back them. - /// - /// Tracks of one album routinely disagree: a release id is written per file - /// by whatever tagger touched it, and a library assembled over years holds - /// files tagged against different releases of the same record. The album's - /// identifier is therefore the one most of its available tracks agree on, - /// not the one the last file scanned happened to carry. - /// - /// Ties are broken by the earliest disc and track number, zero-padded into - /// a single sortable key so `MIN` compares the pair rather than each half - /// independently, and finally by the identifier itself. A tie means the - /// tags genuinely split the album down the middle; what matters is that two - /// scans of unchanged files answer the same thing, so nothing flaps. - /// - /// An artist takes the identifier from the tracks it is the *primary* - /// credit of (`track_artist.position = 0`), because `musicbrainz_artist_id` - /// is one value on a track that may credit several artists, and only the - /// first credit is the one the tag is about. - /// - /// Albums and artists whose tracks carry no identifier are set back to - /// `NULL` rather than left alone: this runs after every scan, so a tag - /// removed from the files has to disappear from the catalogue too. - /// Re-derives `album.sort_name` and `artist.sort_name` from the tags the - /// library's available tracks still carry. - /// - /// Sibling of [`Self::consolidate_catalog_derivations`] and run beside it, for - /// the same reason and by the same rule: a value derived from tags has to - /// follow the tags, including when they go away. Writing the sort name - /// during the per-track upsert could not do that — the row is rewritten - /// once per track of every album the artist appears on, so a file carrying - /// no tag had to be prevented from erasing what a sibling supplied, and - /// that preservation outlived the tag itself. - /// - /// The majority wins, ties broken deterministically, so two scans of - /// unchanged files answer the same thing. An album takes the sort title - /// its tracks agree on; an artist takes the sort form its credits agree - /// on, from `track_artist` where the joined tag was already paired with - /// the joined credit. An album artist credited on none of the tracks - /// therefore has no sort form of its own — the catalogue holds no tag - /// that is about it alone. - pub async fn consolidate_sort_names(&self, library_id: Uuid) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - Self::consolidate_sort_names_in(&mut tx, library_id).await?; - tx.commit().await?; - Ok(()) - } - - /// [`Self::consolidate_sort_names`] inside a transaction the caller holds, - /// under the writer gate it already took: a credit decision re-derives in - /// the transaction that records it, so the two land or fail together. - pub(crate) async fn consolidate_sort_names_in( - tx: &mut Transaction<'_, Sqlite>, - library_id: Uuid, - ) -> Result<(), sqlx::Error> { - sqlx::query( - "UPDATE album SET sort_name = ( \ - SELECT t.sort_album FROM track t \ - WHERE t.album_id = album.id AND t.is_available = 1 \ - AND t.sort_album IS NOT NULL \ - GROUP BY t.sort_album \ - ORDER BY COUNT(*) DESC, t.sort_album \ - LIMIT 1) \ - WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - sqlx::query( - "UPDATE artist SET sort_name = ( \ - SELECT tp.sort_name FROM track_participant tp \ - JOIN track t ON t.id = tp.track_id \ - WHERE tp.artist_id = artist.id AND t.is_available = 1 \ - AND tp.sort_name IS NOT NULL \ - GROUP BY tp.sort_name \ - ORDER BY COUNT(*) DESC, tp.sort_name \ - LIMIT 1) \ - WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - Ok(()) - } - - /// Everything the catalogue derives from its own contents, after a scan. - /// - /// It was named for the MusicBrainz identifiers alone, which stopped being - /// true: it also rebuilds each album's credits from its tracks', recounts - /// what every artist has to show for each capacity, and drops the artist - /// rows nothing credits any more. All of it is a majority or a union over - /// the tracks that are still available, so all of it has to run after - /// `mark_unseen_unavailable` — a file that vanished must stop voting first. - /// - /// A test that builds a catalogue by applying tracks directly runs this - /// too, for the same reason the scanner does: without it the album has no - /// credits and no artist has a count. - pub async fn consolidate_catalog_derivations( - &self, - library_id: Uuid, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - Self::consolidate_catalog_derivations_in(&mut tx, library_id).await?; - tx.commit().await?; - Ok(()) - } - - /// [`Self::consolidate_catalog_derivations`] inside a transaction the caller - /// holds, under the writer gate it already took, for the same reason as - /// [`Self::consolidate_sort_names_in`]. - pub(crate) async fn consolidate_catalog_derivations_in( - tx: &mut Transaction<'_, Sqlite>, - library_id: Uuid, - ) -> Result<(), sqlx::Error> { - sqlx::query( - "UPDATE album SET musicbrainz_id = ( \ - SELECT t.musicbrainz_release_id FROM track t \ - WHERE t.album_id = album.id AND t.is_available = 1 \ - AND t.musicbrainz_release_id IS NOT NULL \ - GROUP BY t.musicbrainz_release_id \ - ORDER BY COUNT(*) DESC, \ - MIN(printf('%06d%06d', COALESCE(t.disc_number, 1), \ - COALESCE(t.track_number, 0))), \ - t.musicbrainz_release_id \ - LIMIT 1) \ - WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - sqlx::query( - "UPDATE artist SET musicbrainz_id = ( \ - SELECT t.musicbrainz_artist_id FROM track t \ - JOIN track_participant tp ON tp.track_id = t.id AND tp.role = 'artist' AND tp.position = 0 \ - WHERE tp.artist_id = artist.id AND t.is_available = 1 \ - AND t.musicbrainz_artist_id IS NOT NULL \ - GROUP BY t.musicbrainz_artist_id \ - ORDER BY COUNT(*) DESC, t.musicbrainz_artist_id \ - LIMIT 1) \ - WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - // The album's own credits, derived here rather than written per track. - // A track-by-track write would be a last-writer-wins race between - // files of one album that disagree; deriving after availability is - // settled makes the answer the same whatever order the scan ran in. - sqlx::query("DELETE FROM album_participant WHERE library_id = ?") - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - sqlx::query( - "INSERT INTO album_participant \ - (album_id, artist_id, library_id, role, sub_role, position) \ - SELECT t.album_id, tp.artist_id, t.library_id, tp.role, tp.sub_role, \ - ROW_NUMBER() OVER ( \ - PARTITION BY t.album_id, tp.role \ - ORDER BY MIN(tp.position), tp.sub_role, tp.artist_id) - 1 \ - FROM track t \ - JOIN track_participant tp ON tp.track_id = t.id \ - WHERE t.library_id = ? AND t.is_available = 1 AND t.album_id IS NOT NULL \ - GROUP BY t.album_id, tp.artist_id, tp.role, tp.sub_role", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - // What each artist has to show for each capacity. `albumCount` used to - // be a count over the album's single artist column, which could only - // ever see the first credit; the roles an artist appears in are now - // simply the keys of this table. - sqlx::query("DELETE FROM artist_role_stats WHERE library_id = ?") - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - sqlx::query( - "INSERT INTO artist_role_stats \ - (artist_id, library_id, role, album_count, song_count) \ - SELECT tp.artist_id, t.library_id, tp.role, \ - COUNT(DISTINCT t.album_id), COUNT(DISTINCT t.id) \ - FROM track_participant tp \ - JOIN track t ON t.id = tp.track_id \ - WHERE t.library_id = ? AND t.is_available = 1 \ - GROUP BY tp.artist_id, t.library_id, tp.role", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - // An album no track depends on is no longer in the catalogue. This - // matters most on the scan that follows the identity migration: the - // rows kept by the migration hold their old identifiers, the rescan - // mints derived ones and moves every track across, and without this - // the library would answer with each album twice — once full, once - // empty. It runs before the artist sweep because `album_artist_id` - // restricts on delete, so a ghost album would hold its artist hostage. - sqlx::query( - "DELETE FROM album WHERE library_id = ? \ - AND NOT EXISTS (SELECT 1 FROM track t WHERE t.album_id = album.id)", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - // An artist row outlives the tag that created it: rename a credit and - // the old name stays in the index forever. An artist credited on - // nothing is no longer part of the catalogue, so it goes with the same - // scan that made it unreferenced. Both relations are checked, because - // an album artist need not be credited on any of the album's tracks. - sqlx::query( - "DELETE FROM artist WHERE library_id = ? \ - AND NOT EXISTS (SELECT 1 FROM track_participant tp WHERE tp.artist_id = artist.id) \ - AND NOT EXISTS (SELECT 1 FROM album_participant ap WHERE ap.artist_id = artist.id) \ - AND NOT EXISTS (SELECT 1 FROM album al WHERE al.album_artist_id = artist.id)", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - // The artist search index, rewritten whole rather than kept in step - // row by row. An artist row is touched once per track of every album - // it appears on, and a rename would leave the old name searchable - // forever unless something swept it — the same reasoning that made - // the sort names a pass instead of a write. Last in this - // transaction because it indexes the artists the sweep above left - // standing, under the sort names settled before this pass ran. - sqlx::query("DELETE FROM artist_fts WHERE library_id = ?") - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - sqlx::query( - "INSERT INTO artist_fts (artist_id, library_id, name, sort_name) \ - SELECT id, library_id, name, COALESCE(sort_name, '') \ - FROM artist WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - Ok(()) - } - - pub async fn finish_scan_job( - &self, - scan_id: Uuid, - unavailable: i64, - ) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let now = now_ms(); - sqlx::query( - "UPDATE scan_job SET status = 'completed', unavailable = ?, current_path = NULL, \ - completed_at = ? WHERE id = ?", - ) - .bind(unavailable) - .bind(now) - .bind(scan_id.to_string()) - .execute(&mut *tx) - .await?; - sqlx::query( - "UPDATE library SET last_scan_completed_at = ?, updated_at = ? \ - WHERE id = (SELECT library_id FROM scan_job WHERE id = ?)", - ) - .bind(now) - .bind(now) - .bind(scan_id.to_string()) - .execute(&mut *tx) - .await?; - // The full-scan request is lifted here and nowhere else, and only by a - // run that actually read every file. A run that dies halfway leaves it - // standing, so the next one reads every file again instead of skipping - // the half it had already rewritten. And a scan that had already begun - // when the request arrived cannot spend it: it started under the old - // rules, so `scan_job.full_scan` says what it really did. - sqlx::query( - "UPDATE library SET full_scan_requested_at = NULL, updated_at = ? \ - WHERE id = (SELECT library_id FROM scan_job WHERE id = ?) \ - AND full_scan_requested_at IS NOT NULL \ - AND (SELECT full_scan FROM scan_job WHERE id = ?) = 1", - ) - .bind(now) - .bind(scan_id.to_string()) - .bind(scan_id.to_string()) - .execute(&mut *tx) - .await?; - // The rules this run wrote the catalogue under, recorded at the end - // and never at the start: a scan that dies halfway would otherwise - // look complete to the next boot, which would find no mismatch and - // leave half the catalogue keyed under the old rule for good. - for (key, value) in [ - ("pid.album", self.pid().album.source()), - ("pid.track", self.pid().track.source()), - ("pid.artist", self.pid().artist.source()), - ] { - sqlx::query( - "INSERT INTO server_property (key, value, updated_at) VALUES (?, ?, ?) \ - ON CONFLICT (key) DO UPDATE SET value = excluded.value, \ - updated_at = excluded.updated_at", - ) - .bind(key) - .bind(value) - .bind(now) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(()) - } - - pub async fn fail_scan_job(&self, scan_id: Uuid, message: &str) -> Result<(), sqlx::Error> { - let _writer = self.writer_guard().await; - sqlx::query( - "UPDATE scan_job SET status = 'failed', message = ?, current_path = NULL, \ - completed_at = ? WHERE id = ?", - ) - .bind(message) - .bind(now_ms()) - .bind(scan_id.to_string()) - .execute(self.pool()) - .await?; - Ok(()) - } - - pub async fn scan_job_for_user( - &self, - user_id: Uuid, - scan_id: Uuid, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT j.* FROM scan_job j JOIN library_member m ON m.library_id = j.library_id \ - WHERE j.id = ? AND m.user_id = ?", - ) - .bind(scan_id.to_string()) - .bind(user_id.to_string()) - .fetch_optional(self.pool()) - .await?; - row.map(scan_job_from_row).transpose() - } - - /// A scan job by its id alone, for the operator's CLI, which acts as the - /// instance rather than as a member of the library. - pub async fn scan_job(&self, scan_id: Uuid) -> Result, sqlx::Error> { - let row = sqlx::query("SELECT * FROM scan_job WHERE id = ?") - .bind(scan_id.to_string()) - .fetch_optional(self.pool()) - .await?; - row.map(scan_job_from_row).transpose() - } - - pub async fn existing_track_by_path( - &self, - library_id: Uuid, - relative_path: &str, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT id, relative_path, file_size, file_modified_at, quick_hash, full_hash, lyrics_hash, is_available, pid \ - FROM track WHERE library_id = ? AND relative_path = ?", - ) - .bind(library_id.to_string()) - .bind(relative_path) - .fetch_optional(self.pool()) - .await?; - row.map(existing_track_from_row).transpose() - } - - pub async fn relocation_candidates( - &self, - library_id: Uuid, - full_hash: &str, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT id, relative_path, file_size, file_modified_at, quick_hash, full_hash, lyrics_hash, is_available, pid \ - FROM track WHERE library_id = ? AND full_hash = ?", - ) - .bind(library_id.to_string()) - .bind(full_hash) - .fetch_all(self.pool()) - .await?; - rows.into_iter().map(existing_track_from_row).collect() - } - - /// Tracks carrying one relocation hint. - /// - /// Only rows that already answered a scan under the current spec have a - /// `pid` at all, and the column is nullable, so this never widens to - /// "every track whose hint was never computed". - pub async fn relocation_candidates_by_pid( - &self, - library_id: Uuid, - pid: Uuid, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT id, relative_path, file_size, file_modified_at, quick_hash, full_hash, lyrics_hash, is_available, pid \ - FROM track WHERE library_id = ? AND pid = ?", - ) - .bind(library_id.to_string()) - .bind(pid.to_string()) - .fetch_all(self.pool()) - .await?; - rows.into_iter().map(existing_track_from_row).collect() - } - - /// Gives a hint to rows that predate the column. - /// - /// An ordinary scan skips a file it has already seen unchanged, so without - /// this an existing catalogue would carry no hints at all until someone - /// asked for a full scan — and the feature would be inert on exactly the - /// installations that have favourites worth keeping. `pid IS NULL` in the - /// predicate makes it a one-time cost: the second scan finds nothing to do. - pub async fn backfill_track_pids(&self, hints: &[(Uuid, Uuid)]) -> Result<(), sqlx::Error> { - if hints.is_empty() { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - for (track_id, pid) in hints { - sqlx::query("UPDATE track SET pid = ? WHERE id = ? AND pid IS NULL") - .bind(pid.to_string()) - .bind(track_id.to_string()) - .execute(&mut *tx) - .await?; - } - tx.commit().await - } - - pub async fn mark_track_seen(&self, track_id: Uuid, scan_id: Uuid) -> Result<(), sqlx::Error> { - self.mark_tracks_seen(&[track_id], scan_id).await - } - - /// Every track of the library by its path, with what the pre-check compares. - /// Read once per scan: a row per track, small, and one query instead of one - /// per file. - pub async fn track_skip_facts( - &self, - library_id: Uuid, - ) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT id, relative_path, file_size, file_modified_at, is_available, pid IS NOT NULL AS has_pid, sidecar_signature FROM track WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .fetch_all(self.pool()) - .await?; - let mut facts = std::collections::HashMap::with_capacity(rows.len()); - for row in rows { - let id: String = row.try_get("id")?; - facts.insert( - row.try_get("relative_path")?, - SkipFacts { - id: Uuid::parse_str(&id).map_err(|error| sqlx::Error::Decode(error.into()))?, - file_size: row.try_get("file_size")?, - modified_at: row.try_get("file_modified_at")?, - available: row.try_get::("is_available")? != 0, - has_pid: row.try_get::("has_pid")? != 0, - sidecar_signature: row.try_get("sidecar_signature")?, - }, - ); - } - Ok(facts) - } - - /// Records the sidecar signature of files a scan read, by path, so the next - /// scan can skip them (RFC-018 decision 1). - pub async fn set_sidecar_signatures( - &self, - library_id: Uuid, - signatures: &[(String, String)], - ) -> Result<(), sqlx::Error> { - if signatures.is_empty() { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - for (relative_path, signature) in signatures { - sqlx::query( - "UPDATE track SET sidecar_signature = ? WHERE library_id = ? AND relative_path = ?", - ) - .bind(signature) - .bind(library_id.to_string()) - .bind(relative_path) - .execute(&mut *tx) - .await?; - } - tx.commit().await - } - - /// Stamps as seen by this scan the tracks under folders the scan could not - /// read, without touching their availability, so the pass that marks the - /// unseen missing leaves them as they are (RFC-018 decision 3). - pub async fn mark_tracks_under_seen( - &self, - library_id: Uuid, - folders: &[String], - scan_id: Uuid, - ) -> Result<(), sqlx::Error> { - if folders.is_empty() { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - for folder in folders { - // A prefix compared as text rather than with LIKE: a folder name - // holding `%` or `_` must not widen the match. - sqlx::query( - "UPDATE track SET last_seen_scan_id = ? WHERE library_id = ? AND (relative_path = ? OR substr(relative_path, 1, length(?) + 1) = ? || '/')", - ) - .bind(scan_id.to_string()) - .bind(library_id.to_string()) - .bind(folder) - .bind(folder) - .bind(folder) - .execute(&mut *tx) - .await?; - } - tx.commit().await - } - - pub async fn mark_tracks_seen( - &self, - track_ids: &[Uuid], - scan_id: Uuid, - ) -> Result<(), sqlx::Error> { - if track_ids.is_empty() { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let now = now_ms(); - for track_id in track_ids { - sqlx::query( - // A file found again takes back a purge still within its - // grace window: the track is there, so nothing is to go. - "UPDATE track SET is_available = 1, purged_at = NULL, last_seen_scan_id = ?, \ - updated_at = ? WHERE id = ?", - ) - .bind(scan_id.to_string()) - .bind(now) - .bind(track_id.to_string()) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(()) - } - - pub async fn apply_catalog_track( - &self, - library_id: Uuid, - scan_id: Uuid, - input: &CatalogTrackInput, - existing_id: Option, - moved: bool, - ) -> Result { - let mut outcomes = self - .apply_catalog_tracks( - library_id, - scan_id, - &[CatalogApply { - input: input.clone(), - existing_id, - moved, - origin_device_id: None, - }], - ) - .await?; - Ok(outcomes.pop().expect("single-item catalogue batch")) - } - - pub async fn apply_catalog_tracks( - &self, - library_id: Uuid, - scan_id: Uuid, - applies: &[CatalogApply], - ) -> Result, sqlx::Error> { - if applies.is_empty() { - return Ok(Vec::new()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - // Read once per batch, inside the transaction that writes it (RFC-015): - // a decision taken while a scan runs is either before this batch, - // which applies it, or after it, in which case the decision's own pass - // finds what this batch wrote. - let decisions = credit_decisions(&mut tx, library_id).await?; - let now = now_ms(); - let mut outcomes = Vec::with_capacity(applies.len()); - for apply in applies { - outcomes.push( - Self::apply_catalog_track_in_transaction( - &mut tx, - self.pid(), - library_id, - Some(scan_id), - apply, - &decisions, - now, - ) - .await?, - ); - } - tx.commit().await?; - Ok(outcomes) - } - - #[allow(clippy::too_many_arguments)] - /// Reachable from the domain services because clearing a correction has to - /// land in the same transaction that removes it: re-deriving afterwards - /// would leave a window where the correction is gone and the rows it wrote - /// are not. - /// Writes one track into the catalogue. - /// - /// `scan_id` is optional, and `None` has a precise meaning: **this row did - /// not come from a scan**. It is not "the scan is unknown". A received file - /// is applied through this same path so that what lands is by construction - /// what a scan would have written — but no scan walked for it, and saying - /// otherwise would put a fact in the scan history that never happened. - /// - /// The helpers above take a real id because they are the scanner's, and a - /// scan always has one. This primitive does not: applying to the catalogue - /// is not intrinsically a scanner operation, and the signature is where - /// that stops being implied. - /// - /// [`Database::mark_unseen_unavailable`] is the other half of this - /// meaning: a row that never came from a scan must not be swept as one a - /// scan failed to find. - pub(crate) async fn apply_catalog_track_in_transaction( - tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, - library_id: Uuid, - scan_id: Option, - apply: &CatalogApply, - decisions: &crate::credit_split::Decisions, - now: i64, - ) -> Result { - let input = &apply.input; - let existing_id = apply.existing_id; - // Credits are derived here rather than by the scanner because this is - // where identity is written: the test suite applies catalogue rows - // directly, and reading the tag values one way there and another way - // in production is how the two drift apart. - // - // These are the first step's credits (RFC-015): what the tags say, - // before any decision. They are stored as they are, and everything an - // identity hangs off — the album's display string, the relocation - // hint — reads them rather than what the decisions make of them, so a - // split never moves an album id. - let file_credits = with_album_artist_sorts(track_credits(input)); - let moved = apply.moved; - let track_id = existing_id.unwrap_or_else(Uuid::new_v4); - // A track that already exists may carry corrections, and a scan must - // not undo them. Only its own `artist` credits are displaced: a - // composer or a conductor comes from the file and nobody corrected it. - let overrides = track_override_lists(tx, track_id).await?; - // The library's decisions, read by the caller inside this transaction. - let credits = crate::credit_split::resolve( - &file_credits, - decisions, - overrides.artists.as_deref(), - |name| pid.artist_id(library_id, name.trim()), - ); - // The display strings follow the correction too, or a track would read - // one way in a listing and another in its own row. - // An empty correction says the track credits nobody, which is absence - // rather than an empty string: `Some("")` would render as a blank name - // where `None` renders as none at all. - let artist_display = display_of(overrides.artists.as_deref(), input.artist.as_deref()); - let genre_display = display_of(overrides.genres.as_deref(), input.genre.as_deref()); - let artwork_hash = upsert_artwork(tx, input.artwork.as_ref(), now).await?; - // The credits arrive already split, already paired with their sort - // forms and already carrying the album-artist fallback — that is the - // tag mapper's whole job, and it applies the reference's rules rather - // than this file's old `;`-only cut. What is left here is turning - // names into rows. - let artist_names: Vec = credit_names(&credits, crate::tags::Role::Artist); - let mut artist_ids = Vec::with_capacity(artist_names.len()); - for artist in &artist_names { - artist_ids.push(upsert_artist(tx, pid, library_id, artist, now).await?); - } - let album_artist_names: Vec = - credit_names(&credits, crate::tags::Role::AlbumArtist); - // The first step's names, not the decisions': the display string is - // what the album's identity hashes (RFC-015 decision 4). - let album_artist = album_artist_display(input, &file_credits); - let mut album_artist_ids = Vec::with_capacity(album_artist_names.len()); - for name in &album_artist_names { - album_artist_ids.push(upsert_artist(tx, pid, library_id, name, now).await?); - } - // The album still names one artist in the frozen `artistId` field, and - // that is the first credit. The rest reach clients through the album's - // participants rather than through this column. - let album_artist_id = album_artist_ids.first().copied(); - let album = upsert_album( - tx, - pid, - library_id, - input, - album_artist.as_deref(), - album_artist_id, - artwork_hash.as_deref(), - now, - ) - .await?; - let album_id = album.map(|(id, _)| id); - // RFC-007 left open whether an album needs its own event or whether a - // client can derive one from the tracks. It cannot, and the desktop - // named the case: its incremental mirror skips an album whose - // `song_count` has not moved, so an album that was merely *retagged* — - // a release date learned, a cover found, an album artist corrected — - // is invisible to that walk. The only way to catch it without an event - // is to refetch every album, which is exactly the saving the walk - // exists for. - // - // Emitted only when the row was really written, so a twelve-track album - // produces one event and not twelve. It carries the origin device of - // the track that caused it, for the same reason the track's own event - // does: the client that just wrote must not read its own change back as - // a discovery. - if let Some((id, _)) = album.filter(|(_, changed)| *changed) { - record_library_event( - tx, - LibraryChange { - library_id, - entity_type: "album", - entity_id: id, - action: "upsert", - // Nothing to carry. The track's event carries `full_hash` - // because no other surface exposes it; everything about an - // album is on the album, which the client refetches on - // seeing this. - payload: serde_json::json!({}), - changed_at: now, - origin_device_id: apply.origin_device_id, - }, - ) - .await?; - } - let genres = overrides - .genres - .clone() - .unwrap_or_else(|| split_values(genre_display.as_deref())); - let mut genre_ids = Vec::with_capacity(genres.len()); - for genre in &genres { - genre_ids.push(upsert_genre(tx, library_id, genre, now).await?); - } - - sqlx::query( - "INSERT INTO track (id, library_id, album_id, artwork_hash, relative_path, file_size, \ - file_modified_at, quick_hash, full_hash, title, album_title, artist_display, genre_display, \ - year, track_number, disc_number, duration_ms, bitrate, sample_rate, channels, bit_depth, \ - codec, musical_key, tag_rating, musicbrainz_recording_id, musicbrainz_release_id, \ - musicbrainz_artist_id, replay_gain_track_gain, replay_gain_track_peak, \ - replay_gain_album_gain, replay_gain_album_peak, bpm, sort_title, sort_album, \ - comment, isrc, \ - moods, explicit_status, \ - lyrics_hash, disc_subtitle, pid, is_available, last_seen_scan_id, \ - created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \ - ?, ?, 1, ?, ?, ?) \ - ON CONFLICT (id) DO UPDATE SET album_id=excluded.album_id, artwork_hash=excluded.artwork_hash, \ - relative_path=excluded.relative_path, file_size=excluded.file_size, \ - file_modified_at=excluded.file_modified_at, quick_hash=excluded.quick_hash, \ - full_hash=excluded.full_hash, title=excluded.title, album_title=excluded.album_title, \ - artist_display=excluded.artist_display, genre_display=excluded.genre_display, year=excluded.year, \ - track_number=excluded.track_number, disc_number=excluded.disc_number, duration_ms=excluded.duration_ms, \ - bitrate=excluded.bitrate, sample_rate=excluded.sample_rate, channels=excluded.channels, \ - bit_depth=excluded.bit_depth, codec=excluded.codec, musical_key=excluded.musical_key, \ - tag_rating=excluded.tag_rating, \ - musicbrainz_recording_id=excluded.musicbrainz_recording_id, \ - musicbrainz_release_id=excluded.musicbrainz_release_id, \ - musicbrainz_artist_id=excluded.musicbrainz_artist_id, \ - replay_gain_track_gain=excluded.replay_gain_track_gain, \ - replay_gain_track_peak=excluded.replay_gain_track_peak, \ - replay_gain_album_gain=excluded.replay_gain_album_gain, \ - replay_gain_album_peak=excluded.replay_gain_album_peak, \ - bpm=excluded.bpm, sort_title=excluded.sort_title, sort_album=excluded.sort_album, \ - comment=excluded.comment, \ - isrc=excluded.isrc, moods=excluded.moods, \ - explicit_status=excluded.explicit_status, \ - lyrics_hash=excluded.lyrics_hash, disc_subtitle=excluded.disc_subtitle, \ - pid=excluded.pid, \ - is_available=1, unavailable_since=NULL, purged_at=NULL, \ - last_seen_scan_id=excluded.last_seen_scan_id, \ - updated_at=excluded.updated_at", - ) - .bind(track_id.to_string()).bind(library_id.to_string()) - .bind(album_id.map(|id| id.to_string())).bind(artwork_hash.as_deref()) - .bind(&input.relative_path).bind(input.file_size).bind(input.modified_at) - .bind(&input.quick_hash).bind(&input.full_hash).bind(&input.title) - .bind(input.album.as_deref()).bind(artist_display.as_deref()).bind(genre_display.as_deref()) - .bind(input.year).bind(input.track_number).bind(input.disc_number).bind(input.duration_ms) - .bind(input.bitrate).bind(input.sample_rate).bind(input.channels).bind(input.bit_depth) - .bind(input.codec.as_deref()).bind(input.musical_key.as_deref()).bind(input.tag_rating) - .bind(input.musicbrainz_recording_id.as_deref()) - .bind(input.musicbrainz_release_id.as_deref()) - .bind(input.musicbrainz_artist_id.as_deref()) - .bind(input.replay_gain_track_gain).bind(input.replay_gain_track_peak) - .bind(input.replay_gain_album_gain).bind(input.replay_gain_album_peak) - .bind(input.bpm).bind(input.sort_title.as_deref()).bind(input.sort_album.as_deref()) - .bind(input.comment.as_deref()) - .bind(input.isrc.as_deref()) - .bind(input.moods.as_deref()).bind(input.explicit_status.as_deref()) - .bind(&input.lyrics_hash) - .bind(input.disc_subtitle.as_deref()) - .bind(track_pid(pid, library_id, input).to_string()) - .bind(scan_id.map(|id| id.to_string())).bind(now).bind(now) - .execute(&mut **tx).await?; - - sqlx::query("DELETE FROM track_lyrics WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - for (position, lyrics) in input.lyrics.iter().enumerate() { - sqlx::query( - "INSERT INTO track_lyrics \ - (track_id, library_id, position, source, lang, synced, content) \ - VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(track_id.to_string()) - .bind(library_id.to_string()) - .bind(position as i64) - .bind(lyrics.source) - .bind(&lyrics.lang) - .bind(i64::from(lyrics.synced)) - .bind(&lyrics.content) - .execute(&mut **tx) - .await?; - } - - write_track_credits(tx, library_id, track_id, &file_credits).await?; - // Every credit the file names, not just its artists. The delete comes - // first for the reason the reference gives: an artist who was both an - // album artist and a composer, and is now only a composer, has to lose - // the row that said otherwise. - sqlx::query("DELETE FROM track_participant WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - for credit in &credits { - let artist_id = match credit.role { - crate::tags::Role::Artist => artist_ids - .get(credit.position) - .copied() - .expect("every artist credit was upserted above"), - crate::tags::Role::AlbumArtist => album_artist_ids - .get(credit.position) - .copied() - .expect("every album-artist credit was upserted above"), - _ => upsert_artist(tx, pid, library_id, &credit.name, now).await?, - }; - let sort_name = credit.sort_name.clone(); - // A file can credit the same artist twice under one role — two - // spellings folding onto one row — and the second insert would - // then collide on the key. The first credit wins, as it does in - // tag order. - sqlx::query( - "INSERT INTO track_participant \ - (track_id, artist_id, library_id, role, sub_role, position, sort_name) \ - VALUES (?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT (track_id, artist_id, role, sub_role) DO NOTHING", - ) - .bind(track_id.to_string()) - .bind(artist_id.to_string()) - .bind(library_id.to_string()) - .bind(credit.role.as_str()) - .bind(&credit.sub_role) - .bind(credit.position as i64) - .bind(sort_name) - .execute(&mut **tx) - .await?; - } - sqlx::query("DELETE FROM track_genre WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - for genre_id in &genre_ids { - sqlx::query( - "INSERT INTO track_genre (track_id, genre_id, library_id) VALUES (?, ?, ?)", - ) - .bind(track_id.to_string()) - .bind(genre_id.to_string()) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - } - sqlx::query("DELETE FROM track_fts WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - // Every participant's name, not just the track artist's. The artist - // index lists only artists an album is credited to, and the argument - // for that is that a composer stays reachable by search — which is - // only true if the search can see the composer's name. - let searchable = { - let mut names: Vec<&str> = credits.iter().map(|credit| credit.name.as_str()).collect(); - names.sort_unstable(); - names.dedup(); - // The effective display string, not the file's. Pushing - // `input.artist` here would leave a corrected track findable under - // the very name it was corrected away from — the same failure the - // title had, one column over. - if let Some(display) = artist_display.as_deref() { - names.push(display); - } - names.join(" ") - }; - sqlx::query("INSERT INTO track_fts (track_id, library_id, title, album, artists, genres) VALUES (?, ?, ?, ?, ?, ?)") - .bind(track_id.to_string()).bind(library_id.to_string()) - .bind(overrides.title.as_deref().unwrap_or(input.title.as_str())) - .bind(input.album.as_deref()).bind(&searchable).bind(genre_display.as_deref()) - .execute(&mut **tx).await?; - // The hash travels with the event because nothing else carries it. A - // client that confirmed a link against this track has no other way to - // learn the file was retagged outside the API — the track keeps its id - // while its bytes move, and the scan is the only witness. - record_library_event( - tx, - LibraryChange { - library_id, - entity_type: "track", - entity_id: track_id, - action: "upsert", - payload: serde_json::json!({ "full_hash": input.full_hash }), - changed_at: now, - origin_device_id: apply.origin_device_id, - }, - ) - .await?; - Ok(if existing_id.is_none() { - ApplyOutcome::Added - } else if moved { - ApplyOutcome::Moved - } else { - ApplyOutcome::Updated - }) - } - - pub async fn mark_unseen_unavailable( - &self, - library_id: Uuid, - scan_id: Uuid, - ) -> Result { - self.mark_unseen_unavailable_within(library_id, scan_id, None) - .await - } - - /// [`Self::mark_unseen_unavailable`], for a scan that walked only - /// `scope`: a track outside the folders it walked was not looked for, so - /// it cannot have been missed (RFC-018 decision 3, which a scoped scan - /// keeps). - pub async fn mark_unseen_unavailable_within( - &self, - library_id: Uuid, - scan_id: Uuid, - scope: Option<&[String]>, - ) -> Result { - let scope = scope - .map(|folders| serde_json::to_string(folders).expect("a list of strings serializes")); - let _writer = self.writer_guard().await; - let now = now_ms(); - let mut tx = self.begin_write(&_writer).await?; - // `RETURNING` rather than a count: the feed needs to name what - // disappeared, and a number cannot. - // Two ways a track can be one this scan did not find, and they are not - // the same fact. - // - // A track another scan stamped is the ordinary case: the walk covered - // the library and this one was not in it, so its file is gone. - // - // A track no scan ever stamped is the other, and it now has a meaning — - // `apply_catalog_track_in_transaction` writes NULL for a row that did - // not come from a scan, which is what a received file is. Sweeping it - // for not having been walked over would mark a file that is on disk as - // gone, and announce a deletion to every client seconds after - // announcing its arrival: a scan running when the file lands started - // its walk before the file existed. - // - // So an unstamped track is only swept when it predates this scan, which - // is the case where the walk genuinely should have found it. The first - // scan to walk past a received file stamps it, and it rejoins the - // ordinary case for good. - let gone: Vec = sqlx::query_scalar( - "UPDATE track SET is_available = 0, unavailable_since = ?, updated_at = ? \ - WHERE library_id = ? AND is_available = 1 \ - AND ((last_seen_scan_id IS NOT NULL AND last_seen_scan_id <> ?) \ - OR (last_seen_scan_id IS NULL \ - AND created_at < COALESCE( \ - (SELECT started_at FROM scan_job WHERE id = ?), ?))) \ - AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(?) folder \ - WHERE folder.value = '' OR track.relative_path = folder.value \ - OR substr(track.relative_path, 1, length(folder.value) + 1) \ - = folder.value || '/')) \ - RETURNING id", - ) - .bind(now) - .bind(now) - .bind(library_id.to_string()) - .bind(scan_id.to_string()) - .bind(scan_id.to_string()) - .bind(now) - .bind(scope.as_deref()) - .bind(scope.as_deref()) - .fetch_all(&mut *tx) - .await?; - for id in &gone { - record_library_event( - &mut tx, - LibraryChange { - library_id, - entity_type: "track", - entity_id: parse_uuid(id.clone())?, - action: "delete", - payload: serde_json::json!({}), - changed_at: now, - // A scan noticing a file is gone is nobody's request. - origin_device_id: None, - }, - ) - .await?; - } - tx.commit().await?; - Ok(gone.len() as i64) - } - - pub async fn add_scan_error( - &self, - scan_id: Uuid, - path: &str, - message: &str, - ) -> Result<(), sqlx::Error> { - self.add_scan_errors(scan_id, &[(path.to_owned(), message.to_owned())]) - .await - } - - pub async fn add_scan_errors( - &self, - scan_id: Uuid, - errors: &[(String, String)], - ) -> Result<(), sqlx::Error> { - if errors.is_empty() { - return Ok(()); - } - let _writer = self.writer_guard().await; - let mut tx = self.begin_write(&_writer).await?; - let now = now_ms(); - for (path, message) in errors { - sqlx::query("INSERT INTO scan_error (scan_id, relative_path, message, created_at) VALUES (?, ?, ?, ?)") - .bind(scan_id.to_string()).bind(path).bind(message).bind(now) - .execute(&mut *tx).await?; - } - tx.commit().await?; - Ok(()) - } - - pub async fn list_tracks_for_user( - &self, - user_id: Uuid, - library_id: Uuid, - ) -> Result, sqlx::Error> { - fetch_tracks(self, user_id, library_id, None, 0, 500).await - } - - pub async fn search_tracks_for_user( - &self, - user_id: Uuid, - library_id: Uuid, - query: &str, - ) -> Result, sqlx::Error> { - fetch_tracks(self, user_id, library_id, Some(query), 0, 200).await - } - - pub async fn browse_tracks_for_user( - &self, - user_id: Uuid, - library_id: Uuid, - query: Option<&str>, - offset: i64, - limit: i64, - ) -> Result, sqlx::Error> { - fetch_tracks(self, user_id, library_id, query, offset, limit).await - } - - /// The file behind `track_id`, for an account that may play it. - /// - /// **The account must not be disabled** (RFC-023 decision 4). A bearer - /// token of a disabled account is refused before it gets here, but a - /// stream ticket is sealed, not looked up, and lives an hour, and a share - /// streams as its owner: this is the one read all three redeem through, so - /// it is where a disabled account stops playing. - pub async fn stream_track_for_user( - &self, - user_id: Uuid, - track_id: Uuid, - ) -> Result, sqlx::Error> { - let row = sqlx::query( - "SELECT t.id, t.library_id, l.root_path, t.relative_path, t.full_hash, \ - t.codec, t.bitrate, t.duration_ms, t.is_available, \ - t.replay_gain_track_gain, t.replay_gain_track_peak, \ - t.replay_gain_album_gain, t.replay_gain_album_peak, \ - l.follow_links, (SELECT json_group_array(o.root_path) FROM library o WHERE o.id <> l.id) AS other_roots FROM track t \ - JOIN library l ON l.id = t.library_id \ - JOIN library_member m ON m.library_id = t.library_id \ - JOIN account a ON a.id = m.user_id \ - WHERE t.id = ? AND m.user_id = ? AND a.disabled = 0", - ) - .bind(track_id.to_string()) - .bind(user_id.to_string()) - .fetch_optional(self.pool()) - .await?; - row.map(stream_track_from_row).transpose() - } -} - -/// The most characters of a search query that are read. -pub const MAX_SEARCH_QUERY_CHARS: usize = 256; - -/// The most terms of a search query that are matched. -pub const MAX_SEARCH_TERMS: usize = 16; - -/// Turns free-form user input into an FTS5 `MATCH` expression. -/// -/// Every term is quoted so punctuation cannot be read as FTS syntax, and terms -/// are ANDed so extra words narrow the result. Returns `None` when the input -/// carries no searchable term — `MATCH ''` is a SQLite error, not an empty -/// result. -/// -/// The trailing term also matches as a prefix, because search-as-you-type is -/// how clients actually query: the user has typed "ech" and expects "Echo", -/// and a bare token match returns nothing until the word is complete. Earlier -/// terms stay exact — "dark side of the m" should narrow, not widen, and -/// prefixing every token would make short words match far too much. -/// -/// **Bounded** (RFC-023 decision 5): the first [`MAX_SEARCH_QUERY_CHARS`] -/// characters, and of those the first [`MAX_SEARCH_TERMS`] terms. Each term is -/// one more phrase FTS5 intersects, for each of the three kinds a search -/// answers, and nothing bounded either but the request body — some seven -/// hundred kilobytes through a Subsonic form. Past the bounds the query is -/// narrowed, not refused, so no client is answered an error it never had: a -/// search that long names what it wants well before its end. -pub(crate) fn fts_prefix_query(query: &str) -> Option { - let query = match query.char_indices().nth(MAX_SEARCH_QUERY_CHARS) { - Some((end, _)) => &query[..end], - None => query, - }; - let mut terms = query - .split_whitespace() - .filter(|term| !term.is_empty()) - .take(MAX_SEARCH_TERMS) - .peekable(); - let mut expression = String::new(); - while let Some(term) = terms.next() { - if !expression.is_empty() { - expression.push_str(" AND "); - } - expression.push('"'); - expression.push_str(&term.replace('"', "\"\"")); - expression.push('"'); - if terms.peek().is_none() { - expression.push('*'); - } - } - (!expression.is_empty()).then_some(expression) -} - -async fn fetch_tracks( - db: &Database, - user: Uuid, - library: Uuid, - query: Option<&str>, - offset: i64, - limit: i64, -) -> Result, sqlx::Error> { - // Same prefix behaviour as /api/v2/search: this is the same user gesture, - // typed into a library rather than the whole catalogue. - let rows = if let Some(fts_query) = query.and_then(fts_prefix_query) { - sqlx::query("SELECT t.id, t.library_id, t.relative_path, t.title, t.album_title, t.artist_display, \ - (SELECT tp.artist_id FROM track_participant tp \ - WHERE tp.track_id=t.id AND tp.role='artist' \ - ORDER BY tp.position LIMIT 1) AS artist_id, \ - t.genre_display, t.duration_ms, t.codec, t.artwork_hash, t.full_hash, t.is_available FROM track t \ - JOIN library_member m ON m.library_id=t.library_id JOIN track_fts f ON f.track_id=t.id \ - WHERE m.user_id=? AND t.library_id=? AND track_fts MATCH ? ORDER BY rank, t.id LIMIT ? OFFSET ?") - .bind(user.to_string()).bind(library.to_string()).bind(fts_query).bind(limit).bind(offset).fetch_all(db.pool()).await? - } else { - sqlx::query("SELECT t.id, t.library_id, t.relative_path, t.title, t.album_title, t.artist_display, \ - (SELECT tp.artist_id FROM track_participant tp \ - WHERE tp.track_id=t.id AND tp.role='artist' \ - ORDER BY tp.position LIMIT 1) AS artist_id, \ - t.genre_display, t.duration_ms, t.codec, t.artwork_hash, t.full_hash, t.is_available FROM track t \ - JOIN library_member m ON m.library_id=t.library_id WHERE m.user_id=? AND t.library_id=? \ - ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?") - .bind(user.to_string()).bind(library.to_string()).bind(limit).bind(offset).fetch_all(db.pool()).await? - }; - rows.into_iter().map(track_from_row).collect() -} - -async fn upsert_artwork( - tx: &mut Transaction<'_, Sqlite>, - artwork: Option<&ArtworkInput>, - now: i64, -) -> Result, sqlx::Error> { - let Some(artwork) = artwork else { - return Ok(None); - }; - sqlx::query("INSERT INTO artwork (hash, format, source, byte_size, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (hash) DO NOTHING") - .bind(&artwork.hash).bind(&artwork.format).bind(&artwork.source).bind(artwork.byte_size).bind(now) - .execute(&mut **tx).await?; - Ok(Some(artwork.hash.clone())) -} - -/// A scanned file, seen through the identity engine. -/// -/// The album artist is the resolved display string rather than the raw tag, -/// because that is what the reference hashes: the fallback to `Various -/// Artists` or to the track's own artists is part of what makes two files the -/// same album. -/// -/// The disc and track numbers are rendered once here rather than looked up as -/// tags, and they matter: without them the default track spec would identify -/// two tracks of one album by title alone, and an album holding two takes of -/// the same song would lose one. -/// -/// A spec naming a tag the scanner does not read resolves empty, and an empty -/// attribute simply lets the next group answer. That is the fall-through the -/// grammar is built around, not a gap. -struct TrackIdentity<'a> { - input: &'a CatalogTrackInput, - album_artist: &'a str, - folder: &'a str, - disc_number: String, - track_number: String, -} - -impl<'a> TrackIdentity<'a> { - fn new(input: &'a CatalogTrackInput, album_artist: &'a str) -> Self { - Self { - input, - album_artist, - folder: input - .relative_path - .rsplit_once(['/', '\\']) - .map(|(folder, _)| folder) - .unwrap_or(""), - disc_number: input.disc_number.map(|v| v.to_string()).unwrap_or_default(), - track_number: input - .track_number - .map(|v| v.to_string()) - .unwrap_or_default(), - } - } -} - -/// The credits a file's tags imply. -/// -/// Derived here rather than by the scanner because this is where identity is -/// written — and now also where the relocation hint is, which has to hash the -/// same values the row is built from. -fn track_credits(input: &CatalogTrackInput) -> Vec { - crate::tags::credits(&crate::tags::RawCredits { - artist: input.artist.iter().cloned().collect(), - artists: input.artists.clone(), - album_artist: input.album_artist.iter().cloned().collect(), - album_artists: input.album_artists.clone(), - sort_artist: input.sort_artist.clone(), - sort_album_artist: input.sort_album_artist.clone(), - is_compilation: input.is_compilation, - roles: input.roles.clone(), - performer_pairs: input.performer_pairs.clone(), - }) -} - -/// The album keeps the joined string the file wrote as its display name, and -/// hangs off the first credited artist. -fn album_artist_display( - input: &CatalogTrackInput, - credits: &[crate::tags::Credit], -) -> Option { - let names = credit_names(credits, crate::tags::Role::AlbumArtist); - input - .album_artist - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(str::to_owned) - .or_else(|| (names.len() == 1).then(|| names[0].clone())) - .or_else(|| (!names.is_empty()).then(|| names.join("; "))) -} - -/// The relocation hint for one file: the track spec evaluated over its tags. -/// -/// Never an identity. Track ids stay drawn at random — six tables cascade off -/// them, so moving one would delete play history rather than orphan it. This -/// only tells a scan that the file it is looking at is probably the one whose -/// path disappeared, when neither the path nor the content hash can say so. -pub fn track_pid( - specs: &crate::pid::PidSpecs, - library_id: Uuid, - input: &CatalogTrackInput, -) -> Uuid { - let credits = track_credits(input); - let album_artist = album_artist_display(input, &credits).unwrap_or_default(); - specs.track_id(library_id, &TrackIdentity::new(input, &album_artist)) -} - -impl crate::pid::PidSource for TrackIdentity<'_> { - fn tag(&self, name: &str) -> Option<&str> { - let value = match name { - "musicbrainz_albumid" => self.input.musicbrainz_release_id.as_deref(), - "musicbrainz_trackid" => self.input.musicbrainz_recording_id.as_deref(), - "musicbrainz_artistid" => self.input.musicbrainz_artist_id.as_deref(), - "albumartist" => self.input.album_artist.as_deref(), - "artist" => self.input.artist.as_deref(), - "genre" => self.input.genre.as_deref(), - "discnumber" => Some(self.disc_number.as_str()), - "tracknumber" => Some(self.track_number.as_str()), - "isrc" => self.input.isrc.as_deref(), - _ => None, - }; - value.filter(|value| !value.is_empty()) - } - - fn folder(&self) -> &str { - self.folder - } - - fn album_artist_display(&self) -> &str { - self.album_artist - } - - fn title(&self) -> &str { - &self.input.title - } - - fn album(&self) -> &str { - self.input.album.as_deref().unwrap_or_default() - } -} - -/// Upserts one artist row, whose id is the identity of its name. -/// -/// The name reaches the row twice: as written, which is what a client renders, -/// and folded through `canonical_name`, which is what search matches on. The -/// id is neither — it is the identity hash, so two spellings that differ by a -/// real character are two artists, as they are in the reference. -async fn upsert_artist( - tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, - library: Uuid, - name: &str, - now: i64, -) -> Result { - let name = name.trim(); - let id = pid.artist_id(library, name); - let canonical = waveflow_core::scanner::canonical_name(name); - sqlx::query( - "INSERT INTO artist (id, library_id, name, canonical_name, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?) \ - ON CONFLICT (id) DO UPDATE SET name=excluded.name, \ - canonical_name=excluded.canonical_name, updated_at=excluded.updated_at", - ) - .bind(id.to_string()) - .bind(library.to_string()) - .bind(name) - .bind(canonical) - .bind(now) - .bind(now) - .execute(&mut **tx) - .await?; - Ok(id) -} - -async fn upsert_genre( - tx: &mut Transaction<'_, Sqlite>, - library: Uuid, - name: &str, - now: i64, -) -> Result { - let id = Uuid::new_v4(); - let canonical = waveflow_core::scanner::canonical_name(name); - let value: String = sqlx::query_scalar("INSERT INTO genre (id, library_id, name, canonical_name, created_at) VALUES (?, ?, ?, ?, ?) \ - ON CONFLICT (library_id, canonical_name) DO UPDATE SET name=excluded.name RETURNING id") - .bind(id.to_string()).bind(library.to_string()).bind(name.trim()).bind(canonical).bind(now) - .fetch_one(&mut **tx).await?; - parse_uuid(value) -} - -#[allow(clippy::too_many_arguments)] -/// Upserts one album row, whose id is the identity of the tags that name it. -/// -/// The identity key this replaces embedded the row id of the album's first -/// credited artist, which is what made "Live" by `A; B` and "Live" by `A; C` -/// answer the same key — and what needed a special case to tell them apart. -/// The engine hashes the credit as written, so the two are simply different -/// and nothing has to notice. -async fn upsert_album( - tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, - library: Uuid, - input: &CatalogTrackInput, - album_artist: Option<&str>, - album_artist_id: Option, - artwork: Option<&str>, - now: i64, -) -> Result, sqlx::Error> { - let Some(title) = input.album.as_deref().filter(|s| !s.trim().is_empty()) else { - return Ok(None); - }; - let canonical = waveflow_core::scanner::canonical_name(title); - let id = pid.album_id( - library, - &TrackIdentity::new(input, album_artist.unwrap_or_default()), - ); - let written = sqlx::query( - "INSERT INTO album (id, library_id, title, canonical_title, album_artist_id, \ - album_artist_name, is_compilation, year, artwork_hash, \ - original_release_date, release_date, release_types, record_labels, \ - created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ - ON CONFLICT (id) DO UPDATE SET title=excluded.title, \ - canonical_title=excluded.canonical_title, \ - album_artist_id=excluded.album_artist_id, \ - album_artist_name=excluded.album_artist_name, \ - is_compilation=excluded.is_compilation, \ - year=COALESCE(excluded.year, album.year), \ - artwork_hash=COALESCE(excluded.artwork_hash, album.artwork_hash), \ - original_release_date=COALESCE(excluded.original_release_date, album.original_release_date), \ - release_date=COALESCE(excluded.release_date, album.release_date), \ - release_types=COALESCE(excluded.release_types, album.release_types), \ - record_labels=COALESCE(excluded.record_labels, album.record_labels), \ - updated_at=excluded.updated_at \ - WHERE album.title IS NOT excluded.title \ - OR album.canonical_title IS NOT excluded.canonical_title \ - OR album.album_artist_id IS NOT excluded.album_artist_id \ - OR album.album_artist_name IS NOT excluded.album_artist_name \ - OR album.is_compilation IS NOT excluded.is_compilation \ - OR album.year IS NOT COALESCE(excluded.year, album.year) \ - OR album.artwork_hash IS NOT COALESCE(excluded.artwork_hash, album.artwork_hash) \ - OR album.original_release_date \ - IS NOT COALESCE(excluded.original_release_date, album.original_release_date) \ - OR album.release_date IS NOT COALESCE(excluded.release_date, album.release_date) \ - OR album.release_types IS NOT COALESCE(excluded.release_types, album.release_types) \ - OR album.record_labels IS NOT COALESCE(excluded.record_labels, album.record_labels) \ - RETURNING id", - ) - .bind(id.to_string()) - .bind(library.to_string()) - .bind(title.trim()) - .bind(canonical) - .bind(album_artist_id.map(|id| id.to_string())) - .bind(album_artist) - .bind(i64::from(input.is_compilation)) - .bind(input.year) - .bind(artwork) - .bind(input.original_release_date.as_deref()) - .bind(input.release_date.as_deref()) - .bind(input.release_types.as_deref()) - .bind(input.record_labels.as_deref()) - .bind(now) - .bind(now) - .fetch_optional(&mut **tx) - .await?; - // A row came back only if one was written: inserted, or updated because the - // `WHERE` above found a difference. Twelve tracks of one album run this - // twelve times and the first is the only one that writes, which is what - // makes the event below one per album rather than one per track. - Ok(Some((id, written.is_some()))) -} - -fn split_values(raw: Option<&str>) -> Vec { - raw.into_iter() - .flat_map(|value| value.split(';')) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - .collect() -} - -/// The namespace a remap stages through. A UUID holds no colon, so a staged -/// value cannot be mistaken for an identifier and no identifier for it. -const REMAP_STAGE: &str = "pid-remap:"; - -/// Moves one artist identifier onto another, in both tables that hold one. -/// -/// Spelled out per table rather than looped: sqlx takes static SQL only, which -/// is what keeps every query in this crate injection-proof by construction. -async fn move_artist_user_data( - tx: &mut Transaction<'_, Sqlite>, - from: &str, - to: &str, -) -> Result { - let mut moved = sqlx::query( - "UPDATE OR IGNORE user_star SET entity_id = ? \ - WHERE entity_type = 'artist' AND entity_id = ?", - ) - .bind(to) - .bind(from) - .execute(&mut **tx) - .await? - .rows_affected(); - sqlx::query("DELETE FROM user_star WHERE entity_type = 'artist' AND entity_id = ?") - .bind(from) - .execute(&mut **tx) - .await?; - moved += sqlx::query( - "UPDATE OR IGNORE user_rating SET entity_id = ? \ - WHERE entity_type = 'artist' AND entity_id = ?", - ) - .bind(to) - .bind(from) - .execute(&mut **tx) - .await? - .rows_affected(); - sqlx::query("DELETE FROM user_rating WHERE entity_type = 'artist' AND entity_id = ?") - .bind(from) - .execute(&mut **tx) - .await?; - Ok(moved) -} - -/// The display string a correction implies, or the file's when there is none. -/// -/// An empty list is a correction meaning "nobody", and joins to nothing — which -/// has to become absence rather than an empty string, or a track answers with a -/// blank name instead of with no name. -pub(crate) fn display_of(corrected: Option<&[String]>, scanned: Option<&str>) -> Option { - match corrected { - None => scanned.map(str::to_owned), - Some([]) => None, - Some(names) => Some(names.join("; ")), - } -} - -/// The corrections a scan has to respect while re-deriving a track. -/// -/// The title is here as well as the two lists, and not because the track row -/// needs it — the projection reads the correction over that row. The full-text -/// index does not have a projection: it is a copy, rebuilt from the file by -/// every full scan, so a correction that never reached it would leave the track -/// findable only under the name it was corrected away from. -#[derive(Debug, Clone, Default)] -pub(crate) struct TrackOverrideLists { - pub title: Option, - pub artists: Option>, - pub genres: Option>, -} - -impl TrackOverrideLists { - fn decode(raw: Option) -> Result>, sqlx::Error> { - raw.map(|value| { - serde_json::from_str::>(&value) - .map_err(|error| sqlx::Error::Decode(Box::new(error))) - }) - .transpose() - } -} - -/// Reads a track's list corrections, if it carries any. -pub(crate) async fn track_override_lists( - tx: &mut Transaction<'_, Sqlite>, - track_id: Uuid, -) -> Result { - let row = sqlx::query("SELECT title, artists, genres FROM track_override WHERE track_id = ?") - .bind(track_id.to_string()) - .fetch_optional(&mut **tx) - .await?; - let Some(row) = row else { - return Ok(TrackOverrideLists::default()); - }; - Ok(TrackOverrideLists { - title: row.try_get("title")?, - artists: TrackOverrideLists::decode(row.try_get("artists")?)?, - genres: TrackOverrideLists::decode(row.try_get("genres")?)?, - }) -} - -/// Writes the rows an explicit list of artists or genres implies. -/// -/// Deliberately not the tag mapper's path. That exists to guess where one name -/// ends in a string a tagger wrote however it liked; a correction arrives -/// already separated, so parsing it again would only reintroduce the ambiguity -/// it was made to settle. -/// -/// Only the `artist` role is rewritten. A composer or a conductor comes from -/// the file and is untouched by someone correcting who performed the track, -/// which is why the delete is narrowed rather than wholesale. -pub(crate) async fn apply_track_override_lists( - tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, - library_id: Uuid, - track_id: Uuid, - lists: &TrackOverrideLists, - now: i64, -) -> Result<(), sqlx::Error> { - if let Some(names) = &lists.artists { - sqlx::query("DELETE FROM track_participant WHERE track_id = ? AND role = 'artist'") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - for (position, name) in names.iter().enumerate() { - let artist_id = upsert_artist(tx, pid, library_id, name, now).await?; - sqlx::query( - "INSERT INTO track_participant \ - (track_id, artist_id, library_id, role, sub_role, position, sort_name) \ - VALUES (?, ?, ?, 'artist', '', ?, NULL) \ - ON CONFLICT (track_id, artist_id, role, sub_role) DO NOTHING", - ) - .bind(track_id.to_string()) - .bind(artist_id.to_string()) - .bind(library_id.to_string()) - .bind(position as i64) - .execute(&mut **tx) - .await?; - } - } - if let Some(names) = &lists.genres { - sqlx::query("DELETE FROM track_genre WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - for name in names { - let genre_id = upsert_genre(tx, library_id, name, now).await?; - sqlx::query( - "INSERT INTO track_genre (track_id, genre_id, library_id) VALUES (?, ?, ?)", - ) - .bind(track_id.to_string()) - .bind(genre_id.to_string()) - .bind(library_id.to_string()) - .execute(&mut **tx) - .await?; - } - } - Ok(()) -} +mod apply; +mod availability; +mod credits; +mod derivations; +mod identity; +mod libraries; +mod overrides; +mod scan_jobs; +mod tracks; + +pub use apply::{ApplyOutcome, ArtworkInput, CatalogApply, CatalogTrackInput}; +pub use availability::SkipFacts; +pub use identity::{track_pid, ExistingTrack}; +pub use libraries::{LibraryAccess, LibraryRecord, LibraryScanSettings}; +pub use scan_jobs::ScanJobRecord; +pub use tracks::{StreamTrack, TrackRecord, MAX_SEARCH_QUERY_CHARS, MAX_SEARCH_TERMS}; + +pub(crate) use credits::{credit_decisions, recompute_track_participants, write_track_credits}; +// Nothing outside `credits` names these today, but they were reachable at +// `crate::catalog::*`, and a split that moves code does not take paths away. +#[allow(unused_imports)] +pub(crate) use credits::{read_track_credits, Recomputed}; +pub(crate) use overrides::{ + apply_track_override_lists, display_of, track_override_lists, TrackOverrideLists, +}; +pub(crate) use tracks::fts_prefix_query; /// Appends one fact to a library's change feed. /// @@ -2535,392 +90,9 @@ pub(crate) async fn record_library_event( Ok(()) } -fn library_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(LibraryRecord { - id: parse_uuid(row.try_get("id")?)?, - name: row.try_get("name")?, - root_path: PathBuf::from(row.try_get::("root_path")?), - }) -} - -fn existing_track_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(ExistingTrack { - id: parse_uuid(row.try_get("id")?)?, - relative_path: row.try_get("relative_path")?, - file_size: row.try_get("file_size")?, - modified_at: row.try_get("file_modified_at")?, - quick_hash: row.try_get("quick_hash")?, - full_hash: row.try_get("full_hash")?, - lyrics_hash: row.try_get("lyrics_hash")?, - available: row.try_get::("is_available")? != 0, - pid: row.try_get("pid")?, - }) -} - -fn stream_track_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(StreamTrack { - id: parse_uuid(row.try_get("id")?)?, - library_id: parse_uuid(row.try_get("library_id")?)?, - library_root: PathBuf::from(row.try_get::("root_path")?), - relative_path: row.try_get("relative_path")?, - full_hash: row.try_get("full_hash")?, - codec: row.try_get("codec")?, - bitrate: row.try_get("bitrate")?, - duration_ms: row.try_get("duration_ms")?, - available: row.try_get::("is_available")? != 0, - link_policy: crate::link_roots::LinkPolicy::from_columns( - row.try_get("follow_links")?, - row.try_get::, _>("other_roots")?.as_deref(), - ), - replay_gain: crate::replay_gain::ReplayGainTags { - track_gain: row.try_get("replay_gain_track_gain")?, - track_peak: row.try_get("replay_gain_track_peak")?, - album_gain: row.try_get("replay_gain_album_gain")?, - album_peak: row.try_get("replay_gain_album_peak")?, - }, - }) -} - -fn scan_job_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(ScanJobRecord { - id: parse_uuid(row.try_get("id")?)?, - library_id: parse_uuid(row.try_get("library_id")?)?, - status: row.try_get("status")?, - total_files: row.try_get("total_files")?, - processed_files: row.try_get("processed_files")?, - added: row.try_get("added")?, - updated: row.try_get("updated")?, - moved: row.try_get("moved")?, - skipped: row.try_get("skipped")?, - unavailable: row.try_get("unavailable")?, - errors: row.try_get("errors")?, - excluded: row.try_get("excluded")?, - skipped_links: row.try_get("skipped_links")?, - current_path: row.try_get("current_path")?, - message: row.try_get("message")?, - }) -} - -fn track_from_row(row: sqlx::sqlite::SqliteRow) -> Result { - Ok(TrackRecord { - id: parse_uuid(row.try_get("id")?)?, - library_id: parse_uuid(row.try_get("library_id")?)?, - relative_path: row.try_get("relative_path")?, - title: row.try_get("title")?, - album: row.try_get("album_title")?, - artist: row.try_get("artist_display")?, - artist_id: row - .try_get::, _>("artist_id")? - .map(parse_uuid) - .transpose()?, - genre: row.try_get("genre_display")?, - duration_ms: row.try_get("duration_ms")?, - codec: row.try_get("codec")?, - artwork_hash: row.try_get("artwork_hash")?, - full_hash: row.try_get("full_hash")?, - available: row.try_get::("is_available")? != 0, - }) -} - fn parse_uuid(value: String) -> Result { Uuid::from_str(&value).map_err(|error| sqlx::Error::Decode(Box::new(error))) } -/// The credits of one role, in tag order. -/// -/// The two lists derived from it are read in lockstep — a name and the sort -/// form paired with it — so they have to come from one ordering. -fn credits_in_role( - credits: &[crate::tags::Credit], - role: crate::tags::Role, -) -> Vec<&crate::tags::Credit> { - let mut named: Vec<&crate::tags::Credit> = credits - .iter() - .filter(|credit| credit.role == role) - .collect(); - named.sort_by_key(|credit| credit.position); - named -} - -/// The sort form of each artist credit *on this track*. A credit the track -/// tag says nothing about falls back to the album artist tag when the two name -/// the same artist — ALBUMARTISTSORT is the more commonly written of the two, -/// and matching on the name rather than on position means this cannot file one -/// artist under another's sort form. -/// -/// Applied to the first step's credits, before they are stored, so -/// `track_credit` holds the sort form a participant gets and a decision splits -/// the same one a scan would. -fn with_album_artist_sorts(mut credits: Vec) -> Vec { - let album: Vec<(String, Option)> = - credits_in_role(&credits, crate::tags::Role::AlbumArtist) - .iter() - .map(|credit| (credit.name.clone(), credit.sort_name.clone())) - .collect(); - for credit in credits - .iter_mut() - .filter(|credit| credit.role == crate::tags::Role::Artist && credit.sort_name.is_none()) - { - credit.sort_name = album - .iter() - .find(|(name, _)| *name == credit.name) - .and_then(|(_, sort)| sort.clone()); - } - credits -} - -/// A library's credit decisions (RFC-015), by key. -/// -/// A row whose parts no longer decode is read as no decision rather than -/// failing the scan that reads it: the one writer stores a `Vec`, and -/// a credit left whole is what the library had before anyone decided. -pub(crate) async fn credit_decisions( - tx: &mut Transaction<'_, Sqlite>, - library_id: Uuid, -) -> Result { - let rows = sqlx::query( - "SELECT credit_key, decision, parts FROM artist_credit_decision WHERE library_id = ?", - ) - .bind(library_id.to_string()) - .fetch_all(&mut **tx) - .await?; - let mut decisions = crate::credit_split::Decisions::new(); - for row in rows { - let key: String = row.try_get("credit_key")?; - let decision = match row.try_get::<&str, _>("decision")? { - "keep" => crate::credit_split::CreditDecision::Keep, - _ => { - let parts: Option = row.try_get("parts")?; - match parts.and_then(|parts| serde_json::from_str::>(&parts).ok()) { - Some(parts) => crate::credit_split::CreditDecision::Split(parts), - None => continue, - } - } - }; - decisions.insert(key, decision); - } - Ok(decisions) -} - -/// Stores a track's first-step credits, replacing what it held. -pub(crate) async fn write_track_credits( - tx: &mut Transaction<'_, Sqlite>, - library_id: Uuid, - track_id: Uuid, - credits: &[crate::tags::Credit], -) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM track_credit WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - for credit in credits { - // `OR IGNORE`: a position is unique per role by construction, and the - // one thing that could repeat it — two groups of one role — the tag - // mapper already merges. Failing a whole scan batch on it would cost - // more than the duplicate credit it would protect. - sqlx::query( - "INSERT OR IGNORE INTO track_credit (track_id, library_id, role, sub_role, position, name, sort_name) VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(track_id.to_string()) - .bind(library_id.to_string()) - .bind(credit.role.as_str()) - .bind(&credit.sub_role) - .bind(credit.position as i64) - .bind(credit.name.trim()) - .bind(credit.sort_name.as_deref()) - .execute(&mut **tx) - .await?; - } - Ok(()) -} - -/// A track's stored first-step credits, in role and position order. -pub(crate) async fn read_track_credits( - tx: &mut Transaction<'_, Sqlite>, - track_id: Uuid, -) -> Result, sqlx::Error> { - let rows = sqlx::query( - "SELECT role, sub_role, position, name, sort_name FROM track_credit WHERE track_id = ? ORDER BY role, position", - ) - .bind(track_id.to_string()) - .fetch_all(&mut **tx) - .await?; - let mut credits = Vec::with_capacity(rows.len()); - for row in rows { - // A role this binary does not know was written by a newer one; it is - // left out rather than guessed, as `track_participant` leaves it. - let Some(role) = crate::tags::Role::parse(row.try_get::<&str, _>("role")?) else { - continue; - }; - credits.push(crate::tags::Credit { - role, - sub_role: row.try_get("sub_role")?, - name: row.try_get("name")?, - sort_name: row.try_get("sort_name")?, - position: row.try_get::("position")?.max(0) as usize, - }); - } - Ok(credits) -} - -/// What recomputing one track's participants did. -#[derive(Debug, Clone)] -pub(crate) struct Recomputed { - pub changed: bool, - pub album_id: Option, - pub full_hash: String, -} - -/// Recomputes one track's participants from its stored credits and the -/// library's decisions (RFC-015 decision 5), with no file read. -/// -/// The same function a scan calls, over the same inputs, so a decision taken -/// now and a rescan later end in identical rows. Nothing is written when the -/// result is what the track already holds: a pass over a track the decision -/// does not change touches no row, records no event and leaves no artist -/// behind. -pub(crate) async fn recompute_track_participants( - tx: &mut Transaction<'_, Sqlite>, - pid: &crate::pid::PidSpecs, - library_id: Uuid, - track_id: Uuid, - decisions: &crate::credit_split::Decisions, - now: i64, -) -> Result { - let row = sqlx::query("SELECT album_id, full_hash, artist_display FROM track WHERE id = ?") - .bind(track_id.to_string()) - .fetch_one(&mut **tx) - .await?; - let album_id = row - .try_get::, _>("album_id")? - .map(parse_uuid) - .transpose()?; - let full_hash: String = row.try_get("full_hash")?; - let artist_display: Option = row.try_get("artist_display")?; - - let file_credits = read_track_credits(tx, track_id).await?; - let overrides = track_override_lists(tx, track_id).await?; - let credits = crate::credit_split::resolve( - &file_credits, - decisions, - overrides.artists.as_deref(), - |name| pid.artist_id(library_id, name.trim()), - ); - let mut desired: Vec<(String, String, String, i64, Option)> = credits - .iter() - .map(|credit| { - ( - credit.role.as_str().to_owned(), - pid.artist_id(library_id, credit.name.trim()).to_string(), - credit.sub_role.clone(), - credit.position as i64, - credit.sort_name.clone(), - ) - }) - .collect(); - desired.sort_by(|left, right| (&left.0, left.3).cmp(&(&right.0, right.3))); - let current: Vec<(String, String, String, i64, Option)> = sqlx::query_as( - "SELECT role, artist_id, sub_role, position, sort_name FROM track_participant WHERE track_id = ? ORDER BY role, position", - ) - .bind(track_id.to_string()) - .fetch_all(&mut **tx) - .await?; - if current == desired { - return Ok(Recomputed { - changed: false, - album_id, - full_hash, - }); - } - - sqlx::query("DELETE FROM track_participant WHERE track_id = ?") - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - let mut first_album_artist = None; - for credit in &credits { - let artist_id = upsert_artist(tx, pid, library_id, &credit.name, now).await?; - if credit.role == crate::tags::Role::AlbumArtist && first_album_artist.is_none() { - first_album_artist = Some(artist_id); - } - sqlx::query( - "INSERT INTO track_participant (track_id, artist_id, library_id, role, sub_role, position, sort_name) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (track_id, artist_id, role, sub_role) DO NOTHING", - ) - .bind(track_id.to_string()) - .bind(artist_id.to_string()) - .bind(library_id.to_string()) - .bind(credit.role.as_str()) - .bind(&credit.sub_role) - .bind(credit.position as i64) - .bind(credit.sort_name.as_deref()) - .execute(&mut **tx) - .await?; - } - // The album hangs off its first credited artist, as the scan's upsert - // writes it; the display string, and with it the album's id, stay what - // the file said. - if let (Some(album), Some(artist)) = (album_id, first_album_artist) { - sqlx::query("UPDATE album SET album_artist_id = ?, updated_at = ? WHERE id = ?") - .bind(artist.to_string()) - .bind(now) - .bind(album.to_string()) - .execute(&mut **tx) - .await?; - } - // The search index holds every credited name, as the scan writes it, so - // a split track is found under each part and no longer under the whole. - let mut names: Vec<&str> = credits.iter().map(|credit| credit.name.as_str()).collect(); - names.sort_unstable(); - names.dedup(); - if let Some(display) = artist_display.as_deref() { - names.push(display); - } - sqlx::query("UPDATE track_fts SET artists = ? WHERE track_id = ?") - .bind(names.join(" ")) - .bind(track_id.to_string()) - .execute(&mut **tx) - .await?; - Ok(Recomputed { - changed: true, - album_id, - full_hash, - }) -} - -fn credit_names(credits: &[crate::tags::Credit], role: crate::tags::Role) -> Vec { - credits_in_role(credits, role) - .iter() - .map(|credit| credit.name.clone()) - .collect() -} - #[cfg(test)] -mod tests { - use super::{fts_prefix_query, MAX_SEARCH_QUERY_CHARS, MAX_SEARCH_TERMS}; - - /// A search query is narrowed to its first terms and characters rather - /// than handed to FTS5 whole (RFC-023 decision 5), and what is kept is - /// still the ordinary expression: the last term kept is the prefix. - #[test] - fn a_search_query_is_bounded_in_terms_and_characters() { - let words: Vec = (0..40).map(|index| format!("w{index}")).collect(); - let expression = fts_prefix_query(&words.join(" ")).unwrap(); - assert_eq!(expression.matches(" AND ").count(), MAX_SEARCH_TERMS - 1); - assert!(expression.ends_with(&format!("\"w{}\"*", MAX_SEARCH_TERMS - 1))); - - // One enormous term is cut on a character boundary, accents included. - let long = "é".repeat(MAX_SEARCH_QUERY_CHARS * 4); - let expression = fts_prefix_query(&long).unwrap(); - assert_eq!( - expression.chars().count(), - MAX_SEARCH_QUERY_CHARS + 3, - "two quotes and the prefix star around the kept characters" - ); - - // An ordinary search is untouched. - assert_eq!( - fts_prefix_query("dark side of the m").unwrap(), - "\"dark\" AND \"side\" AND \"of\" AND \"the\" AND \"m\"*" - ); - } -} +mod tests; diff --git a/src/catalog/overrides.rs b/src/catalog/overrides.rs new file mode 100644 index 00000000..db767a72 --- /dev/null +++ b/src/catalog/overrides.rs @@ -0,0 +1,122 @@ +//! Track corrections as a scan sees them: reading a track's corrected lists, +//! and writing the rows they imply. + +use sqlx::{Row, Sqlite, Transaction}; +use uuid::Uuid; + +use super::apply::{upsert_artist, upsert_genre}; + +/// The display string a correction implies, or the file's when there is none. +/// +/// An empty list is a correction meaning "nobody", and joins to nothing — which +/// has to become absence rather than an empty string, or a track answers with a +/// blank name instead of with no name. +pub(crate) fn display_of(corrected: Option<&[String]>, scanned: Option<&str>) -> Option { + match corrected { + None => scanned.map(str::to_owned), + Some([]) => None, + Some(names) => Some(names.join("; ")), + } +} + +/// The corrections a scan has to respect while re-deriving a track. +/// +/// The title is here as well as the two lists, and not because the track row +/// needs it — the projection reads the correction over that row. The full-text +/// index does not have a projection: it is a copy, rebuilt from the file by +/// every full scan, so a correction that never reached it would leave the track +/// findable only under the name it was corrected away from. +#[derive(Debug, Clone, Default)] +pub(crate) struct TrackOverrideLists { + pub title: Option, + pub artists: Option>, + pub genres: Option>, +} + +impl TrackOverrideLists { + fn decode(raw: Option) -> Result>, sqlx::Error> { + raw.map(|value| { + serde_json::from_str::>(&value) + .map_err(|error| sqlx::Error::Decode(Box::new(error))) + }) + .transpose() + } +} + +/// Reads a track's list corrections, if it carries any. +pub(crate) async fn track_override_lists( + tx: &mut Transaction<'_, Sqlite>, + track_id: Uuid, +) -> Result { + let row = sqlx::query("SELECT title, artists, genres FROM track_override WHERE track_id = ?") + .bind(track_id.to_string()) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { + return Ok(TrackOverrideLists::default()); + }; + Ok(TrackOverrideLists { + title: row.try_get("title")?, + artists: TrackOverrideLists::decode(row.try_get("artists")?)?, + genres: TrackOverrideLists::decode(row.try_get("genres")?)?, + }) +} + +/// Writes the rows an explicit list of artists or genres implies. +/// +/// Deliberately not the tag mapper's path. That exists to guess where one name +/// ends in a string a tagger wrote however it liked; a correction arrives +/// already separated, so parsing it again would only reintroduce the ambiguity +/// it was made to settle. +/// +/// Only the `artist` role is rewritten. A composer or a conductor comes from +/// the file and is untouched by someone correcting who performed the track, +/// which is why the delete is narrowed rather than wholesale. +pub(crate) async fn apply_track_override_lists( + tx: &mut Transaction<'_, Sqlite>, + pid: &crate::pid::PidSpecs, + library_id: Uuid, + track_id: Uuid, + lists: &TrackOverrideLists, + now: i64, +) -> Result<(), sqlx::Error> { + if let Some(names) = &lists.artists { + sqlx::query("DELETE FROM track_participant WHERE track_id = ? AND role = 'artist'") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + for (position, name) in names.iter().enumerate() { + let artist_id = upsert_artist(tx, pid, library_id, name, now).await?; + sqlx::query( + "INSERT INTO track_participant \ + (track_id, artist_id, library_id, role, sub_role, position, sort_name) \ + VALUES (?, ?, ?, 'artist', '', ?, NULL) \ + ON CONFLICT (track_id, artist_id, role, sub_role) DO NOTHING", + ) + .bind(track_id.to_string()) + .bind(artist_id.to_string()) + .bind(library_id.to_string()) + .bind(position as i64) + .execute(&mut **tx) + .await?; + } + } + if let Some(names) = &lists.genres { + sqlx::query("DELETE FROM track_genre WHERE track_id = ?") + .bind(track_id.to_string()) + .execute(&mut **tx) + .await?; + for name in names { + let genre_id = upsert_genre(tx, library_id, name, now).await?; + sqlx::query( + "INSERT INTO track_genre (track_id, genre_id, library_id) VALUES (?, ?, ?)", + ) + .bind(track_id.to_string()) + .bind(genre_id.to_string()) + .bind(library_id.to_string()) + .execute(&mut **tx) + .await?; + } + } + Ok(()) +} diff --git a/src/catalog/scan_jobs.rs b/src/catalog/scan_jobs.rs new file mode 100644 index 00000000..0da15169 --- /dev/null +++ b/src/catalog/scan_jobs.rs @@ -0,0 +1,437 @@ +//! The scan job lifecycle: queueing, starting, progress, finishing or failing, +//! the errors a scan records, and reading a job back. + +use serde::Serialize; +use sqlx::{Row, Sqlite, Transaction}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{authentication::now_ms, database::Database}; + +use super::parse_uuid; + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct ScanJobRecord { + pub id: Uuid, + pub library_id: Uuid, + pub status: String, + pub total_files: i64, + pub processed_files: i64, + pub added: i64, + pub updated: i64, + pub moved: i64, + pub skipped: i64, + pub unavailable: i64, + pub errors: i64, + /// Entries the walk left out by an exclusion or an ignore file, a folder + /// counting once whatever it holds (RFC-021 decision 2). + pub excluded: i64, + /// Symbolic links the walk did not follow, for whatever reason + /// (RFC-021 decision 3). + pub skipped_links: i64, + pub current_path: Option, + pub message: Option, +} + +impl Database { + /// Whether any library the user can see is being scanned, and how many + /// available tracks they can currently reach. + /// + /// Both halves of the Subsonic `scanStatus` shape, resolved in one + /// round trip and scoped to the caller: `count` is what *this* account + /// can see, not what the instance holds. + pub async fn scan_progress_for_user(&self, user_id: Uuid) -> Result<(bool, i64), sqlx::Error> { + let row = sqlx::query( + "SELECT EXISTS (SELECT 1 FROM scan_job sj \ + JOIN library_member m ON m.library_id=sj.library_id \ + WHERE m.user_id=? AND sj.status IN ('queued', 'running')) AS scanning, \ + (SELECT COUNT(*) FROM track t \ + JOIN library_member m ON m.library_id=t.library_id \ + WHERE m.user_id=? AND t.is_available=1) AS scanned", + ) + .bind(user_id.to_string()) + .bind(user_id.to_string()) + .fetch_one(self.pool()) + .await?; + Ok(( + row.try_get::("scanning")? != 0, + row.try_get("scanned")?, + )) + } + + pub async fn create_scan_job( + &self, + library_id: Uuid, + requested_by: Option, + trigger: &str, + ) -> Result { + let _writer = self.writer_guard().await; + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at) \ + VALUES (?, ?, ?, ?, 'queued', ?)", + ) + .bind(id.to_string()) + .bind(library_id.to_string()) + .bind(requested_by.map(|id| id.to_string())) + .bind(trigger) + .bind(now_ms()) + .execute(self.pool()) + .await?; + Ok(id) + } + + /// A job that walks only `scope`, folders relative to the library root: + /// what the watcher queues once a library's changes settle and it can + /// name where they were (RFC-021, what decision 6 left open). + pub async fn create_scoped_scan_job( + &self, + library_id: Uuid, + trigger: &str, + scope: &[String], + ) -> Result { + let _writer = self.writer_guard().await; + let id = Uuid::new_v4(); + let scope = serde_json::to_string(scope).expect("a list of strings serializes"); + sqlx::query( + "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at, \ + scope_json) \ + VALUES (?, ?, NULL, ?, 'queued', ?, ?)", + ) + .bind(id.to_string()) + .bind(library_id.to_string()) + .bind(trigger) + .bind(now_ms()) + .bind(scope) + .execute(self.pool()) + .await?; + Ok(id) + } + + /// The folders a job walks, or `None` for the whole library. A scope that + /// cannot be read is the whole library: walking more than asked is always + /// safe, and walking less is not. + pub async fn scan_scope(&self, scan_id: Uuid) -> Result>, sqlx::Error> { + let scope: Option> = + sqlx::query_scalar("SELECT scope_json FROM scan_job WHERE id = ?") + .bind(scan_id.to_string()) + .fetch_optional(self.pool()) + .await?; + Ok(scope + .flatten() + .and_then(|scope| serde_json::from_str::>(&scope).ok())) + } + + /// Every job still `queued`, oldest first, with its library: what a + /// previous run committed and never started. + pub async fn queued_scan_jobs(&self) -> Result, sqlx::Error> { + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT id, library_id FROM scan_job WHERE status = 'queued' ORDER BY created_at, id", + ) + .fetch_all(self.pool()) + .await?; + rows.into_iter() + .map(|(id, library)| Ok((parse_uuid(id)?, parse_uuid(library)?))) + .collect() + } + + /// A queued job, in a transaction the caller holds under the writer gate: + /// for a change that owes a scan and must not commit without it (RFC-021 + /// decision 5). + pub(crate) async fn insert_scan_job_in( + tx: &mut Transaction<'_, Sqlite>, + library_id: Uuid, + requested_by: Option, + trigger: &str, + ) -> Result { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at) \ + VALUES (?, ?, ?, ?, 'queued', ?)", + ) + .bind(id.to_string()) + .bind(library_id.to_string()) + .bind(requested_by.map(|id| id.to_string())) + .bind(trigger) + .bind(now_ms()) + .execute(&mut **tx) + .await?; + Ok(id) + } + + /// Creates a scan job only if the user is entitled to one, testing that + /// inside the insert rather than before it. + /// + /// A caller that checked entitlement with a separate read leaves a window + /// where a revocation lands between the check and the insert, queuing + /// work for a library the requester can no longer reach. Making the + /// `library_member` row a condition of the statement closes it: there is + /// no moment at which the job exists without the grant that justified it. + /// + /// Membership alone is not that grant. A scan walks the owner's files and + /// competes for the writer gate, so it is reserved to `owner` and + /// `manager`: a `listener` is entitled to read the catalogue, not to spend + /// someone else's disk on it. [`crate::database::LibraryRole::may_scan`] + /// names the same rule for callers that have the row in hand. + /// + /// Returns `None` when the user is not, or no longer, entitled. Callers + /// map that onto the answer a missing library gets, so a refusal never + /// confirms that the library exists. + pub async fn create_scan_job_for_user( + &self, + user_id: Uuid, + library_id: Uuid, + trigger: &str, + ) -> Result, sqlx::Error> { + let _writer = self.writer_guard().await; + let id = Uuid::new_v4(); + let inserted = sqlx::query( + "INSERT INTO scan_job (id, library_id, requested_by, trigger, status, created_at) \ + SELECT ?, ?, ?, ?, 'queued', ? WHERE EXISTS \ + (SELECT 1 FROM library_member WHERE library_id=? AND user_id=? \ + AND role IN ('owner', 'manager'))", + ) + .bind(id.to_string()) + .bind(library_id.to_string()) + .bind(user_id.to_string()) + .bind(trigger) + .bind(now_ms()) + .bind(library_id.to_string()) + .bind(user_id.to_string()) + .execute(self.pool()) + .await?; + Ok((inserted.rows_affected() > 0).then_some(id)) + } + + pub async fn start_scan_job( + &self, + scan_id: Uuid, + total: i64, + full: bool, + ) -> Result<(), sqlx::Error> { + self.start_walked_scan_job(scan_id, total, full, 0, 0).await + } + + /// Marks a job running, with what its walk found: how many files it will + /// look at, and what it left out (RFC-021 decision 7). + pub async fn start_walked_scan_job( + &self, + scan_id: Uuid, + total: i64, + full: bool, + excluded: i64, + skipped_links: i64, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let now = now_ms(); + sqlx::query( + "UPDATE scan_job SET status = 'running', total_files = ?, full_scan = ?, \ + excluded = ?, skipped_links = ?, started_at = ? WHERE id = ?", + ) + .bind(total) + .bind(i64::from(full)) + .bind(excluded) + .bind(skipped_links) + .bind(now) + .bind(scan_id.to_string()) + .execute(self.pool()) + .await?; + sqlx::query( + "UPDATE library SET last_scan_started_at = ?, updated_at = ? \ + WHERE id = (SELECT library_id FROM scan_job WHERE id = ?)", + ) + .bind(now) + .bind(now) + .bind(scan_id.to_string()) + .execute(self.pool()) + .await?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn update_scan_job( + &self, + scan_id: Uuid, + processed: i64, + added: i64, + updated: i64, + moved: i64, + skipped: i64, + errors: i64, + current_path: Option<&str>, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + sqlx::query( + "UPDATE scan_job SET processed_files = ?, added = ?, updated = ?, moved = ?, \ + skipped = ?, errors = ?, current_path = ? WHERE id = ?", + ) + .bind(processed) + .bind(added) + .bind(updated) + .bind(moved) + .bind(skipped) + .bind(errors) + .bind(current_path) + .bind(scan_id.to_string()) + .execute(self.pool()) + .await?; + Ok(()) + } + + pub async fn finish_scan_job( + &self, + scan_id: Uuid, + unavailable: i64, + ) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let now = now_ms(); + sqlx::query( + "UPDATE scan_job SET status = 'completed', unavailable = ?, current_path = NULL, \ + completed_at = ? WHERE id = ?", + ) + .bind(unavailable) + .bind(now) + .bind(scan_id.to_string()) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE library SET last_scan_completed_at = ?, updated_at = ? \ + WHERE id = (SELECT library_id FROM scan_job WHERE id = ?)", + ) + .bind(now) + .bind(now) + .bind(scan_id.to_string()) + .execute(&mut *tx) + .await?; + // The full-scan request is lifted here and nowhere else, and only by a + // run that actually read every file. A run that dies halfway leaves it + // standing, so the next one reads every file again instead of skipping + // the half it had already rewritten. And a scan that had already begun + // when the request arrived cannot spend it: it started under the old + // rules, so `scan_job.full_scan` says what it really did. + sqlx::query( + "UPDATE library SET full_scan_requested_at = NULL, updated_at = ? \ + WHERE id = (SELECT library_id FROM scan_job WHERE id = ?) \ + AND full_scan_requested_at IS NOT NULL \ + AND (SELECT full_scan FROM scan_job WHERE id = ?) = 1", + ) + .bind(now) + .bind(scan_id.to_string()) + .bind(scan_id.to_string()) + .execute(&mut *tx) + .await?; + // The rules this run wrote the catalogue under, recorded at the end + // and never at the start: a scan that dies halfway would otherwise + // look complete to the next boot, which would find no mismatch and + // leave half the catalogue keyed under the old rule for good. + for (key, value) in [ + ("pid.album", self.pid().album.source()), + ("pid.track", self.pid().track.source()), + ("pid.artist", self.pid().artist.source()), + ] { + sqlx::query( + "INSERT INTO server_property (key, value, updated_at) VALUES (?, ?, ?) \ + ON CONFLICT (key) DO UPDATE SET value = excluded.value, \ + updated_at = excluded.updated_at", + ) + .bind(key) + .bind(value) + .bind(now) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + pub async fn fail_scan_job(&self, scan_id: Uuid, message: &str) -> Result<(), sqlx::Error> { + let _writer = self.writer_guard().await; + sqlx::query( + "UPDATE scan_job SET status = 'failed', message = ?, current_path = NULL, \ + completed_at = ? WHERE id = ?", + ) + .bind(message) + .bind(now_ms()) + .bind(scan_id.to_string()) + .execute(self.pool()) + .await?; + Ok(()) + } + + pub async fn scan_job_for_user( + &self, + user_id: Uuid, + scan_id: Uuid, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT j.* FROM scan_job j JOIN library_member m ON m.library_id = j.library_id \ + WHERE j.id = ? AND m.user_id = ?", + ) + .bind(scan_id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(self.pool()) + .await?; + row.map(scan_job_from_row).transpose() + } + + /// A scan job by its id alone, for the operator's CLI, which acts as the + /// instance rather than as a member of the library. + pub async fn scan_job(&self, scan_id: Uuid) -> Result, sqlx::Error> { + let row = sqlx::query("SELECT * FROM scan_job WHERE id = ?") + .bind(scan_id.to_string()) + .fetch_optional(self.pool()) + .await?; + row.map(scan_job_from_row).transpose() + } + + pub async fn add_scan_error( + &self, + scan_id: Uuid, + path: &str, + message: &str, + ) -> Result<(), sqlx::Error> { + self.add_scan_errors(scan_id, &[(path.to_owned(), message.to_owned())]) + .await + } + + pub async fn add_scan_errors( + &self, + scan_id: Uuid, + errors: &[(String, String)], + ) -> Result<(), sqlx::Error> { + if errors.is_empty() { + return Ok(()); + } + let _writer = self.writer_guard().await; + let mut tx = self.begin_write(&_writer).await?; + let now = now_ms(); + for (path, message) in errors { + sqlx::query("INSERT INTO scan_error (scan_id, relative_path, message, created_at) VALUES (?, ?, ?, ?)") + .bind(scan_id.to_string()).bind(path).bind(message).bind(now) + .execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(()) + } +} + +fn scan_job_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(ScanJobRecord { + id: parse_uuid(row.try_get("id")?)?, + library_id: parse_uuid(row.try_get("library_id")?)?, + status: row.try_get("status")?, + total_files: row.try_get("total_files")?, + processed_files: row.try_get("processed_files")?, + added: row.try_get("added")?, + updated: row.try_get("updated")?, + moved: row.try_get("moved")?, + skipped: row.try_get("skipped")?, + unavailable: row.try_get("unavailable")?, + errors: row.try_get("errors")?, + excluded: row.try_get("excluded")?, + skipped_links: row.try_get("skipped_links")?, + current_path: row.try_get("current_path")?, + message: row.try_get("message")?, + }) +} diff --git a/src/catalog/tests.rs b/src/catalog/tests.rs new file mode 100644 index 00000000..97848284 --- /dev/null +++ b/src/catalog/tests.rs @@ -0,0 +1,27 @@ +use super::{fts_prefix_query, MAX_SEARCH_QUERY_CHARS, MAX_SEARCH_TERMS}; + +/// A search query is narrowed to its first terms and characters rather +/// than handed to FTS5 whole (RFC-023 decision 5), and what is kept is +/// still the ordinary expression: the last term kept is the prefix. +#[test] +fn a_search_query_is_bounded_in_terms_and_characters() { + let words: Vec = (0..40).map(|index| format!("w{index}")).collect(); + let expression = fts_prefix_query(&words.join(" ")).unwrap(); + assert_eq!(expression.matches(" AND ").count(), MAX_SEARCH_TERMS - 1); + assert!(expression.ends_with(&format!("\"w{}\"*", MAX_SEARCH_TERMS - 1))); + + // One enormous term is cut on a character boundary, accents included. + let long = "é".repeat(MAX_SEARCH_QUERY_CHARS * 4); + let expression = fts_prefix_query(&long).unwrap(); + assert_eq!( + expression.chars().count(), + MAX_SEARCH_QUERY_CHARS + 3, + "two quotes and the prefix star around the kept characters" + ); + + // An ordinary search is untouched. + assert_eq!( + fts_prefix_query("dark side of the m").unwrap(), + "\"dark\" AND \"side\" AND \"of\" AND \"the\" AND \"m\"*" + ); +} diff --git a/src/catalog/tracks.rs b/src/catalog/tracks.rs new file mode 100644 index 00000000..88a207b0 --- /dev/null +++ b/src/catalog/tracks.rs @@ -0,0 +1,239 @@ +//! Track reads for an account: listing, searching, browsing and streaming, +//! and the bounds a search query is held to. + +use std::path::PathBuf; + +use serde::Serialize; +use sqlx::Row; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::database::Database; + +use super::parse_uuid; + +#[derive(Debug, Clone)] +pub struct StreamTrack { + pub id: Uuid, + pub library_id: Uuid, + pub library_root: PathBuf, + pub relative_path: String, + pub full_hash: String, + pub codec: Option, + pub bitrate: Option, + pub duration_ms: i64, + pub available: bool, + /// Whether, and how far, a link in `relative_path` may be followed when + /// the file is opened (RFC-021, links outside the root). + pub link_policy: crate::link_roots::LinkPolicy, + /// The track's ReplayGain tags, for a transcode asked to apply them. + pub replay_gain: crate::replay_gain::ReplayGainTags, +} + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct TrackRecord { + pub id: Uuid, + pub library_id: Uuid, + pub relative_path: String, + pub title: String, + pub album: Option, + pub artist: Option, + /// Primary credited artist, matching the first artist in `artist`. + pub artist_id: Option, + pub genre: Option, + pub duration_ms: i64, + pub codec: Option, + pub artwork_hash: Option, + /// Content fingerprint: BLAKE3, unkeyed, hexadecimal, over the whole file. + /// See `services::SongItem::full_hash` for the contract. + pub full_hash: String, + pub available: bool, +} + +impl Database { + pub async fn list_tracks_for_user( + &self, + user_id: Uuid, + library_id: Uuid, + ) -> Result, sqlx::Error> { + fetch_tracks(self, user_id, library_id, None, 0, 500).await + } + + pub async fn search_tracks_for_user( + &self, + user_id: Uuid, + library_id: Uuid, + query: &str, + ) -> Result, sqlx::Error> { + fetch_tracks(self, user_id, library_id, Some(query), 0, 200).await + } + + pub async fn browse_tracks_for_user( + &self, + user_id: Uuid, + library_id: Uuid, + query: Option<&str>, + offset: i64, + limit: i64, + ) -> Result, sqlx::Error> { + fetch_tracks(self, user_id, library_id, query, offset, limit).await + } + + /// The file behind `track_id`, for an account that may play it. + /// + /// **The account must not be disabled** (RFC-023 decision 4). A bearer + /// token of a disabled account is refused before it gets here, but a + /// stream ticket is sealed, not looked up, and lives an hour, and a share + /// streams as its owner: this is the one read all three redeem through, so + /// it is where a disabled account stops playing. + pub async fn stream_track_for_user( + &self, + user_id: Uuid, + track_id: Uuid, + ) -> Result, sqlx::Error> { + let row = sqlx::query( + "SELECT t.id, t.library_id, l.root_path, t.relative_path, t.full_hash, \ + t.codec, t.bitrate, t.duration_ms, t.is_available, \ + t.replay_gain_track_gain, t.replay_gain_track_peak, \ + t.replay_gain_album_gain, t.replay_gain_album_peak, \ + l.follow_links, (SELECT json_group_array(o.root_path) FROM library o WHERE o.id <> l.id) AS other_roots FROM track t \ + JOIN library l ON l.id = t.library_id \ + JOIN library_member m ON m.library_id = t.library_id \ + JOIN account a ON a.id = m.user_id \ + WHERE t.id = ? AND m.user_id = ? AND a.disabled = 0", + ) + .bind(track_id.to_string()) + .bind(user_id.to_string()) + .fetch_optional(self.pool()) + .await?; + row.map(stream_track_from_row).transpose() + } +} + +/// The most characters of a search query that are read. +pub const MAX_SEARCH_QUERY_CHARS: usize = 256; + +/// The most terms of a search query that are matched. +pub const MAX_SEARCH_TERMS: usize = 16; + +/// Turns free-form user input into an FTS5 `MATCH` expression. +/// +/// Every term is quoted so punctuation cannot be read as FTS syntax, and terms +/// are ANDed so extra words narrow the result. Returns `None` when the input +/// carries no searchable term — `MATCH ''` is a SQLite error, not an empty +/// result. +/// +/// The trailing term also matches as a prefix, because search-as-you-type is +/// how clients actually query: the user has typed "ech" and expects "Echo", +/// and a bare token match returns nothing until the word is complete. Earlier +/// terms stay exact — "dark side of the m" should narrow, not widen, and +/// prefixing every token would make short words match far too much. +/// +/// **Bounded** (RFC-023 decision 5): the first [`MAX_SEARCH_QUERY_CHARS`] +/// characters, and of those the first [`MAX_SEARCH_TERMS`] terms. Each term is +/// one more phrase FTS5 intersects, for each of the three kinds a search +/// answers, and nothing bounded either but the request body — some seven +/// hundred kilobytes through a Subsonic form. Past the bounds the query is +/// narrowed, not refused, so no client is answered an error it never had: a +/// search that long names what it wants well before its end. +pub(crate) fn fts_prefix_query(query: &str) -> Option { + let query = match query.char_indices().nth(MAX_SEARCH_QUERY_CHARS) { + Some((end, _)) => &query[..end], + None => query, + }; + let mut terms = query + .split_whitespace() + .filter(|term| !term.is_empty()) + .take(MAX_SEARCH_TERMS) + .peekable(); + let mut expression = String::new(); + while let Some(term) = terms.next() { + if !expression.is_empty() { + expression.push_str(" AND "); + } + expression.push('"'); + expression.push_str(&term.replace('"', "\"\"")); + expression.push('"'); + if terms.peek().is_none() { + expression.push('*'); + } + } + (!expression.is_empty()).then_some(expression) +} + +async fn fetch_tracks( + db: &Database, + user: Uuid, + library: Uuid, + query: Option<&str>, + offset: i64, + limit: i64, +) -> Result, sqlx::Error> { + // Same prefix behaviour as /api/v2/search: this is the same user gesture, + // typed into a library rather than the whole catalogue. + let rows = if let Some(fts_query) = query.and_then(fts_prefix_query) { + sqlx::query("SELECT t.id, t.library_id, t.relative_path, t.title, t.album_title, t.artist_display, \ + (SELECT tp.artist_id FROM track_participant tp \ + WHERE tp.track_id=t.id AND tp.role='artist' \ + ORDER BY tp.position LIMIT 1) AS artist_id, \ + t.genre_display, t.duration_ms, t.codec, t.artwork_hash, t.full_hash, t.is_available FROM track t \ + JOIN library_member m ON m.library_id=t.library_id JOIN track_fts f ON f.track_id=t.id \ + WHERE m.user_id=? AND t.library_id=? AND track_fts MATCH ? ORDER BY rank, t.id LIMIT ? OFFSET ?") + .bind(user.to_string()).bind(library.to_string()).bind(fts_query).bind(limit).bind(offset).fetch_all(db.pool()).await? + } else { + sqlx::query("SELECT t.id, t.library_id, t.relative_path, t.title, t.album_title, t.artist_display, \ + (SELECT tp.artist_id FROM track_participant tp \ + WHERE tp.track_id=t.id AND tp.role='artist' \ + ORDER BY tp.position LIMIT 1) AS artist_id, \ + t.genre_display, t.duration_ms, t.codec, t.artwork_hash, t.full_hash, t.is_available FROM track t \ + JOIN library_member m ON m.library_id=t.library_id WHERE m.user_id=? AND t.library_id=? \ + ORDER BY t.title COLLATE NOCASE, t.id LIMIT ? OFFSET ?") + .bind(user.to_string()).bind(library.to_string()).bind(limit).bind(offset).fetch_all(db.pool()).await? + }; + rows.into_iter().map(track_from_row).collect() +} + +fn stream_track_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(StreamTrack { + id: parse_uuid(row.try_get("id")?)?, + library_id: parse_uuid(row.try_get("library_id")?)?, + library_root: PathBuf::from(row.try_get::("root_path")?), + relative_path: row.try_get("relative_path")?, + full_hash: row.try_get("full_hash")?, + codec: row.try_get("codec")?, + bitrate: row.try_get("bitrate")?, + duration_ms: row.try_get("duration_ms")?, + available: row.try_get::("is_available")? != 0, + link_policy: crate::link_roots::LinkPolicy::from_columns( + row.try_get("follow_links")?, + row.try_get::, _>("other_roots")?.as_deref(), + ), + replay_gain: crate::replay_gain::ReplayGainTags { + track_gain: row.try_get("replay_gain_track_gain")?, + track_peak: row.try_get("replay_gain_track_peak")?, + album_gain: row.try_get("replay_gain_album_gain")?, + album_peak: row.try_get("replay_gain_album_peak")?, + }, + }) +} + +fn track_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + Ok(TrackRecord { + id: parse_uuid(row.try_get("id")?)?, + library_id: parse_uuid(row.try_get("library_id")?)?, + relative_path: row.try_get("relative_path")?, + title: row.try_get("title")?, + album: row.try_get("album_title")?, + artist: row.try_get("artist_display")?, + artist_id: row + .try_get::, _>("artist_id")? + .map(parse_uuid) + .transpose()?, + genre: row.try_get("genre_display")?, + duration_ms: row.try_get("duration_ms")?, + codec: row.try_get("codec")?, + artwork_hash: row.try_get("artwork_hash")?, + full_hash: row.try_get("full_hash")?, + available: row.try_get::("is_available")? != 0, + }) +} From e449c21787387c2f985311c325fe2af465cba14a Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 10:26:09 +0100 Subject: [PATCH 4/5] refactor(storage): drop re-exports nothing in the crate uses Signed-off-by: InstaZDLL --- src/catalog/mod.rs | 4 ---- src/database/mod.rs | 5 ----- 2 files changed, 9 deletions(-) diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index 0f811b3a..753d0e2f 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -23,10 +23,6 @@ pub use scan_jobs::ScanJobRecord; pub use tracks::{StreamTrack, TrackRecord, MAX_SEARCH_QUERY_CHARS, MAX_SEARCH_TERMS}; pub(crate) use credits::{credit_decisions, recompute_track_participants, write_track_credits}; -// Nothing outside `credits` names these today, but they were reachable at -// `crate::catalog::*`, and a split that moves code does not take paths away. -#[allow(unused_imports)] -pub(crate) use credits::{read_track_credits, Recomputed}; pub(crate) use overrides::{ apply_track_override_lists, display_of, track_override_lists, TrackOverrideLists, }; diff --git a/src/database/mod.rs b/src/database/mod.rs index 26cd18da..64ae8094 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -30,11 +30,6 @@ pub use sessions::{ }; pub use subsonic_credentials::SubsonicCredentialStatus; pub(crate) use sync_operations::SyncOperationReservation; -// Nothing outside names it today — callers reach it through the `Replayed` -// variant — but `crate::database::SyncOperationReplayRecord` was its path, -// and a split that moves code does not take paths away. -#[allow(unused_imports)] -pub(crate) use sync_operations::SyncOperationReplayRecord; pub static MIGRATOR: Migrator = sqlx::migrate!("./migrations-v2"); From 6f76936af22624af25a5d42bd296ce76b459df23 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 16:09:53 +0100 Subject: [PATCH 5/5] docs(storage): attach the musicbrainz vote doc to consolidate_catalog_derivations Signed-off-by: InstaZDLL --- src/catalog/derivations.rs | 47 +++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/catalog/derivations.rs b/src/catalog/derivations.rs index 427dc7b2..525c0970 100644 --- a/src/catalog/derivations.rs +++ b/src/catalog/derivations.rs @@ -7,29 +7,6 @@ use uuid::Uuid; use crate::database::Database; impl Database { - /// Recomputes the derived MusicBrainz identifier of every album and artist - /// in one library from the tracks that currently back them. - /// - /// Tracks of one album routinely disagree: a release id is written per file - /// by whatever tagger touched it, and a library assembled over years holds - /// files tagged against different releases of the same record. The album's - /// identifier is therefore the one most of its available tracks agree on, - /// not the one the last file scanned happened to carry. - /// - /// Ties are broken by the earliest disc and track number, zero-padded into - /// a single sortable key so `MIN` compares the pair rather than each half - /// independently, and finally by the identifier itself. A tie means the - /// tags genuinely split the album down the middle; what matters is that two - /// scans of unchanged files answer the same thing, so nothing flaps. - /// - /// An artist takes the identifier from the tracks it is the *primary* - /// credit of (`track_artist.position = 0`), because `musicbrainz_artist_id` - /// is one value on a track that may credit several artists, and only the - /// first credit is the one the tag is about. - /// - /// Albums and artists whose tracks carry no identifier are set back to - /// `NULL` rather than left alone: this runs after every scan, so a tag - /// removed from the files has to disappear from the catalogue too. /// Re-derives `album.sort_name` and `artist.sort_name` from the tags the /// library's available tracks still carry. /// @@ -105,6 +82,30 @@ impl Database { /// A test that builds a catalogue by applying tracks directly runs this /// too, for the same reason the scanner does: without it the album has no /// credits and no artist has a count. + /// + /// Recomputes the derived MusicBrainz identifier of every album and artist + /// in one library from the tracks that currently back them. + /// + /// Tracks of one album routinely disagree: a release id is written per file + /// by whatever tagger touched it, and a library assembled over years holds + /// files tagged against different releases of the same record. The album's + /// identifier is therefore the one most of its available tracks agree on, + /// not the one the last file scanned happened to carry. + /// + /// Ties are broken by the earliest disc and track number, zero-padded into + /// a single sortable key so `MIN` compares the pair rather than each half + /// independently, and finally by the identifier itself. A tie means the + /// tags genuinely split the album down the middle; what matters is that two + /// scans of unchanged files answer the same thing, so nothing flaps. + /// + /// An artist takes the identifier from the tracks it is the *primary* + /// credit of (`track_artist.position = 0`), because `musicbrainz_artist_id` + /// is one value on a track that may credit several artists, and only the + /// first credit is the one the tag is about. + /// + /// Albums and artists whose tracks carry no identifier are set back to + /// `NULL` rather than left alone: this runs after every scan, so a tag + /// removed from the files has to disappear from the catalogue too. pub async fn consolidate_catalog_derivations( &self, library_id: Uuid,