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}