Added the playlist generator
All checks were successful
CI / check (push) Successful in 1m12s
CI / docker (push) Successful in 2m1s

This commit is contained in:
Connor Johnstone
2026-03-20 18:09:47 -04:00
parent 4008b4d838
commit 6f73bb87ce
19 changed files with 1526 additions and 21 deletions

View File

@@ -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,
}
}

View File

@@ -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());

View File

@@ -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::*;

View File

@@ -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 {

View File

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

View File

@@ -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 {