1pub mod manager {
2 use super::{
3 token::SoundCloudTokenTracker,
4 track::{SoundCloudStreamKind, SoundCloudTrack},
5 };
6 use crate::{
7 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
8 sources::{SourcePlugin, playable_track::BoxedTrack},
9 };
10 use async_trait::async_trait;
11 use regex::Regex;
12 use serde_json::Value;
13 use std::sync::{Arc, OnceLock};
14 use tracing::{debug, error, trace, warn};
15 const BASE_URL: &str = "https://api-v2.soundcloud.com";
16 fn track_url_re() -> &'static Regex {
17 static RE: OnceLock<Regex> = OnceLock::new();
18 RE.get_or_init(|| {
19 Regex::new(r"^https?://(?:www\.|m\.)?soundcloud\.com/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)(?:/s-[a-zA-Z0-9_-]+)?/?(?:\?.*)?$").unwrap()
20 })
21 }
22 fn playlist_url_re() -> &'static Regex {
23 static RE: OnceLock<Regex> = OnceLock::new();
24 RE.get_or_init(|| {
25 Regex::new(r"^https?://(?:www\.|m\.)?soundcloud\.com/([a-zA-Z0-9_-]+)/sets/([a-zA-Z0-9_:-]+)(?:/[a-zA-Z0-9_-]+)?/?(?:\?.*)?$").unwrap()
26 })
27 }
28 fn liked_url_re() -> &'static Regex {
29 static RE: OnceLock<Regex> = OnceLock::new();
30 RE.get_or_init(|| {
31 Regex::new(
32 r"^https?://(?:www\.|m\.)?soundcloud\.com/([a-zA-Z0-9_-]+)/likes/?(?:\?.*)?$",
33 )
34 .unwrap()
35 })
36 }
37 fn short_url_re() -> &'static Regex {
38 static RE: OnceLock<Regex> = OnceLock::new();
39 RE.get_or_init(|| {
40 Regex::new(r"^https://on\.soundcloud\.com/[a-zA-Z0-9_-]+/?(?:\?.*)?$").unwrap()
41 })
42 }
43 fn mobile_url_re() -> &'static Regex {
44 static RE: OnceLock<Regex> = OnceLock::new();
45 RE.get_or_init(|| {
46 Regex::new(r"^https://soundcloud\.app\.goo\.gl/[a-zA-Z0-9_-]+/?(?:\?.*)?$").unwrap()
47 })
48 }
49 fn liked_user_urn_re() -> &'static Regex {
50 static RE: OnceLock<Regex> = OnceLock::new();
51 RE.get_or_init(|| {
52 Regex::new(r#""urn":"soundcloud:users:(\d+)","username":"([^"]+)""#).unwrap()
53 })
54 }
55 fn user_url_re() -> &'static Regex {
56 static RE: OnceLock<Regex> = OnceLock::new();
57 RE.get_or_init(|| {
58 Regex::new(r"^https?://(?:www\.|m\.)?soundcloud\.com/([a-zA-Z0-9_-]+)(?:/(tracks|popular-tracks|albums|sets|reposts|spotlight))?/?(?:\?.*)?$").unwrap()
59 })
60 }
61 fn search_url_re() -> &'static Regex {
62 static RE: OnceLock<Regex> = OnceLock::new();
63 RE.get_or_init(|| {
64 Regex::new(r"^https?://(?:www\.|m\.)?soundcloud\.com/search(?:/(?:sounds|people|albums|sets))?/?(?:\?.*)?$").unwrap()
65 })
66 }
67 pub struct SoundCloudSource {
68 client: Arc<reqwest::Client>,
69 config: crate::config::SoundCloudConfig,
70 token_tracker: Arc<SoundCloudTokenTracker>,
71 }
72 impl SoundCloudSource {
73 pub fn new(
74 config: crate::config::SoundCloudConfig,
75 client: Arc<reqwest::Client>,
76 ) -> Result<Self, String> {
77 let token_tracker = Arc::new(SoundCloudTokenTracker::new(client.clone(), &config));
78 token_tracker.clone().init();
79 Ok(Self {
80 client,
81 config,
82 token_tracker,
83 })
84 }
85 async fn api_resolve(&self, url: &str, client_id: &str) -> Option<Value> {
86 let req_url = format!(
87 "{}/resolve?url={}&client_id={}",
88 BASE_URL,
89 urlencoding::encode(url),
90 client_id
91 );
92 debug!("SoundCloud: Resolving URL: {}", req_url);
93 let builder = self.client.get(&req_url);
94 let resp = builder.send().await.ok()?;
95 if resp.status().as_u16() == 401 {
96 self.token_tracker.invalidate().await;
97 return None;
98 }
99 if !resp.status().is_success() {
100 warn!(
101 "SoundCloud: API resolve failed with status: {} for {}",
102 resp.status(),
103 url
104 );
105 return None;
106 }
107 let json: Value = resp.json().await.ok()?;
108 trace!("SoundCloud: API resolve response: {:?}", json);
109 Some(json)
110 }
111 fn parse_track(&self, json: &Value) -> Result<Track, String> {
112 let id = json
113 .get("id")
114 .and_then(|v| {
115 v.as_str()
116 .map(|s| s.to_owned())
117 .or_else(|| Some(v.to_string()))
118 })
119 .ok_or_else(|| "Missing track ID".to_owned())?;
120 let title = json
121 .get("title")
122 .and_then(|v| v.as_str())
123 .unwrap_or("Unknown")
124 .to_owned();
125 trace!("SoundCloud: Parsing track {}: {}", id, title);
126 if json.get("policy").and_then(|v| v.as_str()) == Some("BLOCK") {
127 trace!(
128 "SoundCloud: Track '{}' is blocked by policy (likely geo-blocked). Returning metadata for mirroring.",
129 title
130 );
131 }
132 if json.get("monetization_model").and_then(|v| v.as_str()) == Some("SUB_HIGH_TIER") {
133 trace!("SoundCloud: Track '{}' is a Go+ (premium) track", title);
134 }
135 if let Some(transcodings) = json
136 .get("media")
137 .and_then(|m| m.get("transcodings"))
138 .and_then(|v| v.as_array())
139 {
140 let all_preview = !transcodings.is_empty()
141 && transcodings.iter().all(|t| {
142 let snipped = t.get("snipped").and_then(|v| v.as_bool()).unwrap_or(false);
143 let url = t.get("url").and_then(|v| v.as_str()).unwrap_or("");
144 snipped
145 || url.contains("/preview/")
146 || url.contains("cf-preview-media.sndcdn.com")
147 });
148 if all_preview {
149 trace!(
150 "SoundCloud: Track '{}' only has preview transcodings. Returning metadata for mirroring.",
151 title
152 );
153 }
154 }
155 let author = json
156 .get("user")
157 .and_then(|u| u.get("username"))
158 .and_then(|v| v.as_str())
159 .unwrap_or("Unknown")
160 .to_owned();
161 let duration = json
162 .get("full_duration")
163 .or_else(|| json.get("duration"))
164 .and_then(|v| v.as_u64())
165 .unwrap_or(0);
166 let uri = json
167 .get("permalink_url")
168 .and_then(|v| v.as_str())
169 .filter(|s| !s.is_empty())
170 .map(|s| s.to_owned());
171 let artwork_url = json
172 .get("artwork_url")
173 .and_then(|v| v.as_str())
174 .filter(|s| !s.is_empty())
175 .map(|s| s.replace("-large", "-t500x500"));
176 let isrc = json
177 .get("publisher_metadata")
178 .and_then(|m| m.get("isrc"))
179 .and_then(|v| v.as_str())
180 .filter(|s| !s.is_empty())
181 .map(|s| s.to_owned());
182 let track = Track::new(TrackInfo {
183 identifier: id,
184 is_seekable: true,
185 author,
186 length: duration,
187 is_stream: false,
188 position: 0,
189 title,
190 uri: uri.clone(),
191 artwork_url,
192 isrc,
193 source_name: "soundcloud".to_owned(),
194 });
195 Ok(track)
196 }
197 fn select_format(transcodings: &[Value]) -> Option<(SoundCloudStreamKind, String)> {
198 if transcodings.is_empty() {
199 return None;
200 }
201 macro_rules! find_transcoding {
202 ($protocol:expr, $mime_contains:expr) => {
203 transcodings.iter().find(|t| {
204 let fmt = t.get("format");
205 let proto = fmt
206 .and_then(|f| f.get("protocol"))
207 .and_then(|v| v.as_str())
208 .unwrap_or("");
209 let mime = fmt
210 .and_then(|f| f.get("mime_type"))
211 .and_then(|v| v.as_str())
212 .unwrap_or("");
213 let snipped = t.get("snipped").and_then(|v| v.as_bool()).unwrap_or(false);
214 let url = t.get("url").and_then(|v| v.as_str()).unwrap_or("");
215 !snipped
216 && !url.contains("/preview/")
217 && !url.contains("cf-preview-media.sndcdn.com")
218 && proto == $protocol
219 && mime.contains($mime_contains)
220 })
221 };
222 }
223 let selected = find_transcoding!("progressive", "mpeg")
224 .or_else(|| find_transcoding!("progressive", "aac"))
225 .or_else(|| find_transcoding!("hls", "mpeg"))
226 .or_else(|| find_transcoding!("hls", "aac"))
227 .or_else(|| find_transcoding!("hls", "mp4"))
228 .or_else(|| find_transcoding!("hls", "m4a"))
229 .or_else(|| find_transcoding!("hls", "ogg"))
230 .or_else(|| {
231 transcodings.iter().find(|t| {
232 t.get("format")
233 .and_then(|f| f.get("protocol"))
234 .and_then(|v| v.as_str())
235 == Some("progressive")
236 })
237 })
238 .or_else(|| {
239 transcodings.iter().find(|t| {
240 t.get("format")
241 .and_then(|f| f.get("protocol"))
242 .and_then(|v| v.as_str())
243 == Some("hls")
244 })
245 })
246 .or_else(|| transcodings.first())?;
247 let lookup_url = selected.get("url").and_then(|v| v.as_str())?.to_owned();
248 let proto = selected
249 .get("format")
250 .and_then(|f| f.get("protocol"))
251 .and_then(|v| v.as_str())
252 .unwrap_or("progressive");
253 let mime = selected
254 .get("format")
255 .and_then(|f| f.get("mime_type"))
256 .and_then(|v| v.as_str())
257 .unwrap_or("");
258 let kind = if proto == "progressive" {
259 if mime.contains("mpeg") || mime.contains("mp3") {
260 SoundCloudStreamKind::ProgressiveMp3
261 } else {
262 SoundCloudStreamKind::ProgressiveAac
263 }
264 } else {
265 if mime.contains("ogg") {
266 SoundCloudStreamKind::HlsOpus
267 } else if mime.contains("mpeg") || mime.contains("mp3") {
268 SoundCloudStreamKind::HlsMp3
269 } else if mime.contains("aac") || mime.contains("mp4") || mime.contains("m4a") {
270 SoundCloudStreamKind::HlsAac
271 } else {
272 SoundCloudStreamKind::HlsAac
273 }
274 };
275 Some((kind, lookup_url))
276 }
277 async fn resolve_stream_url(&self, lookup_url: &str, client_id: &str) -> Option<String> {
278 let url = format!("{}?client_id={}", lookup_url, client_id);
279 let builder = self.client.get(&url);
280 let resp = builder.send().await.ok()?;
281 if resp.status().as_u16() == 401 {
282 self.token_tracker.invalidate().await;
283 return None;
284 }
285 if !resp.status().is_success() {
286 return None;
287 }
288 let json: Value = resp.json().await.ok()?;
289 let stream_url = json
290 .get("url")
291 .and_then(|v| v.as_str())
292 .map(|s| s.to_owned());
293 if let Some(ref url) = stream_url {
294 debug!("SoundCloud: Resolved playback URL: {}", url);
295 }
296 stream_url
297 }
298 async fn get_track_from_url(
299 &self,
300 url: &str,
301 client_id: &str,
302 local_addr: Option<std::net::IpAddr>,
303 ) -> Option<BoxedTrack> {
304 let json = self.api_resolve(url, client_id).await?;
305 if json.get("kind").and_then(|v| v.as_str()) != Some("track") {
306 return None;
307 }
308 let transcodings = json
309 .get("media")
310 .and_then(|m| m.get("transcodings"))
311 .and_then(|v| v.as_array())
312 .cloned()
313 .unwrap_or_default();
314 if transcodings.is_empty() {
315 warn!("SoundCloud: No transcodings for track {}", url);
316 return None;
317 }
318 let (kind, lookup_url) = Self::select_format(&transcodings)?;
319 trace!("SoundCloud: Selected format {:?} for {}", kind, url);
320 let stream_url = self.resolve_stream_url(&lookup_url, client_id).await?;
321 if stream_url.contains("cf-preview-media.sndcdn.com")
322 || stream_url.contains("/preview/")
323 {
324 warn!("SoundCloud: Track {} only has a preview URL, skipping", url);
325 return None;
326 }
327 Some(Arc::new(SoundCloudTrack {
328 stream_url,
329 kind,
330 bitrate_bps: 128_000,
331 local_addr,
332 proxy: self.config.proxy.clone(),
333 }))
334 }
335 async fn search_tracks(&self, query: &str) -> LoadResult {
336 let client_id = match self.token_tracker.get_client_id().await {
337 Some(id) => id,
338 None => return LoadResult::Empty {},
339 };
340 let limit = self.config.search_limit;
341 let req_url = format!(
342 "{}/search/tracks?q={}&client_id={}&limit={}&offset=0",
343 BASE_URL,
344 urlencoding::encode(query),
345 client_id,
346 limit
347 );
348 let builder = self.client.get(&req_url);
349 let resp = match builder.send().await {
350 Ok(r) => r,
351 Err(e) => {
352 error!("SoundCloud search error: {}", e);
353 return LoadResult::Empty {};
354 }
355 };
356 if !resp.status().is_success() {
357 return LoadResult::Empty {};
358 }
359 let json: Value = match resp.json().await {
360 Ok(v) => v,
361 Err(_) => return LoadResult::Empty {},
362 };
363 let tracks: Vec<Track> = json
364 .get("collection")
365 .and_then(|v| v.as_array())
366 .unwrap_or(&vec![])
367 .iter()
368 .filter_map(|item| self.parse_track(item).ok())
369 .collect();
370 if tracks.is_empty() {
371 LoadResult::Empty {}
372 } else {
373 LoadResult::Search(tracks)
374 }
375 }
376 async fn load_single_track(&self, url: &str) -> LoadResult {
377 let client_id = match self.token_tracker.get_client_id().await {
378 Some(id) => id,
379 None => return LoadResult::Empty {},
380 };
381 let json = match self.api_resolve(url, &client_id).await {
382 Some(v) => v,
383 None => return LoadResult::Empty {},
384 };
385 match self.parse_track(&json) {
386 Ok(track) => LoadResult::Track(track),
387 Err(msg) => {
388 warn!("SoundCloud: Failed to parse track: {}", msg);
389 LoadResult::Empty {}
390 }
391 }
392 }
393 async fn resolve_short_url(&self, url: &str) -> Option<String> {
394 let resp = self.client.head(url).send().await.ok()?;
395 let location = resp.headers().get("location")?.to_str().ok()?.to_owned();
396 Some(location)
397 }
398 async fn resolve_mobile_url(&self, url: &str) -> Option<String> {
399 let resp = self.client.get(url).send().await.ok()?;
400 Some(resp.url().to_string())
401 }
402 async fn load_playlist(&self, url: &str) -> LoadResult {
403 let client_id = match self.token_tracker.get_client_id().await {
404 Some(id) => id,
405 None => return LoadResult::Empty {},
406 };
407 let json = match self.api_resolve(url, &client_id).await {
408 Some(v) => v,
409 None => return LoadResult::Empty {},
410 };
411 if json.get("kind").and_then(|v| v.as_str()) != Some("playlist") {
412 return LoadResult::Empty {};
413 }
414 let name = json
415 .get("title")
416 .and_then(|v| v.as_str())
417 .unwrap_or("Untitled playlist")
418 .to_owned();
419 let raw_tracks = json
420 .get("tracks")
421 .and_then(|v| v.as_array())
422 .cloned()
423 .unwrap_or_default();
424 let mut complete: Vec<Track> = Vec::new();
425 let mut stub_ids: Vec<String> = Vec::new();
426 for t in &raw_tracks {
427 if t.get("title").is_some() {
428 if let Ok(track) = self.parse_track(t) {
429 complete.push(track);
430 }
431 } else if let Some(id) = t.get("id").map(|v| v.to_string()) {
432 stub_ids.push(id);
433 }
434 }
435 let playlist_limit = self.config.playlist_load_limit;
436 let needed = stub_ids
437 .iter()
438 .take(playlist_limit.saturating_sub(complete.len()))
439 .cloned()
440 .collect::<Vec<_>>();
441 for chunk in needed.chunks(50) {
442 let ids = chunk.join(",");
443 let batch_url = format!("{BASE_URL}/tracks?ids={ids}&client_id={client_id}");
444 let builder = self.client.get(&batch_url);
445 if let Ok(resp) = builder.send().await
446 && let Ok(json) = resp.json::<Value>().await
447 && let Some(arr) = json.as_array()
448 {
449 for item in arr {
450 if let Ok(track) = self.parse_track(item) {
451 complete.push(track);
452 }
453 }
454 }
455 }
456 complete.truncate(playlist_limit);
457 if complete.is_empty() {
458 return LoadResult::Empty {};
459 }
460 LoadResult::Playlist(PlaylistData {
461 info: PlaylistInfo {
462 name,
463 selected_track: -1,
464 },
465 plugin_info: serde_json::json!({ "type": json.get("kind").and_then(|v| v.as_str()).unwrap_or("playlist"), "url": url, "artworkUrl": json.get("artwork_url").and_then(|v| v.as_str()).map(|s| s.replace("-large", "-t500x500")), "author": json.get("user").and_then(|u| u.get("username")).and_then(|v| v.as_str()), "totalTracks": json.get("track_count").and_then(|v| v.as_u64()).unwrap_or(complete.len() as u64) }),
466 tracks: complete,
467 })
468 }
469 async fn load_liked_tracks(&self, url: &str) -> LoadResult {
470 let client_id = match self.token_tracker.get_client_id().await {
471 Some(id) => id,
472 None => return LoadResult::Empty {},
473 };
474 let html = match self.client.get(url).send().await {
475 Ok(r) => match r.text().await {
476 Ok(t) => t,
477 Err(_) => return LoadResult::Empty {},
478 },
479 Err(_) => return LoadResult::Empty {},
480 };
481 let caps = match liked_user_urn_re().captures(&html) {
482 Some(c) => c,
483 None => return LoadResult::Empty {},
484 };
485 let user_id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
486 let user_name = caps.get(2).map(|m| m.as_str()).unwrap_or("User");
487 let liked_url = format!(
488 "{BASE_URL}/users/{user_id}/likes?limit=200&offset=0&client_id={client_id}"
489 );
490 let resp = match self.client.get(&liked_url).send().await {
491 Ok(r) => r,
492 Err(_) => return LoadResult::Empty {},
493 };
494 let json: Value = match resp.json().await {
495 Ok(v) => v,
496 Err(_) => return LoadResult::Empty {},
497 };
498 let tracks: Vec<Track> = json
499 .get("collection")
500 .and_then(|v| v.as_array())
501 .unwrap_or(&vec![])
502 .iter()
503 .filter_map(|item| item.get("track").and_then(|t| self.parse_track(t).ok()))
504 .collect();
505 if tracks.is_empty() {
506 return LoadResult::Empty {};
507 }
508 LoadResult::Playlist(PlaylistData {
509 info: PlaylistInfo {
510 name: format!("Liked by {}", user_name),
511 selected_track: -1,
512 },
513 plugin_info: serde_json::json!({ "type": "playlist", "url": url, "author": user_name, "totalTracks": tracks.len() }),
514 tracks,
515 })
516 }
517 async fn load_user(&self, url: &str) -> LoadResult {
518 let client_id = match self.token_tracker.get_client_id().await {
519 Some(id) => id,
520 None => return LoadResult::Empty {},
521 };
522 let caps = match user_url_re().captures(url) {
523 Some(c) => c,
524 None => return LoadResult::Empty {},
525 };
526 let username = caps.get(1).map(|m| m.as_str()).unwrap_or("");
527 let sub_path = caps.get(2).map(|m| m.as_str()).unwrap_or("");
528 let clean_url = format!("https://soundcloud.com/{username}");
529 let json = match self.api_resolve(&clean_url, &client_id).await {
530 Some(v) => v,
531 None => return LoadResult::Empty {},
532 };
533 if json.get("kind").and_then(|v| v.as_str()) != Some("user") {
534 return LoadResult::Empty {};
535 }
536 let user_id = match json.get("id").and_then(|v| v.as_u64()) {
537 Some(id) => id,
538 None => return LoadResult::Empty {},
539 };
540 let user_name = json
541 .get("username")
542 .and_then(|v| v.as_str())
543 .unwrap_or("Unknown User")
544 .to_owned();
545 debug!(
546 "SoundCloud: Loading user '{}' (id={}) with sub-path '{}'",
547 user_name, user_id, sub_path
548 );
549 match sub_path {
550 "popular-tracks" => {
551 self.load_collection_tracks(
552 user_id,
553 &user_name,
554 "toptracks",
555 "Popular tracks from",
556 &client_id,
557 )
558 .await
559 }
560 "albums" => {
561 self.load_collection_tracks(
562 user_id,
563 &user_name,
564 "albums",
565 "Albums from",
566 &client_id,
567 )
568 .await
569 }
570 "sets" => {
571 self.load_collection_tracks(
572 user_id,
573 &user_name,
574 "playlists",
575 "Sets from",
576 &client_id,
577 )
578 .await
579 }
580 "reposts" => {
581 self.load_collection_tracks(
582 user_id,
583 &user_name,
584 "reposts",
585 "Reposts from",
586 &client_id,
587 )
588 .await
589 }
590 "tracks" => {
591 self.load_collection_tracks(
592 user_id,
593 &user_name,
594 "tracks",
595 "Tracks from",
596 &client_id,
597 )
598 .await
599 }
600 "" | "spotlight" => {
601 let result = self
602 .load_collection_tracks(
603 user_id,
604 &user_name,
605 "spotlight",
606 "Spotlight tracks from",
607 &client_id,
608 )
609 .await;
610 if matches!(result, LoadResult::Empty {}) && sub_path.is_empty() {
611 self.load_collection_tracks(
612 user_id,
613 &user_name,
614 "tracks",
615 "Tracks from",
616 &client_id,
617 )
618 .await
619 } else {
620 result
621 }
622 }
623 _ => LoadResult::Empty {},
624 }
625 }
626 async fn load_collection_tracks(
627 &self,
628 user_id: u64,
629 user_name: &str,
630 endpoint: &str,
631 playlist_prefix: &str,
632 client_id: &str,
633 ) -> LoadResult {
634 let req_url = format!(
635 "{BASE_URL}/users/{user_id}/{endpoint}?client_id={client_id}&limit=200&offset=0&linked_partitioning=1"
636 );
637 let resp = match self.client.get(&req_url).send().await {
638 Ok(r) => r,
639 Err(e) => {
640 error!("SoundCloud: Request for {} failed: {}", endpoint, e);
641 return LoadResult::Empty {};
642 }
643 };
644 if !resp.status().is_success() {
645 warn!(
646 "SoundCloud: Request for {} returned status {}",
647 endpoint,
648 resp.status()
649 );
650 return LoadResult::Empty {};
651 }
652 let mut tracks: Vec<Track> = Vec::new();
653 if let Ok(json) = resp.json::<Value>().await {
654 let collection = json.get("collection").and_then(|v| v.as_array());
655 if let Some(items) = collection {
656 for item in items {
657 let track_json = if item.get("track").is_some() {
658 item.get("track")
659 } else if item.get("kind").and_then(|v| v.as_str()) == Some("track") {
660 Some(item)
661 } else if item.get("playlist").is_some() {
662 None
663 } else {
664 Some(item)
665 };
666 if let Some(tj) = track_json
667 && let Ok(track) = self.parse_track(tj)
668 {
669 tracks.push(track);
670 }
671 }
672 }
673 }
674 if tracks.is_empty() {
675 return LoadResult::Empty {};
676 }
677 LoadResult::Playlist(PlaylistData {
678 info: PlaylistInfo {
679 name: format!("{} {}", playlist_prefix, user_name),
680 selected_track: -1,
681 },
682 plugin_info: serde_json::json!({ "type": match endpoint { "albums" => "album", "playlists" | "sets" => "playlist", _ => "artist" }, "url": format!("https://soundcloud.com/{}/{}", user_name, endpoint), "author": user_name, "totalTracks": tracks.len() }),
683 tracks,
684 })
685 }
686 }
687 #[async_trait]
688 impl SourcePlugin for SoundCloudSource {
689 fn name(&self) -> &str {
690 "soundcloud"
691 }
692 fn can_handle(&self, identifier: &str) -> bool {
693 if self
694 .search_prefixes()
695 .into_iter()
696 .any(|p| identifier.starts_with(p))
697 {
698 return true;
699 }
700 let url = identifier
701 .strip_prefix("https://m.")
702 .map(|s| format!("https://{s}"))
703 .unwrap_or_else(|| identifier.to_owned());
704 short_url_re().is_match(&url)
705 || mobile_url_re().is_match(identifier)
706 || liked_url_re().is_match(&url)
707 || playlist_url_re().is_match(&url)
708 || user_url_re().is_match(&url)
709 || search_url_re().is_match(&url)
710 || track_url_re().is_match(&url)
711 }
712 fn search_prefixes(&self) -> Vec<&str> {
713 vec!["scsearch:"]
714 }
715 async fn load(
716 &self,
717 identifier: &str,
718 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
719 ) -> LoadResult {
720 if let Some(prefix) = self
721 .search_prefixes()
722 .into_iter()
723 .find(|p| identifier.starts_with(p))
724 {
725 let query = identifier.strip_prefix(prefix).unwrap();
726 return self.search_tracks(query.trim()).await;
727 }
728 let url = if mobile_url_re().is_match(identifier) {
729 match self.resolve_mobile_url(identifier).await {
730 Some(u) => u,
731 None => return LoadResult::Empty {},
732 }
733 } else if short_url_re().is_match(identifier) {
734 match self.resolve_short_url(identifier).await {
735 Some(u) => u,
736 None => return LoadResult::Empty {},
737 }
738 } else {
739 identifier
740 .strip_prefix("https://m.")
741 .map(|s| format!("https://{s}"))
742 .unwrap_or_else(|| identifier.to_owned())
743 };
744 if search_url_re().is_match(&url)
745 && let Ok(uri) = reqwest::Url::parse(&url)
746 && let Some((_, query)) = uri.query_pairs().find(|(k, _)| k == "q")
747 {
748 return self.search_tracks(&query).await;
749 }
750 if liked_url_re().is_match(&url) {
751 return self.load_liked_tracks(&url).await;
752 }
753 if playlist_url_re().is_match(&url) {
754 return self.load_playlist(&url).await;
755 }
756 if user_url_re().is_match(&url) {
757 return self.load_user(&url).await;
758 }
759 if track_url_re().is_match(&url) {
760 return self.load_single_track(&url).await;
761 }
762 LoadResult::Empty {}
763 }
764 async fn get_track(
765 &self,
766 identifier: &str,
767 routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
768 ) -> Option<BoxedTrack> {
769 let url = if mobile_url_re().is_match(identifier) {
770 self.resolve_mobile_url(identifier).await?
771 } else if short_url_re().is_match(identifier) {
772 self.resolve_short_url(identifier).await?
773 } else {
774 identifier
775 .strip_prefix("https://m.")
776 .map(|s| format!("https://{s}"))
777 .unwrap_or_else(|| identifier.to_owned())
778 };
779 let client_id = self.token_tracker.get_client_id().await?;
780 let local_addr = routeplanner.and_then(|rp| rp.get_address());
781 self.get_track_from_url(&url, &client_id, local_addr).await
782 }
783 fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
784 self.config.proxy.clone()
785 }
786 }
787}
788pub mod reader {
789 use crate::{
790 audio::source::{HttpSource, create_client},
791 common::types::AnyResult,
792 config::HttpProxyConfig,
793 sources::youtube::hls::{
794 fetcher::fetch_segment_into, resolver::resolve_playlist,
795 ts_demux::extract_adts_from_ts, types::Resource,
796 },
797 };
798 use parking_lot::{Condvar, Mutex};
799 use std::{
800 io::{Read, Seek, SeekFrom},
801 sync::Arc,
802 thread,
803 };
804 use symphonia::core::io::MediaSource;
805 use tracing::debug;
806 const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
807 const PREFETCH_SEGMENTS: usize = 3;
808 const LOW_WATER_BYTES: usize = 128 * 1024;
809 pub struct SoundCloudReader {
810 inner: HttpSource,
811 }
812 impl SoundCloudReader {
813 pub async fn new(
814 url: &str,
815 local_addr: Option<std::net::IpAddr>,
816 proxy: Option<HttpProxyConfig>,
817 ) -> AnyResult<Self> {
818 let client = create_client(USER_AGENT.to_owned(), local_addr, proxy, None)?;
819 let inner = HttpSource::new(client, url).await?;
820 Ok(Self { inner })
821 }
822 }
823 impl Read for SoundCloudReader {
824 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
825 self.inner.read(buf)
826 }
827 }
828 impl Seek for SoundCloudReader {
829 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
830 self.inner.seek(pos)
831 }
832 }
833 impl MediaSource for SoundCloudReader {
834 fn is_seekable(&self) -> bool {
835 self.inner.is_seekable()
836 }
837 fn byte_len(&self) -> Option<u64> {
838 self.inner.byte_len()
839 }
840 }
841 #[derive(Debug, Clone)]
842 enum PrefetchCommand {
843 Continue,
844 Seek(usize),
845 Stop,
846 }
847 struct SharedState {
848 next_buf: Vec<u8>,
849 need_data: bool,
850 pending: Vec<Resource>,
851 current_segment_index: usize,
852 command: PrefetchCommand,
853 seek_done: bool,
854 eos: bool,
855 }
856 pub struct SoundCloudHlsReader {
857 buf: Vec<u8>,
858 pos: usize,
859 total_bytes_read: u64,
860 shared: Arc<(Mutex<SharedState>, Condvar)>,
861 bg_thread: Option<thread::JoinHandle<()>>,
862 all_segments: Vec<Resource>,
863 segment_durations: Vec<f64>,
864 byte_rate: u64,
865 }
866 impl SoundCloudHlsReader {
867 pub async fn new(
868 manifest_url: &str,
869 bitrate_bps: u64,
870 local_addr: Option<std::net::IpAddr>,
871 proxy: Option<HttpProxyConfig>,
872 ) -> AnyResult<Self> {
873 let client = create_client(USER_AGENT.to_owned(), local_addr, proxy, None)?;
874 let (segment_urls, _map_url) = resolve_playlist(&client, manifest_url).await?;
875 if segment_urls.is_empty() {
876 return Err("SoundCloud HLS: playlist contained no segments".into());
877 }
878 let segment_durations: Vec<f64> = segment_urls
879 .iter()
880 .map(|r| r.duration.unwrap_or(0.0))
881 .collect();
882 let all_segments = segment_urls.clone();
883 let byte_rate = bitrate_bps / 8;
884 let mut initial_buf = Vec::with_capacity(512 * 1024);
885 let first_batch_count = PREFETCH_SEGMENTS.min(segment_urls.len());
886 let mut pending = segment_urls;
887 let first_batch: Vec<Resource> = pending.drain(..first_batch_count).collect();
888 for res in &first_batch {
889 let _ = fetch_and_demux_into(&client, res, &mut initial_buf).await;
890 }
891 debug!(
892 "SoundCloud HLS init: {} segments, bitrate={} bps ({} B/s)",
893 all_segments.len(),
894 bitrate_bps,
895 byte_rate
896 );
897 let shared_state = SharedState {
898 next_buf: Vec::with_capacity(512 * 1024),
899 need_data: true,
900 pending,
901 current_segment_index: first_batch.len(),
902 command: PrefetchCommand::Continue,
903 seek_done: false,
904 eos: false,
905 };
906 let shared = Arc::new((Mutex::new(shared_state), Condvar::new()));
907 let shared_bg = Arc::clone(&shared);
908 let bg_client = client;
909 let bg_all = all_segments.clone();
910 let bg_thread = thread::Builder::new()
911 .name("sc-hls-prefetch".into())
912 .spawn(move || {
913 let rt = tokio::runtime::Builder::new_current_thread()
914 .enable_all()
915 .build()
916 .unwrap();
917 rt.block_on(prefetch_loop(shared_bg, bg_client, bg_all));
918 })?;
919 Ok(Self {
920 buf: initial_buf,
921 pos: 0,
922 total_bytes_read: 0,
923 shared,
924 bg_thread: Some(bg_thread),
925 all_segments,
926 segment_durations,
927 byte_rate,
928 })
929 }
930 fn seek_to_byte(&mut self, target_byte: u64) -> std::io::Result<u64> {
931 let current_byte = self.total_bytes_read;
932 let diff = (target_byte as i64) - (current_byte as i64);
933 let buf_len = self.buf.len() as i64;
934 let current_pos_in_buf = self.pos as i64;
935 let new_pos_in_buf = current_pos_in_buf + diff;
936 if new_pos_in_buf >= 0 && new_pos_in_buf <= buf_len {
937 debug!(
938 "SoundCloud HLS gapless seek (internal buffer): {} -> {} (pos {} -> {})",
939 current_byte, target_byte, self.pos, new_pos_in_buf
940 );
941 self.pos = new_pos_in_buf as usize;
942 self.total_bytes_read = target_byte;
943 return Ok(target_byte);
944 }
945 let target_secs = target_byte as f64 / self.byte_rate as f64;
946 let mut segment_start_secs = 0.0;
947 let mut target_index = 0;
948 for (i, &dur) in self.segment_durations.iter().enumerate() {
949 if segment_start_secs + dur <= target_secs {
950 segment_start_secs += dur;
951 target_index = i + 1;
952 } else {
953 break;
954 }
955 }
956 if target_index >= self.all_segments.len() {
957 target_index = self.all_segments.len().saturating_sub(1);
958 }
959 let segment_start_byte = (segment_start_secs * self.byte_rate as f64) as u64;
960 let skip_in_segment = target_byte.saturating_sub(segment_start_byte);
961 debug!(
962 "SoundCloud HLS hard seek: target {} -> segment {} (starts at {:.1}s, segment-relative skip={} bytes)",
963 target_byte, target_index, segment_start_secs, skip_in_segment
964 );
965 self.buf.clear();
966 self.pos = 0;
967 self.total_bytes_read = target_byte;
968 {
969 let (lock, cvar) = &*self.shared;
970 let mut state = lock.lock();
971 state.command = PrefetchCommand::Seek(target_index);
972 state.need_data = true;
973 state.seek_done = false;
974 cvar.notify_one();
975 while !state.seek_done {
976 cvar.wait(&mut state);
977 }
978 state.seek_done = false;
979 std::mem::swap(&mut self.buf, &mut state.next_buf);
980 state.next_buf.clear();
981 debug!(
982 "SoundCloud HLS swapped buffers after hard seek. Buffer len: {}",
983 self.buf.len()
984 );
985 self.pos = (skip_in_segment as usize).min(self.buf.len());
986 if self.pos > 0 || skip_in_segment > 0 {
987 debug!(
988 "SoundCloud HLS aligned buffer position to offset {} (skip_in_segment={})",
989 self.pos, skip_in_segment
990 );
991 }
992 state.need_data = true;
993 cvar.notify_one();
994 }
995 Ok(target_byte)
996 }
997 }
998 impl Read for SoundCloudHlsReader {
999 fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
1000 if self.pos < self.buf.len() {
1001 let remaining = self.buf.len() - self.pos;
1002 if remaining <= LOW_WATER_BYTES {
1003 let (lock, cvar) = &*self.shared;
1004 if let Some(mut state) = lock.try_lock()
1005 && !state.need_data
1006 && !state.eos
1007 {
1008 state.need_data = true;
1009 cvar.notify_one();
1010 }
1011 }
1012 let n = out.len().min(remaining);
1013 out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
1014 self.pos += n;
1015 self.total_bytes_read += n as u64;
1016 return Ok(n);
1017 }
1018 let (lock, cvar) = &*self.shared;
1019 let mut state = lock.lock();
1020 if !state.need_data && state.next_buf.is_empty() && !state.eos {
1021 state.need_data = true;
1022 cvar.notify_one();
1023 }
1024 while state.next_buf.is_empty() && !state.eos {
1025 cvar.wait(&mut state);
1026 }
1027 if state.next_buf.is_empty() && state.eos {
1028 return Ok(0);
1029 }
1030 let next_len = state.next_buf.len();
1031 self.buf.clear();
1032 self.pos = 0;
1033 std::mem::swap(&mut self.buf, &mut state.next_buf);
1034 state.next_buf.clear();
1035 debug!(
1036 "SoundCloud HLS buffer swap: replaced active with next_buf ({} bytes)",
1037 next_len
1038 );
1039 state.need_data = true;
1040 cvar.notify_one();
1041 drop(state);
1042 self.read(out)
1043 }
1044 }
1045 impl Seek for SoundCloudHlsReader {
1046 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
1047 match pos {
1048 SeekFrom::Start(n) => self.seek_to_byte(n),
1049 SeekFrom::Current(delta) => {
1050 let target = self.total_bytes_read.saturating_add_signed(delta);
1051 self.seek_to_byte(target)
1052 }
1053 SeekFrom::End(_) => {
1054 let total = self.byte_len().unwrap_or(0);
1055 self.seek_to_byte(total)
1056 }
1057 }
1058 }
1059 }
1060 impl MediaSource for SoundCloudHlsReader {
1061 fn is_seekable(&self) -> bool {
1062 true
1063 }
1064 fn byte_len(&self) -> Option<u64> {
1065 let total_dur: f64 = self.segment_durations.iter().sum();
1066 Some((total_dur * self.byte_rate as f64) as u64)
1067 }
1068 }
1069 impl Drop for SoundCloudHlsReader {
1070 fn drop(&mut self) {
1071 let (lock, cvar) = &*self.shared;
1072 let mut state = lock.lock();
1073 state.command = PrefetchCommand::Stop;
1074 state.need_data = true;
1075 cvar.notify_one();
1076 drop(state);
1077 if let Some(handle) = self.bg_thread.take() {
1078 let _ = handle.join();
1079 }
1080 }
1081 }
1082 async fn prefetch_loop(
1083 shared: Arc<(Mutex<SharedState>, Condvar)>,
1084 client: reqwest::Client,
1085 all_segments: Vec<Resource>,
1086 ) {
1087 let (lock, cvar) = &*shared;
1088 loop {
1089 enum Action {
1090 Stop,
1091 Seek {
1092 target_index: usize,
1093 batch: Vec<Resource>,
1094 },
1095 Fetch {
1096 batch: Vec<Resource>,
1097 current_idx: usize,
1098 },
1099 Eos,
1100 }
1101 let action = {
1102 let mut state = lock.lock();
1103 while !state.need_data {
1104 cvar.wait(&mut state);
1105 }
1106 match std::mem::replace(&mut state.command, PrefetchCommand::Continue) {
1107 PrefetchCommand::Stop => Action::Stop,
1108 PrefetchCommand::Seek(target_index) => {
1109 state.next_buf.clear();
1110 state.eos = false;
1111 state.current_segment_index = target_index;
1112 state.pending = all_segments[target_index..].to_vec();
1113 let count = if !state.pending.is_empty() { 1 } else { 0 };
1114 let batch = state.pending.drain(..count).collect();
1115 Action::Seek {
1116 target_index,
1117 batch,
1118 }
1119 }
1120 PrefetchCommand::Continue => {
1121 if state.pending.is_empty() {
1122 state.eos = true;
1123 state.need_data = false;
1124 cvar.notify_one();
1125 Action::Eos
1126 } else {
1127 let count = PREFETCH_SEGMENTS.min(state.pending.len());
1128 let batch = state.pending.drain(..count).collect();
1129 let current_idx = state.current_segment_index;
1130 Action::Fetch { batch, current_idx }
1131 }
1132 }
1133 }
1134 };
1135 match action {
1136 Action::Stop => return,
1137 Action::Eos => continue,
1138 Action::Seek {
1139 target_index,
1140 batch,
1141 } => {
1142 let mut tmp_buf = Vec::new();
1143 for res in &batch {
1144 debug!(
1145 "SoundCloud HLS prefetcher: fetching seek target segment {}",
1146 target_index
1147 );
1148 let _ = fetch_and_demux_into(&client, res, &mut tmp_buf).await;
1149 }
1150 debug!(
1151 "SoundCloud HLS prefetcher: seek target fetched ({} bytes)",
1152 tmp_buf.len()
1153 );
1154 let mut state = lock.lock();
1155 state.next_buf.extend_from_slice(&tmp_buf);
1156 state.current_segment_index += batch.len();
1157 state.need_data = false;
1158 state.seek_done = true;
1159 state.eos = state.pending.is_empty();
1160 cvar.notify_one();
1161 }
1162 Action::Fetch { batch, current_idx } => {
1163 let mut tmp_buf = Vec::with_capacity(256 * 1024);
1164 for res in &batch {
1165 {
1166 let s = lock.lock();
1167 if !matches!(s.command, PrefetchCommand::Continue) {
1168 break;
1169 }
1170 }
1171 let _ = fetch_and_demux_into(&client, res, &mut tmp_buf).await;
1172 }
1173 let mut state = lock.lock();
1174 if !matches!(state.command, PrefetchCommand::Continue) {
1175 continue;
1176 }
1177 state.next_buf.extend_from_slice(&tmp_buf);
1178 state.current_segment_index = current_idx + batch.len();
1179 state.eos = state.pending.is_empty();
1180 state.need_data = false;
1181 cvar.notify_one();
1182 }
1183 }
1184 }
1185 }
1186 async fn fetch_and_demux_into(
1187 client: &reqwest::Client,
1188 res: &Resource,
1189 out: &mut Vec<u8>,
1190 ) -> AnyResult<()> {
1191 let mut raw = Vec::new();
1192 fetch_segment_into(client, res, &mut raw).await?;
1193 if raw.first() == Some(&0x47) {
1194 let adts = extract_adts_from_ts(&raw);
1195 if !adts.is_empty() {
1196 out.extend_from_slice(&adts);
1197 } else {
1198 out.extend_from_slice(&raw);
1199 }
1200 } else {
1201 out.extend_from_slice(&raw);
1202 }
1203 Ok(())
1204 }
1205}
1206pub mod token {
1207 use crate::common::types::SharedRw;
1208 use regex::Regex;
1209 use std::{
1210 sync::{Arc, OnceLock},
1211 time::{Duration, Instant},
1212 };
1213 use tokio::sync::RwLock;
1214 use tracing::{debug, error, info, trace, warn};
1215 const SOUNDCLOUD_URL: &str = "https://soundcloud.com";
1216 const CLIENT_ID_REFRESH_INTERVAL: Duration = Duration::from_secs(3600);
1217 fn asset_re() -> &'static Regex {
1218 static RE: OnceLock<Regex> = OnceLock::new();
1219 RE.get_or_init(|| {
1220 Regex::new(r"https://a-v2\.sndcdn\.com/assets/[a-zA-Z0-9_-]+\.js").unwrap()
1221 })
1222 }
1223 fn client_id_re() -> &'static Regex {
1224 static RE: OnceLock<Regex> = OnceLock::new();
1225 RE.get_or_init(|| Regex::new(r#"[^_]client_id[:"=]+\s*"?([a-zA-Z0-9_-]{20,})"?"#).unwrap())
1226 }
1227 pub struct SoundCloudTokenTracker {
1228 client: Arc<reqwest::Client>,
1229 client_id: SharedRw<CachedClientId>,
1230 }
1231 struct CachedClientId {
1232 value: Option<String>,
1233 last_updated: Option<Instant>,
1234 }
1235 impl CachedClientId {
1236 fn is_stale(&self) -> bool {
1237 match self.last_updated {
1238 None => true,
1239 Some(t) => t.elapsed() > CLIENT_ID_REFRESH_INTERVAL,
1240 }
1241 }
1242 }
1243 impl SoundCloudTokenTracker {
1244 pub fn new(client: Arc<reqwest::Client>, config: &crate::config::SoundCloudConfig) -> Self {
1245 let cached = CachedClientId {
1246 value: config.client_id.clone(),
1247 last_updated: if config.client_id.is_some() {
1248 Some(Instant::now())
1249 } else {
1250 None
1251 },
1252 };
1253 Self {
1254 client,
1255 client_id: Arc::new(RwLock::new(cached)),
1256 }
1257 }
1258 pub async fn get_client_id(&self) -> Option<String> {
1259 {
1260 let guard = self.client_id.read().await;
1261 if !guard.is_stale()
1262 && let Some(id) = &guard.value
1263 {
1264 return Some(id.clone());
1265 }
1266 }
1267 self.refresh_client_id().await
1268 }
1269 pub async fn refresh_client_id(&self) -> Option<String> {
1270 debug!("Refreshing SoundCloud client_id...");
1271 trace!("SoundCloud: Fetching client_id from soundcloud.com...");
1272 let html = match self.client.get(SOUNDCLOUD_URL).send().await {
1273 Ok(r) => match r.text().await {
1274 Ok(t) => t,
1275 Err(e) => {
1276 error!("SoundCloud: Failed to read main page: {}", e);
1277 return None;
1278 }
1279 },
1280 Err(e) => {
1281 error!("SoundCloud: Failed to fetch main page: {}", e);
1282 return None;
1283 }
1284 };
1285 if let Some(caps) = client_id_re().captures(&html)
1286 && let Some(m) = caps.get(1)
1287 {
1288 let id = m.as_str().to_owned();
1289 trace!("SoundCloud: Found client_id in main page: {id}");
1290 self.store_client_id(id.clone()).await;
1291 info!("Successfully refreshed SoundCloud client_id");
1292 return Some(id);
1293 }
1294 let asset_urls: Vec<String> = asset_re()
1295 .find_iter(&html)
1296 .map(|m| m.as_str().to_owned())
1297 .collect();
1298 if asset_urls.is_empty() {
1299 warn!("SoundCloud: No asset JS URLs found in main page");
1300 return None;
1301 }
1302 trace!(
1303 "SoundCloud: Found {} asset URLs, probing for client_id",
1304 asset_urls.len()
1305 );
1306 for url in asset_urls.iter().rev().take(9) {
1307 let js = match self.client.get(url).send().await {
1308 Ok(r) => match r.text().await {
1309 Ok(t) => t,
1310 Err(_) => continue,
1311 },
1312 Err(_) => continue,
1313 };
1314 if let Some(caps) = client_id_re().captures(&js)
1315 && let Some(m) = caps.get(1)
1316 {
1317 let id = m.as_str().to_owned();
1318 trace!("SoundCloud: Found client_id in asset {url}: {id}");
1319 self.store_client_id(id.clone()).await;
1320 info!("Successfully refreshed SoundCloud client_id");
1321 return Some(id);
1322 }
1323 }
1324 warn!("SoundCloud: client_id not found in any asset scripts");
1325 None
1326 }
1327 pub async fn invalidate(&self) {
1328 let mut guard = self.client_id.write().await;
1329 guard.last_updated = None;
1330 }
1331 async fn store_client_id(&self, id: String) {
1332 let mut guard = self.client_id.write().await;
1333 guard.value = Some(id);
1334 guard.last_updated = Some(Instant::now());
1335 }
1336 pub fn init(self: Arc<Self>) {
1337 let this = self.clone();
1338 tokio::spawn(async move {
1339 this.get_client_id().await;
1340 });
1341 }
1342 }
1343}
1344pub mod track {
1345 use crate::{
1346 common::types::AudioFormat,
1347 config::HttpProxyConfig,
1348 sources::playable_track::{PlayableTrack, ResolvedTrack},
1349 };
1350 use async_trait::async_trait;
1351 use std::net::IpAddr;
1352 #[derive(Debug, Clone)]
1353 pub enum SoundCloudStreamKind {
1354 ProgressiveMp3,
1355 ProgressiveAac,
1356 HlsOpus,
1357 HlsMp3,
1358 HlsAac,
1359 }
1360 pub struct SoundCloudTrack {
1361 pub stream_url: String,
1362 pub kind: SoundCloudStreamKind,
1363 pub bitrate_bps: u64,
1364 pub local_addr: Option<IpAddr>,
1365 pub proxy: Option<HttpProxyConfig>,
1366 }
1367 #[async_trait]
1368 impl PlayableTrack for SoundCloudTrack {
1369 async fn resolve(&self) -> Result<ResolvedTrack, String> {
1370 let (reader, hint) = match self.kind {
1371 SoundCloudStreamKind::ProgressiveMp3 => (
1372 super::reader::SoundCloudReader::new(
1373 &self.stream_url,
1374 self.local_addr,
1375 self.proxy.clone(),
1376 )
1377 .await
1378 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
1379 .map_err(|e| format!("Failed to open stream: {e}"))?,
1380 AudioFormat::Mp3,
1381 ),
1382 SoundCloudStreamKind::ProgressiveAac => (
1383 super::reader::SoundCloudReader::new(
1384 &self.stream_url,
1385 self.local_addr,
1386 self.proxy.clone(),
1387 )
1388 .await
1389 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
1390 .map_err(|e| format!("Failed to open stream: {e}"))?,
1391 AudioFormat::Mp4,
1392 ),
1393 SoundCloudStreamKind::HlsOpus => (
1394 super::reader::SoundCloudHlsReader::new(
1395 &self.stream_url,
1396 self.bitrate_bps,
1397 self.local_addr,
1398 self.proxy.clone(),
1399 )
1400 .await
1401 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
1402 .map_err(|e| format!("Failed to init HLS reader: {e}"))?,
1403 AudioFormat::Opus,
1404 ),
1405 SoundCloudStreamKind::HlsMp3 => (
1406 super::reader::SoundCloudHlsReader::new(
1407 &self.stream_url,
1408 self.bitrate_bps,
1409 self.local_addr,
1410 self.proxy.clone(),
1411 )
1412 .await
1413 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
1414 .map_err(|e| format!("Failed to init HLS reader: {e}"))?,
1415 AudioFormat::Mp3,
1416 ),
1417 SoundCloudStreamKind::HlsAac => (
1418 super::reader::SoundCloudHlsReader::new(
1419 &self.stream_url,
1420 self.bitrate_bps,
1421 self.local_addr,
1422 self.proxy.clone(),
1423 )
1424 .await
1425 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
1426 .map_err(|e| format!("Failed to init HLS reader: {e}"))?,
1427 AudioFormat::Aac,
1428 ),
1429 };
1430 Ok(ResolvedTrack::new(reader, Some(hint)))
1431 }
1432 }
1433}
1434pub use manager::SoundCloudSource;