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(())
|
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
|
/// Delete old sent listens
|
||||||
pub fn cleanup_sent(&self, older_than_days: u32) -> Result<u64, String> {
|
pub fn cleanup_sent(&self, older_than_days: u32) -> Result<u64, String> {
|
||||||
let cutoff = chrono::Utc::now().timestamp() - (older_than_days as i64 * 86400);
|
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.
|
//! SQLite-based cache with TTL expiration for MusicBrainz lookups.
|
||||||
//! Caches:
|
//! Also persists integration settings (enabled state).
|
||||||
//! - Resolved tracks (by ISRC)
|
|
||||||
//! - Resolved artists (by name)
|
|
||||||
//! - Resolved releases (by title/artist)
|
|
||||||
|
|
||||||
use rusqlite::{Connection, Result as SqlResult};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use std::path::Path;
|
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
|
/// Cache statistics
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct CacheStats {
|
pub struct CacheStats {
|
||||||
pub tracks: u64,
|
pub recordings: u64,
|
||||||
pub artists: u64,
|
pub artists: u64,
|
||||||
pub hits: u64,
|
pub releases: u64,
|
||||||
pub misses: u64,
|
pub relations: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MusicBrainz cache
|
/// MusicBrainz cache
|
||||||
@@ -45,6 +61,69 @@ impl MusicBrainzCache {
|
|||||||
self.conn
|
self.conn
|
||||||
.execute_batch(
|
.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 (
|
CREATE TABLE IF NOT EXISTS resolved_tracks (
|
||||||
isrc TEXT PRIMARY KEY,
|
isrc TEXT PRIMARY KEY,
|
||||||
recording_mbid TEXT NOT NULL,
|
recording_mbid TEXT NOT NULL,
|
||||||
@@ -56,6 +135,7 @@ impl MusicBrainzCache {
|
|||||||
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
cached_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- V2 resolved artists (simple cache)
|
||||||
CREATE TABLE IF NOT EXISTS resolved_artists (
|
CREATE TABLE IF NOT EXISTS resolved_artists (
|
||||||
name_lower TEXT PRIMARY KEY,
|
name_lower TEXT PRIMARY KEY,
|
||||||
mbid TEXT NOT NULL,
|
mbid TEXT NOT NULL,
|
||||||
@@ -72,7 +152,6 @@ impl MusicBrainzCache {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value INTEGER NOT NULL DEFAULT 0
|
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 ('hits', 0);
|
||||||
INSERT OR IGNORE INTO cache_stats (key, value) VALUES ('misses', 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))
|
.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> {
|
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
|
"SELECT recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence
|
||||||
FROM resolved_tracks WHERE isrc = ?",
|
FROM resolved_tracks WHERE isrc = ?",
|
||||||
[isrc],
|
[isrc],
|
||||||
@@ -90,7 +475,6 @@ impl MusicBrainzCache {
|
|||||||
let artist_mbids_json: String = row.get(2)?;
|
let artist_mbids_json: String = row.get(2)?;
|
||||||
let isrcs_json: String = row.get(4)?;
|
let isrcs_json: String = row.get(4)?;
|
||||||
let confidence_str: String = row.get(5)?;
|
let confidence_str: String = row.get(5)?;
|
||||||
|
|
||||||
Ok(ResolvedTrack {
|
Ok(ResolvedTrack {
|
||||||
recording_mbid: row.get(0)?,
|
recording_mbid: row.get(0)?,
|
||||||
title: row.get(1)?,
|
title: row.get(1)?,
|
||||||
@@ -107,7 +491,6 @@ impl MusicBrainzCache {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(track) => {
|
Ok(track) => {
|
||||||
self.increment_stat("hits");
|
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> {
|
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 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();
|
let isrcs_json = serde_json::to_string(&track.isrcs).unwrap_or_default();
|
||||||
@@ -132,38 +515,26 @@ impl MusicBrainzCache {
|
|||||||
MatchConfidence::Low => "low",
|
MatchConfidence::Low => "low",
|
||||||
MatchConfidence::None => "none",
|
MatchConfidence::None => "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT OR REPLACE INTO resolved_tracks (isrc, recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence)
|
"INSERT OR REPLACE INTO resolved_tracks (isrc, recording_mbid, title, artist_mbids, release_mbid, isrcs, confidence)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
rusqlite::params![
|
params![isrc, track.recording_mbid, track.title, artist_mbids_json, track.release_mbid, isrcs_json, confidence],
|
||||||
isrc,
|
|
||||||
track.recording_mbid,
|
|
||||||
track.title,
|
|
||||||
artist_mbids_json,
|
|
||||||
track.release_mbid,
|
|
||||||
isrcs_json,
|
|
||||||
confidence,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to cache track: {}", e))?;
|
.map_err(|e| format!("Failed to cache track: {}", e))?;
|
||||||
|
|
||||||
Ok(())
|
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> {
|
pub fn get_artist(&self, name: &str) -> Result<Option<ResolvedArtist>, String> {
|
||||||
let name_lower = name.to_lowercase();
|
let name_lower = name.to_lowercase();
|
||||||
|
let result: rusqlite::Result<ResolvedArtist> = self.conn.query_row(
|
||||||
let result: SqlResult<ResolvedArtist> = self.conn.query_row(
|
|
||||||
"SELECT mbid, name, sort_name, artist_type, country, disambiguation, confidence
|
"SELECT mbid, name, sort_name, artist_type, country, disambiguation, confidence
|
||||||
FROM resolved_artists WHERE name_lower = ?",
|
FROM resolved_artists WHERE name_lower = ?",
|
||||||
[&name_lower],
|
[&name_lower],
|
||||||
|row| {
|
|row| {
|
||||||
let artist_type_str: String = row.get(3)?;
|
let artist_type_str: String = row.get(3)?;
|
||||||
let confidence_str: String = row.get(6)?;
|
let confidence_str: String = row.get(6)?;
|
||||||
|
|
||||||
Ok(ResolvedArtist {
|
Ok(ResolvedArtist {
|
||||||
mbid: row.get(0)?,
|
mbid: row.get(0)?,
|
||||||
name: row.get(1)?,
|
name: row.get(1)?,
|
||||||
@@ -181,7 +552,6 @@ impl MusicBrainzCache {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(artist) => {
|
Ok(artist) => {
|
||||||
self.increment_stat("hits");
|
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> {
|
pub fn put_artist(&self, artist: &ResolvedArtist) -> Result<(), String> {
|
||||||
let name_lower = artist.name.to_lowercase();
|
let name_lower = artist.name.to_lowercase();
|
||||||
let artist_type = match artist.artist_type {
|
let artist_type = match artist.artist_type {
|
||||||
@@ -213,92 +583,110 @@ impl MusicBrainzCache {
|
|||||||
MatchConfidence::Low => "low",
|
MatchConfidence::Low => "low",
|
||||||
MatchConfidence::None => "none",
|
MatchConfidence::None => "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
self.conn
|
self.conn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT OR REPLACE INTO resolved_artists (name_lower, mbid, name, sort_name, artist_type, country, disambiguation, confidence)
|
"INSERT OR REPLACE INTO resolved_artists (name_lower, mbid, name, sort_name, artist_type, country, disambiguation, confidence)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
rusqlite::params![
|
params![name_lower, artist.mbid, artist.name, artist.sort_name, artist_type, artist.country, artist.disambiguation, confidence],
|
||||||
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))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get cache statistics
|
/// Get cache statistics
|
||||||
pub fn get_stats(&self) -> Result<CacheStats, String> {
|
pub fn get_stats(&self) -> Result<CacheStats, String> {
|
||||||
let tracks: u64 = self
|
let recordings: i64 = self.conn
|
||||||
.conn
|
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
|
||||||
.query_row("SELECT COUNT(*) FROM resolved_tracks", [], |row| row.get(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let artists: i64 = self.conn
|
||||||
let artists: u64 = self
|
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
|
||||||
.conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM resolved_artists", [], |row| row.get(0))
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let releases: i64 = self.conn
|
||||||
let hits: u64 = self
|
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT value FROM cache_stats WHERE key = 'hits'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let relations: i64 = self.conn
|
||||||
let misses: u64 = self
|
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |row| row.get(0))
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT value FROM cache_stats WHERE key = 'misses'",
|
|
||||||
[],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(CacheStats {
|
Ok(CacheStats {
|
||||||
tracks,
|
recordings: recordings as u64,
|
||||||
artists,
|
artists: artists as u64,
|
||||||
hits,
|
releases: releases as u64,
|
||||||
misses,
|
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) {
|
fn increment_stat(&self, key: &str) {
|
||||||
let _ = self.conn.execute(
|
let _ = self.conn.execute(
|
||||||
"UPDATE cache_stats SET value = value + 1 WHERE key = ?",
|
"UPDATE cache_stats SET value = value + 1 WHERE key = ?",
|
||||||
[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)
|
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 artist_type: Option<String>,
|
||||||
pub country: Option<String>,
|
pub country: Option<String>,
|
||||||
pub disambiguation: Option<String>,
|
pub disambiguation: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub aliases: Option<Vec<Alias>>,
|
||||||
#[serde(rename = "life-span")]
|
#[serde(rename = "life-span")]
|
||||||
pub life_span: Option<LifeSpan>,
|
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
|
/// Life span for an artist
|
||||||
@@ -203,3 +241,388 @@ pub struct ResolvedRelease {
|
|||||||
pub country: Option<String>,
|
pub country: Option<String>,
|
||||||
pub confidence: MatchConfidence,
|
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,
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@
|
|||||||
//! Tauri state wrappers for qbz-integrations crate.
|
//! Tauri state wrappers for qbz-integrations crate.
|
||||||
//! These provide the glue between Tauri's state management and the
|
//! These provide the glue between Tauri's state management and the
|
||||||
//! Tauri-independent integration clients.
|
//! Tauri-independent integration clients.
|
||||||
|
//!
|
||||||
|
//! IMPORTANT: These wrappers must NEVER depend on legacy modules.
|
||||||
|
//! All persistence goes through V2 caches (qbz-integrations).
|
||||||
|
//! See ADR-004.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -11,9 +16,14 @@ use qbz_integrations::{
|
|||||||
LastFmClient, ListenBrainzClient, ListenBrainzConfig, MusicBrainzClient, MusicBrainzConfig,
|
LastFmClient, ListenBrainzClient, ListenBrainzConfig, MusicBrainzClient, MusicBrainzConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use qbz_integrations::listenbrainz::cache::ListenBrainzCache;
|
||||||
|
use qbz_integrations::musicbrainz::cache::MusicBrainzCache;
|
||||||
|
|
||||||
/// V2 ListenBrainz state wrapper for Tauri
|
/// V2 ListenBrainz state wrapper for Tauri
|
||||||
pub struct ListenBrainzV2State {
|
pub struct ListenBrainzV2State {
|
||||||
pub client: Arc<Mutex<ListenBrainzClient>>,
|
pub client: Arc<Mutex<ListenBrainzClient>>,
|
||||||
|
/// V2 SQLite cache for persistence (credentials, settings, queue)
|
||||||
|
pub cache: Arc<Mutex<Option<ListenBrainzCache>>>,
|
||||||
/// Persisted token (loaded from/saved to user session)
|
/// Persisted token (loaded from/saved to user session)
|
||||||
token: Arc<Mutex<Option<String>>>,
|
token: Arc<Mutex<Option<String>>>,
|
||||||
user_name: Arc<Mutex<Option<String>>>,
|
user_name: Arc<Mutex<Option<String>>>,
|
||||||
@@ -23,12 +33,45 @@ impl ListenBrainzV2State {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Arc::new(Mutex::new(ListenBrainzClient::new())),
|
client: Arc::new(Mutex::new(ListenBrainzClient::new())),
|
||||||
|
cache: Arc::new(Mutex::new(None)),
|
||||||
token: Arc::new(Mutex::new(None)),
|
token: Arc::new(Mutex::new(None)),
|
||||||
user_name: Arc::new(Mutex::new(None)),
|
user_name: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize with saved credentials
|
/// Initialize cache at user data directory
|
||||||
|
pub fn init_cache_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||||
|
let cache_dir = base_dir.join("cache");
|
||||||
|
std::fs::create_dir_all(&cache_dir)
|
||||||
|
.map_err(|e| format!("Failed to create LB cache directory: {}", e))?;
|
||||||
|
let db_path = cache_dir.join("listenbrainz_v2.db");
|
||||||
|
let cache = ListenBrainzCache::new(&db_path)?;
|
||||||
|
log::info!("ListenBrainz V2 cache initialized at {:?}", db_path);
|
||||||
|
let mut guard = self.cache.blocking_lock();
|
||||||
|
*guard = Some(cache);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize from V2 cache (reads persisted credentials + enabled state)
|
||||||
|
pub async fn init_from_cache(&self) {
|
||||||
|
let cache_guard = self.cache.lock().await;
|
||||||
|
if let Some(cache) = cache_guard.as_ref() {
|
||||||
|
let (token, user_name) = cache.get_credentials().unwrap_or((None, None));
|
||||||
|
let enabled = cache.is_enabled().unwrap_or(true);
|
||||||
|
drop(cache_guard);
|
||||||
|
|
||||||
|
let config = ListenBrainzConfig {
|
||||||
|
enabled,
|
||||||
|
token: token.clone(),
|
||||||
|
user_name: user_name.clone(),
|
||||||
|
};
|
||||||
|
*self.client.lock().await = ListenBrainzClient::with_config(config);
|
||||||
|
*self.token.lock().await = token;
|
||||||
|
*self.user_name.lock().await = user_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize with saved credentials (for migration from legacy)
|
||||||
pub async fn init_with_credentials(
|
pub async fn init_with_credentials(
|
||||||
&self,
|
&self,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
@@ -55,19 +98,37 @@ impl ListenBrainzV2State {
|
|||||||
|
|
||||||
/// Save credentials after successful auth
|
/// Save credentials after successful auth
|
||||||
pub async fn save_credentials(&self, token: String, user_name: String) {
|
pub async fn save_credentials(&self, token: String, user_name: String) {
|
||||||
*self.token.lock().await = Some(token);
|
*self.token.lock().await = Some(token.clone());
|
||||||
*self.user_name.lock().await = Some(user_name);
|
*self.user_name.lock().await = Some(user_name.clone());
|
||||||
|
|
||||||
|
// Persist to V2 cache
|
||||||
|
let cache_guard = self.cache.lock().await;
|
||||||
|
if let Some(cache) = cache_guard.as_ref() {
|
||||||
|
if let Err(e) = cache.save_credentials(&token, &user_name) {
|
||||||
|
log::warn!("Failed to persist LB credentials to V2 cache: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear credentials on disconnect
|
/// Clear credentials on disconnect
|
||||||
///
|
|
||||||
/// This clears both wrapper fields AND resets the internal client to ensure
|
|
||||||
/// no credentials leak across sessions.
|
|
||||||
pub async fn clear_credentials(&self) {
|
pub async fn clear_credentials(&self) {
|
||||||
*self.token.lock().await = None;
|
*self.token.lock().await = None;
|
||||||
*self.user_name.lock().await = None;
|
*self.user_name.lock().await = None;
|
||||||
// Reset internal client to fresh state (clears config with token/user_name)
|
|
||||||
*self.client.lock().await = ListenBrainzClient::new();
|
*self.client.lock().await = ListenBrainzClient::new();
|
||||||
|
|
||||||
|
// Clear from V2 cache
|
||||||
|
let cache_guard = self.cache.lock().await;
|
||||||
|
if let Some(cache) = cache_guard.as_ref() {
|
||||||
|
if let Err(e) = cache.clear_credentials() {
|
||||||
|
log::warn!("Failed to clear LB credentials from V2 cache: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teardown cache (on session deactivate)
|
||||||
|
pub async fn teardown(&self) {
|
||||||
|
let mut guard = self.cache.lock().await;
|
||||||
|
*guard = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,20 +141,56 @@ impl Default for ListenBrainzV2State {
|
|||||||
/// V2 MusicBrainz state wrapper for Tauri
|
/// V2 MusicBrainz state wrapper for Tauri
|
||||||
pub struct MusicBrainzV2State {
|
pub struct MusicBrainzV2State {
|
||||||
pub client: Arc<Mutex<MusicBrainzClient>>,
|
pub client: Arc<Mutex<MusicBrainzClient>>,
|
||||||
|
/// V2 SQLite cache for persistence (settings, lookups)
|
||||||
|
pub cache: Arc<Mutex<Option<MusicBrainzCache>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MusicBrainzV2State {
|
impl MusicBrainzV2State {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Arc::new(Mutex::new(MusicBrainzClient::new())),
|
client: Arc::new(Mutex::new(MusicBrainzClient::new())),
|
||||||
|
cache: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize cache at user data directory
|
||||||
|
pub fn init_cache_at(&self, base_dir: &Path) -> Result<(), String> {
|
||||||
|
let cache_dir = base_dir.join("cache");
|
||||||
|
std::fs::create_dir_all(&cache_dir)
|
||||||
|
.map_err(|e| format!("Failed to create MB cache directory: {}", e))?;
|
||||||
|
let db_path = cache_dir.join("musicbrainz_v2.db");
|
||||||
|
let cache = MusicBrainzCache::new(&db_path)?;
|
||||||
|
log::info!("MusicBrainz V2 cache initialized at {:?}", db_path);
|
||||||
|
let mut guard = self.cache.blocking_lock();
|
||||||
|
*guard = Some(cache);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize from V2 cache (reads persisted enabled state)
|
||||||
|
pub async fn init_from_cache(&self, use_proxy: bool) {
|
||||||
|
let cache_guard = self.cache.lock().await;
|
||||||
|
let enabled = if let Some(cache) = cache_guard.as_ref() {
|
||||||
|
cache.is_enabled().unwrap_or(true)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
drop(cache_guard);
|
||||||
|
|
||||||
|
let config = MusicBrainzConfig { enabled, use_proxy };
|
||||||
|
*self.client.lock().await = MusicBrainzClient::with_config(config);
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize with configuration
|
/// Initialize with configuration
|
||||||
pub async fn init_with_config(&self, enabled: bool, use_proxy: bool) {
|
pub async fn init_with_config(&self, enabled: bool, use_proxy: bool) {
|
||||||
let config = MusicBrainzConfig { enabled, use_proxy };
|
let config = MusicBrainzConfig { enabled, use_proxy };
|
||||||
*self.client.lock().await = MusicBrainzClient::with_config(config);
|
*self.client.lock().await = MusicBrainzClient::with_config(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Teardown cache (on session deactivate)
|
||||||
|
pub async fn teardown(&self) {
|
||||||
|
let mut guard = self.cache.lock().await;
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MusicBrainzV2State {
|
impl Default for MusicBrainzV2State {
|
||||||
@@ -118,9 +215,6 @@ impl LastFmV2State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize with saved session key
|
/// Initialize with saved session key
|
||||||
///
|
|
||||||
/// If `None` is passed, this performs a full session clear to ensure
|
|
||||||
/// no credentials leak across sessions.
|
|
||||||
pub async fn init_with_session(&self, session_key: Option<String>) {
|
pub async fn init_with_session(&self, session_key: Option<String>) {
|
||||||
match session_key {
|
match session_key {
|
||||||
Some(key) => self.client.lock().await.set_session_key(key),
|
Some(key) => self.client.lock().await.set_session_key(key),
|
||||||
@@ -129,9 +223,6 @@ impl LastFmV2State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear session and pending token (full teardown)
|
/// Clear session and pending token (full teardown)
|
||||||
///
|
|
||||||
/// This MUST be called on logout/session deactivate to ensure
|
|
||||||
/// no credentials leak to a new session.
|
|
||||||
pub async fn clear_session(&self) {
|
pub async fn clear_session(&self) {
|
||||||
self.client.lock().await.clear_session();
|
self.client.lock().await.clear_session();
|
||||||
*self.pending_token.lock().await = None;
|
*self.pending_token.lock().await = None;
|
||||||
|
|||||||
Reference in new issue
Block a user