Add support for MusicBrainz's Browse API #228

Merged
wojtek merged 9 commits from 160---provide-a-keyboard-shortcut-to-pull-all-release-groups-of-an-artist into main 2024-09-29 21:33:43 +02:00
8 changed files with 104 additions and 85 deletions
Showing only changes of commit e2c103b4d7 - Show all commits

View File

@ -3,7 +3,7 @@ use std::{thread, time};
use musichoard::{
collection::musicbrainz::Mbid,
external::musicbrainz::{
api::{browse::BrowseReleaseGroupRequest, MusicBrainzClient},
api::{browse::BrowseReleaseGroupRequest, MusicBrainzClient, PageSettings},
http::MusicBrainzHttp,
},
};
@ -52,13 +52,14 @@ fn main() {
OptEntity::ReleaseGroup(opt_release_group) => match opt_release_group {
OptReleaseGroup::Artist(opt_mbid) => {
let mbid: Mbid = opt_mbid.mbid.into();
let mut request = BrowseReleaseGroupRequest::artist(&mbid).with_max_limit();
let request = BrowseReleaseGroupRequest::artist(&mbid);
let mut paging = PageSettings::with_max_limit();
let mut response_counts: Vec<usize> = Vec::new();
loop {
let response = client
.browse_release_group(&request)
.browse_release_group(&request, &paging)
.expect("failed to make API call");
for rg in response.release_groups.iter() {
@ -78,7 +79,7 @@ fn main() {
if next_offset == total {
break;
}
request.with_offset(next_offset);
paging.with_offset(next_offset);
thread::sleep(time::Duration::from_secs(1));
}

View File

@ -5,7 +5,7 @@ use musichoard::{
external::musicbrainz::{
api::{
search::{SearchArtistRequest, SearchReleaseGroupRequest},
MusicBrainzClient,
MusicBrainzClient, PageSettings,
},
http::MusicBrainzHttp,
},
@ -106,8 +106,9 @@ fn main() {
println!("Query: {query}");
let paging = PageSettings::default();
let matches = client
.search_artist(&query)
.search_artist(&query, &paging)
.expect("failed to make API call");
println!("{matches:#?}");
@ -138,8 +139,9 @@ fn main() {
println!("Query: {query}");
let paging = PageSettings::default();
let matches = client
.search_release_group(&query)
.search_release_group(&query, &paging)
.expect("failed to make API call");
println!("{matches:#?}");

View File

@ -5,30 +5,27 @@ use serde::Deserialize;
use crate::{
collection::musicbrainz::Mbid,
external::musicbrainz::{
api::{Error, MbReleaseGroupMeta, MusicBrainzClient, SerdeMbReleaseGroupMeta, MB_BASE_URL},
api::{
Error, MbReleaseGroupMeta, MusicBrainzClient, PageSettings, SerdeMbReleaseGroupMeta,
MB_BASE_URL,
},
IMusicBrainzHttp,
},
};
const MB_MAX_BROWSE_LIMIT: usize = 100;
use super::ApiDisplay;
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn browse_release_group(
&mut self,
request: &BrowseReleaseGroupRequest,
paging: &PageSettings,
) -> Result<BrowseReleaseGroupResponse, Error> {
let entity = &request.entity;
let mbid = request.mbid.uuid().as_hyphenated();
let limit = request
.limit
.map(|l| format!("&limit={l}"))
.unwrap_or_default();
let offset = request
.offset
.map(|o| format!("&offset={o}"))
.unwrap_or_default();
let page = ApiDisplay::format_page_settings(paging);
let url = format!("{MB_BASE_URL}/release-group?{entity}={mbid}{limit}{offset}");
let url = format!("{MB_BASE_URL}/release-group?{entity}={mbid}{page}");
let response: DeserializeBrowseReleaseGroupResponse = self.http.get(&url)?;
Ok(response.into())
@ -38,8 +35,6 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub struct BrowseReleaseGroupRequest<'a> {
entity: BrowseReleaseGroupRequestEntity,
mbid: &'a Mbid,
limit: Option<usize>,
offset: Option<usize>,
}
enum BrowseReleaseGroupRequestEntity {
@ -59,25 +54,8 @@ impl<'a> BrowseReleaseGroupRequest<'a> {
BrowseReleaseGroupRequest {
entity: BrowseReleaseGroupRequestEntity::Artist,
mbid,
limit: None,
offset: None,
}
}
#[must_use]
pub const fn with_limit(mut self, limit: usize) -> Self {
self.limit = Some(limit);
self
}
#[must_use]
pub const fn with_max_limit(self) -> Self {
self.with_limit(MB_MAX_BROWSE_LIMIT)
}
pub fn with_offset(&mut self, offset: usize) {
self.offset = Some(offset);
}
}
#[derive(Debug, PartialEq, Eq)]
@ -110,12 +88,15 @@ impl From<DeserializeBrowseReleaseGroupResponse> for BrowseReleaseGroupResponse
#[cfg(test)]
mod tests {
use mockall::{predicate, Sequence};
use mockall::predicate;
use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid},
api::{
SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid,
MB_MAX_PAGE_LIMIT,
},
MockIMusicBrainzHttp,
},
};
@ -150,34 +131,12 @@ mod tests {
release_groups: vec![de_meta.clone().into()],
};
let mut seq = Sequence::new();
let url = format!("https://musicbrainz.org/ws/2/release-group?artist={mbid}",);
let expect_response = de_response.clone();
http.expect_get()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(url))
.return_once(|_| Ok(expect_response));
let url = format!(
"https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit={MB_MAX_BROWSE_LIMIT}",
"https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit={MB_MAX_PAGE_LIMIT}",
);
let expect_response = de_response.clone();
http.expect_get()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(url))
.return_once(|_| Ok(expect_response));
let url = format!(
"https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit={MB_MAX_BROWSE_LIMIT}&offset={offset}",
offset = de_release_group_offset,
);
let expect_response = de_response.clone();
http.expect_get()
.times(1)
.in_sequence(&mut seq)
.with(predicate::eq(url))
.return_once(|_| Ok(expect_response));
@ -186,16 +145,8 @@ mod tests {
let mbid: Mbid = mbid.try_into().unwrap();
let request = BrowseReleaseGroupRequest::artist(&mbid);
let result = client.browse_release_group(&request).unwrap();
assert_eq!(result, response);
let request = request.with_max_limit();
let result = client.browse_release_group(&request).unwrap();
assert_eq!(result, response);
let mut request = request;
request.with_offset(de_release_group_offset);
let result = client.browse_release_group(&request).unwrap();
let paging = PageSettings::with_max_limit();
let result = client.browse_release_group(&request, &paging).unwrap();
assert_eq!(result, response);
}
}

View File

@ -17,6 +17,8 @@ pub mod search;
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
const MB_RATE_LIMIT_CODE: u16 = 503;
const MB_MAX_PAGE_LIMIT: usize = 100;
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
/// The HTTP client failed.
@ -59,6 +61,29 @@ impl<Http> MusicBrainzClient<Http> {
}
}
#[derive(Default)]
pub struct PageSettings {
pub limit: Option<usize>,
pub offset: Option<usize>,
}
impl PageSettings {
pub fn with_limit(limit: usize) -> Self {
PageSettings {
limit: Some(limit),
..Default::default()
}
}
pub fn with_max_limit() -> Self {
Self::with_limit(MB_MAX_PAGE_LIMIT)
}
pub fn with_offset(&mut self, offset: usize) {
self.offset = Some(offset);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbArtistMeta {
pub id: Mbid,
@ -123,6 +148,18 @@ impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
pub struct ApiDisplay;
impl ApiDisplay {
fn format_page_settings(paging: &PageSettings) -> String {
let limit = paging
.limit
.map(|l| format!("&limit={l}"))
.unwrap_or_default();
let offset = paging
.offset
.map(|o| format!("&offset={o}"))
.unwrap_or_default();
format!("{limit}{offset}")
}
fn format_album_date(date: &AlbumDate) -> String {
match date.year {
Some(year) => match date.month {
@ -305,6 +342,26 @@ mod tests {
assert!(!format!("{unk_err:?}").is_empty());
}
#[test]
fn format_page_settings() {
let paging = PageSettings::default();
assert_eq!(ApiDisplay::format_page_settings(&paging), "");
let paging = PageSettings::with_max_limit();
assert_eq!(ApiDisplay::format_page_settings(&paging), "&limit=100");
let mut paging = PageSettings::with_limit(45);
paging.with_offset(145);
assert_eq!(
ApiDisplay::format_page_settings(&paging),
"&limit=45&offset=145"
);
let mut paging = PageSettings::default();
paging.with_offset(26);
assert_eq!(ApiDisplay::format_page_settings(&paging), "&offset=26");
}
#[test]
fn format_album_date() {
assert_eq!(

View File

@ -70,7 +70,7 @@ mod tests {
use mockall::predicate;
use crate::external::musicbrainz::{
api::{MusicBrainzClient, SerdeMbid},
api::{MusicBrainzClient, PageSettings, SerdeMbid},
MockIMusicBrainzHttp,
};
@ -128,7 +128,8 @@ mod tests {
let query = SearchArtistRequest::new().string(name);
let matches = client.search_artist(&query).unwrap();
let paging = PageSettings::default();
let matches = client.search_artist(&query, &paging).unwrap();
assert_eq!(matches, response);
}
}

View File

@ -17,7 +17,7 @@ use crate::external::musicbrainz::{
artist::DeserializeSearchArtistResponse,
release_group::DeserializeSearchReleaseGroupResponse,
},
Error, MusicBrainzClient, MB_BASE_URL,
ApiDisplay, Error, MusicBrainzClient, PageSettings, MB_BASE_URL,
},
IMusicBrainzHttp,
};
@ -27,11 +27,13 @@ macro_rules! impl_search_entity {
paste! {
pub fn [<search_ $name:snake>](
&mut self,
query: &[<Search $name Request>]
query: &[<Search $name Request>],
paging: &PageSettings,
) -> Result<[<Search $name Response>], Error> {
let query: String =
form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect();
let url = format!("{MB_BASE_URL}/{entity}?query={query}", entity = $entity);
let page = ApiDisplay::format_page_settings(paging);
let url = format!("{MB_BASE_URL}/{entity}?query={query}{page}", entity = $entity);
let response: [<DeserializeSearch $name Response>] = self.http.get(&url)?;
Ok(response.into())

View File

@ -99,8 +99,8 @@ mod tests {
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{
api::{
MusicBrainzClient, SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType,
SerdeMbid,
MusicBrainzClient, PageSettings, SerdeAlbumDate, SerdeAlbumPrimaryType,
SerdeAlbumSecondaryType, SerdeMbid,
},
MockIMusicBrainzHttp,
},
@ -161,7 +161,8 @@ mod tests {
let query = SearchReleaseGroupRequest::new().string(title);
let matches = client.search_release_group(&query).unwrap();
let paging = PageSettings::default();
let matches = client.search_release_group(&query, &paging).unwrap();
assert_eq!(matches, response);
}
@ -198,7 +199,8 @@ mod tests {
.and()
.first_release_date(&date);
let matches = client.search_release_group(&query).unwrap();
let paging = PageSettings::default();
let matches = client.search_release_group(&query, &paging).unwrap();
assert_eq!(matches, response);
}
@ -226,7 +228,8 @@ mod tests {
let query = SearchReleaseGroupRequest::new().rgid(&rgid);
let matches = client.search_release_group(&query).unwrap();
let paging = PageSettings::default();
let matches = client.search_release_group(&query, &paging).unwrap();
assert_eq!(matches, response);
}
}

View File

@ -18,7 +18,7 @@ use musichoard::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup,
},
MusicBrainzClient,
MusicBrainzClient, PageSettings,
},
IMusicBrainzHttp,
},
@ -57,7 +57,8 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error> {
let query = SearchArtistRequest::new().string(&artist.id.name);
let mb_response = self.client.search_artist(&query)?;
let paging = PageSettings::with_max_limit();
let mb_response = self.client.search_artist(&query, &paging)?;
Ok(mb_response
.artists
@ -82,7 +83,8 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
.and()
.release_group(&album.id.title);
let mb_response = self.client.search_release_group(&query)?;
let paging = PageSettings::with_max_limit();
let mb_response = self.client.search_release_group(&query, &paging)?;
Ok(mb_response
.release_groups