use once_cell::sync::Lazy; use regex::Regex; use reqwest::{IntoUrl, Url}; use serde::{Deserialize, Serialize}; use crate::error::Result; use crate::models::{Album, Band, Feed, SearchFilter, SearchResult, TrAlbumType}; use crate::{util, Bandcamp, Error, API_URL}; static BAND_ID_REGEX: Lazy = Lazy::new(|| { Regex::new( r#"]*data-band="\{[^"]*"id":(\d+)[,}]"#, ) .unwrap() }); #[derive(Serialize)] struct BandRequest { band_id: u64, } #[derive(Serialize)] struct AlbumRequest { band_id: u64, tralbum_id: u64, tralbum_type: TrAlbumType, } #[derive(Serialize)] struct SearchRequest<'a> { search_text: &'a str, search_filter: SearchFilter, full_page: bool, } #[derive(Deserialize)] struct SearchResultWrapper { auto: SearchResult, } #[derive(Deserialize)] #[serde(untagged)] enum ResponseWrapper { Ok(T), Error { error_message: String }, } impl ResponseWrapper { fn convert(self) -> Result { match self { ResponseWrapper::Ok(data) => Ok(data), ResponseWrapper::Error { error_message } => { if error_message.starts_with("No such ") || error_message.ends_with(" not found") { Err(Error::NotFound(error_message.into())) } else { Err(Error::Bandcamp(error_message.into())) } } } } } impl Bandcamp { /// Get a band from the Bandcamp API pub async fn band(&self, band_id: u64) -> Result { let req = BandRequest { band_id }; let resp = self .client .post(format!("{}mobile/24/band_details", API_URL)) .json(&req) .send() .await? .error_for_status()?; resp.json::>().await?.convert() } /// Get a album (or a track) from the Bandcamp API /// /// # Parameters /// - `band_id` The Band ID of the album/track /// - `tralbum_id` Album/Track ID /// - `tralbum_type` Track/Album pub async fn album( &self, band_id: u64, tralbum_id: u64, tralbum_type: TrAlbumType, ) -> Result { let req = AlbumRequest { band_id, tralbum_id, tralbum_type, }; let resp = self .client .post(format!("{}mobile/25/tralbum_details", API_URL)) .json(&req) .send() .await? .error_for_status()?; resp.json::>().await?.convert() } /// Get an album (or a track) from the Bandcamp API using a unified ID string /// /// # Examples /// - Album: `3760769193a1493086082` /// - Track: `2464198920t716010980` pub async fn album_uid>(&self, uid: S) -> Result { let (band_id, tralbum_type, tralbum_id) = util::parse_album_uid(uid)?; self.album(band_id, tralbum_id, tralbum_type).await } /// Search Bandcamp for bands, albums or tracks pub async fn search>( &self, query: S, filter: SearchFilter, ) -> Result { let req = SearchRequest { search_text: query.as_ref(), search_filter: filter, full_page: false, }; let resp = self .client .post(format!( "{}bcsearch_public_api/1/autocomplete_elastic", API_URL )) .json(&req) .send() .await? .error_for_status()?; Ok(resp .json::>() .await? .convert()? .auto) } /// Get the current Bandcamp feed containing featured works. pub async fn feed(&self) -> Result { let resp = self .client .get(format!( "{}mobile/25/feed_older_logged_out?story_groups=featured", API_URL )) .send() .await? .error_for_status()?; resp.json::>().await?.convert() } /// Get a continuation page for the Bandcamp feed pub async fn feed_cont>(&self, token: S) -> Result { let resp = self .client .get( Url::parse_with_params( &format!("{}mobile/25/feed_older_logged_out", API_URL), &[("story_groups", &format!("featured:{}", token.as_ref()))], ) .unwrap(), ) .send() .await? .error_for_status()?; resp.json::>().await?.convert() } /// Get the numeric band id from their bandcamp URL pub async fn band_id_from_url(&self, url: U) -> Result { let mut url = url.into_url().map_err(|_| Error::InvalidUrl)?; let host = url.host_str().ok_or(Error::InvalidUrl)?; if !host.ends_with(".bandcamp.com") { return Err(Error::InvalidUrl); } url.set_path(""); url.set_query(None); let resp = self.client.get(url).send().await?.error_for_status()?; let html = resp.text().await?; BAND_ID_REGEX .captures(&html) .ok_or_else(|| Error::WebsiteParsing("could not find band id".into()))? .get(1) .unwrap() .as_str() .parse() .map_err(|_| Error::WebsiteParsing("could not parse band id".into())) } }