feat(locallibrary): tag editor remote lookup (MusicBrainz + Discogs)

Complete the tag editor with remote metadata auto-fill.

- qbz-integrations: add frontend-agnostic `discogs` (client + DTOs, Tauri-free)
  and `remote_metadata` (unified DTOs + the pure MusicBrainz/Discogs ->
  RemoteAlbum* converters). Copied from src-tauri so the Slint frontend can do
  remote lookup without depending on the Tauri binary; src-tauri is left fully
  untouched (the production build keeps its own copy — no Tauri-build risk).
- tag_editor.rs: search_remote (provider QbzSelect; MusicBrainzClient::new
  search_releases_extended / DiscogsClient search_releases, generation-guarded),
  select_result, apply_remote (get_release_with_tracks / get_release_metadata ->
  full metadata; overwrites album fields + positional per-track title merge;
  track-count-mismatch + rate-limit toasts), open_in_browser.
- TagEditorModal: a remote-lookup box (provider + Search, a horizontal result
  card strip with select highlight, Apply + open-in-browser). ADR-008: square
  badges, no pills.
- state.slint: RemoteResultItem struct + remote fields/actions on TagEditor*.

LocalLibrary Slint port is now feature-complete (scan/folders, artists images,
tag editor manual+remote, tracks bulk). BUILT BLIND — runtime smoke-test pending.

cc
This commit is contained in:
vicrodh committed 2026-05-29 15:32:16 -06:00
1 parent 5a076187e7
commit 3c2f46b756
8 files changed
+1723

No files matched your search

+626
View File
@@ -0,0 +1,626 @@
//! Discogs API client for fetching album artwork
//!
//! Uses Cloudflare Workers proxy to search the Discogs database and download cover images.
use reqwest::Client;
use serde::Deserialize;
use std::fs;
use std::path::Path;
use std::time::Duration;
// Cloudflare Workers proxy URL - handles credentials
const DISCOGS_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/discogs";
/// Discogs API client
pub struct DiscogsClient {
client: Client,
}
/// Search result from Discogs API
#[derive(Debug, Deserialize, serde::Serialize, Clone)]
pub struct SearchResponse {
pub results: Vec<SearchResult>,
}
#[derive(Debug, Deserialize, serde::Serialize, Clone)]
pub struct SearchResult {
pub id: u64,
pub cover_image: Option<String>,
pub thumb: Option<String>,
pub title: String,
#[serde(rename = "type")]
pub result_type: String,
}
/// Image option for artwork selection
#[derive(Debug, Deserialize, serde::Serialize, Clone)]
pub struct DiscogsImageOption {
pub url: String,
pub width: u32,
pub height: u32,
#[serde(rename = "type")]
pub image_type: String,
pub release_title: Option<String>,
pub release_year: Option<u32>,
}
/// Release details from Discogs API (internal, for artwork)
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct ReleaseDetails {
id: u64,
title: String,
year: Option<u32>,
images: Option<Vec<ReleaseImage>>,
}
/// Image from release details
#[derive(Debug, Deserialize)]
struct ReleaseImage {
uri: String,
width: u32,
height: u32,
#[serde(rename = "type")]
image_type: String,
}
// ============ Public Metadata Structures ============
/// Full release metadata from Discogs (for tag editor)
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsReleaseMetadata {
pub id: u64,
pub title: String,
pub artists: Option<Vec<DiscogsArtist>>,
pub year: Option<u32>,
pub genres: Option<Vec<String>>,
pub styles: Option<Vec<String>>,
pub labels: Option<Vec<DiscogsLabel>>,
pub tracklist: Option<Vec<DiscogsTrack>>,
pub country: Option<String>,
/// URL to view on Discogs
pub uri: Option<String>,
}
/// Artist in Discogs release
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsArtist {
pub name: String,
pub id: Option<u64>,
/// Join phrase (e.g., " & ", " feat. ")
pub join: Option<String>,
}
/// Label in Discogs release
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsLabel {
pub name: String,
pub catno: Option<String>,
pub id: Option<u64>,
}
/// Track in Discogs release
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsTrack {
/// Position (e.g., "1", "A1", "1-1")
pub position: String,
pub title: String,
/// Duration as string (e.g., "3:45")
pub duration: Option<String>,
/// Track type (e.g., "track", "heading")
#[serde(rename = "type_")]
pub track_type: Option<String>,
}
/// Extended search result with more metadata
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
pub struct DiscogsSearchResultExtended {
pub id: u64,
pub title: String,
#[serde(rename = "type")]
pub result_type: String,
pub year: Option<String>,
pub country: Option<String>,
pub label: Option<Vec<String>>,
pub catno: Option<String>,
pub format: Option<Vec<String>>,
pub cover_image: Option<String>,
}
impl DiscogsClient {
/// Create a new Discogs client (proxy handles credentials)
pub fn new() -> Self {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("QBZ/1.0.0"),
);
let client = Client::builder()
.timeout(Duration::from_secs(10))
.default_headers(headers)
.build()
.expect("Failed to create HTTP client");
Self { client }
}
/// Check if credentials are configured (always true - proxy handles credentials)
pub fn has_credentials(&self) -> bool {
true
}
/// Search for album artwork and download if found
/// Returns the path to the downloaded image or None
pub async fn fetch_artwork(
&self,
artist: &str,
album: &str,
cache_dir: &Path,
) -> Option<String> {
// Search for the release
let cover_url = self.search_release(artist, album).await?;
// Generate cache filename
let filename = format!(
"discogs_{:x}.jpg",
Self::simple_hash(&format!("{}_{}", artist, album))
);
let cache_path = cache_dir.join(&filename);
// Return cached if exists
if cache_path.exists() {
return Some(cache_path.to_string_lossy().to_string());
}
// Download the image
self.download_image(&cover_url, &cache_path).await?;
Some(cache_path.to_string_lossy().to_string())
}
/// Search for a release and return the cover image URL
async fn search_release(&self, artist: &str, album: &str) -> Option<String> {
// Build search query
let query = format!("{} {}", artist, album);
let url = format!(
"{}/search?q={}&type=release",
DISCOGS_PROXY_URL,
urlencoding::encode(&query)
);
log::debug!("Searching Discogs for: {} - {}", artist, album);
let response = self.client.get(&url).send().await.ok()?;
if !response.status().is_success() {
log::warn!("Discogs search failed with status: {}", response.status());
return None;
}
let search: SearchResponse = response.json().await.ok()?;
// Find first result with a cover image
for result in search.results {
if result.result_type == "release" || result.result_type == "master" {
if let Some(cover) = result.cover_image {
if !cover.is_empty() && !cover.contains("spacer.gif") {
log::debug!("Found Discogs cover for {} - {}", artist, album);
return Some(cover);
}
}
// Fallback to thumbnail
if let Some(thumb) = result.thumb {
if !thumb.is_empty() && !thumb.contains("spacer.gif") {
return Some(thumb);
}
}
}
}
log::debug!("No Discogs cover found for {} - {}", artist, album);
None
}
/// Get detailed release information including all images
async fn get_release_details(&self, release_id: u64) -> Result<ReleaseDetails, String> {
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
log::debug!("Fetching Discogs release details for ID: {}", release_id);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch release details: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch release details: {}",
response.status()
));
}
let details: ReleaseDetails = response
.json()
.await
.map_err(|e| format!("Failed to parse release details: {}", e))?;
Ok(details)
}
/// Search for album artwork options
/// Returns up to 10 image options, with detailed images from top 2 releases interleaved
/// If catalog_number is provided, searches by that first, then falls back to artist + album
pub async fn search_artwork_options(
&self,
artist: &str,
album: &str,
catalog_number: Option<&str>,
) -> Result<Vec<DiscogsImageOption>, String> {
// Build search query - prefer catalog number if available
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
catno.to_string()
} else {
format!("{} {}", artist, album)
};
let url = format!(
"{}/search?q={}&type=release",
DISCOGS_PROXY_URL,
urlencoding::encode(&query)
);
log::debug!(
"Searching Discogs artwork options for: {} - {} (catalog: {:?})",
artist,
album,
catalog_number
);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Discogs search failed with status: {}",
response.status()
));
}
let search: SearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
// Get IDs of top 2 relevant releases
let mut release_ids: Vec<u64> = Vec::new();
let mut other_results: Vec<&SearchResult> = Vec::new();
for result in search.results.iter().take(20) {
if result.result_type == "release" || result.result_type == "master" {
if release_ids.len() < 2 {
release_ids.push(result.id);
} else {
other_results.push(result);
}
}
}
if release_ids.is_empty() {
return Err("No releases found on Discogs".to_string());
}
let mut all_images = Vec::new();
let mut seen_urls = std::collections::HashSet::new();
// Fetch detailed images from top 2 releases
for (idx, release_id) in release_ids.iter().enumerate() {
match self.get_release_details(*release_id).await {
Ok(details) => {
if let Some(images) = details.images {
let mut count = 0;
for img in images {
if !img.uri.is_empty()
&& !img.uri.contains("spacer.gif")
&& seen_urls.insert(img.uri.clone())
&& count < 4
{
all_images.push(DiscogsImageOption {
url: img.uri,
width: img.width,
height: img.height,
image_type: img.image_type,
release_title: Some(details.title.clone()),
release_year: details.year,
});
count += 1;
}
}
log::debug!(
"Added {} images from release #{} ({})",
count,
idx + 1,
details.title
);
}
}
Err(e) => {
log::warn!("Failed to fetch details for release {}: {}", release_id, e);
}
}
}
// Add up to 2 more images from other search results
for result in other_results.iter().take(10) {
if all_images.len() >= 10 {
break;
}
// Prefer cover image
let image_url = if let Some(cover) = &result.cover_image {
if !cover.is_empty() && !cover.contains("spacer.gif") {
Some((cover.clone(), 600, 600, "primary".to_string()))
} else {
None
}
} else {
None
};
let image_url = image_url.or_else(|| {
result.thumb.as_ref().and_then(|thumb| {
if !thumb.is_empty() && !thumb.contains("spacer.gif") {
Some((thumb.clone(), 150, 150, "secondary".to_string()))
} else {
None
}
})
});
if let Some((url, width, height, img_type)) = image_url {
if seen_urls.insert(url.clone()) {
all_images.push(DiscogsImageOption {
url,
width,
height,
image_type: img_type,
release_title: Some(result.title.clone()),
release_year: None,
});
}
}
}
if all_images.is_empty() {
return Err("No artwork found on Discogs".to_string());
}
// Return up to 10 unique images
all_images.truncate(10);
log::info!(
"Returning {} artwork options from Discogs",
all_images.len()
);
Ok(all_images)
}
/// Download image from URL and return local path
pub async fn download_artwork_from_url(
&self,
image_url: &str,
cache_dir: &Path,
artist: &str,
album: &str,
) -> Result<String, String> {
// Generate cache filename
let filename = format!(
"discogs_{:x}.jpg",
Self::simple_hash(&format!("{}_{}", artist, album))
);
let cache_path = cache_dir.join(&filename);
// Download the image
self.download_image(image_url, &cache_path)
.await
.ok_or_else(|| "Failed to download image".to_string())?;
Ok(cache_path.to_string_lossy().to_string())
}
/// Search for artists and return search results
pub async fn search_artist(&self, query: &str) -> Result<SearchResponse, String> {
let url = format!(
"{}/search?q={}&type=artist",
DISCOGS_PROXY_URL,
urlencoding::encode(query)
);
log::debug!("Searching Discogs for artist: {}", query);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Discogs search failed with status: {}",
response.status()
));
}
let search: SearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
Ok(search)
}
/// Search for releases with extended metadata
/// Returns up to `limit` results with detailed release information
pub async fn search_releases(
&self,
artist: &str,
album: &str,
catalog_number: Option<&str>,
limit: usize,
) -> Result<Vec<DiscogsSearchResultExtended>, String> {
// Build search query - prefer catalog number if available
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
catno.to_string()
} else {
format!("{} {}", artist, album)
};
let url = format!(
"{}/search?q={}&type=release&per_page={}",
DISCOGS_PROXY_URL,
urlencoding::encode(&query),
limit.min(25)
);
log::debug!(
"Searching Discogs releases for: {} - {} (catalog: {:?})",
artist,
album,
catalog_number
);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to search Discogs: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Discogs search failed with status: {}",
response.status()
));
}
// Parse response with extended fields
#[derive(Debug, Deserialize)]
struct ExtendedSearchResponse {
results: Vec<DiscogsSearchResultExtended>,
}
let search: ExtendedSearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Discogs response: {}", e))?;
// Filter to releases only
let results: Vec<_> = search
.results
.into_iter()
.filter(|r| r.result_type == "release" || r.result_type == "master")
.collect();
log::info!("Found {} Discogs releases", results.len());
Ok(results)
}
/// Get full release metadata including tracklist
pub async fn get_release_metadata(
&self,
release_id: u64,
) -> Result<DiscogsReleaseMetadata, String> {
let url = format!("{}/release/{}", DISCOGS_PROXY_URL, release_id);
log::debug!("Fetching Discogs release metadata for ID: {}", release_id);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch release: {}", e))?;
if !response.status().is_success() {
return Err(format!("Failed to fetch release: {}", response.status()));
}
let metadata: DiscogsReleaseMetadata = response
.json()
.await
.map_err(|e| format!("Failed to parse release metadata: {}", e))?;
log::info!(
"Fetched Discogs release: {} ({:?})",
metadata.title,
metadata.year
);
Ok(metadata)
}
/// Download an image to the cache directory
async fn download_image(&self, image_url: &str, path: &Path) -> Option<()> {
log::debug!("Downloading Discogs artwork: {}", image_url);
// Use proxy to download image with authentication
let proxy_url = format!(
"{}/image?url={}",
DISCOGS_PROXY_URL,
urlencoding::encode(image_url)
);
let response = self.client.get(&proxy_url).send().await.ok()?;
if !response.status().is_success() {
log::warn!("Failed to download Discogs image: {}", response.status());
return None;
}
let bytes = response.bytes().await.ok()?;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok()?;
}
fs::write(path, &bytes).ok()?;
log::info!("Saved Discogs artwork to: {}", path.display());
Some(())
}
/// Simple hash function for generating filenames
fn simple_hash(s: &str) -> u64 {
let mut hash: u64 = 5381;
for byte in s.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u64);
}
hash
}
}
impl Default for DiscogsClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash() {
let hash1 = DiscogsClient::simple_hash("Artist_Album");
let hash2 = DiscogsClient::simple_hash("Artist_Album");
assert_eq!(hash1, hash2);
let hash3 = DiscogsClient::simple_hash("Different_Album");
assert_ne!(hash1, hash3);
}
}
+8
View File
@@ -23,14 +23,22 @@
//!
//! The crate exposes async APIs that can be called from any async runtime.
pub mod discogs;
pub mod error;
pub mod lastfm;
pub mod listenbrainz;
pub mod musicbrainz;
pub mod remote_metadata;
pub use error::{IntegrationError, IntegrationResult};
// Re-export main types for convenience
pub use discogs::DiscogsClient;
pub use lastfm::{LastFmClient, LastFmSession};
pub use listenbrainz::{ListenBrainzClient, ListenBrainzConfig, ListenType};
pub use musicbrainz::{MusicBrainzClient, MusicBrainzConfig};
pub use remote_metadata::{
discogs_extended_to_search_result, discogs_full_to_metadata,
musicbrainz_full_to_metadata, musicbrainz_release_to_search_result, RemoteAlbumMetadata,
RemoteAlbumSearchResult, RemoteProvider, RemoteTrackMetadata,
};
@@ -0,0 +1,388 @@
//! Remote-metadata converters (MusicBrainz + Discogs -> unified DTOs).
//!
//! Frontend-agnostic copy of the pure converters that live in
//! `src-tauri/src/library/remote_metadata/`, so the Slint frontend can do
//! remote album lookup via `qbz_integrations` without depending on the Tauri
//! binary. The Tauri side keeps its own copy + its cache/state orchestration;
//! only these pure adapters are shared here.
mod models;
pub use models::{
RemoteAlbumMetadata, RemoteAlbumSearchResult, RemoteMetadataError, RemoteProvider,
RemoteSearchRequest, RemoteSearchResponse, RemoteTrackMetadata,
};
pub fn musicbrainz_release_to_search_result(
release: &crate::musicbrainz::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));
// Get track count - either from direct field or sum from media
let track_count = release.track_count.or_else(|| {
release
.media
.as_ref()
.map(|media| media.iter().filter_map(|m| m.track_count).sum())
});
// Get format from first medium
let format = release
.media
.as_ref()
.and_then(|m| m.first())
.and_then(|m| m.format.clone());
RemoteAlbumSearchResult {
provider: RemoteProvider::MusicBrainz,
provider_id: release.id.clone(),
title: release.title.clone(),
artist,
year,
track_count,
country: release.country.clone(),
label,
catalog_number,
confidence: release.score.map(|s| s.min(100) as u8),
format,
}
}
// ============ Discogs Adapter ============
/// 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,
}
}
/// 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: Vec<String> = 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()
})
.unwrap_or_default();
// 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).unwrap_or(1);
// Convert tracks
let tracks: Vec<RemoteTrackMetadata> = 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.unwrap_or(1),
track_number: track.position.unwrap_or(1),
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
})
.unwrap_or_default();
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: Vec<String> = {
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());
}
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: Vec<RemoteTrackMetadata> = 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,
track_number,
title: t.title.clone(),
duration_ms: t.duration.as_ref().and_then(|d| parse_discogs_duration(d)),
}
})
.collect()
})
.unwrap_or_default();
// Count unique discs
let disc_count = tracks.iter().map(|t| t.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(),
}
}
@@ -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
}
}