use std::{env, fs, path::Path, process, time::Duration};
use clap::{Parser, ValueHint, command};
use exponential_backoff::Backoff;
use log::{LevelFilter, debug, error, info, trace, warn};
use pleezer::{
arl::Arl,
config::{Config, Credentials},
decrypt,
error::{Error, ErrorKind, Result},
player::Player,
protocol::connect::{DeviceType, Percentage},
remote,
signal::{self, ShutdownSignal},
uuid::Uuid,
};
#[cfg(debug_assertions)]
const BUILD_PROFILE: &str = "debug";
#[cfg(not(debug_assertions))]
const BUILD_PROFILE: &str = "release";
const ARGS_GROUP_LOGGING: &str = "logging";
const BACKOFF_ATTEMPTS: u32 = 10;
const MIN_BACKOFF: Duration = Duration::from_millis(100);
const MAX_BACKOFF: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, Default, PartialEq, PartialOrd, Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long, value_name = "FILE", value_hint = ValueHint::FilePath, default_value_t = String::from("secrets.toml"), env = "PLEEZER_SECRETS")]
secrets: String,
#[arg(short, long, value_hint = ValueHint::Hostname, env = "PLEEZER_NAME")]
name: Option<String>,
#[arg(long, default_value_t = DeviceType::Web, env = "PLEEZER_DEVICE_TYPE")]
device_type: DeviceType,
#[arg(short, long, default_value = None, env = "PLEEZER_DEVICE")]
device: Option<String>,
#[arg(long, default_value_t = false, env = "PLEEZER_NORMALIZE_VOLUME")]
normalize_volume: bool,
#[arg(
long,
value_parser = clap::value_parser!(u8).range(0..=100),
env = "PLEEZER_INITIAL_VOLUME"
)]
initial_volume: Option<u8>,
#[arg(long, default_value_t = false, env = "PLEEZER_NO_INTERRUPTIONS")]
no_interruptions: bool,
#[arg(long, default_value = "0.0.0.0", env = "PLEEZER_BIND")]
bind: String,
#[arg(long, value_hint = ValueHint::ExecutablePath, env = "PLEEZER_HOOK")]
hook: Option<String>,
#[arg(short, long, default_value_t = false, group = ARGS_GROUP_LOGGING, env = "PLEEZER_QUIET")]
quiet: bool,
#[arg(short, long, action = clap::ArgAction::Count, group = ARGS_GROUP_LOGGING, env = "PLEEZER_VERBOSE")]
verbose: u8,
#[arg(
long,
default_value_t = false,
requires = "verbose",
env = "PLEEZER_EAVESDROP"
)]
eavesdrop: bool,
}
fn init_logger(config: &Args) {
let mut logger = env_logger::Builder::from_env(
env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "info"),
);
let mut external_level = LevelFilter::Error;
if config.quiet || config.verbose > 0 {
let level = match config.verbose {
0 => {
LevelFilter::Warn
}
1 => LevelFilter::Debug,
_ => LevelFilter::max(),
};
logger.filter_module(module_path!(), level);
if level == LevelFilter::Trace {
external_level = LevelFilter::max();
}
};
for external_module in [
"symphonia",
"symphonia_bundle_flac",
"symphonia_bundle_mp3",
"symphonia_codec_aac",
"symphonia_codec_pcm",
"symphonia_core",
"symphonia_format_isomp4",
"symphonia_format_riff",
"symphonia_metadata",
"symphonia_utils_xiph",
] {
logger.filter_module(external_module, external_level);
}
logger.init();
}
fn parse_secrets(secrets: impl AsRef<Path>) -> Result<toml::Value> {
let attributes = fs::metadata(&secrets)?;
let file_size = attributes.len();
if file_size > 1024 {
return Err(Error::out_of_range(
"{secrets} too large: {file_size} bytes",
));
}
let contents = fs::read_to_string(&secrets)?;
contents.parse::<toml::Value>().map_err(|e| {
Error::invalid_argument(format!(
"{} format invalid: {e}",
secrets.as_ref().to_string_lossy()
))
})
}
async fn run(args: Args) -> Result<ShutdownSignal> {
if args.device.as_ref().is_some_and(|device| device == "?") {
let devices = Player::enumerate_devices();
if devices.is_empty() {
return Err(Error::not_found(
"no stereo 44.1/48 kHz output devices found",
));
}
info!("available stereo 44.1/48 kHz output devices:");
for device in devices {
info!("- {device}");
}
return Ok(ShutdownSignal::Interrupt);
}
if let Ok(proxy) = env::var("HTTPS_PROXY") {
info!("using proxy: {proxy}");
}
let config = {
info!("parsing secrets from {}", args.secrets);
let secrets = parse_secrets(args.secrets)?;
let credentials = match secrets.get("arl").and_then(|value| value.as_str()) {
Some(arl) => {
let result = arl.parse::<Arl>()?;
info!("using arl from secrets file");
Credentials::Arl(result)
}
None => {
let email = secrets
.get("email")
.and_then(|email| email.as_str())
.ok_or_else(|| Error::unauthenticated("email not found"))?;
let password = secrets
.get("password")
.and_then(|password| password.as_str())
.ok_or_else(|| Error::unauthenticated("password not found"))?;
Credentials::Login {
email: email.to_string(),
password: password.to_string(),
}
}
};
let bf_secret = match secrets.get("bf_secret").and_then(|value| value.as_str()) {
Some(value) => {
let key = value.parse::<decrypt::Key>()?;
Some(key)
}
None => None,
};
let app_name = env!("CARGO_PKG_NAME").to_owned();
let app_version = env!("CARGO_PKG_VERSION").to_owned();
let app_lang = "en".to_owned();
let device_id = *machine_uid::get()
.and_then(|uid| uid.parse().map_err(Into::into))
.unwrap_or_else(|_| {
warn!("could not get machine uuid, using random device id");
Uuid::fast_v4()
});
trace!("device uuid: {device_id}");
let illegal_chars = |chr| chr == '/' || chr == ';';
if app_name.is_empty()
|| app_name.contains(illegal_chars)
|| app_version.is_empty()
|| app_version.contains(illegal_chars)
|| app_lang.chars().count() != 2
|| app_lang.contains(illegal_chars)
{
return Err(Error::invalid_argument(format!(
"application name, version and/or language invalid (\"{app_name}\"; \"{app_version}\"; \"{app_lang}\")"
)));
}
let os_name = match std::env::consts::OS {
"macos" => "osx",
other => other,
};
let os_version = match std::env::consts::OS {
"linux" => sysinfo::System::kernel_version(),
_ => sysinfo::System::os_version(),
}
.unwrap_or("0".to_string());
if os_name.is_empty()
|| os_name.contains(illegal_chars)
|| os_version.is_empty()
|| os_version.contains(illegal_chars)
{
return Err(Error::invalid_argument(format!(
"os name and/or version invalid (\"{os_name}\"; \"{os_version}\")"
)));
}
let user_agent = format!(
"{app_name}/{app_version} (Rust; {os_name}/{os_version}; like Desktop; {app_lang})"
);
trace!("user agent: {user_agent}");
let client_id = fastrand::usize(100_000_000..=999_999_999);
trace!("client id: {client_id}");
Config {
app_name: app_name.clone(),
app_version,
app_lang,
device_id,
device_type: args.device_type,
device_name: args
.name
.or_else(|| sysinfo::System::host_name().clone())
.unwrap_or_else(|| app_name.clone()),
interruptions: !args.no_interruptions,
normalization: args.normalize_volume,
initial_volume: args
.initial_volume
.map(|volume| Percentage::from_percent(volume as f32)),
hook: args.hook,
client_id,
user_agent,
credentials,
bf_secret,
eavesdrop: args.eavesdrop,
bind_address: args.bind.parse()?,
}
};
let player = Player::new(&config, args.device.as_deref().unwrap_or_default()).await?;
let mut client = remote::Client::new(&config, player)?;
let mut signals = signal::Handler::new()?;
loop {
tokio::select! {
biased;
signal = signals.recv() => {
match signal {
ShutdownSignal::Interrupt | ShutdownSignal::Terminate => {
info!("received {signal}, shutting down");
}
ShutdownSignal::Reload => {
info!("received {signal}, restarting client");
}
}
client.stop().await;
break Ok(signal);
}
result = async {
for (i, backoff) in Backoff::new(BACKOFF_ATTEMPTS, MIN_BACKOFF, MAX_BACKOFF).into_iter().enumerate() {
match client.start().await {
Ok(result) => return Ok(result),
Err(e) => {
match e.kind {
ErrorKind::PermissionDenied |
ErrorKind::ResourceExhausted |
ErrorKind::Unimplemented => {
return Err(e);
},
ErrorKind::DeadlineExceeded => {
warn!("{e}");
return Ok(());
}
_ => match backoff {
Some(duration) => {
error!("{e}; retrying in {duration:?} ({}/{BACKOFF_ATTEMPTS})", i+1);
tokio::time::sleep(duration).await;
}
None => return Err(e),
}
}
},
}
}
Ok(())
} => {
match result {
Ok(()) => { info!("restarting client"); }
Err(e) => break Err(e),
}
}
}
}
}
#[tokio::main]
async fn main() {
let args = Args::parse();
init_logger(&args);
debug!("Command {:#?}", args);
let cmd = command!();
let name = cmd.get_name().to_string();
let mut version = cmd.get_version().unwrap_or("UNKNOWN").to_string();
if let Some(hash) = option_env!("PLEEZER_COMMIT_HASH") {
version.push_str(&format!(".{hash}"));
}
if let Some(date) = option_env!("PLEEZER_COMMIT_DATE") {
version.push_str(&format!(" ({date})"));
}
info!("starting {name}/{version}; {BUILD_PROFILE}");
loop {
match run(args.clone()).await {
Ok(signal) => {
if signal == ShutdownSignal::Reload {
continue;
}
info!("shut down gracefully");
process::exit(0);
}
Err(e) => {
error!("{e}");
process::exit(1);
}
}
}
}