Compare commits

...

4 Commits

Author SHA1 Message Date
Connor Johnstone
b39dd6cc8e Added the playlist generator 2026-03-20 18:09:47 -04:00
Connor Johnstone
cbd0243516 Re-organized providers and added a few 2026-03-20 14:52:16 -04:00
Connor Johnstone
d358b79a6b Formatting 2026-03-18 15:37:21 -04:00
Connor Johnstone
d09557d953 Lots of fixes for track management 2026-03-18 13:43:52 -04:00
7 changed files with 118 additions and 33 deletions

View File

@@ -7,6 +7,7 @@ description = "Online music search for Shanty"
repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/search.git" repository = "ssh://connor@git.rcjohnstone.com:2222/Shanty/search.git"
[dependencies] [dependencies]
shanty-data = { path = "../shanty-data" }
shanty-db = { path = "../shanty-db" } shanty-db = { path = "../shanty-db" }
shanty-tag = { path = "../shanty-tag" } shanty-tag = { path = "../shanty-tag" }
sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] } sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-native-tls"] }

View File

@@ -15,6 +15,12 @@ pub enum SearchError {
Other(String), Other(String),
} }
impl From<shanty_data::DataError> for SearchError {
fn from(e: shanty_data::DataError) -> Self {
SearchError::Provider(e.to_string())
}
}
impl From<shanty_tag::TagError> for SearchError { impl From<shanty_tag::TagError> for SearchError {
fn from(e: shanty_tag::TagError) -> Self { fn from(e: shanty_tag::TagError) -> Self {
SearchError::Provider(e.to_string()) SearchError::Provider(e.to_string())

View File

@@ -11,5 +11,6 @@ pub mod provider;
pub use error::{SearchError, SearchResult}; pub use error::{SearchError, SearchResult};
pub use musicbrainz::MusicBrainzSearch; pub use musicbrainz::MusicBrainzSearch;
pub use provider::{ pub use provider::{
AlbumResult, ArtistResult, Discography, DiscographyEntry, SearchProvider, TrackResult, AlbumResult, ArtistResult, Discography, DiscographyEntry, ReleaseGroupResult, SearchProvider,
TrackResult,
}; };

View File

@@ -82,7 +82,10 @@ async fn main() -> anyhow::Result<()> {
} else if results.is_empty() { } else if results.is_empty() {
println!("No artists found."); println!("No artists found.");
} else { } else {
println!("{:<40} {:<8} {:<5} {}", "NAME", "COUNTRY", "SCORE", "DISAMBIGUATION"); println!(
"{:<40} {:<8} {:<5} DISAMBIGUATION",
"NAME", "COUNTRY", "SCORE"
);
for r in &results { for r in &results {
println!( println!(
"{:<40} {:<8} {:<5} {}", "{:<40} {:<8} {:<5} {}",
@@ -104,7 +107,10 @@ async fn main() -> anyhow::Result<()> {
} else if results.is_empty() { } else if results.is_empty() {
println!("No albums found."); println!("No albums found.");
} else { } else {
println!("{:<35} {:<25} {:<6} {:<5}", "TITLE", "ARTIST", "YEAR", "SCORE"); println!(
"{:<35} {:<25} {:<6} {:<5}",
"TITLE", "ARTIST", "YEAR", "SCORE"
);
for r in &results { for r in &results {
println!( println!(
"{:<35} {:<25} {:<6} {:<5}", "{:<35} {:<25} {:<6} {:<5}",
@@ -126,13 +132,19 @@ async fn main() -> anyhow::Result<()> {
} else if results.is_empty() { } else if results.is_empty() {
println!("No tracks found."); println!("No tracks found.");
} else { } else {
println!("{:<35} {:<25} {:<25} {:<5}", "TITLE", "ARTIST", "ALBUM", "SCORE"); println!(
"{:<35} {:<25} {:<25} {:<5}",
"TITLE", "ARTIST", "ALBUM", "SCORE"
);
for r in &results { for r in &results {
println!( println!(
"{:<35} {:<25} {:<25} {:<5}", "{:<35} {:<25} {:<25} {:<5}",
truncate(&r.title, 35), truncate(&r.title, 35),
truncate(&r.artist, 25), truncate(&r.artist, 25),
r.album.as_deref().map(|a| truncate(a, 25)).unwrap_or_default(), r.album
.as_deref()
.map(|a| truncate(a, 25))
.unwrap_or_default(),
r.score, r.score,
); );
} }

View File

@@ -1,30 +1,34 @@
use shanty_tag::provider::MetadataProvider; use shanty_data::MetadataFetcher;
use shanty_tag::MusicBrainzClient; use shanty_data::MusicBrainzFetcher;
use shanty_data::http::RateLimiter;
use crate::error::SearchResult; use crate::error::SearchResult;
use crate::provider::{ use crate::provider::{
AlbumResult, ArtistResult, Discography, DiscographyEntry, SearchProvider, TrackResult, AlbumResult, ArtistResult, Discography, DiscographyEntry, SearchProvider, TrackResult,
}; };
/// MusicBrainz implementation of `SearchProvider`, wrapping shanty-tag's client. /// MusicBrainz implementation of `SearchProvider`, wrapping shanty-data's fetcher.
pub struct MusicBrainzSearch { pub struct MusicBrainzSearch {
client: MusicBrainzClient, client: MusicBrainzFetcher,
} }
impl MusicBrainzSearch { impl MusicBrainzSearch {
pub fn new() -> SearchResult<Self> { pub fn new() -> SearchResult<Self> {
let client = MusicBrainzClient::new() let client = MusicBrainzFetcher::new()
.map_err(|e| crate::error::SearchError::Provider(e.to_string()))?;
Ok(Self { client })
}
/// Create with a shared rate limiter (to coordinate with other MB clients).
pub fn with_limiter(limiter: RateLimiter) -> SearchResult<Self> {
let client = MusicBrainzFetcher::with_limiter(limiter)
.map_err(|e| crate::error::SearchError::Provider(e.to_string()))?; .map_err(|e| crate::error::SearchError::Provider(e.to_string()))?;
Ok(Self { client }) Ok(Self { client })
} }
} }
impl SearchProvider for MusicBrainzSearch { impl SearchProvider for MusicBrainzSearch {
async fn search_artist( async fn search_artist(&self, query: &str, limit: u32) -> SearchResult<Vec<ArtistResult>> {
&self,
query: &str,
limit: u32,
) -> SearchResult<Vec<ArtistResult>> {
let results = self.client.search_artist(query, limit).await?; let results = self.client.search_artist(query, limit).await?;
Ok(results Ok(results
.into_iter() .into_iter()
@@ -55,7 +59,11 @@ impl SearchProvider for MusicBrainzSearch {
title: r.title, title: r.title,
artist: r.artist, artist: r.artist,
artist_id: r.artist_mbid, artist_id: r.artist_mbid,
year: r.date.as_deref().and_then(|d| d.split('-').next()).map(String::from), year: r
.date
.as_deref()
.and_then(|d| d.split('-').next())
.map(String::from),
track_count: r.track_count, track_count: r.track_count,
score: r.score, score: r.score,
}) })
@@ -85,10 +93,7 @@ impl SearchProvider for MusicBrainzSearch {
.collect()) .collect())
} }
async fn get_discography( async fn get_discography(&self, artist_id: &str) -> SearchResult<Discography> {
&self,
artist_id: &str,
) -> SearchResult<Discography> {
let releases = self.client.get_artist_releases(artist_id, 100).await?; 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 // Try to get the artist name from the first release, or use the MBID
@@ -119,4 +124,22 @@ impl SearchProvider for MusicBrainzSearch {
.collect(), .collect(),
}) })
} }
async fn get_release_groups(
&self,
artist_id: &str,
) -> SearchResult<Vec<crate::provider::ReleaseGroupResult>> {
let groups = self.client.get_artist_release_groups(artist_id).await?;
Ok(groups
.into_iter()
.map(|rg| crate::provider::ReleaseGroupResult {
id: rg.mbid,
title: rg.title,
primary_type: rg.primary_type,
secondary_types: rg.secondary_types,
first_release_date: rg.first_release_date,
first_release_id: rg.first_release_mbid,
})
.collect())
}
} }

View File

@@ -83,4 +83,21 @@ pub trait SearchProvider: Send + Sync {
&self, &self,
artist_id: &str, artist_id: &str,
) -> impl std::future::Future<Output = SearchResult<Discography>> + Send; ) -> impl std::future::Future<Output = SearchResult<Discography>> + Send;
/// Get deduplicated release groups (albums, EPs, singles) for an artist.
fn get_release_groups(
&self,
artist_id: &str,
) -> impl std::future::Future<Output = SearchResult<Vec<ReleaseGroupResult>>> + Send;
}
/// A deduplicated release group (album/EP/single concept).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseGroupResult {
pub id: String,
pub title: String,
pub primary_type: Option<String>,
pub secondary_types: Vec<String>,
pub first_release_date: Option<String>,
pub first_release_id: Option<String>,
} }

View File

@@ -86,6 +86,13 @@ impl SearchProvider for MockSearch {
], ],
}) })
} }
async fn get_release_groups(
&self,
_artist_id: &str,
) -> SearchResult<Vec<shanty_search::ReleaseGroupResult>> {
Ok(vec![])
}
} }
#[tokio::test] #[tokio::test]
@@ -100,14 +107,20 @@ async fn test_search_artist() {
#[tokio::test] #[tokio::test]
async fn test_search_artist_no_results() { async fn test_search_artist_no_results() {
let provider = MockSearch; let provider = MockSearch;
let results = provider.search_artist("Nonexistent Band", 10).await.unwrap(); let results = provider
.search_artist("Nonexistent Band", 10)
.await
.unwrap();
assert!(results.is_empty()); assert!(results.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn test_search_album() { async fn test_search_album() {
let provider = MockSearch; let provider = MockSearch;
let results = provider.search_album("Dark Side", Some("Pink Floyd"), 10).await.unwrap(); let results = provider
.search_album("Dark Side", Some("Pink Floyd"), 10)
.await
.unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "The Dark Side of the Moon"); assert_eq!(results[0].title, "The Dark Side of the Moon");
} }
@@ -115,10 +128,16 @@ async fn test_search_album() {
#[tokio::test] #[tokio::test]
async fn test_search_track() { async fn test_search_track() {
let provider = MockSearch; let provider = MockSearch;
let results = provider.search_track("Time", Some("Pink Floyd"), 10).await.unwrap(); let results = provider
.search_track("Time", Some("Pink Floyd"), 10)
.await
.unwrap();
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Time"); assert_eq!(results[0].title, "Time");
assert_eq!(results[0].album.as_deref(), Some("The Dark Side of the Moon")); assert_eq!(
results[0].album.as_deref(),
Some("The Dark Side of the Moon")
);
} }
#[tokio::test] #[tokio::test]
@@ -158,18 +177,25 @@ async fn test_cache_roundtrip() {
}]; }];
// Cache miss // Cache miss
let cached: Option<Vec<ArtistResult>> = let cached: Option<Vec<ArtistResult>> = cache::get_cached(Some(db.conn()), "test:artist:query")
cache::get_cached(Some(db.conn()), "test:artist:query").await.unwrap(); .await
.unwrap();
assert!(cached.is_none()); assert!(cached.is_none());
// Store // Store
cache::set_cached(Some(db.conn()), "test:artist:query", "musicbrainz", &results) cache::set_cached(
Some(db.conn()),
"test:artist:query",
"musicbrainz",
&results,
)
.await .await
.unwrap(); .unwrap();
// Cache hit // Cache hit
let cached: Option<Vec<ArtistResult>> = let cached: Option<Vec<ArtistResult>> = cache::get_cached(Some(db.conn()), "test:artist:query")
cache::get_cached(Some(db.conn()), "test:artist:query").await.unwrap(); .await
.unwrap();
assert!(cached.is_some()); assert!(cached.is_some());
assert_eq!(cached.unwrap()[0].name, "Cached Artist"); assert_eq!(cached.unwrap()[0].name, "Cached Artist");
} }
@@ -177,8 +203,7 @@ async fn test_cache_roundtrip() {
#[tokio::test] #[tokio::test]
async fn test_cache_none_conn() { async fn test_cache_none_conn() {
// With no DB connection, caching is a no-op // With no DB connection, caching is a no-op
let cached: Option<Vec<ArtistResult>> = let cached: Option<Vec<ArtistResult>> = cache::get_cached(None, "anything").await.unwrap();
cache::get_cached(None, "anything").await.unwrap();
assert!(cached.is_none()); assert!(cached.is_none());
// set_cached with None conn should not error // set_cached with None conn should not error