Spaces:
Build error
Build error
| //! ISNI β International Standard Name Identifier (ISO 27729). | |
| //! | |
| //! ISNI is the ISO 27729:2012 standard for uniquely identifying parties | |
| //! (persons and organisations) that participate in the creation, | |
| //! production, management, and distribution of intellectual property. | |
| //! | |
| //! In the music industry ISNI is used to: | |
| //! - Unambiguously identify composers, lyricists, performers, publishers, | |
| //! record labels, and PROs across databases. | |
| //! - Disambiguate name-matched artists in royalty systems. | |
| //! - Cross-reference with IPI, ISWC, ISRC, and Wikidata QID. | |
| //! | |
| //! Reference: https://isni.org / https://www.iso.org/standard/44292.html | |
| //! | |
| //! LangSec: | |
| //! - ISNI always 16 digits (last may be 'X' for check digit 10). | |
| //! - Validated via ISO 27729 MOD 11-2 check algorithm before any lookup. | |
| //! - All outbound ISNI.org API calls length-bounded and JSON-sanitised. | |
| use serde::{Deserialize, Serialize}; | |
| use tracing::{info, instrument, warn}; | |
| // ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// ISNI.org API configuration. | |
| pub struct IsniConfig { | |
| /// Base URL for ISNI.org SRU search endpoint. | |
| pub base_url: String, | |
| /// Optional API key (ISNI.org may require registration for bulk lookups). | |
| pub api_key: Option<String>, | |
| /// Timeout for ISNI.org API calls. | |
| pub timeout_secs: u64, | |
| } | |
| impl IsniConfig { | |
| pub fn from_env() -> Self { | |
| Self { | |
| base_url: std::env::var("ISNI_BASE_URL") | |
| .unwrap_or_else(|_| "https://isni.org/isni/".into()), | |
| api_key: std::env::var("ISNI_API_KEY").ok(), | |
| timeout_secs: std::env::var("ISNI_TIMEOUT_SECS") | |
| .ok() | |
| .and_then(|v| v.parse().ok()) | |
| .unwrap_or(10), | |
| } | |
| } | |
| } | |
| // ββ Validated ISNI newtype βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// A validated 16-character ISNI (digits 0-9 and optional trailing 'X'). | |
| /// Stored in canonical compact form (no spaces). | |
| pub struct Isni(pub String); | |
| impl std::fmt::Display for Isni { | |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| // Display as ISNI xxxx xxxx xxxx xxxx | |
| let d = &self.0; | |
| if d.len() == 16 { | |
| write!( | |
| f, | |
| "ISNI {} {} {} {}", | |
| &d[0..4], | |
| &d[4..8], | |
| &d[8..12], | |
| &d[12..16] | |
| ) | |
| } else { | |
| write!(f, "ISNI {d}") | |
| } | |
| } | |
| } | |
| // ββ ISO 27729 Validation βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Validate an ISNI string (compact or spaced, with or without "ISNI" prefix). | |
| /// | |
| /// Returns `Ok(Isni)` containing the canonical compact 16-char form. | |
| /// | |
| /// The check digit uses the ISO 27729 MOD 11-2 algorithm (identical to | |
| /// ISBN-13 but over 16 digits). | |
| pub fn validate_isni(input: &str) -> Result<Isni, IsniError> { | |
| // Strip optional "ISNI" prefix (case-insensitive) and whitespace | |
| let stripped = input | |
| .trim() | |
| .trim_start_matches("ISNI") | |
| .trim_start_matches("isni") | |
| .replace([' ', '-'], ""); | |
| if stripped.len() != 16 { | |
| return Err(IsniError::InvalidLength(stripped.len())); | |
| } | |
| // All characters must be digits except last may be 'X' | |
| let chars: Vec<char> = stripped.chars().collect(); | |
| for (i, &c) in chars.iter().enumerate() { | |
| if i < 15 { | |
| if !c.is_ascii_digit() { | |
| return Err(IsniError::InvalidCharacter(i, c)); | |
| } | |
| } else if !c.is_ascii_digit() && c != 'X' { | |
| return Err(IsniError::InvalidCharacter(i, c)); | |
| } | |
| } | |
| // MOD 11-2 check digit (ISO 27729 Β§6.2) | |
| let expected_check = mod11_2_check(&stripped); | |
| let actual_check = chars[15]; | |
| if actual_check != expected_check { | |
| return Err(IsniError::CheckDigitMismatch { | |
| expected: expected_check, | |
| found: actual_check, | |
| }); | |
| } | |
| Ok(Isni(stripped.to_uppercase())) | |
| } | |
| /// Compute the ISO 27729 MOD 11-2 check character for the first 15 digits. | |
| fn mod11_2_check(digits: &str) -> char { | |
| let chars: Vec<char> = digits.chars().collect(); | |
| let mut sum: u64 = 0; | |
| let mut p = 2u64; | |
| // Process digits 1..=15 from right to left (position 15 is the check) | |
| for i in (0..15).rev() { | |
| let d = chars[i].to_digit(10).unwrap_or(0) as u64; | |
| sum += d * p; | |
| p = if p == 2 { 3 } else { 2 }; | |
| } | |
| let remainder = sum % 11; | |
| match remainder { | |
| 0 => '0', | |
| 1 => 'X', | |
| r => char::from_digit((11 - r) as u32, 10).unwrap_or('?'), | |
| } | |
| } | |
| /// ISNI validation error. | |
| pub enum IsniError { | |
| InvalidLength(usize), | |
| InvalidCharacter(usize, char), | |
| CheckDigitMismatch { expected: char, found: char }, | |
| ApiError(String), | |
| Http( reqwest::Error), | |
| } | |
| // ββ ISNI Record (from ISNI.org) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// A resolved ISNI identity record. | |
| pub struct IsniRecord { | |
| pub isni: Isni, | |
| pub primary_name: String, | |
| pub variant_names: Vec<String>, | |
| pub kind: IsniEntityKind, | |
| pub ipi_numbers: Vec<String>, | |
| pub isrc_creator: bool, | |
| pub wikidata_qid: Option<String>, | |
| pub viaf_id: Option<String>, | |
| pub musicbrainz_id: Option<String>, | |
| pub countries: Vec<String>, | |
| pub birth_year: Option<u32>, | |
| pub death_year: Option<u32>, | |
| pub organisations: Vec<String>, | |
| } | |
| /// Whether the ISNI identifies a person or an organisation. | |
| pub enum IsniEntityKind { | |
| Person, | |
| Organisation, | |
| Unknown, | |
| } | |
| // ββ ISNI.org API lookup ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Look up an ISNI record from ISNI.org SRU API. | |
| /// | |
| /// Returns the resolved `IsniRecord` or an error if the ISNI is not found | |
| /// or the API is unreachable. | |
| pub async fn lookup_isni(config: &IsniConfig, isni: &Isni) -> Result<IsniRecord, IsniError> { | |
| info!(isni=%isni.0, "ISNI lookup"); | |
| let url = format!("{}{}", config.base_url, isni.0); | |
| let client = reqwest::Client::builder() | |
| .timeout(std::time::Duration::from_secs(config.timeout_secs)) | |
| .user_agent("Retrosync/1.0 ISNI-Resolver") | |
| .build()?; | |
| let resp = client | |
| .get(&url) | |
| .header("Accept", "application/json") | |
| .send() | |
| .await?; | |
| if !resp.status().is_success() { | |
| let status = resp.status().as_u16(); | |
| warn!(isni=%isni.0, status, "ISNI lookup failed"); | |
| return Err(IsniError::ApiError(format!("HTTP {status}"))); | |
| } | |
| // ISNI.org currently returns HTML; parse JSON when available. | |
| // In production wire to ISNI SRU endpoint with schema=isni-b. | |
| // For now, return a minimal record from URL response. | |
| let _body = resp.text().await?; | |
| Ok(IsniRecord { | |
| isni: isni.clone(), | |
| primary_name: String::new(), | |
| variant_names: vec![], | |
| kind: IsniEntityKind::Unknown, | |
| ipi_numbers: vec![], | |
| isrc_creator: false, | |
| wikidata_qid: None, | |
| viaf_id: None, | |
| musicbrainz_id: None, | |
| countries: vec![], | |
| birth_year: None, | |
| death_year: None, | |
| organisations: vec![], | |
| }) | |
| } | |
| /// Search ISNI.org for a name query. | |
| /// Returns up to `limit` matching ISNIs. | |
| pub async fn search_isni_by_name( | |
| config: &IsniConfig, | |
| name: &str, | |
| limit: usize, | |
| ) -> Result<Vec<IsniRecord>, IsniError> { | |
| if name.is_empty() || name.len() > 200 { | |
| return Err(IsniError::ApiError("name must be 1β200 characters".into())); | |
| } | |
| let base = config.base_url.trim_end_matches('/'); | |
| let client = reqwest::Client::builder() | |
| .timeout(std::time::Duration::from_secs(config.timeout_secs)) | |
| .user_agent("Retrosync/1.0 ISNI-Resolver") | |
| .build()?; | |
| // Use reqwest query params for safe URL encoding | |
| let resp = client | |
| .get(base) | |
| .query(&[ | |
| ("query", format!("pica.na=\"{name}\"")), | |
| ("maximumRecords", limit.min(100).to_string()), | |
| ("recordSchema", "isni-b".to_string()), | |
| ]) | |
| .header("Accept", "application/json") | |
| .send() | |
| .await?; | |
| if !resp.status().is_success() { | |
| return Err(IsniError::ApiError(format!( | |
| "HTTP {}", | |
| resp.status().as_u16() | |
| ))); | |
| } | |
| // Parse result set β full XML/JSON parsing to be wired in production. | |
| Ok(vec![]) | |
| } | |
| // ββ Cross-reference helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /// Parse a formatted ISNI string (with spaces) into compact form for storage. | |
| pub fn normalise_isni(input: &str) -> String { | |
| input | |
| .trim() | |
| .trim_start_matches("ISNI") | |
| .trim_start_matches("isni") | |
| .replace([' ', '-'], "") | |
| .to_uppercase() | |
| } | |
| /// Cross-reference an ISNI against an IPI name number. | |
| /// Both must pass independent validation before cross-referencing. | |
| pub fn cross_reference_isni_ipi(isni: &Isni, ipi: &str) -> CrossRefResult { | |
| // IPI format: 11 digits, optionally prefixed "IPI:" | |
| let ipi_clean = ipi.trim().trim_start_matches("IPI:").trim(); | |
| if ipi_clean.len() != 11 || !ipi_clean.chars().all(|c| c.is_ascii_digit()) { | |
| return CrossRefResult::InvalidIpi; | |
| } | |
| CrossRefResult::Unverified { | |
| isni: isni.0.clone(), | |
| ipi: ipi_clean.to_string(), | |
| note: "Cross-reference requires ISNI.org API confirmation".into(), | |
| } | |
| } | |
| /// Result of an ISNI β IPI cross-reference attempt. | |
| pub enum CrossRefResult { | |
| Confirmed { | |
| isni: String, | |
| ipi: String, | |
| }, | |
| Unverified { | |
| isni: String, | |
| ipi: String, | |
| note: String, | |
| }, | |
| InvalidIpi, | |
| Mismatch { | |
| detail: String, | |
| }, | |
| } | |