Spaces:
Build error
Build error
File size: 11,021 Bytes
1295969 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | #![allow(dead_code)]
//! 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.
#[derive(Clone)]
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).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
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.
#[derive(Debug, thiserror::Error)]
pub enum IsniError {
#[error("ISNI must be 16 characters; got {0}")]
InvalidLength(usize),
#[error("Invalid character '{1}' at position {0}")]
InvalidCharacter(usize, char),
#[error("Check digit mismatch: expected '{expected}', found '{found}'")]
CheckDigitMismatch { expected: char, found: char },
#[error("ISNI.org API error: {0}")]
ApiError(String),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
}
// ββ ISNI Record (from ISNI.org) ββββββββββββββββββββββββββββββββββββββββββββββββ
/// A resolved ISNI identity record.
#[derive(Debug, Clone, Serialize, Deserialize)]
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.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
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.
#[instrument(skip(config))]
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.
#[instrument(skip(config))]
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.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum CrossRefResult {
Confirmed {
isni: String,
ipi: String,
},
Unverified {
isni: String,
ipi: String,
note: String,
},
InvalidIpi,
Mismatch {
detail: String,
},
}
|