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:
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);
|
||||
|
||||
@@ -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(¤t_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,
|
||||
}
|
||||
Reference in new issue
Block a user