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
This commit is contained in:
vicrodh committed 2026-02-02 13:05:50 -06:00
1 parent a742c22c8f
commit 26c5a1b0a3
6 files changed
+562

No files matched your search

+122
View File
@@ -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<BlacklistService>,
}
impl BlacklistState {
/// Create a new blacklist state
pub fn new() -> Result<Self, String> {
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<Vec<BlacklistedArtist>, 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<BlacklistSettings, String> {
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()
}
}
+25
View File
@@ -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<String>,
}
/// 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 }
}
}
+331
View File
@@ -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<HashSet<u64>>,
/// 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<Self, String> {
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<u64> = 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<Vec<BlacklistedArtist>, 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()));
}
}
@@ -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<BlacklistState>) -> Result<Vec<BlacklistedArtist>, 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<String>,
state: State<BlacklistState>,
) -> 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<BlacklistState>,
) -> Result<(), String> {
state.remove(artist_id)
}
/// Check if an artist is blacklisted
#[tauri::command]
pub fn is_artist_blacklisted(artist_id: u64, state: State<BlacklistState>) -> Result<bool, String> {
Ok(state.is_blacklisted(artist_id))
}
/// Set the blacklist enabled state
#[tauri::command]
pub fn set_blacklist_enabled(enabled: bool, state: State<BlacklistState>) -> Result<(), String> {
state.set_enabled(enabled)
}
/// Check if the blacklist feature is enabled
#[tauri::command]
pub fn is_blacklist_enabled(state: State<BlacklistState>) -> Result<bool, String> {
Ok(state.is_enabled())
}
/// Get blacklist settings
#[tauri::command]
pub fn get_blacklist_settings(state: State<BlacklistState>) -> Result<BlacklistSettings, String> {
state.get_settings()
}
/// Get the count of blacklisted artists
#[tauri::command]
pub fn get_blacklist_count(state: State<BlacklistState>) -> Result<usize, String> {
Ok(state.count())
}
/// Clear all blacklisted artists
#[tauri::command]
pub fn clear_artist_blacklist(state: State<BlacklistState>) -> Result<(), String> {
state.clear_all()
}
+2
View File
@@ -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::*;
+15
View File
@@ -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");