Compare commits

...

2 Commits

Author SHA1 Message Date
7e63999edf Add browse to TUI API and other simplifcations 2024-10-05 20:43:33 +02:00
4db09667fd Add support for MusicBrainz's Browse API (#228)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m58s
Cargo CI / Lint (push) Successful in 1m8s
Reviewed-on: #228
2024-09-29 21:33:42 +02:00
26 changed files with 751 additions and 362 deletions

View File

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

View File

@ -0,0 +1,94 @@
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 count = response.release_groups.len();
response_counts.push(count);
println!("Release groups in this response: {count}");
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>(),
);
}
},
}
}

View File

@ -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:#?}");

View File

@ -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
View 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 {
release_group_offset: usize,
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);
}
}

View File

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

View File

@ -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 {
limit: Option<usize>,
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!(

View File

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

View File

@ -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 {
offset: usize,
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));
}
}

View File

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

View File

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

View File

@ -290,11 +290,7 @@ mod tests {
machine::tests::{inner, music_hoard}, machine::tests::{inner, music_hoard},
Delta, IApp, IAppAccess, IAppInteractBrowse, MatchOption, MatchStateInfo, Delta, IApp, IAppAccess, IAppInteractBrowse, MatchOption, MatchStateInfo,
}, },
lib::interface::musicbrainz::{ lib::interface::musicbrainz::{self, api::Match, daemon::MockIMbJobSender},
self,
api::{Lookup, Match},
daemon::MockIMbJobSender,
},
testmod::COLLECTION, testmod::COLLECTION,
}; };
@ -320,7 +316,7 @@ mod tests {
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty)); assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
let lookup = Lookup::new(artist.clone()); let lookup = Match::item(artist.clone());
let lookup_result = MatchStateInfo::artist_lookup(artist.clone(), lookup); let lookup_result = MatchStateInfo::artist_lookup(artist.clone(), lookup);
lookup_tx.send(Ok(lookup_result.clone())).unwrap(); lookup_tx.send(Ok(lookup_result.clone())).unwrap();
@ -605,7 +601,7 @@ mod tests {
let (tx, rx) = mpsc::channel::<MbApiResult>(); let (tx, rx) = mpsc::channel::<MbApiResult>();
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let artist_match = Match::new(80, COLLECTION[2].meta.clone()); let artist_match = Match::with_score(COLLECTION[2].meta.clone(), 80);
let artist_match_info = let artist_match_info =
MatchStateInfo::artist_search(artist.clone(), vec![artist_match.clone()]); MatchStateInfo::artist_search(artist.clone(), vec![artist_match.clone()]);
let fetch_result = Ok(artist_match_info); let fetch_result = Ok(artist_match_info);

View File

@ -6,13 +6,10 @@ use musichoard::collection::{
musicbrainz::{MbRefOption, Mbid}, musicbrainz::{MbRefOption, Mbid},
}; };
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, Delta, IAppInteractMatch, MatchOption,
AlbumMatches, AppPublicState, AppState, ArtistMatches, IAppInteractMatch, ListOption, MatchStateInfo, MatchStatePublic, WidgetState,
MatchOption, MatchStateInfo, MatchStatePublic, WidgetState,
},
lib::interface::musicbrainz::api::{Lookup, Match},
}; };
trait GetInfoMeta { trait GetInfoMeta {
@ -27,7 +24,7 @@ impl GetInfoMeta for AlbumMeta {
trait GetInfo { trait GetInfo {
type InfoType; type InfoType;
fn into_info(self, info: Self::InfoType) -> InfoOption<Self::InfoType>; fn into_info(&self, info: Self::InfoType) -> InfoOption<Self::InfoType>;
} }
enum InfoOption<T> { enum InfoOption<T> {
@ -35,86 +32,46 @@ enum InfoOption<T> {
NeedInput, NeedInput,
} }
macro_rules! impl_match_option_artist_into_info { impl GetInfo for MatchOption<ArtistMeta> {
($holder:ident) => { type InfoType = ArtistInfo;
impl GetInfo for MatchOption<$holder<ArtistMeta>> {
type InfoType = ArtistInfo;
fn into_info(self, mut info: Self::InfoType) -> InfoOption<Self::InfoType> { fn into_info(&self, mut info: Self::InfoType) -> InfoOption<Self::InfoType> {
match self { match self {
MatchOption::Some(option) => info.musicbrainz = option.item.info.musicbrainz, MatchOption::Some(option) => info.musicbrainz = option.item.info.musicbrainz.clone(),
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid, MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
MatchOption::ManualInputMbid => return InfoOption::NeedInput, MatchOption::ManualInputMbid => return InfoOption::NeedInput,
}
InfoOption::Info(info)
}
} }
}; InfoOption::Info(info)
}
} }
impl_match_option_artist_into_info!(Match); impl GetInfo for MatchOption<AlbumMeta> {
impl_match_option_artist_into_info!(Lookup); type InfoType = AlbumInfo;
macro_rules! impl_match_option_album_into_info { fn into_info(&self, mut info: Self::InfoType) -> InfoOption<Self::InfoType> {
($holder:ident) => {
impl GetInfo for MatchOption<$holder<AlbumMeta>> {
type InfoType = AlbumInfo;
fn into_info(self, mut info: Self::InfoType) -> InfoOption<Self::InfoType> {
match self {
MatchOption::Some(option) => info = option.item.info,
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
}
InfoOption::Info(info)
}
}
};
}
impl_match_option_album_into_info!(Match);
impl_match_option_album_into_info!(Lookup);
impl<T> ListOption<T> {
fn len(&self) -> usize {
match self { match self {
ListOption::Lookup(list) => list.len(), MatchOption::Some(option) => info = option.item.info.clone(),
ListOption::Search(list) => list.len(), MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
} MatchOption::ManualInputMbid => return InfoOption::NeedInput,
}
fn push_cannot_have_mbid(&mut self) {
match self {
ListOption::Lookup(list) => list.push(MatchOption::CannotHaveMbid),
ListOption::Search(list) => list.push(MatchOption::CannotHaveMbid),
}
}
fn push_manual_input_mbid(&mut self) {
match self {
ListOption::Lookup(list) => list.push(MatchOption::ManualInputMbid),
ListOption::Search(list) => list.push(MatchOption::ManualInputMbid),
} }
InfoOption::Info(info)
} }
} }
trait ExtractInfo { trait ExtractInfo {
type InfoType; type InfoType;
fn extract_info(&mut self, index: usize, info: Self::InfoType) -> InfoOption<Self::InfoType>; fn extract_info(&self, index: usize, info: Self::InfoType) -> InfoOption<Self::InfoType>;
} }
impl<T: GetInfoMeta> ExtractInfo for ListOption<T> impl<T: GetInfoMeta> ExtractInfo for Vec<MatchOption<T>>
where where
MatchOption<Match<T>>: GetInfo<InfoType = T::InfoType>, MatchOption<T>: GetInfo<InfoType = T::InfoType>,
MatchOption<Lookup<T>>: GetInfo<InfoType = T::InfoType>, MatchOption<T>: GetInfo<InfoType = T::InfoType>,
{ {
type InfoType = T::InfoType; type InfoType = T::InfoType;
fn extract_info(&mut self, index: usize, info: Self::InfoType) -> InfoOption<Self::InfoType> { fn extract_info(&self, index: usize, info: Self::InfoType) -> InfoOption<Self::InfoType> {
match self { self.get(index).unwrap().into_info(info)
ListOption::Lookup(ref mut list) => list.swap_remove(index).into_info(info),
ListOption::Search(ref mut list) => list.swap_remove(index).into_info(info),
}
} }
} }
@ -124,11 +81,11 @@ impl ArtistMatches {
} }
fn push_cannot_have_mbid(&mut self) { fn push_cannot_have_mbid(&mut self) {
self.list.push_cannot_have_mbid(); self.list.push(MatchOption::CannotHaveMbid);
} }
fn push_manual_input_mbid(&mut self) { fn push_manual_input_mbid(&mut self) {
self.list.push_manual_input_mbid(); self.list.push(MatchOption::ManualInputMbid);
} }
} }
@ -138,11 +95,11 @@ impl AlbumMatches {
} }
fn push_cannot_have_mbid(&mut self) { fn push_cannot_have_mbid(&mut self) {
self.list.push_cannot_have_mbid(); self.list.push(MatchOption::CannotHaveMbid);
} }
fn push_manual_input_mbid(&mut self) { fn push_manual_input_mbid(&mut self) {
self.list.push_manual_input_mbid(); self.list.push(MatchOption::ManualInputMbid);
} }
} }
@ -243,19 +200,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));
@ -314,7 +271,7 @@ mod tests {
IApp, IAppAccess, IAppInput, IApp, IAppAccess, IAppInput,
}, },
lib::interface::musicbrainz::{ lib::interface::musicbrainz::{
api::{Lookup, Match}, api::Match,
daemon::{MbParams, MockIMbJobSender}, daemon::{MbParams, MockIMbJobSender},
}, },
}; };
@ -322,18 +279,9 @@ mod tests {
use super::*; use super::*;
impl<T> Match<T> { impl<T> Match<T> {
pub fn new(score: u8, item: T) -> Self { pub fn with_score(item: T, score: u8) -> Self {
Match { Match {
score, score: Some(score),
item,
disambiguation: None,
}
}
}
impl<T> Lookup<T> {
pub fn new(item: T) -> Self {
Lookup {
item, item,
disambiguation: None, disambiguation: None,
} }
@ -354,10 +302,10 @@ mod tests {
let artist = artist_meta(); let artist = artist_meta();
let artist_1 = artist.clone(); let artist_1 = artist.clone();
let artist_match_1 = Match::new(100, artist_1); let artist_match_1 = Match::with_score(artist_1, 100);
let artist_2 = artist.clone(); let artist_2 = artist.clone();
let mut artist_match_2 = Match::new(100, artist_2); let mut artist_match_2 = Match::with_score(artist_2, 100);
artist_match_2.disambiguation = Some(String::from("some disambiguation")); artist_match_2.disambiguation = Some(String::from("some disambiguation"));
let list = vec![artist_match_1.clone(), artist_match_2.clone()]; let list = vec![artist_match_1.clone(), artist_match_2.clone()];
@ -366,7 +314,7 @@ mod tests {
fn artist_lookup() -> MatchStateInfo { fn artist_lookup() -> MatchStateInfo {
let artist = artist_meta(); let artist = artist_meta();
let lookup = Lookup::new(artist.clone()); let lookup = Match::item(artist.clone());
MatchStateInfo::artist_lookup(artist, lookup) MatchStateInfo::artist_lookup(artist, lookup)
} }
@ -387,12 +335,12 @@ mod tests {
let album = album_meta(); let album = album_meta();
let album_1 = album.clone(); let album_1 = album.clone();
let album_match_1 = Match::new(100, album_1); let album_match_1 = Match::with_score(album_1, 100);
let mut album_2 = album.clone(); let mut album_2 = album.clone();
album_2.id.title.push_str(" extra title part"); album_2.id.title.push_str(" extra title part");
album_2.info.secondary_types.pop(); album_2.info.secondary_types.pop();
let album_match_2 = Match::new(100, album_2); let album_match_2 = Match::with_score(album_2, 100);
let list = vec![album_match_1.clone(), album_match_2.clone()]; let list = vec![album_match_1.clone(), album_match_2.clone()];
MatchStateInfo::album_search(artist_id, album, list) MatchStateInfo::album_search(artist_id, album, list)
@ -401,7 +349,7 @@ mod tests {
fn album_lookup() -> MatchStateInfo { fn album_lookup() -> MatchStateInfo {
let artist_id = ArtistId::new("Artist"); let artist_id = ArtistId::new("Artist");
let album = album_meta(); let album = album_meta();
let lookup = Lookup::new(album.clone()); let lookup = Match::item(album.clone());
MatchStateInfo::album_lookup(artist_id, album, lookup) MatchStateInfo::album_lookup(artist_id, album, lookup)
} }
@ -473,38 +421,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 +567,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 +605,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 +639,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);

View File

@ -226,10 +226,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{AppState, IApp, IAppInput, IAppInteractBrowse, InputEvent, MatchStateInfo}, app::{AppState, IApp, IAppInput, IAppInteractBrowse, InputEvent, MatchStateInfo},
lib::{ lib::{interface::musicbrainz::{api::Match, daemon::MockIMbJobSender}, MockIMusicHoard},
interface::musicbrainz::{api::Lookup, daemon::MockIMbJobSender},
MockIMusicHoard,
},
}; };
use super::*; use super::*;
@ -520,7 +517,7 @@ mod tests {
let (_, rx) = mpsc::channel(); let (_, rx) = mpsc::channel();
let fetch = FetchState::new(rx); let fetch = FetchState::new(rx);
let artist = ArtistMeta::new(ArtistId::new("Artist")); let artist = ArtistMeta::new(ArtistId::new("Artist"));
let info = MatchStateInfo::artist_lookup(artist.clone(), Lookup::new(artist.clone())); let info = MatchStateInfo::artist_lookup(artist.clone(), Match::item(artist.clone()));
app = app =
AppMachine::match_state(app.unwrap_browse().inner, MatchState::new(info, fetch)).into(); AppMachine::match_state(app.unwrap_browse().inner, MatchState::new(info, fetch)).into();

View File

@ -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,
@ -36,8 +37,6 @@ macro_rules! IAppState {
} }
use IAppState; use IAppState;
use super::lib::interface::musicbrainz::api::Lookup;
pub trait IApp { pub trait IApp {
type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>; type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>;
type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>; type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>;
@ -124,8 +123,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 +158,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.
@ -182,19 +215,13 @@ pub type InputPublic<'app> = &'app tui_input::Input;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum MatchOption<T> { pub enum MatchOption<T> {
Some(T), Some(Match<T>),
CannotHaveMbid, CannotHaveMbid,
ManualInputMbid, ManualInputMbid,
} }
#[derive(Clone, Debug, PartialEq, Eq)] impl<T> From<Match<T>> for MatchOption<T> {
pub enum ListOption<T> { fn from(value: Match<T>) -> Self {
Search(Vec<MatchOption<Match<T>>>),
Lookup(Vec<MatchOption<Lookup<T>>>),
}
impl<T> From<T> for MatchOption<T> {
fn from(value: T) -> Self {
MatchOption::Some(value) MatchOption::Some(value)
} }
} }
@ -202,14 +229,14 @@ impl<T> From<T> for MatchOption<T> {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtistMatches { pub struct ArtistMatches {
pub matching: ArtistMeta, pub matching: ArtistMeta,
pub list: ListOption<ArtistMeta>, pub list: Vec<MatchOption<ArtistMeta>>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlbumMatches { pub struct AlbumMatches {
pub artist: ArtistId, pub artist: ArtistId,
pub matching: AlbumMeta, pub matching: AlbumMeta,
pub list: ListOption<AlbumMeta>, pub list: Vec<MatchOption<AlbumMeta>>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -219,20 +246,20 @@ pub enum MatchStateInfo {
} }
impl MatchStateInfo { impl MatchStateInfo {
pub fn artist_search<M: Into<MatchOption<Match<ArtistMeta>>>>( pub fn artist_search<M: Into<MatchOption<ArtistMeta>>>(
matching: ArtistMeta, matching: ArtistMeta,
list: Vec<M>, list: Vec<M>,
) -> Self { ) -> Self {
let list = ListOption::Search(list.into_iter().map(Into::into).collect()); let list = list.into_iter().map(Into::into).collect();
MatchStateInfo::Artist(ArtistMatches { matching, list }) MatchStateInfo::Artist(ArtistMatches { matching, list })
} }
pub fn album_search<M: Into<MatchOption<Match<AlbumMeta>>>>( pub fn album_search<M: Into<MatchOption<AlbumMeta>>>(
artist: ArtistId, artist: ArtistId,
matching: AlbumMeta, matching: AlbumMeta,
list: Vec<M>, list: Vec<M>,
) -> Self { ) -> Self {
let list = ListOption::Search(list.into_iter().map(Into::into).collect()); let list = list.into_iter().map(Into::into).collect();
MatchStateInfo::Album(AlbumMatches { MatchStateInfo::Album(AlbumMatches {
artist, artist,
matching, matching,
@ -240,20 +267,17 @@ impl MatchStateInfo {
}) })
} }
pub fn artist_lookup<M: Into<MatchOption<Lookup<ArtistMeta>>>>( pub fn artist_lookup<M: Into<MatchOption<ArtistMeta>>>(matching: ArtistMeta, item: M) -> Self {
matching: ArtistMeta, let list = vec![item.into()];
item: M,
) -> Self {
let list = ListOption::Lookup(vec![item.into()]);
MatchStateInfo::Artist(ArtistMatches { matching, list }) MatchStateInfo::Artist(ArtistMatches { matching, list })
} }
pub fn album_lookup<M: Into<MatchOption<Lookup<AlbumMeta>>>>( pub fn album_lookup<M: Into<MatchOption<AlbumMeta>>>(
artist: ArtistId, artist: ArtistId,
matching: AlbumMeta, matching: AlbumMeta,
item: M, item: M,
) -> Self { ) -> Self {
let list = ListOption::Lookup(vec![item.into()]); let list = vec![item.into()];
MatchStateInfo::Album(AlbumMatches { MatchStateInfo::Album(AlbumMatches {
artist, artist,
matching, matching,

View File

@ -5,9 +5,12 @@ use musichoard::collection::{
track::Track, track::Track,
}; };
use crate::tui::app::selection::{ use crate::tui::app::{
track::{KeySelectTrack, TrackSelection}, selection::{
Delta, SelectionState, WidgetState, track::{KeySelectTrack, TrackSelection},
SelectionState,
},
Delta, WidgetState,
}; };
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]

View File

@ -6,9 +6,12 @@ use musichoard::collection::{
track::Track, track::Track,
}; };
use crate::tui::app::selection::{ use crate::tui::app::{
album::{AlbumSelection, KeySelectAlbum}, selection::{
Delta, SelectionState, WidgetState, album::{AlbumSelection, KeySelectAlbum},
SelectionState,
},
Delta, WidgetState,
}; };
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ use musichoard::{
}, },
external::musicbrainz::{ external::musicbrainz::{
api::{ api::{
browse::{BrowseReleaseGroupRequest, BrowseReleaseGroupResponse},
lookup::{ lookup::{
LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest, LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest,
LookupReleaseGroupResponse, LookupReleaseGroupResponse,
@ -18,13 +19,13 @@ use musichoard::{
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest, SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
SearchReleaseGroupResponseReleaseGroup, SearchReleaseGroupResponseReleaseGroup,
}, },
MusicBrainzClient, MbArtistMeta, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
}, },
IMusicBrainzHttp, IMusicBrainzHttp,
}, },
}; };
use crate::tui::lib::interface::musicbrainz::api::{Error, IMusicBrainz, Lookup, Match}; use crate::tui::lib::interface::musicbrainz::api::{Error, IMusicBrainz, Match, Paged};
// GRCOV_EXCL_START // GRCOV_EXCL_START
pub struct MusicBrainz<Http> { pub struct MusicBrainz<Http> {
@ -38,18 +39,18 @@ impl<Http> MusicBrainz<Http> {
} }
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> { 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<Match<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))
} }
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Lookup<AlbumMeta>, Error> { fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Match<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 +58,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 +84,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
@ -90,52 +93,75 @@ impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
.map(from_search_release_group_response_release_group) .map(from_search_release_group_response_release_group)
.collect()) .collect())
} }
}
fn from_lookup_artist_response(entity: LookupArtistResponse) -> Lookup<ArtistMeta> { fn browse_release_group(
let sort = Some(entity.meta.sort_name).filter(|s| s != &entity.meta.name); &mut self,
Lookup { artist: &Mbid,
item: ArtistMeta { paging: &mut PageSettings,
id: entity.meta.name.into(), ) -> Result<Paged<Vec<Match<AlbumMeta>>>, Error> {
sort, let request = BrowseReleaseGroupRequest::artist(artist);
info: ArtistInfo {
musicbrainz: MbRefOption::Some(entity.meta.id.into()), let mb_response = self.client.browse_release_group(&request, paging)?;
properties: HashMap::new(),
}, let page_count = mb_response.release_groups.len();
}, let next = mb_response.page.next_page_offset(page_count);
disambiguation: entity.meta.disambiguation, let item = from_browse_release_group_response(mb_response);
Ok(Paged { item, next })
} }
} }
fn from_lookup_release_group_response(entity: LookupReleaseGroupResponse) -> Lookup<AlbumMeta> { fn from_mb_artist_meta(meta: MbArtistMeta) -> (ArtistMeta, Option<String>) {
Lookup { let sort = Some(meta.sort_name).filter(|s| s != &meta.name);
item: AlbumMeta { (
id: entity.meta.title.into(), ArtistMeta {
date: entity.meta.first_release_date, id: meta.name.into(),
seq: AlbumSeq::default(), sort,
info: AlbumInfo { info: ArtistInfo {
musicbrainz: MbRefOption::Some(entity.meta.id.into()), musicbrainz: MbRefOption::Some(meta.id.into()),
primary_type: Some(entity.meta.primary_type), properties: HashMap::new(),
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
}, },
}, },
meta.disambiguation,
)
}
fn from_mb_release_group_meta(meta: MbReleaseGroupMeta) -> AlbumMeta {
AlbumMeta {
id: meta.title.into(),
date: meta.first_release_date,
seq: AlbumSeq::default(),
info: AlbumInfo {
musicbrainz: MbRefOption::Some(meta.id.into()),
primary_type: meta.primary_type,
secondary_types: meta.secondary_types.unwrap_or_default(),
},
}
}
fn from_lookup_artist_response(entity: LookupArtistResponse) -> Match<ArtistMeta> {
let (item, disambiguation) = from_mb_artist_meta(entity.meta);
Match {
score: None,
item,
disambiguation,
}
}
fn from_lookup_release_group_response(entity: LookupReleaseGroupResponse) -> Match<AlbumMeta> {
Match {
score: None,
item: from_mb_release_group_meta(entity.meta),
disambiguation: None, disambiguation: None,
} }
} }
fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> { fn from_search_artist_response_artist(entity: SearchArtistResponseArtist) -> Match<ArtistMeta> {
let sort = Some(entity.meta.sort_name).filter(|s| s != &entity.meta.name); let (item, disambiguation) = from_mb_artist_meta(entity.meta);
Match { Match {
score: entity.score, score: Some(entity.score),
item: ArtistMeta { item,
id: entity.meta.name.into(), disambiguation,
sort,
info: ArtistInfo {
musicbrainz: MbRefOption::Some(entity.meta.id.into()),
properties: HashMap::new(),
},
},
disambiguation: entity.meta.disambiguation,
} }
} }
@ -143,18 +169,17 @@ fn from_search_release_group_response_release_group(
entity: SearchReleaseGroupResponseReleaseGroup, entity: SearchReleaseGroupResponseReleaseGroup,
) -> Match<AlbumMeta> { ) -> Match<AlbumMeta> {
Match { Match {
score: entity.score, score: Some(entity.score),
item: AlbumMeta { item: from_mb_release_group_meta(entity.meta),
id: entity.meta.title.into(),
date: entity.meta.first_release_date,
seq: AlbumSeq::default(),
info: AlbumInfo {
musicbrainz: MbRefOption::Some(entity.meta.id.into()),
primary_type: Some(entity.meta.primary_type),
secondary_types: entity.meta.secondary_types.unwrap_or_default(),
},
},
disambiguation: None, disambiguation: None,
} }
} }
fn from_browse_release_group_response(
entity: BrowseReleaseGroupResponse,
) -> Vec<Match<AlbumMeta>> {
let rgs = entity.release_groups.into_iter();
let metas = rgs.map(from_mb_release_group_meta);
metas.map(|meta| Match::item(meta)).collect()
}
// GRCOV_EXCL_STOP // GRCOV_EXCL_STOP

View File

@ -321,7 +321,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
event::{Event, EventError, MockIFetchCompleteEventSender}, event::{Event, EventError, MockIFetchCompleteEventSender},
lib::interface::musicbrainz::api::{Lookup, Match, MockIMusicBrainz}, lib::interface::musicbrainz::api::{Match, MockIMusicBrainz},
testmod::COLLECTION, testmod::COLLECTION,
}; };
@ -411,8 +411,8 @@ mod tests {
fn search_artist_expectations() -> (ArtistMeta, Vec<Match<ArtistMeta>>) { fn search_artist_expectations() -> (ArtistMeta, Vec<Match<ArtistMeta>>) {
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let artist_match_1 = Match::new(100, artist.clone()); let artist_match_1 = Match::with_score(artist.clone(), 100);
let artist_match_2 = Match::new(50, artist.clone()); let artist_match_2 = Match::with_score(artist.clone(), 50);
let matches = vec![artist_match_1.clone(), artist_match_2.clone()]; let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
(artist, matches) (artist, matches)
@ -445,8 +445,8 @@ mod tests {
let album_1 = COLLECTION[1].albums[0].meta.clone(); let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone(); let album_4 = COLLECTION[1].albums[3].meta.clone();
let album_match_1_1 = Match::new(100, album_1.clone()); let album_match_1_1 = Match::with_score(album_1.clone(), 100);
let album_match_1_2 = Match::new(50, album_4.clone()); let album_match_1_2 = Match::with_score(album_4.clone(), 50);
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()]; let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
(album_1, matches_1) (album_1, matches_1)
@ -456,8 +456,8 @@ mod tests {
let album_1 = COLLECTION[1].albums[0].meta.clone(); let album_1 = COLLECTION[1].albums[0].meta.clone();
let album_4 = COLLECTION[1].albums[3].meta.clone(); let album_4 = COLLECTION[1].albums[3].meta.clone();
let album_match_4_1 = Match::new(100, album_4.clone()); let album_match_4_1 = Match::with_score(album_4.clone(), 100);
let album_match_4_2 = Match::new(30, album_1.clone()); let album_match_4_2 = Match::with_score(album_1.clone(), 30);
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()]; let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
(album_4, matches_4) (album_4, matches_4)
@ -539,7 +539,7 @@ mod tests {
fn lookup_artist_expectation( fn lookup_artist_expectation(
musicbrainz: &mut MockIMusicBrainz, musicbrainz: &mut MockIMusicBrainz,
mbid: &Mbid, mbid: &Mbid,
lookup: &Lookup<ArtistMeta>, lookup: &Match<ArtistMeta>,
) { ) {
let result = Ok(lookup.clone()); let result = Ok(lookup.clone());
musicbrainz musicbrainz
@ -554,7 +554,7 @@ mod tests {
let mut musicbrainz = musicbrainz(); let mut musicbrainz = musicbrainz();
let mbid = mbid(); let mbid = mbid();
let artist = COLLECTION[3].meta.clone(); let artist = COLLECTION[3].meta.clone();
let lookup = Lookup::new(artist.clone()); let lookup = Match::item(artist.clone());
lookup_artist_expectation(&mut musicbrainz, &mbid, &lookup); lookup_artist_expectation(&mut musicbrainz, &mbid, &lookup);
let mut event_sender = event_sender(); let mut event_sender = event_sender();
@ -581,7 +581,7 @@ mod tests {
fn lookup_release_group_expectation( fn lookup_release_group_expectation(
musicbrainz: &mut MockIMusicBrainz, musicbrainz: &mut MockIMusicBrainz,
mbid: &Mbid, mbid: &Mbid,
lookup: &Lookup<AlbumMeta>, lookup: &Match<AlbumMeta>,
) { ) {
let result = Ok(lookup.clone()); let result = Ok(lookup.clone());
musicbrainz musicbrainz
@ -596,7 +596,7 @@ mod tests {
let mut musicbrainz = musicbrainz(); let mut musicbrainz = musicbrainz();
let mbid = mbid(); let mbid = mbid();
let album = COLLECTION[1].albums[0].meta.clone(); let album = COLLECTION[1].albums[0].meta.clone();
let lookup = Lookup::new(album.clone()); let lookup = Match::item(album.clone());
lookup_release_group_expectation(&mut musicbrainz, &mbid, &lookup); lookup_release_group_expectation(&mut musicbrainz, &mbid, &lookup);
let mut event_sender = event_sender(); let mut event_sender = event_sender();

View File

@ -3,32 +3,48 @@
#[cfg(test)] #[cfg(test)]
use mockall::automock; use mockall::automock;
use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid}; use musichoard::{
collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid},
external::musicbrainz::api::{NextPage, PageSettings},
};
/// Trait for interacting with the MusicBrainz API.
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
pub trait IMusicBrainz { pub trait IMusicBrainz {
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Lookup<ArtistMeta>, Error>; fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Match<ArtistMeta>, Error>;
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Lookup<AlbumMeta>, Error>; fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Match<AlbumMeta>, Error>;
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error>; fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Match<ArtistMeta>>, Error>;
fn search_release_group( fn search_release_group(
&mut self, &mut self,
arid: &Mbid, arid: &Mbid,
album: &AlbumMeta, album: &AlbumMeta,
) -> Result<Vec<Match<AlbumMeta>>, Error>; ) -> Result<Vec<Match<AlbumMeta>>, Error>;
fn browse_release_group(
&mut self,
artist: &Mbid,
paging: &mut PageSettings,
) -> Result<Paged<Vec<Match<AlbumMeta>>>, Error>;
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match<T> { pub struct Match<T> {
pub score: u8, pub score: Option<u8>,
pub item: T, pub item: T,
pub disambiguation: Option<String>, pub disambiguation: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] impl<T> Match<T> {
pub struct Lookup<T> { pub fn item(item: T) -> Self {
Match {
score: None,
item,
disambiguation: None,
}
}
}
pub struct Paged<T> {
pub item: T, pub item: T,
pub disambiguation: Option<String>, pub next: NextPage,
} }
pub type Error = musichoard::external::musicbrainz::api::Error; pub type Error = musichoard::external::musicbrainz::api::Error;

View File

@ -5,10 +5,7 @@ use musichoard::collection::{
track::{TrackFormat, TrackQuality}, track::{TrackFormat, TrackQuality},
}; };
use crate::tui::{ use crate::tui::app::{MatchOption, MatchStateInfo};
app::{MatchOption, MatchStateInfo},
lib::interface::musicbrainz::api::{Lookup, Match},
};
pub struct UiDisplay; pub struct UiDisplay;
@ -129,23 +126,24 @@ impl UiDisplay {
} }
} }
pub fn display_search_option_artist(match_option: &MatchOption<Match<ArtistMeta>>) -> String { pub fn display_match_option_artist(match_option: &MatchOption<ArtistMeta>) -> String {
match match_option { Self::display_match_option(Self::display_option_artist, match_option)
MatchOption::Some(match_artist) => format!(
"{} ({}%)",
Self::display_option_artist(&match_artist.item, &match_artist.disambiguation),
match_artist.score,
),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
}
} }
pub fn display_lookup_option_artist(lookup_option: &MatchOption<Lookup<ArtistMeta>>) -> String { pub fn display_match_option_album(match_option: &MatchOption<AlbumMeta>) -> String {
match lookup_option { Self::display_match_option(Self::display_option_album, match_option)
MatchOption::Some(match_artist) => { }
Self::display_option_artist(&match_artist.item, &match_artist.disambiguation)
} fn display_match_option<Fn, T>(display_fn: Fn, match_option: &MatchOption<T>) -> String
where
Fn: FnOnce(&T, &Option<String>) -> String,
{
match match_option {
MatchOption::Some(match_artist) => format!(
"{}{}",
display_fn(&match_artist.item, &match_artist.disambiguation),
Self::display_option_score(match_artist.score),
),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(), MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(), MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
} }
@ -163,27 +161,7 @@ impl UiDisplay {
) )
} }
pub fn display_search_option_album(match_option: &MatchOption<Match<AlbumMeta>>) -> String { fn display_option_album(album: &AlbumMeta, _disambiguation: &Option<String>) -> String {
match match_option {
MatchOption::Some(match_album) => format!(
"{} ({}%)",
Self::display_option_album(&match_album.item),
match_album.score,
),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
}
}
pub fn display_lookup_option_album(lookup_option: &MatchOption<Lookup<AlbumMeta>>) -> String {
match lookup_option {
MatchOption::Some(match_album) => Self::display_option_album(&match_album.item),
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
}
}
fn display_option_album(album: &AlbumMeta) -> String {
format!( format!(
"{:010} | {} [{}]", "{:010} | {} [{}]",
UiDisplay::display_album_date(&album.date), UiDisplay::display_album_date(&album.date),
@ -192,6 +170,10 @@ impl UiDisplay {
) )
} }
fn display_option_score(score: Option<u8>) -> String {
score.map(|s| format!(" ({s}%)")).unwrap_or_default()
}
fn display_cannot_have_mbid() -> &'static str { fn display_cannot_have_mbid() -> &'static str {
"-- Cannot have a MusicBrainz Identifier --" "-- Cannot have a MusicBrainz Identifier --"
} }

View File

@ -2,7 +2,7 @@ use musichoard::collection::{album::AlbumMeta, artist::ArtistMeta};
use ratatui::widgets::{List, ListItem}; use ratatui::widgets::{List, ListItem};
use crate::tui::{ use crate::tui::{
app::{ListOption, MatchStateInfo, WidgetState}, app::{MatchOption, MatchStateInfo, WidgetState},
ui::display::UiDisplay, ui::display::UiDisplay,
}; };
@ -22,19 +22,12 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
fn artists( fn artists(
matching: &ArtistMeta, matching: &ArtistMeta,
matches: &'a ListOption<ArtistMeta>, matches: &'a Vec<MatchOption<ArtistMeta>>,
state: &'b mut WidgetState, state: &'b mut WidgetState,
) -> Self { ) -> Self {
let matching = UiDisplay::display_artist_matching(matching); let matching = UiDisplay::display_artist_matching(matching);
let list = match matches { let list = Self::display_list(UiDisplay::display_match_option_artist, matches);
ListOption::Search(matches) => {
Self::display_list(UiDisplay::display_search_option_artist, matches)
}
ListOption::Lookup(matches) => {
Self::display_list(UiDisplay::display_lookup_option_artist, matches)
}
};
MatchOverlay { MatchOverlay {
matching, matching,
@ -45,19 +38,12 @@ impl<'a, 'b> MatchOverlay<'a, 'b> {
fn albums( fn albums(
matching: &AlbumMeta, matching: &AlbumMeta,
matches: &'a ListOption<AlbumMeta>, matches: &'a Vec<MatchOption<AlbumMeta>>,
state: &'b mut WidgetState, state: &'b mut WidgetState,
) -> Self { ) -> Self {
let matching = UiDisplay::display_album_matching(matching); let matching = UiDisplay::display_album_matching(matching);
let list = match matches { let list = Self::display_list(UiDisplay::display_match_option_album, matches);
ListOption::Search(matches) => {
Self::display_list(UiDisplay::display_search_option_album, matches)
}
ListOption::Lookup(matches) => {
Self::display_list(UiDisplay::display_lookup_option_album, matches)
}
};
MatchOverlay { MatchOverlay {
matching, matching,

View File

@ -204,7 +204,7 @@ mod tests {
use crate::tui::{ use crate::tui::{
app::{AppPublic, AppPublicInner, Delta, MatchStatePublic}, app::{AppPublic, AppPublicInner, Delta, MatchStatePublic},
lib::interface::musicbrainz::api::{Lookup, Match}, lib::interface::musicbrainz::api::Match,
testmod::COLLECTION, testmod::COLLECTION,
tests::terminal, tests::terminal,
}; };
@ -331,7 +331,7 @@ mod tests {
fn artist_matches() -> MatchStateInfo { fn artist_matches() -> MatchStateInfo {
let artist = artist_meta(); let artist = artist_meta();
let artist_match = Match::new(80, artist.clone()); let artist_match = Match::with_score(artist.clone(), 80);
let list = vec![artist_match.clone(), artist_match.clone()]; let list = vec![artist_match.clone(), artist_match.clone()];
let mut info = MatchStateInfo::artist_search(artist, list); let mut info = MatchStateInfo::artist_search(artist, list);
@ -342,7 +342,7 @@ mod tests {
fn artist_lookup() -> MatchStateInfo { fn artist_lookup() -> MatchStateInfo {
let artist = artist_meta(); let artist = artist_meta();
let artist_lookup = Lookup::new(artist.clone()); let artist_lookup = Match::item(artist.clone());
let mut info = MatchStateInfo::artist_lookup(artist, artist_lookup); let mut info = MatchStateInfo::artist_lookup(artist, artist_lookup);
info.push_cannot_have_mbid(); info.push_cannot_have_mbid();
@ -369,7 +369,7 @@ mod tests {
fn album_matches() -> MatchStateInfo { fn album_matches() -> MatchStateInfo {
let artist_id = album_artist_id(); let artist_id = album_artist_id();
let album = album_meta(); let album = album_meta();
let album_match = Match::new(80, album.clone()); let album_match = Match::with_score(album.clone(), 80);
let list = vec![album_match.clone(), album_match.clone()]; let list = vec![album_match.clone(), album_match.clone()];
let mut info = MatchStateInfo::album_search(artist_id, album, list); let mut info = MatchStateInfo::album_search(artist_id, album, list);
@ -381,7 +381,7 @@ mod tests {
fn album_lookup() -> MatchStateInfo { fn album_lookup() -> MatchStateInfo {
let artist_id = album_artist_id(); let artist_id = album_artist_id();
let album = album_meta(); let album = album_meta();
let album_lookup = Lookup::new(album.clone()); let album_lookup = Match::item(album.clone());
let mut info = MatchStateInfo::album_lookup(artist_id, album, album_lookup); let mut info = MatchStateInfo::album_lookup(artist_id, album, album_lookup);
info.push_cannot_have_mbid(); info.push_cannot_have_mbid();