/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ 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 serde::Deserialize; use std::{collections::BTreeMap, sync::Arc, time::Duration}; use tokio::{ sync::Semaphore, time::{sleep_until, Instant}, }; pub struct MusicBrainz { client: Client, rate_limit: Arc, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbRecordingRel { pub id: String, pub first_release_date: String, pub title: String, pub isrcs: Vec, pub video: bool, pub disambiguation: String, pub length: u32, pub relations: Vec, pub artist_credit: Vec, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbArtistCredit { pub name: String, pub artist: MbArtist, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbRelation { pub direction: String, pub r#type: String, pub type_id: String, pub begin: Option, pub end: Option, pub ended: bool, pub target_type: String, pub target_credit: String, pub source_credit: String, pub attributes: Vec, pub attribute_ids: BTreeMap, pub attribute_values: BTreeMap, pub work: Option, pub artist: Option, pub url: Option, pub recording: Option, pub series: Option, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbSeries { pub id: String, pub r#type: String, pub type_id: String, pub name: String, pub disambiguation: String, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbRecording { pub id: String, pub title: String, pub isrcs: Vec, pub video: bool, pub disambiguation: String, pub length: u32, pub artist_credit: Vec, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbWork { pub id: String, pub r#type: String, pub type_id: String, pub languages: Vec, pub iswcs: Vec, pub language: Option, pub title: String, pub attributes: Vec, pub disambiguation: String, } #[derive(Debug, Deserialize, Encode, Decode)] #[serde(rename_all = "kebab-case")] pub struct MbArtist { pub id: String, pub r#type: Option, pub type_id: Option, pub name: String, pub disambiguation: String, pub country: Option, pub 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() -> Self { let client = ClientBuilder::new() .default_headers(HeaderMap::from_iter([ ( HeaderName::from_static("accept"), HeaderValue::from_static("application/json"), ), ( HeaderName::from_static("user-agent"), HeaderValue::from_static(USER_AGENT), ), ])) .build() .unwrap(); Self { client, // 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)), } } pub async fn lookup_recording(&self, id: String) -> Result> { 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", "artists", "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::() .await?; tokio::task::spawn(async move { sleep_until(permit_drop_ts).await; drop(_permit); }); Ok(resp) }) .await } }