feat: add remote metadata service and Tauri commands

Phase 4 of service integration:
- Add provider adapters (MusicBrainz, Discogs to unified DTOs)
- Add RemoteMetadataState with search_albums and get_album_metadata
- Create remote_metadata commands module with Tauri handlers
- Wire up state and commands in lib.rs

Available commands:
- remote_metadata_search: Search albums on MusicBrainz or Discogs
- remote_metadata_get_album: Get full album metadata with tracks
- remote_metadata_cache_stats: Get cache statistics
- remote_metadata_clear_cache: Clear the in-memory cache
This commit is contained in:
vicrodh committed 2026-01-26 18:46:29 -06:00
1 parent 25e877181a
commit 6d20baf914
4 files changed
+478

No files matched your search

+2
View File
@@ -22,6 +22,7 @@ pub mod radio;
pub mod search;
pub mod share;
pub mod smart_playlists;
pub mod remote_metadata;
pub use audio_backends::*;
pub use audio_diagnostics::*;
@@ -43,3 +44,4 @@ pub use radio::*;
pub use search::*;
pub use share::*;
pub use smart_playlists::*;
pub use remote_metadata::*;
+104
View File
@@ -0,0 +1,104 @@
//! Tauri commands for remote metadata fetching (MusicBrainz, Discogs)
//!
//! These commands allow the Tag Editor to search for and fetch
//! album metadata from external services.
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
use crate::library::remote_metadata::{
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataState, RemoteProvider,
};
/// State wrapper for Tauri
pub struct RemoteMetadataSharedState {
pub inner: Arc<Mutex<RemoteMetadataState>>,
}
/// Search for albums on a remote provider
///
/// # Arguments
/// * `provider` - "musicbrainz" or "discogs"
/// * `query` - Album title to search for
/// * `artist` - Optional artist name to narrow search
/// * `limit` - Maximum results (1-25)
#[tauri::command]
pub async fn remote_metadata_search(
provider: String,
query: String,
artist: Option<String>,
limit: Option<usize>,
state: State<'_, RemoteMetadataSharedState>,
) -> Result<Vec<RemoteAlbumSearchResult>, String> {
log::info!(
"Command: remote_metadata_search provider={} query={} artist={:?}",
provider,
query,
artist
);
let provider = parse_provider(&provider)?;
let limit = limit.unwrap_or(10).min(25);
let state_guard = state.inner.lock().await;
state_guard
.search_albums(provider, &query, artist.as_deref(), limit)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get full album metadata from a remote provider
///
/// # Arguments
/// * `provider` - "musicbrainz" or "discogs"
/// * `provider_id` - The provider-specific ID (MBID or Discogs release ID)
#[tauri::command]
pub async fn remote_metadata_get_album(
provider: String,
provider_id: String,
state: State<'_, RemoteMetadataSharedState>,
) -> Result<RemoteAlbumMetadata, String> {
log::info!(
"Command: remote_metadata_get_album provider={} id={}",
provider,
provider_id
);
let provider = parse_provider(&provider)?;
let state_guard = state.inner.lock().await;
state_guard
.get_album_metadata(provider, &provider_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Get cache statistics
#[tauri::command]
pub async fn remote_metadata_cache_stats(
state: State<'_, RemoteMetadataSharedState>,
) -> Result<crate::library::remote_metadata::CacheStats, String> {
let state_guard = state.inner.lock().await;
Ok(state_guard.cache.stats())
}
/// Clear the remote metadata cache
#[tauri::command]
pub async fn remote_metadata_clear_cache(
state: State<'_, RemoteMetadataSharedState>,
) -> Result<(), String> {
log::info!("Command: remote_metadata_clear_cache");
let state_guard = state.inner.lock().await;
state_guard.cache.clear_all();
Ok(())
}
/// Parse provider string to enum
fn parse_provider(provider: &str) -> Result<RemoteProvider, String> {
match provider.to_lowercase().as_str() {
"musicbrainz" | "mb" => Ok(RemoteProvider::MusicBrainz),
"discogs" => Ok(RemoteProvider::Discogs),
_ => Err(format!("Unknown provider: {}", provider)),
}
}
+13
View File
@@ -200,6 +200,13 @@ pub fn run() {
// Initialize ListenBrainz integration state
let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new()
.expect("Failed to initialize ListenBrainz state");
// Initialize remote metadata state (for Tag Editor service integration)
let remote_metadata_state = commands::RemoteMetadataSharedState {
inner: Arc::new(Mutex::new(library::remote_metadata::RemoteMetadataState::new(
Some(Arc::new(musicbrainz::MusicBrainzSharedState::new()
.expect("Failed to initialize MusicBrainz for remote metadata")))
))),
};
// Read saved audio device and settings for player initialization
let (saved_device, audio_settings) = audio_settings_state
@@ -425,6 +432,7 @@ pub fn run() {
.manage(tray_settings_state)
.manage(musicbrainz_state)
.manage(listenbrainz_state)
.manage(remote_metadata_state)
.invoke_handler(tauri::generate_handler![
// Auth commands
commands::init_client,
@@ -804,6 +812,11 @@ pub fn run() {
commands::smart_playlist_preview,
commands::smart_playlist_resolve_artist,
commands::smart_playlist_get_available_types,
// Remote metadata commands (Tag Editor service integration)
commands::remote_metadata_search,
commands::remote_metadata_get_album,
commands::remote_metadata_cache_stats,
commands::remote_metadata_clear_cache,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -222,6 +222,365 @@ pub fn parse_discogs_duration(duration: &str) -> Option<u32> {
}
}
/// Convert Discogs extended search result to unified DTO
pub fn discogs_extended_to_search_result(
result: &crate::discogs::DiscogsSearchResultExtended,
) -> RemoteAlbumSearchResult {
// Discogs title format is usually "Artist - Album"
let (artist, title) = if let Some(pos) = result.title.find(" - ") {
let (a, t) = result.title.split_at(pos);
(a.to_string(), t.trim_start_matches(" - ").to_string())
} else {
("Unknown Artist".to_string(), result.title.clone())
};
// Parse year from string
let year = result.year.as_ref().and_then(|y| y.parse::<u16>().ok());
// Get first label
let label = result.label.as_ref().and_then(|l| l.first().cloned());
// Get format as string
let format = result.format.as_ref().map(|f| f.join(", "));
RemoteAlbumSearchResult {
provider: RemoteProvider::Discogs,
provider_id: result.id.to_string(),
title,
artist,
year,
track_count: None,
country: result.country.clone(),
label,
catalog_number: result.catno.clone(),
confidence: None,
format,
}
}
/// Convert MusicBrainz full release to unified metadata DTO
pub fn musicbrainz_full_to_metadata(
release: &crate::musicbrainz::ReleaseFullResponse,
) -> RemoteAlbumMetadata {
// Extract artist from artist-credit
let artist = release
.artist_credit
.as_ref()
.map(|credits| {
credits
.iter()
.map(|c| {
format!(
"{}{}",
c.name.as_deref().unwrap_or(&c.artist.name),
c.joinphrase.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
// Extract year from date
let year = release.date.as_ref().and_then(|d| {
d.split('-').next().and_then(|y| y.parse::<u16>().ok())
});
// Extract genres from tags (sorted by count, take top 5)
let genres = release.tags.as_ref().map(|tags| {
let mut sorted: Vec<_> = tags.iter().collect();
sorted.sort_by(|a, b| b.count.cmp(&a.count));
sorted.iter().take(5).map(|t| t.name.clone()).collect()
});
// Extract label and catalog number
let (label, catalog_number) = release
.label_info
.as_ref()
.and_then(|info| info.first())
.map(|li| {
(
li.label.as_ref().map(|l| l.name.clone()),
li.catalog_number.clone(),
)
})
.unwrap_or((None, None));
// Count discs
let disc_count = release.media.as_ref().map(|m| m.len() as u8);
// Convert tracks
let tracks = release.media.as_ref().map(|media| {
let mut all_tracks = Vec::new();
for medium in media {
if let Some(tracks) = &medium.tracks {
for track in tracks {
all_tracks.push(RemoteTrackMetadata {
disc_number: medium.position,
track_number: track.position,
title: track.title.clone().or_else(|| {
track.recording.as_ref().and_then(|r| r.title.clone())
}).unwrap_or_default(),
duration_ms: track.length.map(|l| l as u32).or_else(|| {
track.recording.as_ref().and_then(|r| r.length.map(|l| l as u32))
}),
});
}
}
}
all_tracks
});
RemoteAlbumMetadata {
provider: RemoteProvider::MusicBrainz,
provider_id: release.id.clone(),
title: release.title.clone(),
artist,
year,
genres,
label,
catalog_number,
country: release.country.clone(),
barcode: release.barcode.clone(),
tracks,
disc_count,
source_url: Some(format!("https://musicbrainz.org/release/{}", release.id)),
}
}
/// Convert Discogs full release to unified metadata DTO
pub fn discogs_full_to_metadata(
release: &crate::discogs::DiscogsReleaseMetadata,
) -> RemoteAlbumMetadata {
// Combine artists with join phrases
let artist = release
.artists
.as_ref()
.map(|artists| {
artists
.iter()
.map(|a| {
format!(
"{}{}",
a.name.clone(),
a.join.as_deref().unwrap_or("")
)
})
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
// Combine genres and styles
let genres = {
let mut combined = Vec::new();
if let Some(g) = &release.genres {
combined.extend(g.clone());
}
if let Some(s) = &release.styles {
combined.extend(s.clone());
}
if combined.is_empty() {
None
} else {
Some(combined)
}
};
// Get first label and catalog number
let (label, catalog_number) = release
.labels
.as_ref()
.and_then(|labels| labels.first())
.map(|l| (Some(l.name.clone()), l.catno.clone()))
.unwrap_or((None, None));
// Convert tracklist
let tracks = release.tracklist.as_ref().map(|tracklist| {
tracklist
.iter()
.filter(|t| {
// Filter out headings (disc separators)
t.track_type.as_deref() != Some("heading")
})
.map(|t| {
let (disc_number, track_number) = parse_discogs_position(&t.position);
RemoteTrackMetadata {
disc_number: Some(disc_number),
track_number: Some(track_number),
title: t.title.clone(),
duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)),
}
})
.collect()
});
// Count unique discs
let disc_count = tracks.as_ref().map(|t| {
t.iter()
.filter_map(|track| track.disc_number)
.max()
.unwrap_or(1)
});
RemoteAlbumMetadata {
provider: RemoteProvider::Discogs,
provider_id: release.id.to_string(),
title: release.title.clone(),
artist,
year: release.year.map(|y| y as u16),
genres,
label,
catalog_number,
country: release.country.clone(),
barcode: None, // Discogs doesn't include barcode in release details
tracks,
disc_count,
source_url: release.uri.clone(),
}
}
// ============ Main Service Functions ============
impl RemoteMetadataState {
/// Search for albums using the specified provider
pub async fn search_albums(
&self,
provider: RemoteProvider,
query: &str,
artist: Option<&str>,
limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
// Check cache first
let cache_key = format!("{:?}:{}:{:?}:{}", provider, query, artist, limit);
if let Some(cached) = self.cache.get_search(&cache_key) {
log::debug!("Cache hit for search: {}", cache_key);
return Ok(cached);
}
let results = match provider {
RemoteProvider::MusicBrainz => {
self.search_musicbrainz(query, artist.unwrap_or(""), limit).await?
}
RemoteProvider::Discogs => {
self.search_discogs(query, artist.unwrap_or(""), limit).await?
}
};
// Cache results
self.cache.set_search(&cache_key, results.clone());
Ok(results)
}
/// Get full album metadata by provider ID
pub async fn get_album_metadata(
&self,
provider: RemoteProvider,
provider_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
// Check cache first
let cache_key = format!("{:?}:{}", provider, provider_id);
if let Some(cached) = self.cache.get_metadata(&cache_key) {
log::debug!("Cache hit for metadata: {}", cache_key);
return Ok(cached);
}
let metadata = match provider {
RemoteProvider::MusicBrainz => {
self.get_musicbrainz_metadata(provider_id).await?
}
RemoteProvider::Discogs => {
self.get_discogs_metadata(provider_id).await?
}
};
// Cache results
self.cache.set_metadata(&cache_key, metadata.clone());
Ok(metadata)
}
/// Search MusicBrainz for releases
async fn search_musicbrainz(
&self,
query: &str,
artist: &str,
limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
let mb_state = self.musicbrainz.as_ref()
.ok_or(RemoteMetadataError::ProviderUnavailable)?;
let response = mb_state.client
.search_releases_extended(query, artist, None, limit)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
let results = response.releases
.iter()
.map(musicbrainz_release_to_search_result)
.collect();
Ok(results)
}
/// Search Discogs for releases
async fn search_discogs(
&self,
query: &str,
artist: &str,
limit: usize,
) -> Result<Vec<RemoteAlbumSearchResult>, RemoteMetadataError> {
let client = self.discogs.lock().await;
let results = client
.search_releases(artist, query, None, limit)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
let converted = results
.iter()
.map(discogs_extended_to_search_result)
.collect();
Ok(converted)
}
/// Get full metadata from MusicBrainz
async fn get_musicbrainz_metadata(
&self,
release_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
let mb_state = self.musicbrainz.as_ref()
.ok_or(RemoteMetadataError::ProviderUnavailable)?;
let response = mb_state.client
.get_release_with_tracks(release_id)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
Ok(musicbrainz_full_to_metadata(&response))
}
/// Get full metadata from Discogs
async fn get_discogs_metadata(
&self,
release_id: &str,
) -> Result<RemoteAlbumMetadata, RemoteMetadataError> {
let release_id: u64 = release_id.parse()
.map_err(|_| RemoteMetadataError::InvalidProviderId)?;
let client = self.discogs.lock().await;
let response = client
.get_release_metadata(release_id)
.await
.map_err(|e| RemoteMetadataError::NetworkError(e))?;
Ok(discogs_full_to_metadata(&response))
}
}
#[cfg(test)]
mod tests {
use super::*;