diff --git a/src-tauri/src/artist_blacklist/mod.rs b/src-tauri/src/artist_blacklist/mod.rs new file mode 100644 index 000000000..0908daf2c --- /dev/null +++ b/src-tauri/src/artist_blacklist/mod.rs @@ -0,0 +1,122 @@ +//! Artist blacklist module +//! +//! Provides local artist blacklist functionality with: +//! - O(1) lookup performance via in-memory HashSet +//! - SQLite persistence +//! - Global enable/disable toggle (feature flag) +//! +//! ## Usage +//! +//! The blacklist service is initialized at app startup and registered +//! as a Tauri managed state. Use the `is_blacklisted()` method to check +//! if an artist should be filtered. +//! +//! ```rust,ignore +//! if state.blacklist.is_blacklisted(artist_id) { +//! // Skip this artist +//! continue; +//! } +//! ``` + +pub mod models; +pub mod service; + +pub use models::{BlacklistSettings, BlacklistedArtist}; +pub use service::BlacklistService; + +use std::sync::Mutex; + +/// Thread-safe state wrapper for the blacklist service +pub struct BlacklistState { + pub service: Mutex, +} + +impl BlacklistState { + /// Create a new blacklist state + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or("Could not determine data directory")? + .join("qbz"); + + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + let db_path = data_dir.join("artist_blacklist.db"); + let service = BlacklistService::new(&db_path)?; + + Ok(Self { + service: Mutex::new(service), + }) + } + + /// Check if an artist is blacklisted - thread-safe O(1) operation + #[inline] + pub fn is_blacklisted(&self, artist_id: u64) -> bool { + self.service + .lock() + .map(|s| s.is_blacklisted(artist_id)) + .unwrap_or(false) + } + + /// Check if the feature is enabled + #[inline] + pub fn is_enabled(&self) -> bool { + self.service + .lock() + .map(|s| s.is_enabled()) + .unwrap_or(true) + } + + /// Add an artist to the blacklist + pub fn add(&self, artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> { + self.service + .lock() + .map_err(|_| "Failed to acquire lock")? + .add(artist_id, artist_name, notes) + } + + /// Remove an artist from the blacklist + pub fn remove(&self, artist_id: u64) -> Result<(), String> { + self.service + .lock() + .map_err(|_| "Failed to acquire lock")? + .remove(artist_id) + } + + /// Get all blacklisted artists + pub fn get_all(&self) -> Result, String> { + self.service + .lock() + .map_err(|_| "Failed to acquire lock")? + .get_all() + } + + /// Set enabled state + pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { + self.service + .lock() + .map_err(|_| "Failed to acquire lock")? + .set_enabled(enabled) + } + + /// Get blacklist settings + pub fn get_settings(&self) -> Result { + self.service + .lock() + .map(|s| s.get_settings()) + .map_err(|_| "Failed to acquire lock".to_string()) + } + + /// Get count of blacklisted artists + pub fn count(&self) -> usize { + self.service.lock().map(|s| s.count()).unwrap_or(0) + } + + /// Clear all blacklisted artists + pub fn clear_all(&self) -> Result<(), String> { + self.service + .lock() + .map_err(|_| "Failed to acquire lock")? + .clear_all() + } +} diff --git a/src-tauri/src/artist_blacklist/models.rs b/src-tauri/src/artist_blacklist/models.rs new file mode 100644 index 000000000..d9c243b88 --- /dev/null +++ b/src-tauri/src/artist_blacklist/models.rs @@ -0,0 +1,25 @@ +//! Artist blacklist data models + +use serde::{Deserialize, Serialize}; + +/// A blacklisted artist entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlacklistedArtist { + pub artist_id: u64, + pub artist_name: String, + pub added_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Blacklist settings (enable/disable toggle) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlacklistSettings { + pub enabled: bool, +} + +impl Default for BlacklistSettings { + fn default() -> Self { + Self { enabled: true } + } +} diff --git a/src-tauri/src/artist_blacklist/service.rs b/src-tauri/src/artist_blacklist/service.rs new file mode 100644 index 000000000..4ab56a854 --- /dev/null +++ b/src-tauri/src/artist_blacklist/service.rs @@ -0,0 +1,331 @@ +//! Artist blacklist service +//! +//! Provides O(1) artist blacklist checks using an in-memory HashSet +//! with SQLite persistence. + +use rusqlite::{params, Connection}; +use std::collections::HashSet; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::RwLock; + +use super::models::{BlacklistSettings, BlacklistedArtist}; + +/// Artist blacklist service with O(1) lookup performance +pub struct BlacklistService { + conn: Connection, + /// In-memory set for O(1) lookups + blacklisted_ids: RwLock>, + /// Feature flag - when false, is_blacklisted() always returns false + enabled: AtomicBool, +} + +impl BlacklistService { + /// Create a new blacklist service, opening or creating the database + pub fn new(db_path: &Path) -> Result { + log::info!("[Blacklist] Opening database at: {}", db_path.display()); + + let conn = Connection::open(db_path) + .map_err(|e| format!("Failed to open blacklist database: {}", e))?; + + // Enable WAL mode for better concurrent access + conn.execute_batch("PRAGMA journal_mode=WAL;") + .map_err(|e| format!("Failed to set WAL mode: {}", e))?; + + let service = Self { + conn, + blacklisted_ids: RwLock::new(HashSet::new()), + enabled: AtomicBool::new(true), + }; + + service.init_schema()?; + service.load_from_db()?; + service.load_settings()?; + + Ok(service) + } + + /// Initialize database schema + fn init_schema(&self) -> Result<(), String> { + self.conn + .execute_batch( + r#" + -- Artist blacklist entries + CREATE TABLE IF NOT EXISTS artist_blacklist ( + artist_id INTEGER PRIMARY KEY, + artist_name TEXT NOT NULL, + added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + notes TEXT + ); + + -- Index for name search in UI + CREATE INDEX IF NOT EXISTS idx_artist_blacklist_name + ON artist_blacklist(artist_name COLLATE NOCASE); + + -- Settings table (single row) + CREATE TABLE IF NOT EXISTS blacklist_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 1 + ); + + -- Insert default settings if not present + INSERT OR IGNORE INTO blacklist_settings (id, enabled) VALUES (1, 1); + "#, + ) + .map_err(|e| format!("Failed to initialize blacklist schema: {}", e))?; + + Ok(()) + } + + /// Load all blacklisted IDs from database into memory + fn load_from_db(&self) -> Result<(), String> { + let mut stmt = self + .conn + .prepare("SELECT artist_id FROM artist_blacklist") + .map_err(|e| format!("Failed to prepare blacklist query: {}", e))?; + + let ids: Vec = stmt + .query_map([], |row| row.get(0)) + .map_err(|e| format!("Failed to query blacklist: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + let count = ids.len(); + let mut set = self + .blacklisted_ids + .write() + .map_err(|_| "Failed to acquire write lock")?; + *set = ids.into_iter().collect(); + + log::info!("[Blacklist] Loaded {} blacklisted artists into memory", count); + Ok(()) + } + + /// Load enabled setting from database + fn load_settings(&self) -> Result<(), String> { + let enabled: bool = self + .conn + .query_row( + "SELECT enabled FROM blacklist_settings WHERE id = 1", + [], + |row| { + let val: i32 = row.get(0)?; + Ok(val != 0) + }, + ) + .map_err(|e| format!("Failed to load blacklist settings: {}", e))?; + + self.enabled.store(enabled, Ordering::Relaxed); + log::info!("[Blacklist] Feature enabled: {}", enabled); + Ok(()) + } + + /// Check if an artist is blacklisted - O(1) operation + /// + /// Returns false if the feature is disabled. + #[inline] + pub fn is_blacklisted(&self, artist_id: u64) -> bool { + // Fast path: if feature is disabled, always return false + if !self.enabled.load(Ordering::Relaxed) { + return false; + } + + // O(1) HashSet lookup + self.blacklisted_ids + .read() + .map(|set| set.contains(&artist_id)) + .unwrap_or(false) + } + + /// Add an artist to the blacklist + pub fn add(&self, artist_id: u64, artist_name: &str, notes: Option<&str>) -> Result<(), String> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + self.conn + .execute( + "INSERT OR REPLACE INTO artist_blacklist (artist_id, artist_name, added_at, notes) + VALUES (?1, ?2, ?3, ?4)", + params![artist_id as i64, artist_name, now, notes], + ) + .map_err(|e| format!("Failed to add artist to blacklist: {}", e))?; + + // Update in-memory set + if let Ok(mut set) = self.blacklisted_ids.write() { + set.insert(artist_id); + } + + log::info!( + "[Blacklist] Added artist: {} (id={})", + artist_name, + artist_id + ); + Ok(()) + } + + /// Remove an artist from the blacklist + pub fn remove(&self, artist_id: u64) -> Result<(), String> { + self.conn + .execute( + "DELETE FROM artist_blacklist WHERE artist_id = ?1", + params![artist_id as i64], + ) + .map_err(|e| format!("Failed to remove artist from blacklist: {}", e))?; + + // Update in-memory set + if let Ok(mut set) = self.blacklisted_ids.write() { + set.remove(&artist_id); + } + + log::info!("[Blacklist] Removed artist id={}", artist_id); + Ok(()) + } + + /// Get all blacklisted artists + pub fn get_all(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT artist_id, artist_name, added_at, notes + FROM artist_blacklist + ORDER BY artist_name COLLATE NOCASE", + ) + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let artists = stmt + .query_map([], |row| { + Ok(BlacklistedArtist { + artist_id: row.get::<_, i64>(0)? as u64, + artist_name: row.get(1)?, + added_at: row.get(2)?, + notes: row.get(3)?, + }) + }) + .map_err(|e| format!("Failed to query blacklist: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(artists) + } + + /// Get count of blacklisted artists + pub fn count(&self) -> usize { + self.blacklisted_ids + .read() + .map(|set| set.len()) + .unwrap_or(0) + } + + /// Set the enabled state + pub fn set_enabled(&self, enabled: bool) -> Result<(), String> { + self.conn + .execute( + "UPDATE blacklist_settings SET enabled = ?1 WHERE id = 1", + params![if enabled { 1 } else { 0 }], + ) + .map_err(|e| format!("Failed to update enabled setting: {}", e))?; + + self.enabled.store(enabled, Ordering::Relaxed); + log::info!("[Blacklist] Feature enabled set to: {}", enabled); + Ok(()) + } + + /// Check if the feature is enabled + #[inline] + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Get current settings + pub fn get_settings(&self) -> BlacklistSettings { + BlacklistSettings { + enabled: self.is_enabled(), + } + } + + /// Clear all blacklisted artists + pub fn clear_all(&self) -> Result<(), String> { + self.conn + .execute("DELETE FROM artist_blacklist", []) + .map_err(|e| format!("Failed to clear blacklist: {}", e))?; + + if let Ok(mut set) = self.blacklisted_ids.write() { + set.clear(); + } + + log::info!("[Blacklist] Cleared all entries"); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use tempfile::tempdir; + + fn create_test_service() -> BlacklistService { + let dir = tempdir().unwrap(); + let db_path = dir.path().join("test_blacklist.db"); + BlacklistService::new(&db_path).unwrap() + } + + #[test] + fn test_add_and_check() { + let service = create_test_service(); + + assert!(!service.is_blacklisted(123)); + + service.add(123, "Test Artist", None).unwrap(); + + assert!(service.is_blacklisted(123)); + assert!(!service.is_blacklisted(456)); + } + + #[test] + fn test_remove() { + let service = create_test_service(); + + service.add(123, "Test Artist", None).unwrap(); + assert!(service.is_blacklisted(123)); + + service.remove(123).unwrap(); + assert!(!service.is_blacklisted(123)); + } + + #[test] + fn test_enabled_toggle() { + let service = create_test_service(); + + service.add(123, "Test Artist", None).unwrap(); + + // Enabled by default + assert!(service.is_blacklisted(123)); + + // Disable feature + service.set_enabled(false).unwrap(); + assert!(!service.is_blacklisted(123)); + + // Re-enable + service.set_enabled(true).unwrap(); + assert!(service.is_blacklisted(123)); + } + + #[test] + fn test_get_all() { + let service = create_test_service(); + + service.add(1, "Artist A", None).unwrap(); + service.add(2, "Artist B", Some("test note")).unwrap(); + + let all = service.get_all().unwrap(); + assert_eq!(all.len(), 2); + + // Should be sorted by name + assert_eq!(all[0].artist_name, "Artist A"); + assert_eq!(all[1].artist_name, "Artist B"); + assert_eq!(all[1].notes, Some("test note".to_string())); + } +} diff --git a/src-tauri/src/commands/artist_blacklist.rs b/src-tauri/src/commands/artist_blacklist.rs new file mode 100644 index 000000000..5aba210c9 --- /dev/null +++ b/src-tauri/src/commands/artist_blacklist.rs @@ -0,0 +1,67 @@ +//! Tauri commands for artist blacklist management + +use tauri::State; + +use crate::artist_blacklist::{BlacklistSettings, BlacklistState, BlacklistedArtist}; + +/// Get all blacklisted artists +#[tauri::command] +pub fn get_artist_blacklist(state: State) -> Result, String> { + state.get_all() +} + +/// Add an artist to the blacklist +#[tauri::command] +pub fn add_to_artist_blacklist( + artist_id: u64, + artist_name: String, + notes: Option, + state: State, +) -> Result<(), String> { + state.add(artist_id, &artist_name, notes.as_deref()) +} + +/// Remove an artist from the blacklist +#[tauri::command] +pub fn remove_from_artist_blacklist( + artist_id: u64, + state: State, +) -> Result<(), String> { + state.remove(artist_id) +} + +/// Check if an artist is blacklisted +#[tauri::command] +pub fn is_artist_blacklisted(artist_id: u64, state: State) -> Result { + Ok(state.is_blacklisted(artist_id)) +} + +/// Set the blacklist enabled state +#[tauri::command] +pub fn set_blacklist_enabled(enabled: bool, state: State) -> Result<(), String> { + state.set_enabled(enabled) +} + +/// Check if the blacklist feature is enabled +#[tauri::command] +pub fn is_blacklist_enabled(state: State) -> Result { + Ok(state.is_enabled()) +} + +/// Get blacklist settings +#[tauri::command] +pub fn get_blacklist_settings(state: State) -> Result { + state.get_settings() +} + +/// Get the count of blacklisted artists +#[tauri::command] +pub fn get_blacklist_count(state: State) -> Result { + Ok(state.count()) +} + +/// Clear all blacklisted artists +#[tauri::command] +pub fn clear_artist_blacklist(state: State) -> Result<(), String> { + state.clear_all() +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d0f425395..884306a70 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ //! //! Exposes backend functionality to the frontend via IPC +pub mod artist_blacklist; pub mod audio_backends; pub mod audio_diagnostics; pub mod auth; @@ -26,6 +27,7 @@ pub mod smart_playlists; pub mod remote_metadata; pub mod visualizer; +pub use artist_blacklist::*; pub use audio_backends::*; pub use audio_diagnostics::*; pub use auth::*; diff --git a/src-tauri/src/commands/playlist_suggestions.rs b/src-tauri/src/commands/playlist_suggestions.rs index 62c3bd3ca..5feab3f47 100644 --- a/src-tauri/src/commands/playlist_suggestions.rs +++ b/src-tauri/src/commands/playlist_suggestions.rs @@ -6,6 +6,7 @@ use std::collections::HashSet; use std::sync::Arc; use tauri::State; +use crate::artist_blacklist::BlacklistState; use crate::artist_vectors::{ ArtistVectorBuilder, ArtistVectorStoreState, RelationshipWeights, StoreStats, SuggestionConfig, SuggestionResult, SuggestionsEngine, @@ -44,6 +45,7 @@ pub async fn get_playlist_suggestions_v2( store_state: State<'_, ArtistVectorStoreState>, mb_state: State<'_, MusicBrainzSharedState>, app_state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result { use std::time::Instant; let total_start = Instant::now(); @@ -193,8 +195,8 @@ pub async fn get_playlist_suggestions_v2( let engine_elapsed = engine_start.elapsed(); let total_elapsed = total_start.elapsed(); - match &result { - Ok(r) => { + match result { + Ok(mut r) => { log::info!( "[Suggestions] Engine took {:?}, total {:?}. Result: {} tracks from {} similar artists", engine_elapsed, @@ -202,13 +204,32 @@ pub async fn get_playlist_suggestions_v2( r.tracks.len(), r.similar_artists_count ); + + // Filter out tracks from blacklisted artists + let original_count = r.tracks.len(); + r.tracks.retain(|track| { + if let Some(artist_id) = track.artist_id { + !blacklist_state.is_blacklisted(artist_id) + } else { + true // Keep tracks without artist ID + } + }); + + let filtered_count = original_count - r.tracks.len(); + if filtered_count > 0 { + log::info!( + "[Suggestions] Filtered {} tracks from blacklisted artists", + filtered_count + ); + } + + Ok(r) } Err(e) => { log::error!("[Suggestions] Engine failed after {:?}: {}", engine_elapsed, e); + Err(e) } } - - result } /// Get store statistics for debugging diff --git a/src-tauri/src/commands/queue.rs b/src-tauri/src/commands/queue.rs index 60df7bb61..46f27ded4 100644 --- a/src-tauri/src/commands/queue.rs +++ b/src-tauri/src/commands/queue.rs @@ -2,12 +2,30 @@ use tauri::State; +use crate::artist_blacklist::BlacklistState; use crate::queue::{QueueState, QueueTrack, RepeatMode}; use crate::AppState; +/// Check if a track's artist is blacklisted +fn is_track_blacklisted(track: &QueueTrack, blacklist: &BlacklistState) -> bool { + if let Some(artist_id) = track.artist_id { + blacklist.is_blacklisted(artist_id) + } else { + false + } +} + /// Add a track to the queue #[tauri::command] -pub fn add_to_queue(track: QueueTrack, state: State<'_, AppState>) -> Result<(), String> { +pub fn add_to_queue( + track: QueueTrack, + state: State<'_, AppState>, + blacklist: State<'_, BlacklistState>, +) -> Result<(), String> { + if is_track_blacklisted(&track, &blacklist) { + log::debug!("Skipping blacklisted track: {} by {}", track.title, track.artist); + return Ok(()); + } log::info!("Command: add_to_queue - {} by {}", track.title, track.artist); state.queue.add_track(track); Ok(()) @@ -15,7 +33,15 @@ pub fn add_to_queue(track: QueueTrack, state: State<'_, AppState>) -> Result<(), /// Add a track to play next #[tauri::command] -pub fn add_to_queue_next(track: QueueTrack, state: State<'_, AppState>) -> Result<(), String> { +pub fn add_to_queue_next( + track: QueueTrack, + state: State<'_, AppState>, + blacklist: State<'_, BlacklistState>, +) -> Result<(), String> { + if is_track_blacklisted(&track, &blacklist) { + log::debug!("Skipping blacklisted track (next): {} by {}", track.title, track.artist); + return Ok(()); + } log::info!("Command: add_to_queue_next - {} by {}", track.title, track.artist); state.queue.add_track_next(track); Ok(()) @@ -23,17 +49,34 @@ pub fn add_to_queue_next(track: QueueTrack, state: State<'_, AppState>) -> Resul /// Add multiple tracks to the queue #[tauri::command] -pub fn add_tracks_to_queue(tracks: Vec, state: State<'_, AppState>) -> Result<(), String> { - log::info!("Command: add_tracks_to_queue - {} tracks", tracks.len()); - state.queue.add_tracks(tracks); +pub fn add_tracks_to_queue( + tracks: Vec, + state: State<'_, AppState>, + blacklist: State<'_, BlacklistState>, +) -> Result<(), String> { + let filtered: Vec = tracks + .into_iter() + .filter(|t| !is_track_blacklisted(t, &blacklist)) + .collect(); + log::info!("Command: add_tracks_to_queue - {} tracks (after blacklist filter)", filtered.len()); + state.queue.add_tracks(filtered); Ok(()) } /// Set the entire queue (replaces existing) #[tauri::command] -pub fn set_queue(tracks: Vec, start_index: Option, state: State<'_, AppState>) -> Result<(), String> { - log::info!("Command: set_queue - {} tracks, start at {:?}", tracks.len(), start_index); - state.queue.set_queue(tracks, start_index); +pub fn set_queue( + tracks: Vec, + start_index: Option, + state: State<'_, AppState>, + blacklist: State<'_, BlacklistState>, +) -> Result<(), String> { + let filtered: Vec = tracks + .into_iter() + .filter(|t| !is_track_blacklisted(t, &blacklist)) + .collect(); + log::info!("Command: set_queue - {} tracks (after blacklist filter), start at {:?}", filtered.len(), start_index); + state.queue.set_queue(filtered, start_index); Ok(()) } diff --git a/src-tauri/src/commands/radio.rs b/src-tauri/src/commands/radio.rs index fe6840bee..0478e78be 100644 --- a/src-tauri/src/commands/radio.rs +++ b/src-tauri/src/commands/radio.rs @@ -5,7 +5,8 @@ use tauri::State; use tokio::task; -use crate::api::{QobuzClient, Track}; +use crate::api::Track; +use crate::artist_blacklist::BlacklistState; use crate::playback_context::{ContentSource, ContextType, PlaybackContext}; use crate::queue::QueueTrack; use crate::radio_engine::{BuildRadioOptions, RadioEngine, RadioPoolBuilder}; @@ -20,6 +21,7 @@ pub async fn create_artist_radio( artist_id: u64, artist_name: String, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result { log::info!("[Radio] Creating artist radio for: {} (ID: {})", artist_name, artist_id); @@ -83,11 +85,24 @@ pub async fn create_artist_radio( .await .map_err(|e| format!("Track generation task failed: {}", e))??; - // Fetch full track details from Qobuz + // Fetch full track details from Qobuz, filtering blacklisted artists let mut tracks = Vec::new(); + let mut blacklist_skipped = 0; for track_id in track_ids { match client.get_track(track_id).await { Ok(track) => { + // Check if track's artist is blacklisted + if let Some(ref performer) = track.performer { + if blacklist_state.is_blacklisted(performer.id) { + log::debug!( + "[Radio] Skipping blacklisted artist track: {} - {}", + performer.name, + track.title + ); + blacklist_skipped += 1; + continue; + } + } tracks.push(track); } Err(e) => { @@ -96,6 +111,10 @@ pub async fn create_artist_radio( } } + if blacklist_skipped > 0 { + log::info!("[Radio] Skipped {} tracks from blacklisted artists", blacklist_skipped); + } + if tracks.is_empty() { return Err("Failed to generate any radio tracks".to_string()); } @@ -135,6 +154,7 @@ pub async fn create_track_radio( track_name: String, artist_id: u64, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result { log::info!( "[Radio] Creating track radio for: {} (Track ID: {}, Artist ID: {})", @@ -204,11 +224,24 @@ pub async fn create_track_radio( .await .map_err(|e| format!("Track generation task failed: {}", e))??; - // Fetch full track details from Qobuz + // Fetch full track details from Qobuz, filtering blacklisted artists let mut tracks = Vec::new(); + let mut blacklist_skipped = 0; for track_id in track_ids { match client.get_track(track_id).await { Ok(track) => { + // Check if track's artist is blacklisted + if let Some(ref performer) = track.performer { + if blacklist_state.is_blacklisted(performer.id) { + log::debug!( + "[Radio] Skipping blacklisted artist track: {} - {}", + performer.name, + track.title + ); + blacklist_skipped += 1; + continue; + } + } tracks.push(track); } Err(e) => { @@ -217,6 +250,10 @@ pub async fn create_track_radio( } } + if blacklist_skipped > 0 { + log::info!("[Radio] Skipped {} tracks from blacklisted artists", blacklist_skipped); + } + if tracks.is_empty() { return Err("Failed to generate any radio tracks".to_string()); } @@ -301,6 +338,7 @@ fn track_to_queue_track(track: &Track) -> QueueTrack { pub async fn refill_radio_queue( session_id: String, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result { log::info!("[Radio] Refilling queue for session: {}", session_id); @@ -337,12 +375,25 @@ pub async fn refill_radio_queue( return Ok(0); } - // Fetch full track details from Qobuz + // Fetch full track details from Qobuz, filtering blacklisted artists let client = state.client.lock().await; let mut tracks = Vec::new(); + let mut blacklist_skipped = 0; for track_id in track_ids.iter().take(20) { match client.get_track(*track_id).await { Ok(track) => { + // Check if track's artist is blacklisted + if let Some(ref performer) = track.performer { + if blacklist_state.is_blacklisted(performer.id) { + log::debug!( + "[Radio] Skipping blacklisted artist track during refill: {} - {}", + performer.name, + track.title + ); + blacklist_skipped += 1; + continue; + } + } tracks.push(track); } Err(e) => { @@ -351,6 +402,10 @@ pub async fn refill_radio_queue( } } + if blacklist_skipped > 0 { + log::info!("[Radio] Refill skipped {} tracks from blacklisted artists", blacklist_skipped); + } + let added_count = tracks.len() as u32; if tracks.is_empty() { @@ -391,6 +446,7 @@ pub fn get_queue_remaining(state: State<'_, AppState>) -> u32 { pub async fn create_infinite_radio( recent_track_ids: Vec, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result { if recent_track_ids.is_empty() { return Err("No recent tracks provided for infinite radio".to_string()); @@ -468,11 +524,24 @@ pub async fn create_infinite_radio( .await .map_err(|e| format!("Track generation task failed: {}", e))??; - // Fetch full track details from Qobuz + // Fetch full track details from Qobuz, filtering blacklisted artists let mut tracks = Vec::new(); + let mut blacklist_skipped = 0; for track_id in track_ids { match client.get_track(track_id).await { Ok(track) => { + // Check if track's artist is blacklisted + if let Some(ref performer) = track.performer { + if blacklist_state.is_blacklisted(performer.id) { + log::debug!( + "[Radio] Skipping blacklisted artist track in infinite radio: {} - {}", + performer.name, + track.title + ); + blacklist_skipped += 1; + continue; + } + } tracks.push(track); } Err(e) => { @@ -481,6 +550,10 @@ pub async fn create_infinite_radio( } } + if blacklist_skipped > 0 { + log::info!("[Radio] Infinite radio skipped {} tracks from blacklisted artists", blacklist_skipped); + } + if tracks.is_empty() { return Err("Failed to generate any infinite radio tracks".to_string()); } diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index 013e56782..03767c3bb 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -4,6 +4,7 @@ use tauri::State; use crate::api::{endpoints, endpoints::paths, Album, Artist, ArtistAlbums, LabelDetail, Playlist, SearchResultsPage, Track, TracksContainer}; use crate::api_cache::ApiCacheState; +use crate::artist_blacklist::BlacklistState; use crate::AppState; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -33,12 +34,25 @@ pub async fn search_albums( offset: Option, search_type: Option, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result, String> { let client = state.client.lock().await; - client + let mut results = client .search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref()) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Filter out albums from blacklisted artists + let original_count = results.items.len(); + results.items.retain(|album| !blacklist_state.is_blacklisted(album.artist.id)); + + let filtered_count = original_count - results.items.len(); + if filtered_count > 0 { + log::debug!("[Blacklist] Filtered {} albums from search results", filtered_count); + results.total = results.total.saturating_sub(filtered_count as u32); + } + + Ok(results) } #[tauri::command] @@ -48,12 +62,32 @@ pub async fn search_tracks( offset: Option, search_type: Option, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result, String> { let client = state.client.lock().await; - client + let mut results = client .search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref()) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Filter out tracks from blacklisted artists + let original_count = results.items.len(); + results.items.retain(|track| { + if let Some(ref performer) = track.performer { + !blacklist_state.is_blacklisted(performer.id) + } else { + true // Keep tracks without performer info + } + }); + + let filtered_count = original_count - results.items.len(); + if filtered_count > 0 { + log::debug!("[Blacklist] Filtered {} tracks from search results", filtered_count); + // Adjust total to reflect filtered count (approximate) + results.total = results.total.saturating_sub(filtered_count as u32); + } + + Ok(results) } #[tauri::command] @@ -63,18 +97,32 @@ pub async fn search_artists( offset: Option, search_type: Option, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result, String> { let client = state.client.lock().await; - client + let mut results = client .search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref()) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Filter out blacklisted artists + let original_count = results.items.len(); + results.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); + + let filtered_count = original_count - results.items.len(); + if filtered_count > 0 { + log::debug!("[Blacklist] Filtered {} artists from search results", filtered_count); + results.total = results.total.saturating_sub(filtered_count as u32); + } + + Ok(results) } #[tauri::command] pub async fn search_all( query: String, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result { log::debug!("search_all called with query: {}", query); let client = state.client.lock().await; @@ -106,13 +154,13 @@ pub async fn search_all( .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); // Parse tracks - let tracks: SearchResultsPage = response + let mut tracks: SearchResultsPage = response .get("tracks") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); // Parse artists - let artists: SearchResultsPage = response + let mut artists: SearchResultsPage = response .get("artists") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); @@ -123,35 +171,114 @@ pub async fn search_all( .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); - // Parse most_popular - get the first item + // Parse most_popular - find the first non-blacklisted item let most_popular: Option = response .get("most_popular") .and_then(|mp| mp.get("items")) .and_then(|items| items.as_array()) - .and_then(|arr| arr.first()) - .and_then(|item| { - let item_type = item.get("type")?.as_str()?; - let content = item.get("content")?; + .and_then(|arr| { + // Iterate through all items to find the first non-blacklisted one + for item in arr { + let item_type = match item.get("type").and_then(|t| t.as_str()) { + Some(t) => t, + None => continue, + }; + let content = match item.get("content") { + Some(c) => c, + None => continue, + }; - log::debug!("most_popular type: {}, content keys: {:?}", - item_type, - content.as_object().map(|o| o.keys().collect::>()) - ); + log::debug!("most_popular type: {}, content keys: {:?}", + item_type, + content.as_object().map(|o| o.keys().collect::>()) + ); - match item_type { - "tracks" => serde_json::from_value::(content.clone()) - .ok() - .map(MostPopularItem::Tracks), - "albums" => serde_json::from_value::(content.clone()) - .ok() - .map(MostPopularItem::Albums), - "artists" => serde_json::from_value::(content.clone()) - .ok() - .map(MostPopularItem::Artists), - _ => None, + match item_type { + "tracks" => { + if let Ok(track) = serde_json::from_value::(content.clone()) { + // Check if track's performer is blacklisted + if let Some(ref performer) = track.performer { + if blacklist_state.is_blacklisted(performer.id) { + log::debug!("[Blacklist] Skipping most_popular track from blacklisted artist: {}", performer.name); + continue; + } + } + return Some(MostPopularItem::Tracks(track)); + } + } + "albums" => { + if let Ok(album) = serde_json::from_value::(content.clone()) { + // Check if album's artist is blacklisted + if blacklist_state.is_blacklisted(album.artist.id) { + log::debug!("[Blacklist] Skipping most_popular album from blacklisted artist: {}", album.artist.name); + continue; + } + return Some(MostPopularItem::Albums(album)); + } + } + "artists" => { + if let Ok(artist) = serde_json::from_value::(content.clone()) { + // Check if artist is blacklisted + if blacklist_state.is_blacklisted(artist.id) { + log::debug!("[Blacklist] Skipping most_popular blacklisted artist: {}", artist.name); + continue; + } + return Some(MostPopularItem::Artists(artist)); + } + } + _ => continue, + } } + None }); + // Apply blacklist filtering + let mut filtered_tracks = 0; + let mut filtered_artists = 0; + let mut filtered_albums = 0; + + // Filter albums from blacklisted artists + let original_album_count = albums.items.len(); + let mut albums = albums; + albums.items.retain(|album| !blacklist_state.is_blacklisted(album.artist.id)); + filtered_albums = original_album_count - albums.items.len(); + if filtered_albums > 0 { + albums.total = albums.total.saturating_sub(filtered_albums as u32); + } + + // Filter tracks from blacklisted artists + let original_track_count = tracks.items.len(); + tracks.items.retain(|track| { + if let Some(ref performer) = track.performer { + !blacklist_state.is_blacklisted(performer.id) + } else { + true + } + }); + filtered_tracks = original_track_count - tracks.items.len(); + if filtered_tracks > 0 { + tracks.total = tracks.total.saturating_sub(filtered_tracks as u32); + } + + // Filter blacklisted artists + let original_artist_count = artists.items.len(); + artists.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); + filtered_artists = original_artist_count - artists.items.len(); + if filtered_artists > 0 { + artists.total = artists.total.saturating_sub(filtered_artists as u32); + } + + // Note: most_popular is already filtered during parsing (iteration finds first non-blacklisted) + + if filtered_tracks > 0 || filtered_artists > 0 || filtered_albums > 0 { + log::debug!( + "[Blacklist] search_all filtered: {} tracks, {} artists, {} albums", + filtered_tracks, + filtered_artists, + filtered_albums + ); + } + Ok(SearchAllResults { albums, tracks, @@ -365,6 +492,7 @@ pub async fn get_similar_artists( limit: Option, offset: Option, state: State<'_, AppState>, + blacklist_state: State<'_, BlacklistState>, ) -> Result, String> { log::info!( "Command: get_similar_artists {} limit={:?} offset={:?}", @@ -374,10 +502,22 @@ pub async fn get_similar_artists( ); let client = state.client.lock().await; - client + let mut results = client .get_similar_artists(artist_id, limit.unwrap_or(5), offset.unwrap_or(0)) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Filter out blacklisted artists + let original_count = results.items.len(); + results.items.retain(|artist| !blacklist_state.is_blacklisted(artist.id)); + + let filtered_count = original_count - results.items.len(); + if filtered_count > 0 { + log::debug!("[Blacklist] Filtered {} similar artists", filtered_count); + results.total = results.total.saturating_sub(filtered_count as u32); + } + + Ok(results) } /// Get label detail with albums diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d14e2e08f..dd23e848c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod api; pub mod api_cache; +pub mod artist_blacklist; pub mod artist_vectors; pub mod audio; pub mod cache; @@ -223,6 +224,9 @@ pub fn run() { // Initialize artist vector store for playlist suggestions let artist_vectors_state = artist_vectors::ArtistVectorStoreState::new() .expect("Failed to initialize artist vector store"); + // Initialize artist blacklist state + let blacklist_state = artist_blacklist::BlacklistState::new() + .expect("Failed to initialize artist blacklist"); // Initialize ListenBrainz integration state let listenbrainz_state = listenbrainz::ListenBrainzSharedState::new() .expect("Failed to initialize ListenBrainz state"); @@ -472,6 +476,7 @@ pub fn run() { .manage(listenbrainz_state) .manage(remote_metadata_state) .manage(artist_vectors_state) + .manage(blacklist_state) .invoke_handler(tauri::generate_handler![ // Auth commands commands::init_client, @@ -917,6 +922,16 @@ pub fn run() { // Visualizer commands commands::set_visualizer_enabled, commands::is_visualizer_enabled, + // Artist blacklist commands + commands::get_artist_blacklist, + commands::add_to_artist_blacklist, + commands::remove_from_artist_blacklist, + commands::is_artist_blacklisted, + commands::set_blacklist_enabled, + commands::is_blacklist_enabled, + commands::get_blacklist_settings, + commands::get_blacklist_count, + commands::clear_artist_blacklist, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/components/TrackRow.svelte b/src/lib/components/TrackRow.svelte index 34529d4af..cc76d413d 100644 --- a/src/lib/components/TrackRow.svelte +++ b/src/lib/components/TrackRow.svelte @@ -1,6 +1,6 @@ + + +
+ +
+ +
+ +

Artist Blacklist

+
+
+ + +

+ Blacklisted artists are hidden from search results, radio, suggestions, and similar artists. +

+ + +
+ + + + +
+ + + {#if searchQuery} + + {/if} +
+ + + {#if artists.length > 0} + + {/if} + + {getCount()} artists +
+ + + {#if !enabled} +
+ + Blacklist is currently disabled. Artists below will appear in results. +
+ {/if} + + + {#if loading} +
+
+

Loading blacklist...

+
+ {:else if artists.length === 0} +
+ +

No blacklisted artists

+ To blacklist an artist, go to their page and click the ban icon. +
+ {:else if filteredArtists.length === 0} +
+ +

No results for "{searchQuery}"

+
+ {:else} +
+ {#each filteredArtists as artist (artist.artist_id)} +
+
onArtistSelect?.(artist.artist_id)} + onkeydown={(e) => e.key === 'Enter' && onArtistSelect?.(artist.artist_id)} + > +
+ +
+
+ {artist.artist_name} + Added {formatDate(artist.added_at)} + {#if artist.notes} + {artist.notes} + {/if} +
+
+ +
+ {/each} +
+ {/if} +
+
+ + +{#if confirmClearOpen} + +{/if} + + diff --git a/src/lib/components/views/HomeView.svelte b/src/lib/components/views/HomeView.svelte index 136b45255..a5e9a9a34 100644 --- a/src/lib/components/views/HomeView.svelte +++ b/src/lib/components/views/HomeView.svelte @@ -9,6 +9,7 @@ import HomeSettingsModal from '../HomeSettingsModal.svelte'; import GenreFilterButton from '../GenreFilterButton.svelte'; import { formatDuration, formatQuality, getQobuzImage } from '$lib/adapters/qobuzAdapters'; + import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore'; import { subscribe as subscribeHomeSettings, getSettings, @@ -913,6 +914,7 @@ {@const isActiveTrack = isPlaybackActive && activeTrackId === track.id} {@const cacheStatus = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none', progress: 0 }} {@const isTrackDownloaded = cacheStatus.status === 'ready'} + {@const trackBlacklisted = track.artistId ? isArtistBlacklisted(track.artistId) : false} onArtistClick(track.artistId!) : undefined} onAlbumClick={track.albumId && onAlbumClick ? () => onAlbumClick(track.albumId!) : undefined} - onPlay={() => handleContinueTrackPlay(track, index)} - menuActions={{ + onPlay={trackBlacklisted ? undefined : () => handleContinueTrackPlay(track, index)} + menuActions={trackBlacklisted ? { + // Only navigation actions for blacklisted tracks + onGoToAlbum: track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId!) : undefined, + onGoToArtist: track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId!) : undefined, + onShowInfo: onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined + } : { onPlayNow: () => handleContinueTrackPlay(track, index), onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined, onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined, diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index 4e14f9e01..05e1cb54d 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -19,6 +19,7 @@ } from '$lib/stores/offlineStore'; import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore'; import { isTrackUnavailable, clearTrackUnavailable, subscribe as subscribeUnavailable } from '$lib/stores/unavailableTracksStore'; + import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore'; import { showToast } from '$lib/stores/toastStore'; import { t } from '$lib/i18n'; import { onMount, tick } from 'svelte'; @@ -1124,7 +1125,14 @@ } function buildQueueTracks(tracks: DisplayTrack[]) { - const queueTracks = tracks.map(t => ({ + // Filter out blacklisted artists before building queue + const filteredTracks = tracks.filter(t => { + if (t.isLocal) return true; // Local tracks are never blacklisted + if (!t.artistId) return true; // No artist ID, can't check blacklist + return !isArtistBlacklisted(t.artistId); + }); + + const queueTracks = filteredTracks.map(t => ({ id: t.isLocal ? Math.abs(t.id) : t.id, title: t.title, artist: t.artist || 'Unknown Artist', @@ -1139,7 +1147,7 @@ artist_id: t.isLocal ? null : (t.artistId ?? null), })); - const localIds = tracks + const localIds = filteredTracks .filter(t => t.isLocal) .map(t => Math.abs(t.id)); @@ -1398,9 +1406,18 @@ const allTracks = displayTracks; if (allTracks.length === 0) return; + // Filter out blacklisted tracks + const playableTracks = allTracks.filter(t => { + if (t.isLocal) return true; + if (!t.artistId) return true; + return !isArtistBlacklisted(t.artistId); + }); + + if (playableTracks.length === 0) return; + // Set playback context for playlist if (playlist) { - const trackIds = allTracks + const trackIds = playableTracks .filter(t => !t.isLocal) // Only Qobuz tracks in context .map(t => t.id); @@ -1420,8 +1437,8 @@ try { await setPlaylistQueue(0); - // Play first track (handle local vs Qobuz) - const firstTrack = allTracks[0]; + // Play first playable track (handle local vs Qobuz) + const firstTrack = playableTracks[0]; if (firstTrack.isLocal && onLocalTrackPlay) { const localTrack = localTracks.find(t => t.id === Math.abs(firstTrack.id)); if (localTrack) onLocalTrackPlay(localTrack); @@ -1466,14 +1483,23 @@ const allTracks = displayTracks; if (allTracks.length === 0) return; + // Filter out blacklisted tracks + const playableTracks = allTracks.filter(t => { + if (t.isLocal) return true; + if (!t.artistId) return true; + return !isArtistBlacklisted(t.artistId); + }); + + if (playableTracks.length === 0) return; + // Collect local track IDs to add to set - const localIds = allTracks + const localIds = playableTracks .filter(t => t.isLocal) .map(t => Math.abs(t.id)); // Add in reverse order so first track ends up right after current - for (let i = allTracks.length - 1; i >= 0; i--) { - const t = allTracks[i]; + for (let i = playableTracks.length - 1; i >= 0; i--) { + const t = playableTracks[i]; try { await invoke('add_to_queue_next', { track: { @@ -1506,7 +1532,16 @@ const allTracks = displayTracks; if (allTracks.length === 0) return; - const queueTracks = allTracks.map(t => ({ + // Filter out blacklisted tracks + const playableTracks = allTracks.filter(t => { + if (t.isLocal) return true; + if (!t.artistId) return true; + return !isArtistBlacklisted(t.artistId); + }); + + if (playableTracks.length === 0) return; + + const queueTracks = playableTracks.map(t => ({ id: t.isLocal ? Math.abs(t.id) : t.id, title: t.title, artist: t.artist || 'Unknown Artist', @@ -1522,7 +1557,7 @@ })); // Collect local track IDs - const localIds = allTracks + const localIds = playableTracks .filter(t => t.isLocal) .map(t => Math.abs(t.id)); @@ -1763,6 +1798,7 @@ {@const isTrackPlaying = isActiveTrack && isPlaybackActive} {@const available = isTrackAvailable(track)} {@const removedFromQobuz = isTrackRemovedFromQobuz(track)} + {@const trackBlacklisted = !track.isLocal && track.artistId ? isArtistBlacklisted(track.artistId) : false}
handleTrackClick(track, idx) : undefined} - onDownload={available && !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined} - onRemoveDownload={available && !track.isLocal && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined} + onPlay={available && !trackBlacklisted ? () => handleTrackClick(track, idx) : undefined} + onDownload={available && !track.isLocal && !trackBlacklisted && onTrackDownload ? () => onTrackDownload(track) : undefined} + onRemoveDownload={available && !track.isLocal && !trackBlacklisted && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined} menuActions={removedFromQobuz ? (isOwnPlaylist ? { // Only allow remove from playlist and find replacement for tracks removed from Qobuz (owned playlists only) onRemoveFromPlaylist: () => removeTrackFromPlaylist(track), onFindReplacement: () => openReplacementModal(track) - } : {}) : available ? { + } : {}) : trackBlacklisted ? { + // Blacklisted: only allow navigation and info, no playback + onGoToAlbum: !track.isLocal && track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId!) : undefined, + onGoToArtist: !track.isLocal && track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId!) : undefined, + onShowInfo: !track.isLocal && onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined + } : available ? { onPlayNow: () => handleTrackClick(track, idx), onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined), onPlayLater: track.isLocal ? () => handleTrackPlayLater(track) : (onTrackPlayLater ? () => onTrackPlayLater(track) : undefined), diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index dc54a04d4..ff35190f3 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -4,7 +4,7 @@ import ViewTransition from '../ViewTransition.svelte'; import { getCurrentWebview } from '@tauri-apps/api/webview'; import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager'; - import { ArrowLeft, ChevronRight, ChevronDown, ChevronUp, Loader2, Sun, Moon, SunMoon, HelpCircle } from 'lucide-svelte'; + import { ArrowLeft, ChevronRight, ChevronDown, ChevronUp, Loader2, Sun, Moon, SunMoon, HelpCircle, Ban } from 'lucide-svelte'; import Toggle from '../Toggle.svelte'; import Dropdown from '../Dropdown.svelte'; import DeviceDropdown from '../DeviceDropdown.svelte'; @@ -75,10 +75,16 @@ type UpdatePreferences } from '$lib/stores/updatesStore'; import { openReleasePageAndAcknowledge } from '$lib/services/updatesService'; + import { + getCount as getBlacklistCount, + isEnabled as isBlacklistEnabled, + subscribe as subscribeBlacklist + } from '$lib/stores/artistBlacklistStore'; interface Props { onBack?: () => void; onLogout?: () => void; + onBlacklistManagerClick?: () => void; userName?: string; userEmail?: string; subscription?: string; @@ -109,6 +115,7 @@ let { onBack, onLogout, + onBlacklistManagerClick, userName = 'User', userEmail = '', subscription = 'Qobuz™', @@ -164,6 +171,10 @@ let isSettingsWhatsNewOpen = $state(false); let settingsWhatsNewRelease = $state(null); + // Blacklist state + let blacklistCount = $state(getBlacklistCount()); + let blacklistEnabled = $state(isBlacklistEnabled()); + // Section navigation let settingsViewEl: HTMLDivElement; let audioSection: HTMLElement; @@ -172,6 +183,7 @@ let appearanceSection: HTMLElement; let downloadsSection: HTMLElement; let librarySection: HTMLElement; + let contentFilteringSection: HTMLElement; let integrationsSection: HTMLElement; let updatesSection: HTMLElement; let storageSection: HTMLElement; @@ -190,6 +202,7 @@ { id: 'appearance', labelKey: 'settings.appearance.title' }, { id: 'downloads', labelKey: 'settings.offlineLibrary.title' }, { id: 'library', labelKey: 'settings.library.title' }, + { id: 'content-filtering', labelKey: 'settings.contentFiltering.title' }, { id: 'integrations', labelKey: 'settings.integrations.title' }, { id: 'updates', labelKey: 'nav.updates' }, { id: 'storage', labelKey: 'settings.storage.title' }, @@ -212,6 +225,7 @@ case 'appearance': return appearanceSection; case 'downloads': return downloadsSection; case 'library': return librarySection; + case 'content-filtering': return contentFilteringSection; case 'integrations': return integrationsSection; case 'updates': return updatesSection; case 'storage': return storageSection; @@ -703,6 +717,12 @@ hideTitleBar = getHideTitleBar(); }); + // Subscribe to blacklist state changes + const unsubscribeBlacklist = subscribeBlacklist(() => { + blacklistCount = getBlacklistCount(); + blacklistEnabled = isBlacklistEnabled(); + }); + // Scroll tracking for navigation const handleScroll = () => { if (!settingsViewEl) return; @@ -732,6 +752,7 @@ unsubscribeZoom(); unsubscribeTitleBar(); unsubscribeUpdates(); + unsubscribeBlacklist(); settingsViewEl?.removeEventListener('scroll', handleScroll); }; }); @@ -2384,6 +2405,31 @@
+ +
+

{$t('settings.contentFiltering.title')}

+
+
+
+ +
+ {$t('settings.contentFiltering.artistBlacklist')} + + {blacklistCount} {blacklistCount === 1 ? 'artist' : 'artists'} blocked + {#if !blacklistEnabled} + ({$t('settings.contentFiltering.disabled')}) + {/if} + +
+
+
+ +
+
+

{$t('settings.integrations.title')}

@@ -3753,6 +3799,43 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz color: var(--text-primary); } + /* Content Filtering Section */ + .setting-with-icon { + display: flex; + align-items: flex-start; + gap: 12px; + } + + .setting-with-icon :global(.setting-icon) { + color: var(--text-muted); + margin-top: 2px; + flex-shrink: 0; + } + + .link-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + background: var(--bg-tertiary); + border: none; + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: background-color 150ms ease; + flex-shrink: 0; + } + + .link-btn:hover { + background: var(--bg-hover); + } + + .status-disabled { + color: #fbbf24; + font-size: 12px; + } + = new Set(); + +const listeners = new Set<() => void>(); + +function notifyListeners(): void { + for (const listener of listeners) { + listener(); + } +} + +function updateIdSet(): void { + blacklistIds = new Set(blacklist.map(a => a.artist_id)); +} + +// ============ Public API ============ + +/** + * Load blacklist from backend + */ +export async function loadBlacklist(): Promise { + const [list, settings] = await Promise.all([ + invoke('get_artist_blacklist'), + invoke('get_blacklist_settings') + ]); + blacklist = list; + enabled = settings.enabled; + updateIdSet(); + notifyListeners(); + return list; +} + +/** + * Add an artist to the blacklist + */ +export async function addToBlacklist( + artistId: number, + artistName: string, + notes?: string +): Promise { + await invoke('add_to_artist_blacklist', { + artistId, + artistName, + notes: notes ?? null + }); + // Reload to get the full entry with timestamp + await loadBlacklist(); +} + +/** + * Remove an artist from the blacklist + */ +export async function removeFromBlacklist(artistId: number): Promise { + await invoke('remove_from_artist_blacklist', { artistId }); + // Update local state + blacklist = blacklist.filter(a => a.artist_id !== artistId); + updateIdSet(); + notifyListeners(); +} + +/** + * Check if an artist is blacklisted (O(1) local check) + */ +export function isBlacklisted(artistId: number): boolean { + if (!enabled) return false; + return blacklistIds.has(artistId); +} + +/** + * Set the enabled state + */ +export async function setEnabled(value: boolean): Promise { + await invoke('set_blacklist_enabled', { enabled: value }); + enabled = value; + notifyListeners(); +} + +/** + * Check if blacklist is enabled + */ +export function isEnabled(): boolean { + return enabled; +} + +/** + * Get cached blacklist (no backend call) + */ +export function getCachedBlacklist(): BlacklistedArtist[] { + return blacklist; +} + +/** + * Get blacklist count + */ +export function getCount(): number { + return blacklist.length; +} + +/** + * Clear all blacklisted artists + */ +export async function clearBlacklist(): Promise { + await invoke('clear_artist_blacklist'); + blacklist = []; + updateIdSet(); + notifyListeners(); +} + +/** + * Subscribe to blacklist changes + */ +export function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Initialize blacklist store (call on app startup) + */ +export async function initBlacklistStore(): Promise { + await loadBlacklist(); +} diff --git a/src/lib/stores/navigationStore.ts b/src/lib/stores/navigationStore.ts index 5aa4f53b8..3ae43ba1b 100644 --- a/src/lib/stores/navigationStore.ts +++ b/src/lib/stores/navigationStore.ts @@ -18,6 +18,7 @@ export type ViewType = | 'label' | 'playlist' | 'playlist-manager' + | 'blacklist-manager' | 'favorites-tracks' | 'favorites-albums' | 'favorites-artists' diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0ea531aed..aca621b03 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -37,11 +37,12 @@ getCurrentContext, requestContextTrackFocus } from '$lib/stores/playbackContextStore'; - import { + import { initPlaybackPreferences, getCachedPreferences, isAutoplayEnabled } from '$lib/stores/playbackPreferencesStore'; + import { initBlacklistStore, isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore'; // UI state management import { @@ -292,6 +293,7 @@ import FavoritesView from '$lib/components/views/FavoritesView.svelte'; import LocalLibraryView from '$lib/components/views/LocalLibraryView.svelte'; import PlaylistManagerView from '$lib/components/views/PlaylistManagerView.svelte'; + import BlacklistManagerView from '$lib/components/views/BlacklistManagerView.svelte'; // Overlays import QueuePanel from '$lib/components/QueuePanel.svelte'; @@ -1256,12 +1258,18 @@ ? 'Hi-Res' : '-'; - // Build queue from album tracks before playing + // Build queue from album tracks before playing (filter blacklisted artists) if (selectedAlbum?.tracks) { console.log('[Album Queue] Building queue from', selectedAlbum.tracks.length, 'album tracks'); - const trackIndex = selectedAlbum.tracks.findIndex(t => t.id === track.id); - const queueTracks: BackendQueueTrack[] = selectedAlbum.tracks.map(t => ({ + // Filter out blacklisted tracks + const playableTracks = selectedAlbum.tracks.filter(t => { + const artistId = t.artistId ?? selectedAlbum.artistId; + return !artistId || !isArtistBlacklisted(artistId); + }); + + const trackIndex = playableTracks.findIndex(t => t.id === track.id); + const queueTracks: BackendQueueTrack[] = playableTracks.map(t => ({ id: t.id, title: t.title, artist: t.artist || selectedAlbum?.artist || 'Unknown Artist', @@ -1276,7 +1284,7 @@ artist_id: t.artistId ?? selectedAlbum?.artistId })); - console.log('[Album Queue] Mapped to', queueTracks.length, 'queue tracks, startIndex:', trackIndex); + console.log('[Album Queue] Mapped to', queueTracks.length, 'queue tracks (filtered), startIndex:', trackIndex); console.log('[Album Queue] Track IDs:', queueTracks.map(t => t.id)); // Set the queue starting at the clicked track @@ -1612,18 +1620,31 @@ } } - // Play all tracks from album (starting from first track) + // Play all tracks from album (starting from first non-blacklisted track) async function handlePlayAllAlbum() { if (!selectedAlbum?.tracks?.length) return; - const firstTrack = selectedAlbum.tracks[0]; - await handleAlbumTrackPlay(firstTrack); + // Find first non-blacklisted track + const firstPlayableTrack = selectedAlbum.tracks.find(t => { + const artistId = t.artistId ?? selectedAlbum.artistId; + return !artistId || !isArtistBlacklisted(artistId); + }); + if (!firstPlayableTrack) return; + await handleAlbumTrackPlay(firstPlayableTrack); } // Shuffle play all tracks from album async function handleShuffleAlbum() { if (!selectedAlbum?.tracks?.length) return; - console.log('[Album Shuffle] Starting shuffle with', selectedAlbum.tracks.length, 'tracks'); + // Filter out blacklisted tracks + const playableTracks = selectedAlbum.tracks.filter(t => { + const artistId = t.artistId ?? selectedAlbum.artistId; + return !artistId || !isArtistBlacklisted(artistId); + }); + + if (playableTracks.length === 0) return; + + console.log('[Album Shuffle] Starting shuffle with', playableTracks.length, 'playable tracks'); // Set shuffle mode first try { @@ -1634,8 +1655,8 @@ } // Pick a random track to start with - const randomIndex = Math.floor(Math.random() * selectedAlbum.tracks.length); - const randomTrack = selectedAlbum.tracks[randomIndex]; + const randomIndex = Math.floor(Math.random() * playableTracks.length); + const randomTrack = playableTracks[randomIndex]; console.log('[Album Shuffle] Starting from random track index:', randomIndex, 'track:', randomTrack.title); @@ -1648,10 +1669,18 @@ async function handleAddAlbumToQueueNext() { if (!selectedAlbum?.tracks?.length) return; + // Filter out blacklisted tracks + const playableTracks = selectedAlbum.tracks.filter(t => { + const artistId = t.artistId ?? selectedAlbum.artistId; + return !artistId || !isArtistBlacklisted(artistId); + }); + + if (playableTracks.length === 0) return; + const artwork = selectedAlbum.artwork || ''; // Add in reverse order so first track ends up right after current - for (let i = selectedAlbum.tracks.length - 1; i >= 0; i--) { - const t = selectedAlbum.tracks[i]; + for (let i = playableTracks.length - 1; i >= 0; i--) { + const t = playableTracks[i]; queueTrackNext({ id: t.id, title: t.title, @@ -1667,15 +1696,23 @@ artist_id: t.artistId ?? selectedAlbum?.artistId }); } - showToast($t('toast.playingTracksNext', { values: { count: selectedAlbum.tracks.length } }), 'success'); + showToast($t('toast.playingTracksNext', { values: { count: playableTracks.length } }), 'success'); } // Add all album tracks to end of queue async function handleAddAlbumToQueueLater() { if (!selectedAlbum?.tracks?.length) return; + // Filter out blacklisted tracks + const playableTracks = selectedAlbum.tracks.filter(t => { + const artistId = t.artistId ?? selectedAlbum.artistId; + return !artistId || !isArtistBlacklisted(artistId); + }); + + if (playableTracks.length === 0) return; + const artwork = selectedAlbum.artwork || ''; - const queueTracks: BackendQueueTrack[] = selectedAlbum.tracks.map(t => ({ + const queueTracks: BackendQueueTrack[] = playableTracks.map(t => ({ id: t.id, title: t.title, artist: t.artist || selectedAlbum?.artist || 'Unknown Artist', @@ -2437,6 +2474,7 @@ // Initialize playback context and preferences stores initPlaybackContextStore(); initPlaybackPreferences(); + initBlacklistStore(); // Load infinite play preference try { @@ -2958,6 +2996,7 @@ navigateTo('blacklist-manager')} userName={userInfo?.userName} subscription={userInfo?.subscription} subscriptionValidUntil={userInfo?.subscriptionValidUntil} @@ -3172,6 +3211,11 @@ sidebarRef?.refreshLocalTrackCounts(); }} /> + {:else if activeView === 'blacklist-manager'} + {/if}