use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::client::Downloader;
use crate::client::stream_downloads::clip_stream;
use crate::client::streams::selection::VideoSelection;
use crate::download::engine::partial::PartialRange;
use crate::download::{DownloadPriority, DownloadStatus};
use crate::error::Result;
use crate::model::Video;
use crate::model::format::{Format, FormatType, HttpHeaders};
use crate::model::selector::{
AudioCodecPreference, AudioQuality, StoryboardQuality, ThumbnailQuality, VideoCodecPreference, VideoQuality,
};
struct EnqueuedDownloads {
video_id: u64,
audio_id: u64,
}
struct PartialClipStreams<'a> {
video_url: &'a str,
audio_url: &'a str,
video_format: &'a Format,
audio_format: &'a Format,
}
struct EnqueueStreams<'a> {
video_url: &'a str,
audio_url: &'a str,
video_path: PathBuf,
audio_path: PathBuf,
video_headers: HttpHeaders,
audio_headers: HttpHeaders,
}
pub struct DownloadBuilder<'a> {
pub(super) downloader: &'a Downloader,
pub(super) video: &'a Video,
pub(super) output: PathBuf,
pub(super) video_quality: Option<VideoQuality>,
pub(super) audio_quality: Option<AudioQuality>,
pub(super) video_codec: Option<VideoCodecPreference>,
pub(super) audio_codec: Option<AudioCodecPreference>,
pub(super) storyboard_quality: Option<StoryboardQuality>,
pub(super) thumbnail_quality: Option<ThumbnailQuality>,
pub(super) priority: DownloadPriority,
pub(super) progress_callback: Option<Box<dyn Fn(f64) + Send + Sync>>,
pub(super) partial_range: Option<PartialRange>,
}
impl<'a> DownloadBuilder<'a> {
pub fn new(downloader: &'a Downloader, video: &'a Video, output: impl Into<PathBuf>) -> Self {
let output = output.into();
tracing::debug!(
video_id = %video.id,
output = ?output,
"📥 Creating new DownloadBuilder"
);
Self {
downloader,
video,
output,
video_quality: None,
audio_quality: None,
video_codec: None,
audio_codec: None,
storyboard_quality: None,
thumbnail_quality: None,
priority: DownloadPriority::Normal,
progress_callback: None,
partial_range: None,
}
}
pub fn video_quality(mut self, quality: VideoQuality) -> Self {
self.video_quality = Some(quality);
self
}
pub fn audio_quality(mut self, quality: AudioQuality) -> Self {
self.audio_quality = Some(quality);
self
}
pub fn video_codec(mut self, codec: VideoCodecPreference) -> Self {
self.video_codec = Some(codec);
self
}
pub fn audio_codec(mut self, codec: AudioCodecPreference) -> Self {
self.audio_codec = Some(codec);
self
}
pub fn storyboard_quality(mut self, quality: StoryboardQuality) -> Self {
self.storyboard_quality = Some(quality);
self
}
pub fn thumbnail_quality(mut self, quality: ThumbnailQuality) -> Self {
self.thumbnail_quality = Some(quality);
self
}
pub fn priority(mut self, priority: DownloadPriority) -> Self {
self.priority = priority;
self
}
pub fn with_progress<F>(mut self, callback: F) -> Self
where
F: Fn(f64) + Send + Sync + 'static,
{
self.progress_callback = Some(Box::new(callback));
self
}
pub fn partial(mut self, range: PartialRange) -> Self {
self.partial_range = Some(range);
self
}
pub fn time_range(self, start: f64, end: f64) -> Result<Self> {
Ok(self.partial(PartialRange::time_range(start, end)?))
}
pub fn chapter(self, index: usize) -> Self {
self.partial(PartialRange::single_chapter(index))
}
pub fn chapters(self, start: usize, end: usize) -> Result<Self> {
Ok(self.partial(PartialRange::chapter_range(start, end)?))
}
pub async fn execute(self) -> Result<PathBuf> {
let video_quality = self.video_quality.unwrap_or(VideoQuality::Best);
let audio_quality = self.audio_quality.unwrap_or(AudioQuality::Best);
let video_codec = self.video_codec.unwrap_or(VideoCodecPreference::Any);
let audio_codec = self.audio_codec.unwrap_or(AudioCodecPreference::Any);
tracing::debug!(
video_id = %self.video.id,
output = ?self.output,
video_quality = ?video_quality,
audio_quality = ?audio_quality,
video_codec = ?video_codec,
audio_codec = ?audio_codec,
priority = ?self.priority,
has_progress_callback = self.progress_callback.is_some(),
has_partial_range = self.partial_range.is_some(),
"📥 Executing download"
);
let video_format = self
.video
.select_video_format(video_quality, video_codec.clone())
.ok_or_else(|| Self::format_not_available(self.video, FormatType::Video))?;
let audio_format = self
.video
.select_audio_format(audio_quality, audio_codec.clone())
.ok_or_else(|| Self::format_not_available(self.video, FormatType::Audio))?;
tracing::debug!(
video_format_id = %video_format.format_id,
audio_format_id = %audio_format.format_id,
video_ext = ?video_format.download_info.ext,
audio_ext = ?audio_format.download_info.ext,
"📥 Selected video and audio formats"
);
let video_ext = video_format.download_info.ext.as_str();
let video_filename = format!("temp_video_{}.{}", crate::utils::fs::random_filename(8), video_ext);
let audio_ext = audio_format.download_info.ext.as_str();
let audio_filename = format!("temp_audio_{}.{}", crate::utils::fs::random_filename(8), audio_ext);
let video_url = video_format
.download_info
.url
.as_ref()
.ok_or_else(|| Self::format_no_url(&self.video.id, &video_format.format_id))?;
let audio_url = audio_format
.download_info
.url
.as_ref()
.ok_or_else(|| Self::format_no_url(&self.video.id, &audio_format.format_id))?;
let streams = PartialClipStreams {
video_url,
audio_url,
video_format,
audio_format,
};
if let Some(range) = self.partial_range.as_ref()
&& let Some(path) = try_partial_clip(self.downloader, self.video, &streams, range, &self.output).await?
{
return Ok(path);
}
let video_path = self.downloader.output_dir.join(&video_filename);
let audio_path = self.downloader.output_dir.join(&audio_filename);
let enqueue_streams = EnqueueStreams {
video_url: streams.video_url,
audio_url: streams.audio_url,
video_path: video_path.clone(),
audio_path: audio_path.clone(),
video_headers: streams.video_format.download_info.http_headers.clone(),
audio_headers: streams.audio_format.download_info.http_headers.clone(),
};
let enqueued =
enqueue_both_downloads(self.downloader, enqueue_streams, self.priority, self.progress_callback).await;
let video_download_id = enqueued.video_id;
let audio_download_id = enqueued.audio_id;
tracing::debug!(
video_download_id = video_download_id,
audio_download_id = audio_download_id,
"📥 Waiting for downloads to complete"
);
let (video_status, audio_status) = tokio::join!(
self.downloader.wait_for_download(video_download_id),
self.downloader.wait_for_download(audio_download_id),
);
match (video_status, audio_status) {
(Some(DownloadStatus::Completed), Some(DownloadStatus::Completed)) => {
tracing::debug!(
output = ?self.output,
"✅ Both downloads completed, combining audio and video"
);
let combined_path = if self.output.is_absolute() {
self.downloader
.combine_audio_and_video_to_path(&audio_path, &video_path, &self.output)
.await?
} else {
let output_str = self
.output
.to_str()
.ok_or_else(|| crate::error::Error::PathValidation {
path: self.output.clone(),
reason: "output path contains invalid UTF-8".into(),
})?;
self.downloader
.combine_audio_and_video(&audio_filename, &video_filename, output_str)
.await?
};
if let Some(range) = self.partial_range {
let time_range = if range.needs_chapter_metadata() {
range
.to_time_range(&self.video.chapters)
.ok_or_else(|| crate::error::Error::invalid_partial_range("chapter index out of bounds"))?
} else {
range
};
let (start_secs, end_secs) = time_range.get_times().ok_or_else(|| {
crate::error::Error::invalid_partial_range("could not resolve time boundaries for trim")
})?;
let trimmed_name = format!(
"trimmed_{}.{}",
crate::utils::fs::random_filename(8),
combined_path.extension().and_then(|e| e.to_str()).unwrap_or("mp4")
);
let trimmed_path = self.downloader.output_dir.join(&trimmed_name);
self.downloader
.extract_time_range(&combined_path, &trimmed_path, start_secs, end_secs)
.await?;
tokio::fs::rename(&trimmed_path, &combined_path)
.await
.map_err(|e| crate::error::Error::io_with_path("renaming trimmed output", &combined_path, e))?;
}
Ok(combined_path)
}
(Some(DownloadStatus::Failed { reason }), _) => Err(crate::error::Error::download_failed(
video_download_id,
format!("Video download failed: {}", reason),
)),
(_, Some(DownloadStatus::Failed { reason })) => Err(crate::error::Error::download_failed(
audio_download_id,
format!("Audio download failed: {}", reason),
)),
(Some(DownloadStatus::Canceled), _) => Err(crate::error::Error::DownloadCancelled {
download_id: video_download_id,
}),
(_, Some(DownloadStatus::Canceled)) => Err(crate::error::Error::DownloadCancelled {
download_id: audio_download_id,
}),
_ => Err(crate::error::Error::download_failed(
video_download_id,
"Unexpected download status",
)),
}
}
}
async fn try_partial_clip(
downloader: &Downloader,
video: &Video,
streams: &PartialClipStreams<'_>,
range: &PartialRange,
output: &Path,
) -> Result<Option<PathBuf>> {
let time_range = if range.needs_chapter_metadata() {
range
.to_time_range(&video.chapters)
.ok_or_else(|| crate::error::Error::invalid_partial_range("chapter index out of bounds"))?
} else {
range.clone()
};
let Some((start_secs, end_secs)) = time_range.get_times() else {
return Ok(None);
};
let video_total_size = streams
.video_format
.file_info
.filesize
.or(streams.video_format.file_info.filesize_approx)
.filter(|&n| n > 0)
.map(|n| n as u64);
let audio_total_size = streams
.audio_format
.file_info
.filesize
.or(streams.audio_format.file_info.filesize_approx)
.filter(|&n| n > 0)
.map(|n| n as u64);
let video_clip_filename = format!(
"clip_video_{}.{}",
crate::utils::fs::random_filename(8),
streams.video_format.download_info.ext.as_str()
);
let video_clip_path = downloader.output_dir.join(&video_clip_filename);
let audio_clip_filename = format!(
"clip_audio_{}.{}",
crate::utils::fs::random_filename(8),
streams.audio_format.download_info.ext.as_str()
);
let audio_clip_path = downloader.output_dir.join(&audio_clip_filename);
let video_result = clip_stream(
downloader,
streams.video_url,
&streams.video_format.download_info.http_headers,
video_total_size,
start_secs,
end_secs,
&video_clip_path,
)
.await;
match video_result {
Ok(()) => {}
Err(media_seek::Error::UnsupportedFormat | media_seek::Error::ParseFailed { .. }) => {
tracing::warn!("media-seek video clip unavailable for this format, falling back to full download");
let _ = tokio::fs::remove_file(&video_clip_path).await;
let _ = tokio::fs::remove_file(&audio_clip_path).await;
return Ok(None);
}
Err(e) => {
let _ = tokio::fs::remove_file(&video_clip_path).await;
let _ = tokio::fs::remove_file(&audio_clip_path).await;
return Err(e.into());
}
}
let audio_result = clip_stream(
downloader,
streams.audio_url,
&streams.audio_format.download_info.http_headers,
audio_total_size,
start_secs,
end_secs,
&audio_clip_path,
)
.await;
match audio_result {
Ok(()) => {}
Err(media_seek::Error::UnsupportedFormat | media_seek::Error::ParseFailed { .. }) => {
tracing::warn!("media-seek audio clip unavailable for this format, falling back to full download");
let _ = tokio::fs::remove_file(&video_clip_path).await;
let _ = tokio::fs::remove_file(&audio_clip_path).await;
return Ok(None);
}
Err(e) => {
let _ = tokio::fs::remove_file(&video_clip_path).await;
let _ = tokio::fs::remove_file(&audio_clip_path).await;
return Err(e.into());
}
}
tracing::info!(
start_secs,
end_secs,
"✅ media-seek partial download succeeded, combining streams"
);
let output_path = if output.is_absolute() {
output.to_path_buf()
} else {
downloader.output_dir.join(output)
};
let combined_path = downloader
.combine_audio_and_video_to_path(&audio_clip_path, &video_clip_path, &output_path)
.await?;
let trimmed_name = format!(
"trimmed_{}.{}",
crate::utils::fs::random_filename(8),
combined_path.extension().and_then(|e| e.to_str()).unwrap_or("mp4")
);
let trimmed_path = downloader.output_dir.join(&trimmed_name);
downloader
.extract_time_range(&combined_path, &trimmed_path, start_secs, end_secs)
.await?;
tokio::fs::rename(&trimmed_path, &combined_path)
.await
.map_err(|e| crate::error::Error::io_with_path("renaming trimmed output", &combined_path, e))?;
Ok(Some(combined_path))
}
async fn enqueue_both_downloads(
downloader: &Downloader,
streams: EnqueueStreams<'_>,
priority: DownloadPriority,
callback: Option<Box<dyn Fn(f64) + Send + Sync>>,
) -> EnqueuedDownloads {
if let Some(callback) = callback {
let callback = Arc::new(callback);
let video_callback = {
let callback = Arc::clone(&callback);
move |downloaded: u64, total: u64| {
if total > 0 {
let progress = (downloaded as f64 / total as f64) * 0.5;
callback(progress);
}
}
};
let audio_callback = {
let callback = Arc::clone(&callback);
move |downloaded: u64, total: u64| {
if total > 0 {
let progress = 0.5 + (downloaded as f64 / total as f64) * 0.5;
callback(progress);
}
}
};
let video_id = downloader
.download_manager
.enqueue_with_progress_and_headers(
streams.video_url,
streams.video_path,
Some(priority),
video_callback,
Some(streams.video_headers),
)
.await;
let audio_id = downloader
.download_manager
.enqueue_with_progress_and_headers(
streams.audio_url,
streams.audio_path,
Some(priority),
audio_callback,
Some(streams.audio_headers),
)
.await;
EnqueuedDownloads { video_id, audio_id }
} else {
let video_id = downloader
.download_manager
.enqueue_with_headers(
streams.video_url,
streams.video_path,
Some(priority),
Some(streams.video_headers),
)
.await;
let audio_id = downloader
.download_manager
.enqueue_with_headers(
streams.audio_url,
streams.audio_path,
Some(priority),
Some(streams.audio_headers),
)
.await;
EnqueuedDownloads { video_id, audio_id }
}
}