use std::collections::HashMap;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::advanced::Advanced;
use crate::custom_catalog::CustomCatalog;
use crate::errors::{raise_from_error_response, AudDError};
use crate::http::{HttpClient, HttpResponse, TimeoutProfile};
use crate::models::{EnterpriseChunkResult, EnterpriseMatch, RecognitionResult};
use crate::retry::{retry_async, RetryClass, RetryPolicy};
use crate::source::{prepare_source, Source};
use crate::streams::Streams;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Request,
Response,
Exception,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudDEvent {
pub kind: EventKind,
pub method: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub http_status: Option<u16>,
pub elapsed: Duration,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option<i32>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub extras: HashMap<String, Value>,
}
pub type OnEventHook = Arc<dyn Fn(&AudDEvent) + Send + Sync + 'static>;
const API_BASE: &str = "https://api.audd.io";
const ENTERPRISE_BASE: &str = "https://enterprise.audd.io";
const HTTP_CLIENT_ERROR_FLOOR: u16 = 400;
const DEPRECATED_PARAMS_CODE: i32 = 51;
pub(crate) const TOKEN_ENV_VAR: &str = "AUDD_API_TOKEN";
fn resolve_token(explicit: &str) -> Result<String, AudDError> {
if !explicit.is_empty() {
return Ok(explicit.to_string());
}
if let Ok(env) = std::env::var(TOKEN_ENV_VAR) {
if !env.is_empty() {
return Ok(env);
}
}
Err(AudDError::Configuration {
message: format!(
"AudD api_token not supplied and {TOKEN_ENV_VAR} env var is unset. \
Get a token at https://dashboard.audd.io and pass it to AudD::new(...) or \
set {TOKEN_ENV_VAR} and call AudD::from_env()."
),
})
}
#[derive(Debug, Clone)]
pub struct AudD {
inner: AudDInner,
}
#[derive(Clone)]
pub(crate) struct AudDInner {
pub(crate) api_token: Arc<RwLock<String>>,
pub(crate) http: HttpClient,
pub(crate) enterprise_http: HttpClient,
pub(crate) max_attempts: u32,
pub(crate) backoff_factor: f64,
pub(crate) api_base: String,
pub(crate) enterprise_base: String,
pub(crate) on_event: Option<OnEventHook>,
}
impl std::fmt::Debug for AudDInner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudDInner")
.field("api_token", &"<redacted>")
.field("http", &self.http)
.field("enterprise_http", &self.enterprise_http)
.field("max_attempts", &self.max_attempts)
.field("backoff_factor", &self.backoff_factor)
.field("api_base", &self.api_base)
.field("enterprise_base", &self.enterprise_base)
.field(
"on_event",
&self.on_event.as_ref().map(|_| "Fn(&AudDEvent)"),
)
.finish()
}
}
impl AudDInner {
pub(crate) fn api_token(&self) -> String {
self.api_token
.read()
.map(|g| g.clone())
.unwrap_or_else(|p| p.into_inner().clone())
}
pub(crate) fn emit_event(&self, event: &AudDEvent) {
let Some(hook) = self.on_event.as_ref() else {
return;
};
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| hook(event)));
}
pub(crate) fn read_policy(&self) -> RetryPolicy {
RetryPolicy::new(RetryClass::Read)
.with_max_attempts(self.max_attempts)
.with_backoff_factor(self.backoff_factor)
}
pub(crate) fn recognition_policy(&self) -> RetryPolicy {
RetryPolicy::new(RetryClass::Recognition)
.with_max_attempts(self.max_attempts)
.with_backoff_factor(self.backoff_factor)
}
pub(crate) fn mutating_policy(&self) -> RetryPolicy {
RetryPolicy::new(RetryClass::Mutating)
.with_max_attempts(self.max_attempts)
.with_backoff_factor(self.backoff_factor)
}
}
#[derive(Clone)]
pub struct AudDBuilder {
api_token: String,
max_attempts: u32,
backoff_factor: f64,
reqwest_client: Option<reqwest::Client>,
api_base: String,
enterprise_base: String,
on_event: Option<OnEventHook>,
}
impl std::fmt::Debug for AudDBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AudDBuilder")
.field("api_token", &"<redacted>")
.field("max_attempts", &self.max_attempts)
.field("backoff_factor", &self.backoff_factor)
.field(
"reqwest_client",
&self.reqwest_client.as_ref().map(|_| "<custom>"),
)
.field("api_base", &self.api_base)
.field("enterprise_base", &self.enterprise_base)
.field(
"on_event",
&self.on_event.as_ref().map(|_| "Fn(&AudDEvent)"),
)
.finish()
}
}
impl AudDBuilder {
fn new(api_token: impl Into<String>) -> Self {
Self {
api_token: api_token.into(),
max_attempts: 3,
backoff_factor: 0.5,
reqwest_client: None,
api_base: API_BASE.to_string(),
enterprise_base: ENTERPRISE_BASE.to_string(),
on_event: None,
}
}
#[must_use]
pub fn max_attempts(mut self, n: u32) -> Self {
self.max_attempts = n;
self
}
#[must_use]
pub fn backoff_factor(mut self, f: f64) -> Self {
self.backoff_factor = f;
self
}
#[must_use]
pub fn reqwest_client(mut self, client: reqwest::Client) -> Self {
self.reqwest_client = Some(client);
self
}
#[must_use]
pub fn api_base(mut self, url: impl Into<String>) -> Self {
self.api_base = url.into();
self
}
#[must_use]
pub fn enterprise_base(mut self, url: impl Into<String>) -> Self {
self.enterprise_base = url.into();
self
}
#[must_use]
pub fn on_event(mut self, hook: OnEventHook) -> Self {
self.on_event = Some(hook);
self
}
pub fn build(self) -> Result<AudD, AudDError> {
let token = resolve_token(&self.api_token)?;
let token = Arc::new(RwLock::new(token));
let (http, enterprise_http) = if let Some(client) = self.reqwest_client {
(
HttpClient::from_client(Arc::clone(&token), client.clone()),
HttpClient::from_client(Arc::clone(&token), client),
)
} else {
(
HttpClient::new(Arc::clone(&token), TimeoutProfile::Standard)?,
HttpClient::new(Arc::clone(&token), TimeoutProfile::Enterprise)?,
)
};
Ok(AudD {
inner: AudDInner {
api_token: token,
http,
enterprise_http,
max_attempts: self.max_attempts,
backoff_factor: self.backoff_factor,
api_base: self.api_base,
enterprise_base: self.enterprise_base,
on_event: self.on_event,
},
})
}
}
impl AudD {
#[must_use]
pub fn new(api_token: impl Into<String>) -> Self {
Self::builder(api_token)
.build()
.expect("api_token must be supplied or AUDD_API_TOKEN must be set")
}
pub fn try_new(api_token: impl Into<String>) -> Result<Self, AudDError> {
Self::builder(api_token).build()
}
pub fn from_env() -> Result<Self, AudDError> {
Self::builder("").build()
}
pub fn builder(api_token: impl Into<String>) -> AudDBuilder {
AudDBuilder::new(api_token)
}
pub fn streams(&self) -> Streams<'_> {
Streams::new(&self.inner)
}
pub fn custom_catalog(&self) -> CustomCatalog<'_> {
CustomCatalog::new(&self.inner)
}
pub fn advanced(&self) -> Advanced<'_> {
Advanced::new(&self.inner)
}
pub async fn recognize(
&self,
source: impl Into<Source>,
) -> Result<Option<RecognitionResult>, AudDError> {
self.recognize_with(source, None, None, None).await
}
pub async fn recognize_with(
&self,
source: impl Into<Source>,
return_: Option<&[String]>,
market: Option<&str>,
timeout: Option<Duration>,
) -> Result<Option<RecognitionResult>, AudDError> {
let reopen = prepare_source(source.into())?;
let return_str = return_.map(|v| v.join(","));
let market = market.map(str::to_string);
let url = format!("{}/", self.inner.api_base);
let http = self.inner.http.clone();
let started = Instant::now();
self.inner.emit_event(&AudDEvent {
kind: EventKind::Request,
method: "recognize".into(),
url: url.clone(),
request_id: None,
http_status: None,
elapsed: Duration::from_secs(0),
error_code: None,
extras: HashMap::new(),
});
let resp = match retry_async(
|| {
let reopen = &reopen;
let return_str = return_str.clone();
let market = market.clone();
let url = url.clone();
let http = http.clone();
async move {
let prepared = reopen().await?;
let form = prepared.apply(reqwest::multipart::Form::new());
let mut fields: Vec<(&str, String)> = Vec::new();
if let Some(r) = return_str.as_ref() {
fields.push(("return", r.clone()));
}
if let Some(m) = market.as_ref() {
fields.push(("market", m.clone()));
}
http.post_form(&url, &fields, Some(form), timeout).await
}
},
self.inner.recognition_policy(),
)
.await
{
Ok(r) => r,
Err(e) => {
self.inner.emit_event(&AudDEvent {
kind: EventKind::Exception,
method: "recognize".into(),
url,
request_id: e.request_id().map(str::to_string),
http_status: None,
elapsed: started.elapsed(),
error_code: e.error_code(),
extras: HashMap::new(),
});
return Err(e);
}
};
self.inner.emit_event(&AudDEvent {
kind: EventKind::Response,
method: "recognize".into(),
url,
request_id: resp.request_id.clone(),
http_status: Some(resp.http_status),
elapsed: started.elapsed(),
error_code: None,
extras: HashMap::new(),
});
decode_recognize(resp)
}
pub async fn recognize_enterprise(
&self,
source: impl Into<Source>,
opts: EnterpriseOptions<'_>,
) -> Result<Vec<EnterpriseMatch>, AudDError> {
let reopen = prepare_source(source.into())?;
let return_str = opts.return_.map(|v| v.join(","));
let url = format!("{}/", self.inner.enterprise_base);
let http = self.inner.enterprise_http.clone();
let extra: Vec<(&str, String)> = build_enterprise_fields(return_str.as_deref(), &opts);
let started = Instant::now();
self.inner.emit_event(&AudDEvent {
kind: EventKind::Request,
method: "recognize_enterprise".into(),
url: url.clone(),
request_id: None,
http_status: None,
elapsed: Duration::from_secs(0),
error_code: None,
extras: HashMap::new(),
});
let resp = match retry_async(
|| {
let reopen = &reopen;
let extra = extra.clone();
let url = url.clone();
let http = http.clone();
async move {
let prepared = reopen().await?;
let form = prepared.apply(reqwest::multipart::Form::new());
http.post_form(&url, &extra, Some(form), opts.timeout).await
}
},
self.inner.recognition_policy(),
)
.await
{
Ok(r) => r,
Err(e) => {
self.inner.emit_event(&AudDEvent {
kind: EventKind::Exception,
method: "recognize_enterprise".into(),
url,
request_id: e.request_id().map(str::to_string),
http_status: None,
elapsed: started.elapsed(),
error_code: e.error_code(),
extras: HashMap::new(),
});
return Err(e);
}
};
self.inner.emit_event(&AudDEvent {
kind: EventKind::Response,
method: "recognize_enterprise".into(),
url,
request_id: resp.request_id.clone(),
http_status: Some(resp.http_status),
elapsed: started.elapsed(),
error_code: None,
extras: HashMap::new(),
});
decode_enterprise(resp)
}
pub async fn close(self) {
self.inner.http.close();
self.inner.enterprise_http.close();
}
#[must_use]
pub fn api_token(&self) -> String {
self.inner.api_token()
}
pub fn set_api_token(&self, new_token: impl Into<String>) -> Result<(), AudDError> {
let new_token = new_token.into();
if new_token.is_empty() {
return Err(AudDError::Configuration {
message: "set_api_token requires a non-empty token".to_string(),
});
}
let mut guard = self
.inner
.api_token
.write()
.unwrap_or_else(|p| p.into_inner());
*guard = new_token;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct EnterpriseOptions<'a> {
pub return_: Option<&'a [String]>,
pub skip: Option<i64>,
pub every: Option<i64>,
pub limit: Option<i64>,
pub skip_first_seconds: Option<i64>,
pub use_timecode: Option<bool>,
pub accurate_offsets: Option<bool>,
pub timeout: Option<Duration>,
}
fn build_enterprise_fields(
return_str: Option<&str>,
opts: &EnterpriseOptions<'_>,
) -> Vec<(&'static str, String)> {
let mut fields: Vec<(&'static str, String)> = Vec::new();
if let Some(r) = return_str {
fields.push(("return", r.to_string()));
}
if let Some(v) = opts.skip {
fields.push(("skip", v.to_string()));
}
if let Some(v) = opts.every {
fields.push(("every", v.to_string()));
}
if let Some(v) = opts.limit {
fields.push(("limit", v.to_string()));
}
if let Some(v) = opts.skip_first_seconds {
fields.push(("skip_first_seconds", v.to_string()));
}
if let Some(v) = opts.use_timecode {
fields.push((
"use_timecode",
if v { "true".into() } else { "false".into() },
));
}
if let Some(v) = opts.accurate_offsets {
fields.push((
"accurate_offsets",
if v { "true".into() } else { "false".into() },
));
}
fields
}
fn decode_recognize(resp: HttpResponse) -> Result<Option<RecognitionResult>, AudDError> {
let body = decode_or_raise(resp, false)?;
let result = body.get("result").cloned().unwrap_or(Value::Null);
if result.is_null() {
return Ok(None);
}
let rr: RecognitionResult =
serde_json::from_value(result.clone()).map_err(|e| AudDError::Serialization {
message: format!("could not parse recognize result: {e}"),
raw_text: result.to_string(),
})?;
Ok(Some(rr))
}
fn decode_enterprise(resp: HttpResponse) -> Result<Vec<EnterpriseMatch>, AudDError> {
let body = decode_or_raise(resp, false)?;
let chunks_value = body.get("result").cloned().unwrap_or(Value::Null);
if chunks_value.is_null() {
return Ok(Vec::new());
}
let chunks: Vec<EnterpriseChunkResult> =
serde_json::from_value(chunks_value.clone()).map_err(|e| AudDError::Serialization {
message: format!("could not parse enterprise result: {e}"),
raw_text: chunks_value.to_string(),
})?;
Ok(chunks.into_iter().flat_map(|c| c.songs).collect())
}
pub(crate) fn decode_or_raise(
resp: HttpResponse,
custom_catalog_context: bool,
) -> Result<Value, AudDError> {
let HttpResponse {
json_body,
http_status,
request_id,
raw_text,
} = resp;
let Some(mut body) = json_body else {
if http_status >= HTTP_CLIENT_ERROR_FLOOR {
return Err(AudDError::Server {
http_status,
message: format!("HTTP {http_status} with non-JSON response body"),
request_id,
raw_response: raw_text,
});
}
return Err(AudDError::Serialization {
message: "Unparseable response".to_string(),
raw_text,
});
};
maybe_warn_and_strip(&mut body);
let status = body.get("status").and_then(Value::as_str);
if status == Some("error") {
return Err(raise_from_error_response(
&body,
http_status,
request_id,
custom_catalog_context,
));
}
if status == Some("success") {
return Ok(body);
}
Err(AudDError::Server {
http_status,
message: format!("Unexpected response status: {status:?}"),
request_id,
raw_response: body.to_string(),
})
}
fn maybe_warn_and_strip(body: &mut Value) {
let code_matches = body
.get("error")
.and_then(|e| e.get("error_code"))
.and_then(|c| c.as_i64())
.is_some_and(|c| i32::try_from(c).ok() == Some(DEPRECATED_PARAMS_CODE));
let result_is_usable = body.get("result").is_some_and(|r| !r.is_null());
let is_pass_through = code_matches && result_is_usable;
if !is_pass_through {
return;
}
let msg = body
.get("error")
.and_then(|e| e.get("error_message"))
.and_then(Value::as_str)
.unwrap_or("Deprecated parameter used")
.to_string();
tracing::warn!(target: "audd", code = DEPRECATED_PARAMS_CODE, "{msg}");
if let Some(obj) = body.as_object_mut() {
obj.remove("error");
obj.insert("status".into(), Value::String("success".into()));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn resp_ok(body: Value) -> HttpResponse {
HttpResponse {
json_body: Some(body.clone()),
http_status: 200,
request_id: None,
raw_text: body.to_string(),
}
}
#[test]
fn decode_recognize_success() {
let body = json!({
"status": "success",
"result": {"timecode": "00:01", "artist": "X", "title": "Y"}
});
let r = decode_recognize(resp_ok(body)).unwrap().unwrap();
assert_eq!(r.artist.as_deref(), Some("X"));
}
#[test]
fn decode_recognize_no_match() {
let body = json!({"status": "success", "result": null});
let r = decode_recognize(resp_ok(body)).unwrap();
assert!(r.is_none());
}
#[test]
fn decode_recognize_error() {
let body = json!({
"status": "error",
"error": {"error_code": 900, "error_message": "bad"}
});
let e = decode_recognize(resp_ok(body)).unwrap_err();
assert!(e.is_authentication());
}
#[test]
fn http_5xx_with_html_is_server_error() {
let r = HttpResponse {
json_body: None,
http_status: 502,
request_id: None,
raw_text: "<html>bad gateway</html>".to_string(),
};
let e = decode_or_raise(r, false).unwrap_err();
match e {
AudDError::Server { http_status, .. } => assert_eq!(http_status, 502),
other => panic!("not Server: {other:?}"),
}
}
#[test]
fn http_2xx_with_garbage_is_serialization_error() {
let r = HttpResponse {
json_body: None,
http_status: 200,
request_id: None,
raw_text: "not json".to_string(),
};
let e = decode_or_raise(r, false).unwrap_err();
assert!(matches!(e, AudDError::Serialization { .. }));
}
#[test]
fn code_51_passes_through_with_result() {
let body = json!({
"status": "error",
"error": {"error_code": 51, "error_message": "deprecated param X"},
"result": {"timecode": "00:01", "artist": "X", "title": "Y"}
});
let r = decode_recognize(resp_ok(body)).unwrap().unwrap();
assert_eq!(r.artist.as_deref(), Some("X"));
}
#[test]
fn code_51_without_result_raises() {
let body = json!({
"status": "error",
"error": {"error_code": 51, "error_message": "deprecated"},
"result": null
});
let e = decode_recognize(resp_ok(body)).unwrap_err();
assert!(e.is_invalid_request());
}
#[test]
fn enterprise_decode() {
let body = json!({
"status": "success",
"result": [
{"songs": [{"score": 80, "timecode": "00:01"}], "offset": "00:00"}
]
});
let v = decode_enterprise(resp_ok(body)).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].score, 80);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard {
prev: Option<String>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl EnvGuard {
fn new(value: Option<&str>) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var(TOKEN_ENV_VAR).ok();
unsafe {
if let Some(v) = value {
std::env::set_var(TOKEN_ENV_VAR, v);
} else {
std::env::remove_var(TOKEN_ENV_VAR);
}
}
Self { prev, _lock: lock }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(v) = self.prev.take() {
std::env::set_var(TOKEN_ENV_VAR, v);
} else {
std::env::remove_var(TOKEN_ENV_VAR);
}
}
}
}
#[test]
fn from_env_picks_up_audd_api_token() {
let _g = EnvGuard::new(Some("env-tok"));
let audd = AudD::from_env().expect("AUDD_API_TOKEN was set");
assert_eq!(audd.api_token(), "env-tok");
}
#[test]
fn try_new_falls_back_to_env_when_empty() {
let _g = EnvGuard::new(Some("env-tok-2"));
let audd = AudD::try_new("").expect("env fallback should succeed");
assert_eq!(audd.api_token(), "env-tok-2");
}
#[test]
fn try_new_uses_explicit_over_env() {
let _g = EnvGuard::new(Some("env-tok-3"));
let audd = AudD::try_new("explicit").expect("explicit token wins");
assert_eq!(audd.api_token(), "explicit");
}
#[test]
fn from_env_errors_when_missing() {
let _g = EnvGuard::new(None);
let err = AudD::from_env().expect_err("no token => Configuration error");
match err {
AudDError::Configuration { message } => {
assert!(
message.contains("dashboard.audd.io"),
"expected dashboard URL hint in: {message}"
);
assert!(message.contains(TOKEN_ENV_VAR));
}
other => panic!("expected Configuration, got {other:?}"),
}
}
#[test]
fn set_api_token_rotates() {
let audd = AudD::new("orig");
assert_eq!(audd.api_token(), "orig");
audd.set_api_token("new").unwrap();
assert_eq!(audd.api_token(), "new");
}
#[test]
fn set_api_token_rejects_empty() {
let audd = AudD::new("orig");
let err = audd.set_api_token("").unwrap_err();
match err {
AudDError::Configuration { message } => {
assert!(message.to_lowercase().contains("non-empty"));
}
other => panic!("expected Configuration, got {other:?}"),
}
assert_eq!(audd.api_token(), "orig");
}
#[test]
fn set_api_token_concurrent_does_not_panic() {
use std::sync::Arc as StdArc;
use std::thread;
let audd = StdArc::new(AudD::new("t0"));
let mut handles = Vec::new();
for i in 0..8 {
let a = StdArc::clone(&audd);
handles.push(thread::spawn(move || {
for _ in 0..50 {
a.set_api_token(format!("t{i}")).unwrap();
let _ = a.api_token();
}
}));
}
for h in handles {
h.join().unwrap();
}
let final_tok = audd.api_token();
assert!(
final_tok.starts_with('t') && final_tok.len() <= 3,
"unexpected final token: {final_tok}"
);
}
#[test]
fn emit_event_invokes_registered_hook() {
use std::sync::Mutex;
let captured: Arc<Mutex<Vec<AudDEvent>>> = Arc::new(Mutex::new(Vec::new()));
let captured_for_hook = Arc::clone(&captured);
let hook: OnEventHook = Arc::new(move |e: &AudDEvent| {
captured_for_hook.lock().unwrap().push(e.clone());
});
let audd = AudD::builder("test").on_event(hook).build().unwrap();
let ev = AudDEvent {
kind: EventKind::Request,
method: "recognize".into(),
url: "https://api.audd.io/".into(),
request_id: None,
http_status: None,
elapsed: Duration::from_millis(0),
error_code: None,
extras: HashMap::new(),
};
audd.inner.emit_event(&ev);
let got = captured.lock().unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].method, "recognize");
assert_eq!(got[0].kind, EventKind::Request);
}
#[test]
fn emit_event_swallows_panic_in_hook() {
let hook: OnEventHook = Arc::new(|_e: &AudDEvent| panic!("hook panicked"));
let audd = AudD::builder("test").on_event(hook).build().unwrap();
let ev = AudDEvent {
kind: EventKind::Response,
method: "recognize".into(),
url: "https://api.audd.io/".into(),
request_id: Some("req-1".into()),
http_status: Some(200),
elapsed: Duration::from_millis(5),
error_code: None,
extras: HashMap::new(),
};
audd.inner.emit_event(&ev);
}
#[test]
fn audd_event_no_token_field() {
let ev = AudDEvent {
kind: EventKind::Request,
method: "recognize".into(),
url: "https://api.audd.io/".into(),
request_id: None,
http_status: None,
elapsed: Duration::from_secs(0),
error_code: None,
extras: HashMap::new(),
};
let s = format!("{ev:?}");
assert!(!s.to_lowercase().contains("api_token"));
assert!(!s.to_lowercase().contains("token"));
}
}