artifacts: implement musicbrainz tooling

This commit is contained in:
2026-06-16 11:32:15 +02:00
parent ac6cb425ac
commit d69ba43a6b
7 changed files with 177 additions and 56 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -10,7 +12,7 @@ pub struct BandcampQueryArgs {
impl Into<Artifact> for bandcamp::Artist {
fn into(self) -> Artifact {
Artifact::Artist(Artist { name: self.name, bio: self.bio, location: self.location, sources: vec![SourceID::Bandcamp(self.id)] })
Artifact::Artist(Artist { name: self.name, bio: self.bio, location: self.location, sources: HashSet::from([SourceID::Bandcamp(self.id)])})
}
}
@@ -22,7 +24,7 @@ impl Into<Artifact> for bandcamp::Album {
artist: self.band.name,
credits: self.credits,
release_date: Some(self.release_date),
sources: vec!{SourceID::Bandcamp(self.id)}
sources: HashSet::from([SourceID::Bandcamp(self.id)])
})
}
}
+22 -5
View File
@@ -1,4 +1,4 @@
use std::process::{Command, Stdio};
use std::{collections::HashSet, process::{Command, Stdio}};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -22,11 +22,17 @@ struct BeetsTrack {
genres: Option<Vec<String>>,
label: Option<String>,
title: String,
year: u32
year: u32,
mb_trackid: Option<String>
}
impl Into<Artifact> for BeetsTrack {
fn into(self) -> Artifact {
let sources = if let Some(mbid) = self.mb_trackid {
HashSet::from([SourceID::Beets, SourceID::Musicbrainz(mbid)])
} else {
HashSet::from([SourceID::Beets])
};
Artifact::Track(Track {
title: self.title,
label: self.label,
@@ -35,7 +41,7 @@ impl Into<Artifact> for BeetsTrack {
album: Some(self.album),
artist: Some(self.artist),
bpm: None,
sources: vec![SourceID::Beets]
sources
})
}
}
@@ -43,25 +49,36 @@ impl Into<Artifact> for BeetsTrack {
impl BeatsQueryArgs {
pub fn execute(self) -> Result<Vec<Artifact>, ()> {
let mut beets_cmd = Command::new("beet");
beets_cmd.args(["export", "-f", "json", "-i", "title,label,year,genres,album,artist"]);
beets_cmd.args(["export", "-f", "json", "-i", "title,label,year,genres,album,artist,mb_trackid"]);
let mut valid = false;
if let Some(artist) = self.artist {
beets_cmd.arg(format!("artist:{}", artist));
valid = true;
}
if let Some(genre) = self.genre {
beets_cmd.arg(format!("genre:{}", genre));
valid = true;
}
if let Some(album) = self.album {
beets_cmd.arg(format!("album:{}", album));
valid = true;
}
if let Some(title) = self.title {
beets_cmd.arg(format!("title:{}", title));
valid = true;
}
if let Some(year) = self.year {
beets_cmd.arg(format!("year:{}", year));
valid = true;
}
if !valid {
log::warn!("Tried to execute an empty beets query");
return Err(())
}
log::debug!("Executing beets: {:?}", beets_cmd);
if let Ok(output) = beets_cmd.stdout(Stdio::piped()).spawn().unwrap().wait_with_output() {
if let Ok(output) = beets_cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap().wait_with_output() {
match serde_json::from_str::<Vec<BeetsTrack>>(str::from_utf8(&output.stdout).unwrap()) {
Ok(track) => Ok(track.into_iter().map(|t| { t.into()}).collect()),
Err(err) => {
+5 -3
View File
@@ -1,3 +1,5 @@
use std::collections::HashSet;
use sqlite::OpenFlags;
use crate::artifacts::{Album, Artifact, Artist, SourceID, Track};
@@ -39,20 +41,20 @@ impl MixxxDB {
album: Some(album.into()),
title: title.into(),
bpm: Some(bpm),
sources: vec![SourceID::Mixxx],
sources: HashSet::from([SourceID::Mixxx]),
..Default::default()
}));
ret.push(Artifact::Album(Album {
artist: artist.into(),
title: album.into(),
sources: vec![SourceID::Mixxx],
sources: HashSet::from([SourceID::Mixxx]),
..Default::default()
}));
ret.push(Artifact::Artist(Artist {
name: artist.into(),
sources: vec![SourceID::Mixxx],
sources: HashSet::from([SourceID::Mixxx]),
..Default::default()
}));
}
+33 -10
View File
@@ -1,13 +1,17 @@
use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub mod bandcamp;
pub mod mixxx;
pub mod beets;
pub mod musicbrainz;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SourceID {
Bandcamp(u64),
Musicbrainz(String),
Mixxx,
Beets
}
@@ -20,7 +24,7 @@ pub struct Artist {
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
pub sources: Vec<SourceID>
pub sources: HashSet<SourceID>
}
impl PartialEq for Artist {
@@ -48,7 +52,7 @@ pub struct Album {
#[serde(skip_serializing_if = "Option::is_none")]
pub release_date: Option<DateTime<Utc>>,
pub sources: Vec<SourceID>
pub sources: HashSet<SourceID>
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
@@ -68,7 +72,7 @@ pub struct Track {
#[serde(skip_serializing_if = "Option::is_none")]
pub bpm: Option<f64>,
pub sources: Vec<SourceID>
pub sources: HashSet<SourceID>
}
impl PartialEq for Track {
@@ -109,28 +113,47 @@ macro_rules! merge_fields {
($this:tt, $that:tt, $($fields:tt),+) => {
$(
merge_fields!($this, $that, $fields);
for src in &$that.sources {
$this.sources.insert(src.clone());
}
)+
}
}
impl Artifact {
pub fn merge(&mut self, other: Artifact) {
impl Merge for Artifact {
fn merge(&mut self, other: Self) {
if *self != other {
return;
}
match (self, other) {
(Artifact::Track(this_track), Artifact::Track(that_track)) => {
(Self::Track(this_track), Self::Track(that_track)) => {
merge_fields!(this_track, that_track, album, label, year, artist, bpm);
// FIXME: genres
},
(Artifact::Album(this_album), Artifact::Album(that_album)) => {
(Self::Album(this_album), Self::Album(that_album)) => {
merge_fields!(this_album, that_album, about, credits, release_date);
},
(Artifact::Artist(this_artist), Artifact::Artist(that_artist)) => {
(Self::Artist(this_artist), Self::Artist(that_artist)) => {
merge_fields!(this_artist, that_artist, bio, location);
},
_ => ()
}
}
}
impl<M: Merge + PartialEq> Merge for Vec<M> {
fn merge(&mut self, other: Self) {
for artifact in other {
if let Some(merge_target) = self.iter_mut().find(|a| { **a == artifact }) {
merge_target.merge(artifact);
} else {
self.push(artifact);
}
}
}
}
pub trait Merge {
fn merge(&mut self, other: Self);
}
+50
View File
@@ -0,0 +1,50 @@
use std::collections::HashSet;
use musicbrainz_rs::entity::recording::Recording;
use musicbrainz_rs::prelude::*;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::artifacts::{Album, Artifact, Artist, SourceID, Track};
#[derive(Debug, Default, Deserialize, Serialize, JsonSchema)]
pub struct MusicbrainzQueryArgs {
pub mb_ids: Vec<String>
}
pub async fn search_artifacts(query: MusicbrainzQueryArgs) -> Result<Vec<Artifact>, musicbrainz_rs::ApiEndpointError> {
let mut ret = vec![];
for mbid in query.mb_ids {
let track = Recording::fetch()
.id(&mbid)
.with_releases().with_artists().with_annotations().execute_async().await?;
for release in track.releases.unwrap_or_default() {
log::debug!("Found new release: {:?}", release);
let first_artist = release.artist_credit.unwrap_or_default().first().unwrap().clone();
ret.push(Artifact::Album(Album {
title: release.title.clone(),
artist: first_artist.name.clone(),
about: release.annotation,
sources: HashSet::from([SourceID::Musicbrainz(release.id.clone())]),
..Default::default()
}));
ret.push(Artifact::Track(Track {
album: Some(release.title),
title: track.title.clone(),
artist: Some(first_artist.artist.name.clone()),
sources: HashSet::from([SourceID::Musicbrainz(release.id.clone())]),
..Default::default()
}));
ret.push(Artifact::Artist(Artist {
name: first_artist.name,
bio: first_artist.artist.annotation,
location: first_artist.artist.area.and_then(|area| { Some(area.name) }),
sources: HashSet::from([SourceID::Musicbrainz(release.id)]),
..Default::default()
}))
}
}
Ok(ret)
}