From 6cbca10f7e6ef24dc4cbac55ae36282b9554a9a8 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 17 Mar 2026 18:36:35 -0400 Subject: [PATCH] Initial commit --- .gitignore | 4 + Cargo.toml | 26 ++++ readme.md | 36 ++++++ src/error.rs | 12 ++ src/lib.rs | 14 ++ src/library.rs | 295 +++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 194 ++++++++++++++++++++++++++++ src/matching.rs | 129 +++++++++++++++++++ tests/integration.rs | 169 +++++++++++++++++++++++++ 9 files changed, 879 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 readme.md create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/library.rs create mode 100644 src/main.rs create mode 100644 src/matching.rs create mode 100644 tests/integration.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..360fdc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +.env +*.db +*.db-journal diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..14da8c1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "shanty-watch" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Library watchlist management for Shanty" +repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/watch.git" + +[dependencies] +shanty-db = { path = "../shanty-db" } +sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] } +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tokio = { version = "1", features = ["full"] } +anyhow = "1" +strsim = "0.11" +unicode-normalization = "0.1" +chrono = { version = "0.4", features = ["serde"] } +dirs = "6" + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..5748238 --- /dev/null +++ b/readme.md @@ -0,0 +1,36 @@ +# shanty-watch + +Library watchlist management for [Shanty](ssh://connor@git.rcjohnstone.com:2222/Shanty/shanty.git). + +Tracks what music the user *wants* vs what they *have*. Supports monitoring at +artist, album, and individual track level. Auto-detects owned items by fuzzy-matching +against the indexed library. + +## Usage + +```sh +# Add items to the watchlist +shanty-watch add artist "Pink Floyd" +shanty-watch add album "Pink Floyd" "The Dark Side of the Moon" +shanty-watch add track "Pink Floyd" "Time" + +# List watchlist items +shanty-watch list +shanty-watch list --status wanted +shanty-watch list --artist "Pink Floyd" + +# Library summary +shanty-watch status + +# Remove an item +shanty-watch remove 42 +``` + +## Status Flow + +`Wanted` → `Available` → `Downloaded` → `Owned` + +- **Wanted**: User wants this but doesn't have it +- **Available**: Found online but not yet downloaded +- **Downloaded**: Downloaded but not yet processed +- **Owned**: Fully processed, tagged, and organized in the library diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..46bb243 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,12 @@ +use shanty_db::DbError; + +#[derive(Debug, thiserror::Error)] +pub enum WatchError { + #[error("database error: {0}")] + Db(#[from] DbError), + + #[error("{0}")] + Other(String), +} + +pub type WatchResult = Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bf6e77e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +//! Library watchlist management for Shanty. +//! +//! Manages the user's music library — tracking which artists, albums, and songs +//! they want, and comparing against what they already have. + +pub mod error; +pub mod library; +pub mod matching; + +pub use error::{WatchError, WatchResult}; +pub use library::{ + LibrarySummary, WatchListEntry, add_album, add_artist, add_track, library_summary, list_items, + remove_item, +}; diff --git a/src/library.rs b/src/library.rs new file mode 100644 index 0000000..4f16c1a --- /dev/null +++ b/src/library.rs @@ -0,0 +1,295 @@ +use std::fmt; + +use sea_orm::DatabaseConnection; +use serde::{Deserialize, Serialize}; + +use shanty_db::entities::wanted_item::{ItemType, WantedStatus}; +use shanty_db::queries; + +use crate::error::WatchResult; +use crate::matching; + +/// A display-friendly watchlist entry with resolved names. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchListEntry { + pub id: i32, + pub item_type: ItemType, + pub name: String, + pub artist_name: Option, + pub status: WantedStatus, + pub added_at: chrono::NaiveDateTime, +} + +/// Summary statistics for the library. +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct LibrarySummary { + pub total_artists: u64, + pub total_albums: u64, + pub total_tracks: u64, + pub wanted: u64, + pub available: u64, + pub downloaded: u64, + pub owned: u64, +} + +impl fmt::Display for LibrarySummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Monitored: {} artists, {} albums, {} tracks", + self.total_artists, self.total_albums, self.total_tracks)?; + writeln!(f, " Wanted: {}", self.wanted)?; + writeln!(f, " Available: {}", self.available)?; + writeln!(f, " Downloaded: {}", self.downloaded)?; + write!(f, " Owned: {}", self.owned) + } +} + +/// Add an artist to the watchlist. Auto-detects if already owned. +pub async fn add_artist( + conn: &DatabaseConnection, + name: &str, + musicbrainz_id: Option<&str>, +) -> WatchResult { + let artist = queries::artists::upsert(conn, name, musicbrainz_id).await?; + + let is_owned = matching::artist_is_owned(conn, name).await?; + let item = queries::wanted::add( + conn, + ItemType::Artist, + Some(artist.id), + None, + None, + ) + .await?; + + // If already owned, update status + let status = if is_owned { + queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; + WantedStatus::Owned + } else { + WantedStatus::Wanted + }; + + if is_owned { + tracing::info!(name = name, "artist already in library, marked as owned"); + } else { + tracing::info!(name = name, "artist added to watchlist"); + } + + Ok(WatchListEntry { + id: item.id, + item_type: ItemType::Artist, + name: artist.name, + artist_name: None, + status, + added_at: item.added_at, + }) +} + +/// Add an album to the watchlist. Auto-detects if already owned. +pub async fn add_album( + conn: &DatabaseConnection, + artist_name: &str, + album_name: &str, + musicbrainz_id: Option<&str>, +) -> WatchResult { + let artist = queries::artists::upsert(conn, artist_name, None).await?; + let album = queries::albums::upsert(conn, album_name, artist_name, musicbrainz_id, Some(artist.id)).await?; + + let is_owned = matching::album_is_owned(conn, artist_name, album_name).await?; + let item = queries::wanted::add( + conn, + ItemType::Album, + Some(artist.id), + Some(album.id), + None, + ) + .await?; + + let status = if is_owned { + queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; + WantedStatus::Owned + } else { + WantedStatus::Wanted + }; + + Ok(WatchListEntry { + id: item.id, + item_type: ItemType::Album, + name: album.name, + artist_name: Some(artist.name), + status, + added_at: item.added_at, + }) +} + +/// Add a track to the watchlist. Auto-detects if already owned. +pub async fn add_track( + conn: &DatabaseConnection, + artist_name: &str, + title: &str, + musicbrainz_id: Option<&str>, +) -> WatchResult { + let artist = queries::artists::upsert(conn, artist_name, None).await?; + + let is_owned = matching::track_is_owned(conn, artist_name, title).await?; + + // We don't have a track record to link (the track table is for indexed files). + // Create the wanted item with just the artist reference. + let item = queries::wanted::add( + conn, + ItemType::Track, + Some(artist.id), + None, + None, + ) + .await?; + + let _ = musicbrainz_id; // Reserved for future use + + let status = if is_owned { + queries::wanted::update_status(conn, item.id, WantedStatus::Owned).await?; + WantedStatus::Owned + } else { + WantedStatus::Wanted + }; + + Ok(WatchListEntry { + id: item.id, + item_type: ItemType::Track, + name: title.to_string(), + artist_name: Some(artist.name), + status, + added_at: item.added_at, + }) +} + +/// List watchlist items with optional filters, resolved to display-friendly entries. +pub async fn list_items( + conn: &DatabaseConnection, + status_filter: Option, + artist_filter: Option<&str>, +) -> WatchResult> { + let items = queries::wanted::list(conn, status_filter).await?; + let mut entries = Vec::new(); + + for item in items { + let (name, artist_name) = resolve_names(conn, &item).await; + + // Apply artist filter if provided + if let Some(filter) = artist_filter { + let filter_norm = matching::normalize(filter); + let matches = artist_name + .as_deref() + .or(if item.item_type == ItemType::Artist { Some(name.as_str()) } else { None }) + .map(|a| { + let a_norm = matching::normalize(a); + strsim::jaro_winkler(&filter_norm, &a_norm) > 0.85 + }) + .unwrap_or(false); + if !matches { + continue; + } + } + + entries.push(WatchListEntry { + id: item.id, + item_type: item.item_type, + name, + artist_name, + status: item.status, + added_at: item.added_at, + }); + } + + Ok(entries) +} + +/// Remove an item from the watchlist. +pub async fn remove_item(conn: &DatabaseConnection, id: i32) -> WatchResult<()> { + queries::wanted::remove(conn, id).await?; + tracing::info!(id = id, "removed from watchlist"); + Ok(()) +} + +/// Get a summary of the library state. +pub async fn library_summary(conn: &DatabaseConnection) -> WatchResult { + let all = queries::wanted::list(conn, None).await?; + + let mut summary = LibrarySummary::default(); + for item in &all { + match item.item_type { + ItemType::Artist => summary.total_artists += 1, + ItemType::Album => summary.total_albums += 1, + ItemType::Track => summary.total_tracks += 1, + } + match item.status { + WantedStatus::Wanted => summary.wanted += 1, + WantedStatus::Available => summary.available += 1, + WantedStatus::Downloaded => summary.downloaded += 1, + WantedStatus::Owned => summary.owned += 1, + } + } + + Ok(summary) +} + +/// Resolve the display name and artist name for a wanted item. +async fn resolve_names( + conn: &DatabaseConnection, + item: &shanty_db::entities::wanted_item::Model, +) -> (String, Option) { + match item.item_type { + ItemType::Artist => { + let name = if let Some(id) = item.artist_id { + queries::artists::get_by_id(conn, id) + .await + .map(|a| a.name) + .unwrap_or_else(|_| format!("Artist #{id}")) + } else { + "Unknown Artist".to_string() + }; + (name, None) + } + ItemType::Album => { + let (album_name, artist_name) = if let Some(id) = item.album_id { + let album = queries::albums::get_by_id(conn, id).await; + match album { + Ok(a) => (a.name.clone(), Some(a.album_artist.clone())), + Err(_) => (format!("Album #{id}"), None), + } + } else { + ("Unknown Album".to_string(), None) + }; + let artist_name = artist_name.or_else(|| { + // Fall back to artist table + item.artist_id.and_then(|id| { + // Can't await in closure, use a placeholder + Some(format!("Artist #{id}")) + }) + }); + (album_name, artist_name) + } + ItemType::Track => { + // Track wanted items may not have a track_id (we watch by artist+title, not by file) + let artist_name = if let Some(id) = item.artist_id { + queries::artists::get_by_id(conn, id) + .await + .map(|a| a.name) + .ok() + } else { + None + }; + // We don't store the track title in wanted_items directly, + // so we show the artist name or a placeholder + let name = if let Some(id) = item.track_id { + queries::tracks::get_by_id(conn, id) + .await + .map(|t| t.title.unwrap_or_else(|| format!("Track #{id}"))) + .unwrap_or_else(|_| format!("Track #{id}")) + } else { + "Track (title not stored)".to_string() + }; + (name, artist_name) + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..13b091e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,194 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +use shanty_db::Database; +use shanty_db::entities::wanted_item::WantedStatus; +use shanty_watch::{add_album, add_artist, add_track, library_summary, list_items, remove_item}; + +#[derive(Parser)] +#[command(name = "shanty-watch", about = "Manage the Shanty music watchlist")] +struct Cli { + #[command(subcommand)] + command: Commands, + + /// Database URL. + #[arg(long, global = true, env = "SHANTY_DATABASE_URL")] + database: Option, + + /// Increase verbosity (-v info, -vv debug, -vvv trace). + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + verbose: u8, +} + +#[derive(Subcommand)] +enum Commands { + /// Add an item to the watchlist. + Add { + #[command(subcommand)] + what: AddCommand, + }, + /// List watchlist items. + List { + /// Filter by status (wanted, available, downloaded, owned). + #[arg(long)] + status: Option, + + /// Filter by artist name. + #[arg(long)] + artist: Option, + }, + /// Remove an item from the watchlist by ID. + Remove { + /// Watchlist item ID to remove. + id: i32, + }, + /// Show a summary of the library state. + Status, +} + +#[derive(Subcommand)] +enum AddCommand { + /// Add an artist to the watchlist. + Artist { + /// Artist name. + name: String, + /// MusicBrainz ID (optional). + #[arg(long)] + mbid: Option, + }, + /// Add an album to the watchlist. + Album { + /// Artist name. + artist: String, + /// Album name. + album: String, + /// MusicBrainz ID (optional). + #[arg(long)] + mbid: Option, + }, + /// Add a track to the watchlist. + Track { + /// Artist name. + artist: String, + /// Track title. + title: String, + /// MusicBrainz ID (optional). + #[arg(long)] + mbid: Option, + }, +} + +fn default_database_url() -> String { + let data_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("shanty"); + std::fs::create_dir_all(&data_dir).ok(); + let db_path = data_dir.join("shanty.db"); + format!("sqlite://{}?mode=rwc", db_path.display()) +} + +fn parse_status(s: &str) -> anyhow::Result { + match s.to_lowercase().as_str() { + "wanted" => Ok(WantedStatus::Wanted), + "available" => Ok(WantedStatus::Available), + "downloaded" => Ok(WantedStatus::Downloaded), + "owned" => Ok(WantedStatus::Owned), + _ => anyhow::bail!("unknown status: {s} (expected wanted, available, downloaded, or owned)"), + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let filter = match cli.verbose { + 0 => "warn", + 1 => "info,shanty_watch=info", + 2 => "info,shanty_watch=debug", + _ => "debug,shanty_watch=trace", + }; + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)), + ) + .init(); + + let database_url = cli.database.unwrap_or_else(default_database_url); + let db = Database::new(&database_url).await?; + + match cli.command { + Commands::Add { what } => match what { + AddCommand::Artist { name, mbid } => { + let entry = add_artist(db.conn(), &name, mbid.as_deref()).await?; + println!( + "Added artist: {} (id={}, status={:?})", + entry.name, entry.id, entry.status + ); + } + AddCommand::Album { artist, album, mbid } => { + let entry = add_album(db.conn(), &artist, &album, mbid.as_deref()).await?; + println!( + "Added album: {} by {} (id={}, status={:?})", + entry.name, + entry.artist_name.as_deref().unwrap_or("?"), + entry.id, + entry.status + ); + } + AddCommand::Track { artist, title, mbid } => { + let entry = add_track(db.conn(), &artist, &title, mbid.as_deref()).await?; + println!( + "Added track: {} by {} (id={}, status={:?})", + entry.name, + entry.artist_name.as_deref().unwrap_or("?"), + entry.id, + entry.status + ); + } + }, + Commands::List { status, artist } => { + let status_filter = status.as_deref().map(parse_status).transpose()?; + let entries = list_items(db.conn(), status_filter, artist.as_deref()).await?; + + if entries.is_empty() { + println!("Watchlist is empty."); + } else { + println!( + "{:<5} {:<8} {:<12} {:<30} {}", + "ID", "TYPE", "STATUS", "NAME", "ARTIST" + ); + for e in &entries { + println!( + "{:<5} {:<8} {:<12} {:<30} {}", + e.id, + format!("{:?}", e.item_type), + format!("{:?}", e.status), + truncate(&e.name, 30), + e.artist_name.as_deref().unwrap_or(""), + ); + } + println!("\n{} items total", entries.len()); + } + } + Commands::Remove { id } => { + remove_item(db.conn(), id).await?; + println!("Removed item {id} from watchlist."); + } + Commands::Status => { + let summary = library_summary(db.conn()).await?; + println!("{summary}"); + } + } + + Ok(()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max - 1]) + } +} diff --git a/src/matching.rs b/src/matching.rs new file mode 100644 index 0000000..e016d77 --- /dev/null +++ b/src/matching.rs @@ -0,0 +1,129 @@ +use sea_orm::DatabaseConnection; +use unicode_normalization::UnicodeNormalization; + +use shanty_db::queries; + +use crate::error::WatchResult; + +/// Normalize a string for fuzzy comparison: NFC unicode, lowercase, trim. +pub fn normalize(s: &str) -> String { + s.nfc().collect::().to_lowercase().trim().to_string() +} + +/// Check if an artist is "owned" — i.e., any tracks by this artist exist in the indexed library. +pub async fn artist_is_owned(conn: &DatabaseConnection, artist_name: &str) -> WatchResult { + let normalized = normalize(artist_name); + + // Check by exact artist record first + if let Some(artist) = queries::artists::find_by_name(conn, artist_name).await? { + let tracks = queries::tracks::get_by_artist(conn, artist.id).await?; + if !tracks.is_empty() { + return Ok(true); + } + } + + // Fuzzy: search tracks where the artist field matches + let tracks = queries::tracks::search(conn, artist_name).await?; + for track in &tracks { + if let Some(ref track_artist) = track.artist { + let sim = strsim::jaro_winkler(&normalized, &normalize(track_artist)); + if sim > 0.85 { + return Ok(true); + } + } + if let Some(ref album_artist) = track.album_artist { + let sim = strsim::jaro_winkler(&normalized, &normalize(album_artist)); + if sim > 0.85 { + return Ok(true); + } + } + } + + Ok(false) +} + +/// Check if an album is "owned" — tracks from this album by this artist exist. +pub async fn album_is_owned( + conn: &DatabaseConnection, + artist_name: &str, + album_name: &str, +) -> WatchResult { + let norm_artist = normalize(artist_name); + let norm_album = normalize(album_name); + + // Try exact lookup + if let Some(album) = queries::albums::find_by_name_and_artist(conn, album_name, artist_name).await? { + let tracks = queries::tracks::get_by_album(conn, album.id).await?; + if !tracks.is_empty() { + return Ok(true); + } + } + + // Fuzzy: search tracks matching album name + let tracks = queries::tracks::search(conn, album_name).await?; + for track in &tracks { + let album_match = track + .album + .as_deref() + .map(|a| strsim::jaro_winkler(&norm_album, &normalize(a)) > 0.85) + .unwrap_or(false); + let artist_match = track + .artist + .as_deref() + .or(track.album_artist.as_deref()) + .map(|a| strsim::jaro_winkler(&norm_artist, &normalize(a)) > 0.85) + .unwrap_or(false); + if album_match && artist_match { + return Ok(true); + } + } + + Ok(false) +} + +/// Check if a specific track is "owned" — a track with matching artist + title exists. +pub async fn track_is_owned( + conn: &DatabaseConnection, + artist_name: &str, + title: &str, +) -> WatchResult { + let norm_artist = normalize(artist_name); + let norm_title = normalize(title); + + // Fuzzy: search tracks matching the title + let tracks = queries::tracks::search(conn, title).await?; + for track in &tracks { + let title_match = track + .title + .as_deref() + .map(|t| strsim::jaro_winkler(&norm_title, &normalize(t)) > 0.85) + .unwrap_or(false); + let artist_match = track + .artist + .as_deref() + .or(track.album_artist.as_deref()) + .map(|a| strsim::jaro_winkler(&norm_artist, &normalize(a)) > 0.85) + .unwrap_or(false); + if title_match && artist_match { + return Ok(true); + } + } + + Ok(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize() { + assert_eq!(normalize(" Pink Floyd "), "pink floyd"); + assert_eq!(normalize("RADIOHEAD"), "radiohead"); + } + + #[test] + fn test_normalize_unicode() { + assert_eq!(normalize("café"), normalize("café")); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..c6a3cc8 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,169 @@ +use chrono::Utc; +use sea_orm::ActiveValue::Set; + +use shanty_db::entities::wanted_item::{ItemType, WantedStatus}; +use shanty_db::{Database, queries}; +use shanty_watch::{add_album, add_artist, add_track, library_summary, list_items, remove_item}; + +async fn test_db() -> Database { + Database::new("sqlite::memory:") + .await + .expect("failed to create test database") +} + +/// Insert a fake track into the DB to simulate an indexed library. +async fn insert_track(db: &Database, artist: &str, title: &str, album: &str) { + let now = Utc::now().naive_utc(); + let artist_rec = queries::artists::upsert(db.conn(), artist, None) + .await + .unwrap(); + let album_rec = queries::albums::upsert(db.conn(), album, artist, None, Some(artist_rec.id)) + .await + .unwrap(); + let active = shanty_db::entities::track::ActiveModel { + file_path: Set(format!("/music/{artist}/{album}/{title}.mp3")), + title: Set(Some(title.to_string())), + artist: Set(Some(artist.to_string())), + album: Set(Some(album.to_string())), + album_artist: Set(Some(artist.to_string())), + file_size: Set(1_000_000), + artist_id: Set(Some(artist_rec.id)), + album_id: Set(Some(album_rec.id)), + added_at: Set(now), + updated_at: Set(now), + ..Default::default() + }; + queries::tracks::upsert(db.conn(), active).await.unwrap(); +} + +#[tokio::test] +async fn test_add_artist_wanted() { + let db = test_db().await; + + let entry = add_artist(db.conn(), "Radiohead", None).await.unwrap(); + assert_eq!(entry.item_type, ItemType::Artist); + assert_eq!(entry.name, "Radiohead"); + assert_eq!(entry.status, WantedStatus::Wanted); +} + +#[tokio::test] +async fn test_add_artist_auto_owned() { + let db = test_db().await; + + // Index some tracks first + insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; + + // Add artist to watchlist — should auto-detect as owned + let entry = add_artist(db.conn(), "Pink Floyd", None).await.unwrap(); + assert_eq!(entry.status, WantedStatus::Owned); +} + +#[tokio::test] +async fn test_add_album_wanted() { + let db = test_db().await; + + let entry = add_album(db.conn(), "Radiohead", "OK Computer", None) + .await + .unwrap(); + assert_eq!(entry.item_type, ItemType::Album); + assert_eq!(entry.name, "OK Computer"); + assert_eq!(entry.status, WantedStatus::Wanted); +} + +#[tokio::test] +async fn test_add_album_auto_owned() { + let db = test_db().await; + + insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; + + let entry = add_album(db.conn(), "Pink Floyd", "DSOTM", None) + .await + .unwrap(); + assert_eq!(entry.status, WantedStatus::Owned); +} + +#[tokio::test] +async fn test_add_track_wanted() { + let db = test_db().await; + + let entry = add_track(db.conn(), "Radiohead", "Creep", None) + .await + .unwrap(); + assert_eq!(entry.item_type, ItemType::Track); + assert_eq!(entry.name, "Creep"); + assert_eq!(entry.status, WantedStatus::Wanted); +} + +#[tokio::test] +async fn test_add_track_auto_owned() { + let db = test_db().await; + + insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; + + let entry = add_track(db.conn(), "Pink Floyd", "Time", None) + .await + .unwrap(); + assert_eq!(entry.status, WantedStatus::Owned); +} + +#[tokio::test] +async fn test_list_items_with_filters() { + let db = test_db().await; + + add_artist(db.conn(), "Radiohead", None).await.unwrap(); + add_artist(db.conn(), "Tool", None).await.unwrap(); + + // List all + let all = list_items(db.conn(), None, None).await.unwrap(); + assert_eq!(all.len(), 2); + + // List by status + let wanted = list_items(db.conn(), Some(WantedStatus::Wanted), None) + .await + .unwrap(); + assert_eq!(wanted.len(), 2); + + let owned = list_items(db.conn(), Some(WantedStatus::Owned), None) + .await + .unwrap(); + assert!(owned.is_empty()); + + // List by artist + let radiohead = list_items(db.conn(), None, Some("Radiohead")) + .await + .unwrap(); + assert_eq!(radiohead.len(), 1); + assert_eq!(radiohead[0].name, "Radiohead"); +} + +#[tokio::test] +async fn test_remove_item() { + let db = test_db().await; + + let entry = add_artist(db.conn(), "Radiohead", None).await.unwrap(); + let all = list_items(db.conn(), None, None).await.unwrap(); + assert_eq!(all.len(), 1); + + remove_item(db.conn(), entry.id).await.unwrap(); + let all = list_items(db.conn(), None, None).await.unwrap(); + assert!(all.is_empty()); +} + +#[tokio::test] +async fn test_library_summary() { + let db = test_db().await; + + insert_track(&db, "Pink Floyd", "Time", "DSOTM").await; + + add_artist(db.conn(), "Radiohead", None).await.unwrap(); // wanted + add_artist(db.conn(), "Pink Floyd", None).await.unwrap(); // owned + add_album(db.conn(), "Tool", "Lateralus", None).await.unwrap(); // wanted + add_track(db.conn(), "Pink Floyd", "Time", None).await.unwrap(); // owned + + let summary = library_summary(db.conn()).await.unwrap(); + assert_eq!(summary.total_artists, 2); + assert_eq!(summary.total_albums, 1); + assert_eq!(summary.total_tracks, 1); + assert_eq!(summary.wanted, 2); + assert_eq!(summary.owned, 2); +}