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

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