diff options
| author | metamuffin <metamuffin@disroot.org> | 2025-12-10 16:21:38 +0100 |
|---|---|---|
| committer | metamuffin <metamuffin@disroot.org> | 2025-12-10 16:21:38 +0100 |
| commit | a0cfd77b4d19c43a28c4d82072e6ff136e336af3 (patch) | |
| tree | 05df9f5faa54cef0ae4136fffddea57fbbafee6b /import/src/plugins/acoustid.rs | |
| parent | 242d5763d451eed2402be7afde50cd9fa0d6bc79 (diff) | |
| download | jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar.bz2 jellything-a0cfd77b4d19c43a28c4d82072e6ff136e336af3.tar.zst | |
refactor import plugins part 1
Diffstat (limited to 'import/src/plugins/acoustid.rs')
| -rw-r--r-- | import/src/plugins/acoustid.rs | 174 |
1 files changed, 174 insertions, 0 deletions
diff --git a/import/src/plugins/acoustid.rs b/import/src/plugins/acoustid.rs new file mode 100644 index 0000000..154b0a2 --- /dev/null +++ b/import/src/plugins/acoustid.rs @@ -0,0 +1,174 @@ +/* + 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 <metamuffin.org> +*/ +use crate::{ + USER_AGENT, + plugins::{ImportContext, ImportPlugin}, +}; +use anyhow::{Context, Result}; +use jellycache::{HashKey, cache_memory}; +use jellycommon::{IdentifierType, NodeID}; +use jellyremuxer::matroska::Segment; +use log::info; +use reqwest::{ + Client, ClientBuilder, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + io::Read, + path::Path, + process::{Command, Stdio}, + sync::Arc, + time::Duration, +}; +use tokio::{ + runtime::Handle, + sync::Semaphore, + time::{Instant, sleep_until}, +}; + +pub(crate) struct AcoustID { + client: Client, + key: String, + rate_limit: Arc<Semaphore>, +} + +#[derive(Debug, Hash, Clone, Serialize, Deserialize)] +pub(crate) struct Fingerprint { + duration: u32, + fingerprint: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct FpCalcOutput { + duration: f32, + fingerprint: String, +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct AcoustIDLookupResultRecording { + id: String, +} +#[derive(Serialize, Deserialize)] +pub(crate) struct AcoustIDLookupResult { + id: String, + score: f32, + #[serde(default)] + recordings: Vec<AcoustIDLookupResultRecording>, +} +#[derive(Serialize, Deserialize)] +pub(crate) struct AcoustIDLookupResponse { + status: String, + results: Vec<AcoustIDLookupResult>, +} + +impl AcoustID { + pub fn new(api_key: &str) -> 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 3 req/s according to acoustid docs, each lock is therefore held for 1s + // this implementation also never sends more than 3 requests in-flight. + rate_limit: Arc::new(Semaphore::new(3)), + key: api_key.to_owned(), + } + } + + pub fn get_atid_mbid(&self, fp: &Fingerprint, rt: &Handle) -> Result<Option<(String, String)>> { + let res = self.lookup(fp.to_owned(), rt)?; + for r in &res.results { + if let Some(k) = r.recordings.first() { + return Ok(Some((r.id.clone(), k.id.clone()))); + } + } + Ok(None) + } + + pub fn lookup(&self, fp: Fingerprint, rt: &Handle) -> Result<Arc<AcoustIDLookupResponse>> { + cache_memory(&format!("ext/acoustid/{}.json", HashKey(&fp)) , move || rt.block_on(async { + let _permit = self.rate_limit.clone().acquire_owned().await?; + let permit_drop_ts = Instant::now() + Duration::SECOND; + info!("acoustid lookup"); + + let duration = fp.duration; + let fingerprint = fp.fingerprint.replace("=", "%3D"); + let client = &self.key; + let body = format!("format=json&meta=recordingids&client={client}&duration={duration}&fingerprint={fingerprint}"); + + let resp = self + .client + .post("https://api.acoustid.org/v2/lookup".to_string()) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await?.error_for_status()?.json::<AcoustIDLookupResponse>().await?; + + tokio::task::spawn(async move { + sleep_until(permit_drop_ts).await; + drop(_permit); + }); + + Ok(resp) + })) + .context("acoustid lookup") + } +} + +pub(crate) fn acoustid_fingerprint(path: &Path) -> Result<Arc<Fingerprint>> { + cache_memory( + &format!("media/chromaprint/{}.json", HashKey(path)), + move || { + let child = Command::new("fpcalc") + .arg("-json") + .arg(path) + .stdout(Stdio::piped()) + .spawn() + .context("fpcalc")?; + + let mut buf = Vec::new(); + child + .stdout + .unwrap() + .read_to_end(&mut buf) + .context("read fpcalc output")?; + + let out: FpCalcOutput = + serde_json::from_slice(&buf).context("parsing fpcalc output")?; + let out = Fingerprint { + duration: out.duration as u32, + fingerprint: out.fingerprint, + }; + Ok(out) + }, + ) +} + +impl ImportPlugin for AcoustID { + fn media(&self, ct: &ImportContext, node: NodeID, path: &Path, _seg: &Segment) -> Result<()> { + let fp = acoustid_fingerprint(path)?; + if let Some((atid, mbid)) = self.get_atid_mbid(&fp, &ct.rt)? { + ct.db.update_node_init(node, |n| { + n.identifiers.insert(IdentifierType::AcoustIdTrack, atid); + n.identifiers + .insert(IdentifierType::MusicbrainzRecording, mbid); + Ok(()) + })?; + }; + Ok(()) + } +} |