Initial commit
This commit is contained in:
45
src/cache.rs
Normal file
45
src/cache.rs
Normal 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
24
src/error.rs
Normal 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
15
src/lib.rs
Normal 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
188
src/main.rs
Normal 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
122
src/musicbrainz.rs
Normal 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
86
src/provider.rs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user