use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use clap::Parser;
use colored::Colorize;
use console::Emoji;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use podpull::{
NoopReporter, ProgressEvent, ProgressReporter, ReqwestClient, SharedProgressReporter,
SyncOptions, sync_podcast,
};
static MICROPHONE: Emoji<'_, '_> = Emoji("๐๏ธ ", "");
static GLOBE: Emoji<'_, '_> = Emoji("๐ ", "[w] ");
static COG: Emoji<'_, '_> = Emoji("โ๏ธ ", "[*] ");
static SEARCH: Emoji<'_, '_> = Emoji("๐ ", "[~] ");
static HEADPHONES: Emoji<'_, '_> = Emoji("๐ง ", "[i] ");
static SAVING: Emoji<'_, '_> = Emoji("๐พ ", "[v] ");
static SUCCESS: Emoji<'_, '_> = Emoji("โ
", "[+] ");
static FAILURE: Emoji<'_, '_> = Emoji("โ ", "[!] ");
static PARTY: Emoji<'_, '_> = Emoji("๐ ", "[*] ");
static FOLDER: Emoji<'_, '_> = Emoji("๐ ", "");
static CROSS: Emoji<'_, '_> = Emoji("โ ", "x ");
static BROOM: Emoji<'_, '_> = Emoji("๐งน ", "[c] ");
#[derive(Parser, Debug)]
#[command(name = "podpull")]
#[command(about = "Download and synchronize podcasts from RSS feeds")]
#[command(version)]
struct Args {
feed: String,
output_dir: PathBuf,
#[arg(short = 'c', long, default_value = "3")]
concurrent: usize,
#[arg(short, long)]
limit: Option<usize>,
#[arg(short, long)]
quiet: bool,
}
struct IndicatifReporter {
multi: MultiProgress,
bars: Mutex<HashMap<usize, ProgressBar>>,
main_bar: ProgressBar,
}
impl IndicatifReporter {
fn new() -> Self {
let multi = MultiProgress::new();
let main_style = ProgressStyle::default_bar()
.template("{spinner:.green} {wide_msg}")
.unwrap();
let main_bar = multi.add(ProgressBar::new_spinner());
main_bar.set_style(main_style);
main_bar.enable_steady_tick(std::time::Duration::from_millis(100));
Self {
multi,
bars: Mutex::new(HashMap::new()),
main_bar,
}
}
fn get_or_create_bar(&self, download_id: usize) -> ProgressBar {
let mut bars = self.bars.lock().unwrap();
if let Some(bar) = bars.get(&download_id) {
return bar.clone();
}
let style = ProgressStyle::default_bar()
.template(&format!(
" {SAVING}[{{bar:30.cyan/blue}}] {{bytes}}/{{total_bytes}} {{wide_msg}}"
))
.unwrap()
.progress_chars("โโโ");
let bar = self.multi.add(ProgressBar::new(0));
bar.set_style(style);
bars.insert(download_id, bar.clone());
bar
}
fn finish_bar(&self, download_id: usize) {
let mut bars = self.bars.lock().unwrap();
if let Some(bar) = bars.remove(&download_id) {
bar.finish_and_clear();
}
}
}
impl ProgressReporter for IndicatifReporter {
fn report(&self, event: ProgressEvent) {
match event {
ProgressEvent::FetchingFeed { url } => {
self.main_bar
.set_message(format!("{GLOBE}Fetching feed: {}", url.cyan()));
}
ProgressEvent::ParsingFeed { source } => {
self.main_bar
.set_message(format!("{COG}Parsing feed: {}", source.cyan()));
}
ProgressEvent::ScanningDirectory {
files_scanned,
total_files,
} => {
if total_files == 0 {
self.main_bar
.set_message(format!("{SEARCH}Scanning existing episodes..."));
} else {
if files_scanned == 0 {
let scan_style = ProgressStyle::default_bar()
.template(&format!(
"{{spinner:.green}} {SEARCH}Scanning existing episodes... [{{bar:30.cyan/blue}}] {{pos}}/{{len}}"
))
.unwrap()
.progress_chars("โโโ");
self.main_bar.set_style(scan_style);
self.main_bar.set_length(total_files as u64);
}
self.main_bar.set_position(files_scanned as u64);
}
}
ProgressEvent::SyncPlanReady {
podcast_title,
total_episodes,
new_episodes,
to_download,
} => {
let main_style = ProgressStyle::default_bar()
.template("{spinner:.green} {wide_msg}")
.unwrap();
self.main_bar.set_style(main_style);
if new_episodes == to_download {
self.main_bar.set_message(format!(
"{HEADPHONES}{} โข {} total, {} new",
podcast_title.bold().green(),
total_episodes.to_string().cyan(),
new_episodes.to_string().yellow()
));
} else {
self.main_bar.set_message(format!(
"{HEADPHONES}{} โข {} total, {} new, downloading {}",
podcast_title.bold().green(),
total_episodes.to_string().cyan(),
new_episodes.to_string().yellow(),
to_download.to_string().green()
));
}
}
ProgressEvent::DownloadStarting {
download_id,
episode_title,
episode_index,
total_to_download,
content_length,
} => {
let bar = self.get_or_create_bar(download_id);
bar.set_length(content_length.unwrap_or(0));
bar.set_position(0);
let index_width =
(episode_index + 1).to_string().len() + total_to_download.to_string().len();
let title_width = available_title_width(index_width);
bar.set_message(format!(
"[{}/{}] {}",
(episode_index + 1).to_string().cyan(),
total_to_download.to_string().cyan(),
truncate_title(&episode_title, title_width)
));
}
ProgressEvent::DownloadProgress {
download_id,
bytes_downloaded,
total_bytes,
..
} => {
let bar = self.get_or_create_bar(download_id);
if let Some(total) = total_bytes {
bar.set_length(total);
}
bar.set_position(bytes_downloaded);
}
ProgressEvent::DownloadCompleted {
download_id,
episode_title,
bytes_downloaded,
} => {
let bar = self.get_or_create_bar(download_id);
bar.set_position(bytes_downloaded);
let title_width = available_title_width(0);
bar.set_message(format!(
"{SUCCESS}{}",
truncate_title(&episode_title, title_width).green()
));
self.finish_bar(download_id);
}
ProgressEvent::DownloadFailed {
download_id,
episode_title,
error,
} => {
let bar = self.get_or_create_bar(download_id);
let title_width = available_title_width(0).saturating_sub(3 + 30);
bar.abandon_with_message(format!(
"{FAILURE}{} - {}",
truncate_title(&episode_title, title_width.max(20)).red(),
error.red()
));
self.finish_bar(download_id);
}
ProgressEvent::Finalizing { .. } => {
}
ProgressEvent::HashingCompleted { .. } => {
}
ProgressEvent::PartialFilesCleanedUp { count } => {
if count > 0 {
self.main_bar.set_message(format!(
"{BROOM}Cleaned up {} interrupted download{}",
count.to_string().yellow(),
if count == 1 { "" } else { "s" }
));
}
}
ProgressEvent::SyncCompleted {
downloaded_count,
existing_count,
limited_count,
failed_count,
} => {
self.main_bar.finish_and_clear();
let mut parts = vec![
format!("{} downloaded", downloaded_count.to_string().green().bold()),
format!("{} existing", existing_count.to_string().yellow()),
];
if limited_count > 0 {
parts.push(format!("{} limited", limited_count.to_string().cyan()));
}
parts.push(if failed_count > 0 {
format!("{} failed", failed_count.to_string().red().bold())
} else {
format!("{} failed", failed_count.to_string().green())
});
println!(
"\n{PARTY}{} {}",
"Sync complete:".bold().green(),
parts.join(", ")
);
}
}
}
}
fn truncate_title(title: &str, max_len: usize) -> String {
if title.len() <= max_len {
title.to_string()
} else {
format!("{}...", &title[..max_len.saturating_sub(3)])
}
}
fn available_title_width(index_width: usize) -> usize {
let term_width = console::Term::stdout().size().1 as usize;
let fixed_width = 2 + 4 + 2 + 30 + 1 + 21 + 1 + index_width + 4 + 1;
term_width.saturating_sub(fixed_width).max(20) }
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
println!(
"\n{}{} {}\n",
MICROPHONE,
"podpull".bold().magenta(),
"- Podcast Downloader".dimmed()
);
let client = ReqwestClient::new();
let options = SyncOptions {
limit: args.limit,
max_concurrent: args.concurrent,
continue_on_error: true,
};
let reporter: SharedProgressReporter = if args.quiet {
NoopReporter::shared()
} else {
Arc::new(IndicatifReporter::new())
};
let result = sync_podcast(&client, &args.feed, &args.output_dir, &options, reporter)
.await
.context("Failed to sync podcast")?;
if !args.quiet && !result.failed_episodes.is_empty() {
println!("\n{}", "Failed episodes:".red().bold());
for (title, error) in &result.failed_episodes {
println!(
" {}{} - {}",
CROSS,
title.yellow(),
error.to_string().dimmed()
);
}
}
if !args.quiet {
println!(
"\n{FOLDER}Output: {}\n",
args.output_dir.display().to_string().cyan()
);
}
if result.failed > 0 && result.downloaded == 0 {
std::process::exit(1);
}
Ok(())
}