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