library: add remote_metadata module structure and unified DTOs
- Add RemoteProvider enum (MusicBrainz, Discogs) - Add RemoteAlbumSearchResult for lightweight search results - Add RemoteAlbumMetadata for full metadata with tracks - Add RemoteTrackMetadata for track info - Add RemoteMetadataCache with TTL-based expiration - Add adapter functions for MusicBrainz and Discogs - Add Discogs position/duration parsing utilities
This commit is contained in:
1 parent
50d765509d
commit
70df504af3
4 files changed
+717
No files matched your search
@@ -9,6 +9,7 @@ pub mod database;
|
||||
pub mod errors;
|
||||
pub mod metadata;
|
||||
pub mod models;
|
||||
pub mod remote_metadata;
|
||||
pub mod scanner;
|
||||
pub mod tag_sidecar;
|
||||
pub mod thumbnails;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! In-memory cache for remote metadata requests
|
||||
//!
|
||||
//! Provides short-lived caching to avoid redundant API calls during
|
||||
//! a tag editing session.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::models::{RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteProvider};
|
||||
|
||||
/// Cache entry with expiration
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheEntry<T> {
|
||||
data: T,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
fn new(data: T, ttl: Duration) -> Self {
|
||||
Self {
|
||||
data,
|
||||
inserted_at: Instant::now(),
|
||||
ttl,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self) -> bool {
|
||||
self.inserted_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for search results
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
struct SearchCacheKey {
|
||||
provider: String,
|
||||
query: String,
|
||||
catalog_id: Option<String>,
|
||||
artist: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache key for full metadata
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
struct MetadataCacheKey {
|
||||
provider: String,
|
||||
provider_id: String,
|
||||
}
|
||||
|
||||
/// Remote metadata cache
|
||||
pub struct RemoteMetadataCache {
|
||||
/// Search results cache (short TTL: 10 minutes)
|
||||
search_cache: Arc<RwLock<HashMap<SearchCacheKey, CacheEntry<Vec<RemoteAlbumSearchResult>>>>>,
|
||||
/// Full metadata cache (longer TTL: 1 hour)
|
||||
metadata_cache: Arc<RwLock<HashMap<MetadataCacheKey, CacheEntry<RemoteAlbumMetadata>>>>,
|
||||
/// TTL for search results
|
||||
search_ttl: Duration,
|
||||
/// TTL for full metadata
|
||||
metadata_ttl: Duration,
|
||||
}
|
||||
|
||||
impl Default for RemoteMetadataCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteMetadataCache {
|
||||
/// Create a new cache with default TTLs
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
search_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
metadata_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
search_ttl: Duration::from_secs(10 * 60), // 10 minutes
|
||||
metadata_ttl: Duration::from_secs(60 * 60), // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached search results
|
||||
pub async fn get_search(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
query: &str,
|
||||
catalog_id: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
) -> Option<Vec<RemoteAlbumSearchResult>> {
|
||||
let key = SearchCacheKey {
|
||||
provider: provider.to_string(),
|
||||
query: query.to_lowercase(),
|
||||
catalog_id: catalog_id.map(|s| s.to_lowercase()),
|
||||
artist: artist.map(|s| s.to_lowercase()),
|
||||
};
|
||||
|
||||
let cache = self.search_cache.read().await;
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if !entry.is_expired() {
|
||||
log::debug!(
|
||||
"Cache hit for search: {} {:?}",
|
||||
provider,
|
||||
query
|
||||
);
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Store search results in cache
|
||||
pub async fn set_search(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
query: &str,
|
||||
catalog_id: Option<&str>,
|
||||
artist: Option<&str>,
|
||||
results: Vec<RemoteAlbumSearchResult>,
|
||||
) {
|
||||
let key = SearchCacheKey {
|
||||
provider: provider.to_string(),
|
||||
query: query.to_lowercase(),
|
||||
catalog_id: catalog_id.map(|s| s.to_lowercase()),
|
||||
artist: artist.map(|s| s.to_lowercase()),
|
||||
};
|
||||
|
||||
let mut cache = self.search_cache.write().await;
|
||||
cache.insert(key, CacheEntry::new(results, self.search_ttl));
|
||||
}
|
||||
|
||||
/// Get cached full metadata
|
||||
pub async fn get_metadata(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
provider_id: &str,
|
||||
) -> Option<RemoteAlbumMetadata> {
|
||||
let key = MetadataCacheKey {
|
||||
provider: provider.to_string(),
|
||||
provider_id: provider_id.to_string(),
|
||||
};
|
||||
|
||||
let cache = self.metadata_cache.read().await;
|
||||
if let Some(entry) = cache.get(&key) {
|
||||
if !entry.is_expired() {
|
||||
log::debug!(
|
||||
"Cache hit for metadata: {} {}",
|
||||
provider,
|
||||
provider_id
|
||||
);
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Store full metadata in cache
|
||||
pub async fn set_metadata(
|
||||
&self,
|
||||
provider: RemoteProvider,
|
||||
provider_id: &str,
|
||||
metadata: RemoteAlbumMetadata,
|
||||
) {
|
||||
let key = MetadataCacheKey {
|
||||
provider: provider.to_string(),
|
||||
provider_id: provider_id.to_string(),
|
||||
};
|
||||
|
||||
let mut cache = self.metadata_cache.write().await;
|
||||
cache.insert(key, CacheEntry::new(metadata, self.metadata_ttl));
|
||||
}
|
||||
|
||||
/// Clear expired entries from both caches
|
||||
pub async fn cleanup_expired(&self) -> usize {
|
||||
let mut removed = 0;
|
||||
|
||||
{
|
||||
let mut cache = self.search_cache.write().await;
|
||||
let before = cache.len();
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
removed += before - cache.len();
|
||||
}
|
||||
|
||||
{
|
||||
let mut cache = self.metadata_cache.write().await;
|
||||
let before = cache.len();
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
removed += before - cache.len();
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
log::debug!("Cleaned up {} expired cache entries", removed);
|
||||
}
|
||||
|
||||
removed
|
||||
}
|
||||
|
||||
/// Clear all cached data
|
||||
pub async fn clear_all(&self) {
|
||||
{
|
||||
let mut cache = self.search_cache.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
{
|
||||
let mut cache = self.metadata_cache.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
log::info!("Cleared remote metadata cache");
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
pub async fn stats(&self) -> CacheStats {
|
||||
let search_count = self.search_cache.read().await.len();
|
||||
let metadata_count = self.metadata_cache.read().await.len();
|
||||
|
||||
CacheStats {
|
||||
search_entries: search_count,
|
||||
metadata_entries: metadata_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache statistics
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CacheStats {
|
||||
pub search_entries: usize,
|
||||
pub metadata_entries: usize,
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Remote metadata fetching for the Tag Editor
|
||||
//!
|
||||
//! This module provides a unified interface for fetching album metadata
|
||||
//! from MusicBrainz and Discogs. The Tag Editor uses these services to
|
||||
//! help users fill in metadata for their local library albums.
|
||||
//!
|
||||
//! # Providers
|
||||
//!
|
||||
//! - **MusicBrainz** (default): Free, community-maintained database.
|
||||
//! No authentication required. Uses tags for genres.
|
||||
//!
|
||||
//! - **Discogs**: Commercial database with extensive catalog.
|
||||
//! Requires API credentials (handled via proxy). Has genres and styles.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! 1. Search for albums using `search_albums()`
|
||||
//! 2. Display results to user
|
||||
//! 3. Fetch full metadata using `get_album_metadata()`
|
||||
//! 4. Apply to editor form
|
||||
|
||||
mod cache;
|
||||
mod models;
|
||||
|
||||
pub use cache::{CacheStats, RemoteMetadataCache};
|
||||
pub use models::{
|
||||
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataError, RemoteProvider,
|
||||
RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata,
|
||||
};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::discogs::DiscogsClient;
|
||||
use crate::musicbrainz::MusicBrainzSharedState;
|
||||
|
||||
/// Shared state for remote metadata operations
|
||||
pub struct RemoteMetadataState {
|
||||
/// In-memory cache
|
||||
pub cache: RemoteMetadataCache,
|
||||
/// MusicBrainz client (shared with existing integration)
|
||||
pub musicbrainz: Option<Arc<MusicBrainzSharedState>>,
|
||||
/// Discogs client
|
||||
pub discogs: Arc<Mutex<DiscogsClient>>,
|
||||
}
|
||||
|
||||
impl RemoteMetadataState {
|
||||
pub fn new(musicbrainz: Option<Arc<MusicBrainzSharedState>>) -> Self {
|
||||
Self {
|
||||
cache: RemoteMetadataCache::new(),
|
||||
musicbrainz,
|
||||
discogs: Arc::new(Mutex::new(DiscogsClient::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ MusicBrainz Adapter ============
|
||||
|
||||
/// Convert MusicBrainz release search result to unified DTO
|
||||
pub fn musicbrainz_release_to_search_result(
|
||||
release: &crate::musicbrainz::models::ReleaseResult,
|
||||
) -> RemoteAlbumSearchResult {
|
||||
// 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 (YYYY or YYYY-MM-DD)
|
||||
let year = release.date.as_ref().and_then(|d| {
|
||||
d.split('-').next().and_then(|y| y.parse::<u16>().ok())
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
RemoteAlbumSearchResult {
|
||||
provider: RemoteProvider::MusicBrainz,
|
||||
provider_id: release.id.clone(),
|
||||
title: release.title.clone(),
|
||||
artist,
|
||||
year,
|
||||
track_count: None, // Not available in search results
|
||||
country: release.country.clone(),
|
||||
label,
|
||||
catalog_number,
|
||||
confidence: release.score.map(|s| s.min(100) as u8),
|
||||
format: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Discogs Adapter ============
|
||||
|
||||
/// Convert Discogs release search result to unified DTO
|
||||
pub fn discogs_release_to_search_result(
|
||||
result: &crate::discogs::SearchResult,
|
||||
) -> 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())
|
||||
};
|
||||
|
||||
RemoteAlbumSearchResult {
|
||||
provider: RemoteProvider::Discogs,
|
||||
provider_id: result.id.to_string(),
|
||||
title,
|
||||
artist,
|
||||
year: None, // Not in search result, need to fetch details
|
||||
track_count: None,
|
||||
country: None,
|
||||
label: None,
|
||||
catalog_number: None,
|
||||
confidence: None,
|
||||
format: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Discogs track position to (disc_number, track_number)
|
||||
/// Handles formats: "1", "A1", "1-1", "CD1-1", "1.1"
|
||||
pub fn parse_discogs_position(position: &str) -> (u8, u8) {
|
||||
let position = position.trim();
|
||||
|
||||
// Handle empty position
|
||||
if position.is_empty() {
|
||||
return (1, 1);
|
||||
}
|
||||
|
||||
// Try "X-Y" format (e.g., "1-5", "CD1-3")
|
||||
if let Some(pos) = position.find('-') {
|
||||
let disc_part = &position[..pos];
|
||||
let track_part = &position[pos + 1..];
|
||||
|
||||
// Extract number from disc part (handle "CD1", "1", etc.)
|
||||
let disc = disc_part
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u8>()
|
||||
.unwrap_or(1);
|
||||
|
||||
let track = track_part.parse::<u8>().unwrap_or(1);
|
||||
return (disc, track);
|
||||
}
|
||||
|
||||
// Try "X.Y" format
|
||||
if let Some(pos) = position.find('.') {
|
||||
let disc_part = &position[..pos];
|
||||
let track_part = &position[pos + 1..];
|
||||
|
||||
let disc = disc_part.parse::<u8>().unwrap_or(1);
|
||||
let track = track_part.parse::<u8>().unwrap_or(1);
|
||||
return (disc, track);
|
||||
}
|
||||
|
||||
// Handle vinyl sides (A, B, C, D -> disc 1, 1, 2, 2)
|
||||
if position.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
let side = position.chars().next().unwrap().to_ascii_uppercase();
|
||||
let track_str: String = position.chars().skip(1).collect();
|
||||
let track = track_str.parse::<u8>().unwrap_or(1);
|
||||
|
||||
let disc = match side {
|
||||
'A' | 'B' => 1,
|
||||
'C' | 'D' => 2,
|
||||
'E' | 'F' => 3,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
return (disc, track);
|
||||
}
|
||||
|
||||
// Simple number
|
||||
let track = position.parse::<u8>().unwrap_or(1);
|
||||
(1, track)
|
||||
}
|
||||
|
||||
/// Parse Discogs duration string to milliseconds
|
||||
/// Handles format: "M:SS" or "MM:SS" or "H:MM:SS"
|
||||
pub fn parse_discogs_duration(duration: &str) -> Option<u32> {
|
||||
let parts: Vec<&str> = duration.split(':').collect();
|
||||
|
||||
match parts.len() {
|
||||
2 => {
|
||||
// M:SS or MM:SS
|
||||
let minutes: u32 = parts[0].parse().ok()?;
|
||||
let seconds: u32 = parts[1].parse().ok()?;
|
||||
Some((minutes * 60 + seconds) * 1000)
|
||||
}
|
||||
3 => {
|
||||
// H:MM:SS
|
||||
let hours: u32 = parts[0].parse().ok()?;
|
||||
let minutes: u32 = parts[1].parse().ok()?;
|
||||
let seconds: u32 = parts[2].parse().ok()?;
|
||||
Some((hours * 3600 + minutes * 60 + seconds) * 1000)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_discogs_position() {
|
||||
assert_eq!(parse_discogs_position("1"), (1, 1));
|
||||
assert_eq!(parse_discogs_position("5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("1-1"), (1, 1));
|
||||
assert_eq!(parse_discogs_position("1-5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("2-3"), (2, 3));
|
||||
assert_eq!(parse_discogs_position("CD1-5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("CD2-3"), (2, 3));
|
||||
assert_eq!(parse_discogs_position("A1"), (1, 1));
|
||||
assert_eq!(parse_discogs_position("B2"), (1, 2));
|
||||
assert_eq!(parse_discogs_position("C1"), (2, 1));
|
||||
assert_eq!(parse_discogs_position("D3"), (2, 3));
|
||||
assert_eq!(parse_discogs_position("1.5"), (1, 5));
|
||||
assert_eq!(parse_discogs_position("2.3"), (2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_discogs_duration() {
|
||||
assert_eq!(parse_discogs_duration("3:45"), Some(225000));
|
||||
assert_eq!(parse_discogs_duration("0:30"), Some(30000));
|
||||
assert_eq!(parse_discogs_duration("10:00"), Some(600000));
|
||||
assert_eq!(parse_discogs_duration("1:00:00"), Some(3600000));
|
||||
assert_eq!(parse_discogs_duration("invalid"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Unified DTOs for remote metadata providers (MusicBrainz, Discogs)
|
||||
//!
|
||||
//! These structs provide a provider-neutral interface for the Tag Editor
|
||||
//! to consume album metadata from different sources.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Remote metadata provider
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RemoteProvider {
|
||||
MusicBrainz,
|
||||
Discogs,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteProvider {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MusicBrainz => write!(f, "musicbrainz"),
|
||||
Self::Discogs => write!(f, "discogs"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for RemoteProvider {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"musicbrainz" | "mb" => Ok(Self::MusicBrainz),
|
||||
"discogs" => Ok(Self::Discogs),
|
||||
_ => Err(format!("Unknown provider: {}", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight search result for displaying in results list
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteAlbumSearchResult {
|
||||
/// Provider identifier
|
||||
pub provider: RemoteProvider,
|
||||
/// Provider-specific ID (MusicBrainz release ID or Discogs release ID)
|
||||
pub provider_id: String,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artist
|
||||
pub artist: String,
|
||||
/// Release year (extracted from date)
|
||||
pub year: Option<u16>,
|
||||
/// Number of tracks (if available from search)
|
||||
pub track_count: Option<u16>,
|
||||
/// Release country
|
||||
pub country: Option<String>,
|
||||
/// Record label
|
||||
pub label: Option<String>,
|
||||
/// Catalog number
|
||||
pub catalog_number: Option<String>,
|
||||
/// Match confidence (0-100, provider-specific)
|
||||
pub confidence: Option<u8>,
|
||||
/// Format info (e.g., "CD", "Vinyl", "Digital")
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
/// Full album metadata with tracks (for applying to form)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteAlbumMetadata {
|
||||
/// Provider identifier
|
||||
pub provider: RemoteProvider,
|
||||
/// Provider-specific ID
|
||||
pub provider_id: String,
|
||||
/// Album title
|
||||
pub title: String,
|
||||
/// Album artist
|
||||
pub artist: String,
|
||||
/// Release year
|
||||
pub year: Option<u16>,
|
||||
/// Genres/styles/tags
|
||||
pub genres: Vec<String>,
|
||||
/// Record label
|
||||
pub label: Option<String>,
|
||||
/// Catalog number
|
||||
pub catalog_number: Option<String>,
|
||||
/// Release country
|
||||
pub country: Option<String>,
|
||||
/// Barcode/UPC (if available)
|
||||
pub barcode: Option<String>,
|
||||
/// Track list organized by disc
|
||||
pub tracks: Vec<RemoteTrackMetadata>,
|
||||
/// Total disc count
|
||||
pub disc_count: u8,
|
||||
/// URL to view on provider website
|
||||
pub source_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Single track metadata
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteTrackMetadata {
|
||||
/// Disc number (1-based)
|
||||
pub disc_number: u8,
|
||||
/// Track number within disc (1-based)
|
||||
pub track_number: u8,
|
||||
/// Track title
|
||||
pub title: String,
|
||||
/// Duration in milliseconds (if available)
|
||||
pub duration_ms: Option<u32>,
|
||||
}
|
||||
|
||||
/// Search request parameters
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteSearchRequest {
|
||||
/// Provider to search
|
||||
pub provider: RemoteProvider,
|
||||
/// Search query (usually "artist album")
|
||||
pub query: String,
|
||||
/// Optional catalog number for more precise matching
|
||||
pub catalog_id: Option<String>,
|
||||
/// Optional artist name for filtering
|
||||
pub artist: Option<String>,
|
||||
/// Maximum results to return (default: 10)
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RemoteSearchRequest {
|
||||
pub fn limit(&self) -> usize {
|
||||
self.limit.unwrap_or(10).min(25)
|
||||
}
|
||||
}
|
||||
|
||||
/// Search response wrapper
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteSearchResponse {
|
||||
/// Provider that was searched
|
||||
pub provider: RemoteProvider,
|
||||
/// Search results
|
||||
pub results: Vec<RemoteAlbumSearchResult>,
|
||||
/// Total results available (may be more than returned)
|
||||
pub total_count: Option<usize>,
|
||||
/// Whether rate limit was hit
|
||||
pub rate_limited: bool,
|
||||
}
|
||||
|
||||
impl RemoteSearchResponse {
|
||||
pub fn empty(provider: RemoteProvider) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
results: Vec::new(),
|
||||
total_count: Some(0),
|
||||
rate_limited: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rate_limited(provider: RemoteProvider) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
results: Vec::new(),
|
||||
total_count: None,
|
||||
rate_limited: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error types for remote metadata operations
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "message")]
|
||||
pub enum RemoteMetadataError {
|
||||
/// Network or connection error
|
||||
NetworkError(String),
|
||||
/// Rate limit exceeded
|
||||
RateLimited(String),
|
||||
/// Invalid response from provider
|
||||
InvalidResponse(String),
|
||||
/// No results found
|
||||
NoResults,
|
||||
/// Provider not available
|
||||
ProviderUnavailable(String),
|
||||
/// Invalid provider ID
|
||||
InvalidProviderId(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RemoteMetadataError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NetworkError(msg) => write!(f, "Network error: {}", msg),
|
||||
Self::RateLimited(msg) => write!(f, "Rate limited: {}", msg),
|
||||
Self::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
|
||||
Self::NoResults => write!(f, "No results found"),
|
||||
Self::ProviderUnavailable(msg) => write!(f, "Provider unavailable: {}", msg),
|
||||
Self::InvalidProviderId(msg) => write!(f, "Invalid provider ID: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RemoteMetadataError> for String {
|
||||
fn from(err: RemoteMetadataError) -> Self {
|
||||
err.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_parsing() {
|
||||
assert_eq!(
|
||||
"musicbrainz".parse::<RemoteProvider>().unwrap(),
|
||||
RemoteProvider::MusicBrainz
|
||||
);
|
||||
assert_eq!(
|
||||
"discogs".parse::<RemoteProvider>().unwrap(),
|
||||
RemoteProvider::Discogs
|
||||
);
|
||||
assert_eq!(
|
||||
"MB".parse::<RemoteProvider>().unwrap(),
|
||||
RemoteProvider::MusicBrainz
|
||||
);
|
||||
assert!("unknown".parse::<RemoteProvider>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_request_limit() {
|
||||
let req = RemoteSearchRequest {
|
||||
provider: RemoteProvider::MusicBrainz,
|
||||
query: "test".to_string(),
|
||||
catalog_id: None,
|
||||
artist: None,
|
||||
limit: None,
|
||||
};
|
||||
assert_eq!(req.limit(), 10);
|
||||
|
||||
let req_custom = RemoteSearchRequest {
|
||||
limit: Some(50),
|
||||
..req.clone()
|
||||
};
|
||||
assert_eq!(req_custom.limit(), 25); // Capped at 25
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user