Skip to main content

lavende_core/sources/
soundcloud.rs

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;