Initial commit

This commit is contained in:
Connor Johnstone
2026-03-17 18:36:35 -04:00
commit 6cbca10f7e
9 changed files with 879 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
target/
.env
*.db
*.db-journal

26
Cargo.toml Normal file
View File

@@ -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"] }

36
readme.md Normal file
View File

@@ -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

12
src/error.rs Normal file
View File

@@ -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<T> = Result<T, WatchError>;

14
src/lib.rs Normal file
View File

@@ -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,
};

295
src/library.rs Normal file
View File

@@ -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<String>,
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<WatchListEntry> {
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<WatchListEntry> {
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<WatchListEntry> {
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<WantedStatus>,
artist_filter: Option<&str>,
) -> WatchResult<Vec<WatchListEntry>> {
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<LibrarySummary> {
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<String>) {
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)
}
}
}

194
src/main.rs Normal file
View File

@@ -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<String>,
/// 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<String>,
/// Filter by artist name.
#[arg(long)]
artist: Option<String>,
},
/// 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<String>,
},
/// Add an album to the watchlist.
Album {
/// Artist name.
artist: String,
/// Album name.
album: String,
/// MusicBrainz ID (optional).
#[arg(long)]
mbid: Option<String>,
},
/// Add a track to the watchlist.
Track {
/// Artist name.
artist: String,
/// Track title.
title: String,
/// MusicBrainz ID (optional).
#[arg(long)]
mbid: Option<String>,
},
}
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<WantedStatus> {
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])
}
}

129
src/matching.rs Normal file
View File

@@ -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::<String>().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<bool> {
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<bool> {
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<bool> {
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é"));
}
}

169
tests/integration.rs Normal file
View File

@@ -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);
}