Files
qbz/crates/qbz-app/src/settings/local_favorites.rs
T
vicrodh c7a3012955 feat(local-library): complete catalog and metadata workflows
Add bounded native library models, complete grouping rails and scan progress. Bring source-aware metadata editing, media information and collection offline preflight to Qt, and repair inherited Plex album artwork without overriding healthy tokens.

cx
2026-08-28 18:50:05 -06:00

402 lines
15 KiB
Rust

//! Headless local-favorites service.
//!
//! Frontend-agnostic store for favoriting LOCAL LIBRARY items (genuine local
//! files plus configured media servers — never the Qobuz offline cache).
//! Mirrors `pinned_items.rs`
//! (same pragmas, error style, in-memory `(kind, id)` set) per ADR-006; the
//! per-user lifecycle lives in the `qbz` crate wrapper (`crate::local_favorites`).
//!
//! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite
//! time plus a denormalized `artist` (for per-artist counts) and `source`
//! (`local` | `plex` | `jellyfin` | `subsonic`). The `CHECK` on `source`
//! refuses `qobuz_download` at
//! write time, so the mixed-library feed built from this store is inherently
//! free of Qobuz-offline duplicates.
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
use std::sync::RwLock;
/// Database file name, joined onto the per-user data dir by the lifecycle layer.
pub const DB_FILE_NAME: &str = "local_favorites.db";
/// A favorited local item with its display snapshot.
///
/// Ids are Strings: album = the source-aware group key (`plex:…`,
/// `jellyfin:…`, `subsonic:…` or a local key containing `|`/`/`), artist =
/// the artist NAME, track = a source-aware stable key.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalFavItem {
/// "album" | "artist" | "track".
pub kind: String,
pub id: String,
pub title: String,
pub subtitle: String,
pub artwork_url: String,
/// Denormalized artist name (for per-artist counts); empty for kind="artist".
pub artist: String,
/// "local" | "plex" | "jellyfin" | "subsonic" — never a Qobuz
/// download spelling.
pub source: String,
/// Unix seconds; the ordering key (newest first).
pub favorited_at: i64,
}
/// Local-favorites service with O(1) lookup performance.
pub struct LocalFavoritesService {
conn: Connection,
/// In-memory `(kind, id)` set for O(1) heart lookups.
keys: RwLock<HashSet<(String, String)>>,
}
impl LocalFavoritesService {
/// Create a new service, opening or creating the database.
pub fn new(db_path: &Path) -> Result<Self, String> {
log::info!("[LocalFav] Opening database at: {}", db_path.display());
let conn = Connection::open(db_path)
.map_err(|e| format!("Failed to open local favorites database: {}", e))?;
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,
keys: RwLock::new(HashSet::new()),
};
service.init_schema()?;
service.load_from_db()?;
Ok(service)
}
/// Create an in-memory service (test/ephemeral helper).
pub fn new_in_memory() -> Result<Self, String> {
let conn = Connection::open_in_memory()
.map_err(|e| format!("Failed to open in-memory local favorites database: {}", e))?;
let service = Self {
conn,
keys: RwLock::new(HashSet::new()),
};
service.init_schema()?;
service.load_from_db()?;
Ok(service)
}
fn init_schema(&self) -> Result<(), String> {
self.conn
.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS local_favorites (
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
artwork_url TEXT,
artist TEXT,
source TEXT NOT NULL CHECK (
source IN ('local','plex','jellyfin','subsonic')
),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
"#,
)
.map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?;
// v1 accepted only local/Plex. SQLite cannot widen a CHECK in place,
// so rebuild the tiny snapshot table atomically while preserving every
// existing favorite. Index creation deliberately follows the rebuild:
// ALTER TABLE carries the old index names to the legacy table.
let table_sql = self
.conn
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='local_favorites'",
[],
|row| row.get::<_, String>(0),
)
.map_err(|e| format!("Failed to inspect local favorites schema: {e}"))?;
if !table_sql.contains("'jellyfin'") || !table_sql.contains("'subsonic'") {
self.conn
.execute_batch(
r#"
BEGIN IMMEDIATE;
ALTER TABLE local_favorites RENAME TO local_favorites_v1;
CREATE TABLE local_favorites (
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
artwork_url TEXT,
artist TEXT,
source TEXT NOT NULL CHECK (
source IN ('local','plex','jellyfin','subsonic')
),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
INSERT INTO local_favorites(
kind,id,title,subtitle,artwork_url,artist,source,favorited_at
)
SELECT kind,id,title,subtitle,artwork_url,artist,source,favorited_at
FROM local_favorites_v1;
DROP TABLE local_favorites_v1;
COMMIT;
"#,
)
.map_err(|e| format!("Failed to migrate local favorites schema: {e}"))?;
}
self.conn
.execute_batch(
r#"
CREATE INDEX IF NOT EXISTS idx_local_favorites_at
ON local_favorites(favorited_at);
CREATE INDEX IF NOT EXISTS idx_local_favorites_artist
ON local_favorites(kind, artist);
"#,
)
.map_err(|e| format!("Failed to index local favorites schema: {e}"))?;
Ok(())
}
fn load_from_db(&self) -> Result<(), String> {
let mut stmt = self
.conn
.prepare("SELECT kind, id FROM local_favorites")
.map_err(|e| format!("Failed to prepare local favorites query: {}", e))?;
let keys: Vec<(String, String)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| format!("Failed to query local favorites: {}", e))?
.filter_map(|r| r.ok())
.collect();
let count = keys.len();
let mut set = self
.keys
.write()
.map_err(|_| "Failed to acquire write lock")?;
*set = keys.into_iter().collect();
log::info!("[LocalFav] Loaded {} local favorites into memory", count);
Ok(())
}
/// Check if a local item is favorited — O(1).
#[inline]
pub fn is_favorite(&self, kind: &str, id: &str) -> bool {
self.keys
.read()
.map(|set| set.contains(&(kind.to_string(), id.to_string())))
.unwrap_or(false)
}
/// Favorite an item (upsert). `favorited_at` is stamped now.
pub fn favorite(&self, item: &LocalFavItem) -> 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 local_favorites
(kind, id, title, subtitle, artwork_url, artist, source, favorited_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
item.kind,
item.id,
item.title,
item.subtitle,
item.artwork_url,
item.artist,
item.source,
now
],
)
.map_err(|e| format!("Failed to favorite item: {}", e))?;
if let Ok(mut set) = self.keys.write() {
set.insert((item.kind.clone(), item.id.clone()));
}
Ok(())
}
/// Unfavorite an item. Absent rows are Ok, not an error.
pub fn unfavorite(&self, kind: &str, id: &str) -> Result<(), String> {
self.conn
.execute(
"DELETE FROM local_favorites WHERE kind = ?1 AND id = ?2",
params![kind, id],
)
.map_err(|e| format!("Failed to unfavorite item: {}", e))?;
if let Ok(mut set) = self.keys.write() {
set.remove(&(kind.to_string(), id.to_string()));
}
Ok(())
}
/// All favorites, newest first.
pub fn list(&self) -> Result<Vec<LocalFavItem>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT kind, id, title, subtitle, artwork_url, artist, source, favorited_at
FROM local_favorites
ORDER BY favorited_at DESC",
)
.map_err(|e| format!("Failed to prepare local favorites query: {}", e))?;
let items = stmt
.query_map([], |row| {
Ok(LocalFavItem {
kind: row.get(0)?,
id: row.get(1)?,
title: row.get(2)?,
subtitle: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
artwork_url: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
artist: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
source: row.get(6)?,
favorited_at: row.get(7)?,
})
})
.map_err(|e| format!("Failed to query local favorites: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(items)
}
/// Per-artist favorite counts (album + track kinds carry an artist).
pub fn count_by_artist(&self) -> Result<Vec<(String, i64)>, String> {
let mut stmt = self
.conn
.prepare(
"SELECT artist, COUNT(*) FROM local_favorites
WHERE artist IS NOT NULL AND artist != ''
GROUP BY artist ORDER BY COUNT(*) DESC",
)
.map_err(|e| format!("Failed to prepare count-by-artist query: {}", e))?;
let rows = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|e| format!("Failed to query count-by-artist: {}", e))?
.filter_map(|r| r.ok())
.collect();
Ok(rows)
}
/// Count of favorites.
pub fn count(&self) -> usize {
self.keys.read().map(|set| set.len()).unwrap_or(0)
}
/// Snapshot of the in-memory `(kind, id)` set, for bulk card stamping.
pub fn keys_snapshot(&self) -> HashSet<(String, String)> {
self.keys.read().map(|set| set.clone()).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(kind: &str, id: &str, title: &str, artist: &str, source: &str) -> LocalFavItem {
LocalFavItem {
kind: kind.to_string(),
id: id.to_string(),
title: title.to_string(),
subtitle: String::new(),
artwork_url: String::new(),
artist: artist.to_string(),
source: source.to_string(),
favorited_at: 0,
}
}
#[test]
fn lifecycle() {
let s = LocalFavoritesService::new_in_memory().expect("svc");
assert!(!s.is_favorite("album", "plex:abc"));
assert_eq!(s.count(), 0);
s.favorite(&item("album", "plex:abc", "A", "Artist X", "plex"))
.unwrap();
assert!(s.is_favorite("album", "plex:abc"));
assert!(!s.is_favorite("track", "plex:abc"));
s.favorite(&item("track", "/music/x.flac", "T", "Artist X", "local"))
.unwrap();
assert_eq!(s.count(), 2);
let by_artist = s.count_by_artist().unwrap();
assert_eq!(by_artist[0], ("Artist X".to_string(), 2));
let all = s.list().unwrap();
assert_eq!(all.len(), 2);
s.unfavorite("album", "plex:abc").unwrap();
assert!(!s.is_favorite("album", "plex:abc"));
assert_eq!(s.count(), 1);
s.unfavorite("album", "nope").unwrap();
}
#[test]
fn source_check_rejects_offline() {
let s = LocalFavoritesService::new_in_memory().expect("svc");
s.favorite(&item("album", "jf", "J", "A", "jellyfin"))
.expect("Jellyfin belongs to the Local Library favorite domain");
s.favorite(&item("album", "sub", "S", "A", "subsonic"))
.expect("Subsonic belongs to the Local Library favorite domain");
assert!(
s.favorite(&item("album", "x", "X", "A", "qobuz_download"))
.is_err(),
"the source CHECK refuses qobuz-offline rows"
);
}
#[test]
fn v1_schema_migrates_without_losing_existing_favorites() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE local_favorites (
kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')),
id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
artwork_url TEXT,
artist TEXT,
source TEXT NOT NULL CHECK (source IN ('local','plex')),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
INSERT INTO local_favorites(
kind,id,title,subtitle,artwork_url,artist,source,favorited_at
) VALUES ('album','plex:old','Old','','','Artist','plex',42);
"#,
)
.unwrap();
let service = LocalFavoritesService {
conn,
keys: RwLock::new(HashSet::new()),
};
service.init_schema().unwrap();
service.load_from_db().unwrap();
assert!(service.is_favorite("album", "plex:old"));
service
.favorite(&item("album", "jellyfin:new", "New", "Artist", "jellyfin"))
.unwrap();
}
}