mirror of
https://github.com/crunchy-labs/crunchy-cli.git
synced 2026-01-21 12:12:00 -06:00
Add --include-chapters flag to archive and download (#301)
This commit is contained in:
parent
9c44fa7dae
commit
56f0ed1795
6 changed files with 272 additions and 191 deletions
|
|
@ -1,6 +1,8 @@
|
|||
use crate::archive::filter::ArchiveFilter;
|
||||
use crate::utils::context::Context;
|
||||
use crate::utils::download::{DownloadBuilder, DownloadFormat, MergeBehavior};
|
||||
use crate::utils::download::{
|
||||
DownloadBuilder, DownloadFormat, DownloadFormatMetadata, MergeBehavior,
|
||||
};
|
||||
use crate::utils::ffmpeg::FFmpegPreset;
|
||||
use crate::utils::filter::Filter;
|
||||
use crate::utils::format::{Format, SingleFormat};
|
||||
|
|
@ -127,6 +129,17 @@ pub struct Archive {
|
|||
#[arg(help = "Include fonts in the downloaded file")]
|
||||
#[arg(long)]
|
||||
pub(crate) include_fonts: bool,
|
||||
#[arg(
|
||||
help = "Includes chapters (e.g. intro, credits, ...). Only works if `--merge` is set to 'audio'"
|
||||
)]
|
||||
#[arg(
|
||||
long_help = "Includes chapters (e.g. intro, credits, ...). . Only works if `--merge` is set to 'audio'. \
|
||||
Because chapters are essentially only special timeframes in episodes like the intro, most of the video timeline isn't covered by a chapter.
|
||||
These \"gaps\" are filled with an 'Episode' chapter because many video players are ignore those gaps and just assume that a chapter ends when the next chapter start is reached, even if a specific end-time is set.
|
||||
Also chapters aren't always available, so in this case, just a big 'Episode' chapter from start to end will be created"
|
||||
)]
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub(crate) include_chapters: bool,
|
||||
|
||||
#[arg(help = "Omit closed caption subtitles in the downloaded file")]
|
||||
#[arg(long, default_value_t = false)]
|
||||
|
|
@ -188,6 +201,10 @@ impl Execute for Archive {
|
|||
}
|
||||
}
|
||||
|
||||
if self.include_chapters && !matches!(self.merge, MergeBehavior::Audio) {
|
||||
bail!("`--include-chapters` can only be used if `--merge` is set to 'audio'")
|
||||
}
|
||||
|
||||
if self.output.contains("{resolution}")
|
||||
|| self
|
||||
.output_specials
|
||||
|
|
@ -446,6 +463,7 @@ async fn get_format(
|
|||
video: (video, single_format.audio.clone()),
|
||||
audios: vec![(audio, single_format.audio.clone())],
|
||||
subtitles,
|
||||
metadata: DownloadFormatMetadata { skip_events: None },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +482,9 @@ async fn get_format(
|
|||
.iter()
|
||||
.flat_map(|(_, _, _, subtitles)| subtitles.clone())
|
||||
.collect(),
|
||||
metadata: DownloadFormatMetadata {
|
||||
skip_events: format_pairs.first().unwrap().0.skip_events().await?,
|
||||
},
|
||||
}),
|
||||
MergeBehavior::Auto => {
|
||||
let mut d_formats: Vec<(Duration, DownloadFormat)> = vec![];
|
||||
|
|
@ -498,6 +519,7 @@ async fn get_format(
|
|||
video: (video, single_format.audio.clone()),
|
||||
audios: vec![(audio, single_format.audio.clone())],
|
||||
subtitles,
|
||||
metadata: DownloadFormatMetadata { skip_events: None },
|
||||
},
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::download::filter::DownloadFilter;
|
||||
use crate::utils::context::Context;
|
||||
use crate::utils::download::{DownloadBuilder, DownloadFormat};
|
||||
use crate::utils::download::{DownloadBuilder, DownloadFormat, DownloadFormatMetadata};
|
||||
use crate::utils::ffmpeg::{FFmpegPreset, SOFTSUB_CONTAINERS};
|
||||
use crate::utils::filter::Filter;
|
||||
use crate::utils::format::{Format, SingleFormat};
|
||||
|
|
@ -101,6 +101,14 @@ pub struct Download {
|
|||
#[arg(long, default_value_t = false)]
|
||||
pub(crate) skip_specials: bool,
|
||||
|
||||
#[arg(help = "Includes chapters (e.g. intro, credits, ...)")]
|
||||
#[arg(long_help = "Includes chapters (e.g. intro, credits, ...). \
|
||||
Because chapters are essentially only special timeframes in episodes like the intro, most of the video timeline isn't covered by a chapter.
|
||||
These \"gaps\" are filled with an 'Episode' chapter because many video players are ignore those gaps and just assume that a chapter ends when the next chapter start is reached, even if a specific end-time is set.
|
||||
Also chapters aren't always available, so in this case, just a big 'Episode' chapter from start to end will be created")]
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub(crate) include_chapters: bool,
|
||||
|
||||
#[arg(help = "Skip any interactive input")]
|
||||
#[arg(short, long, default_value_t = false)]
|
||||
pub(crate) yes: bool,
|
||||
|
|
@ -342,6 +350,13 @@ async fn get_format(
|
|||
single_format.audio == Locale::ja_JP || stream.subtitles.len() > 1,
|
||||
)]
|
||||
}),
|
||||
metadata: DownloadFormatMetadata {
|
||||
skip_events: if download.include_chapters {
|
||||
single_format.skip_events().await?
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
};
|
||||
let mut format = Format::from_single_formats(vec![(
|
||||
single_format.clone(),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::utils::os::{cache_dir, is_special_file, temp_directory, temp_named_pi
|
|||
use crate::utils::rate_limit::RateLimiterService;
|
||||
use anyhow::{bail, Result};
|
||||
use chrono::NaiveTime;
|
||||
use crunchyroll_rs::media::{Subtitle, VariantData, VariantSegment};
|
||||
use crunchyroll_rs::media::{SkipEvents, SkipEventsEvent, Subtitle, VariantData, VariantSegment};
|
||||
use crunchyroll_rs::Locale;
|
||||
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressFinish, ProgressStyle};
|
||||
use log::{debug, warn, LevelFilter};
|
||||
|
|
@ -113,6 +113,11 @@ pub struct DownloadFormat {
|
|||
pub video: (VariantData, Locale),
|
||||
pub audios: Vec<(VariantData, Locale)>,
|
||||
pub subtitles: Vec<(Subtitle, bool)>,
|
||||
pub metadata: DownloadFormatMetadata,
|
||||
}
|
||||
|
||||
pub struct DownloadFormatMetadata {
|
||||
pub skip_events: Option<SkipEvents>,
|
||||
}
|
||||
|
||||
pub struct Downloader {
|
||||
|
|
@ -205,6 +210,8 @@ impl Downloader {
|
|||
let mut audios = vec![];
|
||||
let mut subtitles = vec![];
|
||||
let mut fonts = vec![];
|
||||
let mut chapters = None;
|
||||
let mut max_len = NaiveTime::MIN;
|
||||
let mut max_frames = 0f64;
|
||||
let fmt_space = self
|
||||
.formats
|
||||
|
|
@ -243,6 +250,9 @@ impl Downloader {
|
|||
}
|
||||
|
||||
let (len, fps) = get_video_stats(&video_path)?;
|
||||
if max_len < len {
|
||||
max_len = len
|
||||
}
|
||||
let frames = len.signed_duration_since(NaiveTime::MIN).num_seconds() as f64 * fps;
|
||||
if frames > max_frames {
|
||||
max_frames = frames;
|
||||
|
|
@ -322,6 +332,22 @@ impl Downloader {
|
|||
format!("#{}", i + 1)
|
||||
},
|
||||
});
|
||||
|
||||
if let Some(skip_events) = &format.metadata.skip_events {
|
||||
let (file, path) = tempfile(".chapter")?.into_parts();
|
||||
chapters = Some((
|
||||
(file, path),
|
||||
[
|
||||
skip_events.recap.as_ref().map(|e| ("Recap", e)),
|
||||
skip_events.intro.as_ref().map(|e| ("Intro", e)),
|
||||
skip_events.credits.as_ref().map(|e| ("Credits", e)),
|
||||
skip_events.preview.as_ref().map(|e| ("Preview", e)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<(&str, &SkipEventsEvent)>>(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if self.download_fonts
|
||||
|
|
@ -440,6 +466,20 @@ impl Downloader {
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(((file, path), chapters)) = chapters.as_mut() {
|
||||
write_ffmpeg_chapters(file, max_len, chapters)?;
|
||||
input.extend(["-i".to_string(), path.to_string_lossy().to_string()]);
|
||||
maps.extend([
|
||||
"-map_metadata".to_string(),
|
||||
(videos.len()
|
||||
+ audios.len()
|
||||
+ container_supports_softsubs
|
||||
.then_some(subtitles.len())
|
||||
.unwrap_or_default())
|
||||
.to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
let preset_custom = matches!(self.ffmpeg_preset, FFmpegPreset::Custom(_));
|
||||
let (input_presets, mut output_presets) = self.ffmpeg_preset.into_input_output_args();
|
||||
let fifo = temp_named_pipe()?;
|
||||
|
|
@ -1156,6 +1196,54 @@ fn fix_subtitles(raw: &mut Vec<u8>, max_length: NaiveTime) {
|
|||
*raw = as_lines.join("\n").into_bytes()
|
||||
}
|
||||
|
||||
fn write_ffmpeg_chapters(
|
||||
file: &mut fs::File,
|
||||
video_len: NaiveTime,
|
||||
events: &mut Vec<(&str, &SkipEventsEvent)>,
|
||||
) -> Result<()> {
|
||||
let video_len = video_len
|
||||
.signed_duration_since(NaiveTime::MIN)
|
||||
.num_seconds() as u32;
|
||||
events.sort_by(|(_, event_a), (_, event_b)| event_a.start.cmp(&event_b.start));
|
||||
|
||||
writeln!(file, ";FFMETADATA1")?;
|
||||
|
||||
let mut last_end_time = 0;
|
||||
for (name, event) in events {
|
||||
// include an extra 'Episode' chapter if the start of the current chapter is more than 10
|
||||
// seconds later than the end of the last chapter.
|
||||
// this is done before writing the actual chapter of this loop to keep the chapter
|
||||
// chronologically in order
|
||||
if event.start as i32 - last_end_time as i32 > 10 {
|
||||
writeln!(file, "[CHAPTER]")?;
|
||||
writeln!(file, "TIMEBASE=1/1")?;
|
||||
writeln!(file, "START={}", last_end_time)?;
|
||||
writeln!(file, "END={}", event.start)?;
|
||||
writeln!(file, "title=Episode")?;
|
||||
}
|
||||
|
||||
writeln!(file, "[CHAPTER]")?;
|
||||
writeln!(file, "TIMEBASE=1/1")?;
|
||||
writeln!(file, "START={}", event.start)?;
|
||||
writeln!(file, "END={}", event.end)?;
|
||||
writeln!(file, "title={}", name)?;
|
||||
|
||||
last_end_time = event.end;
|
||||
}
|
||||
|
||||
// only add a traling chapter if the gab between the end of the last chapter and the total video
|
||||
// length is greater than 10 seconds
|
||||
if video_len as i32 - last_end_time as i32 > 10 {
|
||||
writeln!(file, "[CHAPTER]")?;
|
||||
writeln!(file, "TIMEBASE=1/1")?;
|
||||
writeln!(file, "START={}", last_end_time)?;
|
||||
writeln!(file, "END={}", video_len)?;
|
||||
writeln!(file, "title=Episode")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ffmpeg_progress<R: AsyncReadExt + Unpin>(
|
||||
total_frames: u64,
|
||||
stats: R,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::utils::log::tab_info;
|
|||
use crate::utils::os::{is_special_file, sanitize};
|
||||
use anyhow::Result;
|
||||
use chrono::{Datelike, Duration};
|
||||
use crunchyroll_rs::media::{Resolution, Stream, Subtitle, VariantData};
|
||||
use crunchyroll_rs::media::{Resolution, SkipEvents, Stream, Subtitle, VariantData};
|
||||
use crunchyroll_rs::{Concert, Episode, Locale, MediaCollection, Movie, MusicVideo};
|
||||
use log::{debug, info};
|
||||
use std::cmp::Ordering;
|
||||
|
|
@ -175,6 +175,14 @@ impl SingleFormat {
|
|||
Ok(stream)
|
||||
}
|
||||
|
||||
pub async fn skip_events(&self) -> Result<Option<SkipEvents>> {
|
||||
match &self.source {
|
||||
MediaCollection::Episode(e) => Ok(Some(e.skip_events().await?)),
|
||||
MediaCollection::Movie(m) => Ok(Some(m.skip_events().await?)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_type(&self) -> String {
|
||||
match &self.source {
|
||||
MediaCollection::Episode(_) => "episode",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue