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

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é"));
}
}