Skip to main content

lavende_core/sources/
audius.rs

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}