1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
use std::path::{Path, PathBuf};
use crate::error::Error;
use crate::model::Video;
use crate::model::format::{Format, FormatType};
use crate::model::selector::StoryboardQuality;
use crate::{DownloadStatus, Downloader};
impl Downloader {
/// Downloads all MHTML fragments of a storyboard format.
///
/// Each fragment is a grid of preview images for a contiguous time range.
/// Files are named `{video_id}_sb_{format_id}_{index}.mhtml` where `video_id` comes
/// from `format.video_id` when set (populated automatically when fetching via this library).
///
/// # Arguments
///
/// * `format` - A storyboard `Format` obtained via [`VideoSelection::best_storyboard_format`].
/// * `output_dir` - Directory where fragment files will be written.
///
/// # Returns
///
/// A vector of paths to the downloaded MHTML fragment files.
///
/// # Errors
///
/// Returns an error if the format is not a storyboard, if fragments are missing, or if any
/// fragment download fails.
pub async fn download_storyboard_format(
&self,
format: &Format,
output_dir: impl AsRef<Path>,
) -> crate::error::Result<Vec<PathBuf>> {
let output_dir = output_dir.as_ref();
if format.format_type() != FormatType::Storyboard {
return Err(Error::FormatNotAvailable {
video_id: format.video_id.clone().unwrap_or_default(),
format_type: FormatType::Storyboard,
available_formats: vec![format.format_id.clone()],
});
}
let fragments = format.storyboard_info.fragments.as_deref().unwrap_or_default();
// Use video_id when available (set by the library), fall back to format_id
let prefix = format.video_id.as_deref().unwrap_or(format.format_id.as_str());
tracing::debug!(
video_id = prefix,
format_id = %format.format_id,
fragment_count = fragments.len(),
resolution = ?format.video_resolution.resolution,
"🖼️ Downloading storyboard fragments"
);
let mut paths = Vec::with_capacity(fragments.len());
let mut download_ids = Vec::with_capacity(fragments.len());
for (index, fragment) in fragments.iter().enumerate() {
let filename = format!("{}_sb_{}_{:04}.mhtml", prefix, format.format_id, index);
let output_path = output_dir.join(&filename);
tracing::debug!(
index = index,
url = %fragment.url,
path = ?output_path,
"🖼️ Enqueuing storyboard fragment for download"
);
let id = self
.download_manager
.enqueue(
&fragment.url,
output_path.clone(),
Some(crate::download::DownloadPriority::Normal),
)
.await;
paths.push(output_path);
download_ids.push(id);
}
for id in download_ids {
match self.wait_for_download(id).await {
Some(DownloadStatus::Completed) => continue,
Some(DownloadStatus::Failed { reason }) => {
return Err(crate::error::Error::download_failed(
id,
format!("Storyboard fragment download failed: {}", reason),
));
}
Some(DownloadStatus::Canceled) => {
return Err(crate::error::Error::DownloadCancelled { download_id: id });
}
_ => {
return Err(crate::error::Error::download_failed(id, "Unexpected download status"));
}
}
}
tracing::info!(
video_id = prefix,
format_id = %format.format_id,
downloaded = paths.len(),
"✅ Storyboard fragments downloaded"
);
Ok(paths)
}
/// Downloads the storyboard of the requested quality for a video.
///
/// Selects the best or worst storyboard format via [`VideoSelection`] and delegates
/// to [`Downloader::download_storyboard_format`].
///
/// # Arguments
///
/// * `video` - The `Video` metadata struct.
/// * `quality` - [`StoryboardQuality::Best`] for highest resolution, [`StoryboardQuality::Worst`] for lowest.
/// * `output_dir` - Directory where fragment files will be written.
///
/// # Returns
///
/// A vector of paths to the downloaded MHTML fragment files.
///
/// # Errors
///
/// Returns an error if no storyboard formats are available or if a download fails.
pub async fn download_storyboard(
&self,
video: &Video,
quality: StoryboardQuality,
output_dir: impl AsRef<Path>,
) -> crate::error::Result<Vec<PathBuf>> {
tracing::debug!(
video_id = %video.id,
quality = ?quality,
"🖼️ Selecting storyboard format for download"
);
let format = match quality {
StoryboardQuality::Best => video.best_storyboard_format(),
StoryboardQuality::Worst => video.worst_storyboard_format(),
}
.ok_or_else(|| Error::FormatNotAvailable {
video_id: video.id.clone(),
format_type: FormatType::Storyboard,
available_formats: vec![],
})?;
self.download_storyboard_format(format, output_dir).await
}
}