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

View File

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

View File

@ -5,30 +5,27 @@ use serde::Deserialize;
use crate::{ use crate::{
collection::musicbrainz::Mbid, collection::musicbrainz::Mbid,
external::musicbrainz::{ external::musicbrainz::{
api::{Error, MbReleaseGroupMeta, MusicBrainzClient, SerdeMbReleaseGroupMeta, MB_BASE_URL}, api::{
Error, MbReleaseGroupMeta, MusicBrainzClient, PageSettings, SerdeMbReleaseGroupMeta,
MB_BASE_URL,
},
IMusicBrainzHttp, IMusicBrainzHttp,
}, },
}; };
const MB_MAX_BROWSE_LIMIT: usize = 100; use super::ApiDisplay;
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> { impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub fn browse_release_group( pub fn browse_release_group(
&mut self, &mut self,
request: &BrowseReleaseGroupRequest, request: &BrowseReleaseGroupRequest,
paging: &PageSettings,
) -> Result<BrowseReleaseGroupResponse, Error> { ) -> Result<BrowseReleaseGroupResponse, Error> {
let entity = &request.entity; let entity = &request.entity;
let mbid = request.mbid.uuid().as_hyphenated(); let mbid = request.mbid.uuid().as_hyphenated();
let limit = request let page = ApiDisplay::format_page_settings(paging);
.limit
.map(|l| format!("&limit={l}"))
.unwrap_or_default();
let offset = request
.offset
.map(|o| format!("&offset={o}"))
.unwrap_or_default();
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)?; let response: DeserializeBrowseReleaseGroupResponse = self.http.get(&url)?;
Ok(response.into()) Ok(response.into())
@ -38,8 +35,6 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
pub struct BrowseReleaseGroupRequest<'a> { pub struct BrowseReleaseGroupRequest<'a> {
entity: BrowseReleaseGroupRequestEntity, entity: BrowseReleaseGroupRequestEntity,
mbid: &'a Mbid, mbid: &'a Mbid,
limit: Option<usize>,
offset: Option<usize>,
} }
enum BrowseReleaseGroupRequestEntity { enum BrowseReleaseGroupRequestEntity {
@ -59,25 +54,8 @@ impl<'a> BrowseReleaseGroupRequest<'a> {
BrowseReleaseGroupRequest { BrowseReleaseGroupRequest {
entity: BrowseReleaseGroupRequestEntity::Artist, entity: BrowseReleaseGroupRequestEntity::Artist,
mbid, 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)] #[derive(Debug, PartialEq, Eq)]
@ -110,12 +88,15 @@ impl From<DeserializeBrowseReleaseGroupResponse> for BrowseReleaseGroupResponse
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use mockall::{predicate, Sequence}; use mockall::predicate;
use crate::{ use crate::{
collection::album::{AlbumPrimaryType, AlbumSecondaryType}, collection::album::{AlbumPrimaryType, AlbumSecondaryType},
external::musicbrainz::{ external::musicbrainz::{
api::{SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid}, api::{
SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid,
MB_MAX_PAGE_LIMIT,
},
MockIMusicBrainzHttp, MockIMusicBrainzHttp,
}, },
}; };
@ -150,34 +131,12 @@ mod tests {
release_groups: vec![de_meta.clone().into()], 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!( 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(); let expect_response = de_response.clone();
http.expect_get() http.expect_get()
.times(1) .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)) .with(predicate::eq(url))
.return_once(|_| Ok(expect_response)); .return_once(|_| Ok(expect_response));
@ -186,16 +145,8 @@ mod tests {
let mbid: Mbid = mbid.try_into().unwrap(); let mbid: Mbid = mbid.try_into().unwrap();
let request = BrowseReleaseGroupRequest::artist(&mbid); let request = BrowseReleaseGroupRequest::artist(&mbid);
let result = client.browse_release_group(&request).unwrap(); let paging = PageSettings::with_max_limit();
assert_eq!(result, response); let result = client.browse_release_group(&request, &paging).unwrap();
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();
assert_eq!(result, response); 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_BASE_URL: &str = "https://musicbrainz.org/ws/2";
const MB_RATE_LIMIT_CODE: u16 = 503; const MB_RATE_LIMIT_CODE: u16 = 503;
const MB_MAX_PAGE_LIMIT: usize = 100;
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum Error { pub enum Error {
/// The HTTP client failed. /// 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct MbArtistMeta { pub struct MbArtistMeta {
pub id: Mbid, pub id: Mbid,
@ -123,6 +148,18 @@ impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
pub struct ApiDisplay; pub struct ApiDisplay;
impl 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 { fn format_album_date(date: &AlbumDate) -> String {
match date.year { match date.year {
Some(year) => match date.month { Some(year) => match date.month {
@ -305,6 +342,26 @@ mod tests {
assert!(!format!("{unk_err:?}").is_empty()); 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] #[test]
fn format_album_date() { fn format_album_date() {
assert_eq!( assert_eq!(

View File

@ -70,7 +70,7 @@ mod tests {
use mockall::predicate; use mockall::predicate;
use crate::external::musicbrainz::{ use crate::external::musicbrainz::{
api::{MusicBrainzClient, SerdeMbid}, api::{MusicBrainzClient, PageSettings, SerdeMbid},
MockIMusicBrainzHttp, MockIMusicBrainzHttp,
}; };
@ -128,7 +128,8 @@ mod tests {
let query = SearchArtistRequest::new().string(name); 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); assert_eq!(matches, response);
} }
} }

View File

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

View File

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

View File

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