use std::sync::OnceLock;
use std::time::Duration;
use serde::Deserialize;
use super::blob;
use super::error::{BackendError, Result};
use super::http;
use super::note;
use super::types::*;
use crate::config::{CredentialSource, Credentials, Secret};
const SEARCH_URL: &str = "https://bandcamp.com/api/bcsearch_public_api/1/autocomplete_elastic";
const COLLECTION_SUMMARY_URL: &str = "https://bandcamp.com/api/fan/2/collection_summary";
const COLLECTION_ITEMS_URL: &str = "https://bandcamp.com/api/fancollection/1/collection_items";
const DOWNLOAD_PREF_FREE: i64 = 1;
const DOWNLOAD_PREF_PAID: i64 = 2;
const DIGITAL_FORMATS: &[AudioFormat] = &[
AudioFormat::Flac,
AudioFormat::Aiff,
AudioFormat::Wav,
AudioFormat::Alac,
AudioFormat::Mp3(Some(320)),
AudioFormat::Mp3V0,
AudioFormat::Aac(Some(256)),
AudioFormat::Ogg,
];
pub struct Bandcamp {
identity: Option<(Secret, CredentialSource)>,
budget: Duration,
owned: OnceLock<OwnedSet>,
}
#[derive(Debug, Default)]
struct OwnedSet {
keys: Option<std::collections::HashSet<String>>,
}
impl OwnedSet {
fn contains(&self, kind: ItemKind, id: i64) -> Ownership {
let Some(keys) = &self.keys else {
return Ownership::Unknown;
};
let prefix = if kind == ItemKind::Track { 't' } else { 'a' };
if keys.contains(&format!("{prefix}{id}")) {
Ownership::Yes {
redownloadable: true,
}
} else {
Ownership::No
}
}
}
fn parse_collection_summary(body: &str) -> Result<std::collections::HashSet<String>> {
let v: serde_json::Value = serde_json::from_str(body).map_err(|e| {
BackendError::parse(BackendId::Bandcamp, "collection_summary", e.to_string())
})?;
if v["error"].as_bool().unwrap_or(false) {
let msg = v["error_message"]
.as_str()
.unwrap_or("unknown error")
.to_string();
return Err(BackendError::AuthExpired {
backend: BackendId::Bandcamp,
detail: msg,
reauth: Some("https://bandcamp.com/login".into()),
});
}
let lookup = &v["collection_summary"]["tralbum_lookup"];
let Some(map) = lookup.as_object() else {
return Ok(std::collections::HashSet::new());
};
Ok(map.keys().cloned().collect())
}
#[derive(Debug, Default)]
pub struct CollectionPage {
items: Vec<(ItemKind, i64, String)>,
redownload_urls: std::collections::HashMap<String, String>,
pub more_available: bool,
pub last_token: Option<String>,
}
impl CollectionPage {
pub fn redownload_for(&self, kind: ItemKind, id: i64) -> Option<String> {
self.items
.iter()
.find(|(k, i, _)| *k == kind && *i == id)
.and_then(|(_, _, sale_key)| self.redownload_urls.get(sale_key))
.cloned()
}
}
pub fn parse_collection_items(body: &str) -> Result<CollectionPage> {
let v: serde_json::Value = serde_json::from_str(body)
.map_err(|e| BackendError::parse(BackendId::Bandcamp, "collection_items", e.to_string()))?;
if v["error"].as_bool().unwrap_or(false) {
return Err(BackendError::AuthExpired {
backend: BackendId::Bandcamp,
detail: v["error_message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
reauth: Some("https://bandcamp.com/login".into()),
});
}
let mut page = CollectionPage {
more_available: v["more_available"].as_bool().unwrap_or(false),
last_token: v["last_token"].as_str().map(str::to_string),
..Default::default()
};
for item in v["items"].as_array().unwrap_or(&Vec::new()) {
let kind = match item["tralbum_type"].as_str() {
Some("t") => ItemKind::Track,
Some("a") => ItemKind::Album,
_ => continue,
};
let Some(id) = item["tralbum_id"].as_i64() else {
continue;
};
let Some(sale_id) = item["sale_item_id"].as_i64() else {
continue;
};
let sale_type = item["sale_item_type"].as_str().unwrap_or("p");
page.items.push((kind, id, format!("{sale_type}{sale_id}")));
}
if let Some(map) = v["redownload_urls"].as_object() {
for (k, val) in map {
if let Some(url) = val.as_str() {
page.redownload_urls.insert(k.clone(), url.to_string());
}
}
}
Ok(page)
}
fn parse_fan_id(body: &str) -> Result<i64> {
let v: serde_json::Value = serde_json::from_str(body).map_err(|e| {
BackendError::parse(BackendId::Bandcamp, "collection_summary", e.to_string())
})?;
if v["error"].as_bool().unwrap_or(false) {
return Err(BackendError::AuthExpired {
backend: BackendId::Bandcamp,
detail: v["error_message"]
.as_str()
.unwrap_or("unknown error")
.to_string(),
reauth: Some("https://bandcamp.com/login".into()),
});
}
v["fan_id"]
.as_i64()
.or_else(|| v["collection_summary"]["fan_id"].as_i64())
.ok_or_else(|| BackendError::parse(BackendId::Bandcamp, "collection_summary", "no fan_id"))
}
pub fn parse_download_page(html: &str) -> Result<std::collections::HashMap<AudioFormat, String>> {
let blob = blob::id_attr_json(html, "pagedata", "data-blob")
.or_else(|_| blob::attr_json(html, "data-blob"))
.map_err(|e| {
BackendError::parse(BackendId::Bandcamp, "download pagedata", e.to_string())
})?;
let items = blob["download_items"].as_array().ok_or_else(|| {
BackendError::parse(
BackendId::Bandcamp,
"download pagedata",
"no download_items",
)
})?;
let first = items.first().ok_or_else(|| {
BackendError::parse(
BackendId::Bandcamp,
"download pagedata",
"download_items was empty",
)
})?;
let downloads = first["downloads"].as_object().ok_or_else(|| {
BackendError::parse(BackendId::Bandcamp, "download pagedata", "no downloads map")
})?;
let mut out = std::collections::HashMap::new();
for (slug, entry) in downloads {
let Ok(format) = slug.parse::<AudioFormat>() else {
continue;
};
if let Some(url) = entry["url"].as_str() {
out.insert(format, url.to_string());
}
}
if out.is_empty() {
return Err(BackendError::parse(
BackendId::Bandcamp,
"download pagedata",
"no usable download links",
));
}
Ok(out)
}
const READY_TIMEOUT: Duration = Duration::from_secs(600);
const READY_MAX_BACKOFF: Duration = Duration::from_secs(30);
fn download_when_ready(
agent: &ureq::Agent,
first_url: &str,
cookie: &str,
format: AudioFormat,
target: &std::path::Path,
) -> Result<u64> {
let mut url = first_url.to_string();
let mut delay = Duration::from_secs(2);
let started = std::time::Instant::now();
let mut attempt = 0u32;
while started.elapsed() < READY_TIMEOUT {
attempt += 1;
let mut resp = agent
.get(&url)
.header("Cookie", cookie)
.call()
.map_err(|e| http::map_err(BackendId::Bandcamp, &url, e))?;
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_ascii_lowercase();
if !content_type.starts_with("text/html") {
let mut reader = resp.body_mut().as_reader();
return super::fs::write_audio_atomically(target, &mut reader);
}
let body = resp
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, &url, e))?;
if attempt == 1 {
note!(
" bandcamp is preparing this download — a file nobody has fetched before can \
take several minutes. Waiting up to {}s.",
READY_TIMEOUT.as_secs()
);
} else {
note!(
" still preparing ({}s elapsed, attempt {attempt})",
started.elapsed().as_secs()
);
}
match parse_download_page(&body) {
Ok(links) => match links.get(&format) {
Some(fresh) if *fresh != url => url = fresh.clone(),
Some(_) => {}
None => {
return Err(BackendError::parse(
BackendId::Bandcamp,
"download retry page",
format!("{format} is no longer offered"),
));
}
},
Err(e) => note!(" (could not re-read the download page: {e})"),
}
std::thread::sleep(delay);
delay = (delay * 2).min(READY_MAX_BACKOFF);
}
Err(BackendError::Timeout {
backend: BackendId::Bandcamp,
op: "waiting for bandcamp to prepare the download — try again in a few minutes",
elapsed: started.elapsed(),
})
}
fn item_artist(html: &str) -> Option<String> {
blob::id_attr_json(html, "pagedata", "data-blob")
.ok()?
.pointer("/download_items/0/artist")?
.as_str()
.map(str::to_string)
}
fn item_title(html: &str) -> Option<String> {
blob::id_attr_json(html, "pagedata", "data-blob")
.ok()?
.pointer("/download_items/0/title")?
.as_str()
.map(str::to_string)
}
fn item_key(kind: ItemKind, id: i64, url: &str) -> String {
let prefix = if kind == ItemKind::Track { "t" } else { "a" };
format!("{prefix}:{id}:{url}")
}
fn parse_item_key(key: &str) -> Option<(ItemKind, i64)> {
let mut parts = key.splitn(3, ':');
let kind = match parts.next()? {
"t" => ItemKind::Track,
"a" => ItemKind::Album,
_ => return None,
};
Some((kind, parts.next()?.parse().ok()?))
}
impl Bandcamp {
pub fn new(creds: &Credentials, budget: Duration) -> Self {
Self {
identity: creds.bandcamp_identity().unwrap_or(None),
budget,
owned: OnceLock::new(),
}
}
fn err_parse(&self, at: &'static str, detail: impl Into<String>) -> BackendError {
BackendError::parse(BackendId::Bandcamp, at, detail)
}
fn owned(&self) -> &OwnedSet {
self.owned.get_or_init(|| {
let Some((secret, _)) = &self.identity else {
return OwnedSet::default();
};
let agent = http::agent(self.budget);
let result = agent
.get(COLLECTION_SUMMARY_URL)
.header("Cookie", &format!("identity={}", secret.expose()))
.call()
.map_err(|e| http::map_err(BackendId::Bandcamp, COLLECTION_SUMMARY_URL, e))
.and_then(|mut r| {
r.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, COLLECTION_SUMMARY_URL, e))
})
.and_then(|body| parse_collection_summary(&body));
match result {
Ok(keys) => OwnedSet { keys: Some(keys) },
Err(e) => {
note!("warning: could not read your bandcamp collection: {e}");
OwnedSet::default()
}
}
})
}
fn redownload_url(&self, secret: &Secret, kind: ItemKind, id: i64) -> Result<Option<String>> {
let agent = http::agent(self.budget);
let cookie = format!("identity={}", secret.expose());
let fan_id = {
let body = agent
.get(COLLECTION_SUMMARY_URL)
.header("Cookie", &cookie)
.call()
.map_err(|e| http::map_err(BackendId::Bandcamp, COLLECTION_SUMMARY_URL, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, COLLECTION_SUMMARY_URL, e))?;
parse_fan_id(&body)?
};
let mut token = String::from("9999999999::a::");
for _ in 0..40 {
let body = http::with_retries(3, || {
agent
.post(COLLECTION_ITEMS_URL)
.header("Cookie", &cookie)
.send_json(serde_json::json!({
"fan_id": fan_id,
"older_than_token": token,
"count": 100,
}))
.map_err(|e| http::map_err(BackendId::Bandcamp, COLLECTION_ITEMS_URL, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, COLLECTION_ITEMS_URL, e))
})?;
let page = parse_collection_items(&body)?;
if let Some(url) = page.redownload_for(kind, id) {
return Ok(Some(url));
}
match (page.more_available, page.last_token) {
(true, Some(next)) if next != token => token = next,
_ => break,
}
}
Ok(None)
}
fn enrich_one(&self, offer: &mut Offer) -> Result<()> {
let agent = http::agent(self.budget);
let url = offer.url.clone();
let html = http::with_retries(2, || {
agent
.get(&url)
.call()
.map_err(|e| http::map_err(BackendId::Bandcamp, &url, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, &url, e))
})?;
let facts = parse_item_page(&html)?;
let acquirable = !matches!(facts.pricing, Pricing::Unavailable { .. });
offer.pricing = facts.pricing;
if offer.duration_secs.is_none() {
offer.duration_secs = facts.duration_secs;
}
offer.formats = Some(if acquirable {
DIGITAL_FORMATS.to_vec()
} else {
Vec::new()
});
Ok(())
}
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
auto: Option<AutoBlock>,
#[serde(default)]
error: bool,
#[serde(default)]
error_message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AutoBlock {
#[serde(default)]
results: Vec<SearchHit>,
}
#[derive(Debug, Deserialize)]
struct SearchHit {
#[serde(rename = "type")]
kind: Option<String>,
id: Option<i64>,
name: Option<String>,
band_name: Option<String>,
album_name: Option<String>,
item_url_path: Option<String>,
img: Option<String>,
}
fn parse_search(body: &str, limit: usize) -> Result<Vec<Offer>> {
let resp: SearchResponse = serde_json::from_str(body)
.map_err(|e| BackendError::parse(BackendId::Bandcamp, "search response", e.to_string()))?;
if resp.error {
let msg = resp.error_message.unwrap_or_else(|| "unknown error".into());
return Err(if msg.contains("logged in") {
BackendError::AuthExpired {
backend: BackendId::Bandcamp,
detail: msg,
reauth: Some("https://bandcamp.com/login".into()),
}
} else {
BackendError::parse(BackendId::Bandcamp, "search response", msg)
});
}
let hits = resp.auto.map(|a| a.results).unwrap_or_default();
let mut offers = Vec::new();
for h in hits {
if offers.len() >= limit {
break;
}
let kind = match h.kind.as_deref() {
Some("t") => ItemKind::Track,
Some("a") => ItemKind::Album,
_ => continue,
};
let (Some(id), Some(name), Some(url)) = (h.id, h.name, h.item_url_path) else {
continue;
};
let mut offer = Offer::new(
ItemRef::new(BackendId::Bandcamp, item_key(kind, id, &url)),
kind,
h.band_name.unwrap_or_default(),
name,
url,
);
offer.album = h.album_name;
offer.artwork_url = h.img;
offers.push(offer);
}
Ok(offers)
}
#[derive(Debug, PartialEq)]
pub struct ItemFacts {
pub pricing: Pricing,
pub duration_secs: Option<i64>,
pub free_download: bool,
}
pub fn parse_item_page(html: &str) -> Result<ItemFacts> {
let t = blob::attr_json(html, "data-tralbum")
.map_err(|e| BackendError::parse(BackendId::Bandcamp, "data-tralbum", e.to_string()))?;
let current = &t["current"];
let download_pref = current["download_pref"].as_i64();
let minimum = current["minimum_price"].as_f64();
let set_price = current["set_price"].as_f64();
let is_set_price = current["is_set_price"].as_bool().unwrap_or(false);
let free_download = !t["freeDownloadPage"].is_null();
let currency = seller_currency(&t, html);
let pricing = match download_pref {
Some(DOWNLOAD_PREF_FREE) => Pricing::Free,
Some(DOWNLOAD_PREF_PAID) => {
if is_set_price {
match (set_price, ¤cy) {
(Some(p), Some(c)) => Pricing::Flat(Price::from_major(p, c)),
(Some(p), None) => Pricing::Flat(Price::from_major(p, UNKNOWN_CURRENCY)),
(None, _) => Pricing::Unprobed,
}
} else {
let minimum = minimum
.filter(|m| *m > 0.0)
.map(|m| Price::from_major(m, currency.as_deref().unwrap_or(UNKNOWN_CURRENCY)));
Pricing::NameYourPrice { minimum }
}
}
_ => Pricing::Unavailable {
reason: Some("no digital download offered".into()),
},
};
Ok(ItemFacts {
pricing,
duration_secs: total_duration_secs(&t),
free_download,
})
}
pub const UNKNOWN_CURRENCY: &str = "???";
fn seller_currency(tralbum: &serde_json::Value, html: &str) -> Option<String> {
if let Some(packages) = tralbum["packages"].as_array()
&& let Some(c) = packages
.iter()
.filter_map(|p| p["currency"].as_str())
.find(|c| !c.trim().is_empty())
{
return Some(c.trim().to_ascii_uppercase());
}
rendered_currency(html)
}
fn rendered_currency(html: &str) -> Option<String> {
let marker = "buyItemExtra secondaryText\">";
let at = html.find(marker)? + marker.len();
let rest = html[at..].trim_start();
let code: String = rest
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect();
(code.len() == 3).then(|| code.to_ascii_uppercase())
}
fn total_duration_secs(tralbum: &serde_json::Value) -> Option<i64> {
let tracks = tralbum["trackinfo"].as_array()?;
let total: f64 = tracks.iter().filter_map(|t| t["duration"].as_f64()).sum();
(total > 0.0).then(|| total.round() as i64)
}
impl super::AcquisitionBackend for Bandcamp {
fn id(&self) -> BackendId {
BackendId::Bandcamp
}
fn capabilities(&self) -> Capabilities {
Capabilities {
search: true,
price_quotes: true,
ownership_check: self.identity.is_some(),
requires_purchase: true,
fetch: self.identity.is_some(),
lossless_capable: true,
}
}
fn credentials(&self) -> CredentialState {
match &self.identity {
Some((secret, source)) => CredentialState::Present {
hint: format!("identity cookie, {} bytes, from {source}", secret.len()),
},
None => CredentialState::Missing {
how_to_fix: "put your bandcamp `identity` cookie in credentials.toml, \
or set BANDCAMP_IDENTITY"
.into(),
},
}
}
fn claim_url(&self, url: &str) -> Option<ItemRef> {
if !url.contains(".bandcamp.com/") {
return None;
}
let kind = if url.contains("/track/") {
"url-t"
} else if url.contains("/album/") {
"url-a"
} else {
return None;
};
Some(ItemRef::new(BackendId::Bandcamp, format!("{kind}:{url}")))
}
fn search(&self, query: &SearchQuery) -> Result<Vec<Offer>> {
let text = query.search_text();
if text.is_empty() {
return Ok(Vec::new());
}
let agent = http::agent(self.budget);
let body = http::with_retries(3, || {
agent
.post(SEARCH_URL)
.send_json(serde_json::json!({
"search_text": text,
"search_filter": "",
"full_page": false,
}))
.map_err(|e| http::map_err(BackendId::Bandcamp, SEARCH_URL, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, SEARCH_URL, e))
})?;
parse_search(&body, query.limit)
}
fn enrich(&self, offers: &mut [Offer]) -> Result<()> {
let owned = self.owned();
for offer in offers.iter_mut() {
if let Some((kind, id)) = parse_item_key(&offer.item_ref.key) {
offer.ownership = owned.contains(kind, id);
}
}
for offer in offers.iter_mut() {
if let Err(e) = self.enrich_one(offer) {
if matches!(e, BackendError::AuthExpired { .. }) {
return Err(e);
}
offer.enrich_error = Some(e.to_string());
}
}
Ok(())
}
fn fetch(&self, item: &ItemRef, opts: &FetchOpts) -> Result<Vec<AcquiredFile>> {
let Some((secret, _)) = &self.identity else {
return Err(BackendError::NoCredentials {
backend: BackendId::Bandcamp,
how_to_fix: "put your bandcamp `identity` cookie in credentials.toml".into(),
});
};
let (kind, id) = parse_item_key(&item.key)
.ok_or_else(|| self.err_parse("item ref", "not a bandcamp item key"))?;
let redownload =
self.redownload_url(secret, kind, id)?
.ok_or_else(|| BackendError::NotOwned {
backend: BackendId::Bandcamp,
item: item.clone(),
})?;
let cookie = format!("identity={}", secret.expose());
let agent = http::download_agent(
opts.deadline
.saturating_duration_since(std::time::Instant::now())
.max(Duration::from_secs(30)),
);
let page = http::with_retries(4, || {
agent
.get(&redownload)
.header("Cookie", &cookie)
.call()
.map_err(|e| http::map_err(BackendId::Bandcamp, &redownload, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(BackendId::Bandcamp, &redownload, e))
})?;
let available = parse_download_page(&page)?;
let chosen = opts
.format_pref
.iter()
.find(|f| available.contains_key(f))
.copied()
.ok_or_else(|| BackendError::NoAcceptableFormat {
available: available.keys().copied().collect(),
wanted: opts.format_pref.clone(),
})?;
let url = available[&chosen].clone();
let name = super::fs::track_filename(
Some(&item_artist(&page).unwrap_or_default()),
item_title(&page).as_deref(),
chosen.extension(),
);
let target = super::fs::unique_path(&opts.dest_dir.join(name));
let bytes = download_when_ready(&agent, &url, &cookie, chosen, &target)?;
Ok(vec![AcquiredFile {
path: target,
format: chosen,
bytes,
retention: opts.retention,
source: item.clone(),
source_url: url,
artist: item_artist(&page),
title: item_title(&page),
album: None,
track_number: None,
}])
}
fn purchase(&self, item: &ItemRef) -> Result<PurchaseFlow> {
Ok(PurchaseFlow::OpenInBrowser {
url: item_url(item).ok_or_else(|| self.err_parse("item ref", "no url in ref"))?,
note: Some("pay in the browser, then run `rekord-ripper fetch`".into()),
})
}
}
pub fn item_url(item: &ItemRef) -> Option<String> {
if let Some(rest) = item
.key
.strip_prefix("url-t:")
.or_else(|| item.key.strip_prefix("url-a:"))
{
return Some(rest.to_string());
}
let mut parts = item.key.splitn(3, ':');
let _kind = parts.next()?;
let _id = parts.next()?;
parts
.next()
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
const SEARCH_FIXTURE: &str = r#"{"auto":{"results":[
{"type":"a","id":856850876,"name":"Untrue","band_name":"Burial","album_name":null,
"item_url_path":"https://burial.bandcamp.com/album/untrue","img":"https://f4.bcbits.com/img/a.jpg"},
{"type":"t","id":2920211424,"name":"Burial","band_name":"JORDI GANCHITOS","album_name":"UNTRUE",
"item_url_path":"https://jordiganchitos.bandcamp.com/track/burial","img":null},
{"type":"b","id":999,"name":"Some Band","band_name":"Some Band","album_name":null,
"item_url_path":"https://someband.bandcamp.com","img":null}
]}}"#;
#[test]
fn parses_a_real_search_response() {
let offers = parse_search(SEARCH_FIXTURE, 10).unwrap();
assert_eq!(offers.len(), 2);
assert_eq!(offers[0].kind, ItemKind::Album);
assert_eq!(offers[0].artist, "Burial");
assert_eq!(offers[0].title, "Untrue");
assert_eq!(
parse_item_key(&offers[0].item_ref.key),
Some((ItemKind::Album, 856850876))
);
assert_eq!(offers[1].kind, ItemKind::Track);
assert_eq!(offers[1].album.as_deref(), Some("UNTRUE"));
assert_eq!(
parse_item_key(&offers[1].item_ref.key),
Some((ItemKind::Track, 2920211424))
);
}
#[test]
fn an_item_ref_from_a_search_can_still_be_opened_in_a_browser() {
let o = &parse_search(SEARCH_FIXTURE, 1).unwrap()[0];
assert_eq!(
item_url(&o.item_ref).as_deref(),
Some("https://burial.bandcamp.com/album/untrue")
);
}
#[test]
fn item_refs_round_trip_through_their_string_form() {
let o = &parse_search(SEARCH_FIXTURE, 1).unwrap()[0];
let s = o.item_ref.to_string();
let back: ItemRef = s.parse().unwrap();
assert_eq!(
back, o.item_ref,
"a ref printed for --offer must parse back"
);
assert_eq!(
parse_item_key(&back.key),
Some((ItemKind::Album, 856850876))
);
}
#[test]
fn item_keys_with_urls_containing_colons_still_parse() {
let key = item_key(ItemKind::Track, 42, "https://x.bandcamp.com/track/y");
assert_eq!(parse_item_key(&key), Some((ItemKind::Track, 42)));
assert_eq!(
item_url(&ItemRef::new(BackendId::Bandcamp, key)).as_deref(),
Some("https://x.bandcamp.com/track/y")
);
}
#[test]
fn malformed_item_keys_are_rejected() {
assert_eq!(parse_item_key("b:1:url"), None, "bands are not items");
assert_eq!(parse_item_key("t:notanumber:url"), None);
assert_eq!(parse_item_key("t"), None);
}
#[test]
fn ownership_is_unknown_when_the_collection_could_not_be_read() {
let unknown = OwnedSet::default();
assert_eq!(unknown.contains(ItemKind::Album, 1), Ownership::Unknown);
}
#[test]
fn ownership_distinguishes_tracks_from_albums_with_the_same_id() {
let owned = OwnedSet {
keys: Some(["a856850876".to_string()].into_iter().collect()),
};
assert_eq!(
owned.contains(ItemKind::Album, 856850876),
Ownership::Yes {
redownloadable: true
}
);
assert_eq!(owned.contains(ItemKind::Track, 856850876), Ownership::No);
}
#[test]
fn parses_a_collection_summary_into_owned_keys() {
let body = r#"{"fan_id":123,"collection_summary":{"fan_id":123,
"tralbum_lookup":{"a856850876":{"item_type":"a"},"t42":{"item_type":"t"}}}}"#;
let keys = parse_collection_summary(body).unwrap();
assert!(keys.contains("a856850876"));
assert!(keys.contains("t42"));
}
#[test]
fn a_logged_out_collection_summary_is_an_auth_error_not_an_empty_collection() {
let body = r#"{"error":true,"error_message":"must be logged in"}"#;
let err = parse_collection_summary(body).unwrap_err();
assert!(
matches!(err, BackendError::AuthExpired { .. }),
"got {err:?}"
);
}
#[test]
fn joins_collection_items_to_their_redownload_urls() {
let body = r#"{"items":[
{"tralbum_type":"a","tralbum_id":856850876,"sale_item_id":111,"sale_item_type":"p"},
{"tralbum_type":"t","tralbum_id":42,"sale_item_id":222,"sale_item_type":"t"}],
"redownload_urls":{"p111":"https://bandcamp.com/download?id=1",
"t222":"https://bandcamp.com/download?id=2"},
"more_available":false,"last_token":null}"#;
let page = parse_collection_items(body).unwrap();
assert_eq!(
page.redownload_for(ItemKind::Album, 856850876).as_deref(),
Some("https://bandcamp.com/download?id=1")
);
assert_eq!(
page.redownload_for(ItemKind::Track, 42).as_deref(),
Some("https://bandcamp.com/download?id=2")
);
assert_eq!(page.redownload_for(ItemKind::Track, 856850876), None);
assert_eq!(page.redownload_for(ItemKind::Album, 999), None);
}
#[test]
fn paging_state_is_read_so_a_large_collection_can_be_walked() {
let body = r#"{"items":[],"redownload_urls":{},
"more_available":true,"last_token":"1700000000::a::"}"#;
let page = parse_collection_items(body).unwrap();
assert!(page.more_available);
assert_eq!(page.last_token.as_deref(), Some("1700000000::a::"));
}
#[test]
fn an_empty_collection_page_is_valid() {
let body = r#"{"items":[],"more_available":false,"tracklists":{},
"redownload_urls":{},"item_lookup":{},"last_token":null}"#;
let page = parse_collection_items(body).unwrap();
assert!(!page.more_available);
assert_eq!(page.redownload_for(ItemKind::Album, 1), None);
}
#[test]
fn a_logged_out_collection_page_is_an_auth_error() {
let err = parse_collection_items(r#"{"error":true,"error_message":"must be logged in"}"#)
.unwrap_err();
assert!(matches!(err, BackendError::AuthExpired { .. }));
}
#[test]
fn reads_the_fan_id_from_either_shape() {
assert_eq!(parse_fan_id(r#"{"fan_id":123}"#).unwrap(), 123);
assert_eq!(
parse_fan_id(r#"{"collection_summary":{"fan_id":456}}"#).unwrap(),
456
);
assert!(parse_fan_id(r#"{"something_else":1}"#).is_err());
}
const DOWNLOAD_PAGE: &str = r#"<html><div id="pagedata" data-blob="{
"download_items":[{"artist":"Burial",
"title":"Untrue",
"downloads":{
"flac":{"url":"https://popplers.bcbits.com/flac","size_mb":"280MB"},
"aiff-lossless":{"url":"https://popplers.bcbits.com/aiff"},
"mp3-320":{"url":"https://popplers.bcbits.com/mp3"},
"vorbis":{"url":"https://popplers.bcbits.com/ogg"},
"some-new-format":{"url":"https://popplers.bcbits.com/new"}}}]}"></div></html>"#;
#[test]
fn parses_the_per_format_download_links() {
let links = parse_download_page(DOWNLOAD_PAGE).unwrap();
assert_eq!(
links[&AudioFormat::Flac],
"https://popplers.bcbits.com/flac"
);
assert_eq!(
links[&AudioFormat::Aiff],
"https://popplers.bcbits.com/aiff"
);
assert!(links.contains_key(&AudioFormat::Mp3(Some(320))));
}
#[test]
fn an_unknown_format_slug_does_not_break_the_other_links() {
let links = parse_download_page(DOWNLOAD_PAGE).unwrap();
assert!(links.contains_key(&AudioFormat::Flac));
assert_eq!(
links.len(),
4,
"the unrecognised slug is skipped, not fatal"
);
}
#[test]
fn the_download_page_is_authoritative_and_can_report_ogg() {
let links = parse_download_page(DOWNLOAD_PAGE).unwrap();
assert!(links.contains_key(&AudioFormat::Ogg));
assert!(!AudioFormat::Ogg.usable_in_rekordbox());
}
#[test]
fn format_preference_picks_flac_over_mp3_from_a_real_page() {
let links = parse_download_page(DOWNLOAD_PAGE).unwrap();
let pref = [
AudioFormat::Flac,
AudioFormat::Aiff,
AudioFormat::Mp3(Some(320)),
];
let chosen = pref.iter().find(|f| links.contains_key(f)).unwrap();
assert_eq!(*chosen, AudioFormat::Flac);
}
#[test]
fn a_page_with_no_usable_links_reports_a_shape_change() {
let html = r#"<div id="pagedata" data-blob="{"download_items":[{"downloads":{}}]}"></div>"#;
assert!(matches!(
parse_download_page(html).unwrap_err(),
BackendError::Parse { .. }
));
let html2 = r#"<div id="pagedata" data-blob="{}"></div>"#;
assert!(parse_download_page(html2).is_err());
assert!(parse_download_page("<html>redesigned</html>").is_err());
}
#[test]
fn reads_artist_and_title_for_naming_the_file() {
assert_eq!(item_artist(DOWNLOAD_PAGE).as_deref(), Some("Burial"));
assert_eq!(item_title(DOWNLOAD_PAGE).as_deref(), Some("Untrue"));
}
#[test]
fn fetching_without_a_cookie_says_what_to_do_rather_than_failing_opaquely() {
use super::super::AcquisitionBackend;
let bc = Bandcamp::new(&Credentials::default(), Duration::from_secs(5));
if bc.identity.is_some() {
return; }
let err = bc
.fetch(
&ItemRef::new(BackendId::Bandcamp, "a:1:https://x/album/y"),
&FetchOpts {
dest_dir: std::env::temp_dir(),
format_pref: vec![AudioFormat::Flac],
retention: Retention::Keep,
overwrite: false,
deadline: std::time::Instant::now() + Duration::from_secs(5),
},
)
.unwrap_err();
assert!(
matches!(err, BackendError::NoCredentials { .. }),
"got {err:?}"
);
assert!(err.needs_user_action());
}
#[test]
fn an_empty_collection_is_valid_not_an_error() {
let keys =
parse_collection_summary(r#"{"collection_summary":{"tralbum_lookup":{}}}"#).unwrap();
assert!(keys.is_empty());
}
#[test]
fn search_results_start_unprobed() {
let o = &parse_search(SEARCH_FIXTURE, 10).unwrap()[0];
assert!(matches!(o.pricing, Pricing::Unprobed));
assert_eq!(o.formats, None);
assert_eq!(o.ownership, Ownership::Unknown);
}
#[test]
fn search_respects_the_limit() {
assert_eq!(parse_search(SEARCH_FIXTURE, 1).unwrap().len(), 1);
assert_eq!(parse_search(SEARCH_FIXTURE, 0).unwrap().len(), 0);
}
#[test]
fn an_error_body_with_http_200_is_still_an_error() {
let err = parse_search(r#"{"error":true,"error_message":"bad function"}"#, 5).unwrap_err();
assert!(matches!(err, BackendError::Parse { .. }), "got {err:?}");
}
#[test]
fn a_logged_out_error_body_becomes_auth_expired() {
let err =
parse_search(r#"{"error":true,"error_message":"must be logged in"}"#, 5).unwrap_err();
assert!(
matches!(err, BackendError::AuthExpired { .. }),
"got {err:?}"
);
assert!(err.needs_user_action());
}
#[test]
fn an_empty_result_set_is_success_not_failure() {
assert!(
parse_search(r#"{"auto":{"results":[]}}"#, 5)
.unwrap()
.is_empty()
);
assert!(parse_search(r#"{}"#, 5).unwrap().is_empty());
}
#[test]
fn malformed_hits_are_skipped_rather_than_failing_the_batch() {
let body = r#"{"auto":{"results":[
{"type":"t","id":null,"name":"No Id","item_url_path":"https://x/track/y"},
{"type":"t","id":1,"name":"Good","band_name":"B","item_url_path":"https://x/track/z"}
]}}"#;
let offers = parse_search(body, 10).unwrap();
assert_eq!(offers.len(), 1);
assert_eq!(offers[0].title, "Good");
}
const ALBUM_FIXTURE: &str = r#"<html>
<div id="pagedata" data-cart="{"currency":"USD"}"></div>
<script data-tralbum="{"item_type":"album","freeDownloadPage":null,
"current":{"download_pref":2,"minimum_price":8.5,
"set_price":9.0,"is_set_price":null},
"packages":[{"currency":"GBP","price":10.99}],
"trackinfo":[{"duration":46.2133},{"duration":100.0}]}"></script>
<span class="buyItemExtra secondaryText">GBP</span>
</html>"#;
#[test]
fn uses_the_seller_currency_not_the_viewers_cart_currency() {
let facts = parse_item_page(ALBUM_FIXTURE).unwrap();
match facts.pricing {
Pricing::NameYourPrice {
minimum: Some(price),
} => {
assert_eq!(price.currency, "GBP");
assert_eq!(price.amount_minor, 850);
}
other => panic!("expected name-your-price with a minimum, got {other:?}"),
}
}
#[test]
fn a_null_is_set_price_means_name_your_price() {
let facts = parse_item_page(ALBUM_FIXTURE).unwrap();
assert!(matches!(facts.pricing, Pricing::NameYourPrice { .. }));
}
#[test]
fn a_true_is_set_price_means_a_fixed_price() {
let html = ALBUM_FIXTURE.replace(
""is_set_price":null",
""is_set_price":true",
);
match parse_item_page(&html).unwrap().pricing {
Pricing::Flat(p) => {
assert_eq!(p.to_string(), "9.00 GBP", "the set price, not the minimum");
}
other => panic!("expected a flat price, got {other:?}"),
}
}
#[test]
fn sums_track_durations_for_an_album() {
assert_eq!(
parse_item_page(ALBUM_FIXTURE).unwrap().duration_secs,
Some(146)
);
}
#[test]
fn download_pref_one_is_free() {
let html =
ALBUM_FIXTURE.replace(""download_pref":2", ""download_pref":1");
assert!(matches!(
parse_item_page(&html).unwrap().pricing,
Pricing::Free
));
}
#[test]
fn no_download_pref_means_not_acquirable() {
let html =
r#"<script data-tralbum="{"current":{},"packages":[]}"></script>"#;
let facts = parse_item_page(html).unwrap();
assert!(matches!(facts.pricing, Pricing::Unavailable { .. }));
let mut o = Offer::new(
ItemRef::new(BackendId::Bandcamp, "a:1"),
ItemKind::Album,
"A",
"T",
"u",
);
o.pricing = facts.pricing;
assert_eq!(o.cost_class(), CostClass::Unavailable);
}
#[test]
fn a_digital_only_release_falls_back_to_the_rendered_currency() {
let html = r#"<script data-tralbum="{"current":{"download_pref":2,
"minimum_price":3.0,"is_set_price":true,"set_price":3.0}}"></script>
<span class="buyItemExtra secondaryText">EUR</span>"#;
match parse_item_page(html).unwrap().pricing {
Pricing::Flat(p) => assert_eq!(p.to_string(), "3.00 EUR"),
other => panic!("got {other:?}"),
}
}
#[test]
fn an_unlabelled_price_is_marked_unknown_rather_than_guessed() {
let html = r#"<script data-tralbum="{"current":{"download_pref":2,
"set_price":5.0,"is_set_price":true}}"></script>"#;
match parse_item_page(html).unwrap().pricing {
Pricing::Flat(p) => {
assert_eq!(p.currency, UNKNOWN_CURRENCY);
assert_eq!(p.to_string(), "5.00 ???");
}
other => panic!("got {other:?}"),
}
}
#[test]
fn a_zero_minimum_is_name_your_price_with_no_floor() {
let html = r#"<script data-tralbum="{"current":{"download_pref":2,
"minimum_price":0.0,"is_set_price":null}}"></script>"#;
assert_eq!(
parse_item_page(html).unwrap().pricing_variant_name(),
"NameYourPrice(None)"
);
}
#[test]
fn a_free_download_page_is_noticed() {
let html = r#"<script data-tralbum="{"freeDownloadPage":"https://bandcamp.com/download?x=1",
"current":{"download_pref":2,"minimum_price":0.0}}"></script>"#;
assert!(parse_item_page(html).unwrap().free_download);
assert!(!parse_item_page(ALBUM_FIXTURE).unwrap().free_download);
}
#[test]
fn a_page_without_the_blob_reports_a_shape_change() {
let err = parse_item_page("<html>redesigned</html>").unwrap_err();
match err {
BackendError::Parse { at, .. } => assert_eq!(at, "data-tralbum"),
other => panic!("expected a Parse error naming the blob, got {other:?}"),
}
assert!(!parse_item_page("<html/>").unwrap_err().is_retryable());
}
#[test]
fn rendered_currency_ignores_anything_that_is_not_a_three_letter_code() {
assert_eq!(
rendered_currency("class=\"buyItemExtra secondaryText\">GBP<"),
Some("GBP".into())
);
assert_eq!(
rendered_currency("class=\"buyItemExtra secondaryText\">or more<"),
None
);
assert_eq!(rendered_currency("nothing here"), None);
}
#[test]
fn capabilities_reflect_whether_a_cookie_is_present() {
use super::super::AcquisitionBackend;
let anon = Bandcamp::new(&Credentials::default(), Duration::from_secs(5));
if anon.identity.is_none() {
let c = anon.capabilities();
assert!(c.search, "search must work without credentials");
assert!(!c.fetch, "fetching a purchase needs a session");
assert!(!c.ownership_check);
assert!(matches!(
anon.credentials(),
CredentialState::Missing { .. }
));
}
}
#[test]
fn claims_only_bandcamp_item_urls() {
use super::super::AcquisitionBackend;
let bc = Bandcamp::new(&Credentials::default(), Duration::from_secs(5));
assert!(
bc.claim_url("https://burial.bandcamp.com/album/untrue")
.is_some()
);
assert!(bc.claim_url("https://x.bandcamp.com/track/y").is_some());
assert!(bc.claim_url("https://burial.bandcamp.com").is_none());
assert!(bc.claim_url("https://soundcloud.com/a/b").is_none());
}
#[test]
fn a_claimed_url_round_trips_back_out_for_the_browser() {
use super::super::AcquisitionBackend;
let bc = Bandcamp::new(&Credentials::default(), Duration::from_secs(5));
let url = "https://burial.bandcamp.com/album/untrue";
let r = bc.claim_url(url).unwrap();
match bc.purchase(&r).unwrap() {
PurchaseFlow::OpenInBrowser { url: u, .. } => assert_eq!(u, url),
other => panic!("got {other:?}"),
}
}
impl ItemFacts {
fn pricing_variant_name(&self) -> String {
match &self.pricing {
Pricing::Unprobed => "Unprobed".into(),
Pricing::Free => "Free".into(),
Pricing::NameYourPrice { minimum: None } => "NameYourPrice(None)".into(),
Pricing::NameYourPrice { minimum: Some(_) } => "NameYourPrice(Some)".into(),
Pricing::Flat(_) => "Flat".into(),
Pricing::PerFormat(_) => "PerFormat".into(),
Pricing::Unavailable { .. } => "Unavailable".into(),
}
}
}
}