mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 20:22:01 -06:00
Refactor
This commit is contained in:
parent
90212c4ec0
commit
0a40f3c40f
30 changed files with 3651 additions and 2982 deletions
657
crunchy-cli-core/src/utils/download.rs
Normal file
657
crunchy-cli-core/src/utils/download.rs
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
use crate::utils::context::Context;
|
||||
use crate::utils::ffmpeg::FFmpegPreset;
|
||||
use crate::utils::log::progress;
|
||||
use crate::utils::os::tempfile;
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::NaiveTime;
|
||||
use crunchyroll_rs::media::{Subtitle, VariantData, VariantSegment};
|
||||
use crunchyroll_rs::Locale;
|
||||
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
|
||||
use log::{debug, LevelFilter};
|
||||
use regex::Regex;
|
||||
use std::borrow::Borrow;
|
||||
use std::borrow::BorrowMut;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tempfile::TempPath;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MergeBehavior {
|
||||
Video,
|
||||
Audio,
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl MergeBehavior {
|
||||
pub fn parse(s: &str) -> Result<MergeBehavior, String> {
|
||||
Ok(match s.to_lowercase().as_str() {
|
||||
"video" => MergeBehavior::Video,
|
||||
"audio" => MergeBehavior::Audio,
|
||||
"auto" => MergeBehavior::Auto,
|
||||
_ => return Err(format!("'{}' is not a valid merge behavior", s)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(derive_setters::Setters)]
|
||||
pub struct DownloadBuilder {
|
||||
ffmpeg_preset: FFmpegPreset,
|
||||
default_subtitle: Option<Locale>,
|
||||
output_format: Option<String>,
|
||||
audio_sort: Option<Vec<Locale>>,
|
||||
subtitle_sort: Option<Vec<Locale>>,
|
||||
}
|
||||
|
||||
impl DownloadBuilder {
|
||||
pub fn new() -> DownloadBuilder {
|
||||
Self {
|
||||
ffmpeg_preset: FFmpegPreset::default(),
|
||||
default_subtitle: None,
|
||||
output_format: None,
|
||||
audio_sort: None,
|
||||
subtitle_sort: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(self) -> Downloader {
|
||||
Downloader {
|
||||
ffmpeg_preset: self.ffmpeg_preset,
|
||||
default_subtitle: self.default_subtitle,
|
||||
output_format: self.output_format,
|
||||
audio_sort: self.audio_sort,
|
||||
subtitle_sort: self.subtitle_sort,
|
||||
|
||||
formats: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FFmpegMeta {
|
||||
path: TempPath,
|
||||
language: Locale,
|
||||
title: String,
|
||||
}
|
||||
|
||||
pub struct DownloadFormat {
|
||||
pub video: (VariantData, Locale),
|
||||
pub audios: Vec<(VariantData, Locale)>,
|
||||
pub subtitles: Vec<Subtitle>,
|
||||
}
|
||||
|
||||
pub struct Downloader {
|
||||
ffmpeg_preset: FFmpegPreset,
|
||||
default_subtitle: Option<Locale>,
|
||||
output_format: Option<String>,
|
||||
audio_sort: Option<Vec<Locale>>,
|
||||
subtitle_sort: Option<Vec<Locale>>,
|
||||
|
||||
formats: Vec<DownloadFormat>,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn add_format(&mut self, format: DownloadFormat) {
|
||||
self.formats.push(format);
|
||||
}
|
||||
|
||||
pub async fn download(mut self, ctx: &Context, dst: &Path) -> Result<()> {
|
||||
if let Some(audio_sort_locales) = &self.audio_sort {
|
||||
self.formats.sort_by(|a, b| {
|
||||
audio_sort_locales
|
||||
.iter()
|
||||
.position(|l| l == &a.video.1)
|
||||
.cmp(&audio_sort_locales.iter().position(|l| l == &b.video.1))
|
||||
});
|
||||
}
|
||||
for format in self.formats.iter_mut() {
|
||||
if let Some(audio_sort_locales) = &self.audio_sort {
|
||||
format.audios.sort_by(|(_, a), (_, b)| {
|
||||
audio_sort_locales
|
||||
.iter()
|
||||
.position(|l| l == a)
|
||||
.cmp(&audio_sort_locales.iter().position(|l| l == b))
|
||||
})
|
||||
}
|
||||
if let Some(subtitle_sort) = &self.subtitle_sort {
|
||||
format.subtitles.sort_by(|a, b| {
|
||||
subtitle_sort
|
||||
.iter()
|
||||
.position(|l| l == &a.locale)
|
||||
.cmp(&subtitle_sort.iter().position(|l| l == &b.locale))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut videos = vec![];
|
||||
let mut audios = vec![];
|
||||
let mut subtitles = vec![];
|
||||
|
||||
for (i, format) in self.formats.iter().enumerate() {
|
||||
let fmt_space = format
|
||||
.audios
|
||||
.iter()
|
||||
.map(|(_, locale)| format!("Downloading {} audio", locale).len())
|
||||
.max()
|
||||
.unwrap();
|
||||
|
||||
let video_path = self
|
||||
.download_video(
|
||||
ctx,
|
||||
&format.video.0,
|
||||
format!("{:<1$}", format!("Downloading video #{}", i + 1), fmt_space),
|
||||
)
|
||||
.await?;
|
||||
for (variant_data, locale) in format.audios.iter() {
|
||||
let audio_path = self
|
||||
.download_audio(
|
||||
ctx,
|
||||
variant_data,
|
||||
format!("{:<1$}", format!("Downloading {} audio", locale), fmt_space),
|
||||
)
|
||||
.await?;
|
||||
audios.push(FFmpegMeta {
|
||||
path: audio_path,
|
||||
language: locale.clone(),
|
||||
title: if i == 0 {
|
||||
locale.to_human_readable()
|
||||
} else {
|
||||
format!("{} [Video: #{}]", locale.to_human_readable(), i + 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
let len = get_video_length(&video_path)?;
|
||||
for subtitle in format.subtitles.iter() {
|
||||
let subtitle_path = self.download_subtitle(subtitle.clone(), len).await?;
|
||||
subtitles.push(FFmpegMeta {
|
||||
path: subtitle_path,
|
||||
language: subtitle.locale.clone(),
|
||||
title: if i == 0 {
|
||||
subtitle.locale.to_human_readable()
|
||||
} else {
|
||||
format!(
|
||||
"{} [Video: #{}]",
|
||||
subtitle.locale.to_human_readable(),
|
||||
i + 1
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
videos.push(FFmpegMeta {
|
||||
path: video_path,
|
||||
language: format.video.1.clone(),
|
||||
title: if self.formats.len() == 1 {
|
||||
"Default".to_string()
|
||||
} else {
|
||||
format!("#{}", i + 1)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let mut input = vec![];
|
||||
let mut maps = vec![];
|
||||
let mut metadata = vec![];
|
||||
|
||||
for (i, meta) in videos.iter().enumerate() {
|
||||
input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]);
|
||||
maps.extend(["-map".to_string(), i.to_string()]);
|
||||
metadata.extend([
|
||||
format!("-metadata:s:v:{}", i),
|
||||
format!("title={}", meta.title),
|
||||
]);
|
||||
// the empty language metadata is created to avoid that metadata from the original track
|
||||
// is copied
|
||||
metadata.extend([format!("-metadata:s:v:{}", i), format!("language=")])
|
||||
}
|
||||
for (i, meta) in audios.iter().enumerate() {
|
||||
input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]);
|
||||
maps.extend(["-map".to_string(), (i + videos.len()).to_string()]);
|
||||
metadata.extend([
|
||||
format!("-metadata:s:a:{}", i),
|
||||
format!("language={}", meta.language),
|
||||
]);
|
||||
metadata.extend([
|
||||
format!("-metadata:s:a:{}", i),
|
||||
format!("title={}", meta.title),
|
||||
]);
|
||||
}
|
||||
for (i, meta) in subtitles.iter().enumerate() {
|
||||
input.extend(["-i".to_string(), meta.path.to_string_lossy().to_string()]);
|
||||
maps.extend([
|
||||
"-map".to_string(),
|
||||
(i + videos.len() + audios.len()).to_string(),
|
||||
]);
|
||||
metadata.extend([
|
||||
format!("-metadata:s:s:{}", i),
|
||||
format!("language={}", meta.language),
|
||||
]);
|
||||
metadata.extend([
|
||||
format!("-metadata:s:s:{}", i),
|
||||
format!("title={}", meta.title),
|
||||
]);
|
||||
}
|
||||
|
||||
let (input_presets, mut output_presets) = self.ffmpeg_preset.into_input_output_args();
|
||||
|
||||
let mut command_args = vec!["-y".to_string(), "-hide_banner".to_string()];
|
||||
command_args.extend(input_presets);
|
||||
command_args.extend(input);
|
||||
command_args.extend(maps);
|
||||
command_args.extend(metadata);
|
||||
|
||||
// set default subtitle
|
||||
if let Some(default_subtitle) = self.default_subtitle {
|
||||
if let Some(position) = subtitles
|
||||
.iter()
|
||||
.position(|m| m.language == default_subtitle)
|
||||
{
|
||||
match dst.extension().unwrap_or_default().to_str().unwrap() {
|
||||
"mp4" => output_presets.extend([
|
||||
"-movflags".to_string(),
|
||||
"faststart".to_string(),
|
||||
"-c:s".to_string(),
|
||||
"mov_text".to_string(),
|
||||
format!("-disposition:s:s:{}", position),
|
||||
"forced".to_string(),
|
||||
]),
|
||||
"mkv" => output_presets.extend([
|
||||
format!("-disposition:s:s:{}", position),
|
||||
"forced".to_string(),
|
||||
]),
|
||||
_ => {
|
||||
// remove '-c:v copy' and '-c:a copy' from output presets as its causes issues with
|
||||
// burning subs into the video
|
||||
let mut last = String::new();
|
||||
let mut remove_count = 0;
|
||||
for (i, s) in output_presets.clone().iter().enumerate() {
|
||||
if (last == "-c:v" || last == "-c:a") && s == "copy" {
|
||||
// remove last
|
||||
output_presets.remove(i - remove_count - 1);
|
||||
remove_count += 1;
|
||||
output_presets.remove(i - remove_count);
|
||||
remove_count += 1;
|
||||
}
|
||||
last = s.clone();
|
||||
}
|
||||
|
||||
output_presets.extend([
|
||||
"-vf".to_string(),
|
||||
format!(
|
||||
"subtitles={}",
|
||||
subtitles.get(position).unwrap().path.to_str().unwrap()
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(position) = subtitles
|
||||
.iter()
|
||||
.position(|meta| meta.language == default_subtitle)
|
||||
{
|
||||
command_args.extend([
|
||||
format!("-disposition:s:s:{}", position),
|
||||
"forced".to_string(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
command_args.extend(output_presets);
|
||||
if let Some(output_format) = self.output_format {
|
||||
command_args.extend(["-f".to_string(), output_format]);
|
||||
}
|
||||
command_args.push(dst.to_str().unwrap().to_string());
|
||||
|
||||
debug!("ffmpeg {}", command_args.join(" "));
|
||||
|
||||
// create parent directory if it does not exist
|
||||
if let Some(parent) = dst.parent() {
|
||||
if !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?
|
||||
}
|
||||
}
|
||||
|
||||
let progress_handler = progress!("Generating output file");
|
||||
|
||||
let ffmpeg = Command::new("ffmpeg")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.args(command_args)
|
||||
.output()?;
|
||||
if !ffmpeg.status.success() {
|
||||
bail!("{}", String::from_utf8_lossy(ffmpeg.stderr.as_slice()))
|
||||
}
|
||||
|
||||
progress_handler.stop("Output file generated");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_video(
|
||||
&self,
|
||||
ctx: &Context,
|
||||
variant_data: &VariantData,
|
||||
message: String,
|
||||
) -> Result<TempPath> {
|
||||
let tempfile = tempfile(".mp4")?;
|
||||
let (mut file, path) = tempfile.into_parts();
|
||||
|
||||
download_segments(ctx, &mut file, Some(message), variant_data).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn download_audio(
|
||||
&self,
|
||||
ctx: &Context,
|
||||
variant_data: &VariantData,
|
||||
message: String,
|
||||
) -> Result<TempPath> {
|
||||
let tempfile = tempfile(".m4a")?;
|
||||
let (mut file, path) = tempfile.into_parts();
|
||||
|
||||
download_segments(ctx, &mut file, Some(message), variant_data).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn download_subtitle(
|
||||
&self,
|
||||
subtitle: Subtitle,
|
||||
max_length: NaiveTime,
|
||||
) -> Result<TempPath> {
|
||||
let tempfile = tempfile(".ass")?;
|
||||
let (mut file, path) = tempfile.into_parts();
|
||||
|
||||
let mut buf = vec![];
|
||||
subtitle.write_to(&mut buf).await?;
|
||||
fix_subtitle_look_and_feel(&mut buf);
|
||||
fix_subtitle_length(&mut buf, max_length);
|
||||
|
||||
file.write_all(buf.as_slice())?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_segments(
|
||||
ctx: &Context,
|
||||
writer: &mut impl Write,
|
||||
message: Option<String>,
|
||||
variant_data: &VariantData,
|
||||
) -> Result<()> {
|
||||
let segments = variant_data.segments().await?;
|
||||
let total_segments = segments.len();
|
||||
|
||||
let client = Arc::new(ctx.crunchy.client());
|
||||
let count = Arc::new(Mutex::new(0));
|
||||
|
||||
let progress = if log::max_level() == LevelFilter::Info {
|
||||
let estimated_file_size =
|
||||
(variant_data.bandwidth / 8) * segments.iter().map(|s| s.length.as_secs()).sum::<u64>();
|
||||
|
||||
let progress = ProgressBar::new(estimated_file_size)
|
||||
.with_style(
|
||||
ProgressStyle::with_template(
|
||||
":: {msg}{bytes:>10} {bytes_per_sec:>12} [{wide_bar}] {percent:>3}%",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("##-"),
|
||||
)
|
||||
.with_message(message.map(|m| m + " ").unwrap_or_default())
|
||||
.with_finish(ProgressFinish::Abandon);
|
||||
Some(progress)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cpus = num_cpus::get();
|
||||
let mut segs: Vec<Vec<VariantSegment>> = Vec::with_capacity(cpus);
|
||||
for _ in 0..cpus {
|
||||
segs.push(vec![])
|
||||
}
|
||||
for (i, segment) in segments.clone().into_iter().enumerate() {
|
||||
segs[i - ((i / cpus) * cpus)].push(segment);
|
||||
}
|
||||
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
|
||||
let mut join_set: JoinSet<Result<()>> = JoinSet::new();
|
||||
for num in 0..cpus {
|
||||
let thread_client = client.clone();
|
||||
let thread_sender = sender.clone();
|
||||
let thread_segments = segs.remove(0);
|
||||
let thread_count = count.clone();
|
||||
join_set.spawn(async move {
|
||||
let after_download_sender = thread_sender.clone();
|
||||
|
||||
// the download process is encapsulated in its own function. this is done to easily
|
||||
// catch errors which get returned with `...?` and `bail!(...)` and that the thread
|
||||
// itself can report that an error has occurred
|
||||
let download = || async move {
|
||||
for (i, segment) in thread_segments.into_iter().enumerate() {
|
||||
let mut retry_count = 0;
|
||||
let mut buf = loop {
|
||||
let response = thread_client
|
||||
.get(&segment.url)
|
||||
.timeout(Duration::from_secs(60))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
match response.bytes().await {
|
||||
Ok(b) => break b.to_vec(),
|
||||
Err(e) => {
|
||||
if e.is_body() {
|
||||
if retry_count == 5 {
|
||||
bail!("Max retry count reached ({}), multiple errors occurred while receiving segment {}: {}", retry_count, num + (i * cpus), e)
|
||||
}
|
||||
debug!("Failed to download segment {} ({}). Retrying, {} out of 5 retries left", num + (i * cpus), e, 5 - retry_count)
|
||||
} else {
|
||||
bail!("{}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retry_count += 1;
|
||||
};
|
||||
|
||||
buf = VariantSegment::decrypt(buf.borrow_mut(), segment.key)?.to_vec();
|
||||
|
||||
let mut c = thread_count.lock().unwrap();
|
||||
debug!(
|
||||
"Downloaded and decrypted segment [{}/{} {:.2}%] {}",
|
||||
num + (i * cpus) + 1,
|
||||
total_segments,
|
||||
((*c + 1) as f64 / total_segments as f64) * 100f64,
|
||||
segment.url
|
||||
);
|
||||
|
||||
thread_sender.send((num as i32 + (i * cpus) as i32, buf))?;
|
||||
|
||||
*c += 1;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
|
||||
let result = download().await;
|
||||
if result.is_err() {
|
||||
after_download_sender.send((-1 as i32, vec![]))?;
|
||||
}
|
||||
|
||||
result
|
||||
});
|
||||
}
|
||||
// drop the sender already here so it does not outlive all download threads which are the only
|
||||
// real consumers of it
|
||||
drop(sender);
|
||||
|
||||
// this is the main loop which writes the data. it uses a BTreeMap as a buffer as the write
|
||||
// happens synchronized. the download consist of multiple segments. the map keys are representing
|
||||
// the segment number and the values the corresponding bytes
|
||||
let mut data_pos = 0;
|
||||
let mut buf: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
|
||||
for (pos, bytes) in receiver.iter() {
|
||||
// if the position is lower than 0, an error occurred in the sending download thread
|
||||
if pos < 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(p) = &progress {
|
||||
let progress_len = p.length().unwrap();
|
||||
let estimated_segment_len =
|
||||
(variant_data.bandwidth / 8) * segments.get(pos as usize).unwrap().length.as_secs();
|
||||
let bytes_len = bytes.len() as u64;
|
||||
|
||||
p.set_length(progress_len - estimated_segment_len + bytes_len);
|
||||
p.inc(bytes_len)
|
||||
}
|
||||
|
||||
// check if the currently sent bytes are the next in the buffer. if so, write them directly
|
||||
// to the target without first adding them to the buffer.
|
||||
// if not, add them to the buffer
|
||||
if data_pos == pos {
|
||||
writer.write_all(bytes.borrow())?;
|
||||
data_pos += 1;
|
||||
} else {
|
||||
buf.insert(pos, bytes);
|
||||
}
|
||||
// check if the buffer contains the next segment(s)
|
||||
while let Some(b) = buf.remove(&data_pos) {
|
||||
writer.write_all(b.borrow())?;
|
||||
data_pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// if any error has occurred while downloading it gets returned here
|
||||
while let Some(joined) = join_set.join_next().await {
|
||||
joined??
|
||||
}
|
||||
|
||||
// write the remaining buffer, if existent
|
||||
while let Some(b) = buf.remove(&data_pos) {
|
||||
writer.write_all(b.borrow())?;
|
||||
data_pos += 1;
|
||||
}
|
||||
|
||||
if !buf.is_empty() {
|
||||
bail!(
|
||||
"Download buffer is not empty. Remaining segments: {}",
|
||||
buf.into_keys()
|
||||
.map(|k| k.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add `ScaledBorderAndShadows: yes` to subtitles; without it they look very messy on some video
|
||||
/// players. See [crunchy-labs/crunchy-cli#66](https://github.com/crunchy-labs/crunchy-cli/issues/66)
|
||||
/// for more information.
|
||||
fn fix_subtitle_look_and_feel(raw: &mut Vec<u8>) {
|
||||
let mut script_info = false;
|
||||
let mut new = String::new();
|
||||
|
||||
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
|
||||
if line.trim().starts_with('[') && script_info {
|
||||
new.push_str("ScaledBorderAndShadow: yes\n");
|
||||
script_info = false
|
||||
} else if line.trim() == "[Script Info]" {
|
||||
script_info = true
|
||||
}
|
||||
new.push_str(line);
|
||||
new.push('\n')
|
||||
}
|
||||
|
||||
*raw = new.into_bytes()
|
||||
}
|
||||
|
||||
/// Get the length of a video. This is required because sometimes subtitles have an unnecessary entry
|
||||
/// long after the actual video ends with artificially extends the video length on some video players.
|
||||
/// To prevent this, the video length must be hard set. See
|
||||
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
|
||||
/// information.
|
||||
pub fn get_video_length(path: &Path) -> Result<NaiveTime> {
|
||||
let video_length = Regex::new(r"Duration:\s(?P<time>\d+:\d+:\d+\.\d+),")?;
|
||||
|
||||
let ffmpeg = Command::new("ffmpeg")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.arg("-y")
|
||||
.arg("-hide_banner")
|
||||
.args(["-i", path.to_str().unwrap()])
|
||||
.output()?;
|
||||
let ffmpeg_output = String::from_utf8(ffmpeg.stderr)?;
|
||||
let caps = video_length.captures(ffmpeg_output.as_str()).unwrap();
|
||||
|
||||
Ok(NaiveTime::parse_from_str(caps.name("time").unwrap().as_str(), "%H:%M:%S%.f").unwrap())
|
||||
}
|
||||
|
||||
/// Fix the length of subtitles to a specified maximum amount. This is required because sometimes
|
||||
/// subtitles have an unnecessary entry long after the actual video ends with artificially extends
|
||||
/// the video length on some video players. To prevent this, the video length must be hard set. See
|
||||
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
|
||||
/// information.
|
||||
fn fix_subtitle_length(raw: &mut Vec<u8>, max_length: NaiveTime) {
|
||||
let re =
|
||||
Regex::new(r#"^Dialogue:\s\d+,(?P<start>\d+:\d+:\d+\.\d+),(?P<end>\d+:\d+:\d+\.\d+),"#)
|
||||
.unwrap();
|
||||
|
||||
// chrono panics if we try to format NaiveTime with `%2f` and the nano seconds has more than 2
|
||||
// digits so them have to be reduced manually to avoid the panic
|
||||
fn format_naive_time(native_time: NaiveTime) -> String {
|
||||
let formatted_time = native_time.format("%f").to_string();
|
||||
format!(
|
||||
"{}.{}",
|
||||
native_time.format("%T"),
|
||||
if formatted_time.len() <= 2 {
|
||||
native_time.format("%2f").to_string()
|
||||
} else {
|
||||
formatted_time.split_at(2).0.parse().unwrap()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let length_as_string = format_naive_time(max_length);
|
||||
let mut new = String::new();
|
||||
|
||||
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
|
||||
if let Some(capture) = re.captures(line) {
|
||||
let start = capture.name("start").map_or(NaiveTime::default(), |s| {
|
||||
NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap()
|
||||
});
|
||||
let end = capture.name("end").map_or(NaiveTime::default(), |s| {
|
||||
NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap()
|
||||
});
|
||||
|
||||
if start > max_length {
|
||||
continue;
|
||||
} else if end > max_length {
|
||||
new.push_str(
|
||||
re.replace(
|
||||
line,
|
||||
format!(
|
||||
"Dialogue: {},{},",
|
||||
format_naive_time(start),
|
||||
&length_as_string
|
||||
),
|
||||
)
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
} else {
|
||||
new.push_str(line)
|
||||
}
|
||||
} else {
|
||||
new.push_str(line)
|
||||
}
|
||||
new.push('\n')
|
||||
}
|
||||
|
||||
*raw = new.into_bytes()
|
||||
}
|
||||
344
crunchy-cli-core/src/utils/ffmpeg.rs
Normal file
344
crunchy-cli-core/src/utils/ffmpeg.rs
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use std::env;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum FFmpegPreset {
|
||||
Predefined(FFmpegCodec, Option<FFmpegHwAccel>, FFmpegQuality),
|
||||
Custom(Option<String>, Option<String>),
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref PREDEFINED_PRESET: Regex = Regex::new(r"^\w+(-\w+)*?$").unwrap();
|
||||
}
|
||||
|
||||
macro_rules! ffmpeg_enum {
|
||||
(enum $name:ident { $($field:ident),* }) => {
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||
pub enum $name {
|
||||
$(
|
||||
$field
|
||||
),*,
|
||||
}
|
||||
|
||||
impl $name {
|
||||
fn all() -> Vec<$name> {
|
||||
vec![
|
||||
$(
|
||||
$name::$field
|
||||
),*,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for $name {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
$(
|
||||
&$name::$field => stringify!($field).to_string().to_lowercase()
|
||||
),*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for $name {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
$(
|
||||
stringify!($field) => Ok($name::$field)
|
||||
),*,
|
||||
_ => anyhow::bail!("{} is not a valid {}", s, stringify!($name).to_lowercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg_enum! {
|
||||
enum FFmpegCodec {
|
||||
H264,
|
||||
H265,
|
||||
Av1
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg_enum! {
|
||||
enum FFmpegHwAccel {
|
||||
Nvidia
|
||||
}
|
||||
}
|
||||
|
||||
ffmpeg_enum! {
|
||||
enum FFmpegQuality {
|
||||
Lossless,
|
||||
Normal,
|
||||
Low
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FFmpegPreset {
|
||||
fn default() -> Self {
|
||||
Self::Custom(None, Some("-c:v copy -c:a copy".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FFmpegPreset {
|
||||
pub(crate) fn available_matches(
|
||||
) -> Vec<(FFmpegCodec, Option<FFmpegHwAccel>, Option<FFmpegQuality>)> {
|
||||
let codecs = vec![
|
||||
(
|
||||
FFmpegCodec::H264,
|
||||
FFmpegHwAccel::all(),
|
||||
FFmpegQuality::all(),
|
||||
),
|
||||
(
|
||||
FFmpegCodec::H265,
|
||||
FFmpegHwAccel::all(),
|
||||
FFmpegQuality::all(),
|
||||
),
|
||||
(FFmpegCodec::Av1, vec![], FFmpegQuality::all()),
|
||||
];
|
||||
|
||||
let mut return_values = vec![];
|
||||
|
||||
for (codec, hwaccels, qualities) in codecs {
|
||||
return_values.push((codec.clone(), None, None));
|
||||
for hwaccel in hwaccels.clone() {
|
||||
return_values.push((codec.clone(), Some(hwaccel), None));
|
||||
}
|
||||
for quality in qualities.clone() {
|
||||
return_values.push((codec.clone(), None, Some(quality)))
|
||||
}
|
||||
for hwaccel in hwaccels {
|
||||
for quality in qualities.clone() {
|
||||
return_values.push((codec.clone(), Some(hwaccel.clone()), Some(quality)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return_values
|
||||
}
|
||||
|
||||
pub(crate) fn available_matches_human_readable() -> Vec<String> {
|
||||
let mut return_values = vec![];
|
||||
|
||||
for (codec, hwaccel, quality) in FFmpegPreset::available_matches() {
|
||||
let mut description_details = vec![];
|
||||
if let Some(h) = &hwaccel {
|
||||
description_details.push(format!("{} hardware acceleration", h.to_string()))
|
||||
}
|
||||
if let Some(q) = &quality {
|
||||
description_details.push(format!("{} video quality/compression", q.to_string()))
|
||||
}
|
||||
|
||||
let description = if description_details.len() == 0 {
|
||||
format!(
|
||||
"{} encoded with default video quality/compression",
|
||||
codec.to_string()
|
||||
)
|
||||
} else if description_details.len() == 1 {
|
||||
format!(
|
||||
"{} encoded with {}",
|
||||
codec.to_string(),
|
||||
description_details[0]
|
||||
)
|
||||
} else {
|
||||
let first = description_details.remove(0);
|
||||
let last = description_details.remove(description_details.len() - 1);
|
||||
let mid = if !description_details.is_empty() {
|
||||
format!(", {} ", description_details.join(", "))
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"{} encoded with {}{} and {}",
|
||||
codec.to_string(),
|
||||
first,
|
||||
mid,
|
||||
last
|
||||
)
|
||||
};
|
||||
|
||||
return_values.push(format!(
|
||||
"{} ({})",
|
||||
vec![
|
||||
Some(codec.to_string()),
|
||||
hwaccel.map(|h| h.to_string()),
|
||||
quality.map(|q| q.to_string())
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<String>>()
|
||||
.join("-"),
|
||||
description
|
||||
))
|
||||
}
|
||||
return_values
|
||||
}
|
||||
|
||||
pub(crate) fn parse(s: &str) -> Result<FFmpegPreset, String> {
|
||||
let env_ffmpeg_input_args = env::var("FFMPEG_INPUT_ARGS").ok();
|
||||
let env_ffmpeg_output_args = env::var("FFMPEG_OUTPUT_ARGS").ok();
|
||||
|
||||
if env_ffmpeg_input_args.is_some() || env_ffmpeg_output_args.is_some() {
|
||||
if let Some(input) = &env_ffmpeg_input_args {
|
||||
if shlex::split(input).is_none() {
|
||||
return Err(format!("Failed to find custom ffmpeg input '{}' (`FFMPEG_INPUT_ARGS` env variable)", input));
|
||||
}
|
||||
}
|
||||
if let Some(output) = &env_ffmpeg_output_args {
|
||||
if shlex::split(output).is_none() {
|
||||
return Err(format!("Failed to find custom ffmpeg output '{}' (`FFMPEG_INPUT_ARGS` env variable)", output));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(FFmpegPreset::Custom(
|
||||
env_ffmpeg_input_args,
|
||||
env_ffmpeg_output_args,
|
||||
));
|
||||
} else if !PREDEFINED_PRESET.is_match(s) {
|
||||
return Ok(FFmpegPreset::Custom(None, Some(s.to_string())));
|
||||
}
|
||||
|
||||
let mut codec: Option<FFmpegCodec> = None;
|
||||
let mut hwaccel: Option<FFmpegHwAccel> = None;
|
||||
let mut quality: Option<FFmpegQuality> = None;
|
||||
for token in s.split('-') {
|
||||
if let Some(c) = FFmpegCodec::all()
|
||||
.into_iter()
|
||||
.find(|p| p.to_string() == token.to_lowercase())
|
||||
{
|
||||
if let Some(cc) = codec {
|
||||
return Err(format!(
|
||||
"cannot use multiple codecs (found {} and {})",
|
||||
cc.to_string(),
|
||||
c.to_string()
|
||||
));
|
||||
}
|
||||
codec = Some(c)
|
||||
} else if let Some(h) = FFmpegHwAccel::all()
|
||||
.into_iter()
|
||||
.find(|p| p.to_string() == token.to_lowercase())
|
||||
{
|
||||
if let Some(hh) = hwaccel {
|
||||
return Err(format!(
|
||||
"cannot use multiple hardware accelerations (found {} and {})",
|
||||
hh.to_string(),
|
||||
h.to_string()
|
||||
));
|
||||
}
|
||||
hwaccel = Some(h)
|
||||
} else if let Some(q) = FFmpegQuality::all()
|
||||
.into_iter()
|
||||
.find(|p| p.to_string() == token.to_lowercase())
|
||||
{
|
||||
if let Some(qq) = quality {
|
||||
return Err(format!(
|
||||
"cannot use multiple ffmpeg preset qualities (found {} and {})",
|
||||
qq.to_string(),
|
||||
q.to_string()
|
||||
));
|
||||
}
|
||||
quality = Some(q)
|
||||
} else {
|
||||
return Err(format!(
|
||||
"'{}' is not a valid ffmpeg preset (unknown token '{}'",
|
||||
s, token
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(c) = codec {
|
||||
if !FFmpegPreset::available_matches().contains(&(
|
||||
c.clone(),
|
||||
hwaccel.clone(),
|
||||
quality.clone(),
|
||||
)) {
|
||||
return Err(format!("ffmpeg preset is not supported"));
|
||||
}
|
||||
Ok(FFmpegPreset::Predefined(
|
||||
c,
|
||||
hwaccel,
|
||||
quality.unwrap_or(FFmpegQuality::Normal),
|
||||
))
|
||||
} else {
|
||||
Err(format!("cannot use ffmpeg preset with without a codec"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_input_output_args(self) -> (Vec<String>, Vec<String>) {
|
||||
match self {
|
||||
FFmpegPreset::Custom(input, output) => (
|
||||
input.map_or(vec![], |i| shlex::split(&i).unwrap_or_default()),
|
||||
output.map_or(vec![], |o| shlex::split(&o).unwrap_or_default()),
|
||||
),
|
||||
FFmpegPreset::Predefined(codec, hwaccel_opt, quality) => {
|
||||
let mut input = vec![];
|
||||
let mut output = vec![];
|
||||
|
||||
match codec {
|
||||
FFmpegCodec::H264 => {
|
||||
if let Some(hwaccel) = hwaccel_opt {
|
||||
match hwaccel {
|
||||
FFmpegHwAccel::Nvidia => {
|
||||
input.extend(["-hwaccel", "cuvid", "-c:v", "h264_cuvid"]);
|
||||
output.extend(["-c:v", "h264_nvenc", "-c:a", "copy"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.extend(["-c:v", "libx264", "-c:a", "copy"])
|
||||
}
|
||||
|
||||
match quality {
|
||||
FFmpegQuality::Lossless => output.extend(["-crf", "18"]),
|
||||
FFmpegQuality::Normal => (),
|
||||
FFmpegQuality::Low => output.extend(["-crf", "35"]),
|
||||
}
|
||||
}
|
||||
FFmpegCodec::H265 => {
|
||||
if let Some(hwaccel) = hwaccel_opt {
|
||||
match hwaccel {
|
||||
FFmpegHwAccel::Nvidia => {
|
||||
input.extend(["-hwaccel", "cuvid", "-c:v", "h264_cuvid"]);
|
||||
output.extend(["-c:v", "hevc_nvenc", "-c:a", "copy"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output.extend(["-c:v", "libx265", "-c:a", "copy"])
|
||||
}
|
||||
|
||||
match quality {
|
||||
FFmpegQuality::Lossless => output.extend(["-crf", "20"]),
|
||||
FFmpegQuality::Normal => (),
|
||||
FFmpegQuality::Low => output.extend(["-crf", "35"]),
|
||||
}
|
||||
}
|
||||
FFmpegCodec::Av1 => {
|
||||
output.extend(["-c:v", "libsvtav1", "-c:a", "copy"]);
|
||||
|
||||
match quality {
|
||||
FFmpegQuality::Lossless => output.extend(["-crf", "22"]),
|
||||
FFmpegQuality::Normal => (),
|
||||
FFmpegQuality::Low => output.extend(["-crf", "35"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
input
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
output
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
crunchy-cli-core/src/utils/filter.rs
Normal file
95
crunchy-cli-core/src/utils/filter.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use anyhow::Result;
|
||||
use crunchyroll_rs::{
|
||||
Concert, Episode, MediaCollection, Movie, MovieListing, MusicVideo, Season, Series,
|
||||
};
|
||||
|
||||
// Check when https://github.com/dtolnay/async-trait/issues/224 is resolved and update async-trait
|
||||
// to the new fixed version (as this causes some issues)
|
||||
#[async_trait::async_trait]
|
||||
pub trait Filter {
|
||||
type T: Send + Sized;
|
||||
type Output: Send + Sized;
|
||||
|
||||
async fn visit_series(&mut self, series: Series) -> Result<Vec<Season>>;
|
||||
async fn visit_season(&mut self, season: Season) -> Result<Vec<Episode>>;
|
||||
async fn visit_episode(&mut self, episode: Episode) -> Result<Option<Self::T>>;
|
||||
async fn visit_movie_listing(&mut self, movie_listing: MovieListing) -> Result<Vec<Movie>>;
|
||||
async fn visit_movie(&mut self, movie: Movie) -> Result<Option<Self::T>>;
|
||||
async fn visit_music_video(&mut self, music_video: MusicVideo) -> Result<Option<Self::T>>;
|
||||
async fn visit_concert(&mut self, concert: Concert) -> Result<Option<Self::T>>;
|
||||
|
||||
async fn visit(mut self, media_collection: MediaCollection) -> Result<Vec<Self::Output>>
|
||||
where
|
||||
Self: Send + Sized,
|
||||
{
|
||||
let mut items = vec![media_collection];
|
||||
let mut result = vec![];
|
||||
|
||||
while !items.is_empty() {
|
||||
let mut new_items: Vec<MediaCollection> = vec![];
|
||||
|
||||
for i in items {
|
||||
match i {
|
||||
MediaCollection::Series(series) => new_items.extend(
|
||||
self.visit_series(series)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect::<Vec<MediaCollection>>(),
|
||||
),
|
||||
MediaCollection::Season(season) => new_items.extend(
|
||||
self.visit_season(season)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|s| s.into())
|
||||
.collect::<Vec<MediaCollection>>(),
|
||||
),
|
||||
MediaCollection::Episode(episode) => {
|
||||
if let Some(t) = self.visit_episode(episode).await? {
|
||||
result.push(t)
|
||||
}
|
||||
}
|
||||
MediaCollection::MovieListing(movie_listing) => new_items.extend(
|
||||
self.visit_movie_listing(movie_listing)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|m| m.into())
|
||||
.collect::<Vec<MediaCollection>>(),
|
||||
),
|
||||
MediaCollection::Movie(movie) => {
|
||||
if let Some(t) = self.visit_movie(movie).await? {
|
||||
result.push(t)
|
||||
}
|
||||
}
|
||||
MediaCollection::MusicVideo(music_video) => {
|
||||
if let Some(t) = self.visit_music_video(music_video).await? {
|
||||
result.push(t)
|
||||
}
|
||||
}
|
||||
MediaCollection::Concert(concert) => {
|
||||
if let Some(t) = self.visit_concert(concert).await? {
|
||||
result.push(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items = new_items
|
||||
}
|
||||
|
||||
self.finish(result).await
|
||||
}
|
||||
|
||||
async fn finish(self, input: Vec<Self::T>) -> Result<Vec<Self::Output>>;
|
||||
}
|
||||
|
||||
/// Remove all duplicates from a [`Vec`].
|
||||
pub fn real_dedup_vec<T: Clone + Eq>(input: &mut Vec<T>) {
|
||||
let mut dedup = vec![];
|
||||
for item in input.clone() {
|
||||
if !dedup.contains(&item) {
|
||||
dedup.push(item);
|
||||
}
|
||||
}
|
||||
*input = dedup
|
||||
}
|
||||
|
|
@ -1,19 +1,22 @@
|
|||
use crunchyroll_rs::media::{StreamSubtitle, VariantData};
|
||||
use crunchyroll_rs::{Episode, Locale, Media, Movie};
|
||||
use log::warn;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use crate::utils::filter::real_dedup_vec;
|
||||
use crate::utils::log::tab_info;
|
||||
use crate::utils::os::is_special_file;
|
||||
use crunchyroll_rs::media::{Resolution, VariantData};
|
||||
use crunchyroll_rs::{Concert, Episode, Locale, Movie, MusicVideo};
|
||||
use log::{debug, info};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Format {
|
||||
pub struct SingleFormat {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
|
||||
pub audio: Locale,
|
||||
pub subtitles: Vec<StreamSubtitle>,
|
||||
pub subtitles: Vec<Locale>,
|
||||
|
||||
pub duration: Duration,
|
||||
pub stream: VariantData,
|
||||
pub resolution: Resolution,
|
||||
pub fps: f64,
|
||||
|
||||
pub series_id: String,
|
||||
pub series_name: String,
|
||||
|
|
@ -23,76 +26,148 @@ pub struct Format {
|
|||
pub season_number: u32,
|
||||
|
||||
pub episode_id: String,
|
||||
pub episode_number: f32,
|
||||
pub relative_episode_number: f32,
|
||||
pub episode_number: String,
|
||||
pub sequence_number: f32,
|
||||
pub relative_episode_number: Option<u32>,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
impl SingleFormat {
|
||||
pub fn new_from_episode(
|
||||
episode: &Media<Episode>,
|
||||
season_episodes: &Vec<Media<Episode>>,
|
||||
stream: VariantData,
|
||||
subtitles: Vec<StreamSubtitle>,
|
||||
episode: &Episode,
|
||||
video: &VariantData,
|
||||
subtitles: Vec<Locale>,
|
||||
relative_episode_number: Option<u32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
title: episode.title.clone(),
|
||||
description: episode.description.clone(),
|
||||
|
||||
audio: episode.metadata.audio_locale.clone(),
|
||||
audio: episode.audio_locale.clone(),
|
||||
subtitles,
|
||||
|
||||
duration: episode.metadata.duration.to_std().unwrap(),
|
||||
stream,
|
||||
|
||||
series_id: episode.metadata.series_id.clone(),
|
||||
series_name: episode.metadata.series_title.clone(),
|
||||
|
||||
season_id: episode.metadata.season_id.clone(),
|
||||
season_title: episode.metadata.season_title.clone(),
|
||||
season_number: episode.metadata.season_number.clone(),
|
||||
|
||||
resolution: video.resolution.clone(),
|
||||
fps: video.fps,
|
||||
series_id: episode.series_id.clone(),
|
||||
series_name: episode.series_title.clone(),
|
||||
season_id: episode.season_id.clone(),
|
||||
season_title: episode.season_title.to_string(),
|
||||
season_number: episode.season_number,
|
||||
episode_id: episode.id.clone(),
|
||||
episode_number: episode
|
||||
.metadata
|
||||
.episode
|
||||
.parse()
|
||||
.unwrap_or(episode.metadata.sequence_number),
|
||||
relative_episode_number: season_episodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, e)| if e == episode { Some((i + 1) as f32) } else { None })
|
||||
.unwrap_or_else(|| {
|
||||
warn!("Cannot find relative episode number for episode {} ({}) of season {} ({}) of {}, using normal episode number", episode.metadata.episode_number, episode.title, episode.metadata.season_number, episode.metadata.season_title, episode.metadata.series_title);
|
||||
episode
|
||||
.metadata
|
||||
.episode
|
||||
.parse()
|
||||
.unwrap_or(episode.metadata.sequence_number)
|
||||
}),
|
||||
episode_number: if episode.episode.is_empty() {
|
||||
episode.sequence_number.to_string()
|
||||
} else {
|
||||
episode.episode.clone()
|
||||
},
|
||||
sequence_number: episode.sequence_number,
|
||||
relative_episode_number,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_movie(movie: &Media<Movie>, stream: VariantData) -> Self {
|
||||
pub fn new_from_movie(movie: &Movie, video: &VariantData, subtitles: Vec<Locale>) -> Self {
|
||||
Self {
|
||||
title: movie.title.clone(),
|
||||
description: movie.description.clone(),
|
||||
|
||||
audio: Locale::ja_JP,
|
||||
|
||||
duration: movie.metadata.duration.to_std().unwrap(),
|
||||
stream,
|
||||
subtitles: vec![],
|
||||
|
||||
series_id: movie.metadata.movie_listing_id.clone(),
|
||||
series_name: movie.metadata.movie_listing_title.clone(),
|
||||
|
||||
season_id: movie.metadata.movie_listing_id.clone(),
|
||||
season_title: movie.metadata.movie_listing_title.clone(),
|
||||
subtitles,
|
||||
resolution: video.resolution.clone(),
|
||||
fps: video.fps,
|
||||
series_id: movie.movie_listing_id.clone(),
|
||||
series_name: movie.movie_listing_title.clone(),
|
||||
season_id: movie.movie_listing_id.clone(),
|
||||
season_title: movie.movie_listing_title.to_string(),
|
||||
season_number: 1,
|
||||
|
||||
episode_id: movie.id.clone(),
|
||||
episode_number: 1.0,
|
||||
relative_episode_number: 1.0,
|
||||
episode_number: "1".to_string(),
|
||||
sequence_number: 1.0,
|
||||
relative_episode_number: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_music_video(music_video: &MusicVideo, video: &VariantData) -> Self {
|
||||
Self {
|
||||
title: music_video.title.clone(),
|
||||
description: music_video.description.clone(),
|
||||
audio: Locale::ja_JP,
|
||||
subtitles: vec![],
|
||||
resolution: video.resolution.clone(),
|
||||
fps: video.fps,
|
||||
series_id: music_video.id.clone(),
|
||||
series_name: music_video.title.clone(),
|
||||
season_id: music_video.id.clone(),
|
||||
season_title: music_video.title.clone(),
|
||||
season_number: 1,
|
||||
episode_id: music_video.id.clone(),
|
||||
episode_number: "1".to_string(),
|
||||
sequence_number: 1.0,
|
||||
relative_episode_number: Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_concert(concert: &Concert, video: &VariantData) -> Self {
|
||||
Self {
|
||||
title: concert.title.clone(),
|
||||
description: concert.description.clone(),
|
||||
audio: Locale::ja_JP,
|
||||
subtitles: vec![],
|
||||
resolution: video.resolution.clone(),
|
||||
fps: video.fps,
|
||||
series_id: concert.id.clone(),
|
||||
series_name: concert.title.clone(),
|
||||
season_id: concert.id.clone(),
|
||||
season_title: concert.title.clone(),
|
||||
season_number: 1,
|
||||
episode_id: concert.id.clone(),
|
||||
episode_number: "1".to_string(),
|
||||
sequence_number: 1.0,
|
||||
relative_episode_number: Some(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Format {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
|
||||
pub locales: Vec<(Locale, Vec<Locale>)>,
|
||||
|
||||
pub resolution: Resolution,
|
||||
pub fps: f64,
|
||||
|
||||
pub series_id: String,
|
||||
pub series_name: String,
|
||||
|
||||
pub season_id: String,
|
||||
pub season_title: String,
|
||||
pub season_number: u32,
|
||||
|
||||
pub episode_id: String,
|
||||
pub episode_number: String,
|
||||
pub sequence_number: f32,
|
||||
pub relative_episode_number: Option<u32>,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn from_single_formats(mut single_formats: Vec<SingleFormat>) -> Self {
|
||||
let locales: Vec<(Locale, Vec<Locale>)> = single_formats
|
||||
.iter()
|
||||
.map(|sf| (sf.audio.clone(), sf.subtitles.clone()))
|
||||
.collect();
|
||||
let first = single_formats.remove(0);
|
||||
|
||||
Self {
|
||||
title: first.title,
|
||||
description: first.description,
|
||||
locales,
|
||||
resolution: first.resolution,
|
||||
fps: first.fps,
|
||||
series_id: first.series_id,
|
||||
series_name: first.series_name,
|
||||
season_id: first.season_id,
|
||||
season_title: first.season_title,
|
||||
season_number: first.season_number,
|
||||
episode_id: first.episode_id,
|
||||
episode_number: first.episode_number,
|
||||
sequence_number: first.sequence_number,
|
||||
relative_episode_number: first.relative_episode_number,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,11 +186,18 @@ impl Format {
|
|||
PathBuf::from(
|
||||
as_string
|
||||
.replace("{title}", &sanitize_func(&self.title))
|
||||
.replace("{audio}", &sanitize_func(&self.audio.to_string()))
|
||||
.replace(
|
||||
"{resolution}",
|
||||
&sanitize_func(&self.stream.resolution.to_string()),
|
||||
"{audio}",
|
||||
&sanitize_func(
|
||||
&self
|
||||
.locales
|
||||
.iter()
|
||||
.map(|(a, _)| a.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join("|"),
|
||||
),
|
||||
)
|
||||
.replace("{resolution}", &sanitize_func(&self.resolution.to_string()))
|
||||
.replace("{series_id}", &sanitize_func(&self.series_id))
|
||||
.replace("{series_name}", &sanitize_func(&self.series_name))
|
||||
.replace("{season_id}", &sanitize_func(&self.season_id))
|
||||
|
|
@ -131,12 +213,109 @@ impl Format {
|
|||
)
|
||||
.replace(
|
||||
"{relative_episode_number}",
|
||||
&sanitize_func(&format!("{:0>2}", self.relative_episode_number.to_string())),
|
||||
&sanitize_func(&format!(
|
||||
"{:0>2}",
|
||||
self.relative_episode_number.unwrap_or_default().to_string()
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn visual_output(&self, dst: &Path) {
|
||||
info!(
|
||||
"Downloading {} to '{}'",
|
||||
self.title,
|
||||
if is_special_file(&dst) {
|
||||
dst.to_str().unwrap()
|
||||
} else {
|
||||
dst.file_name().unwrap().to_str().unwrap()
|
||||
}
|
||||
);
|
||||
tab_info!(
|
||||
"Episode: S{:02}E{:0>2}",
|
||||
self.season_number,
|
||||
self.episode_number
|
||||
);
|
||||
tab_info!(
|
||||
"Audio: {}",
|
||||
self.locales
|
||||
.iter()
|
||||
.map(|(a, _)| a.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
);
|
||||
let mut subtitles: Vec<Locale> = self.locales.iter().flat_map(|(_, s)| s.clone()).collect();
|
||||
real_dedup_vec(&mut subtitles);
|
||||
tab_info!(
|
||||
"Subtitles: {}",
|
||||
subtitles
|
||||
.into_iter()
|
||||
.map(|l| l.to_string())
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
);
|
||||
tab_info!("Resolution: {}", self.resolution);
|
||||
tab_info!("FPS: {:.2}", self.fps)
|
||||
}
|
||||
|
||||
pub fn has_relative_episodes_fmt<S: AsRef<str>>(s: S) -> bool {
|
||||
return s.as_ref().contains("{relative_episode_number}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn formats_visual_output(formats: Vec<&Format>) {
|
||||
if log::max_level() == log::Level::Debug {
|
||||
let seasons = sort_formats_after_seasons(formats);
|
||||
debug!("Series has {} seasons", seasons.len());
|
||||
for (i, season) in seasons.into_iter().enumerate() {
|
||||
info!("Season {}", i + 1);
|
||||
for format in season {
|
||||
info!(
|
||||
"{}: {}px, {:.02} FPS (S{:02}E{:0>2})",
|
||||
format.title,
|
||||
format.resolution,
|
||||
format.fps,
|
||||
format.season_number,
|
||||
format.episode_number,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for season in sort_formats_after_seasons(formats) {
|
||||
let first = season.get(0).unwrap();
|
||||
info!("{} Season {}", first.series_name, first.season_number);
|
||||
|
||||
for (i, format) in season.into_iter().enumerate() {
|
||||
tab_info!(
|
||||
"{}. {} » {}px, {:.2} FPS (S{:02}E{:0>2})",
|
||||
i + 1,
|
||||
format.title,
|
||||
format.resolution,
|
||||
format.fps,
|
||||
format.season_number,
|
||||
format.episode_number
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_formats_after_seasons(formats: Vec<&Format>) -> Vec<Vec<&Format>> {
|
||||
let mut season_map = BTreeMap::new();
|
||||
|
||||
for format in formats {
|
||||
season_map
|
||||
.entry(format.season_number)
|
||||
.or_insert(vec![])
|
||||
.push(format)
|
||||
}
|
||||
|
||||
season_map
|
||||
.into_values()
|
||||
.into_iter()
|
||||
.map(|mut fmts| {
|
||||
fmts.sort_by(|a, b| a.sequence_number.total_cmp(&b.sequence_number));
|
||||
fmts
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,3 +13,17 @@ pub fn system_locale() -> Locale {
|
|||
Locale::en_US
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if [`Locale::Custom("all")`] is in the provided locale list and return [`Locale::all`] if
|
||||
/// so. If not, just return the provided locale list.
|
||||
pub fn all_locale_in_locales(locales: Vec<Locale>) -> Vec<Locale> {
|
||||
if locales
|
||||
.iter()
|
||||
.find(|l| l.to_string().to_lowercase().trim() == "all")
|
||||
.is_some()
|
||||
{
|
||||
Locale::all()
|
||||
} else {
|
||||
locales
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
use log::info;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use log::{
|
||||
info, set_boxed_logger, set_max_level, Level, LevelFilter, Log, Metadata, Record,
|
||||
SetLoggerError,
|
||||
};
|
||||
use std::io::{stdout, Write};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct ProgressHandler {
|
||||
pub(crate) stopped: bool,
|
||||
|
|
@ -28,3 +36,133 @@ macro_rules! progress {
|
|||
}
|
||||
}
|
||||
pub(crate) use progress;
|
||||
|
||||
macro_rules! tab_info {
|
||||
($($arg:tt)+) => {
|
||||
if log::max_level() == log::LevelFilter::Debug {
|
||||
info!($($arg)+)
|
||||
} else {
|
||||
info!("\t{}", format!($($arg)+))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) use tab_info;
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct CliLogger {
|
||||
all: bool,
|
||||
level: LevelFilter,
|
||||
progress: Mutex<Option<ProgressBar>>,
|
||||
}
|
||||
|
||||
impl Log for CliLogger {
|
||||
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||
metadata.level() <= self.level
|
||||
}
|
||||
|
||||
fn log(&self, record: &Record) {
|
||||
if !self.enabled(record.metadata())
|
||||
|| (record.target() != "progress"
|
||||
&& record.target() != "progress_end"
|
||||
&& (!self.all && !record.target().starts_with("crunchy_cli")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if self.level >= LevelFilter::Debug {
|
||||
self.extended(record);
|
||||
return;
|
||||
}
|
||||
|
||||
match record.target() {
|
||||
"progress" => self.progress(record, false),
|
||||
"progress_end" => self.progress(record, true),
|
||||
_ => {
|
||||
if self.progress.lock().unwrap().is_some() {
|
||||
self.progress(record, false)
|
||||
} else if record.level() > Level::Warn {
|
||||
self.normal(record)
|
||||
} else {
|
||||
self.error(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {
|
||||
let _ = stdout().flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl CliLogger {
|
||||
pub fn new(all: bool, level: LevelFilter) -> Self {
|
||||
Self {
|
||||
all,
|
||||
level,
|
||||
progress: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(all: bool, level: LevelFilter) -> Result<(), SetLoggerError> {
|
||||
set_max_level(level);
|
||||
set_boxed_logger(Box::new(CliLogger::new(all, level)))
|
||||
}
|
||||
|
||||
fn extended(&self, record: &Record) {
|
||||
println!(
|
||||
"[{}] {} {} ({}) {}",
|
||||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S"),
|
||||
record.level(),
|
||||
// replace the 'progress' prefix if this function is invoked via 'progress!'
|
||||
record
|
||||
.target()
|
||||
.replacen("crunchy_cli_core", "crunchy_cli", 1)
|
||||
.replacen("progress_end", "crunchy_cli", 1)
|
||||
.replacen("progress", "crunchy_cli", 1),
|
||||
format!("{:?}", thread::current().id())
|
||||
.replace("ThreadId(", "")
|
||||
.replace(')', ""),
|
||||
record.args()
|
||||
)
|
||||
}
|
||||
|
||||
fn normal(&self, record: &Record) {
|
||||
println!(":: {}", record.args())
|
||||
}
|
||||
|
||||
fn error(&self, record: &Record) {
|
||||
eprintln!(":: {}", record.args())
|
||||
}
|
||||
|
||||
fn progress(&self, record: &Record, stop: bool) {
|
||||
let mut progress = self.progress.lock().unwrap();
|
||||
|
||||
let msg = format!("{}", record.args());
|
||||
if stop && progress.is_some() {
|
||||
if msg.is_empty() {
|
||||
progress.take().unwrap().finish()
|
||||
} else {
|
||||
progress.take().unwrap().finish_with_message(msg)
|
||||
}
|
||||
} else if let Some(p) = &*progress {
|
||||
p.println(format!(":: → {}", msg))
|
||||
} else {
|
||||
#[cfg(not(windows))]
|
||||
let finish_str = "✔";
|
||||
#[cfg(windows)]
|
||||
// windows does not support all unicode characters by default in their consoles, so
|
||||
// we're using this (square root?) symbol instead. microsoft.
|
||||
let finish_str = "√";
|
||||
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::with_template(":: {spinner} {msg}")
|
||||
.unwrap()
|
||||
.tick_strings(&["—", "\\", "|", "/", finish_str]),
|
||||
);
|
||||
pb.enable_steady_tick(Duration::from_millis(200));
|
||||
pb.set_message(msg);
|
||||
*progress = Some(pb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
pub mod clap;
|
||||
pub mod context;
|
||||
pub mod download;
|
||||
pub mod ffmpeg;
|
||||
pub mod filter;
|
||||
pub mod format;
|
||||
pub mod locale;
|
||||
pub mod log;
|
||||
pub mod os;
|
||||
pub mod parse;
|
||||
pub mod sort;
|
||||
pub mod subtitle;
|
||||
pub mod video;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ pub fn tempfile<S: AsRef<str>>(suffix: S) -> io::Result<NamedTempFile> {
|
|||
|
||||
/// Check if the given path exists and rename it until the new (renamed) file does not exist.
|
||||
pub fn free_file(mut path: PathBuf) -> (PathBuf, bool) {
|
||||
// if it's a special file does not rename it
|
||||
// do not rename it if it exists but is a special file
|
||||
if is_special_file(&path) {
|
||||
return (path, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ use crunchyroll_rs::{Crunchyroll, MediaCollection, UrlType};
|
|||
use log::debug;
|
||||
use regex::Regex;
|
||||
|
||||
/// Define a filter, based on season and episode number to filter episodes / movies.
|
||||
/// If a struct instance equals the [`Default::default()`] it's considered that no filter is applied.
|
||||
/// Define a find, based on season and episode number to find episodes / movies.
|
||||
/// If a struct instance equals the [`Default::default()`] it's considered that no find is applied.
|
||||
/// If `from_*` is [`None`] they're set to [`u32::MIN`].
|
||||
/// If `to_*` is [`None`] they're set to [`u32::MAX`].
|
||||
#[derive(Debug)]
|
||||
|
|
@ -62,7 +62,7 @@ impl UrlFilter {
|
|||
/// - `...[S3,S5]` - Download episode 3 and 5.
|
||||
/// - `...[S1-S3,S4E2-S4E6]` - Download season 1 to 3 and episode 2 to episode 6 of season 4.
|
||||
|
||||
/// In practice, it would look like this: `https://beta.crunchyroll.com/series/12345678/example[S1E5-S3E2]`.
|
||||
/// In practice, it would look like this: `https://crunchyroll.com/series/12345678/example[S1E5-S3E2]`.
|
||||
pub async fn parse_url(
|
||||
crunchy: &Crunchyroll,
|
||||
mut url: String,
|
||||
|
|
@ -115,7 +115,7 @@ pub async fn parse_url(
|
|||
|
||||
let url_filter = UrlFilter { inner: filters };
|
||||
|
||||
debug!("Url filter: {:?}", url_filter);
|
||||
debug!("Url find: {:?}", url_filter);
|
||||
|
||||
url_filter
|
||||
} else {
|
||||
|
|
@ -125,9 +125,11 @@ pub async fn parse_url(
|
|||
let parsed_url = crunchyroll_rs::parse_url(url).map_or(Err(anyhow!("Invalid url")), Ok)?;
|
||||
debug!("Url type: {:?}", parsed_url);
|
||||
let media_collection = match parsed_url {
|
||||
UrlType::Series(id) | UrlType::MovieListing(id) | UrlType::EpisodeOrMovie(id) => {
|
||||
crunchy.media_collection_from_id(id).await?
|
||||
}
|
||||
UrlType::Series(id)
|
||||
| UrlType::MovieListing(id)
|
||||
| UrlType::EpisodeOrMovie(id)
|
||||
| UrlType::MusicVideo(id)
|
||||
| UrlType::Concert(id) => crunchy.media_collection_from_id(id).await?,
|
||||
};
|
||||
|
||||
Ok((media_collection, url_filter))
|
||||
|
|
@ -150,7 +152,7 @@ pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
|
|||
} else if resolution.ends_with('p') {
|
||||
let without_p = resolution.as_str()[0..resolution.len() - 1]
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Could not parse resolution"))?;
|
||||
.map_err(|_| anyhow!("Could not find resolution"))?;
|
||||
Ok(Resolution {
|
||||
width: without_p * 16 / 9,
|
||||
height: without_p,
|
||||
|
|
@ -159,12 +161,12 @@ pub fn parse_resolution(mut resolution: String) -> Result<Resolution> {
|
|||
Ok(Resolution {
|
||||
width: w
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Could not parse resolution"))?,
|
||||
.map_err(|_| anyhow!("Could not find resolution"))?,
|
||||
height: h
|
||||
.parse()
|
||||
.map_err(|_| anyhow!("Could not parse resolution"))?,
|
||||
.map_err(|_| anyhow!("Could not find resolution"))?,
|
||||
})
|
||||
} else {
|
||||
bail!("Could not parse resolution")
|
||||
bail!("Could not find resolution")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
use crate::utils::format::Format;
|
||||
use crunchyroll_rs::{Media, Season};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Sort seasons after their season number. Crunchyroll may have multiple seasons for one season
|
||||
/// number. They generally store different language in individual seasons with the same season number.
|
||||
/// E.g. series X has one official season but crunchy has translations for it in 3 different languages
|
||||
/// so there exist 3 different "seasons" on Crunchyroll which are actual the same season but with
|
||||
/// different audio.
|
||||
pub fn sort_seasons_after_number(seasons: Vec<Media<Season>>) -> Vec<Vec<Media<Season>>> {
|
||||
let mut as_map = BTreeMap::new();
|
||||
|
||||
for season in seasons {
|
||||
as_map
|
||||
.entry(season.metadata.season_number)
|
||||
.or_insert_with(Vec::new);
|
||||
as_map
|
||||
.get_mut(&season.metadata.season_number)
|
||||
.unwrap()
|
||||
.push(season)
|
||||
}
|
||||
|
||||
as_map.into_values().collect()
|
||||
}
|
||||
|
||||
/// Sort formats after their seasons and episodes (inside it) ascending. Make sure to have only
|
||||
/// episodes from one series and in one language as argument since the function does not handle those
|
||||
/// differences which could then lead to a semi messed up result.
|
||||
pub fn sort_formats_after_seasons(formats: Vec<Format>) -> Vec<Vec<Format>> {
|
||||
let mut as_map = BTreeMap::new();
|
||||
|
||||
for format in formats {
|
||||
// the season title is used as key instead of season number to distinguish duplicated season
|
||||
// numbers which are actually two different seasons; season id is not used as this somehow
|
||||
// messes up ordering when duplicated seasons exist
|
||||
as_map
|
||||
.entry(format.season_title.clone())
|
||||
.or_insert_with(Vec::new);
|
||||
as_map.get_mut(&format.season_title).unwrap().push(format);
|
||||
}
|
||||
|
||||
let mut sorted = as_map
|
||||
.into_iter()
|
||||
.map(|(_, mut values)| {
|
||||
values.sort_by(|a, b| a.episode_number.total_cmp(&b.episode_number));
|
||||
values
|
||||
})
|
||||
.collect::<Vec<Vec<Format>>>();
|
||||
sorted.sort_by(|a, b| a[0].season_number.cmp(&b[0].season_number));
|
||||
|
||||
sorted
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
use crate::utils::os::tempfile;
|
||||
use anyhow::Result;
|
||||
use chrono::NaiveTime;
|
||||
use crunchyroll_rs::media::StreamSubtitle;
|
||||
use crunchyroll_rs::Locale;
|
||||
use regex::Regex;
|
||||
use std::io::Write;
|
||||
use tempfile::TempPath;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Subtitle {
|
||||
pub stream_subtitle: StreamSubtitle,
|
||||
pub audio_locale: Locale,
|
||||
pub episode_id: String,
|
||||
pub forced: bool,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
pub async fn download_subtitle(
|
||||
subtitle: StreamSubtitle,
|
||||
max_length: NaiveTime,
|
||||
) -> Result<TempPath> {
|
||||
let tempfile = tempfile(".ass")?;
|
||||
let (mut file, path) = tempfile.into_parts();
|
||||
|
||||
let mut buf = vec![];
|
||||
subtitle.write_to(&mut buf).await?;
|
||||
buf = fix_subtitle_look_and_feel(buf);
|
||||
buf = fix_subtitle_length(buf, max_length);
|
||||
|
||||
file.write_all(buf.as_slice())?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Add `ScaledBorderAndShadows: yes` to subtitles; without it they look very messy on some video
|
||||
/// players. See [crunchy-labs/crunchy-cli#66](https://github.com/crunchy-labs/crunchy-cli/issues/66)
|
||||
/// for more information.
|
||||
fn fix_subtitle_look_and_feel(raw: Vec<u8>) -> Vec<u8> {
|
||||
let mut script_info = false;
|
||||
let mut new = String::new();
|
||||
|
||||
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
|
||||
if line.trim().starts_with('[') && script_info {
|
||||
new.push_str("ScaledBorderAndShadow: yes\n");
|
||||
script_info = false
|
||||
} else if line.trim() == "[Script Info]" {
|
||||
script_info = true
|
||||
}
|
||||
new.push_str(line);
|
||||
new.push('\n')
|
||||
}
|
||||
|
||||
new.into_bytes()
|
||||
}
|
||||
|
||||
/// Fix the length of subtitles to a specified maximum amount. This is required because sometimes
|
||||
/// subtitles have an unnecessary entry long after the actual video ends with artificially extends
|
||||
/// the video length on some video players. To prevent this, the video length must be hard set. See
|
||||
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
|
||||
/// information.
|
||||
fn fix_subtitle_length(raw: Vec<u8>, max_length: NaiveTime) -> Vec<u8> {
|
||||
let re =
|
||||
Regex::new(r#"^Dialogue:\s\d+,(?P<start>\d+:\d+:\d+\.\d+),(?P<end>\d+:\d+:\d+\.\d+),"#)
|
||||
.unwrap();
|
||||
|
||||
// chrono panics if we try to format NaiveTime with `%2f` and the nano seconds has more than 2
|
||||
// digits so them have to be reduced manually to avoid the panic
|
||||
fn format_naive_time(native_time: NaiveTime) -> String {
|
||||
let formatted_time = native_time.format("%f").to_string();
|
||||
format!(
|
||||
"{}.{}",
|
||||
native_time.format("%T"),
|
||||
if formatted_time.len() <= 2 {
|
||||
native_time.format("%2f").to_string()
|
||||
} else {
|
||||
formatted_time.split_at(2).0.parse().unwrap()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let length_as_string = format_naive_time(max_length);
|
||||
let mut new = String::new();
|
||||
|
||||
for line in String::from_utf8_lossy(raw.as_slice()).split('\n') {
|
||||
if let Some(capture) = re.captures(line) {
|
||||
let start = capture.name("start").map_or(NaiveTime::default(), |s| {
|
||||
NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap()
|
||||
});
|
||||
let end = capture.name("end").map_or(NaiveTime::default(), |s| {
|
||||
NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S.%f").unwrap()
|
||||
});
|
||||
|
||||
if start > max_length {
|
||||
continue;
|
||||
} else if end > max_length {
|
||||
new.push_str(
|
||||
re.replace(
|
||||
line,
|
||||
format!(
|
||||
"Dialogue: {},{},",
|
||||
format_naive_time(start),
|
||||
&length_as_string
|
||||
),
|
||||
)
|
||||
.to_string()
|
||||
.as_str(),
|
||||
)
|
||||
} else {
|
||||
new.push_str(line)
|
||||
}
|
||||
} else {
|
||||
new.push_str(line)
|
||||
}
|
||||
new.push('\n')
|
||||
}
|
||||
|
||||
new.into_bytes()
|
||||
}
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
use anyhow::Result;
|
||||
use chrono::NaiveTime;
|
||||
use regex::Regex;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use crunchyroll_rs::media::{Resolution, Stream, VariantData};
|
||||
|
||||
/// Get the length of a video. This is required because sometimes subtitles have an unnecessary entry
|
||||
/// long after the actual video ends with artificially extends the video length on some video players.
|
||||
/// To prevent this, the video length must be hard set. See
|
||||
/// [crunchy-labs/crunchy-cli#32](https://github.com/crunchy-labs/crunchy-cli/issues/32) for more
|
||||
/// information.
|
||||
pub fn get_video_length(path: PathBuf) -> Result<NaiveTime> {
|
||||
let video_length = Regex::new(r"Duration:\s(?P<time>\d+:\d+:\d+\.\d+),")?;
|
||||
pub async fn variant_data_from_stream(
|
||||
stream: &Stream,
|
||||
resolution: &Resolution,
|
||||
) -> Result<Option<(VariantData, VariantData)>> {
|
||||
let mut streaming_data = stream.dash_streaming_data(None).await?;
|
||||
streaming_data
|
||||
.0
|
||||
.sort_by(|a, b| a.bandwidth.cmp(&b.bandwidth).reverse());
|
||||
streaming_data
|
||||
.1
|
||||
.sort_by(|a, b| a.bandwidth.cmp(&b.bandwidth).reverse());
|
||||
|
||||
let ffmpeg = Command::new("ffmpeg")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.arg("-y")
|
||||
.args(["-i", path.to_str().unwrap()])
|
||||
.output()?;
|
||||
let ffmpeg_output = String::from_utf8(ffmpeg.stderr)?;
|
||||
let caps = video_length.captures(ffmpeg_output.as_str()).unwrap();
|
||||
|
||||
Ok(NaiveTime::parse_from_str(caps.name("time").unwrap().as_str(), "%H:%M:%S%.f").unwrap())
|
||||
let video_variant = match resolution.height {
|
||||
u64::MAX => Some(streaming_data.0.into_iter().next().unwrap()),
|
||||
u64::MIN => Some(streaming_data.0.into_iter().last().unwrap()),
|
||||
_ => streaming_data
|
||||
.0
|
||||
.into_iter()
|
||||
.find(|v| resolution.height == v.resolution.height),
|
||||
};
|
||||
Ok(video_variant.map(|v| (v, streaming_data.1.first().unwrap().clone())))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue