Skip to main content

lavende_core/sources/
bandcamp.rs

1pub mod track {
2    use crate::sources::{
3        http::HttpTrack,
4        playable_track::{PlayableTrack, ResolvedTrack},
5    };
6    use async_trait::async_trait;
7    use regex::Regex;
8    use std::{
9        net::IpAddr,
10        sync::{Arc, OnceLock},
11    };
12    use tracing::debug;
13    pub struct BandcampTrack {
14        pub client: Arc<reqwest::Client>,
15        pub uri: String,
16        pub stream_url: Option<String>,
17        pub local_addr: Option<IpAddr>,
18    }
19    pub static STREAM_PATTERN: OnceLock<Regex> = OnceLock::new();
20    #[async_trait]
21    impl PlayableTrack for BandcampTrack {
22        async fn resolve(&self) -> Result<ResolvedTrack, String> {
23            let url = if let Some(url) = self.stream_url.clone() {
24                url
25            } else {
26                fetch_stream_url(&self.client, &self.uri)
27                    .await
28                    .ok_or_else(|| {
29                        format!("Failed to fetch Bandcamp stream URL for {}", self.uri)
30                    })?
31            };
32            debug!("Bandcamp stream URL: {url}");
33            HttpTrack {
34                url,
35                local_addr: self.local_addr,
36                proxy: None,
37            }
38            .resolve()
39            .await
40        }
41    }
42    pub async fn fetch_stream_url(client: &Arc<reqwest::Client>, uri: &str) -> Option<String> {
43        let resp = client
44        .get(uri)
45        .header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
46        .send()
47        .await
48        .ok()?;
49        if !resp.status().is_success() {
50            return None;
51        }
52        let body = resp.text().await.ok()?;
53        extract_stream_url(&body)
54    }
55    pub fn extract_stream_url(body: &str) -> Option<String> {
56        STREAM_PATTERN
57        .get_or_init(|| Regex::new(r"https?://t4\.bcbits\.com/stream/[a-zA-Z0-9]+/mp3-128/\d+\?p=\d+&amp;ts=\d+&amp;t=[a-zA-Z0-9]+&amp;token=\d+_[a-zA-Z0-9]+").unwrap())
58        .find(body)
59        .map(|m| m.as_str().replace("&amp;", "&"))
60    }
61}
62use crate::{
63    protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
64    sources::{SourcePlugin, playable_track::BoxedTrack},
65};
66use async_trait::async_trait;
67use regex::Regex;
68use serde_json::{Value, json};
69use std::sync::{Arc, OnceLock};
70use tracing::error;
71static URL_PATTERN: OnceLock<Regex> = OnceLock::new();
72static IDENTIFIER_PATTERN: OnceLock<Regex> = OnceLock::new();
73static RESULT_BLOCKS_PATTERN: OnceLock<Regex> = OnceLock::new();
74static ART_URL_PATTERN: OnceLock<Regex> = OnceLock::new();
75static TITLE_PATTERN: OnceLock<Regex> = OnceLock::new();
76static SUBHEAD_PATTERN: OnceLock<Regex> = OnceLock::new();
77static ARTWORK_PATTERN: OnceLock<Regex> = OnceLock::new();
78static TRALBUM_PATTERN: OnceLock<Regex> = OnceLock::new();
79pub struct BandcampSource {
80    client: Arc<reqwest::Client>,
81    search_limit: usize,
82}
83impl BandcampSource {
84    pub fn new(
85        config: Option<crate::config::BandcampConfig>,
86        client: Arc<reqwest::Client>,
87    ) -> Result<Self, String> {
88        Ok(Self {
89            client,
90            search_limit: config.map(|c| c.search_limit).unwrap_or(10),
91        })
92    }
93    async fn search(&self, query: &str) -> LoadResult {
94        let url = format!(
95            "https://bandcamp.com/search?q={}&item_type=t&from=results",
96            urlencoding::encode(query)
97        );
98        let resp = match self.base_request(self.client.get(url)).send().await {
99            Ok(r) => r,
100            Err(e) => {
101                error!("Bandcamp search request failed: {e}");
102                return LoadResult::Empty {};
103            }
104        };
105        if !resp.status().is_success() {
106            return LoadResult::Empty {};
107        }
108        let body = match resp.text().await {
109            Ok(t) => t,
110            Err(_) => return LoadResult::Empty {},
111        };
112        let result_blocks_re = RESULT_BLOCKS_PATTERN.get_or_init(|| {
113            Regex::new(r"(?s)<li class=.searchresult data-search.[\s\S]*?</li>").unwrap()
114        });
115        let url_re = ART_URL_PATTERN
116            .get_or_init(|| Regex::new(r#"<a class="artcont" href="([^"]+)">"#).unwrap());
117        let title_re = TITLE_PATTERN.get_or_init(|| {
118            Regex::new(r#"(?s)<div class="heading">\s*<a[^>]*>\s*(.+?)\s*</a>"#).unwrap()
119        });
120        let subhead_re = SUBHEAD_PATTERN
121            .get_or_init(|| Regex::new(r#"(?s)<div class="subhead">([\s\S]*?)</div>"#).unwrap());
122        let artwork_re = ARTWORK_PATTERN
123            .get_or_init(|| Regex::new(r#"(?s)<div class="art">\s*<img src="([^"]+)""#).unwrap());
124        let mut tracks = Vec::new();
125        for block in result_blocks_re.find_iter(&body) {
126            let block_str = block.as_str();
127            if let (Some(url_m), Some(title_m), Some(subhead_m)) = (
128                url_re.captures(block_str),
129                title_re.captures(block_str),
130                subhead_re.captures(block_str),
131            ) {
132                let uri = url_m[1].split('?').next().unwrap_or(&url_m[1]).to_owned();
133                let title = title_m[1].trim().to_owned();
134                let subhead = subhead_m[1].trim();
135                let artist = subhead
136                    .split(" de ")
137                    .last()
138                    .unwrap_or(subhead)
139                    .trim()
140                    .to_owned();
141                let artwork_url = artwork_re.captures(block_str).map(|m| m[1].to_owned());
142                tracks.push(Track::new(TrackInfo {
143                    identifier: self.get_identifier_from_url(&uri),
144                    is_seekable: true,
145                    author: artist,
146                    length: 0,
147                    is_stream: false,
148                    position: 0,
149                    title,
150                    uri: Some(uri),
151                    artwork_url,
152                    isrc: None,
153                    source_name: "bandcamp".to_owned(),
154                }));
155                if tracks.len() >= self.search_limit {
156                    break;
157                }
158            }
159        }
160        if tracks.is_empty() {
161            LoadResult::Empty {}
162        } else {
163            LoadResult::Search(tracks)
164        }
165    }
166    async fn resolve(&self, url: &str) -> LoadResult {
167        let (tralbum_data, _) = match self.fetch_track_data(url).await {
168            Some(d) => d,
169            None => return LoadResult::Empty {},
170        };
171        let artist = tralbum_data["artist"]
172            .as_str()
173            .unwrap_or("Unknown Artist")
174            .to_owned();
175        let artwork_url = tralbum_data["art_id"]
176            .as_u64()
177            .map(|id| format!("https://f4.bcbits.com/img/a{id}_10.jpg"));
178        if let Some(trackinfo) = tralbum_data["trackinfo"].as_array() {
179            if trackinfo.len() > 1 {
180                let mut tracks = Vec::new();
181                for item in trackinfo {
182                    let title = match item["title"].as_str() {
183                        Some(t) => t.to_owned(),
184                        None => continue,
185                    };
186                    if let Some(suffix) = item["title_link"].as_str() {
187                        let track_url = if suffix.starts_with("http") {
188                            suffix.to_owned()
189                        } else {
190                            let base = url.split(".bandcamp.com").next().unwrap_or("");
191                            format!("{base}.bandcamp.com{suffix}")
192                        };
193                        let duration = (item["duration"].as_f64().unwrap_or(0.0) * 1000.0) as u64;
194                        let identifier = item["track_id"]
195                            .as_u64()
196                            .or(item["id"].as_u64())
197                            .map(|id| id.to_string())
198                            .unwrap_or_else(|| self.get_identifier_from_url(&track_url));
199                        tracks.push(Track::new(TrackInfo {
200                            identifier,
201                            is_seekable: true,
202                            author: artist.clone(),
203                            length: duration,
204                            is_stream: false,
205                            position: 0,
206                            title,
207                            uri: Some(track_url),
208                            artwork_url: artwork_url.clone(),
209                            isrc: None,
210                            source_name: "bandcamp".to_owned(),
211                        }));
212                    }
213                }
214                let playlist_name = tralbum_data["current"]["title"]
215                    .as_str()
216                    .unwrap_or("Bandcamp Album")
217                    .to_owned();
218                return LoadResult::Playlist(PlaylistData {
219                    info: PlaylistInfo {
220                        name: playlist_name,
221                        selected_track: -1,
222                    },
223                    plugin_info: json!({}),
224                    tracks,
225                });
226            } else if let Some(track_data) = trackinfo.first() {
227                let title = track_data["title"]
228                    .as_str()
229                    .unwrap_or("Unknown Title")
230                    .to_owned();
231                let duration = (track_data["duration"].as_f64().unwrap_or(0.0) * 1000.0) as u64;
232                let identifier = track_data["track_id"]
233                    .as_u64()
234                    .or(track_data["id"].as_u64())
235                    .map(|id| id.to_string())
236                    .unwrap_or_else(|| self.get_identifier_from_url(url));
237                return LoadResult::Track(Track::new(TrackInfo {
238                    identifier,
239                    is_seekable: true,
240                    author: artist,
241                    length: duration,
242                    is_stream: false,
243                    position: 0,
244                    title,
245                    uri: Some(url.to_owned()),
246                    artwork_url,
247                    isrc: None,
248                    source_name: "bandcamp".to_owned(),
249                }));
250            }
251        }
252        LoadResult::Empty {}
253    }
254    async fn fetch_track_data(&self, url: &str) -> Option<(Value, Option<String>)> {
255        let resp = self.base_request(self.client.get(url)).send().await.ok()?;
256        if !resp.status().is_success() {
257            return None;
258        }
259        let body = resp.text().await.ok()?;
260        let tralbum_re =
261            TRALBUM_PATTERN.get_or_init(|| Regex::new(r#"data-tralbum=["'](.+?)["']"#).unwrap());
262        let tralbum_data = if let Some(match_cap) = tralbum_re.captures(&body) {
263            let decoded = match_cap[1].replace("&quot;", "\"");
264            serde_json::from_str(&decoded).ok()?
265        } else {
266            return None;
267        };
268        let stream_url = track::extract_stream_url(&body);
269        Some((tralbum_data, stream_url))
270    }
271    fn get_identifier_from_url(&self, url: &str) -> String {
272        let url_re = URL_PATTERN.get_or_init(|| Regex::new(r"(?i)^https?://(?P<subdomain>[^/]+)\.bandcamp\.com/(?P<type>track|album)/(?P<slug>[^/?]+)").unwrap());
273        if let Some(caps) = url_re.captures(url) {
274            return format!("{}:{}", &caps["subdomain"], &caps["slug"]);
275        }
276        url.to_owned()
277    }
278    pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
279        builder.header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
280    }
281}
282#[async_trait]
283impl SourcePlugin for BandcampSource {
284    fn name(&self) -> &str {
285        "bandcamp"
286    }
287    fn can_handle(&self, identifier: &str) -> bool {
288        let url_re = URL_PATTERN.get_or_init(|| Regex::new(r"(?i)^https?://(?P<subdomain>[^/]+)\.bandcamp\.com/(?P<type>track|album)/(?P<slug>[^/?]+)").unwrap());
289        self.search_prefixes()
290            .iter()
291            .any(|p| identifier.starts_with(p))
292            || url_re.is_match(identifier)
293    }
294    fn search_prefixes(&self) -> Vec<&str> {
295        vec!["bcsearch:"]
296    }
297    async fn load(
298        &self,
299        identifier: &str,
300        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
301    ) -> LoadResult {
302        if let Some(prefix) = self
303            .search_prefixes()
304            .into_iter()
305            .find(|p| identifier.starts_with(p))
306        {
307            return self.search(&identifier[prefix.len()..]).await;
308        }
309        let url_re = URL_PATTERN.get_or_init(|| Regex::new(r"(?i)^https?://(?P<subdomain>[^/]+)\.bandcamp\.com/(?P<type>track|album)/(?P<slug>[^/?]+)").unwrap());
310        if url_re.is_match(identifier) {
311            return self.resolve(identifier).await;
312        }
313        LoadResult::Empty {}
314    }
315    async fn get_track(
316        &self,
317        identifier: &str,
318        routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
319    ) -> Option<BoxedTrack> {
320        let id_re = IDENTIFIER_PATTERN.get_or_init(|| {
321            Regex::new(r"^(?P<subdomain>[a-zA-Z0-9\-]+):(?P<slug>[a-zA-Z0-9\-]+)$").unwrap()
322        });
323        let url = if identifier.starts_with("http") {
324            identifier.to_owned()
325        } else if let Some(caps) = id_re.captures(identifier) {
326            format!(
327                "https://{}.bandcamp.com/track/{}",
328                &caps["subdomain"], &caps["slug"]
329            )
330        } else {
331            return None;
332        };
333        let (_, stream_url_opt) = self.fetch_track_data(&url).await?;
334        let stream_url = stream_url_opt?;
335        Some(Arc::new(track::BandcampTrack {
336            client: self.client.clone(),
337            uri: url,
338            stream_url: Some(stream_url),
339            local_addr: routeplanner.and_then(|rp| rp.get_address()),
340        }))
341    }
342}