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+&ts=\d+&t=[a-zA-Z0-9]+&token=\d+_[a-zA-Z0-9]+").unwrap())
58 .find(body)
59 .map(|m| m.as_str().replace("&", "&"))
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(""", "\"");
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}