1pub mod track {
2 use crate::sources::{
3 http::HttpTrack,
4 playable_track::{PlayableTrack, ResolvedTrack},
5 };
6 use async_trait::async_trait;
7 use std::{net::IpAddr, sync::Arc};
8 use tracing::debug;
9 pub struct AudiusTrack {
10 pub client: Arc<reqwest::Client>,
11 pub track_id: String,
12 pub stream_url: Option<String>,
13 pub app_name: String,
14 pub local_addr: Option<IpAddr>,
15 }
16 const API_BASE: &str = "https://discoveryprovider.audius.co";
17 #[async_trait]
18 impl PlayableTrack for AudiusTrack {
19 async fn resolve(&self) -> Result<ResolvedTrack, String> {
20 let url = if let Some(url) = self.stream_url.clone() {
21 url
22 } else {
23 fetch_stream_url(&self.client, &self.track_id, &self.app_name)
24 .await
25 .ok_or_else(|| {
26 format!(
27 "Failed to fetch Audius stream URL for track ID {}",
28 self.track_id
29 )
30 })?
31 };
32 debug!("Audius stream URL: {url}");
33 HttpTrack {
34 url,
35 local_addr: self.local_addr,
36 proxy: None,
37 }
38 .resolve()
39 .await
40 }
41 }
42 pub async fn fetch_stream_url(
43 client: &Arc<reqwest::Client>,
44 track_id: &str,
45 app_name: &str,
46 ) -> Option<String> {
47 let url = format!(
48 "{API_BASE}/v1/tracks/{}/stream",
49 urlencoding::encode(track_id)
50 );
51 let resp = client
52 .get(url)
53 .query(&[("app_name", app_name), ("no_redirect", "true")])
54 .send()
55 .await
56 .ok()?;
57 if !resp.status().is_success() {
58 return None;
59 }
60 let body: serde_json::Value = resp.json().await.ok()?;
61 body["data"].as_str().map(|s| s.to_owned())
62 }
63}
64use crate::{
65 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
66 sources::{SourcePlugin, playable_track::BoxedTrack},
67};
68use async_trait::async_trait;
69use regex::Regex;
70use serde_json::{Value, json};
71use std::sync::{Arc, OnceLock};
72const API_BASE: &str = "https://discoveryprovider.audius.co";
73static TRACK_PATTERN: OnceLock<Regex> = OnceLock::new();
74static PLAYLIST_PATTERN: OnceLock<Regex> = OnceLock::new();
75static ALBUM_PATTERN: OnceLock<Regex> = OnceLock::new();
76static USER_PATTERN: OnceLock<Regex> = OnceLock::new();
77pub struct AudiusSource {
78 client: Arc<reqwest::Client>,
79 app_name: String,
80 search_limit: usize,
81 playlist_load_limit: usize,
82 album_load_limit: usize,
83}
84impl AudiusSource {
85 pub fn new(
86 config: Option<crate::config::AudiusConfig>,
87 client: Arc<reqwest::Client>,
88 ) -> Result<Self, String> {
89 let config = config.unwrap_or_default();
90 Ok(Self {
91 client,
92 app_name: config.app_name.unwrap_or_else(|| "Lavende".to_owned()),
93 search_limit: config.search_limit,
94 playlist_load_limit: config.playlist_load_limit,
95 album_load_limit: config.album_load_limit,
96 })
97 }
98 async fn api_request(
99 &self,
100 endpoint: &str,
101 query: Option<std::collections::BTreeMap<String, String>>,
102 ) -> Option<Value> {
103 let url = format!("{API_BASE}{endpoint}");
104 let mut builder = self.client.get(&url);
105 if let Some(q) = query {
106 builder = builder.query(&q);
107 }
108 builder = builder.query(&[("app_name", &self.app_name)]);
109 let resp = builder.send().await.ok()?;
110 if !resp.status().is_success() {
111 return None;
112 }
113 let body: Value = resp.json().await.ok()?;
114 Some(body["data"].clone())
115 }
116 async fn search(&self, query: &str) -> LoadResult {
117 let mut params = std::collections::BTreeMap::new();
118 params.insert("query".to_owned(), query.to_owned());
119 params.insert("limit".to_owned(), self.search_limit.to_string());
120 let data = match self.api_request("/v1/tracks/search", Some(params)).await {
121 Some(d) => d,
122 None => return LoadResult::Empty {},
123 };
124 let tracks = self.parse_tracks(&data);
125 if tracks.is_empty() {
126 LoadResult::Empty {}
127 } else {
128 LoadResult::Search(tracks)
129 }
130 }
131 async fn resolve_url(&self, url: &str) -> LoadResult {
132 if PLAYLIST_PATTERN
133 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<artist>[^/]+)/playlist/(?P<slug>[^/?#]+)(?:\?.*)?$").unwrap())
134 .is_match(url)
135 {
136 return self.resolve_playlist_or_album(url, "playlist").await;
137 }
138 if ALBUM_PATTERN
139 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<artist>[^/]+)/album/(?P<slug>[^/?#]+)(?:\?.*)?$").unwrap())
140 .is_match(url)
141 {
142 return self.resolve_playlist_or_album(url, "album").await;
143 }
144 if TRACK_PATTERN
145 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<artist>[^/]+)/(?P<slug>[^/?#]+)(?:\?.*)?$").unwrap())
146 .is_match(url)
147 {
148 return self.resolve_track(url).await;
149 }
150 if USER_PATTERN
151 .get_or_init(|| {
152 Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<user>[^/?#]+)(?:\?.*)?$")
153 .unwrap()
154 })
155 .is_match(url)
156 {
157 return self.resolve_user(url).await;
158 }
159 LoadResult::Empty {}
160 }
161 async fn resolve_track(&self, url: &str) -> LoadResult {
162 let mut params = std::collections::BTreeMap::new();
163 params.insert("url".to_owned(), url.to_owned());
164 let data = match self.api_request("/v1/resolve", Some(params)).await {
165 Some(d) => d,
166 None => return LoadResult::Empty {},
167 };
168 match self.build_track(&data) {
169 Some(t) => LoadResult::Track(t),
170 None => LoadResult::Empty {},
171 }
172 }
173 async fn resolve_playlist_or_album(&self, url: &str, type_: &str) -> LoadResult {
174 let mut params = std::collections::BTreeMap::new();
175 params.insert("url".to_owned(), url.to_owned());
176 let data = match self.api_request("/v1/resolve", Some(params)).await {
177 Some(d) => d,
178 None => return LoadResult::Empty {},
179 };
180 let id = match data["id"].as_str() {
181 Some(i) => i,
182 None => return LoadResult::Empty {},
183 };
184 let limit = if type_ == "playlist" {
185 self.playlist_load_limit
186 } else {
187 self.album_load_limit
188 };
189 let mut tracks_params = std::collections::BTreeMap::new();
190 tracks_params.insert("limit".to_owned(), limit.to_string());
191 let tracks_data = match self
192 .api_request(&format!("/v1/playlists/{id}/tracks"), Some(tracks_params))
193 .await
194 {
195 Some(d) => d,
196 None => return LoadResult::Empty {},
197 };
198 let tracks = self.parse_tracks(&tracks_data);
199 if tracks.is_empty() {
200 return LoadResult::Empty {};
201 }
202 let name = data["playlist_name"]
203 .as_str()
204 .unwrap_or("Audius Playlist")
205 .to_owned();
206 LoadResult::Playlist(PlaylistData {
207 info: PlaylistInfo {
208 name,
209 selected_track: -1,
210 },
211 plugin_info: json!({}),
212 tracks,
213 })
214 }
215 async fn resolve_user(&self, url: &str) -> LoadResult {
216 let mut params = std::collections::BTreeMap::new();
217 params.insert("url".to_owned(), url.to_owned());
218 let data = match self.api_request("/v1/resolve", Some(params)).await {
219 Some(d) => d,
220 None => return LoadResult::Empty {},
221 };
222 let id = match data["id"].as_str() {
223 Some(i) => i,
224 None => return LoadResult::Empty {},
225 };
226 let mut tracks_params = std::collections::BTreeMap::new();
227 tracks_params.insert("limit".to_owned(), self.search_limit.to_string());
228 let tracks_data = match self
229 .api_request(&format!("/v1/users/{id}/tracks"), Some(tracks_params))
230 .await
231 {
232 Some(d) => d,
233 None => return LoadResult::Empty {},
234 };
235 let tracks = self.parse_tracks(&tracks_data);
236 if tracks.is_empty() {
237 return LoadResult::Empty {};
238 }
239 let name = format!("{}'s Tracks", data["name"].as_str().unwrap_or("Artist"));
240 LoadResult::Playlist(PlaylistData {
241 info: PlaylistInfo {
242 name,
243 selected_track: -1,
244 },
245 plugin_info: json!({}),
246 tracks,
247 })
248 }
249 fn parse_tracks(&self, data: &Value) -> Vec<Track> {
250 data.as_array()
251 .map(|arr| {
252 arr.iter()
253 .filter_map(|item| self.build_track(item))
254 .collect()
255 })
256 .unwrap_or_default()
257 }
258 fn build_track(&self, data: &Value) -> Option<Track> {
259 let id = data["id"].as_str()?;
260 let title = data["title"].as_str()?.to_owned();
261 let author = data["user"]["name"]
262 .as_str()
263 .unwrap_or("Unknown Artist")
264 .to_owned();
265 let duration = (data["duration"].as_f64().unwrap_or(0.0) * 1000.0) as u64;
266 let uri = data["permalink"].as_str().map(|p| {
267 if p.starts_with("http") {
268 p.to_owned()
269 } else {
270 format!("https://audius.co{p}")
271 }
272 });
273 let artwork_url = self.get_artwork_url(&data["artwork"]);
274 Some(Track::new(TrackInfo {
275 identifier: id.to_owned(),
276 is_seekable: true,
277 author,
278 length: duration,
279 is_stream: false,
280 position: 0,
281 title,
282 uri,
283 artwork_url,
284 isrc: None,
285 source_name: "audius".to_owned(),
286 }))
287 }
288 fn get_artwork_url(&self, artwork: &Value) -> Option<String> {
289 if artwork.is_null() {
290 return None;
291 }
292 if let Some(url) = artwork.as_str() {
293 return Some(if url.starts_with('/') {
294 format!("https://audius.co{url}")
295 } else {
296 url.to_owned()
297 });
298 }
299 for size in &["480x480", "1000x1000", "150x150"] {
300 if let Some(url) = artwork[size].as_str() {
301 return Some(if url.starts_with('/') {
302 format!("https://audius.co{url}")
303 } else {
304 url.to_owned()
305 });
306 }
307 }
308 None
309 }
310}
311#[async_trait]
312impl SourcePlugin for AudiusSource {
313 fn name(&self) -> &str {
314 "audius"
315 }
316 fn can_handle(&self, identifier: &str) -> bool {
317 self.search_prefixes()
318 .iter()
319 .any(|p| identifier.starts_with(p))
320 || TRACK_PATTERN
321 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<artist>[^/]+)/(?P<slug>[^/?#]+)(?:\?.*)?$").unwrap())
322 .is_match(identifier)
323 || PLAYLIST_PATTERN
324 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<artist>[^/]+)/playlist/(?P<slug>[^/?#]+)(?:\?.*)?$").unwrap())
325 .is_match(identifier)
326 || ALBUM_PATTERN
327 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<artist>[^/]+)/album/(?P<slug>[^/?#]+)(?:\?.*)?$").unwrap())
328 .is_match(identifier)
329 || USER_PATTERN
330 .get_or_init(|| Regex::new(r"(?i)^https?://(?:www\.)?audius\.co/(?P<user>[^/?#]+)(?:\?.*)?$").unwrap())
331 .is_match(identifier)
332 }
333 fn search_prefixes(&self) -> Vec<&str> {
334 vec!["ausearch:", "audsearch:"]
335 }
336 async fn load(
337 &self,
338 identifier: &str,
339 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
340 ) -> LoadResult {
341 if let Some(prefix) = self
342 .search_prefixes()
343 .into_iter()
344 .find(|p| identifier.starts_with(p))
345 {
346 return self.search(&identifier[prefix.len()..]).await;
347 }
348 self.resolve_url(identifier).await
349 }
350 async fn get_track(
351 &self,
352 identifier: &str,
353 routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
354 ) -> Option<BoxedTrack> {
355 let track_id = if identifier.starts_with("http") {
356 let mut params = std::collections::BTreeMap::new();
357 params.insert("url".to_owned(), identifier.to_owned());
358 let data = self.api_request("/v1/resolve", Some(params)).await?;
359 data["id"].as_str()?.to_owned()
360 } else {
361 identifier.to_owned()
362 };
363 let stream_url = track::fetch_stream_url(&self.client, &track_id, &self.app_name).await?;
364 Some(Arc::new(track::AudiusTrack {
365 client: self.client.clone(),
366 track_id,
367 stream_url: Some(stream_url),
368 app_name: self.app_name.clone(),
369 local_addr: routeplanner.and_then(|rp| rp.get_address()),
370 }))
371 }
372}