Add support for MusicBrainz's Browse API #228
@ -47,6 +47,11 @@ required-features = ["bin", "database-json", "library-beets", "library-beets-ssh
|
|||||||
name = "musichoard-edit"
|
name = "musichoard-edit"
|
||||||
required-features = ["bin", "database-json"]
|
required-features = ["bin", "database-json"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "musicbrainz-api---browse"
|
||||||
|
path = "examples/musicbrainz_api/browse.rs"
|
||||||
|
required-features = ["bin", "musicbrainz"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "musicbrainz-api---lookup"
|
name = "musicbrainz-api---lookup"
|
||||||
path = "examples/musicbrainz_api/lookup.rs"
|
path = "examples/musicbrainz_api/lookup.rs"
|
||||||
|
98
examples/musicbrainz_api/browse.rs
Normal file
98
examples/musicbrainz_api/browse.rs
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
use std::{thread, time};
|
||||||
|
|
||||||
|
use musichoard::{
|
||||||
|
collection::musicbrainz::Mbid,
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{browse::BrowseReleaseGroupRequest, MusicBrainzClient, NextPage, PageSettings},
|
||||||
|
http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use structopt::StructOpt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!(
|
||||||
|
"MusicHoard---examples---musicbrainz-api---browse/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" ( musichoard@thenineworlds.net )"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct Opt {
|
||||||
|
#[structopt(subcommand)]
|
||||||
|
entity: OptEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptEntity {
|
||||||
|
#[structopt(about = "Browse release groups")]
|
||||||
|
ReleaseGroup(OptReleaseGroup),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptReleaseGroup {
|
||||||
|
#[structopt(about = "Browse release groups of an artist")]
|
||||||
|
Artist(OptMbid),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptMbid {
|
||||||
|
#[structopt(help = "MBID of the entity")]
|
||||||
|
mbid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let opt = Opt::from_args();
|
||||||
|
|
||||||
|
println!("USER_AGENT: {USER_AGENT}");
|
||||||
|
|
||||||
|
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
match opt.entity {
|
||||||
|
OptEntity::ReleaseGroup(opt_release_group) => match opt_release_group {
|
||||||
|
OptReleaseGroup::Artist(opt_mbid) => {
|
||||||
|
let mbid: Mbid = opt_mbid.mbid.into();
|
||||||
|
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, &paging)
|
||||||
|
.expect("failed to make API call");
|
||||||
|
|
||||||
|
for rg in response.release_groups.iter() {
|
||||||
|
println!("{rg:?}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = response.page.release_group_offset;
|
||||||
|
let count = response.release_groups.len();
|
||||||
|
response_counts.push(count);
|
||||||
|
let total = response.page.release_group_count;
|
||||||
|
|
||||||
|
println!("Release group offset : {offset}");
|
||||||
|
println!("Release groups in this response: {count}");
|
||||||
|
println!("Release groups in total : {total}");
|
||||||
|
|
||||||
|
match response.page.next_page_offset(count) {
|
||||||
|
NextPage::Offset(next_offset) => paging.with_offset(next_offset),
|
||||||
|
NextPage::Complete => break,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Total: {}={} release groups",
|
||||||
|
response_counts
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("+"),
|
||||||
|
response_counts.iter().sum::<usize>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,3 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
collection::musicbrainz::Mbid,
|
collection::musicbrainz::Mbid,
|
||||||
external::musicbrainz::{
|
external::musicbrainz::{
|
||||||
@ -64,7 +62,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.lookup_artist(request)
|
.lookup_artist(&request)
|
||||||
.expect("failed to make API call");
|
.expect("failed to make API call");
|
||||||
|
|
||||||
println!("{response:#?}");
|
println!("{response:#?}");
|
||||||
@ -74,7 +72,7 @@ fn main() {
|
|||||||
let request = LookupReleaseGroupRequest::new(&mbid);
|
let request = LookupReleaseGroupRequest::new(&mbid);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.lookup_release_group(request)
|
.lookup_release_group(&request)
|
||||||
.expect("failed to make API call");
|
.expect("failed to make API call");
|
||||||
|
|
||||||
println!("{response:#?}");
|
println!("{response:#?}");
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use std::{num::ParseIntError, str::FromStr};
|
use std::{num::ParseIntError, str::FromStr};
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
@ -7,7 +5,7 @@ use musichoard::{
|
|||||||
external::musicbrainz::{
|
external::musicbrainz::{
|
||||||
api::{
|
api::{
|
||||||
search::{SearchArtistRequest, SearchReleaseGroupRequest},
|
search::{SearchArtistRequest, SearchReleaseGroupRequest},
|
||||||
MusicBrainzClient,
|
MusicBrainzClient, PageSettings,
|
||||||
},
|
},
|
||||||
http::MusicBrainzHttp,
|
http::MusicBrainzHttp,
|
||||||
},
|
},
|
||||||
@ -108,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:#?}");
|
||||||
@ -140,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:#?}");
|
||||||
|
178
src/external/musicbrainz/api/browse.rs
vendored
Normal file
178
src/external/musicbrainz/api/browse.rs
vendored
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::musicbrainz::Mbid,
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
ApiDisplay, Error, MbReleaseGroupMeta, MusicBrainzClient, NextPage, PageSettings,
|
||||||
|
SerdeMbReleaseGroupMeta, MB_BASE_URL,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct BrowseReleaseGroupPage {
|
||||||
|
pub release_group_offset: usize,
|
||||||
|
pub release_group_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowseReleaseGroupPage {
|
||||||
|
pub fn next_page_offset(&self, page_count: usize) -> NextPage {
|
||||||
|
NextPage::next_page_offset(
|
||||||
|
self.release_group_offset,
|
||||||
|
self.release_group_count,
|
||||||
|
page_count,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SerdeBrowseReleaseGroupPage = BrowseReleaseGroupPage;
|
||||||
|
|
||||||
|
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 page = ApiDisplay::format_page_settings(paging);
|
||||||
|
|
||||||
|
let url = format!("{MB_BASE_URL}/release-group?{entity}={mbid}{page}");
|
||||||
|
|
||||||
|
let response: DeserializeBrowseReleaseGroupResponse = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BrowseReleaseGroupRequest<'a> {
|
||||||
|
entity: BrowseReleaseGroupRequestEntity,
|
||||||
|
mbid: &'a Mbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BrowseReleaseGroupRequestEntity {
|
||||||
|
Artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BrowseReleaseGroupRequestEntity {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
BrowseReleaseGroupRequestEntity::Artist => write!(f, "artist"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BrowseReleaseGroupRequest<'a> {
|
||||||
|
pub fn artist(mbid: &'a Mbid) -> Self {
|
||||||
|
BrowseReleaseGroupRequest {
|
||||||
|
entity: BrowseReleaseGroupRequestEntity::Artist,
|
||||||
|
mbid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct BrowseReleaseGroupResponse {
|
||||||
|
pub release_groups: Vec<MbReleaseGroupMeta>,
|
||||||
|
pub page: BrowseReleaseGroupPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeBrowseReleaseGroupResponse {
|
||||||
|
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
page: SerdeBrowseReleaseGroupPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeBrowseReleaseGroupResponse> for BrowseReleaseGroupResponse {
|
||||||
|
fn from(value: DeserializeBrowseReleaseGroupResponse) -> Self {
|
||||||
|
BrowseReleaseGroupResponse {
|
||||||
|
page: value.page,
|
||||||
|
release_groups: value
|
||||||
|
.release_groups
|
||||||
|
.map(|rgs| rgs.into_iter().map(Into::into).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::predicate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
tests::next_page_test, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||||
|
SerdeAlbumSecondaryType, SerdeMbid, MB_MAX_PAGE_LIMIT,
|
||||||
|
},
|
||||||
|
MockIMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_release_group_next_page() {
|
||||||
|
let page = BrowseReleaseGroupPage {
|
||||||
|
release_group_offset: 5,
|
||||||
|
release_group_count: 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
next_page_test(|val| page.next_page_offset(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_release_group() {
|
||||||
|
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
|
||||||
|
let de_release_group_offset = 24;
|
||||||
|
let de_release_group_count = 302;
|
||||||
|
let de_meta = SerdeMbReleaseGroupMeta {
|
||||||
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
|
title: String::from("an album"),
|
||||||
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||||
|
AlbumSecondaryType::Compilation,
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
let de_response = DeserializeBrowseReleaseGroupResponse {
|
||||||
|
page: SerdeBrowseReleaseGroupPage {
|
||||||
|
release_group_offset: de_release_group_offset,
|
||||||
|
release_group_count: de_release_group_count,
|
||||||
|
},
|
||||||
|
release_groups: Some(vec![de_meta.clone()]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = BrowseReleaseGroupResponse {
|
||||||
|
page: de_response.page,
|
||||||
|
release_groups: vec![de_meta.clone().into()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"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)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(expect_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let mbid: Mbid = mbid.try_into().unwrap();
|
||||||
|
|
||||||
|
let request = BrowseReleaseGroupRequest::artist(&mbid);
|
||||||
|
let paging = PageSettings::with_max_limit();
|
||||||
|
let result = client.browse_release_group(&request, &paging).unwrap();
|
||||||
|
assert_eq!(result, response);
|
||||||
|
}
|
||||||
|
}
|
12
src/external/musicbrainz/api/lookup.rs
vendored
12
src/external/musicbrainz/api/lookup.rs
vendored
@ -14,7 +14,7 @@ use super::{MbArtistMeta, MbReleaseGroupMeta, SerdeMbArtistMeta, SerdeMbReleaseG
|
|||||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||||
pub fn lookup_artist(
|
pub fn lookup_artist(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: LookupArtistRequest,
|
request: &LookupArtistRequest,
|
||||||
) -> Result<LookupArtistResponse, Error> {
|
) -> Result<LookupArtistResponse, Error> {
|
||||||
let mut include: Vec<String> = vec![];
|
let mut include: Vec<String> = vec![];
|
||||||
|
|
||||||
@ -35,7 +35,7 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
|||||||
|
|
||||||
pub fn lookup_release_group(
|
pub fn lookup_release_group(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: LookupReleaseGroupRequest,
|
request: &LookupReleaseGroupRequest,
|
||||||
) -> Result<LookupReleaseGroupResponse, Error> {
|
) -> Result<LookupReleaseGroupResponse, Error> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{MB_BASE_URL}/release-group/{mbid}",
|
"{MB_BASE_URL}/release-group/{mbid}",
|
||||||
@ -152,7 +152,7 @@ mod tests {
|
|||||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
title: String::from("an album"),
|
title: String::from("an album"),
|
||||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||||
AlbumSecondaryType::Compilation,
|
AlbumSecondaryType::Compilation,
|
||||||
)]),
|
)]),
|
||||||
@ -177,7 +177,7 @@ mod tests {
|
|||||||
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||||
let mut request = LookupArtistRequest::new(&mbid);
|
let mut request = LookupArtistRequest::new(&mbid);
|
||||||
request.include_release_groups();
|
request.include_release_groups();
|
||||||
let result = client.lookup_artist(request).unwrap();
|
let result = client.lookup_artist(&request).unwrap();
|
||||||
|
|
||||||
assert_eq!(result, response);
|
assert_eq!(result, response);
|
||||||
}
|
}
|
||||||
@ -192,7 +192,7 @@ mod tests {
|
|||||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
title: String::from("an album"),
|
title: String::from("an album"),
|
||||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||||
AlbumSecondaryType::Compilation,
|
AlbumSecondaryType::Compilation,
|
||||||
)]),
|
)]),
|
||||||
@ -214,7 +214,7 @@ mod tests {
|
|||||||
|
|
||||||
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||||
let request = LookupReleaseGroupRequest::new(&mbid);
|
let request = LookupReleaseGroupRequest::new(&mbid);
|
||||||
let result = client.lookup_release_group(request).unwrap();
|
let result = client.lookup_release_group(&request).unwrap();
|
||||||
|
|
||||||
assert_eq!(result, response);
|
assert_eq!(result, response);
|
||||||
}
|
}
|
||||||
|
100
src/external/musicbrainz/api/mod.rs
vendored
100
src/external/musicbrainz/api/mod.rs
vendored
@ -11,11 +11,14 @@ use crate::{
|
|||||||
external::musicbrainz::HttpError,
|
external::musicbrainz::HttpError,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod browse;
|
||||||
pub mod lookup;
|
pub mod lookup;
|
||||||
pub mod search;
|
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.
|
||||||
@ -58,6 +61,46 @@ 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(Debug, PartialEq, Eq)]
|
||||||
|
pub enum NextPage {
|
||||||
|
Offset(usize),
|
||||||
|
Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NextPage {
|
||||||
|
pub fn next_page_offset(offset: usize, total_count: usize, page_count: usize) -> NextPage {
|
||||||
|
let next_offset = offset + page_count;
|
||||||
|
if next_offset < total_count {
|
||||||
|
NextPage::Offset(next_offset)
|
||||||
|
} else {
|
||||||
|
NextPage::Complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct MbArtistMeta {
|
pub struct MbArtistMeta {
|
||||||
pub id: Mbid,
|
pub id: Mbid,
|
||||||
@ -91,7 +134,7 @@ pub struct MbReleaseGroupMeta {
|
|||||||
pub id: Mbid,
|
pub id: Mbid,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub first_release_date: AlbumDate,
|
pub first_release_date: AlbumDate,
|
||||||
pub primary_type: AlbumPrimaryType,
|
pub primary_type: Option<AlbumPrimaryType>,
|
||||||
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,7 +144,7 @@ pub struct SerdeMbReleaseGroupMeta {
|
|||||||
id: SerdeMbid,
|
id: SerdeMbid,
|
||||||
title: String,
|
title: String,
|
||||||
first_release_date: SerdeAlbumDate,
|
first_release_date: SerdeAlbumDate,
|
||||||
primary_type: SerdeAlbumPrimaryType,
|
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||||
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,7 +154,7 @@ impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
|
|||||||
id: value.id.into(),
|
id: value.id.into(),
|
||||||
title: value.title,
|
title: value.title,
|
||||||
first_release_date: value.first_release_date.into(),
|
first_release_date: value.first_release_date.into(),
|
||||||
primary_type: value.primary_type.into(),
|
primary_type: value.primary_type.map(Into::into),
|
||||||
secondary_types: value
|
secondary_types: value
|
||||||
.secondary_types
|
.secondary_types
|
||||||
.map(|v| v.into_iter().map(Into::into).collect()),
|
.map(|v| v.into_iter().map(Into::into).collect()),
|
||||||
@ -122,6 +165,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 {
|
||||||
@ -304,6 +359,45 @@ mod tests {
|
|||||||
assert!(!format!("{unk_err:?}").is_empty());
|
assert!(!format!("{unk_err:?}").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn next_page_test<Fn>(mut f: Fn)
|
||||||
|
where
|
||||||
|
Fn: FnMut(usize) -> NextPage,
|
||||||
|
{
|
||||||
|
let next = f(20);
|
||||||
|
assert_eq!(next, NextPage::Offset(25));
|
||||||
|
|
||||||
|
let next = f(40);
|
||||||
|
assert_eq!(next, NextPage::Complete);
|
||||||
|
|
||||||
|
let next = f(100);
|
||||||
|
assert_eq!(next, NextPage::Complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_page() {
|
||||||
|
next_page_test(|val| NextPage::next_page_offset(5, 45, val));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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!(
|
||||||
|
21
src/external/musicbrainz/api/search/artist.rs
vendored
21
src/external/musicbrainz/api/search/artist.rs
vendored
@ -3,7 +3,10 @@ use std::fmt;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::external::musicbrainz::api::{
|
use crate::external::musicbrainz::api::{
|
||||||
search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
search::{
|
||||||
|
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
||||||
|
SearchPage, SerdeSearchPage,
|
||||||
|
},
|
||||||
MbArtistMeta, SerdeMbArtistMeta,
|
MbArtistMeta, SerdeMbArtistMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -26,18 +29,22 @@ impl_term!(string, SearchArtist<'a>, String, &'a str);
|
|||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct SearchArtistResponse {
|
pub struct SearchArtistResponse {
|
||||||
pub artists: Vec<SearchArtistResponseArtist>,
|
pub artists: Vec<SearchArtistResponseArtist>,
|
||||||
|
pub page: SearchPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
pub struct DeserializeSearchArtistResponse {
|
pub struct DeserializeSearchArtistResponse {
|
||||||
artists: Vec<DeserializeSearchArtistResponseArtist>,
|
artists: Vec<DeserializeSearchArtistResponseArtist>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
page: SerdeSearchPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
|
impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
|
||||||
fn from(value: DeserializeSearchArtistResponse) -> Self {
|
fn from(value: DeserializeSearchArtistResponse) -> Self {
|
||||||
SearchArtistResponse {
|
SearchArtistResponse {
|
||||||
artists: value.artists.into_iter().map(Into::into).collect(),
|
artists: value.artists.into_iter().map(Into::into).collect(),
|
||||||
|
page: value.page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,13 +77,15 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn de_response() -> DeserializeSearchArtistResponse {
|
fn de_response() -> DeserializeSearchArtistResponse {
|
||||||
|
let de_offset = 24;
|
||||||
|
let de_count = 124;
|
||||||
let de_artist = DeserializeSearchArtistResponseArtist {
|
let de_artist = DeserializeSearchArtistResponseArtist {
|
||||||
score: 67,
|
score: 67,
|
||||||
meta: SerdeMbArtistMeta {
|
meta: SerdeMbArtistMeta {
|
||||||
@ -88,6 +97,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
DeserializeSearchArtistResponse {
|
DeserializeSearchArtistResponse {
|
||||||
artists: vec![de_artist.clone()],
|
artists: vec![de_artist.clone()],
|
||||||
|
page: SerdeSearchPage {
|
||||||
|
offset: de_offset,
|
||||||
|
count: de_count,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +114,7 @@ mod tests {
|
|||||||
meta: a.meta.into(),
|
meta: a.meta.into(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
page: de_response.page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,7 +142,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
43
src/external/musicbrainz/api/search/mod.rs
vendored
43
src/external/musicbrainz/api/search/mod.rs
vendored
@ -9,6 +9,7 @@ pub use release_group::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use paste::paste;
|
use paste::paste;
|
||||||
|
use serde::Deserialize;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
|
|
||||||
use crate::external::musicbrainz::{
|
use crate::external::musicbrainz::{
|
||||||
@ -17,21 +18,40 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::NextPage;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct SearchPage {
|
||||||
|
pub offset: usize,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchPage {
|
||||||
|
pub fn next_page_offset(&self, page_count: usize) -> NextPage {
|
||||||
|
NextPage::next_page_offset(self.offset, self.count, page_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SerdeSearchPage = SearchPage;
|
||||||
|
|
||||||
macro_rules! impl_search_entity {
|
macro_rules! impl_search_entity {
|
||||||
($name:ident, $entity:literal) => {
|
($name:ident, $entity:literal) => {
|
||||||
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())
|
||||||
@ -44,3 +64,20 @@ impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
|||||||
impl_search_entity!(Artist, "artist");
|
impl_search_entity!(Artist, "artist");
|
||||||
impl_search_entity!(ReleaseGroup, "release-group");
|
impl_search_entity!(ReleaseGroup, "release-group");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::external::musicbrainz::api::tests::next_page_test;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_next_page() {
|
||||||
|
let page = SearchPage {
|
||||||
|
offset: 5,
|
||||||
|
count: 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
next_page_test(|val| page.next_page_offset(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -5,7 +5,10 @@ use serde::Deserialize;
|
|||||||
use crate::{
|
use crate::{
|
||||||
collection::{album::AlbumDate, musicbrainz::Mbid},
|
collection::{album::AlbumDate, musicbrainz::Mbid},
|
||||||
external::musicbrainz::api::{
|
external::musicbrainz::api::{
|
||||||
search::query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
search::{
|
||||||
|
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
||||||
|
SearchPage, SerdeSearchPage,
|
||||||
|
},
|
||||||
ApiDisplay, MbReleaseGroupMeta, SerdeMbReleaseGroupMeta,
|
ApiDisplay, MbReleaseGroupMeta, SerdeMbReleaseGroupMeta,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -50,18 +53,22 @@ impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid);
|
|||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub struct SearchReleaseGroupResponse {
|
pub struct SearchReleaseGroupResponse {
|
||||||
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
||||||
|
pub page: SearchPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
pub struct DeserializeSearchReleaseGroupResponse {
|
pub struct DeserializeSearchReleaseGroupResponse {
|
||||||
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
page: SerdeSearchPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
||||||
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
||||||
SearchReleaseGroupResponse {
|
SearchReleaseGroupResponse {
|
||||||
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||||
|
page: value.page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,8 +106,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,
|
||||||
},
|
},
|
||||||
@ -109,18 +116,24 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn de_response() -> DeserializeSearchReleaseGroupResponse {
|
fn de_response() -> DeserializeSearchReleaseGroupResponse {
|
||||||
|
let de_offset = 26;
|
||||||
|
let de_count = 126;
|
||||||
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
|
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||||
score: 67,
|
score: 67,
|
||||||
meta: SerdeMbReleaseGroupMeta {
|
meta: SerdeMbReleaseGroupMeta {
|
||||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
title: String::from("an album"),
|
title: String::from("an album"),
|
||||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
primary_type: SerdeAlbumPrimaryType(AlbumPrimaryType::Album),
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
DeserializeSearchReleaseGroupResponse {
|
DeserializeSearchReleaseGroupResponse {
|
||||||
release_groups: vec![de_release_group.clone()],
|
release_groups: vec![de_release_group.clone()],
|
||||||
|
page: SerdeSearchPage {
|
||||||
|
offset: de_offset,
|
||||||
|
count: de_count,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +147,7 @@ mod tests {
|
|||||||
meta: rg.meta.into(),
|
meta: rg.meta.into(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
page: de_response.page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,7 +175,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 +213,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 +242,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
use crate::tui::app::{
|
use crate::tui::app::{
|
||||||
machine::{App, AppInner, AppMachine},
|
machine::{App, AppInner, AppMachine},
|
||||||
selection::{Delta, ListSelection},
|
selection::ListSelection,
|
||||||
AppPublicState, AppState, IAppInteractBrowse,
|
AppPublicState, AppState, Delta, IAppInteractBrowse,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct BrowseState;
|
pub struct BrowseState;
|
||||||
|
@ -9,8 +9,8 @@ use musichoard::collection::{
|
|||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{
|
app::{
|
||||||
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
|
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
|
||||||
AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, ListOption,
|
AlbumMatches, AppPublicState, AppState, ArtistMatches, Delta, IAppInteractMatch,
|
||||||
MatchOption, MatchStateInfo, MatchStatePublic, WidgetState,
|
ListOption, MatchOption, MatchStateInfo, MatchStatePublic, WidgetState,
|
||||||
},
|
},
|
||||||
lib::interface::musicbrainz::api::{Lookup, Match},
|
lib::interface::musicbrainz::api::{Lookup, Match},
|
||||||
};
|
};
|
||||||
@ -243,19 +243,19 @@ impl<'a> From<&'a mut MatchState> for AppPublicState<'a> {
|
|||||||
impl IAppInteractMatch for AppMachine<MatchState> {
|
impl IAppInteractMatch for AppMachine<MatchState> {
|
||||||
type APP = App;
|
type APP = App;
|
||||||
|
|
||||||
fn prev_match(mut self) -> Self::APP {
|
fn decrement_match(mut self, delta: Delta) -> Self::APP {
|
||||||
if let Some(index) = self.state.state.list.selected() {
|
if let Some(index) = self.state.state.list.selected() {
|
||||||
let result = index.saturating_sub(1);
|
let result = index.saturating_sub(delta.as_usize(&self.state.state));
|
||||||
self.state.state.list.select(Some(result));
|
self.state.state.list.select(Some(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.into()
|
self.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_match(mut self) -> Self::APP {
|
fn increment_match(mut self, delta: Delta) -> Self::APP {
|
||||||
let index = self.state.state.list.selected().unwrap();
|
let index = self.state.state.list.selected().unwrap();
|
||||||
let to = cmp::min(
|
let to = cmp::min(
|
||||||
index.saturating_add(1),
|
index.saturating_add(delta.as_usize(&self.state.state)),
|
||||||
self.state.current.len().saturating_sub(1),
|
self.state.current.len().saturating_sub(1),
|
||||||
);
|
);
|
||||||
self.state.state.list.select(Some(to));
|
self.state.state.list.select(Some(to));
|
||||||
@ -473,38 +473,38 @@ mod tests {
|
|||||||
assert_eq!(matches.state.current, matches_info);
|
assert_eq!(matches.state.current, matches_info);
|
||||||
assert_eq!(matches.state.state, widget_state);
|
assert_eq!(matches.state.state, widget_state);
|
||||||
|
|
||||||
let matches = matches.prev_match().unwrap_match();
|
let matches = matches.decrement_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
assert_eq!(matches.state.current, matches_info);
|
assert_eq!(matches.state.current, matches_info);
|
||||||
assert_eq!(matches.state.state.list.selected(), Some(0));
|
assert_eq!(matches.state.state.list.selected(), Some(0));
|
||||||
|
|
||||||
let mut matches = matches;
|
let mut matches = matches;
|
||||||
for ii in 1..len {
|
for ii in 1..len {
|
||||||
matches = matches.next_match().unwrap_match();
|
matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
assert_eq!(matches.state.current, matches_info);
|
assert_eq!(matches.state.current, matches_info);
|
||||||
assert_eq!(matches.state.state.list.selected(), Some(ii));
|
assert_eq!(matches.state.state.list.selected(), Some(ii));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next is CannotHaveMBID
|
// Next is CannotHaveMBID
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
assert_eq!(matches.state.current, matches_info);
|
assert_eq!(matches.state.current, matches_info);
|
||||||
assert_eq!(matches.state.state.list.selected(), Some(len));
|
assert_eq!(matches.state.state.list.selected(), Some(len));
|
||||||
|
|
||||||
// Next is ManualInputMbid
|
// Next is ManualInputMbid
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
assert_eq!(matches.state.current, matches_info);
|
assert_eq!(matches.state.current, matches_info);
|
||||||
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
||||||
|
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
assert_eq!(matches.state.current, matches_info);
|
assert_eq!(matches.state.current, matches_info);
|
||||||
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
||||||
|
|
||||||
// Go prev_match first as selecting on manual input does not go back to fetch.
|
// Go prev_match first as selecting on manual input does not go back to fetch.
|
||||||
let matches = matches.prev_match().unwrap_match();
|
let matches = matches.decrement_match(Delta::Line).unwrap_match();
|
||||||
matches.select().unwrap_fetch();
|
matches.select().unwrap_fetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -619,10 +619,10 @@ mod tests {
|
|||||||
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match()));
|
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match()));
|
||||||
|
|
||||||
// album_match has two matches which means that the fourth option should be manual input.
|
// album_match has two matches which means that the fourth option should be manual input.
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
let app = matches.select();
|
let app = matches.select();
|
||||||
|
|
||||||
@ -657,8 +657,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// There are no matches which means that the second option should be manual input.
|
// There are no matches which means that the second option should be manual input.
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
let mut app = matches.select();
|
let mut app = matches.select();
|
||||||
app = input_mbid(app);
|
app = input_mbid(app);
|
||||||
@ -691,8 +691,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// There are no matches which means that the second option should be manual input.
|
// There are no matches which means that the second option should be manual input.
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
let matches = matches.next_match().unwrap_match();
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
let mut app = matches.select();
|
let mut app = matches.select();
|
||||||
app = input_mbid(app);
|
app = input_mbid(app);
|
||||||
|
@ -2,7 +2,8 @@ mod machine;
|
|||||||
mod selection;
|
mod selection;
|
||||||
|
|
||||||
pub use machine::App;
|
pub use machine::App;
|
||||||
pub use selection::{Category, Delta, Selection, WidgetState};
|
use ratatui::widgets::ListState;
|
||||||
|
pub use selection::{Category, Selection};
|
||||||
|
|
||||||
use musichoard::collection::{
|
use musichoard::collection::{
|
||||||
album::AlbumMeta,
|
album::AlbumMeta,
|
||||||
@ -124,8 +125,8 @@ pub trait IAppEventFetch {
|
|||||||
pub trait IAppInteractMatch {
|
pub trait IAppInteractMatch {
|
||||||
type APP: IApp;
|
type APP: IApp;
|
||||||
|
|
||||||
fn prev_match(self) -> Self::APP;
|
fn decrement_match(self, delta: Delta) -> Self::APP;
|
||||||
fn next_match(self) -> Self::APP;
|
fn increment_match(self, delta: Delta) -> Self::APP;
|
||||||
fn select(self) -> Self::APP;
|
fn select(self) -> Self::APP;
|
||||||
|
|
||||||
fn abort(self) -> Self::APP;
|
fn abort(self) -> Self::APP;
|
||||||
@ -159,6 +160,40 @@ pub trait IAppInteractError {
|
|||||||
fn dismiss_error(self) -> Self::APP;
|
fn dismiss_error(self) -> Self::APP;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct WidgetState {
|
||||||
|
pub list: ListState,
|
||||||
|
pub height: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for WidgetState {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.list.selected().eq(&other.list.selected()) && self.height.eq(&other.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetState {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_selected(mut self, selected: Option<usize>) -> Self {
|
||||||
|
self.list = self.list.with_selected(selected);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Delta {
|
||||||
|
Line,
|
||||||
|
Page,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Delta {
|
||||||
|
fn as_usize(&self, state: &WidgetState) -> usize {
|
||||||
|
match self {
|
||||||
|
Delta::Line => 1,
|
||||||
|
Delta::Page => state.height.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// It would be preferable to have a getter for each field separately. However, the selection field
|
// It would be preferable to have a getter for each field separately. However, the selection field
|
||||||
// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait.
|
// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait.
|
||||||
// This in turn complicates simultaneous field access since only a single mutable borrow is allowed.
|
// This in turn complicates simultaneous field access since only a single mutable borrow is allowed.
|
||||||
|
@ -5,9 +5,12 @@ use musichoard::collection::{
|
|||||||
track::Track,
|
track::Track,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::tui::app::selection::{
|
use crate::tui::app::{
|
||||||
|
selection::{
|
||||||
track::{KeySelectTrack, TrackSelection},
|
track::{KeySelectTrack, TrackSelection},
|
||||||
Delta, SelectionState, WidgetState,
|
SelectionState,
|
||||||
|
},
|
||||||
|
Delta, WidgetState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
@ -6,9 +6,12 @@ use musichoard::collection::{
|
|||||||
track::Track,
|
track::Track,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::tui::app::selection::{
|
use crate::tui::app::{
|
||||||
|
selection::{
|
||||||
album::{AlbumSelection, KeySelectAlbum},
|
album::{AlbumSelection, KeySelectAlbum},
|
||||||
Delta, SelectionState, WidgetState,
|
SelectionState,
|
||||||
|
},
|
||||||
|
Delta, WidgetState,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
@ -5,7 +5,10 @@ mod track;
|
|||||||
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
|
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
|
||||||
use artist::{ArtistSelection, KeySelectArtist};
|
use crate::tui::app::{
|
||||||
|
selection::artist::{ArtistSelection, KeySelectArtist},
|
||||||
|
Delta, WidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum Category {
|
pub enum Category {
|
||||||
@ -24,40 +27,6 @@ pub struct SelectionState<'a, T> {
|
|||||||
pub index: usize,
|
pub index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct WidgetState {
|
|
||||||
pub list: ListState,
|
|
||||||
pub height: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq for WidgetState {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.list.selected().eq(&other.list.selected()) && self.height.eq(&other.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WidgetState {
|
|
||||||
#[must_use]
|
|
||||||
pub const fn with_selected(mut self, selected: Option<usize>) -> Self {
|
|
||||||
self.list = self.list.with_selected(selected);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Delta {
|
|
||||||
Line,
|
|
||||||
Page,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Delta {
|
|
||||||
fn as_usize(&self, state: &WidgetState) -> usize {
|
|
||||||
match self {
|
|
||||||
Delta::Line => 1,
|
|
||||||
Delta::Page => state.height.saturating_sub(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selection {
|
impl Selection {
|
||||||
pub fn new(artists: &[Artist]) -> Self {
|
pub fn new(artists: &[Artist]) -> Self {
|
||||||
Selection {
|
Selection {
|
||||||
|
@ -2,7 +2,7 @@ use std::cmp;
|
|||||||
|
|
||||||
use musichoard::collection::track::{Track, TrackId, TrackNum};
|
use musichoard::collection::track::{Track, TrackId, TrackNum};
|
||||||
|
|
||||||
use crate::tui::app::selection::{Delta, SelectionState, WidgetState};
|
use crate::tui::app::{selection::SelectionState, Delta, WidgetState};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct TrackSelection {
|
pub struct TrackSelection {
|
||||||
|
@ -212,8 +212,10 @@ impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
|
|||||||
// Abort.
|
// Abort.
|
||||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(),
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(),
|
||||||
// Select.
|
// Select.
|
||||||
KeyCode::Up => app.prev_match(),
|
KeyCode::Up => app.decrement_match(Delta::Line),
|
||||||
KeyCode::Down => app.next_match(),
|
KeyCode::Down => app.increment_match(Delta::Line),
|
||||||
|
KeyCode::PageUp => app.decrement_match(Delta::Page),
|
||||||
|
KeyCode::PageDown => app.increment_match(Delta::Page),
|
||||||
KeyCode::Enter => app.select(),
|
KeyCode::Enter => app.select(),
|
||||||
// Othey keys.
|
// Othey keys.
|
||||||
_ => app.no_op(),
|
_ => app.no_op(),
|
||||||
|
16
src/tui/lib/external/musicbrainz/api/mod.rs
vendored
16
src/tui/lib/external/musicbrainz/api/mod.rs
vendored
@ -18,7 +18,7 @@ use musichoard::{
|
|||||||
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
|
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
|
||||||
SearchReleaseGroupResponseReleaseGroup,
|
SearchReleaseGroupResponseReleaseGroup,
|
||||||
},
|
},
|
||||||
MusicBrainzClient,
|
MusicBrainzClient, PageSettings,
|
||||||
},
|
},
|
||||||
IMusicBrainzHttp,
|
IMusicBrainzHttp,
|
||||||
},
|
},
|
||||||
@ -41,7 +41,7 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
|
|||||||
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Lookup<ArtistMeta>, Error> {
|
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Lookup<ArtistMeta>, Error> {
|
||||||
let request = LookupArtistRequest::new(mbid);
|
let request = LookupArtistRequest::new(mbid);
|
||||||
|
|
||||||
let mb_response = self.client.lookup_artist(request)?;
|
let mb_response = self.client.lookup_artist(&request)?;
|
||||||
|
|
||||||
Ok(from_lookup_artist_response(mb_response))
|
Ok(from_lookup_artist_response(mb_response))
|
||||||
}
|
}
|
||||||
@ -49,7 +49,7 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
|
|||||||
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Lookup<AlbumMeta>, Error> {
|
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Lookup<AlbumMeta>, Error> {
|
||||||
let request = LookupReleaseGroupRequest::new(mbid);
|
let request = LookupReleaseGroupRequest::new(mbid);
|
||||||
|
|
||||||
let mb_response = self.client.lookup_release_group(request)?;
|
let mb_response = self.client.lookup_release_group(&request)?;
|
||||||
|
|
||||||
Ok(from_lookup_release_group_response(mb_response))
|
Ok(from_lookup_release_group_response(mb_response))
|
||||||
}
|
}
|
||||||
@ -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::default();
|
||||||
|
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::default();
|
||||||
|
let mb_response = self.client.search_release_group(&query, &paging)?;
|
||||||
|
|
||||||
Ok(mb_response
|
Ok(mb_response
|
||||||
.release_groups
|
.release_groups
|
||||||
@ -115,7 +117,7 @@ fn from_lookup_release_group_response(entity: LookupReleaseGroupResponse) -> Loo
|
|||||||
seq: AlbumSeq::default(),
|
seq: AlbumSeq::default(),
|
||||||
info: AlbumInfo {
|
info: AlbumInfo {
|
||||||
musicbrainz: MbRefOption::Some(entity.meta.id.into()),
|
musicbrainz: MbRefOption::Some(entity.meta.id.into()),
|
||||||
primary_type: Some(entity.meta.primary_type),
|
primary_type: entity.meta.primary_type,
|
||||||
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
|
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -150,7 +152,7 @@ fn from_search_release_group_response_release_group(
|
|||||||
seq: AlbumSeq::default(),
|
seq: AlbumSeq::default(),
|
||||||
info: AlbumInfo {
|
info: AlbumInfo {
|
||||||
musicbrainz: MbRefOption::Some(entity.meta.id.into()),
|
musicbrainz: MbRefOption::Some(entity.meta.id.into()),
|
||||||
primary_type: Some(entity.meta.primary_type),
|
primary_type: entity.meta.primary_type,
|
||||||
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
|
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user