diff --git a/src-tauri/src/commands/listenbrainz.rs b/src-tauri/src/commands/listenbrainz.rs new file mode 100644 index 000000000..d89555f95 --- /dev/null +++ b/src-tauri/src/commands/listenbrainz.rs @@ -0,0 +1,300 @@ +//! ListenBrainz Tauri commands +//! +//! Exposes ListenBrainz scrobbling to the frontend + +use tauri::State; + +use crate::listenbrainz::{ + AdditionalInfo, ListenBrainzSharedState, ListenBrainzStatus, QueueStats, QueuedListen, UserInfo, +}; + +/// Get ListenBrainz connection status +#[tauri::command] +pub async fn listenbrainz_get_status( + state: State<'_, ListenBrainzSharedState>, +) -> Result { + let client = state.client.lock().await; + Ok(client.get_status().await) +} + +/// Check if ListenBrainz is enabled +#[tauri::command] +pub async fn listenbrainz_is_enabled( + state: State<'_, ListenBrainzSharedState>, +) -> Result { + let client = state.client.lock().await; + Ok(client.is_enabled().await) +} + +/// Enable or disable ListenBrainz integration +#[tauri::command] +pub async fn listenbrainz_set_enabled( + enabled: bool, + state: State<'_, ListenBrainzSharedState>, +) -> Result<(), String> { + // Update client + { + let client = state.client.lock().await; + client.set_enabled(enabled).await; + } + + // Persist setting + { + let cache = state.cache.lock().await; + cache.set_enabled(enabled)?; + } + + log::info!( + "ListenBrainz integration {}", + if enabled { "enabled" } else { "disabled" } + ); + Ok(()) +} + +/// Connect to ListenBrainz with a user token +#[tauri::command] +pub async fn listenbrainz_connect( + token: String, + state: State<'_, ListenBrainzSharedState>, +) -> Result { + log::info!("Command: listenbrainz_connect"); + + // Validate token and get user info + let user_info = { + let client = state.client.lock().await; + client.set_token(&token).await? + }; + + // Persist credentials + { + let cache = state.cache.lock().await; + cache.set_credentials(Some(&token), Some(&user_info.user_name))?; + } + + Ok(user_info) +} + +/// Disconnect from ListenBrainz +#[tauri::command] +pub async fn listenbrainz_disconnect( + state: State<'_, ListenBrainzSharedState>, +) -> Result<(), String> { + log::info!("Command: listenbrainz_disconnect"); + + // Clear client state + { + let client = state.client.lock().await; + client.disconnect().await; + } + + // Clear persisted credentials + { + let cache = state.cache.lock().await; + cache.clear_credentials()?; + } + + Ok(()) +} + +/// Submit "now playing" to ListenBrainz +#[tauri::command] +pub async fn listenbrainz_now_playing( + artist: String, + track: String, + album: Option, + recording_mbid: Option, + release_mbid: Option, + artist_mbids: Option>, + isrc: Option, + duration_ms: Option, + state: State<'_, ListenBrainzSharedState>, +) -> Result<(), String> { + log::debug!("Command: listenbrainz_now_playing - {} - {}", artist, track); + + let client = state.client.lock().await; + + // Build additional info with MusicBrainz data + let mut info = AdditionalInfo::new(); + info.recording_mbid = recording_mbid; + info.release_mbid = release_mbid; + info.artist_mbids = artist_mbids; + info.isrc = isrc; + info.duration_ms = duration_ms; + + client + .submit_playing_now(&artist, &track, album.as_deref(), Some(info)) + .await +} + +/// Submit a scrobble to ListenBrainz +#[tauri::command] +pub async fn listenbrainz_scrobble( + artist: String, + track: String, + album: Option, + timestamp: i64, + recording_mbid: Option, + release_mbid: Option, + artist_mbids: Option>, + isrc: Option, + duration_ms: Option, + state: State<'_, ListenBrainzSharedState>, +) -> Result<(), String> { + log::info!("Command: listenbrainz_scrobble - {} - {}", artist, track); + + let client = state.client.lock().await; + + // Build additional info with MusicBrainz data + let mut info = AdditionalInfo::new(); + info.recording_mbid = recording_mbid; + info.release_mbid = release_mbid; + info.artist_mbids = artist_mbids; + info.isrc = isrc; + info.duration_ms = duration_ms; + + client + .submit_listen(&artist, &track, album.as_deref(), timestamp, Some(info)) + .await +} + +/// Queue a listen for offline submission +#[tauri::command] +pub async fn listenbrainz_queue_listen( + artist: String, + track: String, + album: Option, + timestamp: i64, + recording_mbid: Option, + release_mbid: Option, + artist_mbids: Option>, + isrc: Option, + duration_ms: Option, + state: State<'_, ListenBrainzSharedState>, +) -> Result { + log::info!("Command: listenbrainz_queue_listen - {} - {}", artist, track); + + let cache = state.cache.lock().await; + cache.queue_listen( + timestamp, + &artist, + &track, + album.as_deref(), + recording_mbid.as_deref(), + release_mbid.as_deref(), + artist_mbids.as_deref(), + isrc.as_deref(), + duration_ms, + ) +} + +/// Get queued listens (for sync) +#[tauri::command] +pub async fn listenbrainz_get_queue( + limit: Option, + state: State<'_, ListenBrainzSharedState>, +) -> Result, String> { + let cache = state.cache.lock().await; + cache.get_queued_listens(limit.unwrap_or(50)) +} + +/// Get queue count +#[tauri::command] +pub async fn listenbrainz_get_queue_count( + state: State<'_, ListenBrainzSharedState>, +) -> Result { + let cache = state.cache.lock().await; + cache.get_queue_count() +} + +/// Mark queued listens as sent +#[tauri::command] +pub async fn listenbrainz_mark_sent( + ids: Vec, + state: State<'_, ListenBrainzSharedState>, +) -> Result<(), String> { + let cache = state.cache.lock().await; + cache.mark_listens_sent(&ids) +} + +/// Flush queue (submit all pending listens) +#[tauri::command] +pub async fn listenbrainz_flush_queue( + state: State<'_, ListenBrainzSharedState>, +) -> Result { + log::info!("Command: listenbrainz_flush_queue"); + + // Get pending listens + let pending = { + let cache = state.cache.lock().await; + cache.get_queued_listens(50)? + }; + + if pending.is_empty() { + return Ok(0); + } + + let client = state.client.lock().await; + let mut sent_ids = Vec::new(); + + for listen in &pending { + // Build additional info + let mut info = AdditionalInfo::new(); + info.recording_mbid = listen.recording_mbid.clone(); + info.release_mbid = listen.release_mbid.clone(); + info.artist_mbids = listen.artist_mbids.clone(); + info.isrc = listen.isrc.clone(); + info.duration_ms = listen.duration_ms; + + match client + .submit_listen( + &listen.artist_name, + &listen.track_name, + listen.release_name.as_deref(), + listen.listened_at, + Some(info), + ) + .await + { + Ok(()) => { + sent_ids.push(listen.id); + } + Err(e) => { + log::warn!("Failed to submit queued listen {}: {}", listen.id, e); + // Increment attempt count + let cache = state.cache.lock().await; + let _ = cache.increment_attempts(listen.id); + } + } + } + + // Mark successful ones as sent + if !sent_ids.is_empty() { + let cache = state.cache.lock().await; + cache.mark_listens_sent(&sent_ids)?; + } + + let sent_count = sent_ids.len() as u32; + log::info!("ListenBrainz queue flush: sent {}/{} listens", sent_count, pending.len()); + + Ok(sent_count) +} + +/// Clear the offline queue +#[tauri::command] +pub async fn listenbrainz_clear_queue( + state: State<'_, ListenBrainzSharedState>, +) -> Result<(), String> { + log::info!("Command: listenbrainz_clear_queue"); + let cache = state.cache.lock().await; + cache.clear_queue() +} + +/// Cleanup old sent listens +#[tauri::command] +pub async fn listenbrainz_cleanup_queue( + older_than_days: Option, + state: State<'_, ListenBrainzSharedState>, +) -> Result { + let cache = state.cache.lock().await; + cache.cleanup_sent_listens(older_than_days.unwrap_or(7)) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 839dfb5f3..feaff6185 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod cache; pub mod credits; pub mod favorites; pub mod lastfm; +pub mod listenbrainz; pub mod musicbrainz; pub mod notification; pub mod playback; @@ -27,6 +28,7 @@ pub use cache::*; pub use credits::*; pub use favorites::*; pub use lastfm::*; +pub use listenbrainz::*; pub use musicbrainz::*; pub use notification::*; pub use playback::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 930d0b00c..16d155df5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ pub mod offline_cache; pub mod flatpak; pub mod lastfm; pub mod library; +pub mod listenbrainz; pub mod lyrics; pub mod media_controls; pub mod musicbrainz; @@ -196,6 +197,9 @@ pub fn run() { // Initialize MusicBrainz integration state let musicbrainz_state = musicbrainz::MusicBrainzSharedState::new() .expect("Failed to initialize MusicBrainz state"); + // Initialize ListenBrainz integration state + let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new() + .expect("Failed to initialize ListenBrainz state"); // Read saved audio device and settings for player initialization let (saved_device, audio_settings) = audio_settings_state @@ -406,6 +410,7 @@ pub fn run() { .manage(favorites_prefs_state) .manage(tray_settings_state) .manage(musicbrainz_state) + .manage(listenbrainz_state) .invoke_handler(tauri::generate_handler![ // Auth commands commands::init_client, @@ -755,6 +760,21 @@ pub fn run() { commands::musicbrainz_get_cache_stats, commands::musicbrainz_clear_cache, commands::musicbrainz_cleanup_cache, + // ListenBrainz integration commands + commands::listenbrainz_get_status, + commands::listenbrainz_is_enabled, + commands::listenbrainz_set_enabled, + commands::listenbrainz_connect, + commands::listenbrainz_disconnect, + commands::listenbrainz_now_playing, + commands::listenbrainz_scrobble, + commands::listenbrainz_queue_listen, + commands::listenbrainz_get_queue, + commands::listenbrainz_get_queue_count, + commands::listenbrainz_mark_sent, + commands::listenbrainz_flush_queue, + commands::listenbrainz_clear_queue, + commands::listenbrainz_cleanup_queue, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/listenbrainz/cache.rs b/src-tauri/src/listenbrainz/cache.rs new file mode 100644 index 000000000..0abfd7055 --- /dev/null +++ b/src-tauri/src/listenbrainz/cache.rs @@ -0,0 +1,365 @@ +//! ListenBrainz cache and queue layer +//! +//! SQLite-based queue for offline listens and token persistence + +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::Path; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::Mutex; + +use super::models::*; + +/// Max age for queued listens (30 days in seconds) +const QUEUE_MAX_AGE_SECS: i64 = 30 * 24 * 60 * 60; + +/// Max queue size +const QUEUE_MAX_SIZE: i64 = 500; + +/// ListenBrainz cache state for Tauri +pub struct ListenBrainzCacheState { + pub cache: Arc>, +} + +impl ListenBrainzCacheState { + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz") + .join("cache"); + + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + let db_path = data_dir.join("listenbrainz_cache.db"); + let cache = ListenBrainzCache::new(&db_path)?; + + log::info!("ListenBrainz cache initialized at {:?}", db_path); + + Ok(Self { + cache: Arc::new(Mutex::new(cache)), + }) + } +} + +/// ListenBrainz SQLite cache +pub struct ListenBrainzCache { + conn: Connection, +} + +impl ListenBrainzCache { + pub fn new(path: &Path) -> Result { + let conn = Connection::open(path) + .map_err(|e| format!("Failed to open ListenBrainz cache: {}", e))?; + + // Enable WAL mode for better concurrency + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to set pragmas: {}", e))?; + + let cache = Self { conn }; + cache.init()?; + Ok(cache) + } + + fn init(&self) -> Result<(), String> { + self.conn + .execute_batch( + r#" + -- Token persistence + CREATE TABLE IF NOT EXISTS lb_credentials ( + id INTEGER PRIMARY KEY CHECK (id = 1), + token TEXT, + user_name TEXT, + updated_at INTEGER NOT NULL + ); + INSERT OR IGNORE INTO lb_credentials (id, updated_at) VALUES (1, 0); + + -- Offline listen queue + CREATE TABLE IF NOT EXISTS lb_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listened_at INTEGER NOT NULL, + artist_name TEXT NOT NULL, + track_name TEXT NOT NULL, + release_name TEXT, + recording_mbid TEXT, + release_mbid TEXT, + artist_mbids TEXT, + isrc TEXT, + duration_ms INTEGER, + created_at INTEGER NOT NULL, + attempts INTEGER DEFAULT 0, + sent INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_lb_queue_sent ON lb_queue(sent); + CREATE INDEX IF NOT EXISTS idx_lb_queue_created ON lb_queue(created_at); + + -- Settings + CREATE TABLE IF NOT EXISTS lb_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 1 + ); + INSERT OR IGNORE INTO lb_settings (id, enabled) VALUES (1, 1); + "#, + ) + .map_err(|e| format!("Failed to initialize ListenBrainz cache: {}", e))?; + Ok(()) + } + + fn current_timestamp() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + } + + // ============ Credentials ============ + + /// Get saved token and username + pub fn get_credentials(&self) -> Result<(Option, Option), String> { + let result: (Option, Option) = self + .conn + .query_row( + "SELECT token, user_name FROM lb_credentials WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| format!("Failed to get credentials: {}", e))?; + Ok(result) + } + + /// Save token and username + pub fn set_credentials(&self, token: Option<&str>, user_name: Option<&str>) -> Result<(), String> { + let now = Self::current_timestamp(); + self.conn + .execute( + "UPDATE lb_credentials SET token = ?, user_name = ?, updated_at = ? WHERE id = 1", + params![token, user_name, now], + ) + .map_err(|e| format!("Failed to save credentials: {}", e))?; + Ok(()) + } + + /// Clear saved credentials + pub fn clear_credentials(&self) -> Result<(), String> { + self.set_credentials(None, None) + } + + // ============ Settings ============ + + /// Check if ListenBrainz is enabled + pub fn is_enabled(&self) -> Result { + let enabled: i64 = self + .conn + .query_row("SELECT enabled FROM lb_settings WHERE id = 1", [], |row| row.get(0)) + .unwrap_or(1); + Ok(enabled != 0) + } + + /// Set enabled state + pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE lb_settings SET enabled = ? WHERE id = 1", + params![enabled as i64], + ) + .map_err(|e| format!("Failed to set enabled state: {}", e))?; + Ok(()) + } + + // ============ Queue ============ + + /// Queue a listen for later submission + pub fn queue_listen( + &self, + listened_at: i64, + artist: &str, + track: &str, + album: Option<&str>, + recording_mbid: Option<&str>, + release_mbid: Option<&str>, + artist_mbids: Option<&[String]>, + isrc: Option<&str>, + duration_ms: Option, + ) -> Result { + // First, enforce max queue size by removing oldest entries + self.enforce_queue_limits()?; + + let now = Self::current_timestamp(); + let artist_mbids_json = artist_mbids.map(|ids| serde_json::to_string(ids).unwrap_or_default()); + + self.conn + .execute( + "INSERT INTO lb_queue (listened_at, artist_name, track_name, release_name, + recording_mbid, release_mbid, artist_mbids, isrc, + duration_ms, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + listened_at, + artist, + track, + album, + recording_mbid, + release_mbid, + artist_mbids_json, + isrc, + duration_ms.map(|d| d as i64), + now + ], + ) + .map_err(|e| format!("Failed to queue listen: {}", e))?; + + Ok(self.conn.last_insert_rowid()) + } + + /// Enforce queue size and age limits + fn enforce_queue_limits(&self) -> Result<(), String> { + let now = Self::current_timestamp(); + let cutoff = now - QUEUE_MAX_AGE_SECS; + + // Remove old entries + self.conn + .execute("DELETE FROM lb_queue WHERE created_at < ?", params![cutoff]) + .map_err(|e| format!("Failed to cleanup old queue entries: {}", e))?; + + // Remove excess entries (keep newest) + let count: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM lb_queue WHERE sent = 0", [], |row| row.get(0)) + .unwrap_or(0); + + if count >= QUEUE_MAX_SIZE { + let to_remove = count - QUEUE_MAX_SIZE + 1; + self.conn + .execute( + "DELETE FROM lb_queue WHERE id IN ( + SELECT id FROM lb_queue WHERE sent = 0 + ORDER BY listened_at ASC LIMIT ? + )", + params![to_remove], + ) + .map_err(|e| format!("Failed to trim queue: {}", e))?; + } + + Ok(()) + } + + /// Get queued listens (unsent, up to limit) + pub fn get_queued_listens(&self, limit: u32) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT id, listened_at, artist_name, track_name, release_name, + recording_mbid, release_mbid, artist_mbids, isrc, + duration_ms, created_at, attempts, sent + FROM lb_queue WHERE sent = 0 + ORDER BY listened_at ASC LIMIT ?", + ) + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let listens = stmt + .query_map(params![limit], |row| { + let artist_mbids_json: Option = row.get(7)?; + let artist_mbids: Option> = artist_mbids_json + .and_then(|json| serde_json::from_str(&json).ok()); + + Ok(QueuedListen { + id: row.get(0)?, + listened_at: row.get(1)?, + artist_name: row.get(2)?, + track_name: row.get(3)?, + release_name: row.get(4)?, + recording_mbid: row.get(5)?, + release_mbid: row.get(6)?, + artist_mbids, + isrc: row.get(8)?, + duration_ms: row.get::<_, Option>(9)?.map(|d| d as u64), + created_at: row.get(10)?, + attempts: row.get(11)?, + sent: row.get::<_, i64>(12)? != 0, + }) + }) + .map_err(|e| format!("Failed to query queued listens: {}", e))?; + + listens + .collect::, _>>() + .map_err(|e| format!("Failed to collect queued listens: {}", e)) + } + + /// Mark listens as sent + pub fn mark_listens_sent(&self, ids: &[i64]) -> Result<(), String> { + if ids.is_empty() { + return Ok(()); + } + + let placeholders: Vec = ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + "UPDATE lb_queue SET sent = 1 WHERE id IN ({})", + placeholders.join(",") + ); + + let mut stmt = self + .conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare statement: {}", e))?; + + let params: Vec<&dyn rusqlite::ToSql> = ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); + stmt.execute(params.as_slice()) + .map_err(|e| format!("Failed to mark listens as sent: {}", e))?; + + Ok(()) + } + + /// Increment attempt count for a listen + pub fn increment_attempts(&self, id: i64) -> Result<(), String> { + self.conn + .execute( + "UPDATE lb_queue SET attempts = attempts + 1 WHERE id = ?", + params![id], + ) + .map_err(|e| format!("Failed to increment attempts: {}", e))?; + Ok(()) + } + + /// Get count of queued (unsent) listens + pub fn get_queue_count(&self) -> Result { + self.conn + .query_row( + "SELECT COUNT(*) FROM lb_queue WHERE sent = 0", + [], + |row| row.get::<_, i64>(0), + ) + .map(|count| count as u32) + .map_err(|e| format!("Failed to count queued listens: {}", e)) + } + + /// Clear all queued listens + pub fn clear_queue(&self) -> Result<(), String> { + self.conn + .execute("DELETE FROM lb_queue", []) + .map_err(|e| format!("Failed to clear queue: {}", e))?; + Ok(()) + } + + /// Cleanup sent listens older than specified days + pub fn cleanup_sent_listens(&self, older_than_days: u32) -> Result { + let cutoff = Self::current_timestamp() - (older_than_days as i64 * 24 * 60 * 60); + + let deleted = self + .conn + .execute( + "DELETE FROM lb_queue WHERE sent = 1 AND created_at < ?", + params![cutoff], + ) + .map_err(|e| format!("Failed to cleanup sent listens: {}", e))?; + + Ok(deleted as u32) + } +} + +/// Queue statistics +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueueStats { + pub pending_count: u32, + pub total_count: u32, +} diff --git a/src-tauri/src/listenbrainz/client.rs b/src-tauri/src/listenbrainz/client.rs new file mode 100644 index 000000000..7c4d5c6d3 --- /dev/null +++ b/src-tauri/src/listenbrainz/client.rs @@ -0,0 +1,288 @@ +//! ListenBrainz API client +//! +//! Direct client for ListenBrainz submissions (no proxy needed - uses user token) + +use reqwest::Client; +use std::sync::Arc; +use tokio::sync::Mutex; + +use super::models::*; + +/// ListenBrainz API base URL +const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1"; + +/// ListenBrainz client configuration +#[derive(Debug, Clone)] +pub struct ListenBrainzConfig { + /// Whether ListenBrainz integration is enabled + pub enabled: bool, + /// User token from listenbrainz.org + pub token: Option, + /// Username (set after token validation) + pub user_name: Option, +} + +impl Default for ListenBrainzConfig { + fn default() -> Self { + Self { + enabled: true, + token: None, + user_name: None, + } + } +} + +/// ListenBrainz API client +pub struct ListenBrainzClient { + client: Client, + config: Arc>, +} + +impl Default for ListenBrainzClient { + fn default() -> Self { + Self::new() + } +} + +impl ListenBrainzClient { + pub fn new() -> Self { + Self::with_config(ListenBrainzConfig::default()) + } + + pub fn with_config(config: ListenBrainzConfig) -> Self { + let version = env!("CARGO_PKG_VERSION"); + let user_agent = format!( + "QBZ/{} (https://github.com/vicrodh/qbz; qbz@vicrodh.dev)", + version + ); + + let client = Client::builder() + .user_agent(&user_agent) + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| Client::new()); + + Self { + client, + config: Arc::new(Mutex::new(config)), + } + } + + /// Check if ListenBrainz integration is enabled + pub async fn is_enabled(&self) -> bool { + self.config.lock().await.enabled + } + + /// Enable or disable ListenBrainz integration + pub async fn set_enabled(&self, enabled: bool) { + self.config.lock().await.enabled = enabled; + } + + /// Check if authenticated (has valid token) + pub async fn is_authenticated(&self) -> bool { + let config = self.config.lock().await; + config.token.is_some() && config.user_name.is_some() + } + + /// Get current status + pub async fn get_status(&self) -> ListenBrainzStatus { + let config = self.config.lock().await; + ListenBrainzStatus { + connected: config.token.is_some() && config.user_name.is_some(), + user_name: config.user_name.clone(), + enabled: config.enabled, + } + } + + /// Set user token and validate it + pub async fn set_token(&self, token: &str) -> Result { + // Validate token first + let validation = self.validate_token(token).await?; + + if !validation.valid { + return Err(validation.message); + } + + let user_name = validation.user_name.ok_or("Token valid but no username returned")?; + + // Store validated token and username + { + let mut config = self.config.lock().await; + config.token = Some(token.to_string()); + config.user_name = Some(user_name.clone()); + } + + log::info!("ListenBrainz connected as: {}", user_name); + + Ok(UserInfo { user_name }) + } + + /// Restore token from saved session (without re-validating) + pub async fn restore_token(&self, token: String, user_name: String) { + let mut config = self.config.lock().await; + config.token = Some(token); + config.user_name = Some(user_name); + } + + /// Get current token (for persistence) + pub async fn get_token(&self) -> Option { + self.config.lock().await.token.clone() + } + + /// Get current username + pub async fn get_user_name(&self) -> Option { + self.config.lock().await.user_name.clone() + } + + /// Disconnect (clear token) + pub async fn disconnect(&self) { + let mut config = self.config.lock().await; + config.token = None; + config.user_name = None; + log::info!("ListenBrainz disconnected"); + } + + /// Validate a token with ListenBrainz API + async fn validate_token(&self, token: &str) -> Result { + let url = format!("{}/validate-token", LISTENBRAINZ_API_URL); + + let response = self + .client + .get(&url) + .header("Authorization", format!("Token {}", token)) + .send() + .await + .map_err(|e| format!("Failed to validate token: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(format!("Token validation failed: {} - {}", status, text)); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse validation response: {}", e)) + } + + /// Submit "now playing" notification + pub async fn submit_playing_now( + &self, + artist: &str, + track: &str, + album: Option<&str>, + additional_info: Option, + ) -> Result<(), String> { + let token = { + let config = self.config.lock().await; + if !config.enabled { + return Ok(()); // Silently skip if disabled + } + config.token.clone() + }; + + let token = token.ok_or("Not authenticated with ListenBrainz")?; + + let mut info = additional_info.unwrap_or_default(); + // Ensure QBZ identifiers are set + let version = env!("CARGO_PKG_VERSION").to_string(); + info.media_player = "QBZ".to_string(); + info.media_player_version = version.clone(); + info.submission_client = "QBZ".to_string(); + info.submission_client_version = version; + + let payload = SubmitListensPayload { + listen_type: ListenType::PlayingNow, + payload: vec![Listen { + listened_at: None, // Not used for playing_now + track_metadata: TrackMetadata { + artist_name: artist.to_string(), + track_name: track.to_string(), + release_name: album.map(|s| s.to_string()), + additional_info: Some(info), + }, + }], + }; + + self.submit_listens(&token, &payload).await + } + + /// Submit a scrobble (track finished playing) + pub async fn submit_listen( + &self, + artist: &str, + track: &str, + album: Option<&str>, + timestamp: i64, + additional_info: Option, + ) -> Result<(), String> { + let token = { + let config = self.config.lock().await; + if !config.enabled { + return Ok(()); // Silently skip if disabled + } + config.token.clone() + }; + + let token = token.ok_or("Not authenticated with ListenBrainz")?; + + let mut info = additional_info.unwrap_or_default(); + // Ensure QBZ identifiers are set + let version = env!("CARGO_PKG_VERSION").to_string(); + info.media_player = "QBZ".to_string(); + info.media_player_version = version.clone(); + info.submission_client = "QBZ".to_string(); + info.submission_client_version = version; + + let payload = SubmitListensPayload { + listen_type: ListenType::Single, + payload: vec![Listen { + listened_at: Some(timestamp), + track_metadata: TrackMetadata { + artist_name: artist.to_string(), + track_name: track.to_string(), + release_name: album.map(|s| s.to_string()), + additional_info: Some(info), + }, + }], + }; + + self.submit_listens(&token, &payload).await + } + + /// Internal: Submit listens to API + async fn submit_listens(&self, token: &str, payload: &SubmitListensPayload) -> Result<(), String> { + let url = format!("{}/submit-listens", LISTENBRAINZ_API_URL); + + let response = self + .client + .post(&url) + .header("Authorization", format!("Token {}", token)) + .header("Content-Type", "application/json") + .json(payload) + .send() + .await + .map_err(|e| format!("Failed to submit listen: {}", e))?; + + if response.status().is_success() { + let listen_type = match payload.listen_type { + ListenType::PlayingNow => "now playing", + ListenType::Single => "scrobble", + }; + if let Some(listen) = payload.payload.first() { + log::debug!( + "ListenBrainz {}: {} - {}", + listen_type, + listen.track_metadata.artist_name, + listen.track_metadata.track_name + ); + } + Ok(()) + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + Err(format!("ListenBrainz submission failed: {} - {}", status, text)) + } + } +} diff --git a/src-tauri/src/listenbrainz/mod.rs b/src-tauri/src/listenbrainz/mod.rs new file mode 100644 index 000000000..d2f3b2c07 --- /dev/null +++ b/src-tauri/src/listenbrainz/mod.rs @@ -0,0 +1,79 @@ +//! ListenBrainz integration module +//! +//! Provides scrobbling and now-playing notifications to ListenBrainz. +//! Uses personal user tokens (not OAuth) for authentication. +//! +//! ## Architecture +//! +//! - `client.rs`: HTTP client for ListenBrainz API +//! - `models.rs`: Submission payload types +//! - `cache.rs`: SQLite-based offline queue and token persistence +//! +//! ## Usage +//! +//! The module is accessed via Tauri commands in `commands/listenbrainz.rs`. +//! +//! ## Scrobbling Rules +//! +//! - Now Playing: Submitted when track starts +//! - Scrobble: Submitted after 50% of track OR 4 minutes played +//! - Skip: No submission if < 30 seconds played +//! +//! ## MusicBrainz Integration +//! +//! Uses Stage 1 MusicBrainz data to enrich scrobbles with: +//! - Recording MBID +//! - Release MBID +//! - Artist MBIDs +//! - ISRC codes + +pub mod cache; +pub mod client; +pub mod models; + +pub use cache::{ListenBrainzCache, ListenBrainzCacheState, QueueStats}; +pub use client::{ListenBrainzClient, ListenBrainzConfig}; +pub use models::{ + AdditionalInfo, Listen, ListenBrainzStatus, ListenType, QueuedListen, + SubmitListensPayload, TrackMetadata, UserInfo, +}; + +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Shared state wrapper for Tauri +pub struct ListenBrainzSharedState { + pub client: Arc>, + pub cache: Arc>, +} + +impl ListenBrainzSharedState { + pub fn new() -> Result { + let cache_state = ListenBrainzCacheState::new()?; + + // Check for saved credentials and enabled state + let (token, user_name, enabled) = { + let cache = cache_state.cache.blocking_lock(); + let (token, user_name) = cache.get_credentials().unwrap_or((None, None)); + let enabled = cache.is_enabled().unwrap_or(true); + (token, user_name, enabled) + }; + + // Create client with saved config + let config = ListenBrainzConfig { + enabled, + token: token.clone(), + user_name: user_name.clone(), + }; + let client = ListenBrainzClient::with_config(config); + + if token.is_some() && user_name.is_some() { + log::info!("ListenBrainz: restored session for user {:?}", user_name); + } + + Ok(Self { + client: Arc::new(Mutex::new(client)), + cache: cache_state.cache, + }) + } +} diff --git a/src-tauri/src/listenbrainz/models.rs b/src-tauri/src/listenbrainz/models.rs new file mode 100644 index 000000000..3bae9b9b8 --- /dev/null +++ b/src-tauri/src/listenbrainz/models.rs @@ -0,0 +1,157 @@ +//! ListenBrainz API models +//! +//! Types for ListenBrainz submission payloads and responses + +use serde::{Deserialize, Serialize}; + +/// Listen type for submission +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ListenType { + /// Currently playing track + PlayingNow, + /// Single scrobble + Single, +} + +/// A single listen submission +#[derive(Debug, Clone, Serialize)] +pub struct Listen { + /// Unix timestamp (omit for playing_now) + #[serde(skip_serializing_if = "Option::is_none")] + pub listened_at: Option, + /// Track metadata + pub track_metadata: TrackMetadata, +} + +/// Track metadata for a listen +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackMetadata { + /// Artist name (required) + pub artist_name: String, + /// Track name (required) + pub track_name: String, + /// Release/album name (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub release_name: Option, + /// Additional metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_info: Option, +} + +/// Additional track info for richer scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdditionalInfo { + /// MusicBrainz recording ID (from Stage 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub recording_mbid: Option, + /// MusicBrainz release ID (from Stage 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub release_mbid: Option, + /// MusicBrainz artist IDs (from Stage 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub artist_mbids: Option>, + /// ISRC code + #[serde(skip_serializing_if = "Option::is_none")] + pub isrc: Option, + /// Track duration in milliseconds + #[serde(skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + /// Track number on release + #[serde(skip_serializing_if = "Option::is_none")] + pub tracknumber: Option, + /// Media player name + pub media_player: String, + /// Media player version + pub media_player_version: String, + /// Submission client name + pub submission_client: String, + /// Submission client version + pub submission_client_version: String, +} + +impl AdditionalInfo { + /// Create new AdditionalInfo with QBZ identifiers + pub fn new() -> Self { + let version = env!("CARGO_PKG_VERSION").to_string(); + Self { + recording_mbid: None, + release_mbid: None, + artist_mbids: None, + isrc: None, + duration_ms: None, + tracknumber: None, + media_player: "QBZ".to_string(), + media_player_version: version.clone(), + submission_client: "QBZ".to_string(), + submission_client_version: version, + } + } +} + +impl Default for AdditionalInfo { + fn default() -> Self { + Self::new() + } +} + +/// Payload for submitting listens +#[derive(Debug, Clone, Serialize)] +pub struct SubmitListensPayload { + pub listen_type: ListenType, + pub payload: Vec, +} + +/// Response from ListenBrainz API +#[derive(Debug, Deserialize)] +pub struct ListenBrainzResponse { + pub status: String, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub error: Option, +} + +/// User info response +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UserInfo { + pub user_name: String, +} + +/// Token validation response +#[derive(Debug, Deserialize)] +pub struct TokenValidationResponse { + pub code: i32, + pub message: String, + pub valid: bool, + #[serde(default)] + pub user_name: Option, +} + +/// ListenBrainz connection status +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListenBrainzStatus { + pub connected: bool, + pub user_name: Option, + pub enabled: bool, +} + +/// Queued listen for offline submission +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueuedListen { + pub id: i64, + pub listened_at: i64, + pub artist_name: String, + pub track_name: String, + pub release_name: Option, + pub recording_mbid: Option, + pub release_mbid: Option, + pub artist_mbids: Option>, + pub isrc: Option, + pub duration_ms: Option, + pub created_at: i64, + pub attempts: i32, + pub sent: bool, +} diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 8003b6a2c..322d786fc 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -399,6 +399,14 @@ // MusicBrainz integration state let musicbrainzEnabled = $state(true); + // ListenBrainz integration state + let listenbrainzConnected = $state(false); + let listenbrainzUsername = $state(''); + let listenbrainzEnabled = $state(true); + let listenbrainzToken = $state(''); + let listenbrainzConnecting = $state(false); + let showListenBrainzConfig = $state(false); + // Load saved settings on mount onMount(() => { // Load theme @@ -468,6 +476,9 @@ // Load MusicBrainz state loadMusicBrainzState(); + // Load ListenBrainz state + loadListenBrainzState(); + // Load notification preferences loadToastsPreference(); toastsEnabled = getToastsEnabled(); @@ -681,6 +692,64 @@ } } + // ListenBrainz functions + async function loadListenBrainzState() { + try { + const status = await invoke<{ + connected: boolean; + userName: string | null; + enabled: boolean; + }>('listenbrainz_get_status'); + listenbrainzConnected = status.connected; + listenbrainzUsername = status.userName || ''; + listenbrainzEnabled = status.enabled; + } catch (err) { + console.error('Failed to load ListenBrainz state:', err); + } + } + + async function handleListenBrainzConnect() { + if (!listenbrainzToken.trim()) { + showListenBrainzConfig = true; + return; + } + + listenbrainzConnecting = true; + try { + const userInfo = await invoke<{ user_name: string }>('listenbrainz_connect', { + token: listenbrainzToken.trim() + }); + listenbrainzConnected = true; + listenbrainzUsername = userInfo.user_name; + listenbrainzToken = ''; + showListenBrainzConfig = false; + } catch (err) { + console.error('Failed to connect to ListenBrainz:', err); + alert(`ListenBrainz error: ${err}`); + } finally { + listenbrainzConnecting = false; + } + } + + async function handleListenBrainzDisconnect() { + try { + await invoke('listenbrainz_disconnect'); + listenbrainzConnected = false; + listenbrainzUsername = ''; + } catch (err) { + console.error('Failed to disconnect ListenBrainz:', err); + } + } + + async function handleListenBrainzEnabledChange(enabled: boolean) { + try { + await invoke('listenbrainz_set_enabled', { enabled }); + listenbrainzEnabled = enabled; + } catch (err) { + console.error('Failed to update ListenBrainz setting:', err); + } + } + async function handleShowDownloadsChange(enabled: boolean) { try { await invoke('set_show_downloads_in_library', { show: enabled }); @@ -1942,6 +2011,64 @@ {/if} {/if} + + {#if listenbrainzConnected} +
+
+ ListenBrainz + {$t('settings.integrations.connectedAs', { values: { username: listenbrainzUsername }})} +
+ +
+
+ Scrobbling + +
+ {:else} +
+ ListenBrainz + +
+ + {#if showListenBrainzConfig} +
+

+ Get your personal token from + + listenbrainz.org/settings + +

+
+ + +
+ +
+ {/if} + {/if} +