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:
8 files changed
+1723
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,22 @@
|
|||||||
//!
|
//!
|
||||||
//! The crate exposes async APIs that can be called from any async runtime.
|
//! The crate exposes async APIs that can be called from any async runtime.
|
||||||
|
|
||||||
|
pub mod discogs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod lastfm;
|
pub mod lastfm;
|
||||||
pub mod listenbrainz;
|
pub mod listenbrainz;
|
||||||
pub mod musicbrainz;
|
pub mod musicbrainz;
|
||||||
|
pub mod remote_metadata;
|
||||||
|
|
||||||
pub use error::{IntegrationError, IntegrationResult};
|
pub use error::{IntegrationError, IntegrationResult};
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
|
pub use discogs::DiscogsClient;
|
||||||
pub use lastfm::{LastFmClient, LastFmSession};
|
pub use lastfm::{LastFmClient, LastFmSession};
|
||||||
pub use listenbrainz::{ListenBrainzClient, ListenBrainzConfig, ListenType};
|
pub use listenbrainz::{ListenBrainzClient, ListenBrainzConfig, ListenType};
|
||||||
pub use musicbrainz::{MusicBrainzClient, MusicBrainzConfig};
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4187,6 +4187,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<TagEditorActions>()
|
||||||
|
.on_set_provider(move |i| {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.global::<TagEditorState>().set_remote_provider_index(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<TagEditorActions>()
|
||||||
|
.on_search_remote(move || tag_editor::search_remote(weak.clone(), handle.clone()));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<TagEditorActions>()
|
||||||
|
.on_select_result(move |id| tag_editor::select_result(weak.clone(), id.to_string()));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<TagEditorActions>()
|
||||||
|
.on_apply_remote(move || tag_editor::apply_remote(weak.clone(), handle.clone()));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<TagEditorActions>()
|
||||||
|
.on_open_in_browser(move || tag_editor::open_in_browser(weak.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
// Local Library — Albums tab controls (search / sort re-query page 1;
|
// Local Library — Albums tab controls (search / sort re-query page 1;
|
||||||
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ fn populate(
|
|||||||
s.set_write_progress_current(0);
|
s.set_write_progress_current(0);
|
||||||
s.set_write_progress_total(0);
|
s.set_write_progress_total(0);
|
||||||
s.set_tracks(ModelRc::new(VecModel::from(rows)));
|
s.set_tracks(ModelRc::new(VecModel::from(rows)));
|
||||||
|
// Reset remote-lookup state.
|
||||||
|
s.set_remote_provider_index(0);
|
||||||
|
s.set_remote_searching(false);
|
||||||
|
s.set_remote_loading(false);
|
||||||
|
s.set_remote_results(ModelRc::new(VecModel::from(Vec::<crate::RemoteResultItem>::new())));
|
||||||
|
s.set_selected_result_id("".into());
|
||||||
|
s.set_show_remote_panel(false);
|
||||||
|
s.set_has_searched(false);
|
||||||
s.set_open(true);
|
s.set_open(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +387,204 @@ pub fn save_tags(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================== Remote lookup =============================
|
||||||
|
|
||||||
|
/// Remote search/apply generation — a newer request supersedes a slow one.
|
||||||
|
static REMOTE_GEN: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn map_search(r: &qbz_integrations::RemoteAlbumSearchResult) -> crate::RemoteResultItem {
|
||||||
|
let provider = if matches!(r.provider, qbz_integrations::RemoteProvider::Discogs) {
|
||||||
|
"discogs"
|
||||||
|
} else {
|
||||||
|
"musicbrainz"
|
||||||
|
};
|
||||||
|
crate::RemoteResultItem {
|
||||||
|
provider: provider.into(),
|
||||||
|
provider_id: r.provider_id.clone().into(),
|
||||||
|
title: r.title.clone().into(),
|
||||||
|
artist: r.artist.clone().into(),
|
||||||
|
year: r.year.unwrap_or(0) as i32,
|
||||||
|
has_year: r.year.is_some(),
|
||||||
|
track_count: r.track_count.unwrap_or(0) as i32,
|
||||||
|
has_track_count: r.track_count.is_some(),
|
||||||
|
country: r.country.clone().unwrap_or_default().into(),
|
||||||
|
format: r.format.clone().unwrap_or_default().into(),
|
||||||
|
label: r.label.clone().unwrap_or_default().into(),
|
||||||
|
catalog_number: r.catalog_number.clone().unwrap_or_default().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search the selected provider (MusicBrainz/Discogs) for the current album
|
||||||
|
/// title + artist. Generation-guarded so a slow reply can't clobber a newer one.
|
||||||
|
pub fn search_remote(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let Some(w) = weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let s = w.global::<TagEditorState>();
|
||||||
|
let title = s.get_album_title().trim().to_string();
|
||||||
|
let artist = s.get_album_artist().trim().to_string();
|
||||||
|
let provider = s.get_remote_provider_index();
|
||||||
|
if title.is_empty() && artist.is_empty() {
|
||||||
|
crate::toast::error_weak(&weak, "Enter a title or artist to search");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let gen = REMOTE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
s.set_remote_searching(true);
|
||||||
|
|
||||||
|
handle.spawn(async move {
|
||||||
|
let results: Result<Vec<crate::RemoteResultItem>, String> = if provider == 1 {
|
||||||
|
let dc = qbz_integrations::DiscogsClient::new();
|
||||||
|
dc.search_releases(&artist, &title, None, 12).await.map(|v| {
|
||||||
|
v.iter()
|
||||||
|
.map(|r| map_search(&qbz_integrations::discogs_extended_to_search_result(r)))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let mb = qbz_integrations::MusicBrainzClient::new();
|
||||||
|
mb.search_releases_extended(&title, &artist, None, 12)
|
||||||
|
.await
|
||||||
|
.map(|resp| {
|
||||||
|
resp.releases
|
||||||
|
.iter()
|
||||||
|
.map(|r| map_search(&qbz_integrations::musicbrainz_release_to_search_result(r)))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
};
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
if REMOTE_GEN.load(Ordering::SeqCst) != gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let s = w.global::<TagEditorState>();
|
||||||
|
s.set_remote_searching(false);
|
||||||
|
s.set_has_searched(true);
|
||||||
|
match results {
|
||||||
|
Ok(items) => {
|
||||||
|
let empty = items.is_empty();
|
||||||
|
s.set_remote_results(ModelRc::new(VecModel::from(items)));
|
||||||
|
s.set_show_remote_panel(!empty);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
s.set_show_remote_panel(false);
|
||||||
|
crate::toast::error(&w, format!("Search failed: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a result card selected.
|
||||||
|
pub fn select_result(weak: Weak<AppWindow>, provider_id: String) {
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
w.global::<TagEditorState>().set_selected_result_id(provider_id.into());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the selected result's full metadata and apply it (album fields +
|
||||||
|
/// positional per-track titles). Generation-guarded.
|
||||||
|
pub fn apply_remote(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let Some(w) = weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let s = w.global::<TagEditorState>();
|
||||||
|
let id = s.get_selected_result_id().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let provider = s.get_remote_provider_index();
|
||||||
|
let gen = REMOTE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
s.set_remote_loading(true);
|
||||||
|
|
||||||
|
handle.spawn(async move {
|
||||||
|
let meta: Result<qbz_integrations::RemoteAlbumMetadata, String> = if provider == 1 {
|
||||||
|
match id.parse::<u64>() {
|
||||||
|
Ok(rid) => {
|
||||||
|
let dc = qbz_integrations::DiscogsClient::new();
|
||||||
|
dc.get_release_metadata(rid)
|
||||||
|
.await
|
||||||
|
.map(|m| qbz_integrations::discogs_full_to_metadata(&m))
|
||||||
|
}
|
||||||
|
Err(_) => Err("Invalid Discogs release id".to_string()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mb = qbz_integrations::MusicBrainzClient::new();
|
||||||
|
mb.get_release_with_tracks(&id)
|
||||||
|
.await
|
||||||
|
.map(|r| qbz_integrations::musicbrainz_full_to_metadata(&r))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
};
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
if REMOTE_GEN.load(Ordering::SeqCst) != gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let s = w.global::<TagEditorState>();
|
||||||
|
s.set_remote_loading(false);
|
||||||
|
match meta {
|
||||||
|
Ok(m) => {
|
||||||
|
s.set_album_title(m.title.clone().into());
|
||||||
|
s.set_album_artist(m.artist.clone().into());
|
||||||
|
if let Some(y) = m.year {
|
||||||
|
s.set_year_input(y.to_string().into());
|
||||||
|
}
|
||||||
|
if let Some(g) = m.genres.first() {
|
||||||
|
s.set_genre(g.clone().into());
|
||||||
|
}
|
||||||
|
if let Some(c) = m.catalog_number.as_ref() {
|
||||||
|
s.set_catalog_number(c.clone().into());
|
||||||
|
}
|
||||||
|
if m.disc_count > 0 {
|
||||||
|
s.set_album_total_discs(m.disc_count as i32);
|
||||||
|
}
|
||||||
|
// Positional per-track title merge.
|
||||||
|
let model = s.get_tracks();
|
||||||
|
let local_n = model.row_count();
|
||||||
|
let n = local_n.min(m.tracks.len());
|
||||||
|
for i in 0..n {
|
||||||
|
if let Some(mut row) = model.row_data(i) {
|
||||||
|
row.title = m.tracks[i].title.clone().into();
|
||||||
|
model.set_row_data(i, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.set_show_remote_panel(false);
|
||||||
|
let remote_n = m.tracks.len();
|
||||||
|
if remote_n > 0 && remote_n != local_n {
|
||||||
|
crate::toast::warning(
|
||||||
|
&w,
|
||||||
|
"Track count differs from the result; titles applied by position",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let lower = e.to_lowercase();
|
||||||
|
if lower.contains("429") || lower.contains("rate") {
|
||||||
|
crate::toast::error(&w, "Rate limited, try again shortly");
|
||||||
|
} else {
|
||||||
|
crate::toast::error(&w, "Failed to fetch metadata");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the selected result's provider page in the system browser.
|
||||||
|
pub fn open_in_browser(weak: Weak<AppWindow>) {
|
||||||
|
let Some(w) = weak.upgrade() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let s = w.global::<TagEditorState>();
|
||||||
|
let id = s.get_selected_result_id().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = if s.get_remote_provider_index() == 1 {
|
||||||
|
format!("https://www.discogs.com/release/{id}")
|
||||||
|
} else {
|
||||||
|
format!("https://musicbrainz.org/release/{id}")
|
||||||
|
};
|
||||||
|
let _ = open::that(url);
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-fetch the album's tracks and re-apply the detail view, and reset the
|
/// Re-fetch the album's tracks and re-apply the detail view, and reset the
|
||||||
/// LocalLibrary browse models so the tabs re-fetch on next visit. Avoids a
|
/// LocalLibrary browse models so the tabs re-fetch on next visit. Avoids a
|
||||||
/// full library reload (the 16K-track freeze).
|
/// full library reload (the 16K-track freeze).
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Radius } from "../foundation/radius.slint";
|
|||||||
import { TagEditorState, TagEditorActions } from "../state.slint";
|
import { TagEditorState, TagEditorActions } from "../state.slint";
|
||||||
import { QbzIcon } from "QbzIcon.slint";
|
import { QbzIcon } from "QbzIcon.slint";
|
||||||
import { QbzSelect } from "QbzSelect.slint";
|
import { QbzSelect } from "QbzSelect.slint";
|
||||||
|
import { LoadingSpinner } from "LoadingSpinner.slint";
|
||||||
import { LineEdit, ScrollView } from "std-widgets.slint";
|
import { LineEdit, ScrollView } from "std-widgets.slint";
|
||||||
|
|
||||||
// A labeled field block (label above the control).
|
// A labeled field block (label above the control).
|
||||||
@@ -144,6 +145,197 @@ export component TagEditorModal inherits Rectangle {
|
|||||||
font-size: Typography.legal;
|
font-size: Typography.legal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remote metadata lookup (MusicBrainz / Discogs).
|
||||||
|
Rectangle {
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 10px;
|
||||||
|
spacing: 8px;
|
||||||
|
// Provider + search.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzSelect {
|
||||||
|
menu-width: 150px;
|
||||||
|
sm: true;
|
||||||
|
options: ["MusicBrainz", "Discogs"];
|
||||||
|
current-index: TagEditorState.remote_provider_index;
|
||||||
|
selected(i) => {
|
||||||
|
TagEditorActions.set-provider(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: search-row.preferred-width + 26px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: search-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
|
||||||
|
search-row := HorizontalLayout {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
spacing: 6px;
|
||||||
|
if TagEditorState.remote_searching: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
LoadingSpinner { size: 14px; }
|
||||||
|
}
|
||||||
|
if !TagEditorState.remote_searching: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/search.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: TagEditorState.remote_searching ? @tr("Searching…") : @tr("Search");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search-ta := TouchArea {
|
||||||
|
mouse-cursor: TagEditorState.remote_searching ? default : pointer;
|
||||||
|
clicked => {
|
||||||
|
if (!TagEditorState.remote_searching) {
|
||||||
|
TagEditorActions.search-remote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-results note.
|
||||||
|
if TagEditorState.has_searched && !TagEditorState.remote_searching
|
||||||
|
&& TagEditorState.remote_results.length == 0: Text {
|
||||||
|
text: @tr("No results, try a different search");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results — horizontal scroll of selectable cards.
|
||||||
|
if TagEditorState.show_remote_panel
|
||||||
|
&& TagEditorState.remote_results.length > 0: Rectangle {
|
||||||
|
height: 116px;
|
||||||
|
ScrollView {
|
||||||
|
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
for r in TagEditorState.remote_results: Rectangle {
|
||||||
|
width: 200px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
property <bool> sel: r.provider_id == TagEditorState.selected_result_id;
|
||||||
|
border-color: self.sel ? Theme.accent : Theme.border-subtle;
|
||||||
|
background: self.sel ? Theme.surface-hover : Theme.surface-card;
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 8px;
|
||||||
|
spacing: 3px;
|
||||||
|
Text {
|
||||||
|
text: r.title;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: r.artist;
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: (r.has-year ? "\{r.year}" : "—")
|
||||||
|
+ (r.has-track-count ? " · \{r.track-count} " + @tr("tracks") : "")
|
||||||
|
+ (r.country != "" ? " · " + r.country : "");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: r.format != "" ? r.format : (r.label != "" ? r.label : "");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
TagEditorActions.select-result(r.provider_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply + open-in-browser (when a result is selected).
|
||||||
|
if TagEditorState.selected_result_id != "": HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
// Open in browser.
|
||||||
|
Rectangle {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: ext-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/external-link.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
ext-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
TagEditorActions.open-in-browser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply.
|
||||||
|
Rectangle {
|
||||||
|
width: apply-label.preferred-width + 28px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: apply-ta.has-hover && !TagEditorState.remote_loading
|
||||||
|
? Theme.accent-hover : Theme.accent;
|
||||||
|
opacity: TagEditorState.remote_loading ? 0.6 : 1.0;
|
||||||
|
apply-label := Text {
|
||||||
|
text: TagEditorState.remote_loading ? @tr("Loading…") : @tr("Apply");
|
||||||
|
color: Theme.accent-text;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
apply-ta := TouchArea {
|
||||||
|
mouse-cursor: TagEditorState.remote_loading ? default : pointer;
|
||||||
|
clicked => {
|
||||||
|
if (!TagEditorState.remote_loading) {
|
||||||
|
TagEditorActions.apply-remote();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track table.
|
// Track table.
|
||||||
Text {
|
Text {
|
||||||
text: @tr("Tracks");
|
text: @tr("Tracks");
|
||||||
|
|||||||
@@ -628,6 +628,23 @@ export struct TagTrackEdit {
|
|||||||
track_number: string, // raw text (LineEdit); controller parses on save ("" = unset)
|
track_number: string, // raw text (LineEdit); controller parses on save ("" = unset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One remote (MusicBrainz/Discogs) search-result card. Option fields map to
|
||||||
|
// a 0/"" sentinel + a has_* companion where the zero value is valid.
|
||||||
|
export struct RemoteResultItem {
|
||||||
|
provider: string, // "musicbrainz" | "discogs"
|
||||||
|
provider_id: string, // selection key + open-in-browser
|
||||||
|
title: string,
|
||||||
|
artist: string,
|
||||||
|
year: int,
|
||||||
|
has_year: bool,
|
||||||
|
track_count: int,
|
||||||
|
has_track_count: bool,
|
||||||
|
country: string,
|
||||||
|
format: string,
|
||||||
|
label: string,
|
||||||
|
catalog_number: string,
|
||||||
|
}
|
||||||
|
|
||||||
export global TagEditorState {
|
export global TagEditorState {
|
||||||
in-out property <bool> open: false;
|
in-out property <bool> open: false;
|
||||||
in property <string> album_group_key; // == LocalAlbum.id == directory_path
|
in property <string> album_group_key; // == LocalAlbum.id == directory_path
|
||||||
@@ -644,12 +661,25 @@ export global TagEditorState {
|
|||||||
in-out property <bool> saving: false;
|
in-out property <bool> saving: false;
|
||||||
in-out property <int> write_progress_current: 0;
|
in-out property <int> write_progress_current: 0;
|
||||||
in-out property <int> write_progress_total: 0;
|
in-out property <int> write_progress_total: 0;
|
||||||
|
// Remote lookup (MusicBrainz / Discogs).
|
||||||
|
in-out property <int> remote_provider_index: 0; // 0 musicbrainz, 1 discogs
|
||||||
|
in-out property <bool> remote_searching: false;
|
||||||
|
in-out property <bool> remote_loading: false; // apply (get full) in flight
|
||||||
|
in-out property <[RemoteResultItem]> remote_results: [];
|
||||||
|
in-out property <string> selected_result_id: ""; // "" = none
|
||||||
|
in-out property <bool> show_remote_panel: false;
|
||||||
|
in-out property <bool> has_searched: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export global TagEditorActions {
|
export global TagEditorActions {
|
||||||
callback close();
|
callback close();
|
||||||
callback save();
|
callback save();
|
||||||
callback set-persistence(int); // QbzSelect index -> mode (controller maps)
|
callback set-persistence(int); // QbzSelect index -> mode (controller maps)
|
||||||
|
callback set-provider(int); // provider QbzSelect index
|
||||||
|
callback search-remote();
|
||||||
|
callback select-result(string /* provider_id */);
|
||||||
|
callback apply-remote();
|
||||||
|
callback open-in-browser();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
||||||
|
|||||||
Reference in new issue
Block a user