feat: downloader: add audio tagging
This commit is contained in:
parent
e608811e5f
commit
1e1315a837
7 changed files with 323 additions and 29 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"rust-analyzer.cargo.features": ["rss"]
|
"rust-analyzer.cargo.features": ["rss", "indicatif", "audiotag"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,10 @@ tracing-test = "0.2.5"
|
||||||
|
|
||||||
# Included crates
|
# Included crates
|
||||||
rustypipe = { path = ".", version = "0.2.0", default-features = false }
|
rustypipe = { path = ".", version = "0.2.0", default-features = false }
|
||||||
rustypipe-downloader = { path = "./downloader", version = "0.1.0", default-features = false }
|
rustypipe-downloader = { path = "./downloader", version = "0.1.0", default-features = false, features = [
|
||||||
|
"indicatif",
|
||||||
|
"audiotag",
|
||||||
|
] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["default-tls"]
|
default = ["default-tls"]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
# 
|
# 
|
||||||
|
|
||||||
|
[](https://crates.io/crates/smartcrop2)
|
||||||
|
[](http://opensource.org/licenses/MIT)
|
||||||
|
[](https://code.thetadev.de/ThetaDev/rustypipe/actions/?workflow=ci.yaml)
|
||||||
|
|
||||||
Rust client for the public YouTube / YouTube Music API (Innertube), inspired by
|
Rust client for the public YouTube / YouTube Music API (Innertube), inspired by
|
||||||
[NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
[NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,8 @@ async fn download_video(
|
||||||
.rustypipe(rp)
|
.rustypipe(rp)
|
||||||
.stream_filter(filter)
|
.stream_filter(filter)
|
||||||
.progress_bar(multi)
|
.progress_bar(multi)
|
||||||
|
.audio_tag()
|
||||||
|
.crop_cover()
|
||||||
.build();
|
.build();
|
||||||
let mut q = target.apply(dl.download_id(id));
|
let mut q = target.apply(dl.download_id(id));
|
||||||
if let Some(player_type) = player_type {
|
if let Some(player_type) = player_type {
|
||||||
|
|
@ -361,6 +363,8 @@ async fn download_videos(
|
||||||
.rustypipe(rp)
|
.rustypipe(rp)
|
||||||
.stream_filter(filter)
|
.stream_filter(filter)
|
||||||
.progress_bar(multi.clone())
|
.progress_bar(multi.clone())
|
||||||
|
.audio_tag()
|
||||||
|
.crop_cover()
|
||||||
.path_precheck()
|
.path_precheck()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ rustls-tls-native-roots = [
|
||||||
"rustypipe/rustls-tls-native-roots",
|
"rustypipe/rustls-tls-native-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
audiotag = ["dep:lofty", "dep:image", "dep:smartcrop2"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
rustypipe.workspace = true
|
rustypipe.workspace = true
|
||||||
once_cell.workspace = true
|
once_cell.workspace = true
|
||||||
|
|
@ -39,6 +41,10 @@ futures.workspace = true
|
||||||
reqwest = { workspace = true, features = ["stream"] }
|
reqwest = { workspace = true, features = ["stream"] }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "fs", "process"] }
|
tokio = { workspace = true, features = ["macros", "fs", "process"] }
|
||||||
indicatif.workspace = true
|
indicatif = { workspace = true, optional = true }
|
||||||
filenamify.workspace = true
|
filenamify.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
lofty = { version = "0.21.0", optional = true }
|
||||||
|
image = { version = "0.25.0", optional = true }
|
||||||
|
smartcrop2 = { version = "0.2.0", optional = true }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
cmp::Ordering,
|
cmp::Ordering,
|
||||||
ffi::OsString,
|
ffi::OsString,
|
||||||
|
io::Cursor,
|
||||||
ops::Range,
|
ops::Range,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
|
@ -15,7 +16,6 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use futures::stream::{self, StreamExt};
|
use futures::stream::{self, StreamExt};
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
@ -23,17 +23,25 @@ use reqwest::{header, Client, StatusCode};
|
||||||
use rustypipe::{
|
use rustypipe::{
|
||||||
client::{ClientType, RustyPipe},
|
client::{ClientType, RustyPipe},
|
||||||
model::{
|
model::{
|
||||||
|
richtext::ToPlaintext,
|
||||||
traits::{FileFormat, YtEntity},
|
traits::{FileFormat, YtEntity},
|
||||||
AudioCodec, VideoCodec, VideoPlayer,
|
AudioCodec, TrackItem, VideoCodec, VideoDetails, VideoPlayer,
|
||||||
},
|
},
|
||||||
param::StreamFilter,
|
param::StreamFilter,
|
||||||
};
|
};
|
||||||
|
use time::{Date, OffsetDateTime};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs::{self, File},
|
fs::{self, File},
|
||||||
io::AsyncWriteExt,
|
io::AsyncWriteExt,
|
||||||
process::Command,
|
process::Command,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
use lofty::{config::WriteOptions, picture::Picture, prelude::*, tag::Tag};
|
||||||
|
|
||||||
pub use util::DownloadError;
|
pub use util::DownloadError;
|
||||||
|
|
||||||
type Result<T> = core::result::Result<T, DownloadError>;
|
type Result<T> = core::result::Result<T, DownloadError>;
|
||||||
|
|
@ -54,11 +62,16 @@ pub struct Downloader {
|
||||||
pub struct DownloaderBuilder {
|
pub struct DownloaderBuilder {
|
||||||
rp: Option<RustyPipe>,
|
rp: Option<RustyPipe>,
|
||||||
ffmpeg: String,
|
ffmpeg: String,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
multi: Option<MultiProgress>,
|
multi: Option<MultiProgress>,
|
||||||
filter: StreamFilter,
|
filter: StreamFilter,
|
||||||
video_format: DownloadVideoFormat,
|
video_format: DownloadVideoFormat,
|
||||||
n_retries: u32,
|
n_retries: u32,
|
||||||
path_precheck: bool,
|
path_precheck: bool,
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
audio_tag: bool,
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
crop_cover: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DownloaderInner {
|
struct DownloaderInner {
|
||||||
|
|
@ -69,6 +82,7 @@ struct DownloaderInner {
|
||||||
/// Path to the ffmpeg binary
|
/// Path to the ffmpeg binary
|
||||||
ffmpeg: String,
|
ffmpeg: String,
|
||||||
/// Global progress
|
/// Global progress
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
multi: Option<MultiProgress>,
|
multi: Option<MultiProgress>,
|
||||||
/// Default stream filter
|
/// Default stream filter
|
||||||
filter: StreamFilter,
|
filter: StreamFilter,
|
||||||
|
|
@ -78,6 +92,12 @@ struct DownloaderInner {
|
||||||
n_retries: u32,
|
n_retries: u32,
|
||||||
/// Check if destination path exists before player is fetched
|
/// Check if destination path exists before player is fetched
|
||||||
path_precheck: bool,
|
path_precheck: bool,
|
||||||
|
/// Apply metadata to audio files
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
audio_tag: bool,
|
||||||
|
/// Crop YT thumbnails to ensure square album covers
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
crop_cover: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download query
|
/// Download query
|
||||||
|
|
@ -89,6 +109,7 @@ pub struct DownloadQuery {
|
||||||
/// Destination
|
/// Destination
|
||||||
dest: DownloadDest,
|
dest: DownloadDest,
|
||||||
/// Progress bar
|
/// Progress bar
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
multi: Option<MultiProgress>,
|
multi: Option<MultiProgress>,
|
||||||
/// Stream filter
|
/// Stream filter
|
||||||
filter: Option<StreamFilter>,
|
filter: Option<StreamFilter>,
|
||||||
|
|
@ -200,11 +221,16 @@ impl Default for DownloaderBuilder {
|
||||||
Self {
|
Self {
|
||||||
rp: None,
|
rp: None,
|
||||||
ffmpeg: "ffmpeg".to_owned(),
|
ffmpeg: "ffmpeg".to_owned(),
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
multi: None,
|
multi: None,
|
||||||
filter: StreamFilter::new(),
|
filter: StreamFilter::new(),
|
||||||
video_format: DownloadVideoFormat::Mp4,
|
video_format: DownloadVideoFormat::Mp4,
|
||||||
n_retries: 3,
|
n_retries: 3,
|
||||||
path_precheck: false,
|
path_precheck: false,
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
audio_tag: false,
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
crop_cover: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,6 +261,7 @@ impl DownloaderBuilder {
|
||||||
|
|
||||||
/// Set the indicatif [`MultiProgress`] used to show download progress
|
/// Set the indicatif [`MultiProgress`] used to show download progress
|
||||||
/// for all downloads
|
/// for all downloads
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn progress_bar(mut self, progress: MultiProgress) -> Self {
|
pub fn progress_bar(mut self, progress: MultiProgress) -> Self {
|
||||||
self.multi = Some(progress);
|
self.multi = Some(progress);
|
||||||
|
|
@ -277,6 +304,22 @@ impl DownloaderBuilder {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable audio tagging
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
#[must_use]
|
||||||
|
pub fn audio_tag(mut self) -> Self {
|
||||||
|
self.audio_tag = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crop YouTube thumbnails to get square album covers
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
#[must_use]
|
||||||
|
pub fn crop_cover(mut self) -> Self {
|
||||||
|
self.crop_cover = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new, configured [`Downloader`] instance
|
/// Create a new, configured [`Downloader`] instance
|
||||||
pub fn build(self) -> Downloader {
|
pub fn build(self) -> Downloader {
|
||||||
self.build_with_client(
|
self.build_with_client(
|
||||||
|
|
@ -294,11 +337,16 @@ impl DownloaderBuilder {
|
||||||
rp: self.rp.unwrap_or_default(),
|
rp: self.rp.unwrap_or_default(),
|
||||||
http: http_client,
|
http: http_client,
|
||||||
ffmpeg: self.ffmpeg,
|
ffmpeg: self.ffmpeg,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
multi: self.multi,
|
multi: self.multi,
|
||||||
filter: self.filter,
|
filter: self.filter,
|
||||||
video_format: self.video_format,
|
video_format: self.video_format,
|
||||||
n_retries: self.n_retries,
|
n_retries: self.n_retries,
|
||||||
path_precheck: self.path_precheck,
|
path_precheck: self.path_precheck,
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
audio_tag: self.audio_tag,
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
crop_cover: self.crop_cover,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +376,7 @@ impl Downloader {
|
||||||
dl: self.clone(),
|
dl: self.clone(),
|
||||||
video,
|
video,
|
||||||
dest: DownloadDest::Default,
|
dest: DownloadDest::Default,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
multi: None,
|
multi: None,
|
||||||
filter: None,
|
filter: None,
|
||||||
video_format: None,
|
video_format: None,
|
||||||
|
|
@ -414,6 +463,7 @@ impl DownloadQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use a [`MultiProgress`] progress bar for all downloads
|
/// Use a [`MultiProgress`] progress bar for all downloads
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
pub fn progress_bar(mut self, progress: MultiProgress) -> Self {
|
pub fn progress_bar(mut self, progress: MultiProgress) -> Self {
|
||||||
self.multi = Some(progress);
|
self.multi = Some(progress);
|
||||||
self
|
self
|
||||||
|
|
@ -443,16 +493,26 @@ impl DownloadQuery {
|
||||||
let mut last_err = None;
|
let mut last_err = None;
|
||||||
|
|
||||||
// Progress bar
|
// Progress bar
|
||||||
let multi = self.multi.clone().or_else(|| self.dl.i.multi.clone());
|
#[cfg(feature = "indicatif")]
|
||||||
let pb = multi.map(|m| {
|
let pb = {
|
||||||
let pb = ProgressBar::new(1);
|
let multi = self.multi.clone().or_else(|| self.dl.i.multi.clone());
|
||||||
pb.set_style(ProgressStyle::with_template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap()
|
multi.map(|m| {
|
||||||
.progress_chars("#>-"));
|
let pb = ProgressBar::new(1);
|
||||||
m.add(pb)
|
pb.set_style(ProgressStyle::with_template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").unwrap()
|
||||||
});
|
.progress_chars("#>-"));
|
||||||
|
m.add(pb)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
for n in 0..=self.dl.i.n_retries {
|
for n in 0..=self.dl.i.n_retries {
|
||||||
let err = match self.download_attempt(&pb, n).await {
|
let err = match self
|
||||||
|
.download_attempt(
|
||||||
|
n,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
|
&pb,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(res) => return Ok(res),
|
Ok(res) => return Ok(res),
|
||||||
Err(DownloadError::Http(e)) => {
|
Err(DownloadError::Http(e)) => {
|
||||||
if !e.is_timeout() && e.status() != Some(StatusCode::FORBIDDEN) {
|
if !e.is_timeout() && e.status() != Some(StatusCode::FORBIDDEN) {
|
||||||
|
|
@ -472,7 +532,11 @@ impl DownloadQuery {
|
||||||
Err(last_err.unwrap())
|
Err(last_err.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_attempt(&self, pb: &Option<ProgressBar>, n: u32) -> Result<DownloadResult> {
|
async fn download_attempt(
|
||||||
|
&self,
|
||||||
|
#[allow(unused_variables)] n: u32,
|
||||||
|
#[cfg(feature = "indicatif")] pb: &Option<ProgressBar>,
|
||||||
|
) -> Result<DownloadResult> {
|
||||||
let filter = self.filter.as_ref().unwrap_or(&self.dl.i.filter);
|
let filter = self.filter.as_ref().unwrap_or(&self.dl.i.filter);
|
||||||
let video_format = self.video_format.unwrap_or(self.dl.i.video_format);
|
let video_format = self.video_format.unwrap_or(self.dl.i.video_format);
|
||||||
|
|
||||||
|
|
@ -495,11 +559,13 @@ impl DownloadQuery {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
let attempt_suffix = if n > 0 {
|
let attempt_suffix = if n > 0 {
|
||||||
format!(" (retry #{n})")
|
format!(" (retry #{n})")
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = pb {
|
if let Some(pb) = pb {
|
||||||
pb.set_message(format!(
|
pb.set_message(format!(
|
||||||
"Fetching player data for {}{}",
|
"Fetching player data for {}{}",
|
||||||
|
|
@ -550,7 +616,7 @@ impl DownloadQuery {
|
||||||
.details
|
.details
|
||||||
.channel_name
|
.channel_name
|
||||||
.clone()
|
.clone()
|
||||||
.or(details.map(|d| d.channel.name)),
|
.or(details.as_ref().map(|d| d.channel.name.to_owned())),
|
||||||
};
|
};
|
||||||
let output_path = self.dest.get_dest_path(&pv).with_extension(extension);
|
let output_path = self.dest.get_dest_path(&pv).with_extension(extension);
|
||||||
|
|
||||||
|
|
@ -580,11 +646,20 @@ impl DownloadQuery {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = pb {
|
if let Some(pb) = pb {
|
||||||
pb.set_message(format!("Downloading {name}{attempt_suffix}"))
|
pb.set_message(format!("Downloading {name}{attempt_suffix}"))
|
||||||
}
|
}
|
||||||
download_streams(&downloads, &self.dl.i.http, &user_agent, pb.clone()).await?;
|
download_streams(
|
||||||
|
&downloads,
|
||||||
|
&self.dl.i.http,
|
||||||
|
&user_agent,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
|
pb.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = &pb {
|
if let Some(pb) = &pb {
|
||||||
pb.set_message(format!("Converting {name}"));
|
pb.set_message(format!("Converting {name}"));
|
||||||
pb.set_style(
|
pb.set_style(
|
||||||
|
|
@ -595,6 +670,25 @@ impl DownloadQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
convert_streams(&downloads, &output_path, &self.dl.i.ffmpeg, &name).await?;
|
convert_streams(&downloads, &output_path, &self.dl.i.ffmpeg, &name).await?;
|
||||||
|
|
||||||
|
// Tag audio file
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
if self.dl.i.audio_tag && video.is_none() {
|
||||||
|
let (details, track) = match details {
|
||||||
|
Some(d) => (d, self.dl.i.rp.query().music_details(&self.video.id).await?),
|
||||||
|
None => {
|
||||||
|
let q = self.dl.i.rp.query();
|
||||||
|
tokio::try_join!(
|
||||||
|
q.video_details(&self.video.id),
|
||||||
|
q.music_details(&self.video.id)
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.apply_audio_tags(&output_path, details, track.track)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = pb {
|
if let Some(pb) = pb {
|
||||||
pb.disable_steady_tick();
|
pb.disable_steady_tick();
|
||||||
}
|
}
|
||||||
|
|
@ -606,8 +700,9 @@ impl DownloadQuery {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.await
|
.await
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<core::result::Result<_, _>>()?;
|
.collect::<core::result::Result<(), _>>()?;
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = pb {
|
if let Some(pb) = pb {
|
||||||
pb.finish_and_clear();
|
pb.finish_and_clear();
|
||||||
}
|
}
|
||||||
|
|
@ -616,6 +711,107 @@ impl DownloadQuery {
|
||||||
player_data,
|
player_data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
async fn apply_audio_tags(
|
||||||
|
&self,
|
||||||
|
file: &Path,
|
||||||
|
details: VideoDetails,
|
||||||
|
track: TrackItem,
|
||||||
|
) -> Result<()> {
|
||||||
|
use std::num::NonZeroU32;
|
||||||
|
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
|
|
||||||
|
let mut tagged_file = lofty::read_from_path(file)?;
|
||||||
|
let tag = match tagged_file.primary_tag_mut() {
|
||||||
|
Some(primary_tag) => primary_tag,
|
||||||
|
None => {
|
||||||
|
if let Some(first_tag) = tagged_file.first_tag_mut() {
|
||||||
|
first_tag
|
||||||
|
} else {
|
||||||
|
let tag_type = tagged_file.primary_tag_type();
|
||||||
|
tagged_file.insert_tag(Tag::new(tag_type));
|
||||||
|
|
||||||
|
tagged_file.primary_tag_mut().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let description = details.description.to_plaintext();
|
||||||
|
|
||||||
|
tag.set_album(
|
||||||
|
track
|
||||||
|
.album
|
||||||
|
.map(|b| b.name)
|
||||||
|
.unwrap_or_else(|| track.name.clone()),
|
||||||
|
);
|
||||||
|
tag.set_artist(
|
||||||
|
track
|
||||||
|
.artists
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|a| a.name)
|
||||||
|
.unwrap_or(details.channel.name),
|
||||||
|
);
|
||||||
|
tag.set_title(track.name);
|
||||||
|
if let Some(release_date) = extract_yt_release_date(&description, details.publish_date) {
|
||||||
|
if let Ok(date_str) = release_date.format(&YMD_FORMAT) {
|
||||||
|
tag.insert_text(ItemKey::RecordingDate, date_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag.set_comment(description);
|
||||||
|
|
||||||
|
let thumbnail = track.cover.into_iter().max_by_key(|c| c.height);
|
||||||
|
if let Some(thumbnail) = thumbnail {
|
||||||
|
let resp = self
|
||||||
|
.dl
|
||||||
|
.i
|
||||||
|
.http
|
||||||
|
.get(thumbnail.url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
let img_type = resp
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|fmt| fmt.to_str().ok())
|
||||||
|
.and_then(image::ImageFormat::from_mime_type);
|
||||||
|
let img_bts = resp.bytes().await?;
|
||||||
|
|
||||||
|
let mut lofty_img = if self.dl.i.crop_cover {
|
||||||
|
let mut img = if let Some(fmt) = img_type {
|
||||||
|
image::load_from_memory_with_format(&img_bts, fmt)?
|
||||||
|
} else {
|
||||||
|
image::load_from_memory(&img_bts)?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Crop cover image if it is not square
|
||||||
|
if img.height() != img.width() && img.height() > 0 {
|
||||||
|
let crop = smartcrop::find_best_crop(&img, NonZeroU32::MIN, NonZeroU32::MIN)
|
||||||
|
.unwrap()
|
||||||
|
.crop;
|
||||||
|
img = img.crop_imm(crop.x, crop.y, crop.width, crop.height);
|
||||||
|
let mut enc_bts = Vec::new();
|
||||||
|
img.write_with_encoder(JpegEncoder::new_with_quality(&mut enc_bts, 90))?;
|
||||||
|
let mut rd = Cursor::new(enc_bts);
|
||||||
|
Picture::from_reader(&mut rd)?
|
||||||
|
} else {
|
||||||
|
let mut rd = Cursor::new(img_bts);
|
||||||
|
Picture::from_reader(&mut rd)?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut rd = Cursor::new(img_bts);
|
||||||
|
Picture::from_reader(&mut rd)?
|
||||||
|
};
|
||||||
|
|
||||||
|
lofty_img.set_pic_type(lofty::picture::PictureType::CoverFront);
|
||||||
|
tag.set_picture(0, lofty_img);
|
||||||
|
}
|
||||||
|
|
||||||
|
tag.save_to_path(file, WriteOptions::default())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_download_range(offset: u64, size: Option<u64>) -> Range<u64> {
|
fn get_download_range(offset: u64, size: Option<u64>) -> Range<u64> {
|
||||||
|
|
@ -666,12 +862,12 @@ fn filenamify_lim(name: &str) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_single_file<P: Into<PathBuf>>(
|
async fn download_single_file(
|
||||||
url: &str,
|
url: &str,
|
||||||
output: P,
|
output: &Path,
|
||||||
http: &Client,
|
http: &Client,
|
||||||
user_agent: &str,
|
user_agent: &str,
|
||||||
pb: Option<ProgressBar>,
|
#[cfg(feature = "indicatif")] pb: Option<ProgressBar>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Check if file is already downloaded
|
// Check if file is already downloaded
|
||||||
let output_path: PathBuf = output.into();
|
let output_path: PathBuf = output.into();
|
||||||
|
|
@ -729,6 +925,7 @@ async fn download_single_file<P: Into<PathBuf>>(
|
||||||
size = Some(original_size);
|
size = Some(original_size);
|
||||||
offset = file_size;
|
offset = file_size;
|
||||||
|
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = &pb {
|
if let Some(pb) = &pb {
|
||||||
pb.inc_length(original_size);
|
pb.inc_length(original_size);
|
||||||
pb.inc(offset);
|
pb.inc(offset);
|
||||||
|
|
@ -759,10 +956,29 @@ async fn download_single_file<P: Into<PathBuf>>(
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if is_gvideo && size.is_some() {
|
if is_gvideo && size.is_some() {
|
||||||
download_chunks_by_param(http, &mut file, url, size.unwrap(), offset, user_agent, pb)
|
download_chunks_by_param(
|
||||||
.await?;
|
http,
|
||||||
|
&mut file,
|
||||||
|
url,
|
||||||
|
size.unwrap(),
|
||||||
|
offset,
|
||||||
|
user_agent,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
|
pb,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
download_chunks_by_header(http, &mut file, url, size, offset, user_agent, pb).await?;
|
download_chunks_by_header(
|
||||||
|
http,
|
||||||
|
&mut file,
|
||||||
|
url,
|
||||||
|
size,
|
||||||
|
offset,
|
||||||
|
user_agent,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
|
pb,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::rename(&output_path_tmp, &output_path).await?;
|
fs::rename(&output_path_tmp, &output_path).await?;
|
||||||
|
|
@ -779,7 +995,7 @@ async fn download_chunks_by_header(
|
||||||
size: Option<u64>,
|
size: Option<u64>,
|
||||||
offset: u64,
|
offset: u64,
|
||||||
user_agent: &str,
|
user_agent: &str,
|
||||||
pb: Option<ProgressBar>,
|
#[cfg(feature = "indicatif")] pb: Option<ProgressBar>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut offset = offset;
|
let mut offset = offset;
|
||||||
let mut size = size;
|
let mut size = size;
|
||||||
|
|
@ -826,6 +1042,7 @@ async fn download_chunks_by_header(
|
||||||
offset = parsed_offset + 1;
|
offset = parsed_offset + 1;
|
||||||
if size.is_none() {
|
if size.is_none() {
|
||||||
size = Some(parsed_size);
|
size = Some(parsed_size);
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = &pb {
|
if let Some(pb) = &pb {
|
||||||
pb.inc_length(parsed_size);
|
pb.inc_length(parsed_size);
|
||||||
}
|
}
|
||||||
|
|
@ -836,6 +1053,7 @@ async fn download_chunks_by_header(
|
||||||
while let Some(item) = stream.next().await {
|
while let Some(item) = stream.next().await {
|
||||||
// Retrieve chunk.
|
// Retrieve chunk.
|
||||||
let mut chunk = item?;
|
let mut chunk = item?;
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = &pb {
|
if let Some(pb) = &pb {
|
||||||
pb.inc(chunk.len() as u64);
|
pb.inc(chunk.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
@ -859,9 +1077,10 @@ async fn download_chunks_by_param(
|
||||||
size: u64,
|
size: u64,
|
||||||
offset: u64,
|
offset: u64,
|
||||||
user_agent: &str,
|
user_agent: &str,
|
||||||
pb: Option<ProgressBar>,
|
#[cfg(feature = "indicatif")] pb: Option<ProgressBar>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut offset = offset;
|
let mut offset = offset;
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = &pb {
|
if let Some(pb) = &pb {
|
||||||
pb.inc_length(size);
|
pb.inc_length(size);
|
||||||
}
|
}
|
||||||
|
|
@ -891,6 +1110,7 @@ async fn download_chunks_by_param(
|
||||||
while let Some(item) = stream.next().await {
|
while let Some(item) = stream.next().await {
|
||||||
// Retrieve chunk.
|
// Retrieve chunk.
|
||||||
let mut chunk = item?;
|
let mut chunk = item?;
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
if let Some(pb) = &pb {
|
if let Some(pb) = &pb {
|
||||||
pb.inc(chunk.len() as u64);
|
pb.inc(chunk.len() as u64);
|
||||||
}
|
}
|
||||||
|
|
@ -918,12 +1138,21 @@ async fn download_streams(
|
||||||
downloads: &Vec<StreamDownload>,
|
downloads: &Vec<StreamDownload>,
|
||||||
http: &Client,
|
http: &Client,
|
||||||
user_agent: &str,
|
user_agent: &str,
|
||||||
pb: Option<ProgressBar>,
|
#[cfg(feature = "indicatif")] pb: Option<ProgressBar>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let n = downloads.len();
|
let n = downloads.len();
|
||||||
|
|
||||||
stream::iter(downloads)
|
stream::iter(downloads)
|
||||||
.map(|d| download_single_file(&d.url, d.file.clone(), http, user_agent, pb.clone()))
|
.map(|d| {
|
||||||
|
download_single_file(
|
||||||
|
&d.url,
|
||||||
|
&d.file,
|
||||||
|
http,
|
||||||
|
user_agent,
|
||||||
|
#[cfg(feature = "indicatif")]
|
||||||
|
pb.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.buffer_unordered(n)
|
.buffer_unordered(n)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.await
|
.await
|
||||||
|
|
@ -933,9 +1162,9 @@ async fn download_streams(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn convert_streams<P: Into<PathBuf>>(
|
async fn convert_streams(
|
||||||
downloads: &[StreamDownload],
|
downloads: &[StreamDownload],
|
||||||
output: P,
|
output: &Path,
|
||||||
ffmpeg: &str,
|
ffmpeg: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
|
@ -975,3 +1204,33 @@ async fn convert_streams<P: Into<PathBuf>>(
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const YMD_FORMAT: &[time::format_description::FormatItem] =
|
||||||
|
time::macros::format_description!("[year]-[month]-[day]");
|
||||||
|
|
||||||
|
fn extract_yt_release_date(
|
||||||
|
description: &str,
|
||||||
|
publish_date: Option<OffsetDateTime>,
|
||||||
|
) -> Option<Date> {
|
||||||
|
static RELEASE_DATE_REGEX: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"Released on: (\d{4}-\d{2}-\d{2})").unwrap());
|
||||||
|
|
||||||
|
RELEASE_DATE_REGEX
|
||||||
|
.captures(description)
|
||||||
|
.and_then(|cap| {
|
||||||
|
let raw_date = &cap[1];
|
||||||
|
Date::parse(raw_date, YMD_FORMAT).ok()
|
||||||
|
})
|
||||||
|
.map(|release_date| {
|
||||||
|
if let Some(upload_date) = publish_date {
|
||||||
|
// Prefer the video upload date if it lies within 4 days of the release date
|
||||||
|
let upload_date = upload_date.date();
|
||||||
|
let diff = (upload_date - release_date).abs();
|
||||||
|
if diff < time::Duration::days(4) {
|
||||||
|
return upload_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
release_date
|
||||||
|
})
|
||||||
|
.or_else(|| publish_date.map(|d| d.date()))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,29 @@ pub enum DownloadError {
|
||||||
/// Download target already exists
|
/// Download target already exists
|
||||||
#[error("file {0} already exists")]
|
#[error("file {0} already exists")]
|
||||||
Exists(PathBuf),
|
Exists(PathBuf),
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
/// Audio tagging error
|
||||||
|
#[error("Audio tag error: {0}")]
|
||||||
|
AudioTag(Cow<'static, str>),
|
||||||
/// Other error
|
/// Other error
|
||||||
#[error("error: {0}")]
|
#[error("error: {0}")]
|
||||||
Other(Cow<'static, str>),
|
Other(Cow<'static, str>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
impl From<lofty::error::LoftyError> for DownloadError {
|
||||||
|
fn from(value: lofty::error::LoftyError) -> Self {
|
||||||
|
Self::AudioTag(value.to_string().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "audiotag")]
|
||||||
|
impl From<image::ImageError> for DownloadError {
|
||||||
|
fn from(value: image::ImageError) -> Self {
|
||||||
|
Self::AudioTag(value.to_string().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Split an URL into its base string and parameter map
|
/// Split an URL into its base string and parameter map
|
||||||
///
|
///
|
||||||
/// Example:
|
/// Example:
|
||||||
|
|
|
||||||
Reference in a new issue