use crate::error::{BooruError, Result};
use crate::model::Post;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
#[derive(Debug, Clone, Default)]
pub struct DownloadOptions {
pub overwrite: bool,
pub filename_template: Option<String>,
pub organize_by_rating: bool,
}
impl DownloadOptions {
#[must_use]
pub fn overwrite(mut self) -> Self {
self.overwrite = true;
self
}
#[must_use]
pub fn filename(mut self, template: impl Into<String>) -> Self {
self.filename_template = Some(template.into());
self
}
#[must_use]
pub fn organize_by_rating(mut self) -> Self {
self.organize_by_rating = true;
self
}
}
#[derive(Debug, Clone)]
pub struct DownloadResult {
pub path: PathBuf,
pub size: u64,
pub skipped: bool,
}
#[derive(Debug, Clone)]
pub struct DownloadProgress {
pub total: Option<u64>,
pub downloaded: u64,
pub post_id: u32,
}
pub type ProgressCallback = Box<dyn Fn(DownloadProgress) + Send + Sync>;
#[derive(Clone)]
pub struct Downloader {
client: reqwest::Client,
options: DownloadOptions,
}
impl std::fmt::Debug for Downloader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Downloader")
.field("options", &self.options)
.finish()
}
}
impl Default for Downloader {
fn default() -> Self {
Self::new()
}
}
impl Downloader {
#[must_use]
pub fn new() -> Self {
Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.expect("Failed to create HTTP client"),
options: DownloadOptions::default(),
}
}
#[must_use]
pub fn with_client(client: reqwest::Client) -> Self {
Self {
client,
options: DownloadOptions::default(),
}
}
#[must_use]
pub fn options(mut self, options: DownloadOptions) -> Self {
self.options = options;
self
}
#[must_use]
pub fn with_timeout(self, timeout: std::time::Duration) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(timeout)
.build()
.expect("Failed to create HTTP client"),
options: self.options,
}
}
pub async fn download_url(
&self,
url: &str,
dest_dir: &Path,
filename: Option<&str>,
) -> Result<DownloadResult> {
let filename = match filename {
Some(f) => f.to_string(),
None => url
.rsplit('/')
.next()
.ok_or_else(|| BooruError::InvalidUrl(url.to_string()))?
.to_string(),
};
let dest_path = dest_dir.join(&filename);
if dest_path.exists() && !self.options.overwrite {
let metadata = tokio::fs::metadata(&dest_path).await?;
return Ok(DownloadResult {
path: dest_path,
size: metadata.len(),
skipped: true,
});
}
tokio::fs::create_dir_all(dest_dir).await?;
let response = self
.client
.get(url)
.send()
.await?
.error_for_status()
.map_err(BooruError::Request)?;
let bytes = response.bytes().await?;
let size = bytes.len() as u64;
let mut file = tokio::fs::File::create(&dest_path).await?;
file.write_all(&bytes).await?;
file.flush().await?;
Ok(DownloadResult {
path: dest_path,
size,
skipped: false,
})
}
pub async fn download_url_with_progress<F>(
&self,
url: &str,
dest_dir: &Path,
filename: Option<&str>,
post_id: u32,
on_progress: F,
) -> Result<DownloadResult>
where
F: Fn(DownloadProgress) + Send,
{
let filename = match filename {
Some(f) => f.to_string(),
None => url
.rsplit('/')
.next()
.ok_or_else(|| BooruError::InvalidUrl(url.to_string()))?
.to_string(),
};
let dest_path = dest_dir.join(&filename);
if dest_path.exists() && !self.options.overwrite {
let metadata = tokio::fs::metadata(&dest_path).await?;
return Ok(DownloadResult {
path: dest_path,
size: metadata.len(),
skipped: true,
});
}
tokio::fs::create_dir_all(dest_dir).await?;
let response = self
.client
.get(url)
.send()
.await?
.error_for_status()
.map_err(BooruError::Request)?;
let total = response.content_length();
let mut downloaded: u64 = 0;
let mut file = tokio::fs::File::create(&dest_path).await?;
let mut stream = response.bytes_stream();
use futures_core::Stream;
use std::pin::Pin;
use std::task::Context;
let mut stream = Pin::new(&mut stream);
loop {
let chunk =
std::future::poll_fn(|cx: &mut Context<'_>| stream.as_mut().poll_next(cx)).await;
match chunk {
Some(Ok(bytes)) => {
file.write_all(&bytes).await?;
downloaded += bytes.len() as u64;
on_progress(DownloadProgress {
total,
downloaded,
post_id,
});
}
Some(Err(e)) => return Err(BooruError::Request(e)),
None => break,
}
}
file.flush().await?;
Ok(DownloadResult {
path: dest_path,
size: downloaded,
skipped: false,
})
}
pub async fn download_post(&self, post: &impl Post, dest_dir: &Path) -> Result<DownloadResult> {
let url = post
.file_url()
.ok_or_else(|| BooruError::InvalidUrl("Post has no file URL".to_string()))?;
let filename = self.generate_filename(post, url);
self.download_url(url, dest_dir, Some(&filename)).await
}
pub async fn download_post_with_progress<F>(
&self,
post: &impl Post,
dest_dir: &Path,
on_progress: F,
) -> Result<DownloadResult>
where
F: Fn(DownloadProgress) + Send,
{
let url = post
.file_url()
.ok_or_else(|| BooruError::InvalidUrl("Post has no file URL".to_string()))?;
let filename = self.generate_filename(post, url);
self.download_url_with_progress(url, dest_dir, Some(&filename), post.id(), on_progress)
.await
}
pub async fn download_posts(
&self,
posts: &[impl Post + Sync],
dest_dir: &Path,
concurrency: usize,
) -> Vec<Result<DownloadResult>> {
use std::sync::Arc;
use tokio::sync::Semaphore;
let semaphore = Arc::new(Semaphore::new(concurrency));
let mut handles = Vec::with_capacity(posts.len());
for post in posts {
let permit = semaphore.clone().acquire_owned().await.unwrap();
let url = post.file_url().map(|s| s.to_string());
let id = post.id();
let filename = url.as_ref().map(|u| self.generate_filename(post, u));
let dest = dest_dir.to_path_buf();
let client = self.client.clone();
let options = self.options.clone();
handles.push(tokio::spawn(async move {
let _permit = permit;
let url = url.ok_or_else(|| {
BooruError::InvalidUrl(format!("Post {} has no file URL", id))
})?;
let filename = filename.unwrap();
let dest_path = dest.join(&filename);
if dest_path.exists() && !options.overwrite {
let metadata = tokio::fs::metadata(&dest_path).await?;
return Ok(DownloadResult {
path: dest_path,
size: metadata.len(),
skipped: true,
});
}
tokio::fs::create_dir_all(&dest).await?;
let response = client
.get(&url)
.send()
.await?
.error_for_status()
.map_err(BooruError::Request)?;
let bytes = response.bytes().await?;
let size = bytes.len() as u64;
let mut file = tokio::fs::File::create(&dest_path).await?;
file.write_all(&bytes).await?;
file.flush().await?;
Ok(DownloadResult {
path: dest_path,
size,
skipped: false,
})
}));
}
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
results.push(
handle.await.unwrap_or_else(|e| {
Err(BooruError::InvalidUrl(format!("Task panicked: {}", e)))
}),
);
}
results
}
fn generate_filename(&self, post: &impl Post, url: &str) -> String {
let ext = url
.rsplit('.')
.next()
.unwrap_or("jpg")
.split('?')
.next()
.unwrap_or("jpg");
if let Some(template) = &self.options.filename_template {
let mut filename = template.clone();
filename = filename.replace("{id}", &post.id().to_string());
filename = filename.replace("{md5}", post.md5().unwrap_or("unknown"));
filename = filename.replace("{ext}", ext);
filename
} else {
format!("{}.{}", post.id(), ext)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_download_options_default() {
let opts = DownloadOptions::default();
assert!(!opts.overwrite);
assert!(opts.filename_template.is_none());
}
#[test]
fn test_download_options_builder() {
let opts = DownloadOptions::default()
.overwrite()
.filename("{id}_{md5}.{ext}".to_string());
assert!(opts.overwrite);
assert!(opts.filename_template.is_some());
}
}