Added the playlist generator
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// A simple rate limiter that enforces a minimum interval between requests.
|
||||
/// Can be cloned (via Arc) to share across multiple clients.
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
last_request: Mutex<Instant>,
|
||||
last_request: Arc<Mutex<Instant>>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(interval: Duration) -> Self {
|
||||
Self {
|
||||
last_request: Mutex::new(Instant::now() - interval),
|
||||
last_request: Arc::new(Mutex::new(Instant::now() - interval)),
|
||||
interval,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::error::DataResult;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{DataError, DataResult};
|
||||
use crate::http::{build_client, urlencoded};
|
||||
use crate::traits::ArtistBioFetcher;
|
||||
use crate::types::ArtistInfo;
|
||||
use crate::traits::{ArtistBioFetcher, SimilarArtistFetcher};
|
||||
use crate::types::{ArtistInfo, PopularTrack, SimilarArtist};
|
||||
|
||||
const USER_AGENT: &str = "Shanty/0.1.0 (shanty-music-app)";
|
||||
|
||||
@@ -51,6 +53,211 @@ impl ArtistBioFetcher for LastFmBioFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Similar artist fetcher (ported from drift) ---
|
||||
|
||||
const BASE_URL: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||
|
||||
/// Fetches similar artists and top tracks from Last.fm.
|
||||
pub struct LastFmSimilarFetcher {
|
||||
api_key: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl LastFmSimilarFetcher {
|
||||
pub fn new(api_key: String) -> DataResult<Self> {
|
||||
let client = build_client(USER_AGENT, 30)?;
|
||||
Ok(Self { api_key, client })
|
||||
}
|
||||
|
||||
/// Normalize Unicode hyphens/quotes to ASCII, then URL-encode.
|
||||
fn normalize_name(name: &str) -> String {
|
||||
name.replace(
|
||||
[
|
||||
'\u{2010}', '\u{2011}', '\u{2012}', '\u{2013}', '\u{2014}', '\u{2015}',
|
||||
],
|
||||
"-",
|
||||
)
|
||||
.replace(['\u{2018}', '\u{2019}'], "'")
|
||||
}
|
||||
|
||||
/// Fetch a URL and return the body, or None if Last.fm returns an API error.
|
||||
async fn fetch_or_none(&self, url: &str) -> DataResult<Option<String>> {
|
||||
let resp = match self.client.get(url).send().await {
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
tracing::debug!(status = %r.status(), url = url, "Last.fm non-success");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(DataError::Http(e)),
|
||||
};
|
||||
let body = resp.text().await?;
|
||||
// Check for Last.fm error response
|
||||
if serde_json::from_str::<LfmApiError>(&body).is_ok() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
/// Fetch by artist name.
|
||||
async fn fetch_by_name(
|
||||
&self,
|
||||
method: &str,
|
||||
artist_name: &str,
|
||||
extra_params: &str,
|
||||
) -> DataResult<Option<String>> {
|
||||
let name = Self::normalize_name(artist_name);
|
||||
let encoded = urlencoded(&name);
|
||||
let url = format!(
|
||||
"{}?method={}&artist={}&api_key={}{}&format=json",
|
||||
BASE_URL, method, encoded, self.api_key, extra_params
|
||||
);
|
||||
self.fetch_or_none(&url).await
|
||||
}
|
||||
|
||||
/// Try MBID lookup then name lookup, returning whichever yields more results.
|
||||
async fn dual_lookup<T>(
|
||||
&self,
|
||||
method: &str,
|
||||
artist_name: &str,
|
||||
mbid: Option<&str>,
|
||||
extra_params: &str,
|
||||
parse: fn(&str) -> DataResult<Vec<T>>,
|
||||
) -> DataResult<Vec<T>> {
|
||||
let mbid_results = if let Some(mbid) = mbid {
|
||||
let url = format!(
|
||||
"{}?method={}&mbid={}&api_key={}{}&format=json",
|
||||
BASE_URL, method, mbid, self.api_key, extra_params
|
||||
);
|
||||
match self.fetch_or_none(&url).await? {
|
||||
Some(body) => parse(&body).unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let name_results = match self
|
||||
.fetch_by_name(method, artist_name, extra_params)
|
||||
.await?
|
||||
{
|
||||
Some(body) => parse(&body).unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
if name_results.len() > mbid_results.len() {
|
||||
Ok(name_results)
|
||||
} else {
|
||||
Ok(mbid_results)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_similar_artists(body: &str) -> DataResult<Vec<SimilarArtist>> {
|
||||
let resp: LfmSimilarArtistsResponse = serde_json::from_str(body)?;
|
||||
Ok(resp
|
||||
.similarartists
|
||||
.artist
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let mbid = a.mbid.filter(|s| !s.is_empty());
|
||||
SimilarArtist {
|
||||
name: a.name,
|
||||
mbid,
|
||||
match_score: a.match_score.parse().unwrap_or(0.0),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_top_tracks(body: &str) -> DataResult<Vec<PopularTrack>> {
|
||||
let resp: LfmTopTracksResponse = serde_json::from_str(body)?;
|
||||
Ok(resp
|
||||
.toptracks
|
||||
.track
|
||||
.into_iter()
|
||||
.map(|t| PopularTrack {
|
||||
name: t.name,
|
||||
mbid: t.mbid.filter(|s| !s.is_empty()),
|
||||
playcount: t.playcount.parse().unwrap_or(0),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl SimilarArtistFetcher for LastFmSimilarFetcher {
|
||||
async fn get_similar_artists(
|
||||
&self,
|
||||
artist_name: &str,
|
||||
mbid: Option<&str>,
|
||||
) -> DataResult<Vec<SimilarArtist>> {
|
||||
self.dual_lookup(
|
||||
"artist.getSimilar",
|
||||
artist_name,
|
||||
mbid,
|
||||
"&limit=500",
|
||||
Self::parse_similar_artists,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_top_tracks(
|
||||
&self,
|
||||
artist_name: &str,
|
||||
mbid: Option<&str>,
|
||||
) -> DataResult<Vec<PopularTrack>> {
|
||||
self.dual_lookup(
|
||||
"artist.getTopTracks",
|
||||
artist_name,
|
||||
mbid,
|
||||
"&limit=1000",
|
||||
Self::parse_top_tracks,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
// Last.fm JSON response structs
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmApiError {
|
||||
#[allow(dead_code)]
|
||||
error: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmSimilarArtistsResponse {
|
||||
similarartists: LfmSimilarArtistsWrapper,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmSimilarArtistsWrapper {
|
||||
artist: Vec<LfmArtistEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmArtistEntry {
|
||||
name: String,
|
||||
mbid: Option<String>,
|
||||
#[serde(rename = "match")]
|
||||
match_score: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmTopTracksResponse {
|
||||
toptracks: LfmTopTracksWrapper,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmTopTracksWrapper {
|
||||
track: Vec<LfmTrackEntry>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LfmTrackEntry {
|
||||
name: String,
|
||||
mbid: Option<String>,
|
||||
playcount: String,
|
||||
}
|
||||
|
||||
/// Strip HTML tags from a string with a simple approach.
|
||||
fn strip_html_tags(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod wikipedia;
|
||||
pub use coverart::CoverArtArchiveFetcher;
|
||||
pub use error::{DataError, DataResult};
|
||||
pub use fanarttv::FanartTvFetcher;
|
||||
pub use lastfm::LastFmBioFetcher;
|
||||
pub use lastfm::{LastFmBioFetcher, LastFmSimilarFetcher};
|
||||
pub use lrclib::LrclibFetcher;
|
||||
pub use musicbrainz::MusicBrainzFetcher;
|
||||
pub use traits::*;
|
||||
|
||||
@@ -21,14 +21,21 @@ pub struct MusicBrainzFetcher {
|
||||
|
||||
impl MusicBrainzFetcher {
|
||||
pub fn new() -> DataResult<Self> {
|
||||
Self::with_limiter(RateLimiter::new(RATE_LIMIT))
|
||||
}
|
||||
|
||||
/// Create a fetcher that shares a rate limiter with other MB clients.
|
||||
pub fn with_limiter(limiter: RateLimiter) -> DataResult<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
limiter: RateLimiter::new(RATE_LIMIT),
|
||||
})
|
||||
Ok(Self { client, limiter })
|
||||
}
|
||||
|
||||
/// Get a clone of the rate limiter for sharing with other MB clients.
|
||||
pub fn limiter(&self) -> RateLimiter {
|
||||
self.limiter.clone()
|
||||
}
|
||||
|
||||
async fn get_json<T: serde::de::DeserializeOwned>(&self, url: &str) -> DataResult<T> {
|
||||
@@ -84,6 +91,24 @@ impl MusicBrainzFetcher {
|
||||
urls,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a release-group MBID to a release MBID (first release in the group).
|
||||
pub async fn resolve_release_from_group(&self, release_group_mbid: &str) -> DataResult<String> {
|
||||
let url = format!("{BASE_URL}/release?release-group={release_group_mbid}&fmt=json&limit=1");
|
||||
let resp: serde_json::Value = self.get_json(&url).await?;
|
||||
|
||||
resp.get("releases")
|
||||
.and_then(|r| r.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|r| r.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
DataError::Other(format!(
|
||||
"no releases for release-group {release_group_mbid}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MetadataFetcher for MusicBrainzFetcher {
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::future::Future;
|
||||
|
||||
use crate::error::DataResult;
|
||||
use crate::types::{
|
||||
ArtistInfo, ArtistSearchResult, DiscographyEntry, LyricsResult, RecordingDetails,
|
||||
RecordingMatch, ReleaseGroupEntry, ReleaseMatch, ReleaseTrack,
|
||||
ArtistInfo, ArtistSearchResult, DiscographyEntry, LyricsResult, PopularTrack, RecordingDetails,
|
||||
RecordingMatch, ReleaseGroupEntry, ReleaseMatch, ReleaseTrack, SimilarArtist,
|
||||
};
|
||||
|
||||
/// Trait for metadata lookup backends. MusicBrainz is the default implementation;
|
||||
@@ -86,6 +86,21 @@ pub trait LyricsFetcher: Send + Sync {
|
||||
) -> impl Future<Output = DataResult<LyricsResult>> + Send;
|
||||
}
|
||||
|
||||
/// Fetches similar artists and top tracks from an external source (e.g. Last.fm).
|
||||
pub trait SimilarArtistFetcher: Send + Sync {
|
||||
fn get_similar_artists(
|
||||
&self,
|
||||
artist_name: &str,
|
||||
mbid: Option<&str>,
|
||||
) -> impl Future<Output = DataResult<Vec<SimilarArtist>>> + Send;
|
||||
|
||||
fn get_top_tracks(
|
||||
&self,
|
||||
artist_name: &str,
|
||||
mbid: Option<&str>,
|
||||
) -> impl Future<Output = DataResult<Vec<PopularTrack>>> + Send;
|
||||
}
|
||||
|
||||
/// Fetches cover art URLs for releases.
|
||||
pub trait CoverArtFetcher: Send + Sync {
|
||||
fn get_cover_art_url(&self, release_id: &str) -> Option<String>;
|
||||
|
||||
@@ -111,6 +111,22 @@ pub struct ReleaseTrack {
|
||||
pub duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// A similar artist returned by Last.fm or another provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SimilarArtist {
|
||||
pub name: String,
|
||||
pub mbid: Option<String>,
|
||||
pub match_score: f64,
|
||||
}
|
||||
|
||||
/// A popular/top track for an artist from Last.fm.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PopularTrack {
|
||||
pub name: String,
|
||||
pub mbid: Option<String>,
|
||||
pub playcount: u64,
|
||||
}
|
||||
|
||||
/// Result from a lyrics lookup.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LyricsResult {
|
||||
|
||||
Reference in New Issue
Block a user