feat(core): add MusicBrainz orchestration methods to QbzCore

Adds qbz-integrations as a dependency of qbz-core and embeds a
MusicBrainzClient on the QbzCore struct (initialized in new()). The
client's own enabled flag governs whether requests fire; calls return
the integration-layer ServiceUnavailable error when disabled.

New methods on QbzCore<A>:

  musicbrainz_is_enabled()
  musicbrainz_set_enabled(enabled)
  musicbrainz_resolve_artist(name) -> Option<ResolvedArtist>
  musicbrainz_get_artist_metadata(mbid) -> ArtistMetadata
  musicbrainz_get_artist_relationships(mbid) -> ArtistRelationships

The metadata method calls location::extract_metadata on the full artist
response and then resolves the real country from the begin_area
hierarchy when a city-level location was found (MB's "country" field is
where the artist is currently active, not where they were born/formed).
The relationships method splits relations into members / past_members /
groups / collaborators by relation_type and direction, mirroring
v2_musicbrainz_get_artist_relationships exactly.

No cache wiring yet — every call hits the network. A cache wrapper can
be plugged in later via a setter without changing the method
signatures.

cc
This commit is contained in:
vicrodh committed 2026-05-20 08:57:50 -06:00
1 parent 508609a5d8
commit 4430d7fb2f
4 files changed
+136

No files matched your search

+133
View File
@@ -14,6 +14,10 @@ use qbz_models::{
RepeatMode, SearchAllResults, SearchResultsPage, StreamUrl, Track, TracksContainer,
UserSession,
};
use qbz_integrations::musicbrainz::{
location, ArtistMetadata, ArtistRelationships, MusicBrainzClient, Period, RelatedArtist,
ResolvedArtist,
};
use qbz_player::{PlaybackState, Player, QueueManager};
use qbz_qobuz::QobuzClient;
@@ -136,6 +140,8 @@ pub struct QbzCore<A: FrontendAdapter> {
queue: Arc<RwLock<QueueManager>>,
/// Audio player
player: Arc<Player>,
/// MusicBrainz client (always present; enable/disable toggle lives inside)
musicbrainz: Arc<MusicBrainzClient>,
/// Whether the core is initialized
initialized: Arc<RwLock<bool>>,
}
@@ -151,6 +157,7 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
client: Arc::new(RwLock::new(None)),
queue: Arc::new(RwLock::new(QueueManager::new())),
player: Arc::new(player),
musicbrainz: Arc::new(MusicBrainzClient::new()),
initialized: Arc::new(RwLock::new(false)),
}
}
@@ -1385,6 +1392,132 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
pub fn queue(&self) -> Arc<RwLock<QueueManager>> {
Arc::clone(&self.queue)
}
// ----- MusicBrainz -------------------------------------------------------
/// Whether MusicBrainz integration is currently enabled.
pub async fn musicbrainz_is_enabled(&self) -> bool {
self.musicbrainz.is_enabled().await
}
/// Enable or disable MusicBrainz integration.
pub async fn musicbrainz_set_enabled(&self, enabled: bool) {
self.musicbrainz.set_enabled(enabled).await;
}
/// Resolve an artist name to a MusicBrainz id. Returns `None` if no
/// confident match is found.
pub async fn musicbrainz_resolve_artist(
&self,
name: &str,
) -> Result<Option<ResolvedArtist>, CoreError> {
self.musicbrainz
.resolve_artist(name)
.await
.map_err(|e| CoreError::Internal(e.to_string()))
}
/// Fetch the artist metadata (location, life_span, genre seeds) for
/// the Origin section of the artist network sidebar. Resolves the
/// real country from the begin_area hierarchy when a city-level
/// location is found, because MB's `country` field is where the
/// artist is active, not where they were born/formed.
pub async fn musicbrainz_get_artist_metadata(
&self,
mbid: &str,
) -> Result<ArtistMetadata, CoreError> {
let artist = self
.musicbrainz
.get_artist_with_relations(mbid)
.await
.map_err(|e| CoreError::Internal(e.to_string()))?;
let mut metadata = location::extract_metadata(&artist);
if let Some(ref mut loc) = metadata.location {
if loc.city.is_some() {
if let Some(ref area_id) = loc.area_id {
if let Ok(Some((country_name, country_code))) =
self.musicbrainz.resolve_area_country(area_id).await
{
loc.display_name = format!("{}, {}", loc.display_name, country_name);
loc.country = Some(country_name);
loc.country_code = country_code;
}
}
}
}
Ok(metadata)
}
/// Fetch the artist relationships (band members, member-of groups,
/// collaborators) for the Relationships section of the sidebar.
/// Splits `member of band` by direction: backward direction lists
/// members of *this* artist (still-active vs ended -> past), forward
/// direction lists groups this artist is a member of.
pub async fn musicbrainz_get_artist_relationships(
&self,
mbid: &str,
) -> Result<ArtistRelationships, CoreError> {
let artist = self
.musicbrainz
.get_artist_with_relations(mbid)
.await
.map_err(|e| CoreError::Internal(e.to_string()))?;
let mut members = Vec::new();
let mut past_members = Vec::new();
let mut groups = Vec::new();
let mut collaborators = Vec::new();
if let Some(relations) = &artist.relations {
for relation in relations {
let Some(related_artist) = &relation.artist else {
continue;
};
let related = RelatedArtist {
mbid: related_artist.id.clone(),
name: related_artist.name.clone(),
role: relation
.attributes
.as_ref()
.and_then(|a| a.first().cloned()),
period: Some(Period {
begin: relation.begin.clone(),
end: relation.end.clone(),
}),
ended: relation.ended.unwrap_or(false),
};
match relation.relation_type.as_str() {
"member of band" => {
if relation.direction.as_deref() == Some("backward") {
if related.ended {
past_members.push(related);
} else {
members.push(related);
}
} else {
groups.push(related);
}
}
"collaboration" => {
collaborators.push(related);
}
_ => {}
}
}
}
Ok(ArtistRelationships {
members,
past_members,
groups,
collaborators,
})
}
}
#[cfg(test)]