668 lines
23 KiB
Rust
668 lines
23 KiB
Rust
use std::process;
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
io::{BufRead, BufReader, Cursor, Read, Write},
|
|
net::TcpStream,
|
|
os::unix::net::UnixStream,
|
|
path::PathBuf,
|
|
sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
mpsc, Arc, Mutex,
|
|
},
|
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use clap::Parser;
|
|
use discord_presence::{
|
|
models::{ActivityAssets, ActivityTimestamps, ActivityType},
|
|
Client, Event,
|
|
};
|
|
use image::{GenericImageView, ImageFormat};
|
|
use mpd::Idle as _;
|
|
use mpd::{song::Id, Song, State, Subsystem};
|
|
use reqwest::blocking::Client as HttpClient;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct Config {
|
|
address: String,
|
|
client_id: u64,
|
|
small_image: Option<String>,
|
|
small_text: Option<String>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
Self {
|
|
address: "localhost:6600".into(),
|
|
client_id: 1464985070992363645,
|
|
small_image: None,
|
|
small_text: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Parser)]
|
|
struct Args {
|
|
#[arg(short, long)]
|
|
config: Option<PathBuf>,
|
|
}
|
|
|
|
struct Application {
|
|
mpdc: Arc<Mutex<mpd::Client>>,
|
|
client: Client,
|
|
config: Config,
|
|
album_art_cache: HashMap<String, AlbumArtCacheEntry>,
|
|
album_art_pending: HashSet<String>,
|
|
update_tx: mpsc::Sender<UpdateMessage>,
|
|
update_rx: mpsc::Receiver<UpdateMessage>,
|
|
running: Arc<AtomicBool>,
|
|
player_update_pending: Arc<AtomicBool>,
|
|
mpd_last_ok: Arc<Mutex<Instant>>,
|
|
mpd_disconnected: Arc<AtomicBool>,
|
|
mpd_connected: Arc<AtomicBool>,
|
|
}
|
|
|
|
const MPD_LOGO: &str = "https://www.musicpd.org/logo.png";
|
|
const PASTE_EXPIRY: &str = "4h";
|
|
const PASTE_TTL: Duration = Duration::from_secs(4 * 60 * 60);
|
|
|
|
#[derive(Debug)]
|
|
enum UpdateMessage {
|
|
Player,
|
|
AlbumArt {
|
|
cache_key: String,
|
|
cover_url: Option<String>,
|
|
ttl: Option<Duration>,
|
|
},
|
|
ClearActivity,
|
|
Stop,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AlbumArtCacheEntry {
|
|
cover_url: Option<String>,
|
|
expires_at: Option<Instant>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct AlbumArtFetch {
|
|
cover_url: Option<String>,
|
|
ttl: Option<Duration>,
|
|
}
|
|
|
|
enum MpdStream {
|
|
Tcp(TcpStream),
|
|
Unix(UnixStream),
|
|
}
|
|
|
|
impl Read for MpdStream {
|
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
|
match self {
|
|
MpdStream::Tcp(stream) => stream.read(buf),
|
|
MpdStream::Unix(stream) => stream.read(buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Write for MpdStream {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
match self {
|
|
MpdStream::Tcp(stream) => stream.write(buf),
|
|
MpdStream::Unix(stream) => stream.write(buf),
|
|
}
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
match self {
|
|
MpdStream::Tcp(stream) => stream.flush(),
|
|
MpdStream::Unix(stream) => stream.flush(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Application {
|
|
pub fn new(config: Config) -> Self {
|
|
let (update_tx, update_rx) = mpsc::channel();
|
|
let mut ret = Self {
|
|
mpdc: Arc::new(Mutex::new(
|
|
mpd::Client::connect(config.address.clone()).unwrap(),
|
|
)),
|
|
client: Client::new(config.client_id),
|
|
album_art_cache: HashMap::new(),
|
|
album_art_pending: HashSet::new(),
|
|
update_tx,
|
|
update_rx,
|
|
running: Arc::new(AtomicBool::new(true)),
|
|
player_update_pending: Arc::new(AtomicBool::new(false)),
|
|
mpd_last_ok: Arc::new(Mutex::new(Instant::now())),
|
|
mpd_disconnected: Arc::new(AtomicBool::new(false)),
|
|
mpd_connected: Arc::new(AtomicBool::new(true)),
|
|
config,
|
|
};
|
|
|
|
let _error = ret.client.on_error(|ctx| {
|
|
eprintln!("An error occured, {:?}", ctx.event);
|
|
});
|
|
|
|
ret.client.start();
|
|
ret.client.block_until_event(Event::Ready).unwrap();
|
|
let _ = ret.mpdc.lock().unwrap().idle(&[Subsystem::Player]);
|
|
assert!(Client::is_ready());
|
|
|
|
ret
|
|
}
|
|
|
|
fn tag(song: &Song, key: &str) -> Option<String> {
|
|
song.tags
|
|
.iter()
|
|
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
|
.map(|(_, v)| v.clone())
|
|
}
|
|
|
|
fn fetch_album_art(&mut self, song: &mpd::Song) -> Option<String> {
|
|
let cache_key = format!("file:{}", song.file);
|
|
|
|
if let Some(cached) = self.album_art_cache.get(&cache_key).cloned() {
|
|
if cached
|
|
.expires_at
|
|
.map_or(true, |expires_at| Instant::now() < expires_at)
|
|
{
|
|
return cached.cover_url;
|
|
}
|
|
self.album_art_cache.remove(&cache_key);
|
|
}
|
|
|
|
if self.album_art_pending.contains(&cache_key) {
|
|
return None;
|
|
}
|
|
|
|
self.album_art_pending.insert(cache_key.clone());
|
|
let mpd_address = self.config.address.clone();
|
|
let song_file = song.file.clone();
|
|
let tx = self.update_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let fetch = Self::fetch_album_art_blocking(&mpd_address, &song_file);
|
|
let _ = tx.send(UpdateMessage::AlbumArt {
|
|
cache_key,
|
|
cover_url: fetch.cover_url,
|
|
ttl: fetch.ttl,
|
|
});
|
|
});
|
|
|
|
None
|
|
}
|
|
|
|
fn http_client() -> Option<HttpClient> {
|
|
HttpClient::builder()
|
|
.user_agent("mpd-discord-presence")
|
|
.build()
|
|
.ok()
|
|
}
|
|
|
|
fn fetch_embedded_album_art(mpd_address: &str, song_file: &str) -> Option<Vec<u8>> {
|
|
Self::fetch_mpd_binary(mpd_address, "albumart", song_file)
|
|
.or_else(|| Self::fetch_mpd_binary(mpd_address, "readpicture", song_file))
|
|
}
|
|
|
|
fn upload_album_art_to_paste(data: &[u8]) -> Option<String> {
|
|
let client = Self::http_client()?;
|
|
let url = format!("https://paste.slendi.dev/?expiry={PASTE_EXPIRY}");
|
|
let response = client
|
|
.post(url)
|
|
.header("Content-Type", "application/octet-stream")
|
|
.body(data.to_vec())
|
|
.send()
|
|
.ok()?
|
|
.error_for_status()
|
|
.ok()?;
|
|
let response_text = response.text().ok()?;
|
|
let trimmed = response_text.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
Some(trimmed.to_string())
|
|
}
|
|
|
|
fn resize_album_art(data: &[u8]) -> Option<Vec<u8>> {
|
|
let image = image::load_from_memory(data).ok()?;
|
|
let (width, height) = image.dimensions();
|
|
if width <= 256 && height <= 256 {
|
|
return Some(data.to_vec());
|
|
}
|
|
|
|
let scale = 256.0 / width.max(height) as f32;
|
|
let new_width = ((width as f32) * scale).round().max(1.0) as u32;
|
|
let new_height = ((height as f32) * scale).round().max(1.0) as u32;
|
|
let resized = image.resize(new_width, new_height, image::imageops::FilterType::Lanczos3);
|
|
|
|
let mut output = Vec::new();
|
|
let mut cursor = Cursor::new(&mut output);
|
|
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
|
|
Some(output)
|
|
}
|
|
|
|
fn fetch_album_art_blocking(mpd_address: &str, song_file: &str) -> AlbumArtFetch {
|
|
if let Some(album_art) = Self::fetch_embedded_album_art(mpd_address, song_file) {
|
|
if let Some(resized) = Self::resize_album_art(&album_art) {
|
|
if let Some(cover_url) = Self::upload_album_art_to_paste(&resized) {
|
|
return AlbumArtFetch {
|
|
cover_url: Some(cover_url),
|
|
ttl: Some(PASTE_TTL),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
AlbumArtFetch {
|
|
cover_url: None,
|
|
ttl: None,
|
|
}
|
|
}
|
|
|
|
fn fetch_mpd_binary(mpd_address: &str, command: &str, song_file: &str) -> Option<Vec<u8>> {
|
|
let mut reader = Self::open_mpd_stream(mpd_address)?;
|
|
let mut greeting = String::new();
|
|
reader.read_line(&mut greeting).ok()?;
|
|
if !greeting.starts_with("OK MPD") {
|
|
return None;
|
|
}
|
|
|
|
let mut buffer = Vec::new();
|
|
let mut offset: usize = 0;
|
|
loop {
|
|
let quoted = Self::mpd_quote(song_file);
|
|
let command_line = format!("{command} {quoted} {offset}\n");
|
|
reader.get_mut().write_all(command_line.as_bytes()).ok()?;
|
|
reader.get_mut().flush().ok()?;
|
|
|
|
let mut size: Option<usize> = None;
|
|
let binary_len = loop {
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).ok()?;
|
|
if line.starts_with("ACK") {
|
|
return None;
|
|
}
|
|
let line = line.trim_end();
|
|
if line.is_empty() {
|
|
continue;
|
|
}
|
|
let mut parts = line.splitn(2, ": ");
|
|
let key = parts.next()?;
|
|
let value = parts.next().unwrap_or("");
|
|
match key {
|
|
"size" => {
|
|
size = value.parse::<usize>().ok();
|
|
}
|
|
"binary" => {
|
|
break value.parse::<usize>().ok()?;
|
|
}
|
|
_ => {}
|
|
}
|
|
};
|
|
|
|
let mut chunk = vec![0u8; binary_len];
|
|
reader.read_exact(&mut chunk).ok()?;
|
|
buffer.extend_from_slice(&chunk);
|
|
|
|
let mut line = String::new();
|
|
reader.read_line(&mut line).ok()?;
|
|
line.clear();
|
|
reader.read_line(&mut line).ok()?;
|
|
if line.starts_with("ACK") {
|
|
return None;
|
|
}
|
|
|
|
let size = size?;
|
|
if buffer.len() >= size {
|
|
break;
|
|
}
|
|
offset = buffer.len();
|
|
}
|
|
|
|
Some(buffer)
|
|
}
|
|
|
|
fn open_mpd_stream(mpd_address: &str) -> Option<BufReader<MpdStream>> {
|
|
if mpd_address.contains('/') {
|
|
let stream = UnixStream::connect(mpd_address).ok()?;
|
|
Some(BufReader::new(MpdStream::Unix(stream)))
|
|
} else {
|
|
let stream = TcpStream::connect(mpd_address).ok()?;
|
|
Some(BufReader::new(MpdStream::Tcp(stream)))
|
|
}
|
|
}
|
|
|
|
fn mpd_quote(value: &str) -> String {
|
|
let mut quoted = String::with_capacity(value.len() + 2);
|
|
quoted.push('"');
|
|
for ch in value.chars() {
|
|
if ch == '"' || ch == '\\' {
|
|
quoted.push('\\');
|
|
}
|
|
quoted.push(ch);
|
|
}
|
|
quoted.push('"');
|
|
quoted
|
|
}
|
|
|
|
fn run_update(&mut self) {
|
|
self.player_update_pending.store(false, Ordering::Relaxed);
|
|
let (status, song) = {
|
|
let mut mpdc = match self.mpdc.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
let status = match mpdc.status() {
|
|
Ok(status) => status,
|
|
Err(_) => {
|
|
if self.mpd_connected.swap(false, Ordering::Relaxed) {
|
|
eprintln!("Lost MPD connection.");
|
|
}
|
|
eprintln!("Connecting to MPD...");
|
|
let Ok(client) = mpd::Client::connect(self.config.address.clone()) else {
|
|
return;
|
|
};
|
|
*mpdc = client;
|
|
if !self.mpd_connected.swap(true, Ordering::Relaxed) {
|
|
eprintln!("Regained MPD connection.");
|
|
}
|
|
match mpdc.status() {
|
|
Ok(status) => status,
|
|
Err(_) => return,
|
|
}
|
|
}
|
|
};
|
|
let song = match mpdc.currentsong() {
|
|
Ok(song) => song,
|
|
Err(_) => {
|
|
if self.mpd_connected.swap(false, Ordering::Relaxed) {
|
|
eprintln!("Lost MPD connection.");
|
|
}
|
|
eprintln!("Connecting to MPD...");
|
|
let Ok(client) = mpd::Client::connect(self.config.address.clone()) else {
|
|
return;
|
|
};
|
|
*mpdc = client;
|
|
if !self.mpd_connected.swap(true, Ordering::Relaxed) {
|
|
eprintln!("Regained MPD connection.");
|
|
}
|
|
match mpdc.currentsong() {
|
|
Ok(song) => song,
|
|
Err(_) => return,
|
|
}
|
|
}
|
|
};
|
|
(status, song)
|
|
};
|
|
self.mpd_disconnected.store(false, Ordering::Relaxed);
|
|
if !self.mpd_connected.swap(true, Ordering::Relaxed) {
|
|
eprintln!("Gained MPD connection.");
|
|
}
|
|
if let Ok(mut last_ok) = self.mpd_last_ok.lock() {
|
|
*last_ok = Instant::now();
|
|
}
|
|
|
|
if song.is_none() && status.state == State::Play {
|
|
if !self.player_update_pending.swap(true, Ordering::Relaxed) {
|
|
let tx = self.update_tx.clone();
|
|
let pending = self.player_update_pending.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
pending.store(false, Ordering::Relaxed);
|
|
let _ = tx.send(UpdateMessage::Player);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
|
|
|
|
let start_dur: Duration;
|
|
let end_dur: Duration;
|
|
if let Some((position, end)) = status.time {
|
|
start_dur = now
|
|
.checked_sub(position)
|
|
.unwrap_or_else(|| std::time::Duration::from_secs(0));
|
|
|
|
end_dur = start_dur + end;
|
|
} else {
|
|
start_dur = std::time::Duration::from_secs(0);
|
|
end_dur = std::time::Duration::from_secs(0);
|
|
}
|
|
|
|
let started_ms = start_dur.as_millis() as u64;
|
|
let ends_ms = end_dur.as_millis() as u64;
|
|
|
|
if let Some(song) = song {
|
|
if status.state == State::Play {
|
|
let album = Self::tag(&song, "Album");
|
|
let album_art = self.fetch_album_art(&song);
|
|
let namae = format!(
|
|
"{} / {}",
|
|
song.artist.unwrap_or("Unknown".into()),
|
|
album.clone().unwrap_or("Unknown".into())
|
|
);
|
|
let _err = self.client.set_activity(|a| {
|
|
a.state(namae.clone())
|
|
.name(namae)
|
|
.details(song.title.unwrap_or("Unknown".into()))
|
|
.activity_type(ActivityType::Listening)
|
|
.timestamps(|_| ActivityTimestamps::new().start(started_ms).end(ends_ms))
|
|
.assets(|_| {
|
|
let mut assets = ActivityAssets::new()
|
|
.small_image(
|
|
self.config.small_image.clone().unwrap_or(MPD_LOGO.into()),
|
|
)
|
|
.small_text(
|
|
self.config
|
|
.small_text
|
|
.clone()
|
|
.unwrap_or("Music Player Daemon".into()),
|
|
);
|
|
if let Some(album_art) = album_art {
|
|
eprintln!("URL: {}", album_art);
|
|
assets = assets.large_image(album_art);
|
|
assets = assets.large_text(album.unwrap_or("Unknown".into()));
|
|
} else {
|
|
eprintln!("No album art URL found");
|
|
}
|
|
assets
|
|
})
|
|
});
|
|
} else {
|
|
let _err = self.client.clear_activity();
|
|
}
|
|
} else {
|
|
let _err = self.client.clear_activity();
|
|
}
|
|
}
|
|
|
|
fn run(&mut self) {
|
|
self.running.store(true, Ordering::Relaxed);
|
|
|
|
let running = self.running.clone();
|
|
let mpdc = self.mpdc.clone();
|
|
let mpd_address = self.config.address.clone();
|
|
let mpd_last_ok = self.mpd_last_ok.clone();
|
|
let mpd_disconnected = self.mpd_disconnected.clone();
|
|
let mpd_connected = self.mpd_connected.clone();
|
|
|
|
{
|
|
let tx = self.update_tx.clone();
|
|
let running = running.clone();
|
|
let mpd_address = mpd_address.clone();
|
|
let mpd_last_ok = mpd_last_ok.clone();
|
|
let mpd_disconnected = mpd_disconnected.clone();
|
|
let mpd_connected = mpd_connected.clone();
|
|
std::thread::spawn(move || {
|
|
let mut last_state: Option<State> = None;
|
|
let mut last_song_id: Option<Id> = None;
|
|
while running.load(Ordering::Relaxed) {
|
|
let idle_ok = {
|
|
let mut guard = match mpdc.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
guard.idle(&[Subsystem::Player]).is_ok()
|
|
};
|
|
if !idle_ok {
|
|
if mpd_connected.swap(false, Ordering::Relaxed) {
|
|
eprintln!("Lost MPD connection.");
|
|
}
|
|
eprintln!("Connecting to MPD...");
|
|
if let Ok(client) = mpd::Client::connect(mpd_address.clone()) {
|
|
if let Ok(mut guard) = mpdc.lock() {
|
|
*guard = client;
|
|
}
|
|
if !mpd_connected.swap(true, Ordering::Relaxed) {
|
|
eprintln!("Regained MPD connection.");
|
|
}
|
|
let _ = tx.send(UpdateMessage::Player);
|
|
}
|
|
let disconnected_for = mpd_last_ok
|
|
.lock()
|
|
.ok()
|
|
.map(|last_ok| last_ok.elapsed())
|
|
.unwrap_or_default();
|
|
if disconnected_for > Duration::from_secs(5)
|
|
&& !mpd_disconnected.swap(true, Ordering::Relaxed)
|
|
{
|
|
let _ = tx.send(UpdateMessage::ClearActivity);
|
|
}
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
continue;
|
|
}
|
|
if !running.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
|
|
let status = {
|
|
let mut guard = match mpdc.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
};
|
|
guard.status()
|
|
};
|
|
let status = match status {
|
|
Ok(status) => status,
|
|
Err(_) => {
|
|
if mpd_connected.swap(false, Ordering::Relaxed) {
|
|
eprintln!("Lost MPD connection.");
|
|
}
|
|
eprintln!("Connecting to MPD...");
|
|
if let Ok(client) = mpd::Client::connect(mpd_address.clone()) {
|
|
if let Ok(mut guard) = mpdc.lock() {
|
|
*guard = client;
|
|
}
|
|
if !mpd_connected.swap(true, Ordering::Relaxed) {
|
|
eprintln!("Regained MPD connection.");
|
|
}
|
|
let _ = tx.send(UpdateMessage::Player);
|
|
}
|
|
let disconnected_for = mpd_last_ok
|
|
.lock()
|
|
.ok()
|
|
.map(|last_ok| last_ok.elapsed())
|
|
.unwrap_or_default();
|
|
if disconnected_for > Duration::from_secs(5)
|
|
&& !mpd_disconnected.swap(true, Ordering::Relaxed)
|
|
{
|
|
let _ = tx.send(UpdateMessage::ClearActivity);
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
if !mpd_connected.swap(true, Ordering::Relaxed) {
|
|
eprintln!("Gained MPD connection.");
|
|
let _ = tx.send(UpdateMessage::Player);
|
|
}
|
|
if let Ok(mut last_ok) = mpd_last_ok.lock() {
|
|
*last_ok = Instant::now();
|
|
}
|
|
let song_id = status.song.map(|place| place.id);
|
|
|
|
if last_state != Some(status.state) || last_song_id != song_id {
|
|
last_state = Some(status.state);
|
|
last_song_id = song_id;
|
|
let _ = tx.send(UpdateMessage::Player);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
{
|
|
let tx = self.update_tx.clone();
|
|
let running = running.clone();
|
|
let last_ctrlc = Arc::new(Mutex::new(None::<Instant>));
|
|
let last_ctrlc_handler = last_ctrlc.clone();
|
|
ctrlc::set_handler(move || {
|
|
let mut last = last_ctrlc_handler
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
if let Some(previous) = *last {
|
|
if previous.elapsed() <= Duration::from_secs(2) {
|
|
eprintln!("Force quitting after second Ctrl-C.");
|
|
process::exit(1);
|
|
}
|
|
}
|
|
*last = Some(Instant::now());
|
|
running.store(false, Ordering::Relaxed);
|
|
let _ = tx.send(UpdateMessage::Stop);
|
|
})
|
|
.expect("Failed to set Ctrl-C handler");
|
|
}
|
|
|
|
self.run_update();
|
|
|
|
while self.running.load(Ordering::Relaxed) {
|
|
let Ok(message) = self.update_rx.recv() else {
|
|
break;
|
|
};
|
|
match message {
|
|
UpdateMessage::Player => self.run_update(),
|
|
UpdateMessage::AlbumArt {
|
|
cache_key,
|
|
cover_url,
|
|
ttl,
|
|
} => {
|
|
self.album_art_pending.remove(&cache_key);
|
|
let expires_at = ttl.map(|ttl| Instant::now() + ttl);
|
|
self.album_art_cache.insert(
|
|
cache_key,
|
|
AlbumArtCacheEntry {
|
|
cover_url,
|
|
expires_at,
|
|
},
|
|
);
|
|
self.run_update();
|
|
}
|
|
UpdateMessage::ClearActivity => {
|
|
let _ = self.client.clear_activity();
|
|
}
|
|
UpdateMessage::Stop => break,
|
|
}
|
|
}
|
|
|
|
self.run_update();
|
|
}
|
|
}
|
|
|
|
impl Drop for Application {
|
|
fn drop(&mut self) {
|
|
let _ = self.client.clear_activity();
|
|
}
|
|
}
|
|
|
|
fn main() {
|
|
let args = Args::parse();
|
|
let config = args
|
|
.config
|
|
.and_then(|path| std::fs::read_to_string(path).ok())
|
|
.and_then(|t| toml::from_str::<Config>(&t).ok())
|
|
.unwrap_or_default();
|
|
|
|
let mut app = Application::new(config);
|
|
app.run();
|
|
}
|