feat: port full MusicBrainz API to V2 crate + add cache persistence

V2 MusicBrainz client now has all methods from legacy:
- search_recording, get_artist_with_relations, get_artist_tags
- search_artists_by_tag, search_artists_by_tag_and_area
- search_release_by_barcode, search_releases_extended
- get_release_with_tracks, browse_artists_by_area
- search_area, get_area_with_relations, resolve_parent_subdivision

V2 MusicBrainz cache now has:
- Settings persistence (is_enabled/set_enabled)
- All legacy cache tables (relations, metadata, scene, qobuz_validation)
- clear_all, cleanup_expired, get_stats

V2 ListenBrainz cache adds:
- mark_listens_sent (batch), get_queue_count, clear_queue

V2 state wrappers (integrations_v2.rs) now include:
- Cache field on both MusicBrainzV2State and ListenBrainzV2State
- init_cache_at, init_from_cache, teardown methods
- ListenBrainzV2State credentials auto-persist to V2 cache

V2 models include all types from legacy (ArtistRelationships,
ArtistMetadata, LocationDiscoveryResponse, ResolvedMusician, etc.)
This commit is contained in:
vicrodh committed 2026-03-10 14:14:45 -06:00
1 parent f660f9b068
commit f473065c21
5 files changed
+1404 -108

No files matched your search

@@ -227,6 +227,44 @@ impl ListenBrainzCache {
Ok(())
}
/// Batch mark multiple listens as sent
pub fn mark_listens_sent(&self, ids: &[i64]) -> Result<(), String> {
if ids.is_empty() {
return Ok(());
}
let placeholders: Vec<String> = ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"UPDATE listen_queue SET sent = 1 WHERE id IN ({})",
placeholders.join(", ")
);
let params: Vec<Box<dyn rusqlite::types::ToSql>> = ids
.iter()
.map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
.collect();
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
self.conn
.execute(&sql, params_refs.as_slice())
.map_err(|e| format!("Failed to batch mark sent: {}", e))?;
Ok(())
}
/// Get count of unsent listens in queue
pub fn get_queue_count(&self) -> Result<u32, String> {
let count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM listen_queue WHERE sent = 0", [], |row| row.get(0))
.unwrap_or(0);
Ok(count as u32)
}
/// Clear all queued listens
pub fn clear_queue(&self) -> Result<(), String> {
self.conn
.execute("DELETE FROM listen_queue", [])
.map_err(|e| format!("Failed to clear queue: {}", e))?;
Ok(())
}
/// Delete old sent listens
pub fn cleanup_sent(&self, older_than_days: u32) -> Result<u64, String> {
let cutoff = chrono::Utc::now().timestamp() - (older_than_days as i64 * 86400);
+483 -95
View File
@@ -1,23 +1,39 @@
//! MusicBrainz cache for resolved entities
//! MusicBrainz cache for resolved entities and settings
//!
//! SQLite-based cache with TTL expiration for MusicBrainz lookups.
//! Caches:
//! - Resolved tracks (by ISRC)
//! - Resolved artists (by name)
//! - Resolved releases (by title/artist)
//! Also persists integration settings (enabled state).
use rusqlite::{Connection, Result as SqlResult};
use rusqlite::{params, Connection, OptionalExtension};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use super::models::{MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType};
use super::models::{
ArtistMetadata, ArtistRelationships, LocationDiscoveryResponse,
MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType,
};
/// TTL for recording cache (30 days)
const RECORDING_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for artist cache (7 days)
const ARTIST_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// TTL for release cache (30 days)
const RELEASE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for artist relationships cache (7 days)
const RELATIONS_TTL_SECS: i64 = 7 * 24 * 60 * 60;
/// TTL for artist metadata cache (30 days)
const METADATA_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for scene discovery cache (30 days)
const SCENE_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// TTL for Qobuz artist validation cache (30 days)
const QOBUZ_VALIDATION_TTL_SECS: i64 = 30 * 24 * 60 * 60;
/// Cache statistics
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, serde::Serialize)]
pub struct CacheStats {
pub tracks: u64,
pub recordings: u64,
pub artists: u64,
pub hits: u64,
pub misses: u64,
pub releases: u64,
pub relations: u64,
}
/// MusicBrainz cache
@@ -45,6 +61,69 @@ impl MusicBrainzCache {
self.conn
.execute_batch(
"
-- Settings (enabled state, etc.)
CREATE TABLE IF NOT EXISTS mb_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Recordings indexed by ISRC
CREATE TABLE IF NOT EXISTS mb_recordings (
isrc TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_recordings_fetched ON mb_recordings(fetched_at);
-- Artists indexed by normalized name
CREATE TABLE IF NOT EXISTS mb_artists (
name_normalized TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_artists_fetched ON mb_artists(fetched_at);
-- Releases indexed by UPC/barcode
CREATE TABLE IF NOT EXISTS mb_releases (
barcode TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_releases_fetched ON mb_releases(fetched_at);
-- Artist relationships indexed by MBID
CREATE TABLE IF NOT EXISTS mb_artist_relations (
mbid TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_relations_fetched ON mb_artist_relations(fetched_at);
-- Artist metadata (location, genres, life span) indexed by MBID
CREATE TABLE IF NOT EXISTS mb_artist_metadata (
mbid TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_metadata_fetched ON mb_artist_metadata(fetched_at);
-- Scene discovery results indexed by area + seed hash
CREATE TABLE IF NOT EXISTS mb_scene_cache (
cache_key TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_scene_fetched ON mb_scene_cache(fetched_at);
-- Qobuz artist validation cache
CREATE TABLE IF NOT EXISTS mb_qobuz_validation (
name_normalized TEXT PRIMARY KEY,
data TEXT NOT NULL,
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mb_qobuz_validation_fetched ON mb_qobuz_validation(fetched_at);
-- V2 resolved tracks (simple cache)
CREATE TABLE IF NOT EXISTS resolved_tracks (
isrc TEXT PRIMARY KEY,
recording_mbid TEXT NOT NULL,
@@ -56,6 +135,7 @@ impl MusicBrainzCache {
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- V2 resolved artists (simple cache)
CREATE TABLE IF NOT EXISTS resolved_artists (
name_lower TEXT PRIMARY KEY,
mbid TEXT NOT NULL,
@@ -72,7 +152,6 @@ impl MusicBrainzCache {
key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO cache_stats (key, value) VALUES ('hits', 0);
INSERT OR IGNORE INTO cache_stats (key, value) VALUES ('misses', 0);
",
@@ -80,9 +159,315 @@ impl MusicBrainzCache {
.map_err(|e| format!("Failed to init MusicBrainz schema: {}", e))
}
/// Get cached track by ISRC
fn current_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Normalize artist name for consistent cache keys
pub fn normalize_name(name: &str) -> String {
name.to_lowercase()
.trim()
.replace(['\'', '"', '.', ','], "")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
// ============ Settings ============
/// Check if MusicBrainz is enabled
pub fn is_enabled(&self) -> Result<bool, String> {
let result: rusqlite::Result<String> = self.conn.query_row(
"SELECT value FROM mb_settings WHERE key = 'enabled'",
[],
|row| row.get(0),
);
match result {
Ok(val) => Ok(val != "0"),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(true), // Default enabled
Err(e) => Err(format!("Failed to get enabled state: {}", e)),
}
}
/// Set enabled state
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
let value = if enabled { "1" } else { "0" };
self.conn
.execute(
"INSERT OR REPLACE INTO mb_settings (key, value) VALUES ('enabled', ?)",
[value],
)
.map_err(|e| format!("Failed to set enabled: {}", e))?;
Ok(())
}
// ============ Recording Cache (JSON-serialized) ============
/// Get cached recording by ISRC (legacy format)
pub fn get_recording(&self, isrc: &str) -> Result<Option<serde_json::Value>, String> {
let min_fetched_at = Self::current_timestamp() - RECORDING_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_recordings WHERE isrc = ? AND fetched_at > ?",
params![isrc, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query recording cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached recording: {}", e))
} else {
Ok(None)
}
}
/// Cache a recording (JSON-serialized)
pub fn set_recording<T: serde::Serialize>(&self, isrc: &str, data: &T) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize recording: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_recordings (isrc, data, fetched_at) VALUES (?, ?, ?)",
params![isrc, json, fetched_at],
)
.map_err(|e| format!("Failed to cache recording: {}", e))?;
Ok(())
}
// ============ Artist Cache (JSON-serialized) ============
/// Get cached artist by name (JSON-serialized)
pub fn get_artist_by_name<T: serde::de::DeserializeOwned>(&self, name: &str) -> Result<Option<T>, String> {
let normalized = Self::normalize_name(name);
let min_fetched_at = Self::current_timestamp() - ARTIST_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_artists WHERE name_normalized = ? AND fetched_at > ?",
params![normalized, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query artist cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached artist: {}", e))
} else {
Ok(None)
}
}
/// Cache an artist (JSON-serialized)
pub fn set_artist_by_name<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<(), String> {
let normalized = Self::normalize_name(name);
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize artist: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_artists (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
params![normalized, json, fetched_at],
)
.map_err(|e| format!("Failed to cache artist: {}", e))?;
Ok(())
}
// ============ Release Cache ============
/// Get cached release by barcode
pub fn get_release<T: serde::de::DeserializeOwned>(&self, barcode: &str) -> Result<Option<T>, String> {
let min_fetched_at = Self::current_timestamp() - RELEASE_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_releases WHERE barcode = ? AND fetched_at > ?",
params![barcode, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query release cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached release: {}", e))
} else {
Ok(None)
}
}
/// Cache a release
pub fn set_release<T: serde::Serialize>(&self, barcode: &str, data: &T) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize release: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_releases (barcode, data, fetched_at) VALUES (?, ?, ?)",
params![barcode, json, fetched_at],
)
.map_err(|e| format!("Failed to cache release: {}", e))?;
Ok(())
}
// ============ Artist Relations Cache ============
/// Get cached artist relationships by MBID
pub fn get_artist_relations(&self, mbid: &str) -> Result<Option<ArtistRelationships>, String> {
let min_fetched_at = Self::current_timestamp() - RELATIONS_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_artist_relations WHERE mbid = ? AND fetched_at > ?",
params![mbid, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query relations cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached relations: {}", e))
} else {
Ok(None)
}
}
/// Cache artist relationships
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize relations: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_artist_relations (mbid, data, fetched_at) VALUES (?, ?, ?)",
params![mbid, json, fetched_at],
)
.map_err(|e| format!("Failed to cache relations: {}", e))?;
Ok(())
}
// ============ Artist Metadata Cache ============
/// Get cached artist metadata by MBID
pub fn get_artist_metadata(&self, mbid: &str) -> Result<Option<ArtistMetadata>, String> {
let min_fetched_at = Self::current_timestamp() - METADATA_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_artist_metadata WHERE mbid = ? AND fetched_at > ?",
params![mbid, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query metadata cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached metadata: {}", e))
} else {
Ok(None)
}
}
/// Cache artist metadata
pub fn set_artist_metadata(&self, mbid: &str, data: &ArtistMetadata) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize metadata: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_artist_metadata (mbid, data, fetched_at) VALUES (?, ?, ?)",
params![mbid, json, fetched_at],
)
.map_err(|e| format!("Failed to cache metadata: {}", e))?;
Ok(())
}
// ============ Scene Discovery Cache ============
/// Get cached scene discovery results
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> {
let min_fetched_at = Self::current_timestamp() - SCENE_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_scene_cache WHERE cache_key = ? AND fetched_at > ?",
params![cache_key, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query scene cache: {}", e))?;
if let Some(data) = result {
serde_json::from_str(&data)
.map(Some)
.map_err(|e| format!("Failed to parse cached scene: {}", e))
} else {
Ok(None)
}
}
/// Cache scene discovery results
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
let json = serde_json::to_string(data)
.map_err(|e| format!("Failed to serialize scene: {}", e))?;
self.conn
.execute(
"INSERT OR REPLACE INTO mb_scene_cache (cache_key, data, fetched_at) VALUES (?, ?, ?)",
params![cache_key, json, fetched_at],
)
.map_err(|e| format!("Failed to cache scene: {}", e))?;
Ok(())
}
// ============ Qobuz Validation Cache ============
/// Get cached Qobuz validation result for an artist name
pub fn get_qobuz_validation(&self, name_normalized: &str) -> Result<Option<String>, String> {
let min_fetched_at = Self::current_timestamp() - QOBUZ_VALIDATION_TTL_SECS;
let result: Option<String> = self
.conn
.query_row(
"SELECT data FROM mb_qobuz_validation WHERE name_normalized = ? AND fetched_at > ?",
params![name_normalized, min_fetched_at],
|row| row.get(0),
)
.optional()
.map_err(|e| format!("Failed to query validation cache: {}", e))?;
Ok(result)
}
/// Cache Qobuz validation result
pub fn set_qobuz_validation(&self, name_normalized: &str, data: &str) -> Result<(), String> {
let fetched_at = Self::current_timestamp();
self.conn
.execute(
"INSERT OR REPLACE INTO mb_qobuz_validation (name_normalized, data, fetched_at) VALUES (?, ?, ?)",
params![name_normalized, data, fetched_at],
)
.map_err(|e| format!("Failed to cache validation: {}", e))?;
Ok(())
}
// ============ V2 Resolved Types (structured cache) ============
/// Get cached track by ISRC (V2 structured format)
pub fn get_track(&self, isrc: &str) -> Result<Option<ResolvedTrack>, String> {
let result: SqlResult<ResolvedTrack> = self.conn.query_row(
let result: rusqlite::Result<ResolvedTrack> = self.conn.query_row(
"SELECT recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence
FROM resolved_tracks WHERE isrc = ?",
[isrc],
@@ -90,7 +475,6 @@ impl MusicBrainzCache {
let artist_mbids_json: String = row.get(2)?;
let isrcs_json: String = row.get(4)?;
let confidence_str: String = row.get(5)?;
Ok(ResolvedTrack {
recording_mbid: row.get(0)?,
title: row.get(1)?,
@@ -107,7 +491,6 @@ impl MusicBrainzCache {
})
},
);
match result {
Ok(track) => {
self.increment_stat("hits");
@@ -121,7 +504,7 @@ impl MusicBrainzCache {
}
}
/// Cache a resolved track
/// Cache a resolved track (V2 structured format)
pub fn put_track(&self, isrc: &str, track: &ResolvedTrack) -> Result<(), String> {
let artist_mbids_json = serde_json::to_string(&track.artist_mbids).unwrap_or_default();
let isrcs_json = serde_json::to_string(&track.isrcs).unwrap_or_default();
@@ -132,38 +515,26 @@ impl MusicBrainzCache {
MatchConfidence::Low => "low",
MatchConfidence::None => "none",
};
self.conn
.execute(
"INSERT OR REPLACE INTO resolved_tracks (isrc, recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
isrc,
track.recording_mbid,
track.title,
artist_mbids_json,
track.release_mbid,
isrcs_json,
confidence,
],
params![isrc, track.recording_mbid, track.title, artist_mbids_json, track.release_mbid, isrcs_json, confidence],
)
.map_err(|e| format!("Failed to cache track: {}", e))?;
Ok(())
}
/// Get cached artist by name
/// Get cached artist by name (V2 structured format)
pub fn get_artist(&self, name: &str) -> Result<Option<ResolvedArtist>, String> {
let name_lower = name.to_lowercase();
let result: SqlResult<ResolvedArtist> = self.conn.query_row(
let result: rusqlite::Result<ResolvedArtist> = self.conn.query_row(
"SELECT mbid, name, sort_name, artist_type, country, disambiguation, confidence
FROM resolved_artists WHERE name_lower = ?",
[&name_lower],
|row| {
let artist_type_str: String = row.get(3)?;
let confidence_str: String = row.get(6)?;
Ok(ResolvedArtist {
mbid: row.get(0)?,
name: row.get(1)?,
@@ -181,7 +552,6 @@ impl MusicBrainzCache {
})
},
);
match result {
Ok(artist) => {
self.increment_stat("hits");
@@ -195,7 +565,7 @@ impl MusicBrainzCache {
}
}
/// Cache a resolved artist
/// Cache a resolved artist (V2 structured format)
pub fn put_artist(&self, artist: &ResolvedArtist) -> Result<(), String> {
let name_lower = artist.name.to_lowercase();
let artist_type = match artist.artist_type {
@@ -213,92 +583,110 @@ impl MusicBrainzCache {
MatchConfidence::Low => "low",
MatchConfidence::None => "none",
};
self.conn
.execute(
"INSERT OR REPLACE INTO resolved_artists (name_lower, mbid, name, sort_name, artist_type, country, disambiguation, confidence)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
name_lower,
artist.mbid,
artist.name,
artist.sort_name,
artist_type,
artist.country,
artist.disambiguation,
confidence,
],
params![name_lower, artist.mbid, artist.name, artist.sort_name, artist_type, artist.country, artist.disambiguation, confidence],
)
.map_err(|e| format!("Failed to cache artist: {}", e))?;
Ok(())
}
// ============ Maintenance ============
/// Clear expired entries from all tables
pub fn cleanup_expired(&self) -> Result<usize, String> {
let now = Self::current_timestamp();
let mut total_deleted = 0;
let tables_and_ttls = [
("mb_recordings", RECORDING_TTL_SECS),
("mb_artists", ARTIST_TTL_SECS),
("mb_releases", RELEASE_TTL_SECS),
("mb_artist_relations", RELATIONS_TTL_SECS),
("mb_artist_metadata", METADATA_TTL_SECS),
("mb_scene_cache", SCENE_TTL_SECS),
("mb_qobuz_validation", QOBUZ_VALIDATION_TTL_SECS),
];
for (table, ttl) in &tables_and_ttls {
total_deleted += self
.conn
.execute(
&format!("DELETE FROM {} WHERE fetched_at <= ?", table),
params![now - ttl],
)
.map_err(|e| format!("Failed to cleanup {}: {}", table, e))?;
}
if total_deleted > 0 {
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted);
}
Ok(total_deleted)
}
/// Clear all cached data (not settings)
pub fn clear_all(&self) -> Result<(), String> {
self.conn
.execute_batch(
"
DELETE FROM mb_recordings;
DELETE FROM mb_artists;
DELETE FROM mb_releases;
DELETE FROM mb_artist_relations;
DELETE FROM mb_artist_metadata;
DELETE FROM mb_scene_cache;
DELETE FROM mb_qobuz_validation;
DELETE FROM resolved_tracks;
DELETE FROM resolved_artists;
UPDATE cache_stats SET value = 0;
",
)
.map_err(|e| format!("Failed to clear MusicBrainz cache: {}", e))?;
log::info!("MusicBrainz cache cleared");
Ok(())
}
/// Get cache statistics
pub fn get_stats(&self) -> Result<CacheStats, String> {
let tracks: u64 = self
.conn
.query_row("SELECT COUNT(*) FROM resolved_tracks", [], |row| row.get(0))
let recordings: i64 = self.conn
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
.unwrap_or(0);
let artists: u64 = self
.conn
.query_row("SELECT COUNT(*) FROM resolved_artists", [], |row| row.get(0))
let artists: i64 = self.conn
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
.unwrap_or(0);
let hits: u64 = self
.conn
.query_row(
"SELECT value FROM cache_stats WHERE key = 'hits'",
[],
|row| row.get(0),
)
let releases: i64 = self.conn
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
.unwrap_or(0);
let misses: u64 = self
.conn
.query_row(
"SELECT value FROM cache_stats WHERE key = 'misses'",
[],
|row| row.get(0),
)
let relations: i64 = self.conn
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
.unwrap_or(0);
Ok(CacheStats {
tracks,
artists,
hits,
misses,
recordings: recordings as u64,
artists: artists as u64,
releases: releases as u64,
relations: relations as u64,
})
}
/// TTL-based cleanup (V2 style)
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
let tracks_deleted = self.conn
.execute("DELETE FROM resolved_tracks WHERE cached_at < ?", [cutoff])
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
let artists_deleted = self.conn
.execute("DELETE FROM resolved_artists WHERE cached_at < ?", [cutoff])
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
Ok((tracks_deleted, artists_deleted))
}
fn increment_stat(&self, key: &str) {
let _ = self.conn.execute(
"UPDATE cache_stats SET value = value + 1 WHERE key = ?",
[key],
);
}
/// Clear expired entries (older than ttl_days)
pub fn cleanup(&self, ttl_days: u32) -> Result<(u64, u64), String> {
let cutoff = chrono::Utc::now().timestamp() - (ttl_days as i64 * 86400);
let tracks_deleted = self
.conn
.execute(
"DELETE FROM resolved_tracks WHERE cached_at < ?",
[cutoff],
)
.map_err(|e| format!("Failed to cleanup tracks: {}", e))? as u64;
let artists_deleted = self
.conn
.execute(
"DELETE FROM resolved_artists WHERE cached_at < ?",
[cutoff],
)
.map_err(|e| format!("Failed to cleanup artists: {}", e))? as u64;
Ok((tracks_deleted, artists_deleted))
}
}
@@ -271,4 +271,360 @@ impl MusicBrainzClient {
Ok(None)
}
// ============ Extended API Methods ============
/// Search recordings by title and artist
pub async fn search_recording(&self, title: &str, artist: &str) -> IntegrationResult<RecordingSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let query = format!(
"recording:\"{}\" AND artist:\"{}\"",
Self::escape_query(title),
Self::escape_query(artist)
);
let url = format!("{}/recording?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
let response = self.client.get(&url).send().await?;
self.check_response(&response).await;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Get artist details with relationships and tags
pub async fn get_artist_with_relations(&self, mbid: &str) -> IntegrationResult<ArtistFullResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let url = format!("{}/artist/{}?inc=artist-rels+tags&fmt=json", base, mbid);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Fetch artist tags only (lightweight, no relations)
pub async fn get_artist_tags(&self, mbid: &str) -> IntegrationResult<Vec<String>> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let url = format!("{}/artist/{}?inc=tags&fmt=json", base, mbid);
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
return Ok(Vec::new());
}
let artist: ArtistFullResponse = response.json().await.map_err(|e| {
IntegrationError::internal(format!("Failed to parse MusicBrainz response: {}", e))
})?;
let mut tags: Vec<_> = artist
.tags
.unwrap_or_default()
.into_iter()
.filter(|tag| tag.count.unwrap_or(0) > 0)
.collect();
tags.sort_by(|a, b| b.count.unwrap_or(0).cmp(&a.count.unwrap_or(0)));
Ok(tags.into_iter().map(|tag| tag.name.to_lowercase()).collect())
}
/// Search artists by tag (genre)
pub async fn search_artists_by_tag(&self, tag: &str, limit: usize) -> IntegrationResult<ArtistSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let limit = limit.min(100).max(1);
let query = format!("tag:\"{}\"", Self::escape_query(tag));
let url = format!(
"{}/artist?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit
);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Search artists by tag AND area
pub async fn search_artists_by_tag_and_area(
&self,
tag: &str,
area_name: &str,
country: Option<&str>,
limit: usize,
offset: usize,
) -> IntegrationResult<ArtistSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let limit = limit.min(100).max(1);
let search_area = country.unwrap_or(area_name);
let query = format!(
"tag:\"{}\" AND area:\"{}\"",
Self::escape_query(tag),
Self::escape_query(search_area)
);
let url = format!(
"{}/artist?query={}&fmt=json&limit={}&offset={}",
base, urlencoding::encode(&query), limit, offset
);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Search releases by barcode (UPC/EAN)
pub async fn search_release_by_barcode(&self, barcode: &str) -> IntegrationResult<ReleaseSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let url = format!("{}/release?query=barcode:{}&fmt=json&limit=5", base, barcode);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Search releases by title and artist
pub async fn search_release(&self, title: &str, artist: &str) -> IntegrationResult<ReleaseSearchResponse> {
self.search_releases_extended(title, artist, None, 5).await
}
/// Search releases with extended options
pub async fn search_releases_extended(
&self,
title: &str,
artist: &str,
catalog_number: Option<&str>,
limit: usize,
) -> IntegrationResult<ReleaseSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let query = if let Some(catno) = catalog_number.filter(|s| !s.trim().is_empty()) {
format!(
"catno:\"{}\" AND artist:\"{}\"",
Self::escape_query(catno),
Self::escape_query(artist)
)
} else {
format!(
"release:\"{}\" AND artist:\"{}\"",
Self::escape_query(title),
Self::escape_query(artist)
)
};
let limit = limit.min(25).max(1);
let url = format!(
"{}/release?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit
);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Get full release details including tracks
pub async fn get_release_with_tracks(&self, release_id: &str) -> IntegrationResult<ReleaseFullResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let url = format!(
"{}/release/{}?inc=recordings+artist-credits+labels+tags&fmt=json",
base, release_id
);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Browse artists by area MBID
pub async fn browse_artists_by_area(
&self,
area_id: &str,
limit: usize,
offset: usize,
) -> IntegrationResult<ArtistBrowseResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let limit = limit.min(100).max(1);
let url = format!(
"{}/artist?area={}&fmt=json&limit={}&offset={}&inc=tags",
base, area_id, limit, offset
);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Search for an area by name
pub async fn search_area(&self, name: &str, area_type: Option<&str>) -> IntegrationResult<AreaSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let query = if let Some(atype) = area_type {
format!(
"area:\"{}\" AND type:\"{}\"",
Self::escape_query(name),
Self::escape_query(atype)
)
} else {
format!("area:\"{}\"", Self::escape_query(name))
};
let url = format!("{}/area?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Look up an area and its parent relationships
pub async fn get_area_with_relations(&self, area_id: &str) -> IntegrationResult<AreaDetailResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
let base = self.base_url().await;
let url = format!("{}/area/{}?inc=area-rels&fmt=json", base, area_id);
let response = self.client.get(&url).send().await?;
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
/// Resolve a city area to its parent subdivision (state/region)
pub async fn resolve_parent_subdivision(&self, area_id: &str) -> IntegrationResult<Option<(String, String)>> {
let mut current_id = area_id.to_string();
let mut path: Vec<String> = Vec::new();
let max_hops = 5;
for _hop in 0..max_hops {
let detail = self.get_area_with_relations(&current_id).await?;
path.push(format!("{}[{:?}]", detail.name, detail.area_type));
let parents: Vec<_> = detail
.relations
.as_ref()
.map(|rels| {
rels.iter()
.filter(|rel| {
rel.relation_type == "part of"
&& rel.direction.as_deref() == Some("backward")
})
.filter_map(|rel| rel.area.as_ref())
.collect()
})
.unwrap_or_default();
if parents.is_empty() {
return Ok(None);
}
let has_country_parent = parents.iter().any(|p| {
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("country")).unwrap_or(false)
});
if has_country_parent {
let own_type = detail.area_type.as_deref().unwrap_or("");
if own_type.eq_ignore_ascii_case("subdivision") {
if current_id == area_id {
return Ok(None);
}
return Ok(Some((detail.name.clone(), detail.id.clone())));
}
if current_id == area_id {
return Ok(None);
}
return Ok(Some((detail.name.clone(), detail.id.clone())));
}
let next = parents
.iter()
.find(|p| {
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("subdivision")).unwrap_or(false)
})
.or_else(|| {
parents.iter().find(|p| {
let t = p.area_type.as_deref().unwrap_or("");
!t.eq_ignore_ascii_case("city") && !t.eq_ignore_ascii_case("country")
})
})
.or_else(|| parents.first());
match next {
Some(parent) => { current_id = parent.id.clone(); }
None => { return Ok(None); }
}
}
Ok(None)
}
// ============ Internal Helpers ============
async fn check_enabled(&self) -> IntegrationResult<()> {
if !self.is_enabled().await {
return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(),
));
}
Ok(())
}
#[allow(unused)]
async fn check_response(&self, _response: &reqwest::Response) {
// Placeholder for response logging/metrics
}
async fn handle_response_status(&self, response: reqwest::Response) -> IntegrationResult<reqwest::Response> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status();
if status.as_u16() == 429 {
return Err(IntegrationError::RateLimited(60));
}
let text = response.text().await.unwrap_or_default();
Err(IntegrationError::internal(format!("MusicBrainz API error {}: {}", status, text)))
}
/// Escape special characters in Lucene queries
fn escape_query(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace(':', "\\:")
.replace('(', "\\(")
.replace(')', "\\)")
.replace('[', "\\[")
.replace(']', "\\]")
.replace('{', "\\{")
.replace('}', "\\}")
.replace('^', "\\^")
.replace('~', "\\~")
.replace('*', "\\*")
.replace('?', "\\?")
.replace('!', "\\!")
.replace('+', "\\+")
.replace('-', "\\-")
.replace('&', "\\&")
.replace('|', "\\|")
}
}
@@ -141,8 +141,46 @@ pub struct ArtistResult {
pub artist_type: Option<String>,
pub country: Option<String>,
pub disambiguation: Option<String>,
#[serde(default)]
pub aliases: Option<Vec<Alias>>,
#[serde(rename = "life-span")]
pub life_span: Option<LifeSpan>,
#[serde(default)]
pub area: Option<Area>,
#[serde(rename = "begin-area", default)]
pub begin_area: Option<Area>,
#[serde(default)]
pub tags: Option<Vec<Tag>>,
}
/// Artist alias
#[derive(Debug, Deserialize)]
pub struct Alias {
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: Option<String>,
#[serde(rename = "type")]
pub alias_type: Option<String>,
pub locale: Option<String>,
pub primary: Option<bool>,
}
/// MusicBrainz area (city, state, country, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Area {
pub id: String,
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: Option<String>,
#[serde(rename = "type")]
pub area_type: Option<String>,
}
/// Community tag (used for genres)
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Tag {
pub name: String,
pub count: Option<i32>,
}
/// Life span for an artist
@@ -203,3 +241,388 @@ pub struct ResolvedRelease {
pub country: Option<String>,
pub confidence: MatchConfidence,
}
// ============ Full Response Types (for lookups with includes) ============
/// Full artist response (with includes like relations, tags)
#[derive(Debug, Deserialize)]
pub struct ArtistFullResponse {
pub id: String,
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: Option<String>,
#[serde(rename = "type")]
pub artist_type: Option<String>,
pub country: Option<String>,
pub disambiguation: Option<String>,
#[serde(rename = "life-span")]
pub life_span: Option<LifeSpan>,
#[serde(default)]
pub area: Option<Area>,
#[serde(rename = "begin-area", default)]
pub begin_area: Option<Area>,
pub relations: Option<Vec<Relation>>,
#[serde(default)]
pub tags: Option<Vec<Tag>>,
}
/// Release search response
#[derive(Debug, Deserialize)]
pub struct ReleaseSearchResponse {
pub created: Option<String>,
pub count: i32,
pub offset: i32,
pub releases: Vec<ReleaseResult>,
}
/// Single release in search results
#[derive(Debug, Deserialize)]
pub struct ReleaseResult {
pub id: String,
pub score: Option<i32>,
pub title: String,
pub status: Option<String>,
pub date: Option<String>,
pub country: Option<String>,
pub barcode: Option<String>,
#[serde(rename = "label-info")]
pub label_info: Option<Vec<LabelInfo>>,
#[serde(rename = "release-group")]
pub release_group: Option<ReleaseGroupRef>,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
pub media: Option<Vec<ReleaseSearchMedium>>,
#[serde(rename = "track-count")]
pub track_count: Option<u16>,
}
/// Simplified medium info for search results
#[derive(Debug, Deserialize)]
pub struct ReleaseSearchMedium {
pub format: Option<String>,
#[serde(rename = "track-count")]
pub track_count: Option<u16>,
}
/// Label information
#[derive(Debug, Deserialize)]
pub struct LabelInfo {
#[serde(rename = "catalog-number")]
pub catalog_number: Option<String>,
pub label: Option<LabelRef>,
}
/// Reference to a label
#[derive(Debug, Deserialize)]
pub struct LabelRef {
pub id: String,
pub name: String,
}
/// Full release response from lookup endpoint (with tracks)
#[derive(Debug, Deserialize)]
pub struct ReleaseFullResponse {
pub id: String,
pub title: String,
pub status: Option<String>,
pub date: Option<String>,
pub country: Option<String>,
pub barcode: Option<String>,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
#[serde(rename = "label-info")]
pub label_info: Option<Vec<LabelInfo>>,
#[serde(rename = "release-group")]
pub release_group: Option<ReleaseGroupRef>,
pub media: Option<Vec<Medium>>,
pub tags: Option<Vec<Tag>>,
}
/// A medium (disc) containing tracks
#[derive(Debug, Deserialize)]
pub struct Medium {
pub position: Option<u8>,
pub format: Option<String>,
#[serde(rename = "track-count")]
pub track_count: Option<u16>,
pub tracks: Option<Vec<MediumTrack>>,
}
/// A track on a medium
#[derive(Debug, Deserialize)]
pub struct MediumTrack {
pub position: Option<u8>,
pub number: Option<String>,
pub title: Option<String>,
pub length: Option<i64>,
pub recording: Option<TrackRecording>,
}
/// Recording reference within a track
#[derive(Debug, Deserialize)]
pub struct TrackRecording {
pub id: String,
pub title: Option<String>,
pub length: Option<i64>,
#[serde(rename = "artist-credit")]
pub artist_credit: Option<Vec<ArtistCredit>>,
}
/// Artist browse response (from browse API)
#[derive(Debug, Deserialize)]
pub struct ArtistBrowseResponse {
#[serde(rename = "artist-count")]
pub artist_count: Option<i32>,
#[serde(rename = "artist-offset")]
pub artist_offset: Option<i32>,
pub artists: Vec<ArtistResult>,
}
/// Area search response
#[derive(Debug, Deserialize)]
pub struct AreaSearchResponse {
pub count: Option<i32>,
pub offset: Option<i32>,
pub areas: Vec<AreaResult>,
}
/// Single area in search results
#[derive(Debug, Deserialize)]
pub struct AreaResult {
pub id: String,
pub score: Option<i32>,
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: Option<String>,
#[serde(rename = "type")]
pub area_type: Option<String>,
#[serde(rename = "iso-3166-1-codes", default)]
pub iso_codes: Option<Vec<String>>,
#[serde(rename = "life-span")]
pub life_span: Option<LifeSpan>,
}
/// Area detail response (from /area/{id}?inc=area-rels)
#[derive(Debug, Deserialize)]
pub struct AreaDetailResponse {
pub id: String,
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: Option<String>,
#[serde(rename = "type")]
pub area_type: Option<String>,
#[serde(rename = "iso-3166-1-codes", default)]
pub iso_codes: Option<Vec<String>>,
#[serde(rename = "iso-3166-2-codes", default)]
pub iso_3166_2_codes: Option<Vec<String>>,
#[serde(default)]
pub relations: Option<Vec<AreaRelation>>,
}
/// Area relationship entry
#[derive(Debug, Deserialize)]
pub struct AreaRelation {
#[serde(rename = "type")]
pub relation_type: String,
pub direction: Option<String>,
pub area: Option<AreaRelTarget>,
}
/// Target area in an area relationship
#[derive(Debug, Deserialize)]
pub struct AreaRelTarget {
pub id: String,
pub name: String,
#[serde(rename = "sort-name")]
pub sort_name: Option<String>,
#[serde(rename = "type")]
pub area_type: Option<String>,
#[serde(rename = "iso-3166-1-codes", default)]
pub iso_codes: Option<Vec<String>>,
#[serde(rename = "iso-3166-2-codes", default)]
pub iso_3166_2_codes: Option<Vec<String>>,
}
// ============ Relationship Types ============
/// Related artist (for relationships)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedArtist {
pub mbid: String,
pub name: String,
pub role: Option<String>,
pub period: Option<Period>,
pub ended: bool,
}
/// Time period for a relationship
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Period {
pub begin: Option<String>,
pub end: Option<String>,
}
/// Artist relationships
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistRelationships {
pub members: Vec<RelatedArtist>,
pub past_members: Vec<RelatedArtist>,
pub groups: Vec<RelatedArtist>,
pub collaborators: Vec<RelatedArtist>,
}
impl ArtistRelationships {
pub fn empty() -> Self {
Self {
members: Vec::new(),
past_members: Vec::new(),
groups: Vec::new(),
collaborators: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.members.is_empty()
&& self.past_members.is_empty()
&& self.groups.is_empty()
&& self.collaborators.is_empty()
}
}
// ============ Artist Metadata (Location Discovery) ============
/// Precision level for artist location data
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocationPrecision {
City,
State,
Country,
}
/// Resolved location for an artist
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistLocation {
pub city: Option<String>,
pub area_id: Option<String>,
pub country: Option<String>,
pub country_code: Option<String>,
pub display_name: String,
pub precision: LocationPrecision,
}
/// Affinity seeds extracted from an artist's tags
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AffinitySeeds {
pub genres: Vec<String>,
pub tags: Vec<String>,
pub normalized_seeds: Vec<String>,
}
/// Complete artist metadata for location discovery
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistMetadata {
pub mbid: String,
pub name: String,
pub artist_type: ArtistType,
pub life_span: Option<LifeSpan>,
pub location: Option<ArtistLocation>,
pub affinity_seeds: AffinitySeeds,
}
/// A candidate artist from location discovery, validated against Qobuz
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationCandidate {
pub mbid: String,
pub mb_name: String,
pub qobuz_id: Option<i64>,
pub qobuz_name: Option<String>,
pub qobuz_image: Option<String>,
pub score: i32,
pub genres: Vec<String>,
pub qobuz_albums_count: Option<u32>,
}
/// Response from the location discovery pipeline
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationDiscoveryResponse {
pub artists: Vec<LocationCandidate>,
pub scene_label: String,
pub genre_summary: String,
pub total_candidates: usize,
pub has_more: bool,
pub next_offset: usize,
}
// ============ Musician Types ============
/// Musician confidence level for MusicBrainz <-> Qobuz matching
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MusicianConfidence {
Confirmed,
Contextual,
Weak,
None,
}
impl MusicianConfidence {
pub fn level(&self) -> u8 {
match self {
Self::Confirmed => 3,
Self::Contextual => 2,
Self::Weak => 1,
Self::None => 0,
}
}
}
impl Default for MusicianConfidence {
fn default() -> Self {
Self::None
}
}
/// Resolved musician with confidence assessment
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedMusician {
pub name: String,
pub role: String,
pub mbid: Option<String>,
pub qobuz_artist_id: Option<i64>,
pub confidence: MusicianConfidence,
pub bands: Vec<String>,
pub appears_on_count: usize,
}
impl ResolvedMusician {
pub fn empty(name: String, role: String) -> Self {
Self {
name,
role,
mbid: None,
qobuz_artist_id: None,
confidence: MusicianConfidence::None,
bands: Vec::new(),
appears_on_count: 0,
}
}
}
/// Album appearance for a musician
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumAppearance {
pub album_id: String,
pub album_title: String,
pub album_artwork: String,
pub artist_name: String,
pub year: Option<String>,
pub role_on_album: String,
}
/// Musician appearances response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MusicianAppearances {
pub albums: Vec<AlbumAppearance>,
pub total: usize,
}