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:
1 parent
25e877181a
commit
6d20baf914
4 files changed
+478
No files matched your search
@@ -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::*;
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
Reference in new issue
Block a user