chore(qconnect): checkpoint current poc state

cx
This commit is contained in:
vicrodh committed 2026-03-13 17:28:05 -06:00
1 parent 6d20360dc3
commit de29f1e2e0
92 files changed
+3886 -2433

No files matched your search

+13 -14
View File
@@ -3,8 +3,11 @@
use reqwest::Client;
use serde_json::json;
use super::models::{
AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession,
LastFmSimilarArtist,
};
use crate::error::{IntegrationError, IntegrationResult};
use super::models::{AuthGetSessionResponse, AuthGetTokenResponse, LastFmResponse, LastFmSession, LastFmSimilarArtist};
/// Cloudflare Workers proxy URL - handles API credentials and signature generation
const LASTFM_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/lastfm";
@@ -78,25 +81,18 @@ impl LastFmClient {
pub async fn get_token(&self) -> IntegrationResult<(String, String)> {
let url = format!("{}/auth.getToken", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({}))
.send()
.await?;
let response = self.client.post(&url).json(&json!({})).send().await?;
let data: LastFmResponse<AuthGetTokenResponse> = response.json().await?;
match data {
LastFmResponse::Success(r) => {
let auth_url = r.auth_url.unwrap_or_else(|| {
format!("https://www.last.fm/api/auth/?token={}", r.token)
});
let auth_url = r
.auth_url
.unwrap_or_else(|| format!("https://www.last.fm/api/auth/?token={}", r.token));
Ok((r.token, auth_url))
}
LastFmResponse::Error { error, message } => {
Err(IntegrationError::api(error, message))
}
LastFmResponse::Error { error, message } => Err(IntegrationError::api(error, message)),
}
}
@@ -171,7 +167,10 @@ impl LastFmClient {
Ok(())
} else {
let text = response.text().await.unwrap_or_default();
Err(IntegrationError::internal(format!("Scrobble failed: {}", text)))
Err(IntegrationError::internal(format!(
"Scrobble failed: {}",
text
)))
}
}
@@ -142,7 +142,8 @@ impl ListenBrainzCache {
isrc: Option<&str>,
duration_ms: Option<u64>,
) -> Result<i64, String> {
let artist_mbids_json = artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default());
let artist_mbids_json =
artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default());
self.conn
.execute(
@@ -183,7 +184,8 @@ impl ListenBrainzCache {
let listens = stmt
.query_map([limit], |row| {
let artist_mbids_json: Option<String> = row.get(7)?;
let artist_mbids = artist_mbids_json.and_then(|json| serde_json::from_str(&json).ok());
let artist_mbids =
artist_mbids_json.and_then(|json| serde_json::from_str(&json).ok());
Ok(QueuedListen {
id: row.get(0)?,
@@ -241,7 +243,8 @@ impl ListenBrainzCache {
.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();
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))?;
@@ -252,7 +255,11 @@ impl ListenBrainzCache {
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))
.query_row(
"SELECT COUNT(*) FROM listen_queue WHERE sent = 0",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(count as u32)
}
@@ -6,8 +6,8 @@ use reqwest::Client;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::*;
use crate::error::{IntegrationError, IntegrationResult};
/// ListenBrainz API base URL
const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1";
@@ -113,9 +113,9 @@ impl ListenBrainzClient {
return Err(IntegrationError::AuthFailed(validation.message));
}
let user_name = validation
.user_name
.ok_or_else(|| IntegrationError::AuthFailed("Token valid but no username returned".into()))?;
let user_name = validation.user_name.ok_or_else(|| {
IntegrationError::AuthFailed("Token valid but no username returned".into())
})?;
// Store validated token and username
{
@@ -174,7 +174,10 @@ impl ListenBrainzClient {
)));
}
response.json::<TokenValidationResponse>().await.map_err(Into::into)
response
.json::<TokenValidationResponse>()
.await
.map_err(Into::into)
}
/// Submit "now playing" notification
@@ -261,7 +264,11 @@ impl ListenBrainzClient {
}
/// Internal: Submit listens to API
async fn submit_listens(&self, token: &str, payload: &SubmitListensPayload) -> IntegrationResult<()> {
async fn submit_listens(
&self,
token: &str,
payload: &SubmitListensPayload,
) -> IntegrationResult<()> {
let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL);
let response = self
@@ -45,6 +45,6 @@ pub mod cache;
pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{
ListenType, TrackMetadata, AdditionalInfo, Listen, SubmitListensPayload,
ListenBrainzStatus, UserInfo, QueuedListen,
AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
TrackMetadata, UserInfo,
};
@@ -8,8 +8,8 @@ use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use super::models::{
ArtistMetadata, ArtistRelationships, LocationDiscoveryResponse,
MatchConfidence, ResolvedArtist, ResolvedTrack, ArtistType,
ArtistMetadata, ArtistRelationships, ArtistType, LocationDiscoveryResponse, MatchConfidence,
ResolvedArtist, ResolvedTrack,
};
/// TTL for recording cache (30 days)
@@ -245,7 +245,10 @@ impl MusicBrainzCache {
// ============ 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> {
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
@@ -268,7 +271,11 @@ impl MusicBrainzCache {
}
/// Cache an artist (JSON-serialized)
pub fn set_artist_by_name<T: serde::Serialize>(&self, name: &str, data: &T) -> Result<(), String> {
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)
@@ -285,7 +292,10 @@ impl MusicBrainzCache {
// ============ Release Cache ============
/// Get cached release by barcode
pub fn get_release<T: serde::de::DeserializeOwned>(&self, barcode: &str) -> Result<Option<T>, String> {
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
@@ -345,7 +355,11 @@ impl MusicBrainzCache {
}
/// Cache artist relationships
pub fn set_artist_relations(&self, mbid: &str, data: &ArtistRelationships) -> Result<(), String> {
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))?;
@@ -399,7 +413,10 @@ impl MusicBrainzCache {
// ============ Scene Discovery Cache ============
/// Get cached scene discovery results
pub fn get_scene_cache(&self, cache_key: &str) -> Result<Option<LocationDiscoveryResponse>, String> {
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
@@ -421,10 +438,14 @@ impl MusicBrainzCache {
}
/// Cache scene discovery results
pub fn set_scene_cache(&self, cache_key: &str, data: &LocationDiscoveryResponse) -> Result<(), String> {
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))?;
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 (?, ?, ?)",
@@ -621,7 +642,10 @@ impl MusicBrainzCache {
}
if total_deleted > 0 {
log::info!("MusicBrainz cache cleanup: removed {} expired entries", total_deleted);
log::info!(
"MusicBrainz cache cleanup: removed {} expired entries",
total_deleted
);
}
Ok(total_deleted)
}
@@ -650,17 +674,23 @@ impl MusicBrainzCache {
/// Get cache statistics
pub fn get_stats(&self) -> Result<CacheStats, String> {
let recordings: i64 = self.conn
let recordings: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_recordings", [], |row| row.get(0))
.unwrap_or(0);
let artists: i64 = self.conn
let artists: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_artists", [], |row| row.get(0))
.unwrap_or(0);
let releases: i64 = self.conn
let releases: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM mb_releases", [], |row| row.get(0))
.unwrap_or(0);
let relations: i64 = self.conn
.query_row("SELECT COUNT(*) FROM mb_artist_relations", [], |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 {
@@ -674,12 +704,14 @@ impl MusicBrainzCache {
/// 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;
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))
}
+105 -26
View File
@@ -8,8 +8,8 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use crate::error::{IntegrationError, IntegrationResult};
use super::models::*;
use crate::error::{IntegrationError, IntegrationResult};
/// Proxy URL for MusicBrainz requests
const MUSICBRAINZ_PROXY_URL: &str = "https://qbz-api-proxy.blitzkriegfc.workers.dev/musicbrainz";
@@ -144,7 +144,10 @@ impl MusicBrainzClient {
}
/// Search recordings by ISRC
pub async fn search_recording_by_isrc(&self, isrc: &str) -> IntegrationResult<RecordingSearchResponse> {
pub async fn search_recording_by_isrc(
&self,
isrc: &str,
) -> IntegrationResult<RecordingSearchResponse> {
if !self.is_enabled().await {
return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(),
@@ -174,7 +177,11 @@ impl MusicBrainzClient {
}
/// Search artists by name
pub async fn search_artist(&self, name: &str, limit: u32) -> IntegrationResult<ArtistSearchResponse> {
pub async fn search_artist(
&self,
name: &str,
limit: u32,
) -> IntegrationResult<ArtistSearchResponse> {
if !self.is_enabled().await {
return Err(IntegrationError::ServiceUnavailable(
"MusicBrainz integration is disabled".into(),
@@ -220,7 +227,11 @@ impl MusicBrainzClient {
if let Some(isrc) = isrc {
let response = self.search_recording_by_isrc(isrc).await?;
if let Some(recording) = response.recordings.first() {
let confidence = if recording.isrcs.as_ref().map_or(false, |isrcs| isrcs.contains(&isrc.to_string())) {
let confidence = if recording
.isrcs
.as_ref()
.map_or(false, |isrcs| isrcs.contains(&isrc.to_string()))
{
MatchConfidence::Exact
} else {
MatchConfidence::from_score(recording.score)
@@ -275,7 +286,11 @@ impl MusicBrainzClient {
// ============ Extended API Methods ============
/// Search recordings by title and artist
pub async fn search_recording(&self, title: &str, artist: &str) -> IntegrationResult<RecordingSearchResponse> {
pub async fn search_recording(
&self,
title: &str,
artist: &str,
) -> IntegrationResult<RecordingSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
@@ -285,7 +300,11 @@ impl MusicBrainzClient {
Self::escape_query(title),
Self::escape_query(artist)
);
let url = format!("{}/recording?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
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;
@@ -294,7 +313,10 @@ impl MusicBrainzClient {
}
/// Get artist details with relationships and tags
pub async fn get_artist_with_relations(&self, mbid: &str) -> IntegrationResult<ArtistFullResponse> {
pub async fn get_artist_with_relations(
&self,
mbid: &str,
) -> IntegrationResult<ArtistFullResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
@@ -330,11 +352,18 @@ impl MusicBrainzClient {
.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())
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> {
pub async fn search_artists_by_tag(
&self,
tag: &str,
limit: usize,
) -> IntegrationResult<ArtistSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
@@ -343,7 +372,9 @@ impl MusicBrainzClient {
let query = format!("tag:\"{}\"", Self::escape_query(tag));
let url = format!(
"{}/artist?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit
base,
urlencoding::encode(&query),
limit
);
let response = self.client.get(&url).send().await?;
@@ -373,7 +404,10 @@ impl MusicBrainzClient {
);
let url = format!(
"{}/artist?query={}&fmt=json&limit={}&offset={}",
base, urlencoding::encode(&query), limit, offset
base,
urlencoding::encode(&query),
limit,
offset
);
let response = self.client.get(&url).send().await?;
@@ -382,12 +416,18 @@ impl MusicBrainzClient {
}
/// Search releases by barcode (UPC/EAN)
pub async fn search_release_by_barcode(&self, barcode: &str) -> IntegrationResult<ReleaseSearchResponse> {
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 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?;
@@ -395,7 +435,11 @@ impl MusicBrainzClient {
}
/// Search releases by title and artist
pub async fn search_release(&self, title: &str, artist: &str) -> IntegrationResult<ReleaseSearchResponse> {
pub async fn search_release(
&self,
title: &str,
artist: &str,
) -> IntegrationResult<ReleaseSearchResponse> {
self.search_releases_extended(title, artist, None, 5).await
}
@@ -428,7 +472,9 @@ impl MusicBrainzClient {
let limit = limit.min(25).max(1);
let url = format!(
"{}/release?query={}&fmt=json&limit={}",
base, urlencoding::encode(&query), limit
base,
urlencoding::encode(&query),
limit
);
let response = self.client.get(&url).send().await?;
@@ -437,7 +483,10 @@ impl MusicBrainzClient {
}
/// Get full release details including tracks
pub async fn get_release_with_tracks(&self, release_id: &str) -> IntegrationResult<ReleaseFullResponse> {
pub async fn get_release_with_tracks(
&self,
release_id: &str,
) -> IntegrationResult<ReleaseFullResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
@@ -475,7 +524,11 @@ impl MusicBrainzClient {
}
/// Search for an area by name
pub async fn search_area(&self, name: &str, area_type: Option<&str>) -> IntegrationResult<AreaSearchResponse> {
pub async fn search_area(
&self,
name: &str,
area_type: Option<&str>,
) -> IntegrationResult<AreaSearchResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
@@ -490,7 +543,11 @@ impl MusicBrainzClient {
format!("area:\"{}\"", Self::escape_query(name))
};
let url = format!("{}/area?query={}&fmt=json&limit=5", base, urlencoding::encode(&query));
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?;
@@ -498,7 +555,10 @@ impl MusicBrainzClient {
}
/// Look up an area and its parent relationships
pub async fn get_area_with_relations(&self, area_id: &str) -> IntegrationResult<AreaDetailResponse> {
pub async fn get_area_with_relations(
&self,
area_id: &str,
) -> IntegrationResult<AreaDetailResponse> {
self.check_enabled().await?;
self.rate_limiter.wait().await;
@@ -511,7 +571,10 @@ impl MusicBrainzClient {
}
/// Resolve a city area to its parent subdivision (state/region)
pub async fn resolve_parent_subdivision(&self, area_id: &str) -> IntegrationResult<Option<(String, String)>> {
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;
@@ -539,7 +602,10 @@ impl MusicBrainzClient {
}
let has_country_parent = parents.iter().any(|p| {
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("country")).unwrap_or(false)
p.area_type
.as_deref()
.map(|t| t.eq_ignore_ascii_case("country"))
.unwrap_or(false)
});
if has_country_parent {
@@ -559,7 +625,10 @@ impl MusicBrainzClient {
let next = parents
.iter()
.find(|p| {
p.area_type.as_deref().map(|t| t.eq_ignore_ascii_case("subdivision")).unwrap_or(false)
p.area_type
.as_deref()
.map(|t| t.eq_ignore_ascii_case("subdivision"))
.unwrap_or(false)
})
.or_else(|| {
parents.iter().find(|p| {
@@ -570,8 +639,12 @@ impl MusicBrainzClient {
.or_else(|| parents.first());
match next {
Some(parent) => { current_id = parent.id.clone(); }
None => { return Ok(None); }
Some(parent) => {
current_id = parent.id.clone();
}
None => {
return Ok(None);
}
}
}
@@ -594,7 +667,10 @@ impl MusicBrainzClient {
// Placeholder for response logging/metrics
}
async fn handle_response_status(&self, response: reqwest::Response) -> IntegrationResult<reqwest::Response> {
async fn handle_response_status(
&self,
response: reqwest::Response,
) -> IntegrationResult<reqwest::Response> {
if response.status().is_success() {
return Ok(response);
}
@@ -603,7 +679,10 @@ impl MusicBrainzClient {
return Err(IntegrationError::RateLimited(60));
}
let text = response.text().await.unwrap_or_default();
Err(IntegrationError::internal(format!("MusicBrainz API error {}: {}", status, text)))
Err(IntegrationError::internal(format!(
"MusicBrainz API error {}: {}",
status, text
)))
}
/// Escape special characters in Lucene queries