1//! AuDD audio fingerprinting integration. 2 3use axum::{extract::State, Json}; 4use serde::{Deserialize, Serialize}; 5use tracing::info; 6 7use crate::state::{AppError, AppState}; 8 9// --- request/response types --- 10 11#[derive(Debug, Deserialize)] 12pub struct ScanRequest { 13 pub audio_url: String, 14} 15 16#[derive(Debug, Serialize)] 17pub struct ScanResponse { 18 pub matches: Vec<AuddMatch>, 19 pub is_flagged: bool, 20 pub highest_score: i32, 21 pub raw_response: serde_json::Value, 22} 23 24#[derive(Debug, Serialize, Clone)] 25pub struct AuddMatch { 26 pub artist: String, 27 pub title: String, 28 #[serde(skip_serializing_if = "Option::is_none")] 29 pub album: Option<String>, 30 pub score: i32, 31 #[serde(skip_serializing_if = "Option::is_none")] 32 pub isrc: Option<String>, 33 #[serde(skip_serializing_if = "Option::is_none")] 34 pub timecode: Option<String>, 35 #[serde(skip_serializing_if = "Option::is_none")] 36 pub offset_ms: Option<i64>, 37} 38 39// --- audd api types --- 40 41#[derive(Debug, Deserialize)] 42pub struct AuddResponse { 43 pub status: Option<String>, 44 pub result: Option<AuddResult>, 45} 46 47#[derive(Debug, Deserialize)] 48#[serde(untagged)] 49pub enum AuddResult { 50 Groups(Vec<AuddGroup>), 51 Single(AuddSong), 52} 53 54#[derive(Debug, Deserialize)] 55pub struct AuddGroup { 56 pub offset: Option<serde_json::Value>, 57 pub songs: Option<Vec<AuddSong>>, 58} 59 60#[derive(Debug, Deserialize)] 61#[allow(dead_code)] 62pub struct AuddSong { 63 pub artist: Option<String>, 64 pub title: Option<String>, 65 pub album: Option<String>, 66 pub score: Option<i32>, 67 pub isrc: Option<String>, 68 pub timecode: Option<String>, 69 pub release_date: Option<String>, 70 pub label: Option<String>, 71 pub song_link: Option<String>, 72} 73 74// --- handler --- 75 76/// Scan audio for copyright matches via AuDD. 77pub async fn scan( 78 State(state): State<AppState>, 79 Json(request): Json<ScanRequest>, 80) -> Result<Json<ScanResponse>, AppError> { 81 info!(audio_url = %request.audio_url, "scanning audio"); 82 83 let client = reqwest::Client::new(); 84 let response = client 85 .post(&state.audd_api_url) 86 .form(&[ 87 ("api_token", &state.audd_api_token), 88 ("url", &request.audio_url), 89 ("accurate_offsets", &"1".to_string()), 90 ]) 91 .send() 92 .await 93 .map_err(|e| AppError::Audd(format!("request failed: {e}")))?; 94 95 let raw_response: serde_json::Value = response 96 .json() 97 .await 98 .map_err(|e| AppError::Audd(format!("failed to parse response: {e}")))?; 99 100 let audd_response: AuddResponse = serde_json::from_value(raw_response.clone()) 101 .map_err(|e| AppError::Audd(format!("failed to parse audd response: {e}")))?; 102 103 if audd_response.status.as_deref() == Some("error") { 104 return Err(AppError::Audd(format!( 105 "audd returned error: {}", 106 raw_response 107 ))); 108 } 109 110 let matches = extract_matches(&audd_response); 111 let highest_score = matches.iter().map(|m| m.score).max().unwrap_or(0); 112 let is_flagged = highest_score >= state.copyright_score_threshold; 113 114 info!( 115 match_count = matches.len(), 116 highest_score, is_flagged, "scan complete" 117 ); 118 119 Ok(Json(ScanResponse { 120 matches, 121 is_flagged, 122 highest_score, 123 raw_response, 124 })) 125} 126 127// --- helpers --- 128 129fn extract_matches(response: &AuddResponse) -> Vec<AuddMatch> { 130 let Some(result) = &response.result else { 131 return vec![]; 132 }; 133 134 match result { 135 AuddResult::Groups(groups) => groups 136 .iter() 137 .flat_map(|group| { 138 group 139 .songs 140 .as_ref() 141 .map(|songs| { 142 songs 143 .iter() 144 .map(|song| parse_song(song, group.offset.as_ref())) 145 .collect::<Vec<_>>() 146 }) 147 .unwrap_or_default() 148 }) 149 .collect(), 150 AuddResult::Single(song) => vec![parse_song(song, None)], 151 } 152} 153 154fn parse_song(song: &AuddSong, offset: Option<&serde_json::Value>) -> AuddMatch { 155 let offset_ms = offset.and_then(|v| match v { 156 serde_json::Value::Number(n) => n.as_i64(), 157 serde_json::Value::String(s) => parse_timecode_to_ms(s), 158 _ => None, 159 }); 160 161 AuddMatch { 162 artist: song.artist.clone().unwrap_or_else(|| "Unknown".to_string()), 163 title: song.title.clone().unwrap_or_else(|| "Unknown".to_string()), 164 album: song.album.clone(), 165 score: song.score.unwrap_or(0), 166 isrc: song.isrc.clone(), 167 timecode: song.timecode.clone(), 168 offset_ms, 169 } 170} 171 172fn parse_timecode_to_ms(timecode: &str) -> Option<i64> { 173 let parts: Vec<&str> = timecode.split(':').collect(); 174 match parts.len() { 175 2 => { 176 let mins: i64 = parts[0].parse().ok()?; 177 let secs: i64 = parts[1].parse().ok()?; 178 Some((mins * 60 + secs) * 1000) 179 } 180 3 => { 181 let hours: i64 = parts[0].parse().ok()?; 182 let mins: i64 = parts[1].parse().ok()?; 183 let secs: i64 = parts[2].parse().ok()?; 184 Some((hours * 3600 + mins * 60 + secs) * 1000) 185 } 186 _ => None, 187 } 188}