/* 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) 2026 metamuffin */ use crate::{ USER_AGENT, plugins::{ImportPlugin, PluginContext, PluginInfo}, source_rank::ObjectImportSourceExt, }; use anyhow::{Context, Result}; use jellycache::{Cache, HashKey}; use jellycommon::*; use jellydb::RowNum; 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, } #[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, } #[derive(Serialize, Deserialize)] pub(crate) struct AcoustIDLookupResponse { status: String, results: Vec, } 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, cache: &Cache, fp: &Fingerprint, rt: &Handle, ) -> Result> { let res = self.lookup(cache, 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, cache: &Cache, fp: Fingerprint, rt: &Handle, ) -> Result> { cache.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::().await?; tokio::task::spawn(async move { sleep_until(permit_drop_ts).await; drop(_permit); }); Ok(resp) })) .context("acoustid lookup") } } pub(crate) fn acoustid_fingerprint(cache: &Cache, path: &Path) -> Result> { cache.cache_memory( &format!("media/chromaprint/{}.json", HashKey(path)), move || { info!("fpcalc {path:?}"); 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 info(&self) -> PluginInfo { PluginInfo { name: "acoustid", tag: MSOURCE_ACOUSTID, handle_media: true, ..Default::default() } } fn media(&self, ct: &PluginContext, node: RowNum, path: &Path, seg: &Segment) -> Result<()> { if !ct.iflags.use_acoustid { return Ok(()); } let duration = (seg.info.duration.unwrap_or_default() * seg.info.timestamp_scale as f64) / 1_000_000_000.; if duration > 600. || duration < 10. { return Ok(()); } let fp = acoustid_fingerprint(&ct.ic.cache, path)?; let Some((atid, mbid)) = self.get_atid_mbid(&ct.ic.cache, &fp, &ct.rt)? else { return Ok(()); }; ct.ic.update_node(node, |node| { node.as_object().update(NO_IDENTIFIERS, |ids| { ids.insert_s(ct.is, IDENT_ACOUST_ID_TRACK, &atid) .as_object() .insert_s(ct.is, IDENT_MUSICBRAINZ_RECORDING, &mbid) }) })?; Ok(()) } }