Spaces:
Building
Building
| //! 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}; | |
| pub struct DdexRegistration { | |
| pub isrc: String, | |
| pub iswc: Option<String>, | |
| } | |
| /// A single credited contributor for DDEX delivery (songwriter, publisher, etc.). | |
| 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::<Vec<_>>(), | |
| '<' => "<".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#" <ResourceContributor SequenceNumber="{seq}"> | |
| <PartyName><FullName>{role}</FullName></PartyName> | |
| <PartyId>IPI:{ipi}</PartyId> | |
| <ResourceContributorRole>{role}</ResourceContributorRole> | |
| <rs:CreatorWallet>{wallet}</rs:CreatorWallet> | |
| <rs:RoyaltyBps>{bps}</rs:RoyaltyBps> | |
| </ResourceContributor>"#, | |
| seq = i + 1, | |
| role = role, | |
| ipi = ipi, | |
| wallet = wallet, | |
| bps = bps, | |
| ) | |
| }) | |
| .collect::<Vec<_>>() | |
| .join("\n"); | |
| format!( | |
| r#"<?xml version="1.0" encoding="UTF-8"?> | |
| <ern:NewReleaseMessage | |
| xmlns:ern="http://ddex.net/xml/ern/41" | |
| xmlns:mp="http://retrosync.media/xml/master-pattern/1" | |
| xmlns:wd="http://retrosync.media/xml/wikidata/1" | |
| xmlns:rs="http://retrosync.media/xml/creator-attribution/1" | |
| MessageSchemaVersionId="ern/41" LanguageAndScriptCode="en"> | |
| <MessageHeader> | |
| <MessageThreadId>retrosync-{isrc}</MessageThreadId> | |
| <MessageSender> | |
| <PartyId>PADPIDA2024RETROSYNC</PartyId> | |
| <PartyName><FullName>Retrosync Media Group</FullName></PartyName> | |
| </MessageSender> | |
| <MessageCreatedDateTime>{ts}</MessageCreatedDateTime> | |
| </MessageHeader> | |
| <ResourceList> | |
| <SoundRecording> | |
| <SoundRecordingType>MusicalWorkSoundRecording</SoundRecordingType> | |
| <SoundRecordingId><ISRC>{isrc}</ISRC></SoundRecordingId> | |
| <ReferenceTitle><TitleText>{title}</TitleText></ReferenceTitle> | |
| <ResourceContributorList> | |
| {contributor_xml} | |
| </ResourceContributorList> | |
| <mp:MasterPattern> | |
| <mp:Band>{band}</mp:Band> | |
| <mp:BandName>{band_name}</mp:BandName> | |
| <mp:BandResidue>{residue}</mp:BandResidue> | |
| <mp:MappedPrime>{prime}</mp:MappedPrime> | |
| <mp:CyclePosition>{cycle}</mp:CyclePosition> | |
| <mp:DigitRoot>{dr}</mp:DigitRoot> | |
| <mp:ClosureVerified>{closure}</mp:ClosureVerified> | |
| <mp:BtfsCid>{cid}</mp:BtfsCid> | |
| </mp:MasterPattern> | |
| <wd:WikidataEnrichment> | |
| <wd:ArtistQID>{wikidata_qid}</wd:ArtistQID> | |
| <wd:WikidataURL>{wikidata_url}</wd:WikidataURL> | |
| <wd:MusicBrainzArtistID>{mbid}</wd:MusicBrainzArtistID> | |
| <wd:LabelName>{label_name}</wd:LabelName> | |
| <wd:CountryOfOrigin>{country}</wd:CountryOfOrigin> | |
| <wd:Genres>{genres}</wd:Genres> | |
| </wd:WikidataEnrichment> | |
| </SoundRecording> | |
| </ResourceList> | |
| <ReleaseList> | |
| <Release> | |
| <ReleaseId><ISRC>{isrc}</ISRC></ReleaseId> | |
| <ReleaseType>TrackRelease</ReleaseType> | |
| <ReleaseResourceReferenceList> | |
| <ReleaseResourceReference>A1</ReleaseResourceReference> | |
| </ReleaseResourceReferenceList> | |
| </Release> | |
| </ReleaseList> | |
| </ern:NewReleaseMessage>"#, | |
| 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<DdexRegistration> { | |
| 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<DdexRegistration> { | |
| 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, | |
| }) | |
| } | |