From 208dbf422b34dea3c5bb0837e5f425206119baa9 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Tue, 17 Mar 2026 19:00:43 -0400 Subject: [PATCH] Initial commit --- .gitignore | 4 + Cargo.toml | 25 ++++++ readme.md | 21 +++++ src/cache.rs | 45 +++++++++++ src/error.rs | 24 ++++++ src/lib.rs | 15 ++++ src/main.rs | 188 +++++++++++++++++++++++++++++++++++++++++++ src/musicbrainz.rs | 122 ++++++++++++++++++++++++++++ src/provider.rs | 86 ++++++++++++++++++++ tests/integration.rs | 188 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 718 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 readme.md create mode 100644 src/cache.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/musicbrainz.rs create mode 100644 src/provider.rs create mode 100644 tests/integration.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..360fdc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +.env +*.db +*.db-journal diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..da87ae6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "shanty-search" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Online music search for Shanty" +repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/search.git" + +[dependencies] +shanty-db = { path = "../shanty-db" } +shanty-tag = { path = "../shanty-tag" } +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" +chrono = { version = "0.4", features = ["serde"] } +dirs = "6" + +[dev-dependencies] +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c3d32ef --- /dev/null +++ b/readme.md @@ -0,0 +1,21 @@ +# shanty-search + +Online music search for [Shanty](ssh://connor@git.rcjohnstone.com:2222/Shanty/shanty.git). + +Searches for artists, albums, and tracks via online databases (MusicBrainz, etc.) +and returns structured results for adding to the library watchlist. + +## Usage + +```sh +shanty-search artist "Pink Floyd" +shanty-search album "Dark Side" --artist "Pink Floyd" +shanty-search track "Time" --artist "Pink Floyd" +shanty-search discography "83d91898-7763-47d7-b03b-b92132375c47" + +# JSON output +shanty-search artist "Radiohead" --json + +# Limit results +shanty-search artist "The" --limit 5 +``` diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..407de14 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,45 @@ +use sea_orm::DatabaseConnection; + +use shanty_db::queries; + +use crate::error::SearchResult; + +const DEFAULT_TTL: i64 = 86400; // 24 hours + +/// Try to get a cached result. Returns None if not cached or expired. +pub async fn get_cached( + conn: Option<&DatabaseConnection>, + key: &str, +) -> SearchResult> { + let conn = match conn { + Some(c) => c, + None => return Ok(None), + }; + + match queries::cache::get(conn, key).await? { + Some(json) => { + let result: T = serde_json::from_str(&json)?; + tracing::debug!(key = key, "cache hit"); + Ok(Some(result)) + } + None => Ok(None), + } +} + +/// Store a result in the cache. +pub async fn set_cached( + conn: Option<&DatabaseConnection>, + key: &str, + provider: &str, + value: &T, +) -> SearchResult<()> { + let conn = match conn { + Some(c) => c, + None => return Ok(()), + }; + + let json = serde_json::to_string(value)?; + queries::cache::set(conn, key, provider, &json, DEFAULT_TTL).await?; + tracing::debug!(key = key, "cached result"); + Ok(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..14df5df --- /dev/null +++ b/src/error.rs @@ -0,0 +1,24 @@ +use shanty_db::DbError; + +#[derive(Debug, thiserror::Error)] +pub enum SearchError { + #[error("database error: {0}")] + Db(#[from] DbError), + + #[error("provider error: {0}")] + Provider(String), + + #[error("serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("{0}")] + Other(String), +} + +impl From for SearchError { + fn from(e: shanty_tag::TagError) -> Self { + SearchError::Provider(e.to_string()) + } +} + +pub type SearchResult = Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5315f7e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +//! Online music search for Shanty. +//! +//! Searches for artists, albums, and tracks via online databases (MusicBrainz, +//! Last.fm, etc.) and returns structured results for adding to the library. + +pub mod cache; +pub mod error; +pub mod musicbrainz; +pub mod provider; + +pub use error::{SearchError, SearchResult}; +pub use musicbrainz::MusicBrainzSearch; +pub use provider::{ + AlbumResult, ArtistResult, Discography, DiscographyEntry, SearchProvider, TrackResult, +}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cf826f7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,188 @@ +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +use shanty_search::{MusicBrainzSearch, SearchProvider}; + +#[derive(Parser)] +#[command(name = "shanty-search", about = "Search for music online")] +struct Cli { + #[command(subcommand)] + command: Commands, + + /// Output results as JSON. + #[arg(long, global = true)] + json: bool, + + /// Maximum number of results. + #[arg(long, global = true, default_value = "10")] + limit: u32, + + /// Database URL (optional, for caching). + #[arg(long, global = true, env = "SHANTY_DATABASE_URL")] + database: Option, + + /// Increase verbosity (-v info, -vv debug, -vvv trace). + #[arg(short, long, global = true, action = clap::ArgAction::Count)] + verbose: u8, +} + +#[derive(Subcommand)] +enum Commands { + /// Search for an artist. + Artist { + /// Artist name to search for. + query: String, + }, + /// Search for an album. + Album { + /// Album name to search for. + query: String, + /// Filter by artist name. + #[arg(long)] + artist: Option, + }, + /// Search for a track. + Track { + /// Track title to search for. + query: String, + /// Filter by artist name. + #[arg(long)] + artist: Option, + }, + /// Show an artist's discography. + Discography { + /// Artist name or MusicBrainz ID. + artist: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let filter = match cli.verbose { + 0 => "warn", + 1 => "info,shanty_search=info", + 2 => "info,shanty_search=debug", + _ => "debug,shanty_search=trace", + }; + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)), + ) + .init(); + + let provider = MusicBrainzSearch::new()?; + + match cli.command { + Commands::Artist { query } => { + let results = provider.search_artist(&query, cli.limit).await?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&results)?); + } else if results.is_empty() { + println!("No artists found."); + } else { + println!("{:<40} {:<8} {:<5} {}", "NAME", "COUNTRY", "SCORE", "DISAMBIGUATION"); + for r in &results { + println!( + "{:<40} {:<8} {:<5} {}", + truncate(&r.name, 40), + r.country.as_deref().unwrap_or(""), + r.score, + r.disambiguation.as_deref().unwrap_or(""), + ); + } + println!("\n{} results", results.len()); + } + } + Commands::Album { query, artist } => { + let results = provider + .search_album(&query, artist.as_deref(), cli.limit) + .await?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&results)?); + } else if results.is_empty() { + println!("No albums found."); + } else { + println!("{:<35} {:<25} {:<6} {:<5}", "TITLE", "ARTIST", "YEAR", "SCORE"); + for r in &results { + println!( + "{:<35} {:<25} {:<6} {:<5}", + truncate(&r.title, 35), + truncate(&r.artist, 25), + r.year.as_deref().unwrap_or(""), + r.score, + ); + } + println!("\n{} results", results.len()); + } + } + Commands::Track { query, artist } => { + let results = provider + .search_track(&query, artist.as_deref(), cli.limit) + .await?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&results)?); + } else if results.is_empty() { + println!("No tracks found."); + } else { + println!("{:<35} {:<25} {:<25} {:<5}", "TITLE", "ARTIST", "ALBUM", "SCORE"); + for r in &results { + println!( + "{:<35} {:<25} {:<25} {:<5}", + truncate(&r.title, 35), + truncate(&r.artist, 25), + r.album.as_deref().map(|a| truncate(a, 25)).unwrap_or_default(), + r.score, + ); + } + println!("\n{} results", results.len()); + } + } + Commands::Discography { artist } => { + // If it looks like an MBID (UUID format), use directly; otherwise search first + let artist_mbid = if artist.contains('-') && artist.len() == 36 { + artist.clone() + } else { + let results = provider.search_artist(&artist, 1).await?; + let first = results + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("no artist found for '{artist}'"))?; + tracing::info!(name = %first.name, mbid = %first.id, "resolved artist"); + first.id + }; + + let disco = provider.get_discography(&artist_mbid).await?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&disco)?); + } else { + println!("Discography for: {}\n", disco.artist_name); + if disco.releases.is_empty() { + println!("No releases found."); + } else { + println!("{:<40} {:<12} {:<6}", "TITLE", "DATE", "TRACKS"); + for r in &disco.releases { + println!( + "{:<40} {:<12} {:<6}", + truncate(&r.title, 40), + r.date.as_deref().unwrap_or(""), + r.track_count.map(|n| n.to_string()).unwrap_or_default(), + ); + } + println!("\n{} releases", disco.releases.len()); + } + } + } + } + + Ok(()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max - 1]) + } +} diff --git a/src/musicbrainz.rs b/src/musicbrainz.rs new file mode 100644 index 0000000..cff7043 --- /dev/null +++ b/src/musicbrainz.rs @@ -0,0 +1,122 @@ +use shanty_tag::provider::MetadataProvider; +use shanty_tag::MusicBrainzClient; + +use crate::error::SearchResult; +use crate::provider::{ + AlbumResult, ArtistResult, Discography, DiscographyEntry, SearchProvider, TrackResult, +}; + +/// MusicBrainz implementation of `SearchProvider`, wrapping shanty-tag's client. +pub struct MusicBrainzSearch { + client: MusicBrainzClient, +} + +impl MusicBrainzSearch { + pub fn new() -> SearchResult { + let client = MusicBrainzClient::new() + .map_err(|e| crate::error::SearchError::Provider(e.to_string()))?; + Ok(Self { client }) + } +} + +impl SearchProvider for MusicBrainzSearch { + async fn search_artist( + &self, + query: &str, + limit: u32, + ) -> SearchResult> { + let results = self.client.search_artist(query, limit).await?; + Ok(results + .into_iter() + .map(|a| ArtistResult { + id: a.mbid, + name: a.name, + disambiguation: a.disambiguation, + country: a.country, + artist_type: a.artist_type, + score: a.score, + }) + .collect()) + } + + async fn search_album( + &self, + query: &str, + artist_hint: Option<&str>, + limit: u32, + ) -> SearchResult> { + let artist = artist_hint.unwrap_or(""); + let results = self.client.search_release(artist, query).await?; + Ok(results + .into_iter() + .take(limit as usize) + .map(|r| AlbumResult { + id: r.mbid, + title: r.title, + artist: r.artist, + artist_id: r.artist_mbid, + year: r.date.as_deref().and_then(|d| d.split('-').next()).map(String::from), + track_count: r.track_count, + score: r.score, + }) + .collect()) + } + + async fn search_track( + &self, + query: &str, + artist_hint: Option<&str>, + limit: u32, + ) -> SearchResult> { + let artist = artist_hint.unwrap_or(""); + let results = self.client.search_recording(artist, query).await?; + Ok(results + .into_iter() + .take(limit as usize) + .map(|r| TrackResult { + id: r.mbid, + title: r.title, + artist: r.artist, + artist_id: r.artist_mbid, + album: r.releases.first().map(|rel| rel.title.clone()), + duration_ms: None, // not in search results + score: r.score, + }) + .collect()) + } + + async fn get_discography( + &self, + artist_id: &str, + ) -> SearchResult { + let releases = self.client.get_artist_releases(artist_id, 100).await?; + + // Try to get the artist name from the first release, or use the MBID + let artist_name = if !releases.is_empty() { + // We don't have the artist name from this endpoint directly, + // so do a quick artist search by MBID + let artists = self.client.search_artist(artist_id, 1).await.ok(); + artists + .and_then(|a| a.into_iter().next()) + .map(|a| a.name) + .unwrap_or_else(|| artist_id.to_string()) + } else { + artist_id.to_string() + }; + + Ok(Discography { + artist_name, + artist_id: artist_id.to_string(), + releases: releases + .into_iter() + .map(|r| DiscographyEntry { + id: r.mbid, + title: r.title, + date: r.date, + release_type: r.release_type, + track_count: r.track_count, + }) + .collect(), + }) + } +} diff --git a/src/provider.rs b/src/provider.rs new file mode 100644 index 0000000..08c06a0 --- /dev/null +++ b/src/provider.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::SearchResult; + +/// An artist found by search. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtistResult { + /// Provider-specific ID (e.g., MusicBrainz MBID). + pub id: String, + pub name: String, + pub disambiguation: Option, + pub country: Option, + pub artist_type: Option, + pub score: u8, +} + +/// An album/release found by search. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlbumResult { + pub id: String, + pub title: String, + pub artist: String, + pub artist_id: Option, + pub year: Option, + pub track_count: Option, + pub score: u8, +} + +/// A track/recording found by search. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackResult { + pub id: String, + pub title: String, + pub artist: String, + pub artist_id: Option, + pub album: Option, + pub duration_ms: Option, + pub score: u8, +} + +/// A single entry in an artist's discography. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscographyEntry { + pub id: String, + pub title: String, + pub date: Option, + pub release_type: Option, + pub track_count: Option, +} + +/// An artist's full discography. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Discography { + pub artist_name: String, + pub artist_id: String, + pub releases: Vec, +} + +/// Trait for search backends. MusicBrainz is the first implementation; +/// others (Last.fm, Discogs, Spotify metadata, etc.) can be added later. +pub trait SearchProvider: Send + Sync { + fn search_artist( + &self, + query: &str, + limit: u32, + ) -> impl std::future::Future>> + Send; + + fn search_album( + &self, + query: &str, + artist_hint: Option<&str>, + limit: u32, + ) -> impl std::future::Future>> + Send; + + fn search_track( + &self, + query: &str, + artist_hint: Option<&str>, + limit: u32, + ) -> impl std::future::Future>> + Send; + + fn get_discography( + &self, + artist_id: &str, + ) -> impl std::future::Future> + Send; +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..55acba1 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,188 @@ +use shanty_db::Database; +use shanty_search::cache; +use shanty_search::error::SearchResult; +use shanty_search::provider::*; + +/// Mock search provider for testing. +struct MockSearch; + +impl SearchProvider for MockSearch { + async fn search_artist(&self, query: &str, _limit: u32) -> SearchResult> { + if query.contains("Pink Floyd") { + Ok(vec![ArtistResult { + id: "83d91898-7763-47d7-b03b-b92132375c47".into(), + name: "Pink Floyd".into(), + disambiguation: Some("English rock band".into()), + country: Some("GB".into()), + artist_type: Some("Group".into()), + score: 100, + }]) + } else { + Ok(vec![]) + } + } + + async fn search_album( + &self, + query: &str, + _artist_hint: Option<&str>, + _limit: u32, + ) -> SearchResult> { + if query.contains("Dark Side") { + Ok(vec![AlbumResult { + id: "release-123".into(), + title: "The Dark Side of the Moon".into(), + artist: "Pink Floyd".into(), + artist_id: Some("83d91898".into()), + year: Some("1973".into()), + track_count: Some(10), + score: 100, + }]) + } else { + Ok(vec![]) + } + } + + async fn search_track( + &self, + query: &str, + _artist_hint: Option<&str>, + _limit: u32, + ) -> SearchResult> { + if query.contains("Time") { + Ok(vec![TrackResult { + id: "rec-456".into(), + title: "Time".into(), + artist: "Pink Floyd".into(), + artist_id: Some("83d91898".into()), + album: Some("The Dark Side of the Moon".into()), + duration_ms: Some(413_000), + score: 100, + }]) + } else { + Ok(vec![]) + } + } + + async fn get_discography(&self, _artist_id: &str) -> SearchResult { + Ok(Discography { + artist_name: "Pink Floyd".into(), + artist_id: "83d91898".into(), + releases: vec![ + DiscographyEntry { + id: "r1".into(), + title: "The Dark Side of the Moon".into(), + date: Some("1973-03-01".into()), + release_type: Some("Album".into()), + track_count: Some(10), + }, + DiscographyEntry { + id: "r2".into(), + title: "Wish You Were Here".into(), + date: Some("1975-09-12".into()), + release_type: Some("Album".into()), + track_count: Some(5), + }, + ], + }) + } +} + +#[tokio::test] +async fn test_search_artist() { + let provider = MockSearch; + let results = provider.search_artist("Pink Floyd", 10).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "Pink Floyd"); + assert_eq!(results[0].country.as_deref(), Some("GB")); +} + +#[tokio::test] +async fn test_search_artist_no_results() { + let provider = MockSearch; + let results = provider.search_artist("Nonexistent Band", 10).await.unwrap(); + assert!(results.is_empty()); +} + +#[tokio::test] +async fn test_search_album() { + let provider = MockSearch; + let results = provider.search_album("Dark Side", Some("Pink Floyd"), 10).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "The Dark Side of the Moon"); +} + +#[tokio::test] +async fn test_search_track() { + let provider = MockSearch; + let results = provider.search_track("Time", Some("Pink Floyd"), 10).await.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "Time"); + assert_eq!(results[0].album.as_deref(), Some("The Dark Side of the Moon")); +} + +#[tokio::test] +async fn test_discography() { + let provider = MockSearch; + let disco = provider.get_discography("83d91898").await.unwrap(); + assert_eq!(disco.artist_name, "Pink Floyd"); + assert_eq!(disco.releases.len(), 2); +} + +#[tokio::test] +async fn test_result_serialization() { + let result = ArtistResult { + id: "test-id".into(), + name: "Test Artist".into(), + disambiguation: None, + country: Some("US".into()), + artist_type: Some("Person".into()), + score: 95, + }; + let json = serde_json::to_string(&result).unwrap(); + let back: ArtistResult = serde_json::from_str(&json).unwrap(); + assert_eq!(back.name, "Test Artist"); + assert_eq!(back.score, 95); +} + +#[tokio::test] +async fn test_cache_roundtrip() { + let db = Database::new("sqlite::memory:").await.unwrap(); + let results = vec![ArtistResult { + id: "123".into(), + name: "Cached Artist".into(), + disambiguation: None, + country: None, + artist_type: None, + score: 90, + }]; + + // Cache miss + let cached: Option> = + cache::get_cached(Some(db.conn()), "test:artist:query").await.unwrap(); + assert!(cached.is_none()); + + // Store + cache::set_cached(Some(db.conn()), "test:artist:query", "musicbrainz", &results) + .await + .unwrap(); + + // Cache hit + let cached: Option> = + cache::get_cached(Some(db.conn()), "test:artist:query").await.unwrap(); + assert!(cached.is_some()); + assert_eq!(cached.unwrap()[0].name, "Cached Artist"); +} + +#[tokio::test] +async fn test_cache_none_conn() { + // With no DB connection, caching is a no-op + let cached: Option> = + cache::get_cached(None, "anything").await.unwrap(); + assert!(cached.is_none()); + + // set_cached with None conn should not error + cache::set_cached::>(None, "key", "provider", &vec![]) + .await + .unwrap(); +}