Initial commit

This commit is contained in:
Connor Johnstone
2026-03-17 19:00:43 -04:00
commit 208dbf422b
10 changed files with 718 additions and 0 deletions

4
.gitignore vendored Normal file
View File

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

25
Cargo.toml Normal file
View File

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

21
readme.md Normal file
View File

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

45
src/cache.rs Normal file
View File

@@ -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<T: serde::de::DeserializeOwned>(
conn: Option<&DatabaseConnection>,
key: &str,
) -> SearchResult<Option<T>> {
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<T: serde::Serialize>(
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(())
}

24
src/error.rs Normal file
View File

@@ -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<shanty_tag::TagError> for SearchError {
fn from(e: shanty_tag::TagError) -> Self {
SearchError::Provider(e.to_string())
}
}
pub type SearchResult<T> = Result<T, SearchError>;

15
src/lib.rs Normal file
View File

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

188
src/main.rs Normal file
View File

@@ -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<String>,
/// 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<String>,
},
/// Search for a track.
Track {
/// Track title to search for.
query: String,
/// Filter by artist name.
#[arg(long)]
artist: Option<String>,
},
/// 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])
}
}

122
src/musicbrainz.rs Normal file
View File

@@ -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<Self> {
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<Vec<ArtistResult>> {
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<Vec<AlbumResult>> {
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<Vec<TrackResult>> {
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<Discography> {
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(),
})
}
}

86
src/provider.rs Normal file
View File

@@ -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<String>,
pub country: Option<String>,
pub artist_type: Option<String>,
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<String>,
pub year: Option<String>,
pub track_count: Option<i32>,
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<String>,
pub album: Option<String>,
pub duration_ms: Option<u64>,
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<String>,
pub release_type: Option<String>,
pub track_count: Option<i32>,
}
/// An artist's full discography.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Discography {
pub artist_name: String,
pub artist_id: String,
pub releases: Vec<DiscographyEntry>,
}
/// 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<Output = SearchResult<Vec<ArtistResult>>> + Send;
fn search_album(
&self,
query: &str,
artist_hint: Option<&str>,
limit: u32,
) -> impl std::future::Future<Output = SearchResult<Vec<AlbumResult>>> + Send;
fn search_track(
&self,
query: &str,
artist_hint: Option<&str>,
limit: u32,
) -> impl std::future::Future<Output = SearchResult<Vec<TrackResult>>> + Send;
fn get_discography(
&self,
artist_id: &str,
) -> impl std::future::Future<Output = SearchResult<Discography>> + Send;
}

188
tests/integration.rs Normal file
View File

@@ -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<Vec<ArtistResult>> {
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<Vec<AlbumResult>> {
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<Vec<TrackResult>> {
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<Discography> {
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<Vec<ArtistResult>> =
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<Vec<ArtistResult>> =
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<Vec<ArtistResult>> =
cache::get_cached(None, "anything").await.unwrap();
assert!(cached.is_none());
// set_cached with None conn should not error
cache::set_cached::<Vec<ArtistResult>>(None, "key", "provider", &vec![])
.await
.unwrap();
}