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
|
||||
25
Cargo.toml
Normal file
25
Cargo.toml
Normal 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
21
readme.md
Normal 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
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;
|
||||
}
|
||||
188
tests/integration.rs
Normal file
188
tests/integration.rs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user