pub mod backend;
pub mod bandcamp;
pub mod blob;
pub mod cmd;
pub mod error;
pub mod fs;
pub mod http;
pub mod pick;
pub mod render;
pub mod report;
pub mod shop;
pub mod soulseek;
pub mod soundcloud;
pub mod types;
pub use backend::AcquisitionBackend;
pub use error::{BackendError, Result};
pub use types::*;
use std::sync::Mutex;
use crate::config::{Config, Credentials};
type Sink = Box<dyn Fn(&str) + Send + Sync>;
static SINK: Mutex<Option<Sink>> = Mutex::new(None);
pub fn route_progress(sink: Sink) {
*SINK.lock().unwrap_or_else(|e| e.into_inner()) = Some(sink);
}
pub fn note_line(line: &str) {
match SINK.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
Some(sink) => sink(line),
None => eprintln!("{line}"),
}
}
macro_rules! note {
($($arg:tt)*) => {
$crate::acquire::note_line(&format!($($arg)*))
};
}
pub(crate) use note;
pub struct Registry {
backends: Vec<Box<dyn AcquisitionBackend>>,
}
impl Registry {
pub fn from_config(cfg: &Config, creds: &Credentials) -> Self {
let budget = std::time::Duration::from_secs(cfg.search.timeout_secs.max(1));
let mut backends: Vec<Box<dyn AcquisitionBackend>> = Vec::new();
if cfg.bandcamp.enabled {
backends.push(Box::new(bandcamp::Bandcamp::new(creds, budget)));
}
if cfg.soundcloud.enabled {
backends.push(Box::new(soundcloud::SoundCloud::new(
&cfg.soundcloud.yt_dlp_path,
soundcloud::Cookies::from_config(
&cfg.soundcloud.cookies_from_browser,
&cfg.soundcloud.cookies_file,
),
cfg.soundcloud.extra_args.clone(),
budget,
)));
}
if cfg.soulseek.enabled {
backends.push(Box::new(soulseek::Soulseek::new(
&cfg.soulseek,
creds,
budget,
)));
}
Self { backends }
}
pub fn is_empty(&self) -> bool {
self.backends.is_empty()
}
pub fn len(&self) -> usize {
self.backends.len()
}
pub fn iter(&self) -> impl Iterator<Item = &dyn AcquisitionBackend> {
self.backends.iter().map(|b| b.as_ref())
}
pub fn get(&self, id: BackendId) -> Option<&dyn AcquisitionBackend> {
self.iter().find(|b| b.id() == id)
}
pub fn searchable(&self) -> impl Iterator<Item = &dyn AcquisitionBackend> {
self.iter().filter(|b| b.capabilities().search)
}
pub fn claim_url(&self, url: &str) -> Option<(&dyn AcquisitionBackend, ItemRef)> {
self.iter().find_map(|b| b.claim_url(url).map(|r| (b, r)))
}
}
pub fn format_preference(cfg: &Config) -> anyhow::Result<Vec<AudioFormat>> {
let mut out = Vec::new();
for raw in &cfg.general.format_preference {
match raw.parse::<AudioFormat>() {
Ok(f) if f.usable_in_rekordbox() => out.push(f),
Ok(f) => note!(
"warning: format_preference lists {f}, which rekordbox cannot read — ignoring"
),
Err(e) => note!("warning: {e} in format_preference — ignoring"),
}
}
if out.is_empty() {
anyhow::bail!(
"format_preference has no formats rekordbox can read; \
expected some of flac, aiff, wav, alac, mp3-320"
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_routed_note_goes_to_the_sink_instead_of_stderr() {
static SEEN: Mutex<Vec<String>> = Mutex::new(Vec::new());
route_progress(Box::new(|line| SEEN.lock().unwrap().push(line.to_string())));
note!("soulseek: {} at position {}", "queued", 12);
assert!(
SEEN.lock()
.unwrap()
.contains(&"soulseek: queued at position 12".to_string())
);
}
#[test]
fn default_config_registers_the_enabled_backends() {
let reg = Registry::from_config(&Config::default(), &Credentials::default());
assert!(!reg.is_empty());
for id in BackendId::ALL {
assert!(reg.get(*id).is_some(), "{id} should be enabled by default");
}
assert!(reg.searchable().any(|b| b.id() == BackendId::Bandcamp));
assert!(reg.searchable().any(|b| b.id() == BackendId::Soulseek));
}
#[test]
fn a_disabled_backend_is_absent_entirely() {
let mut cfg = Config::default();
cfg.bandcamp.enabled = false;
cfg.soulseek.enabled = false;
let reg = Registry::from_config(&cfg, &Credentials::default());
assert!(reg.get(BackendId::Bandcamp).is_none());
assert!(reg.get(BackendId::Soulseek).is_none());
assert!(reg.get(BackendId::SoundCloud).is_some());
}
#[test]
fn backends_claim_only_their_own_urls() {
let reg = Registry::from_config(&Config::default(), &Credentials::default());
let (b, _) = reg.claim_url(r"slsk://peer/@@a\b - c.flac").unwrap();
assert_eq!(b.id(), BackendId::Soulseek);
let (b, _) = reg
.claim_url("https://soundcloud.com/artist/track")
.unwrap();
assert_eq!(b.id(), BackendId::SoundCloud);
}
#[test]
fn url_claiming_routes_to_the_owning_backend() {
let reg = Registry::from_config(&Config::default(), &Credentials::default());
let (b, r) = reg
.claim_url("https://burial.bandcamp.com/album/untrue")
.expect("bandcamp should claim its own album url");
assert_eq!(b.id(), BackendId::Bandcamp);
assert_eq!(r.backend, BackendId::Bandcamp);
assert!(reg.claim_url("https://example.com/whatever").is_none());
}
#[test]
fn default_format_preference_resolves_lossless_first() {
let prefs = format_preference(&Config::default()).unwrap();
assert_eq!(prefs.first(), Some(&AudioFormat::Flac));
assert!(prefs.iter().all(|f| f.usable_in_rekordbox()));
}
#[test]
fn unreadable_and_unknown_formats_are_dropped_from_the_preference() {
let mut cfg = Config::default();
cfg.general.format_preference = vec!["vorbis".into(), "not-a-format".into(), "flac".into()];
assert_eq!(format_preference(&cfg).unwrap(), vec![AudioFormat::Flac]);
}
#[test]
fn a_preference_with_nothing_usable_is_an_error_not_a_silent_empty() {
let mut cfg = Config::default();
cfg.general.format_preference = vec!["vorbis".into(), "opus".into()];
let err = format_preference(&cfg).unwrap_err().to_string();
assert!(err.contains("no formats rekordbox can read"), "got: {err}");
}
}