mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-08 09:06:53 +08:00
feat: 后端支持嵌入章节元数据
This commit is contained in:
@@ -20,6 +20,7 @@ pub struct Config {
|
|||||||
pub download_video: bool,
|
pub download_video: bool,
|
||||||
pub download_audio: bool,
|
pub download_audio: bool,
|
||||||
pub auto_merge: bool,
|
pub auto_merge: bool,
|
||||||
|
pub embed_chapter: bool,
|
||||||
pub download_xml_danmaku: bool,
|
pub download_xml_danmaku: bool,
|
||||||
pub download_ass_danmaku: bool,
|
pub download_ass_danmaku: bool,
|
||||||
pub download_json_danmaku: bool,
|
pub download_json_danmaku: bool,
|
||||||
@@ -104,6 +105,7 @@ impl Config {
|
|||||||
download_video: true,
|
download_video: true,
|
||||||
download_audio: true,
|
download_audio: true,
|
||||||
auto_merge: true,
|
auto_merge: true,
|
||||||
|
embed_chapter: true,
|
||||||
download_xml_danmaku: true,
|
download_xml_danmaku: true,
|
||||||
download_ass_danmaku: true,
|
download_ass_danmaku: true,
|
||||||
download_json_danmaku: true,
|
download_json_danmaku: true,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use crate::{
|
|||||||
config::Config,
|
config::Config,
|
||||||
downloader::tasks::{
|
downloader::tasks::{
|
||||||
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
||||||
json_task::JsonTask, merge_task::MergeTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
embed_chapter_task::EmbedChapterTask, json_task::JsonTask, merge_task::MergeTask,
|
||||||
video_task::VideoTask,
|
nfo_task::NfoTask, subtitle_task::SubtitleTask, video_task::VideoTask,
|
||||||
},
|
},
|
||||||
extensions::AppHandleExt,
|
extensions::AppHandleExt,
|
||||||
types::{
|
types::{
|
||||||
@@ -53,6 +53,7 @@ pub struct DownloadProgress {
|
|||||||
pub video_task: VideoTask,
|
pub video_task: VideoTask,
|
||||||
pub audio_task: AudioTask,
|
pub audio_task: AudioTask,
|
||||||
pub merge_task: MergeTask,
|
pub merge_task: MergeTask,
|
||||||
|
pub embed_chapter_task: EmbedChapterTask,
|
||||||
pub subtitle_task: SubtitleTask,
|
pub subtitle_task: SubtitleTask,
|
||||||
pub danmaku_task: DanmakuTask,
|
pub danmaku_task: DanmakuTask,
|
||||||
pub cover_task: CoverTask,
|
pub cover_task: CoverTask,
|
||||||
@@ -125,6 +126,7 @@ impl DownloadProgress {
|
|||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
merge_task: tasks.merge,
|
||||||
|
embed_chapter_task: tasks.embed_chapter,
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -176,6 +178,7 @@ impl DownloadProgress {
|
|||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
merge_task: tasks.merge,
|
||||||
|
embed_chapter_task: tasks.embed_chapter,
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -320,6 +323,7 @@ impl DownloadProgress {
|
|||||||
self.video_task.is_completed()
|
self.video_task.is_completed()
|
||||||
&& self.audio_task.is_completed()
|
&& self.audio_task.is_completed()
|
||||||
&& self.merge_task.is_completed()
|
&& self.merge_task.is_completed()
|
||||||
|
&& self.embed_chapter_task.is_completed()
|
||||||
&& self.danmaku_task.is_completed()
|
&& self.danmaku_task.is_completed()
|
||||||
&& self.subtitle_task.is_completed()
|
&& self.subtitle_task.is_completed()
|
||||||
&& self.cover_task.is_completed()
|
&& self.cover_task.is_completed()
|
||||||
@@ -331,6 +335,7 @@ impl DownloadProgress {
|
|||||||
self.video_task.mark_uncompleted();
|
self.video_task.mark_uncompleted();
|
||||||
self.audio_task.mark_uncompleted();
|
self.audio_task.mark_uncompleted();
|
||||||
self.merge_task.completed = false;
|
self.merge_task.completed = false;
|
||||||
|
self.embed_chapter_task.completed = false;
|
||||||
self.danmaku_task.completed = false;
|
self.danmaku_task.completed = false;
|
||||||
self.subtitle_task.completed = false;
|
self.subtitle_task.completed = false;
|
||||||
self.cover_task.completed = false;
|
self.cover_task.completed = false;
|
||||||
@@ -384,6 +389,7 @@ fn create_normal_progresses_for_single(
|
|||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
merge_task: tasks.merge,
|
||||||
|
embed_chapter_task: tasks.embed_chapter,
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -424,6 +430,7 @@ fn create_normal_progresses_for_single(
|
|||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
merge_task: tasks.merge,
|
||||||
|
embed_chapter_task: tasks.embed_chapter,
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -464,6 +471,7 @@ fn create_normal_progresses_for_single(
|
|||||||
video_task: tasks.video.clone(),
|
video_task: tasks.video.clone(),
|
||||||
audio_task: tasks.audio.clone(),
|
audio_task: tasks.audio.clone(),
|
||||||
merge_task: tasks.merge.clone(),
|
merge_task: tasks.merge.clone(),
|
||||||
|
embed_chapter_task: tasks.embed_chapter.clone(),
|
||||||
danmaku_task: tasks.danmaku.clone(),
|
danmaku_task: tasks.danmaku.clone(),
|
||||||
subtitle_task: tasks.subtitle.clone(),
|
subtitle_task: tasks.subtitle.clone(),
|
||||||
cover_task: tasks.cover.clone(),
|
cover_task: tasks.cover.clone(),
|
||||||
@@ -536,6 +544,7 @@ fn create_normal_progresses_for_season(
|
|||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
merge_task: tasks.merge,
|
||||||
|
embed_chapter_task: tasks.embed_chapter,
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -576,6 +585,7 @@ fn create_normal_progresses_for_season(
|
|||||||
video_task: tasks.video,
|
video_task: tasks.video,
|
||||||
audio_task: tasks.audio,
|
audio_task: tasks.audio,
|
||||||
merge_task: tasks.merge,
|
merge_task: tasks.merge,
|
||||||
|
embed_chapter_task: tasks.embed_chapter,
|
||||||
danmaku_task: tasks.danmaku,
|
danmaku_task: tasks.danmaku,
|
||||||
subtitle_task: tasks.subtitle,
|
subtitle_task: tasks.subtitle,
|
||||||
cover_task: tasks.cover,
|
cover_task: tasks.cover,
|
||||||
@@ -617,6 +627,7 @@ fn create_normal_progresses_for_season(
|
|||||||
video_task: tasks.video.clone(),
|
video_task: tasks.video.clone(),
|
||||||
audio_task: tasks.audio.clone(),
|
audio_task: tasks.audio.clone(),
|
||||||
merge_task: tasks.merge.clone(),
|
merge_task: tasks.merge.clone(),
|
||||||
|
embed_chapter_task: tasks.embed_chapter.clone(),
|
||||||
danmaku_task: tasks.danmaku.clone(),
|
danmaku_task: tasks.danmaku.clone(),
|
||||||
subtitle_task: tasks.subtitle.clone(),
|
subtitle_task: tasks.subtitle.clone(),
|
||||||
cover_task: tasks.cover.clone(),
|
cover_task: tasks.cover.clone(),
|
||||||
@@ -639,6 +650,7 @@ struct Tasks {
|
|||||||
video: VideoTask,
|
video: VideoTask,
|
||||||
audio: AudioTask,
|
audio: AudioTask,
|
||||||
merge: MergeTask,
|
merge: MergeTask,
|
||||||
|
embed_chapter: EmbedChapterTask,
|
||||||
danmaku: DanmakuTask,
|
danmaku: DanmakuTask,
|
||||||
subtitle: SubtitleTask,
|
subtitle: SubtitleTask,
|
||||||
cover: CoverTask,
|
cover: CoverTask,
|
||||||
@@ -672,6 +684,11 @@ impl Tasks {
|
|||||||
completed: false,
|
completed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let embed_chapter = EmbedChapterTask {
|
||||||
|
selected: config.embed_chapter,
|
||||||
|
completed: false,
|
||||||
|
};
|
||||||
|
|
||||||
let danmaku = DanmakuTask {
|
let danmaku = DanmakuTask {
|
||||||
xml_selected: config.download_xml_danmaku,
|
xml_selected: config.download_xml_danmaku,
|
||||||
ass_selected: config.download_ass_danmaku,
|
ass_selected: config.download_ass_danmaku,
|
||||||
@@ -704,6 +721,7 @@ impl Tasks {
|
|||||||
video,
|
video,
|
||||||
audio,
|
audio,
|
||||||
merge,
|
merge,
|
||||||
|
embed_chapter,
|
||||||
danmaku,
|
danmaku,
|
||||||
subtitle,
|
subtitle,
|
||||||
cover,
|
cover,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs::{File, OpenOptions},
|
fs::{File, OpenOptions},
|
||||||
io::{Seek, Write},
|
io::{Seek, Write as IoWrite},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
@@ -17,7 +17,6 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
bili_client::BiliClient,
|
|
||||||
danmaku_xml_to_ass::xml_to_ass,
|
danmaku_xml_to_ass::xml_to_ass,
|
||||||
downloader::episode_type::EpisodeType,
|
downloader::episode_type::EpisodeType,
|
||||||
events::DownloadEvent,
|
events::DownloadEvent,
|
||||||
@@ -27,6 +26,7 @@ use crate::{
|
|||||||
create_download_task_params::CreateDownloadTaskParams,
|
create_download_task_params::CreateDownloadTaskParams,
|
||||||
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
||||||
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
|
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
|
||||||
|
player_info::PlayerInfo,
|
||||||
},
|
},
|
||||||
utils::{self, ToXml},
|
utils::{self, ToXml},
|
||||||
};
|
};
|
||||||
@@ -288,6 +288,9 @@ impl DownloadTask {
|
|||||||
episode_dir.display()
|
episode_dir.display()
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
|
let mut player_info = None;
|
||||||
|
let mut episode_info = None;
|
||||||
|
|
||||||
if !progress.video_task.is_completed() && progress.video_task.content_length != 0 {
|
if !progress.video_task.is_completed() && progress.video_task.content_length != 0 {
|
||||||
// 如果视频任务被选中且未完成且有要下载的内容,则下载视频
|
// 如果视频任务被选中且未完成且有要下载的内容,则下载视频
|
||||||
self.download_video(&progress)
|
self.download_video(&progress)
|
||||||
@@ -304,11 +307,29 @@ impl DownloadTask {
|
|||||||
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
tracing::debug!("{ids_string} `{filename}`音频下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.merge_task.is_completed() {
|
let merge_completed = progress.merge_task.is_completed();
|
||||||
|
let embed_completed = progress.embed_chapter_task.is_completed();
|
||||||
|
|
||||||
|
if !merge_completed && !embed_completed {
|
||||||
|
// 如果合并任务和嵌入章节任务都未完成,则调用merge_and_embed,将两个任务通过一个ffmpeg命令完成
|
||||||
|
self.merge_and_embed(&progress, &mut player_info)
|
||||||
|
.await
|
||||||
|
.context(format!(
|
||||||
|
"{ids_string} `{filename}`合并视频和音频失败并+嵌入章节元数据失败"
|
||||||
|
))?;
|
||||||
|
tracing::debug!("{ids_string} `{filename}`视频和音频合并+嵌入章节元数据完成");
|
||||||
|
} else if !merge_completed {
|
||||||
|
// 如果合并任务未完成,嵌入章节任务已完成,则只合并
|
||||||
self.merge_video_audio(&progress)
|
self.merge_video_audio(&progress)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`合并视频和音频失败"))?;
|
.context(format!("{ids_string} `{filename}`合并视频和音频失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`视频和音频合并完成");
|
tracing::debug!("{ids_string} `{filename}`视频和音频合并完成");
|
||||||
|
} else if !embed_completed {
|
||||||
|
// 如果嵌入章节任务未完成,合并任务已完成,则只嵌入
|
||||||
|
self.embed_chapter(&progress, &mut player_info)
|
||||||
|
.await
|
||||||
|
.context(format!("{ids_string} `{filename}`嵌入章节元数据失败"))?;
|
||||||
|
tracing::debug!("{ids_string} `{filename}`嵌入章节元数据完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !progress.danmaku_task.is_completed() {
|
if !progress.danmaku_task.is_completed() {
|
||||||
@@ -319,7 +340,7 @@ impl DownloadTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !progress.subtitle_task.is_completed() {
|
if !progress.subtitle_task.is_completed() {
|
||||||
self.download_subtitle(&progress)
|
self.download_subtitle(&progress, &mut player_info)
|
||||||
.await
|
.await
|
||||||
.context(format!("{ids_string} `{filename}`下载字幕失败"))?;
|
.context(format!("{ids_string} `{filename}`下载字幕失败"))?;
|
||||||
tracing::debug!("{ids_string} `{filename}`字幕下载完成");
|
tracing::debug!("{ids_string} `{filename}`字幕下载完成");
|
||||||
@@ -332,8 +353,6 @@ impl DownloadTask {
|
|||||||
tracing::debug!("{ids_string} `{filename}`封面下载完成");
|
tracing::debug!("{ids_string} `{filename}`封面下载完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut episode_info = None;
|
|
||||||
|
|
||||||
if !progress.nfo_task.is_completed() {
|
if !progress.nfo_task.is_completed() {
|
||||||
self.download_nfo(&progress, &mut episode_info)
|
self.download_nfo(&progress, &mut episode_info)
|
||||||
.await
|
.await
|
||||||
@@ -599,11 +618,7 @@ impl DownloadTask {
|
|||||||
|
|
||||||
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||||
|
|
||||||
let ffmpeg_program = std::env::current_exe()
|
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||||
.context("获取当前可执行文件路径失败")?
|
|
||||||
.parent()
|
|
||||||
.context("获取当前可执行文件所在目录失败")?
|
|
||||||
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
|
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
let video_path_clone = video_path.clone();
|
let video_path_clone = video_path.clone();
|
||||||
@@ -665,6 +680,202 @@ impl DownloadTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn embed_chapter(
|
||||||
|
&self,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
|
if !video_path.exists() {
|
||||||
|
self.update_progress(|p| p.embed_chapter_task.completed = true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||||
|
let output_path = episode_dir.join(format!("{filename}-embed.mp4"));
|
||||||
|
|
||||||
|
let player_info = player_info.get_or_init(&self.app, progress).await?;
|
||||||
|
|
||||||
|
let metadata_content = player_info.generate_chapter_metadata();
|
||||||
|
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||||
|
|
||||||
|
std::fs::write(&metadata_path, metadata_content)
|
||||||
|
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let video_path_clone = video_path.clone();
|
||||||
|
let metadata_path_clone = metadata_path.clone();
|
||||||
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
|
command
|
||||||
|
.arg("-i")
|
||||||
|
.arg(video_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(metadata_path_clone)
|
||||||
|
.arg("-map_metadata")
|
||||||
|
.arg("1")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("copy")
|
||||||
|
.arg(output_path_clone)
|
||||||
|
.arg("-y");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// 隐藏窗口
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = command.output();
|
||||||
|
|
||||||
|
let _ = tx.send(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = rx.await??;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||||
|
.context(format!("STDERR: {stderr}"))
|
||||||
|
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&video_path)
|
||||||
|
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
|
std::fs::rename(&output_path, &video_path).context(format!(
|
||||||
|
"将`{}`重命名为`{}`失败",
|
||||||
|
output_path.display(),
|
||||||
|
video_path.display()
|
||||||
|
))?;
|
||||||
|
std::fs::remove_file(&metadata_path).context(format!(
|
||||||
|
"删除章节元数据文件`{}`失败",
|
||||||
|
metadata_path.display()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
self.update_progress(|p| p.embed_chapter_task.completed = true);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn merge_and_embed(
|
||||||
|
&self,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
|
let video_path = episode_dir.join(format!("{filename}.mp4"));
|
||||||
|
if !video_path.exists() {
|
||||||
|
self.update_progress(|p| {
|
||||||
|
p.merge_task.completed = true;
|
||||||
|
p.embed_chapter_task.completed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let audio_path = episode_dir.join(format!("{filename}.m4a"));
|
||||||
|
if !audio_path.exists() {
|
||||||
|
// 如果音频文件不存在,则只嵌入章节元数据
|
||||||
|
self.embed_chapter(progress, player_info)
|
||||||
|
.await
|
||||||
|
.context("嵌入章节元数据失败")?;
|
||||||
|
|
||||||
|
self.update_progress(|p| p.merge_task.completed = true);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let player_info = player_info.get_or_init(&self.app, progress).await?;
|
||||||
|
|
||||||
|
let metadata_content = player_info.generate_chapter_metadata();
|
||||||
|
let metadata_path = episode_dir.join(format!("{filename}.FFMETA.ini"));
|
||||||
|
|
||||||
|
std::fs::write(&metadata_path, metadata_content)
|
||||||
|
.context(format!("保存章节元数据到`{}`失败", metadata_path.display()))?;
|
||||||
|
|
||||||
|
let output_path = episode_dir.join(format!("{filename}-merged.mp4"));
|
||||||
|
|
||||||
|
let ffmpeg_program = utils::get_ffmpeg_program().context("获取FFmpeg程序路径失败")?;
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
let video_path_clone = video_path.clone();
|
||||||
|
let audio_path_clone = audio_path.clone();
|
||||||
|
let metadata_path_clone = metadata_path.clone();
|
||||||
|
let output_path_clone = output_path.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut command = std::process::Command::new(ffmpeg_program);
|
||||||
|
|
||||||
|
command
|
||||||
|
.arg("-i")
|
||||||
|
.arg(video_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(audio_path_clone)
|
||||||
|
.arg("-i")
|
||||||
|
.arg(metadata_path_clone)
|
||||||
|
.arg("-map_metadata")
|
||||||
|
.arg("2")
|
||||||
|
.arg("-c")
|
||||||
|
.arg("copy")
|
||||||
|
.arg("-map")
|
||||||
|
.arg("0:v:0")
|
||||||
|
.arg("-map")
|
||||||
|
.arg("1:a:0")
|
||||||
|
.arg(output_path_clone)
|
||||||
|
.arg("-y");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// 隐藏窗口
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
command.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = command.output();
|
||||||
|
|
||||||
|
let _ = tx.send(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = rx.await??;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let err = anyhow!(format!("STDOUT: {stdout}"))
|
||||||
|
.context(format!("STDERR: {stderr}"))
|
||||||
|
.context("原因可能是视频或音频文件损坏,建议[重来]试试");
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&video_path)
|
||||||
|
.context(format!("删除视频文件`{}`失败", video_path.display()))?;
|
||||||
|
std::fs::remove_file(&audio_path)
|
||||||
|
.context(format!("删除音频文件`{}`失败", audio_path.display()))?;
|
||||||
|
std::fs::rename(&output_path, &video_path).context(format!(
|
||||||
|
"将`{}`重命名为`{}`失败",
|
||||||
|
output_path.display(),
|
||||||
|
video_path.display()
|
||||||
|
))?;
|
||||||
|
std::fs::remove_file(&metadata_path).context(format!(
|
||||||
|
"删除章节元数据文件`{}`失败",
|
||||||
|
metadata_path.display()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
self.update_progress(|p| {
|
||||||
|
p.merge_task.completed = true;
|
||||||
|
p.embed_chapter_task.completed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn download_danmaku(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
async fn download_danmaku(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
||||||
let (aid, cid, duration) = (progress.aid, progress.cid, progress.duration);
|
let (aid, cid, duration) = (progress.aid, progress.cid, progress.duration);
|
||||||
let danmaku_task = &progress.danmaku_task;
|
let danmaku_task = &progress.danmaku_task;
|
||||||
@@ -705,24 +916,20 @@ impl DownloadTask {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_subtitle(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
|
async fn download_subtitle(
|
||||||
|
&self,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
player_info: &mut Option<PlayerInfo>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
|
|
||||||
let (aid, cid) = {
|
let player_info = player_info.get_or_init(&self.app, progress).await?;
|
||||||
let progress = self.progress.read();
|
|
||||||
(progress.aid, progress.cid)
|
|
||||||
};
|
|
||||||
|
|
||||||
let bili_client = self.app.get_bili_client();
|
let bili_client = self.app.get_bili_client();
|
||||||
let player_info = bili_client
|
|
||||||
.get_player_info(aid, cid)
|
|
||||||
.await
|
|
||||||
.context("获取播放器信息失败")?;
|
|
||||||
|
|
||||||
let subtitle = &player_info.subtitle;
|
for subtitle_detail in &player_info.subtitle.subtitles {
|
||||||
for subtitle_detail in &subtitle.subtitles {
|
|
||||||
let url = format!("http:{}", subtitle_detail.subtitle_url);
|
let url = format!("http:{}", subtitle_detail.subtitle_url);
|
||||||
let subtitle = bili_client
|
let subtitle = bili_client
|
||||||
.get_subtitle(&url)
|
.get_subtitle(&url)
|
||||||
@@ -775,18 +982,15 @@ impl DownloadTask {
|
|||||||
episode_info: &mut Option<EpisodeInfo>,
|
episode_info: &mut Option<EpisodeInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
|
|
||||||
|
let episode_info = episode_info.get_or_init(&self.app, progress).await?;
|
||||||
|
|
||||||
let bili_client = self.app.get_bili_client();
|
let bili_client = self.app.get_bili_client();
|
||||||
|
|
||||||
let episode_info = episode_info
|
|
||||||
.get_or_init(&bili_client, aid, ep_id, episode_type)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match episode_info {
|
match episode_info {
|
||||||
EpisodeInfo::Normal(info) => {
|
EpisodeInfo::Normal(info) => {
|
||||||
let tags = bili_client
|
let tags = bili_client
|
||||||
.get_tags(aid)
|
.get_tags(progress.aid)
|
||||||
.await
|
.await
|
||||||
.context("获取视频标签失败")?;
|
.context("获取视频标签失败")?;
|
||||||
let movie_nfo = info
|
let movie_nfo = info
|
||||||
@@ -885,13 +1089,8 @@ impl DownloadTask {
|
|||||||
episode_info: &mut Option<EpisodeInfo>,
|
episode_info: &mut Option<EpisodeInfo>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
|
||||||
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
|
|
||||||
|
|
||||||
let bili_client = self.app.get_bili_client();
|
let episode_info = episode_info.get_or_init(&self.app, progress).await?;
|
||||||
|
|
||||||
let episode_info = episode_info
|
|
||||||
.get_or_init(&bili_client, aid, ep_id, episode_type)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
|
let json_path = episode_dir.join(format!("{filename}-元数据.json"));
|
||||||
let json_string = match episode_info {
|
let json_string = match episode_info {
|
||||||
@@ -1156,25 +1355,24 @@ enum EpisodeInfo {
|
|||||||
trait GetOrInitEpisodeInfo {
|
trait GetOrInitEpisodeInfo {
|
||||||
async fn get_or_init<'a>(
|
async fn get_or_init<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
bili_client: &BiliClient,
|
app: &AppHandle,
|
||||||
aid: i64,
|
progress: &DownloadProgress,
|
||||||
ep_id: Option<i64>,
|
|
||||||
episode_type: EpisodeType,
|
|
||||||
) -> anyhow::Result<&'a mut EpisodeInfo>;
|
) -> anyhow::Result<&'a mut EpisodeInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
||||||
async fn get_or_init<'a>(
|
async fn get_or_init<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
bili_client: &BiliClient,
|
app: &AppHandle,
|
||||||
aid: i64,
|
progress: &DownloadProgress,
|
||||||
ep_id: Option<i64>,
|
|
||||||
episode_type: EpisodeType,
|
|
||||||
) -> anyhow::Result<&'a mut EpisodeInfo> {
|
) -> anyhow::Result<&'a mut EpisodeInfo> {
|
||||||
if let Some(info) = self {
|
if let Some(info) = self {
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let (aid, ep_id, episode_type) = (progress.aid, progress.ep_id, progress.episode_type);
|
||||||
|
|
||||||
let new_info = match episode_type {
|
let new_info = match episode_type {
|
||||||
EpisodeType::Normal => {
|
EpisodeType::Normal => {
|
||||||
let info = bili_client
|
let info = bili_client
|
||||||
@@ -1204,3 +1402,31 @@ impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
|||||||
Ok(self.insert(new_info))
|
Ok(self.insert(new_info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trait GetOrInitPlayerInfo {
|
||||||
|
async fn get_or_init<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
app: &AppHandle,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
) -> anyhow::Result<&'a mut PlayerInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetOrInitPlayerInfo for Option<PlayerInfo> {
|
||||||
|
async fn get_or_init<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
app: &AppHandle,
|
||||||
|
progress: &DownloadProgress,
|
||||||
|
) -> anyhow::Result<&'a mut PlayerInfo> {
|
||||||
|
if let Some(info) = self {
|
||||||
|
return Ok(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
let bili_client = app.get_bili_client();
|
||||||
|
let info = bili_client
|
||||||
|
.get_player_info(progress.aid, progress.cid)
|
||||||
|
.await
|
||||||
|
.context("获取播放器信息失败")?;
|
||||||
|
|
||||||
|
Ok(self.insert(info))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use specta::Type;
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
|
pub struct EmbedChapterTask {
|
||||||
|
pub selected: bool,
|
||||||
|
pub completed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbedChapterTask {
|
||||||
|
pub fn is_completed(&self) -> bool {
|
||||||
|
!self.selected || self.completed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod audio_task;
|
pub mod audio_task;
|
||||||
pub mod cover_task;
|
pub mod cover_task;
|
||||||
pub mod danmaku_task;
|
pub mod danmaku_task;
|
||||||
|
pub mod embed_chapter_task;
|
||||||
pub mod json_task;
|
pub mod json_task;
|
||||||
pub mod merge_task;
|
pub mod merge_task;
|
||||||
pub mod nfo_task;
|
pub mod nfo_task;
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ pub struct PlayerInfo {
|
|||||||
pub is_upower_exclusive_with_qa: bool,
|
pub is_upower_exclusive_with_qa: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PlayerInfo {
|
||||||
|
pub fn generate_chapter_metadata(&self) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
let mut metadata_content = ";FFMETADATA1\n".to_string();
|
||||||
|
|
||||||
|
for view_point in &self.view_points {
|
||||||
|
let start = view_point.from;
|
||||||
|
let end = view_point.to;
|
||||||
|
let title = &view_point.content;
|
||||||
|
let _ = writeln!(
|
||||||
|
&mut metadata_content,
|
||||||
|
"[CHAPTER]\nTIMEBASE=1/1\nSTART={start}\nEND={end}\ntitle={title}\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||||
pub struct IpInfo {
|
pub struct IpInfo {
|
||||||
pub ip: String,
|
pub ip: String,
|
||||||
|
|||||||
+11
-1
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs::File,
|
fs::File,
|
||||||
io::{BufReader, Read},
|
io::{BufReader, Read},
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
@@ -176,3 +176,13 @@ pub fn seconds_to_srt_time(seconds: f64) -> String {
|
|||||||
let h = total_m / 60;
|
let h = total_m / 60;
|
||||||
format!("{h:02}:{m:02}:{s:02},{ms:03}")
|
format!("{h:02}:{m:02}:{s:02},{ms:03}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_ffmpeg_program() -> anyhow::Result<PathBuf> {
|
||||||
|
let ffmpeg_program = std::env::current_exe()
|
||||||
|
.context("获取当前可执行文件路径失败")?
|
||||||
|
.parent()
|
||||||
|
.context("获取当前可执行文件所在目录失败")?
|
||||||
|
.join("com.lanyeeee.bilibili-video-downloader-ffmpeg");
|
||||||
|
|
||||||
|
Ok(ffmpeg_program)
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -239,7 +239,7 @@ export type CntInfo = { collect: number; play: number; thumb_up: number; share:
|
|||||||
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
|
export type CntInfoInMedia = { collect: number; play: number; danmaku: number; vt: number; play_switch: number; reply: number; view_text_1: string }
|
||||||
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
export type CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
||||||
export type CommandError = { err_title: string; err_message: string }
|
export type CommandError = { err_title: string; err_message: string }
|
||||||
export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig }
|
export type Config = { download_dir: string; enable_file_logger: boolean; sessdata: string; prefer_video_quality: PreferVideoQuality; prefer_codec_type: PreferCodecType; prefer_audio_quality: PreferAudioQuality; download_video: boolean; download_audio: boolean; auto_merge: boolean; embed_chapter: boolean; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; download_json: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; proxy_mode: ProxyMode; proxy_host: string; proxy_port: number; task_concurrency: number; task_download_interval_sec: number; chunk_concurrency: number; chunk_download_interval_sec: number; danmaku_config: CanvasConfig }
|
||||||
export type Consulting = { consulting_flag: boolean; consulting_url: string }
|
export type Consulting = { consulting_flag: boolean; consulting_url: string }
|
||||||
export type ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }
|
export type ContentAttr = { text: string; bg_color: string; bg_color_night: string; img: string; multi_img: MultiImg }
|
||||||
export type ContentList = { bold: boolean; content: string; number: string }
|
export type ContentList = { bold: boolean; content: string; number: string }
|
||||||
@@ -255,9 +255,10 @@ export type Dimension = { width: number; height: number; rotate: number }
|
|||||||
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
export type DimensionInBangumi = { height: number; rotate: number; width: number }
|
||||||
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
||||||
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
export type DownloadEvent = { event: "Speed"; data: { speed: string } } | { event: "TaskCreate"; data: { state: DownloadTaskState; progress: DownloadProgress } } | { event: "TaskStateUpdate"; data: { task_id: string; state: DownloadTaskState } } | { event: "TaskSleeping"; data: { task_id: string; remaining_sec: number } } | { event: "TaskDelete"; data: { task_id: string } } | { event: "ProgressPreparing"; data: { task_id: string } } | { event: "ProgressUpdate"; data: { progress: DownloadProgress } }
|
||||||
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; merge_task: MergeTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; json_task: JsonTask; create_ts: number; completed_ts: number | null }
|
export type DownloadProgress = { task_id: string; episode_type: EpisodeType; aid: number; bvid: string | null; cid: number; ep_id: number | null; duration: number; pub_ts: number; collection_title: string; part_title: string | null; part_order: number | null; episode_title: string; episode_order: number; up_name: string | null; up_uid: number | null; up_avatar: string | null; episode_dir: string; filename: string; video_task: VideoTask; audio_task: AudioTask; merge_task: MergeTask; embed_chapter_task: EmbedChapterTask; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; json_task: JsonTask; create_ts: number; completed_ts: number | null }
|
||||||
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||||
export type Ed = { end: number; start: number }
|
export type Ed = { end: number; start: number }
|
||||||
|
export type EmbedChapterTask = { selected: boolean; completed: boolean }
|
||||||
export type EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfoInBangumi; badge_type: number | null; bvid: string | null; cid: number; cover: string; dimension: DimensionInBangumi | null; duration: number | null; enable_vt: boolean; ep_id: number; from: string | null; id: number; is_view_hide: boolean; link: string; link_type: string | null; long_title: string | null; pub_time: number; pv: number; release_date: string | null; rights: RightsInBangumiEp | null; section_type: number; share_copy: string | null; share_url: string | null; short_link: string | null; showDrmLoginDialog: boolean; show_title: string | null; skip: Skip | null; status: number; subtitle: string | null; title: string; vid: string | null; icon_font: IconFont | null }
|
export type EpInBangumi = { aid: number; badge: string; badge_info: BadgeInfoInBangumi; badge_type: number | null; bvid: string | null; cid: number; cover: string; dimension: DimensionInBangumi | null; duration: number | null; enable_vt: boolean; ep_id: number; from: string | null; id: number; is_view_hide: boolean; link: string; link_type: string | null; long_title: string | null; pub_time: number; pv: number; release_date: string | null; rights: RightsInBangumiEp | null; section_type: number; share_copy: string | null; share_url: string | null; short_link: string | null; showDrmLoginDialog: boolean; show_title: string | null; skip: Skip | null; status: number; subtitle: string | null; title: string; vid: string | null; icon_font: IconFont | null }
|
||||||
export type EpInBangumiFollow = { season_id: number; media_id: number; season_type: number; season_type_name: string; title: string; cover: string; total_count: number; is_finish: number; is_started: number; is_play: number; badge: string; badge_type: number; rights: RightsInBangumiFollow; stat: StatInBangumiFollow; new_ep: NewEpInBangumiFollow; rating: RatingInBangumiFollow | null; square_cover: string; season_status: number; season_title: string; badge_ep: string; media_attr: number; season_attr: number; evaluate: string; areas: AreaInBangumiFollow[]; subtitle: string; first_ep: number; can_watch: number; release_date_show: string | null; series: SeriesInBangumiFollow; publish: PublishInBangumiFollow; mode: number; section: SectionInBangumiFollow[]; url: string; badge_info: BadgeInfoInBangumiFollow; renewal_time: string | null; first_ep_info: FirstEpInfo; formal_ep_count: number | null; short_url: string; badge_infos: BadgeInfos | null; season_version: string | null; horizontal_cover_16_9: string | null; horizontal_cover_16_10: string | null; subtitle_14: string | null; viewable_crowd_type: number; producers?: Producer[]; summary: string; styles?: string[]; follow_status: number; is_new: number; progress: string; both_follow: boolean; subtitle_25: string | null }
|
export type EpInBangumiFollow = { season_id: number; media_id: number; season_type: number; season_type_name: string; title: string; cover: string; total_count: number; is_finish: number; is_started: number; is_play: number; badge: string; badge_type: number; rights: RightsInBangumiFollow; stat: StatInBangumiFollow; new_ep: NewEpInBangumiFollow; rating: RatingInBangumiFollow | null; square_cover: string; season_status: number; season_title: string; badge_ep: string; media_attr: number; season_attr: number; evaluate: string; areas: AreaInBangumiFollow[]; subtitle: string; first_ep: number; can_watch: number; release_date_show: string | null; series: SeriesInBangumiFollow; publish: PublishInBangumiFollow; mode: number; section: SectionInBangumiFollow[]; url: string; badge_info: BadgeInfoInBangumiFollow; renewal_time: string | null; first_ep_info: FirstEpInfo; formal_ep_count: number | null; short_url: string; badge_infos: BadgeInfos | null; season_version: string | null; horizontal_cover_16_9: string | null; horizontal_cover_16_10: string | null; subtitle_14: string | null; viewable_crowd_type: number; producers?: Producer[]; summary: string; styles?: string[]; follow_status: number; is_new: number; progress: string; both_follow: boolean; subtitle_25: string | null }
|
||||||
export type EpInCheese = { aid: number; catalogue_index: number; cid: number; cover: string; duration: number; ep_status: number; episode_can_view: boolean; from: string; id: number; index: number; label: string | null; page: number; play: number; play_way: number; playable: boolean; release_date: number; show_vt: boolean; status: number; subtitle: string; title: string; watched: boolean; watchedHistory: number }
|
export type EpInCheese = { aid: number; catalogue_index: number; cid: number; cover: string; duration: number; ep_status: number; episode_can_view: boolean; from: string; id: number; index: number; label: string | null; page: number; play: number; play_way: number; playable: boolean; release_date: number; show_vt: boolean; status: number; subtitle: string; title: string; watched: boolean; watchedHistory: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user