Skip to main content

lavende_core/sources/
mixcloud.rs

1pub mod reader {
2    use crate::{
3        audio::source::{HttpSource, create_client},
4        common::types::AnyResult,
5    };
6    use std::io::{Read, Seek, SeekFrom};
7    use symphonia::core::io::MediaSource;
8    pub struct MixcloudReader {
9        inner: HttpSource,
10    }
11    const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
12    impl MixcloudReader {
13        pub async fn new(url: &str, local_addr: Option<std::net::IpAddr>) -> AnyResult<Self> {
14            let client = create_client(USER_AGENT.to_owned(), local_addr, None, None)?;
15            let inner = HttpSource::new(client, url).await?;
16            Ok(Self { inner })
17        }
18    }
19    impl Read for MixcloudReader {
20        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
21            self.inner.read(buf)
22        }
23    }
24    impl Seek for MixcloudReader {
25        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
26            self.inner.seek(pos)
27        }
28    }
29    impl MediaSource for MixcloudReader {
30        fn is_seekable(&self) -> bool {
31            self.inner.is_seekable()
32        }
33        fn byte_len(&self) -> Option<u64> {
34            self.inner.byte_len()
35        }
36    }
37}
38pub mod track {
39    use crate::{
40        common::types::AudioFormat,
41        sources::playable_track::{PlayableTrack, ResolvedTrack},
42    };
43    use async_trait::async_trait;
44    use std::sync::Arc;
45    use tracing::error;
46    pub struct MixcloudTrack {
47        pub client: Arc<reqwest::Client>,
48        pub hls_url: Option<String>,
49        pub stream_url: Option<String>,
50        pub uri: String,
51        pub local_addr: Option<std::net::IpAddr>,
52    }
53    #[async_trait]
54    impl PlayableTrack for MixcloudTrack {
55        async fn resolve(&self) -> Result<ResolvedTrack, String> {
56            let (hls_url, stream_url) = if self.hls_url.is_some() || self.stream_url.is_some() {
57                (self.hls_url.clone(), self.stream_url.clone())
58            } else {
59                let (enc_hls, enc_url) = super::fetch_track_stream_info(&self.client, &self.uri)
60                    .await
61                    .unwrap_or((None, None));
62                (
63                    enc_hls.map(|s| super::decrypt(&s)),
64                    enc_url.map(|s| super::decrypt(&s)),
65                )
66            };
67            let local_addr = self.local_addr;
68            let uri = self.uri.clone();
69            if let Some(url) = hls_url {
70                crate::sources::youtube::hls::HlsReader::new(&url, local_addr, None, None, None)
71                    .await
72                    .map(|r| {
73                        ResolvedTrack::new(
74                            Box::new(r) as Box<dyn symphonia::core::io::MediaSource>,
75                            Some(AudioFormat::Aac),
76                        )
77                    })
78                    .map_err(|e| {
79                        error!("Mixcloud HlsReader failed to initialize: {e}");
80                        format!("Failed to init HLS reader: {e}")
81                    })
82            } else if let Some(url) = stream_url {
83                let hint = std::path::Path::new(&url)
84                    .extension()
85                    .and_then(|s| s.to_str())
86                    .map(AudioFormat::from_ext)
87                    .or(Some(AudioFormat::Mp4));
88                super::reader::MixcloudReader::new(&url, local_addr)
89                    .await
90                    .map(|r| {
91                        ResolvedTrack::new(
92                            Box::new(r) as Box<dyn symphonia::core::io::MediaSource>,
93                            hint,
94                        )
95                    })
96                    .map_err(|e| {
97                        error!("MixcloudReader failed to initialize: {e}");
98                        format!("Failed to init reader: {e}")
99                    })
100            } else {
101                error!("Mixcloud: no stream URL available for {uri}");
102                Err(format!("No stream URL available for {uri}"))
103            }
104        }
105    }
106}
107use crate::{
108    protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
109    sources::{SourcePlugin, playable_track::BoxedTrack},
110};
111use async_trait::async_trait;
112use base64::{Engine as _, engine::general_purpose};
113use regex::Regex;
114use serde_json::{Value, json};
115use std::sync::{Arc, OnceLock};
116const DECRYPTION_KEY: &[u8] = b"IFYOUWANTTHEARTISTSTOGETPAIDDONOTDOWNLOADFROMMIXCLOUD";
117const GRAPHQL_URL: &str = "https://app.mixcloud.com/graphql";
118fn track_url_re() -> &'static Regex {
119    static RE: OnceLock<Regex> = OnceLock::new();
120    RE.get_or_init(|| {
121        Regex::new(
122            r"(?i)^https?://(?:(?:www|beta|m)\.)?mixcloud\.com/(?P<user>[^/]+)/(?P<slug>[^/]+)/?$",
123        )
124        .unwrap()
125    })
126}
127fn playlist_url_re() -> &'static Regex {
128    static RE: OnceLock<Regex> = OnceLock::new();
129    RE.get_or_init(|| {
130        Regex::new(r"(?i)^https?://(?:(?:www|beta|m)\.)?mixcloud\.com/(?P<user>[^/]+)/playlists/(?P<playlist>[^/]+)/?$").unwrap()
131    })
132}
133fn user_url_re() -> &'static Regex {
134    static RE: OnceLock<Regex> = OnceLock::new();
135    RE.get_or_init(|| {
136        Regex::new(r"(?i)^https?://(?:(?:www|beta|m)\.)?mixcloud\.com/(?P<id>[^/]+)(?:/(?P<type>uploads|favorites|listens|stream))?/?$").unwrap()
137    })
138}
139pub struct MixcloudSource {
140    client: Arc<reqwest::Client>,
141    search_limit: usize,
142}
143impl MixcloudSource {
144    pub fn new(
145        config: Option<crate::config::MixcloudConfig>,
146        client: Arc<reqwest::Client>,
147    ) -> Result<Self, String> {
148        Ok(Self {
149            client,
150            search_limit: config.map(|c| c.search_limit).unwrap_or(10),
151        })
152    }
153}
154pub fn decrypt(ciphertext_b64: &str) -> String {
155    let ciphertext: Vec<u8> = match general_purpose::STANDARD.decode(ciphertext_b64) {
156        Ok(b) => b,
157        Err(_) => return String::new(),
158    };
159    let mut decrypted = Vec::with_capacity(ciphertext.len());
160    for (i, &byte) in ciphertext.iter().enumerate() {
161        decrypted.push(byte ^ DECRYPTION_KEY[i % DECRYPTION_KEY.len()]);
162    }
163    String::from_utf8(decrypted).unwrap_or_default()
164}
165impl MixcloudSource {
166    async fn graphql_request(&self, query: &str) -> Option<Value> {
167        let url = format!("{GRAPHQL_URL}?query={}", urlencoding::encode(query));
168        let resp = self.client.get(url).send().await.ok()?;
169        if !resp.status().is_success() {
170            return None;
171        }
172        resp.json::<Value>().await.ok()
173    }
174    fn parse_track_data(&self, data: &Value) -> Option<Track> {
175        let url_raw = data["url"].as_str()?;
176        let path_parts: Vec<&str> = url_raw
177            .split("mixcloud.com/")
178            .nth(1)?
179            .split('/')
180            .filter(|s| !s.is_empty())
181            .collect();
182        if path_parts.len() < 2 {
183            return None;
184        }
185        let user_id = path_parts[0];
186        let slug = path_parts[1];
187        let id = format!("{user_id}_{slug}");
188        let title = data["name"].as_str()?.to_owned();
189        let author = data["owner"]["displayName"]
190            .as_str()
191            .unwrap_or(user_id)
192            .to_owned();
193        let duration_ms = data["audioLength"].as_u64().unwrap_or(0) * 1000;
194        let artwork_url = data["picture"]["url"].as_str().map(|s| s.to_owned());
195        Some(Track::new(TrackInfo {
196            identifier: id,
197            is_seekable: true,
198            author,
199            length: duration_ms,
200            is_stream: false,
201            position: 0,
202            title,
203            uri: Some(url_raw.to_owned()),
204            artwork_url,
205            isrc: None,
206            source_name: "mixcloud".to_owned(),
207        }))
208    }
209    async fn resolve_track(&self, username: &str, slug: &str) -> LoadResult {
210        let query = format!(
211            "{{
212                cloudcastLookup(lookup: {{username: \"{username}\", slug: \"{slug}\"}}) {{
213                  audioLength
214                  name
215                  url
216                  owner {{ displayName username }}
217                  picture(width: 1024, height: 1024) {{ url }}
218                  streamInfo {{ hlsUrl url }}
219                  restrictedReason
220                }}
221            }}"
222        );
223        match self.graphql_request(&query).await {
224            Some(body) => {
225                if let Some(data) = body["data"]["cloudcastLookup"].as_object() {
226                    if let Some(reason) = data.get("restrictedReason").and_then(|v| v.as_str()) {
227                        return LoadResult::Error(crate::protocol::tracks::LoadError {
228                            message: Some(format!("Track restricted: {reason}")),
229                            severity: crate::common::Severity::Common,
230                            cause: reason.to_owned(),
231                            cause_stack_trace: None,
232                        });
233                    }
234                    if let Some(track) = self.parse_track_data(&Value::Object(data.clone())) {
235                        return LoadResult::Track(track);
236                    }
237                }
238                LoadResult::Empty {}
239            }
240            None => LoadResult::Empty {},
241        }
242    }
243    async fn resolve_playlist(&self, user: &str, slug: &str) -> LoadResult {
244        let query_template = |cursor: Option<&str>| {
245            let cursor_arg = cursor
246                .map(|c| format!(", after: \"{c}\""))
247                .unwrap_or_default();
248            format!(
249                "{{
250                    playlistLookup(lookup: {{username: \"{user}\", slug: \"{slug}\"}}) {{
251                      name
252                      items(first: 100{cursor_arg}) {{
253                        edges {{
254                          node {{
255                            cloudcast {{
256                              audioLength
257                              name
258                              url
259                              owner {{ displayName username }}
260                              picture(width: 1024, height: 1024) {{ url }}
261                              streamInfo {{ hlsUrl url }}
262                            }}
263                          }}
264                        }}
265                        pageInfo {{ endCursor hasNextPage }}
266                      }}
267                    }}
268                }}"
269            )
270        };
271        let mut tracks = Vec::new();
272        let mut cursor: Option<String> = None;
273        let mut playlist_name = "Mixcloud Playlist".to_owned();
274        loop {
275            let query = query_template(cursor.as_deref());
276            let body = match self.graphql_request(&query).await {
277                Some(b) => b,
278                None => break,
279            };
280            let lookup = &body["data"]["playlistLookup"];
281            if lookup.is_null() {
282                break;
283            }
284            if let Some(name) = lookup["name"].as_str() {
285                playlist_name = name.to_owned();
286            }
287            if let Some(edges) = lookup["items"]["edges"].as_array() {
288                for edge in edges {
289                    if let Some(track) = self.parse_track_data(&edge["node"]["cloudcast"]) {
290                        tracks.push(track);
291                    }
292                }
293            }
294            if lookup["items"]["pageInfo"]["hasNextPage"].as_bool() == Some(true) {
295                cursor = lookup["items"]["pageInfo"]["endCursor"]
296                    .as_str()
297                    .map(|s| s.to_owned());
298                if cursor.is_none() || tracks.len() >= 1000 {
299                    break;
300                }
301            } else {
302                break;
303            }
304        }
305        if tracks.is_empty() {
306            return LoadResult::Empty {};
307        }
308        LoadResult::Playlist(PlaylistData {
309            info: PlaylistInfo {
310                name: playlist_name,
311                selected_track: -1,
312            },
313            plugin_info: json!({}),
314            tracks,
315        })
316    }
317    async fn resolve_user(&self, username: &str, list_type: &str) -> LoadResult {
318        let (query_type, node_query) = match list_type {
319            "stream" => (
320                "stream",
321                "... on Cloudcast { audioLength name url owner { displayName username } picture(width: 1024, height: 1024) { url } streamInfo { hlsUrl url } }",
322            ),
323            _ => (
324                list_type,
325                "audioLength name url owner { displayName username } picture(width: 1024, height: 1024) { url } streamInfo { hlsUrl url }",
326            ),
327        };
328        let query_template = |cursor: Option<&str>| {
329            let cursor_arg = cursor
330                .map(|c| format!(", after: \"{c}\""))
331                .unwrap_or_default();
332            format!(
333                "{{
334                    userLookup(lookup: {{username: \"{username}\"}}) {{
335                      displayName
336                      {query_type}(first: 100{cursor_arg}) {{
337                        edges {{
338                          node {{
339                            {node_query}
340                          }}
341                        }}
342                        pageInfo {{ endCursor hasNextPage }}
343                      }}
344                    }}
345                }}"
346            )
347        };
348        let mut tracks = Vec::new();
349        let mut cursor: Option<String> = None;
350        let mut display_name = username.to_owned();
351        loop {
352            let query = query_template(cursor.as_deref());
353            let body = match self.graphql_request(&query).await {
354                Some(b) => b,
355                None => break,
356            };
357            let lookup = &body["data"]["userLookup"];
358            if lookup.is_null() {
359                break;
360            }
361            display_name = lookup["displayName"]
362                .as_str()
363                .unwrap_or(username)
364                .to_owned();
365            if let Some(edges) = lookup[query_type]["edges"].as_array() {
366                for edge in edges {
367                    if let Some(track) = self.parse_track_data(&edge["node"]) {
368                        tracks.push(track);
369                    }
370                }
371            }
372            if lookup[query_type]["pageInfo"]["hasNextPage"].as_bool() == Some(true) {
373                cursor = lookup[query_type]["pageInfo"]["endCursor"]
374                    .as_str()
375                    .map(|s| s.to_owned());
376                if cursor.is_none() || tracks.len() >= 1000 {
377                    break;
378                }
379            } else {
380                break;
381            }
382        }
383        if tracks.is_empty() {
384            return LoadResult::Empty {};
385        }
386        LoadResult::Playlist(PlaylistData {
387            info: PlaylistInfo {
388                name: format!("{display_name} ({list_type})"),
389                selected_track: -1,
390            },
391            plugin_info: json!({}),
392            tracks,
393        })
394    }
395    async fn search(&self, query_raw: &str) -> LoadResult {
396        let url = format!(
397            "https://api.mixcloud.com/search/?q={}&type=cloudcast",
398            urlencoding::encode(query_raw)
399        );
400        let resp = match self.client.get(url).send().await {
401            Ok(r) => r,
402            Err(_) => return LoadResult::Empty {},
403        };
404        let body: Value = match resp.json().await {
405            Ok(v) => v,
406            Err(_) => return LoadResult::Empty {},
407        };
408        let mut tracks = Vec::new();
409        if let Some(data) = body["data"].as_array() {
410            for item in data {
411                if let Some(url_raw) = item["url"].as_str() {
412                    let path_parts: Vec<&str> = url_raw
413                        .split("mixcloud.com/")
414                        .nth(1)
415                        .unwrap_or("")
416                        .split('/')
417                        .filter(|s| !s.is_empty())
418                        .collect();
419                    if path_parts.len() < 2 {
420                        continue;
421                    }
422                    let id = format!("{}_{}", path_parts[0], path_parts[1]);
423                    tracks.push(Track::new(TrackInfo {
424                        identifier: id,
425                        is_seekable: true,
426                        author: item["user"]["name"]
427                            .as_str()
428                            .unwrap_or(path_parts[0])
429                            .to_owned(),
430                        length: item["audio_length"].as_u64().unwrap_or(0) * 1000,
431                        is_stream: false,
432                        position: 0,
433                        title: item["name"].as_str().unwrap_or("Unknown").to_owned(),
434                        uri: Some(url_raw.to_owned()),
435                        artwork_url: item["pictures"]["large"]
436                            .as_str()
437                            .or_else(|| item["pictures"]["medium"].as_str())
438                            .map(|s| s.to_owned()),
439                        isrc: None,
440                        source_name: "mixcloud".to_owned(),
441                    }));
442                }
443                if tracks.len() >= self.search_limit {
444                    break;
445                }
446            }
447        }
448        if tracks.is_empty() {
449            return LoadResult::Empty {};
450        }
451        LoadResult::Search(tracks)
452    }
453}
454pub async fn fetch_track_stream_info(
455    client: &Arc<reqwest::Client>,
456    url: &str,
457) -> Option<(Option<String>, Option<String>)> {
458    let path_parts: Vec<&str> = url
459        .split("mixcloud.com/")
460        .nth(1)?
461        .split('/')
462        .filter(|s| !s.is_empty())
463        .collect();
464    if path_parts.len() < 2 {
465        return None;
466    }
467    let query = format!(
468        "{{
469            cloudcastLookup(lookup: {{username: \"{}\", slug: \"{}\"}}) {{
470              streamInfo {{ hlsUrl url }}
471            }}
472        }}",
473        path_parts[0], path_parts[1]
474    );
475    let body = graphql_request_internal(client, &query).await?;
476    let data = body["data"]["cloudcastLookup"].as_object()?;
477    let info = data.get("streamInfo")?;
478    let hls = info
479        .get("hlsUrl")
480        .and_then(|v| v.as_str())
481        .map(|s| s.to_owned());
482    let stream = info
483        .get("url")
484        .and_then(|v| v.as_str())
485        .map(|s| s.to_owned());
486    Some((hls, stream))
487}
488#[async_trait]
489impl SourcePlugin for MixcloudSource {
490    fn name(&self) -> &str {
491        "mixcloud"
492    }
493    fn can_handle(&self, identifier: &str) -> bool {
494        self.search_prefixes()
495            .iter()
496            .any(|p| identifier.starts_with(p))
497            || track_url_re().is_match(identifier)
498            || playlist_url_re().is_match(identifier)
499            || user_url_re().is_match(identifier)
500    }
501    fn search_prefixes(&self) -> Vec<&str> {
502        vec!["mcsearch:"]
503    }
504    async fn load(
505        &self,
506        identifier: &str,
507        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
508    ) -> LoadResult {
509        if let Some(prefix) = self
510            .search_prefixes()
511            .into_iter()
512            .find(|p| identifier.starts_with(p))
513        {
514            return self.search(&identifier[prefix.len()..]).await;
515        }
516        if let Some(caps) = playlist_url_re().captures(identifier) {
517            return self
518                .resolve_playlist(&caps["user"], &caps["playlist"])
519                .await;
520        }
521        if let Some(caps) = user_url_re().captures(identifier) {
522            return self
523                .resolve_user(
524                    &caps["id"],
525                    caps.name("type").map(|m| m.as_str()).unwrap_or("uploads"),
526                )
527                .await;
528        }
529        if let Some(caps) = track_url_re().captures(identifier) {
530            return self.resolve_track(&caps["user"], &caps["slug"]).await;
531        }
532        LoadResult::Empty {}
533    }
534    async fn get_track(
535        &self,
536        identifier: &str,
537        routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
538    ) -> Option<BoxedTrack> {
539        let url = match self.load(identifier, None).await {
540            LoadResult::Track(track) => track.info.uri?,
541            _ => return None,
542        };
543        let (enc_hls, enc_url) = fetch_track_stream_info(&self.client, &url)
544            .await
545            .unwrap_or((None, None));
546        let hls_url = enc_hls.map(|s| decrypt(&s));
547        let stream_url = enc_url.map(|s| decrypt(&s));
548        if hls_url.is_none() && stream_url.is_none() {
549            return None;
550        }
551        Some(Arc::new(track::MixcloudTrack {
552            client: self.client.clone(),
553            hls_url,
554            stream_url,
555            uri: url,
556            local_addr: routeplanner.and_then(|rp| rp.get_address()),
557        }))
558    }
559}
560async fn graphql_request_internal(client: &Arc<reqwest::Client>, query: &str) -> Option<Value> {
561    let url = format!("{GRAPHQL_URL}?query={}", urlencoding::encode(query));
562    let resp = client.get(url).send().await.ok()?;
563    if !resp.status().is_success() {
564        return None;
565    }
566    resp.json::<Value>().await.ok()
567}