diff options
Diffstat (limited to 'import')
-rw-r--r-- | import/src/lib.rs | 49 | ||||
-rw-r--r-- | import/src/musicbrainz.rs | 128 |
2 files changed, 168 insertions, 9 deletions
diff --git a/import/src/lib.rs b/import/src/lib.rs index b45361a..f033171 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -18,6 +18,7 @@ use jellyclient::{ }; use jellyremuxer::metadata::checked_matroska_metadata; use log::info; +use musicbrainz::MusicBrainz; use rayon::iter::{ParallelBridge, ParallelIterator}; use regex::Regex; use std::{ @@ -58,6 +59,7 @@ struct Apis { trakt: Option<Trakt>, tmdb: Option<Tmdb>, acoustid: Option<AcoustID>, + musicbrainz: MusicBrainz, } pub fn is_importing() -> bool { @@ -84,6 +86,7 @@ fn import(db: &Database, incremental: bool) -> Result<()> { trakt: SECRETS.api.trakt.as_ref().map(|key| Trakt::new(key)), tmdb: SECRETS.api.tmdb.as_ref().map(|key| Tmdb::new(key)), acoustid: SECRETS.api.acoustid.as_ref().map(|key| AcoustID::new(key)), + musicbrainz: MusicBrainz::new(), }; let rthandle = Handle::current(); @@ -367,11 +370,15 @@ fn import_media_file( for (key, value) in &tags { match key.as_str() { - "MUSICBRAINZ_TRACKID" => eids.insert("musicbrainz.track".to_string(), value.to_owned()), + "MUSICBRAINZ_TRACKID" => { + eids.insert("musicbrainz.recording".to_string(), value.to_owned()) + } "MUSICBRAINZ_ARTISTID" => { eids.insert("musicbrainz.artist".to_string(), value.to_owned()) } - "MUSICBRAINZ_ALBUMID" => eids.insert("musicbrainz.album".to_string(), value.to_owned()), + "MUSICBRAINZ_ALBUMID" => { + eids.insert("musicbrainz.release".to_string(), value.to_owned()) + } "MUSICBRAINZ_ALBUMARTISTID" => { eids.insert("musicbrainz.albumartist".to_string(), value.to_owned()) } @@ -393,10 +400,12 @@ fn import_media_file( .get_atid_mbid(&fp), )? { eids.insert("acoustid.track".to_string(), atid); - eids.insert("musicbrainz.track".to_string(), mbid); + eids.insert("musicbrainz.recording".to_string(), mbid); }; } + let mbrec = eids.get("musicbrainz.recording").cloned(); + db.update_node_init(node, |node| { node.slug = slug; node.title = info.title.or(node.title.clone()); @@ -599,6 +608,9 @@ fn import_media_file( for tok in filename_toks { apply_node_flag(db, rthandle, apis, node, tok)?; } + if let Some(mbid) = mbrec { + apply_musicbrainz_recording(db, rthandle, apis, node, mbid)?; + } Ok(()) } @@ -652,6 +664,37 @@ fn apply_node_flag( Ok(()) })?; } + if let Some(mbid) = flag.strip_prefix("mbrec-").or(flag.strip_prefix("mbrec=")) { + apply_musicbrainz_recording(db, rthandle, apis, node, mbid.to_string())? + } + Ok(()) +} + +fn apply_musicbrainz_recording( + db: &Database, + rthandle: &Handle, + apis: &Apis, + node: NodeID, + mbid: String, +) -> Result<()> { + let rec = rthandle.block_on(apis.musicbrainz.lookup_recording(mbid))?; + + db.update_node_init(node, |node| { + node.title = Some(rec.title.clone()); + node.external_ids + .insert("musicbrainz.recording".to_string(), rec.id.to_string()); + if let Some(a) = rec.artist_credit.get(0) { + node.subtitle = Some(a.artist.name.clone()); + node.external_ids + .insert("musicbrainz.artist".to_string(), a.artist.id.to_string()); + } + + for isrc in &rec.isrcs { + node.external_ids + .insert("isrc".to_string(), isrc.to_string()); + } + Ok(()) + })?; Ok(()) } diff --git a/import/src/musicbrainz.rs b/import/src/musicbrainz.rs index 059b8f5..7a58cec 100644 --- a/import/src/musicbrainz.rs +++ b/import/src/musicbrainz.rs @@ -4,22 +4,95 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ +use crate::USER_AGENT; +use anyhow::Result; +use bincode::{Decode, Encode}; +use jellybase::cache::async_cache_memory; use reqwest::{ header::{HeaderMap, HeaderName, HeaderValue}, Client, ClientBuilder, }; -use std::sync::Arc; -use tokio::sync::Semaphore; -use crate::USER_AGENT; +use serde::Deserialize; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; +use tokio::{ + sync::Semaphore, + time::{sleep_until, Instant}, +}; pub struct MusicBrainz { client: Client, - key: String, rate_limit: Arc<Semaphore>, } +#[derive(Debug, Deserialize, Encode, Decode)] +#[serde(rename_all = "kebab-case")] +pub struct MbRecording { + pub id: String, + pub first_release_date: String, + pub title: String, + pub isrcs: Vec<String>, + pub video: bool, + pub disambiguation: String, + pub length: u32, + pub relations: Vec<MbRelation>, +} + +#[derive(Debug, Deserialize, Encode, Decode)] +#[serde(rename_all = "kebab-case")] +pub struct MbRelation { + direction: String, + r#type: String, + type_id: String, + begin: Option<String>, + end: Option<String>, + ended: bool, + target_type: String, + target_credit: String, + source_credit: String, + attributes: Vec<String>, + attribute_ids: BTreeMap<String, String>, + attribute_values: BTreeMap<String, String>, + + work: Option<MbWork>, + artist: Option<MbArtist>, + url: Option<MbUrl>, +} + +#[derive(Debug, Deserialize, Encode, Decode)] +#[serde(rename_all = "kebab-case")] +pub struct MbWork { + id: String, + r#type: String, + type_id: String, + languages: Vec<String>, + iswcs: Vec<String>, + language: Option<String>, + title: String, + attributes: Vec<String>, + disambiguation: String, +} + +#[derive(Debug, Deserialize, Encode, Decode)] +#[serde(rename_all = "kebab-case")] +pub struct MbArtist { + id: String, + r#type: String, + type_id: String, + name: String, + disambiguation: String, + country: String, + sort_name: String, +} + +#[derive(Debug, Deserialize, Encode, Decode)] +#[serde(rename_all = "kebab-case")] +pub struct MbUrl { + id: String, + resource: String, +} + impl MusicBrainz { - pub fn new(api_key: &str) -> Self { + pub fn new() -> Self { let client = ClientBuilder::new() .default_headers(HeaderMap::from_iter([ ( @@ -38,7 +111,50 @@ impl MusicBrainz { // send at most 1 req/s according to musicbrainz docs, each lock is held for 10s // this implementation also never sends more than 10 requests in-flight. rate_limit: Arc::new(Semaphore::new(10)), - key: api_key.to_owned(), } } + + pub async fn lookup_recording(&self, id: String) -> Result<Arc<MbRecording>> { + async_cache_memory("api-musicbrainz-recording", id.clone(), || async move { + let _permit = self.rate_limit.clone().acquire_owned().await?; + let permit_drop_ts = Instant::now() + Duration::from_secs(10); + + let inc = [ + "isrcs", + "area-rels", + "artist-rels", + "event-rels", + "genre-rels", + "instrument-rels", + "label-rels", + "place-rels", + "recording-rels", + "release-rels", + "release-group-rels", + "series-rels", + "url-rels", + "work-rels", + ] + .join("+"); + + let resp = self + .client + .get(format!( + "https://musicbrainz.org/ws/2/recording/{id}?inc={inc}" + )) + .send() + .await? + .error_for_status()? + .json::<MbRecording>() + .await?; + + tokio::task::spawn(async move { + sleep_until(permit_drop_ts).await; + drop(_permit); + }); + + Ok(resp) + }) + .await + } } |