//! Local MusicBrainz database fetcher. //! //! Implements [`MetadataFetcher`] backed by a local SQLite database (populated //! via [`crate::mb_import`]). All queries are instant local lookups — no rate //! limiting needed. use std::sync::Mutex; use rusqlite::Connection; use crate::error::{DataError, DataResult}; use crate::traits::MetadataFetcher; use crate::types::{ ArtistInfo, ArtistSearchResult, ArtistUrl, DiscographyEntry, RecordingDetails, RecordingMatch, ReleaseGroupEntry, ReleaseMatch, ReleaseRef, ReleaseTrack, }; /// Statistics about the local MusicBrainz database. #[derive(Debug, Clone, Default, serde::Serialize)] pub struct LocalMbStats { pub artists: u64, pub release_groups: u64, pub releases: u64, pub recordings: u64, pub tracks: u64, pub last_import_date: Option, } /// A [`MetadataFetcher`] backed by a local SQLite database. pub struct LocalMusicBrainzFetcher { conn: Mutex, } impl LocalMusicBrainzFetcher { /// Open (or create) a local MusicBrainz SQLite database. pub fn new(db_path: &str) -> Result> { let conn = Connection::open(db_path)?; conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA cache_size = -16000;")?; Ok(Self { conn: Mutex::new(conn), }) } /// Check whether the database has been populated with data. pub fn is_available(&self) -> bool { let conn = self.conn.lock().unwrap(); // Check if the mb_artists table exists and has at least one row. // Use EXISTS (SELECT 1 ...) instead of COUNT(*) to avoid scanning the entire table. conn.query_row( "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='mb_artists')", [], |row| row.get::<_, bool>(0), ) .unwrap_or(false) && conn .query_row( "SELECT EXISTS(SELECT 1 FROM mb_artists LIMIT 1)", [], |row| row.get::<_, bool>(0), ) .unwrap_or(false) } /// Get statistics about the imported data. pub fn stats(&self) -> LocalMbStats { let conn = self.conn.lock().unwrap(); let get_meta = |key: &str| -> Option { conn.query_row( "SELECT value FROM mb_import_meta WHERE key = ?1", rusqlite::params![key], |row| row.get(0), ) .ok() }; LocalMbStats { artists: get_meta("artist_count") .and_then(|s| s.parse().ok()) .unwrap_or(0), release_groups: get_meta("release_group_count") .and_then(|s| s.parse().ok()) .unwrap_or(0), releases: get_meta("release_count") .and_then(|s| s.parse().ok()) .unwrap_or(0), recordings: get_meta("recording_count") .and_then(|s| s.parse().ok()) .unwrap_or(0), tracks: get_meta("track_count") .and_then(|s| s.parse().ok()) .unwrap_or(0), last_import_date: get_meta("last_import_date"), } } /// Look up an artist by MBID (returns name and disambiguation). pub fn get_artist_by_mbid_sync(&self, mbid: &str) -> DataResult<(String, Option)> { let conn = self.conn.lock().unwrap(); let result = conn.query_row( "SELECT name, disambiguation FROM mb_artists WHERE mbid = ?1", rusqlite::params![mbid], |row| { let name: String = row.get(0)?; let disambiguation: Option = row.get(1)?; Ok((name, disambiguation.filter(|s| !s.is_empty()))) }, ); match result { Ok(r) => Ok(r), Err(rusqlite::Error::QueryReturnedNoRows) => { Err(DataError::Other(format!("artist {mbid} not found locally"))) } Err(e) => Err(DataError::Other(e.to_string())), } } /// Look up detailed artist info by MBID, including URLs. pub fn get_artist_info_sync(&self, mbid: &str) -> DataResult { let conn = self.conn.lock().unwrap(); let artist = conn.query_row( "SELECT name, disambiguation, country, artist_type, begin_year FROM mb_artists WHERE mbid = ?1", rusqlite::params![mbid], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?, row.get::<_, Option>(3)?, row.get::<_, Option>(4)?, )) }, ); let (name, disambiguation, country, artist_type, begin_year) = match artist { Ok(a) => a, Err(rusqlite::Error::QueryReturnedNoRows) => { return Err(DataError::Other(format!("artist {mbid} not found locally"))); } Err(e) => return Err(DataError::Other(e.to_string())), }; // Fetch URLs let mut url_stmt = conn .prepare("SELECT url, link_type FROM mb_artist_urls WHERE artist_mbid = ?1") .map_err(|e| DataError::Other(e.to_string()))?; let urls: Vec = url_stmt .query_map(rusqlite::params![mbid], |row| { Ok(ArtistUrl { url: row.get(0)?, link_type: row.get(1)?, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); Ok(ArtistInfo { name, mbid: Some(mbid.to_string()), disambiguation: disambiguation.filter(|s| !s.is_empty()), country: country.filter(|s| !s.is_empty()), artist_type, begin_year: begin_year.map(|y| y.to_string()), urls, }) } } impl MetadataFetcher for LocalMusicBrainzFetcher { async fn search_recording(&self, artist: &str, title: &str) -> DataResult> { let conn = self.conn.lock().unwrap(); let query = if artist.is_empty() { let pattern = format!("%{title}%"); let mut stmt = conn .prepare( "SELECT r.mbid, r.title, r.artist_mbid, a.name FROM mb_recordings r LEFT JOIN mb_artists a ON r.artist_mbid = a.mbid WHERE r.title LIKE ?1 COLLATE NOCASE LIMIT 10", ) .map_err(|e| DataError::Other(e.to_string()))?; stmt.query_map(rusqlite::params![pattern], |row| { Ok(RecordingMatch { mbid: row.get(0)?, title: row.get(1)?, artist_mbid: row.get(2)?, artist: row .get::<_, Option>(3)? .unwrap_or_else(|| "Unknown Artist".into()), releases: vec![], score: 100, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect() } else { let artist_pattern = format!("%{artist}%"); let title_pattern = format!("%{title}%"); let mut stmt = conn .prepare( "SELECT r.mbid, r.title, r.artist_mbid, a.name FROM mb_recordings r LEFT JOIN mb_artists a ON r.artist_mbid = a.mbid WHERE r.title LIKE ?1 COLLATE NOCASE AND a.name LIKE ?2 COLLATE NOCASE LIMIT 10", ) .map_err(|e| DataError::Other(e.to_string()))?; stmt.query_map(rusqlite::params![title_pattern, artist_pattern], |row| { Ok(RecordingMatch { mbid: row.get(0)?, title: row.get(1)?, artist_mbid: row.get(2)?, artist: row .get::<_, Option>(3)? .unwrap_or_else(|| "Unknown Artist".into()), releases: vec![], score: 100, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect() }; Ok(query) } async fn search_release(&self, artist: &str, album: &str) -> DataResult> { let conn = self.conn.lock().unwrap(); let results = if artist.is_empty() { let pattern = format!("%{album}%"); let mut stmt = conn .prepare( "SELECT r.mbid, r.title, r.artist_mbid, a.name, r.date FROM mb_releases r LEFT JOIN mb_artists a ON r.artist_mbid = a.mbid WHERE r.title LIKE ?1 COLLATE NOCASE LIMIT 10", ) .map_err(|e| DataError::Other(e.to_string()))?; stmt.query_map(rusqlite::params![pattern], |row| { Ok(ReleaseMatch { mbid: row.get(0)?, title: row.get(1)?, artist_mbid: row.get(2)?, artist: row .get::<_, Option>(3)? .unwrap_or_else(|| "Unknown Artist".into()), date: row.get(4)?, track_count: None, score: 100, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect() } else { let artist_pattern = format!("%{artist}%"); let album_pattern = format!("%{album}%"); let mut stmt = conn .prepare( "SELECT r.mbid, r.title, r.artist_mbid, a.name, r.date FROM mb_releases r LEFT JOIN mb_artists a ON r.artist_mbid = a.mbid WHERE r.title LIKE ?1 COLLATE NOCASE AND a.name LIKE ?2 COLLATE NOCASE LIMIT 10", ) .map_err(|e| DataError::Other(e.to_string()))?; stmt.query_map(rusqlite::params![album_pattern, artist_pattern], |row| { Ok(ReleaseMatch { mbid: row.get(0)?, title: row.get(1)?, artist_mbid: row.get(2)?, artist: row .get::<_, Option>(3)? .unwrap_or_else(|| "Unknown Artist".into()), date: row.get(4)?, track_count: None, score: 100, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect() }; Ok(results) } async fn get_recording(&self, mbid: &str) -> DataResult { let conn = self.conn.lock().unwrap(); let recording = conn.query_row( "SELECT r.mbid, r.title, r.artist_mbid, r.duration_ms, a.name FROM mb_recordings r LEFT JOIN mb_artists a ON r.artist_mbid = a.mbid WHERE r.mbid = ?1", rusqlite::params![mbid], |row| { Ok(RecordingDetails { mbid: row.get(0)?, title: row.get(1)?, artist_mbid: row.get(2)?, duration_ms: row.get(3)?, artist: row .get::<_, Option>(4)? .unwrap_or_else(|| "Unknown Artist".into()), releases: vec![], genres: vec![], }) }, ); match recording { Ok(mut r) => { // Fetch releases that contain this recording let mut stmt = conn .prepare( "SELECT DISTINCT rel.mbid, rel.title, rel.date FROM mb_tracks t JOIN mb_releases rel ON t.release_mbid = rel.mbid WHERE t.recording_mbid = ?1 LIMIT 10", ) .map_err(|e| DataError::Other(e.to_string()))?; r.releases = stmt .query_map(rusqlite::params![mbid], |row| { Ok(ReleaseRef { mbid: row.get(0)?, title: row.get(1)?, date: row.get(2)?, track_number: None, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); Ok(r) } Err(rusqlite::Error::QueryReturnedNoRows) => { // Not in standalone recordings — try finding via tracks-on-releases let track_recording = conn.query_row( "SELECT t.recording_mbid, t.title, rel.artist_mbid, a.name, t.duration_ms FROM mb_tracks t JOIN mb_releases rel ON t.release_mbid = rel.mbid LEFT JOIN mb_artists a ON rel.artist_mbid = a.mbid WHERE t.recording_mbid = ?1 LIMIT 1", rusqlite::params![mbid], |row| { let duration: Option = row.get(4)?; Ok(RecordingDetails { mbid: row.get(0)?, title: row.get(1)?, artist_mbid: row.get(2)?, duration_ms: duration.map(|d| d as u64), artist: row .get::<_, Option>(3)? .unwrap_or_else(|| "Unknown Artist".into()), releases: vec![], genres: vec![], }) }, ); match track_recording { Ok(mut r) => { // Fetch all releases containing this recording let mut stmt = conn .prepare( "SELECT DISTINCT rel.mbid, rel.title, rel.date FROM mb_tracks t JOIN mb_releases rel ON t.release_mbid = rel.mbid WHERE t.recording_mbid = ?1 LIMIT 10", ) .map_err(|e| DataError::Other(e.to_string()))?; r.releases = stmt .query_map(rusqlite::params![mbid], |row| { Ok(ReleaseRef { mbid: row.get(0)?, title: row.get(1)?, date: row.get(2)?, track_number: None, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); Ok(r) } Err(_) => Err(DataError::Other(format!( "recording {mbid} not found locally" ))), } } Err(e) => Err(DataError::Other(e.to_string())), } } async fn search_artist(&self, query: &str, limit: u32) -> DataResult> { let conn = self.conn.lock().unwrap(); let pattern = format!("%{query}%"); let mut stmt = conn .prepare( "SELECT mbid, name, disambiguation, country, artist_type FROM mb_artists WHERE name LIKE ?1 COLLATE NOCASE LIMIT ?2", ) .map_err(|e| DataError::Other(e.to_string()))?; let results: Vec = stmt .query_map(rusqlite::params![pattern, limit], |row| { Ok(ArtistSearchResult { mbid: row.get(0)?, name: row.get(1)?, disambiguation: row.get::<_, Option>(2)?.filter(|s| !s.is_empty()), country: row.get(3)?, artist_type: row.get(4)?, score: 100, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); Ok(results) } async fn get_artist_releases( &self, artist_mbid: &str, limit: u32, ) -> DataResult> { let conn = self.conn.lock().unwrap(); let mut stmt = conn .prepare( "SELECT mbid, title, date, status FROM mb_releases WHERE artist_mbid = ?1 LIMIT ?2", ) .map_err(|e| DataError::Other(e.to_string()))?; let results: Vec = stmt .query_map(rusqlite::params![artist_mbid, limit], |row| { Ok(DiscographyEntry { mbid: row.get(0)?, title: row.get(1)?, date: row.get(2)?, release_type: row.get(3)?, track_count: None, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); Ok(results) } async fn get_release_tracks(&self, release_mbid: &str) -> DataResult> { let conn = self.conn.lock().unwrap(); let mut stmt = conn .prepare( "SELECT recording_mbid, title, track_number, disc_number, duration_ms FROM mb_tracks WHERE release_mbid = ?1 ORDER BY disc_number, track_number", ) .map_err(|e| DataError::Other(e.to_string()))?; let tracks: Vec = stmt .query_map(rusqlite::params![release_mbid], |row| { Ok(ReleaseTrack { recording_mbid: row.get(0)?, title: row.get(1)?, track_number: row.get(2)?, disc_number: row.get(3)?, duration_ms: row.get(4)?, }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); if tracks.is_empty() { Err(DataError::Other(format!( "no tracks found for release {release_mbid}" ))) } else { Ok(tracks) } } async fn get_artist_release_groups( &self, artist_mbid: &str, ) -> DataResult> { let conn = self.conn.lock().unwrap(); let mut stmt = conn .prepare( "SELECT rg.mbid, rg.title, rg.primary_type, rg.secondary_types, rg.first_release_date, (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = rg.mbid LIMIT 1) as first_release_mbid FROM mb_release_groups rg WHERE rg.artist_mbid = ?1 ORDER BY rg.first_release_date", ) .map_err(|e| DataError::Other(e.to_string()))?; let results: Vec = stmt .query_map(rusqlite::params![artist_mbid], |row| { let secondary_types_json: Option = row.get(3)?; let secondary_types: Vec = secondary_types_json .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); Ok(ReleaseGroupEntry { mbid: row.get(0)?, title: row.get(1)?, primary_type: row.get(2)?, secondary_types, first_release_date: row.get(4)?, first_release_mbid: row.get(5)?, featured: false, // Local DB only stores primary-credit releases }) }) .map_err(|e| DataError::Other(e.to_string()))? .filter_map(|r| r.ok()) .collect(); Ok(results) } async fn resolve_release_from_group(&self, release_group_mbid: &str) -> DataResult { let conn = self.conn.lock().unwrap(); let result = conn.query_row( "SELECT mbid FROM mb_releases WHERE release_group_mbid = ?1 LIMIT 1", rusqlite::params![release_group_mbid], |row| row.get::<_, String>(0), ); match result { Ok(mbid) => Ok(mbid), Err(rusqlite::Error::QueryReturnedNoRows) => Err(DataError::Other(format!( "no releases for release-group {release_group_mbid}" ))), Err(e) => Err(DataError::Other(e.to_string())), } } } #[cfg(test)] mod tests { use super::*; use crate::mb_import; fn setup_test_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); mb_import::create_schema(&conn).unwrap(); // Insert test data conn.execute( "INSERT INTO mb_artists (mbid, name, sort_name, disambiguation, artist_type, country, begin_year) VALUES ('a-1', 'Test Artist', 'Artist, Test', 'test', 'Person', 'US', 1990)", [], ).unwrap(); conn.execute( "INSERT INTO mb_artist_urls (artist_mbid, url, link_type) VALUES ('a-1', 'https://en.wikipedia.org/wiki/Test', 'wikipedia')", [], ).unwrap(); conn.execute( "INSERT INTO mb_release_groups (mbid, title, artist_mbid, primary_type, secondary_types, first_release_date) VALUES ('rg-1', 'Test Album', 'a-1', 'Album', NULL, '2020-01-15')", [], ).unwrap(); conn.execute( "INSERT INTO mb_releases (mbid, title, release_group_mbid, artist_mbid, date, country, status) VALUES ('r-1', 'Test Album', 'rg-1', 'a-1', '2020-01-15', 'US', 'Official')", [], ).unwrap(); conn.execute( "INSERT INTO mb_tracks (release_mbid, recording_mbid, title, track_number, disc_number, duration_ms, position) VALUES ('r-1', 'rec-1', 'Track One', 1, 1, 240000, 1)", [], ).unwrap(); conn.execute( "INSERT INTO mb_recordings (mbid, title, artist_mbid, duration_ms) VALUES ('rec-1', 'Track One', 'a-1', 240000)", [], ).unwrap(); // Insert import metadata conn.execute( "INSERT INTO mb_import_meta (key, value) VALUES ('artist_count', '1')", [], ) .unwrap(); conn } #[test] fn test_get_artist_info_sync() { let conn = setup_test_db(); // We can't easily test the struct directly since it wraps a Mutex, // but we can test the SQL works let (name, disambig): (String, Option) = conn .query_row( "SELECT name, disambiguation FROM mb_artists WHERE mbid = 'a-1'", [], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!(name, "Test Artist"); assert_eq!(disambig, Some("test".to_string())); } #[test] fn test_resolve_release_from_group() { let conn = setup_test_db(); let mbid: String = conn .query_row( "SELECT mbid FROM mb_releases WHERE release_group_mbid = 'rg-1' LIMIT 1", [], |row| row.get(0), ) .unwrap(); assert_eq!(mbid, "r-1"); } }