Files
qbz/crates/qbz-app/src/settings/artist_blacklist.rs
T
vicrodh 6d8c951a38 chore(deps): rusqlite 0.40 (u64 casts; a latent two-statement execute fixed)
rusqlite 0.40 removed ToSql/FromSql for u64 (SQLite integers are i64):
every u64 column now reads/writes through an explicit i64 cast. It also
rejects a multi-statement execute() instead of silently running only the
first statement — which is what catalog.rs's editions/logical_albums
prune had been doing all along; it is an execute_batch now, so the
logical_albums prune finally runs.

cc
2026-08-29 08:36:21 -06:00

668 lines
22 KiB
Rust

//! Headless artist blacklist service.
//!
//! Frontend-agnostic 1:1 port of the Tauri `BlacklistService`
//! (`src-tauri/src/artist_blacklist/service.rs` + `models.rs`). No
//! `tauri::State`, per ADR-006 and the V2 "move logic to a core crate, never
//! wrap legacy" rule. The DB filename, schema, and pragmas are kept IDENTICAL
//! to the Tauri store so existing users' `artist_blacklist.db` keeps working.
//!
//! Provides O(1) artist blacklist checks via an in-memory `HashSet` backed by
//! SQLite persistence, plus a global enable/disable feature flag.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
/// Database file name for the artist blacklist store.
///
/// Kept identical to the Tauri store so the later lifecycle layer opens the
/// same per-user database.
pub const DB_FILE_NAME: &str = "artist_blacklist.db";
/// 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>,
}
/// A blacklisted album entry.
///
/// The album axis is a parallel, `String`-keyed pipeline alongside the
/// `u64` artist one: Qobuz album ids are alphanumeric strings, so they
/// cannot be stored in the artist table's INTEGER primary key. This is its
/// own table in the same database. Blocking an album hides it by its OWN
/// id regardless of artist — the surgical fix for Qobuz's same-name artist
/// merges (e.g. a Trance "Anthrax" release landing on the Thrash Anthrax id).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlacklistedAlbum {
pub album_id: String,
pub album_title: String,
pub artist_name: String,
pub cover_url: 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 }
}
}
/// 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>>,
/// In-memory set of blocked album ids (String-keyed) for O(1) lookups.
blacklisted_album_ids: RwLock<HashSet<String>>,
/// Feature flag - when false, `is_blacklisted()` always returns false.
/// Shared by both axes: it also gates `is_album_blacklisted()`.
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 (ADR-002).
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=1000;")
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
let service = Self {
conn,
blacklisted_ids: RwLock::new(HashSet::new()),
blacklisted_album_ids: RwLock::new(HashSet::new()),
enabled: AtomicBool::new(true),
};
service.init_schema()?;
service.load_from_db()?;
service.load_albums_from_db()?;
service.load_settings()?;
Ok(service)
}
/// Create an in-memory blacklist service (test/ephemeral helper).
///
/// Opens a `:memory:` connection and runs schema init + loads, but does not
/// set WAL mode (not needed for an in-memory database).
pub fn new_in_memory() -> Result<Self, String> {
let conn = Connection::open_in_memory()
.map_err(|e| format!("Failed to open in-memory blacklist database: {}", e))?;
let service = Self {
conn,
blacklisted_ids: RwLock::new(HashSet::new()),
blacklisted_album_ids: RwLock::new(HashSet::new()),
enabled: AtomicBool::new(true),
};
service.init_schema()?;
service.load_from_db()?;
service.load_albums_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);
-- Album blacklist entries (parallel String-keyed axis)
CREATE TABLE IF NOT EXISTS album_blacklist (
album_id TEXT PRIMARY KEY,
album_title TEXT NOT NULL,
artist_name TEXT,
cover_url TEXT,
added_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
notes TEXT
);
-- Index for album title search in UI
CREATE INDEX IF NOT EXISTS idx_album_blacklist_title
ON album_blacklist(album_title COLLATE NOCASE);
"#,
)
.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::<_, i64>(0).map(|v| v as u64))
.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 all blocked album ids from database into memory.
fn load_albums_from_db(&self) -> Result<(), String> {
let mut stmt = self
.conn
.prepare("SELECT album_id FROM album_blacklist")
.map_err(|e| format!("Failed to prepare album blacklist query: {}", e))?;
let ids: Vec<String> = stmt
.query_map([], |row| row.get(0))
.map_err(|e| format!("Failed to query album blacklist: {}", e))?
.filter_map(|r| r.ok())
.collect();
let count = ids.len();
let mut set = self
.blacklisted_album_ids
.write()
.map_err(|_| "Failed to acquire album write lock")?;
*set = ids.into_iter().collect();
log::info!("[Blacklist] Loaded {} blocked albums 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.
///
/// Does not respect the enabled flag.
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.
///
/// Does not touch the settings row.
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(())
}
// ----- Album axis (String-keyed, shares the `enabled` flag) -----
/// Check if an album is blacklisted - O(1) operation.
///
/// Returns false if the (shared) feature flag is disabled.
#[inline]
pub fn is_album_blacklisted(&self, album_id: &str) -> bool {
if !self.enabled.load(Ordering::Relaxed) {
return false;
}
self.blacklisted_album_ids
.read()
.map(|set| set.contains(album_id))
.unwrap_or(false)
}
/// Add an album to the blacklist.
pub fn add_album(
&self,
album_id: &str,
album_title: &str,
artist_name: &str,
cover_url: &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 album_blacklist
(album_id, album_title, artist_name, cover_url, added_at, notes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![album_id, album_title, artist_name, cover_url, now, notes],
)
.map_err(|e| format!("Failed to add album to blacklist: {}", e))?;
if let Ok(mut set) = self.blacklisted_album_ids.write() {
set.insert(album_id.to_string());
}
log::info!(
"[Blacklist] Added album: {} (id={})",
album_title,
album_id
);
Ok(())
}
/// Remove an album from the blacklist.
pub fn remove_album(&self, album_id: &str) -> Result<(), String> {
self.conn
.execute(
"DELETE FROM album_blacklist WHERE album_id = ?1",
params![album_id],
)
.map_err(|e| format!("Failed to remove album from blacklist: {}", e))?;
if let Ok(mut set) = self.blacklisted_album_ids.write() {
set.remove(album_id);
}
log::info!("[Blacklist] Removed album id={}", album_id);
Ok(())
}
/// Get all blacklisted albums, ordered by title (case-insensitive).
pub fn get_all_albums(&self) -> Result<Vec<BlacklistedAlbum>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT album_id, album_title, artist_name, cover_url, added_at, notes
FROM album_blacklist
ORDER BY album_title COLLATE NOCASE",
)
.map_err(|e| format!("Failed to prepare album query: {}", e))?;
let albums = stmt
.query_map([], |row| {
Ok(BlacklistedAlbum {
album_id: row.get(0)?,
album_title: row.get(1)?,
artist_name: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
cover_url: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
added_at: row.get(4)?,
notes: row.get(5)?,
})
})
.map_err(|e| format!("Failed to query album blacklist: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(albums)
}
/// Get count of blacklisted albums.
///
/// Does not respect the enabled flag.
pub fn album_count(&self) -> usize {
self.blacklisted_album_ids
.read()
.map(|set| set.len())
.unwrap_or(0)
}
/// Clear all blacklisted albums.
///
/// Does not touch the settings row nor the artist table.
pub fn clear_all_albums(&self) -> Result<(), String> {
self.conn
.execute("DELETE FROM album_blacklist", [])
.map_err(|e| format!("Failed to clear album blacklist: {}", e))?;
if let Ok(mut set) = self.blacklisted_album_ids.write() {
set.clear();
}
log::info!("[Blacklist] Cleared all album entries");
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn svc() -> BlacklistService {
BlacklistService::new_in_memory().expect("svc")
}
#[test]
fn add_and_check() {
let s = svc();
s.add(123, "Test Artist", None).unwrap();
assert!(s.is_blacklisted(123));
assert!(!s.is_blacklisted(456));
}
#[test]
fn remove_is_not_error_when_absent() {
let s = svc();
s.add(1, "A", None).unwrap();
s.remove(1).unwrap();
assert!(!s.is_blacklisted(1));
s.remove(999).unwrap(); // absent -> Ok, not error
}
#[test]
fn disabled_short_circuits_even_with_row() {
let s = svc();
s.add(1, "A", None).unwrap();
s.set_enabled(false).unwrap();
assert!(!s.is_blacklisted(1)); // disabled => false even though row exists
assert_eq!(s.count(), 1); // count ignores the enabled flag
s.set_enabled(true).unwrap();
assert!(s.is_blacklisted(1)); // re-enable restores instantly
}
#[test]
fn get_all_sorted_by_name_nocase_with_notes_roundtrip() {
let s = svc();
s.add(2, "zeta", Some("note-z".into())).unwrap();
s.add(1, "Alpha", None).unwrap();
let all = s.get_all().unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].artist_name, "Alpha"); // case-insensitive asc
assert_eq!(all[1].artist_name, "zeta");
assert_eq!(all[1].notes.as_deref(), Some("note-z"));
assert_eq!(all[0].notes, None);
}
#[test]
fn upsert_replaces_name_and_notes() {
let s = svc();
s.add(5, "Old", Some("n".into())).unwrap();
s.add(5, "New", None).unwrap(); // INSERT OR REPLACE
let all = s.get_all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].artist_name, "New");
assert_eq!(all[0].notes, None);
}
#[test]
fn clear_all_keeps_settings() {
let s = svc();
s.add(1, "A", None).unwrap();
s.set_enabled(false).unwrap();
s.clear_all().unwrap();
assert_eq!(s.count(), 0);
assert!(!s.is_enabled()); // clear_all does NOT touch enabled
}
// ----- Album axis -----
#[test]
fn album_add_and_check() {
let s = svc();
s.add_album("abc123", "Bogus Anthrax", "Anthrax", "http://c", None)
.unwrap();
assert!(s.is_album_blacklisted("abc123"));
assert!(!s.is_album_blacklisted("zzz999"));
}
#[test]
fn album_remove_is_not_error_when_absent() {
let s = svc();
s.add_album("a", "T", "Ar", "", None).unwrap();
s.remove_album("a").unwrap();
assert!(!s.is_album_blacklisted("a"));
s.remove_album("nope").unwrap(); // absent -> Ok
}
#[test]
fn album_get_all_sorted_by_title_with_fields_roundtrip() {
let s = svc();
s.add_album("2", "zeta", "Z Artist", "http://z", Some("n"))
.unwrap();
s.add_album("1", "Alpha", "A Artist", "", None).unwrap();
let all = s.get_all_albums().unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].album_title, "Alpha"); // case-insensitive asc
assert_eq!(all[1].album_title, "zeta");
assert_eq!(all[0].artist_name, "A Artist");
assert_eq!(all[0].cover_url, "");
assert_eq!(all[1].cover_url, "http://z");
assert_eq!(all[1].notes.as_deref(), Some("n"));
}
#[test]
fn album_upsert_replaces() {
let s = svc();
s.add_album("5", "Old", "A", "u1", Some("n")).unwrap();
s.add_album("5", "New", "B", "u2", None).unwrap();
let all = s.get_all_albums().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].album_title, "New");
assert_eq!(all[0].cover_url, "u2");
assert_eq!(all[0].notes, None);
}
#[test]
fn shared_enabled_flag_gates_both_axes() {
let s = svc();
s.add(1, "Artist", None).unwrap();
s.add_album("alb", "Album", "Artist", "", None).unwrap();
s.set_enabled(false).unwrap();
assert!(!s.is_blacklisted(1)); // both off
assert!(!s.is_album_blacklisted("alb"));
assert_eq!(s.count(), 1); // counts ignore the flag
assert_eq!(s.album_count(), 1);
s.set_enabled(true).unwrap();
assert!(s.is_blacklisted(1)); // both back on
assert!(s.is_album_blacklisted("alb"));
}
#[test]
fn axes_are_independent() {
let s = svc();
s.add_album("alb", "Album", "Artist", "", None).unwrap();
assert_eq!(s.album_count(), 1);
assert_eq!(s.count(), 0); // blocking an album leaves the artist set empty
s.add(7, "Artist", None).unwrap();
s.clear_all_albums().unwrap();
assert_eq!(s.album_count(), 0);
assert_eq!(s.count(), 1); // clear_all_albums leaves artist rows intact
assert!(s.is_blacklisted(7));
s.add_album("alb2", "A2", "Ar", "", None).unwrap();
s.clear_all().unwrap();
assert_eq!(s.count(), 0);
assert_eq!(s.album_count(), 1); // clear_all (artists) leaves albums intact
}
}