//! DDEX ERN 4.1 registration with Master Pattern + Wikidata + creator attribution. use serde::{Deserialize, Serialize}; use shared::master_pattern::{PatternFingerprint, RarityTier}; use tracing::{info, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DdexRegistration { pub isrc: String, pub iswc: Option, } /// A single credited contributor for DDEX delivery (songwriter, publisher, etc.). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DdexContributor { pub wallet_address: String, pub ipi_number: String, pub role: String, pub bps: u16, } /// Escape a string for safe embedding in XML content or attribute values. /// Prevents XML injection from user-controlled inputs. fn xml_escape(s: &str) -> String { s.chars() .flat_map(|c| match c { '&' => "&".chars().collect::>(), '<' => "<".chars().collect(), '>' => ">".chars().collect(), '"' => """.chars().collect(), '\'' => "'".chars().collect(), c => vec![c], }) .collect() } pub fn build_ern_xml_with_contributors( title: &str, isrc: &str, cid: &str, fp: &PatternFingerprint, wiki: &crate::wikidata::WikidataArtist, contributors: &[DdexContributor], ) -> String { // SECURITY: XML-escape all user-controlled inputs before embedding in XML let title = xml_escape(title); let isrc = xml_escape(isrc); let cid = xml_escape(cid); let wikidata_qid = xml_escape(wiki.qid.as_deref().unwrap_or("")); let wikidata_url = xml_escape(wiki.wikidata_url.as_deref().unwrap_or("")); let mbid = xml_escape(wiki.musicbrainz_id.as_deref().unwrap_or("")); let label_name = xml_escape(wiki.label_name.as_deref().unwrap_or("")); let country = xml_escape(wiki.country.as_deref().unwrap_or("")); let genres = xml_escape(&wiki.genres.join(", ")); let tier = RarityTier::from_band(fp.band); // Build contributor XML block let contributor_xml: String = contributors .iter() .enumerate() .map(|(i, c)| { let wallet = xml_escape(&c.wallet_address); let ipi = xml_escape(&c.ipi_number); let role = xml_escape(&c.role); let bps = c.bps; // DDEX ERN 4.1 ResourceContributor element with extended retrosync namespace format!( r#" {role} IPI:{ipi} {role} {wallet} {bps} "#, seq = i + 1, role = role, ipi = ipi, wallet = wallet, bps = bps, ) }) .collect::>() .join("\n"); format!( r#" retrosync-{isrc} PADPIDA2024RETROSYNC Retrosync Media Group {ts} MusicalWorkSoundRecording {isrc} {title} {contributor_xml} {band} {band_name} {residue} {prime} {cycle} {dr} {closure} {cid} {wikidata_qid} {wikidata_url} {mbid} {label_name} {country} {genres} {isrc} TrackRelease A1 "#, isrc = isrc, title = title, cid = cid, contributor_xml = contributor_xml, band = fp.band, band_name = tier.as_str(), residue = fp.band_residue, prime = fp.mapped_prime, cycle = fp.cycle_position, dr = fp.digit_root, closure = fp.closure_verified, ts = chrono::Utc::now().to_rfc3339(), wikidata_qid = wikidata_qid, wikidata_url = wikidata_url, mbid = mbid, label_name = label_name, country = country, genres = genres, ) } pub async fn register( title: &str, isrc: &shared::types::Isrc, cid: &shared::types::BtfsCid, fp: &PatternFingerprint, wiki: &crate::wikidata::WikidataArtist, ) -> anyhow::Result { register_with_contributors(title, isrc, cid, fp, wiki, &[]).await } pub async fn register_with_contributors( title: &str, isrc: &shared::types::Isrc, cid: &shared::types::BtfsCid, fp: &PatternFingerprint, wiki: &crate::wikidata::WikidataArtist, contributors: &[DdexContributor], ) -> anyhow::Result { let xml = build_ern_xml_with_contributors(title, &isrc.0, &cid.0, fp, wiki, contributors); let ddex_url = std::env::var("DDEX_SANDBOX_URL").unwrap_or_else(|_| "https://sandbox.ddex.net/ern".into()); let api_key = std::env::var("DDEX_API_KEY").ok(); info!(isrc=%isrc, band=%fp.band, contributors=%contributors.len(), "Submitting ERN 4.1 to DDEX"); if std::env::var("DDEX_DEV_MODE").unwrap_or_default() == "1" { warn!("DDEX_DEV_MODE=1 — stub"); return Ok(DdexRegistration { isrc: isrc.0.clone(), iswc: None, }); } let mut client = reqwest::Client::new() .post(&ddex_url) .header("Content-Type", "application/xml"); if let Some(key) = api_key { client = client.header("Authorization", format!("Bearer {key}")); } let resp = client.body(xml).send().await?; if !resp.status().is_success() { anyhow::bail!("DDEX failed: {}", resp.status()); } Ok(DdexRegistration { isrc: isrc.0.clone(), iswc: None, }) }