From 26c5a1b0a3386e2c36ca203cc70e4f36581ac6a2 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:05:50 -0600 Subject: [PATCH 01/15] feat(blacklist): add artist blacklist module with O(1) lookups Phase 1 of artist blacklist implementation: - Add artist_blacklist module with HashSet-based service for O(1) lookups - Add SQLite persistence with WAL mode - Add global enable/disable feature flag - Add Tauri commands for CRUD operations - Register BlacklistState in app state cc --- src-tauri/src/artist_blacklist/mod.rs | 122 ++++++++ src-tauri/src/artist_blacklist/models.rs | 25 ++ src-tauri/src/artist_blacklist/service.rs | 331 +++++++++++++++++++++ src-tauri/src/commands/artist_blacklist.rs | 67 +++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/lib.rs | 15 + 6 files changed, 562 insertions(+) create mode 100644 src-tauri/src/artist_blacklist/mod.rs create mode 100644 src-tauri/src/artist_blacklist/models.rs create mode 100644 src-tauri/src/artist_blacklist/service.rs create mode 100644 src-tauri/src/commands/artist_blacklist.rs 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/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"); From bec6907dbee082d2bfb3623911563ad983f9818b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:09:35 -0600 Subject: [PATCH 02/15] feat(blacklist): integrate filtering into search and radio commands Phase 2 (part 1) - Read-only integration: - Filter blacklisted artists from search_tracks, search_artists, search_all - Filter blacklisted artists from get_similar_artists - Filter most_popular item if blacklisted - Add blacklist filtering to all radio commands (artist/track/infinite/refill) - Use output-layer filtering for radio (engine internals unchanged) cc --- src-tauri/src/commands/radio.rs | 83 +++++++++++++++++++-- src-tauri/src/commands/search.rs | 122 +++++++++++++++++++++++++++++-- 2 files changed, 192 insertions(+), 13 deletions(-) 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..c5531e34d 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; @@ -48,12 +49,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 +84,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 +141,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 }); @@ -152,6 +187,64 @@ pub async fn search_all( } }); + // Apply blacklist filtering + let mut filtered_tracks = 0; + let mut filtered_artists = 0; + + // 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); + } + + // Filter most_popular if it's a blacklisted item + let most_popular = most_popular.and_then(|mp| { + match &mp { + MostPopularItem::Artists(artist) => { + if blacklist_state.is_blacklisted(artist.id) { + log::debug!("[Blacklist] Filtered most_popular artist: {}", artist.name); + None + } else { + Some(mp) + } + } + MostPopularItem::Tracks(track) => { + if let Some(ref performer) = track.performer { + if blacklist_state.is_blacklisted(performer.id) { + log::debug!("[Blacklist] Filtered most_popular track: {}", track.title); + return None; + } + } + Some(mp) + } + MostPopularItem::Albums(_) => Some(mp), // Albums not filtered by artist blacklist + } + }); + + if filtered_tracks > 0 || filtered_artists > 0 { + log::debug!( + "[Blacklist] search_all filtered: {} tracks, {} artists", + filtered_tracks, + filtered_artists + ); + } + Ok(SearchAllResults { albums, tracks, @@ -365,6 +458,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 +468,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 From bd4c9b7792c2417c84b5b95bc455d0f8bf6c0c7f Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:10:36 -0600 Subject: [PATCH 03/15] feat(blacklist): filter playlist suggestions by artist Phase 2 (part 2) - Suggestions integration: - Filter SuggestedTrack results by artist_id - Log filtered count for debugging cc --- .../src/commands/playlist_suggestions.rs | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) 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 From e1849f0bb3c2ff833768192e81f2d613a7999778 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:18:38 -0600 Subject: [PATCH 04/15] feat(blacklist): add frontend store and artist page UI Phase 3 (minimal) - UI for testing: - Add artistBlacklistStore.ts with O(1) local checks - Initialize store on app startup - Add blacklist toggle button to ArtistDetailView - Button shows red when artist is blacklisted cc --- .../components/views/ArtistDetailView.svelte | 83 +++++++++- src/lib/stores/artistBlacklistStore.ts | 150 ++++++++++++++++++ src/routes/+page.svelte | 4 +- 3 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/lib/stores/artistBlacklistStore.ts diff --git a/src/lib/components/views/ArtistDetailView.svelte b/src/lib/components/views/ArtistDetailView.svelte index f293091e4..04d3a82f4 100644 --- a/src/lib/components/views/ArtistDetailView.svelte +++ b/src/lib/components/views/ArtistDetailView.svelte @@ -1,6 +1,12 @@ + + +
+ +
+ +
+ +

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/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 19c0c5089..581852132 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,6 +75,12 @@ type UpdatePreferences } from '$lib/stores/updatesStore'; import { openReleasePageAndAcknowledge } from '$lib/services/updatesService'; + import { navigateTo } from '$lib/stores/navigationStore'; + import { + getCount as getBlacklistCount, + isEnabled as isBlacklistEnabled, + subscribe as subscribeBlacklist + } from '$lib/stores/artistBlacklistStore'; interface Props { onBack?: () => void; @@ -164,6 +170,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 +182,7 @@ let appearanceSection: HTMLElement; let downloadsSection: HTMLElement; let librarySection: HTMLElement; + let contentFilteringSection: HTMLElement; let integrationsSection: HTMLElement; let updatesSection: HTMLElement; let storageSection: HTMLElement; @@ -190,6 +201,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 +224,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 +716,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 +751,7 @@ unsubscribeZoom(); unsubscribeTitleBar(); unsubscribeUpdates(); + unsubscribeBlacklist(); settingsViewEl?.removeEventListener('scroll', handleScroll); }; }); @@ -2384,6 +2404,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 +3798,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; + } + + {:else if activeView === 'blacklist-manager'} + {/if} From 958f0d7dca7debab86d6ed9bb9896431fabaf8af Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:51:21 -0600 Subject: [PATCH 07/15] fix(blacklist): use callback prop for navigation to blacklist manager - Replace direct navigateTo import with onBlacklistManagerClick callback - Pass navigation callback from +page.svelte to SettingsView - Follows same pattern as other view navigation (playlist-manager, etc) --- src/lib/components/views/SettingsView.svelte | 5 +++-- src/routes/+page.svelte | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 581852132..e3d72c578 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -75,7 +75,6 @@ type UpdatePreferences } from '$lib/stores/updatesStore'; import { openReleasePageAndAcknowledge } from '$lib/services/updatesService'; - import { navigateTo } from '$lib/stores/navigationStore'; import { getCount as getBlacklistCount, isEnabled as isBlacklistEnabled, @@ -85,6 +84,7 @@ interface Props { onBack?: () => void; onLogout?: () => void; + onBlacklistManagerClick?: () => void; userName?: string; userEmail?: string; subscription?: string; @@ -115,6 +115,7 @@ let { onBack, onLogout, + onBlacklistManagerClick, userName = 'User', userEmail = '', subscription = 'Qobuz™', @@ -2422,7 +2423,7 @@ - diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index cb5e083a2..f9da6e564 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -2954,6 +2954,7 @@ navigateTo('blacklist-manager')} userName={userInfo?.userName} subscription={userInfo?.subscription} subscriptionValidUntil={userInfo?.subscriptionValidUntil} From 6257f7648436d310485731853397f4cb1df44d9e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:02:23 -0600 Subject: [PATCH 08/15] fix(blacklist): use correct handleArtistClick function in BlacklistManagerView --- src/routes/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f9da6e564..0daf98f93 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3172,7 +3172,7 @@ {:else if activeView === 'blacklist-manager'} {/if} From bdffe6c7091486231affc23cd4b479407cf1996c Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:06:10 -0600 Subject: [PATCH 09/15] feat(blacklist): add toast notifications for blacklist operations - Show success/error toasts when adding/removing artists - Show feedback when enabling/disabling blacklist - Show confirmation toast when clearing all artists --- src/lib/components/views/ArtistDetailView.svelte | 4 ++++ src/lib/components/views/BlacklistManagerView.svelte | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/lib/components/views/ArtistDetailView.svelte b/src/lib/components/views/ArtistDetailView.svelte index 04d3a82f4..d09e5aa49 100644 --- a/src/lib/components/views/ArtistDetailView.svelte +++ b/src/lib/components/views/ArtistDetailView.svelte @@ -7,6 +7,7 @@ removeFromBlacklist, subscribe as subscribeBlacklist } from '$lib/stores/artistBlacklistStore'; + import { showToast } from '$lib/stores/toastStore'; import type { ArtistDetail, QobuzArtist } from '$lib/types'; import AlbumCard from '../AlbumCard.svelte'; import TrackMenu from '../TrackMenu.svelte'; @@ -417,13 +418,16 @@ if (wasBlacklisted) { await removeFromBlacklist(artist.id); artistIsBlacklisted = false; + showToast(`${artist.name} removed from blacklist`, 'success'); } else { await addToBlacklist(artist.id, artist.name); artistIsBlacklisted = true; + showToast(`${artist.name} added to blacklist`, 'success'); } } catch (err) { console.error('Failed to toggle artist blacklist:', err); artistIsBlacklisted = wasBlacklisted; // Rollback on error + showToast('Failed to update blacklist', 'error'); } finally { isBlacklistLoading = false; } diff --git a/src/lib/components/views/BlacklistManagerView.svelte b/src/lib/components/views/BlacklistManagerView.svelte index 3c498bc11..adcd5daed 100644 --- a/src/lib/components/views/BlacklistManagerView.svelte +++ b/src/lib/components/views/BlacklistManagerView.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { ArrowLeft, Search, X, Trash2, Ban, ToggleLeft, ToggleRight, AlertCircle } from 'lucide-svelte'; import ViewTransition from '../ViewTransition.svelte'; + import { showToast } from '$lib/stores/toastStore'; import { subscribe, loadBlacklist, @@ -59,27 +60,36 @@ } async function handleRemove(artistId: number) { + const artist = artists.find(a => a.artist_id === artistId); try { await removeFromBlacklist(artistId); + showToast(`${artist?.artist_name || 'Artist'} removed from blacklist`, 'success'); } catch (err) { console.error('Failed to remove artist from blacklist:', err); + showToast('Failed to remove artist', 'error'); } } async function handleToggleEnabled() { try { - await setEnabled(!enabled); + const newState = !enabled; + await setEnabled(newState); + showToast(`Blacklist ${newState ? 'enabled' : 'disabled'}`, 'info'); } catch (err) { console.error('Failed to toggle blacklist:', err); + showToast('Failed to toggle blacklist', 'error'); } } async function handleClearAll() { + const count = artists.length; try { await clearBlacklist(); confirmClearOpen = false; + showToast(`Removed ${count} artists from blacklist`, 'success'); } catch (err) { console.error('Failed to clear blacklist:', err); + showToast('Failed to clear blacklist', 'error'); } } From 6ff6269f7447653d806e2b0f86969392b5eaab7a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:17:01 -0600 Subject: [PATCH 10/15] feat(blacklist): add grayout styling for blacklisted artist tracks - TrackRow: Add isBlacklisted prop with grayout styling and ban icon - VirtualizedTrackList: Check blacklist status for each track - PlaylistDetailView: Gray out and restrict actions for blacklisted tracks - AlbumDetailView: Apply album-level blacklist check to all tracks - HomeView: Gray out blacklisted tracks in Continue Listening section - ArtistDetailView: Banner shown when viewing blacklisted artist page Blacklisted tracks show reduced opacity, ban icon instead of track number, and restricted menu actions (navigation only, no playback). --- src/lib/components/TrackRow.svelte | 42 +++++++++++++--- .../components/VirtualizedTrackList.svelte | 18 +++++-- .../components/views/AlbumDetailView.svelte | 17 +++++-- .../components/views/ArtistDetailView.svelte | 49 +++++++++++++++++++ src/lib/components/views/HomeView.svelte | 14 +++++- .../views/PlaylistDetailView.svelte | 20 +++++--- 6 files changed, 136 insertions(+), 24 deletions(-) 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 @@