Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
26
Cargo.toml
Normal file
26
Cargo.toml
Normal 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
36
readme.md
Normal 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
12
src/error.rs
Normal 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
14
src/lib.rs
Normal 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
295
src/library.rs
Normal 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
194
src/main.rs
Normal 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
129
src/matching.rs
Normal 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
169
tests/integration.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user