music on atproto
plyr.fm
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}