feat(library): All mixed feed, TrackCard, artist playlists + library toggle, local favorites

- Library "All": mixed feed (tracks/albums/artists/playlists/labels) sorted by
  date added, reusing the existing cards; new TrackCard; toolbar (search + source
  switches + sort); "All" added to the Library nav surfaces.
- ArtistPage: restored the curated Playlists carousel (parsed but dropped in the
  Slint port); added the catalog/library toggle backed by a per-session
  per-artist index (favorites).
- Local-library favorites layer: new SQLite store (mirrors the pinned service),
  album-level; excludes the Qobuz offline cache at write time. Feeds "All" behind
  the show-local switch.
- i18n: 3 new strings across all 8 locales.

cc
This commit is contained in:
vicrodh committed 2026-07-18 01:33:23 -06:00
1 parent e84d1035f8
commit 83df91439c
26 files changed
+2049 -12

No files matched your search

@@ -0,0 +1,307 @@
//! Headless local-favorites service.
//!
//! Frontend-agnostic store for favoriting LOCAL library items (genuine local
//! files + Plex — 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`). 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 local group key (`plex:…` / contains `|`/`/`),
/// artist = the artist NAME (local artists have no numeric id), track =
/// `file_path` (local) or `plex:<file_path>` (Plex) — the 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" — never "qobuz_download".
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;")
.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')),
favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
PRIMARY KEY (kind, id)
);
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 initialize 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");
assert!(
s.favorite(&item("album", "x", "X", "A", "qobuz_download"))
.is_err(),
"the source CHECK refuses qobuz-offline rows"
);
}
}