mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-05 15:38:52 +08:00
feat: 后端支持nfo元数据下载
This commit is contained in:
@@ -24,7 +24,7 @@ use crate::{
|
||||
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
||||
get_fav_info_params::GetFavInfoParams, get_normal_info_params::GetNormalInfoParams,
|
||||
normal_info::NormalInfo, normal_media_url::NormalMediaUrl, player_info::PlayerInfo,
|
||||
qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, subtitle::Subtitle,
|
||||
qrcode_data::QrcodeData, qrcode_status::QrcodeStatus, subtitle::Subtitle, tags::Tags,
|
||||
user_info::UserInfo, watch_later_info::WatchLaterInfo,
|
||||
},
|
||||
};
|
||||
@@ -715,6 +715,41 @@ impl BiliClient {
|
||||
Ok((bytes, ext.to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_tags(&self, aid: i64) -> anyhow::Result<Tags> {
|
||||
// 发送获取普通视频标签的请求
|
||||
let params = json!({"aid": aid});
|
||||
let request = self
|
||||
.api_client
|
||||
.read()
|
||||
.get("https://api.bilibili.com/x/web-interface/view/detail/tag")
|
||||
.query(¶ms)
|
||||
.header("cookie", self.get_cookie());
|
||||
let http_resp = request.send().await?;
|
||||
// 检查http响应状态码
|
||||
let status = http_resp.status();
|
||||
let body = http_resp.text().await?;
|
||||
if status != StatusCode::OK {
|
||||
return Err(anyhow!("预料之外的状态码({status}): {body}"));
|
||||
}
|
||||
// 尝试将body解析为BiliResp
|
||||
let bili_resp: BiliResp =
|
||||
serde_json::from_str(&body).context(format!("将body解析为BiliResp失败: {body}"))?;
|
||||
// 检查BiliResp的code字段
|
||||
if bili_resp.code != 0 {
|
||||
return Err(anyhow!("预料之外的code: {bili_resp:?}"));
|
||||
}
|
||||
// 检查BiliResp的data是否存在
|
||||
let Some(data) = bili_resp.data else {
|
||||
return Err(anyhow!("BiliResp中不存在data字段: {bili_resp:?}"));
|
||||
};
|
||||
// 尝试将data解析为Tags
|
||||
let data_str = data.to_string();
|
||||
let tags: Tags =
|
||||
serde_json::from_str(&data_str).context(format!("将data解析为Tags失败: {data_str}"))?;
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
fn get_cookie(&self) -> String {
|
||||
let sessdata = self.app.get_config().read().sessdata.clone();
|
||||
format!("SESSDATA={sessdata}")
|
||||
|
||||
@@ -25,6 +25,7 @@ pub struct Config {
|
||||
pub download_json_danmaku: bool,
|
||||
pub download_subtitle: bool,
|
||||
pub download_cover: bool,
|
||||
pub download_nfo: bool,
|
||||
pub dir_fmt: String,
|
||||
pub dir_fmt_for_part: String,
|
||||
pub time_fmt: String,
|
||||
@@ -104,6 +105,7 @@ impl Config {
|
||||
download_json_danmaku: true,
|
||||
download_subtitle: true,
|
||||
download_cover: true,
|
||||
download_nfo: true,
|
||||
dir_fmt: "{collection_title}/{episode_title}".to_string(),
|
||||
dir_fmt_for_part: DEFAULT_FMT_FOR_PART.to_string(),
|
||||
time_fmt: "%Y-%m-%d_%H-%M-%S".to_string(),
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::{
|
||||
config::Config,
|
||||
downloader::tasks::{
|
||||
audio_task::AudioTask, cover_task::CoverTask, danmaku_task::DanmakuTask,
|
||||
merge_task::MergeTask, subtitle_task::SubtitleTask, video_task::VideoTask,
|
||||
merge_task::MergeTask, nfo_task::NfoTask, subtitle_task::SubtitleTask,
|
||||
video_task::VideoTask,
|
||||
},
|
||||
extensions::AppHandleExt,
|
||||
types::{
|
||||
@@ -55,6 +56,7 @@ pub struct DownloadProgress {
|
||||
pub subtitle_task: SubtitleTask,
|
||||
pub danmaku_task: DanmakuTask,
|
||||
pub cover_task: CoverTask,
|
||||
pub nfo_task: NfoTask,
|
||||
pub create_ts: u64,
|
||||
pub completed_ts: Option<u64>,
|
||||
}
|
||||
@@ -125,6 +127,7 @@ impl DownloadProgress {
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
nfo_task: tasks.nfo,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -174,6 +177,7 @@ impl DownloadProgress {
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
nfo_task: tasks.nfo,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -312,6 +316,7 @@ impl DownloadProgress {
|
||||
&& self.danmaku_task.is_completed()
|
||||
&& self.subtitle_task.is_completed()
|
||||
&& self.cover_task.is_completed()
|
||||
&& self.nfo_task.is_completed()
|
||||
}
|
||||
|
||||
pub fn mark_uncompleted(&mut self) {
|
||||
@@ -321,6 +326,7 @@ impl DownloadProgress {
|
||||
self.danmaku_task.completed = false;
|
||||
self.subtitle_task.completed = false;
|
||||
self.cover_task.completed = false;
|
||||
self.nfo_task.completed = false;
|
||||
}
|
||||
|
||||
pub fn get_ids_string(&self) -> String {
|
||||
@@ -372,6 +378,7 @@ fn create_normal_progresses_for_single(
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
nfo_task: tasks.nfo,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -410,6 +417,7 @@ fn create_normal_progresses_for_single(
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
nfo_task: tasks.nfo,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -448,6 +456,7 @@ fn create_normal_progresses_for_single(
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
subtitle_task: tasks.subtitle.clone(),
|
||||
cover_task: tasks.cover.clone(),
|
||||
nfo_task: tasks.nfo.clone(),
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -518,6 +527,7 @@ fn create_normal_progresses_for_season(
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
nfo_task: tasks.nfo,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -556,6 +566,7 @@ fn create_normal_progresses_for_season(
|
||||
danmaku_task: tasks.danmaku,
|
||||
subtitle_task: tasks.subtitle,
|
||||
cover_task: tasks.cover,
|
||||
nfo_task: tasks.nfo,
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -595,6 +606,7 @@ fn create_normal_progresses_for_season(
|
||||
danmaku_task: tasks.danmaku.clone(),
|
||||
subtitle_task: tasks.subtitle.clone(),
|
||||
cover_task: tasks.cover.clone(),
|
||||
nfo_task: tasks.nfo.clone(),
|
||||
create_ts,
|
||||
completed_ts: None,
|
||||
};
|
||||
@@ -615,6 +627,7 @@ struct Tasks {
|
||||
danmaku: DanmakuTask,
|
||||
subtitle: SubtitleTask,
|
||||
cover: CoverTask,
|
||||
nfo: NfoTask,
|
||||
}
|
||||
|
||||
impl Tasks {
|
||||
@@ -661,6 +674,11 @@ impl Tasks {
|
||||
completed: false,
|
||||
};
|
||||
|
||||
let nfo = NfoTask {
|
||||
selected: config.download_nfo,
|
||||
completed: false,
|
||||
};
|
||||
|
||||
Self {
|
||||
video,
|
||||
audio,
|
||||
@@ -668,6 +686,7 @@ impl Tasks {
|
||||
danmaku,
|
||||
subtitle,
|
||||
cover,
|
||||
nfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,17 @@ use tokio::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
bili_client::BiliClient,
|
||||
danmaku_xml_to_ass::xml_to_ass,
|
||||
downloader::episode_type::EpisodeType,
|
||||
events::DownloadEvent,
|
||||
extensions::{AnyhowErrorToStringChain, AppHandleExt},
|
||||
types::create_download_task_params::CreateDownloadTaskParams,
|
||||
types::{
|
||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo,
|
||||
create_download_task_params::CreateDownloadTaskParams,
|
||||
get_bangumi_info_params::GetBangumiInfoParams, get_cheese_info_params::GetCheeseInfoParams,
|
||||
get_normal_info_params::GetNormalInfoParams, normal_info::NormalInfo,
|
||||
},
|
||||
utils::{self, ToXml},
|
||||
};
|
||||
|
||||
@@ -325,6 +332,15 @@ impl DownloadTask {
|
||||
tracing::debug!("{ids_string} `{filename}`封面下载完成");
|
||||
}
|
||||
|
||||
let mut episode_info = None;
|
||||
|
||||
if !progress.nfo_task.is_completed() {
|
||||
self.download_nfo(&progress, &mut episode_info)
|
||||
.await
|
||||
.context(format!("{ids_string} `{filename}`下载NFO失败"))?;
|
||||
tracing::debug!("{ids_string} `{filename}`NFO下载完成");
|
||||
}
|
||||
|
||||
let completed_ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
@@ -746,6 +762,116 @@ impl DownloadTask {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_nfo(
|
||||
&self,
|
||||
progress: &DownloadProgress,
|
||||
episode_info: &mut Option<EpisodeInfo>,
|
||||
) -> anyhow::Result<()> {
|
||||
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(&bili_client, aid, ep_id, episode_type)
|
||||
.await?;
|
||||
|
||||
match episode_info {
|
||||
EpisodeInfo::Normal(info) => {
|
||||
let tags = bili_client
|
||||
.get_tags(aid)
|
||||
.await
|
||||
.context("获取视频标签失败")?;
|
||||
let movie_nfo = info
|
||||
.to_movie_nfo(tags)
|
||||
.context("将普通视频信息转换为movie NFO失败")?;
|
||||
let nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&nfo_path, movie_nfo)
|
||||
.context(format!("保存普通视频NFO到`{}`失败", nfo_path.display()))?;
|
||||
|
||||
if let Some(ugc_season) = &info.ugc_season {
|
||||
let collection_cover = &ugc_season.cover;
|
||||
let (cover_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(collection_cover)
|
||||
.await
|
||||
.context("获取普通视频合集封面失败")?;
|
||||
let cover_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&cover_path, cover_data).context(format!(
|
||||
"保存普通视频合集封面到`{}`失败",
|
||||
cover_path.display()
|
||||
))?;
|
||||
}
|
||||
}
|
||||
EpisodeInfo::Bangumi(info, ep_id) => {
|
||||
let tvshow_nfo = info
|
||||
.to_tvshow_nfo()
|
||||
.context("将番剧信息转换为tvshow NFO失败")?;
|
||||
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
|
||||
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
|
||||
.context(format!("保存番剧NFO到`{}`失败", tvshow_nfo_path.display()))?;
|
||||
|
||||
let episode_details_nfo = info
|
||||
.to_episode_details_nfo(*ep_id)
|
||||
.context("将番剧信息转换为episodedetail NFO失败")?;
|
||||
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!(
|
||||
"保存番剧NFO到`{}`失败",
|
||||
episode_details_nfo_path.display()
|
||||
))?;
|
||||
|
||||
let poster_url = &info.cover;
|
||||
let (poster_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(poster_url)
|
||||
.await
|
||||
.context("获取番剧封面失败")?;
|
||||
let poster_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&poster_path, poster_data)
|
||||
.context(format!("保存番剧封面到`{}`失败", poster_path.display()))?;
|
||||
|
||||
let fanart_url = &info.bkg_cover;
|
||||
if !fanart_url.is_empty() {
|
||||
let (fanart_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(fanart_url)
|
||||
.await
|
||||
.context("获取番剧封面失败")?;
|
||||
let fanart_path = episode_dir.join(format!("fanart.{ext}"));
|
||||
std::fs::write(&fanart_path, fanart_data)
|
||||
.context(format!("保存番剧封面到`{}`失败", fanart_path.display()))?;
|
||||
}
|
||||
}
|
||||
EpisodeInfo::Cheese(info, ep_id) => {
|
||||
let tvshow_nfo = info
|
||||
.to_tvshow_nfo()
|
||||
.context("将课程信息转换为tvshow NFO失败")?;
|
||||
let tvshow_nfo_path = episode_dir.join("tvshow.nfo");
|
||||
std::fs::write(&tvshow_nfo_path, tvshow_nfo)
|
||||
.context(format!("保存课程NFO到`{}`失败", tvshow_nfo_path.display()))?;
|
||||
|
||||
let episode_details_nfo = info
|
||||
.to_episode_details_nfo(*ep_id)
|
||||
.context("将课程信息转换为episodedetail NFO失败")?;
|
||||
let episode_details_nfo_path = episode_dir.join(format!("{filename}.nfo"));
|
||||
std::fs::write(&episode_details_nfo_path, episode_details_nfo).context(format!(
|
||||
"保存课程NFO到`{}`失败",
|
||||
episode_details_nfo_path.display()
|
||||
))?;
|
||||
|
||||
let poster_url = &info.cover;
|
||||
let (poster_data, ext) = bili_client
|
||||
.get_cover_data_and_ext(poster_url)
|
||||
.await
|
||||
.context("获取课程封面失败")?;
|
||||
let poster_path = episode_dir.join(format!("poster.{ext}"));
|
||||
std::fs::write(&poster_path, poster_data)
|
||||
.context(format!("保存课程封面到`{}`失败", poster_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
self.update_progress(|p| p.nfo_task.completed = true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sleep_between_task(&self) {
|
||||
let task_id = &self.task_id;
|
||||
let mut remaining_sec = self.app.get_config().read().task_download_interval_sec;
|
||||
@@ -978,3 +1104,62 @@ impl DownloadChunkTask {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum EpisodeInfo {
|
||||
Normal(NormalInfo),
|
||||
Bangumi(BangumiInfo, i64),
|
||||
Cheese(CheeseInfo, i64),
|
||||
}
|
||||
|
||||
trait GetOrInitEpisodeInfo {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
bili_client: &BiliClient,
|
||||
aid: i64,
|
||||
ep_id: Option<i64>,
|
||||
episode_type: EpisodeType,
|
||||
) -> anyhow::Result<&'a mut EpisodeInfo>;
|
||||
}
|
||||
|
||||
impl GetOrInitEpisodeInfo for Option<EpisodeInfo> {
|
||||
async fn get_or_init<'a>(
|
||||
&'a mut self,
|
||||
bili_client: &BiliClient,
|
||||
aid: i64,
|
||||
ep_id: Option<i64>,
|
||||
episode_type: EpisodeType,
|
||||
) -> anyhow::Result<&'a mut EpisodeInfo> {
|
||||
if let Some(info) = self {
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
let new_info = match episode_type {
|
||||
EpisodeType::Normal => {
|
||||
let info = bili_client
|
||||
.get_normal_info(GetNormalInfoParams::Aid(aid))
|
||||
.await
|
||||
.context("获取普通视频信息失败")?;
|
||||
EpisodeInfo::Normal(info)
|
||||
}
|
||||
EpisodeType::Bangumi => {
|
||||
let ep_id = ep_id.context("ep_id为None")?;
|
||||
let info = bili_client
|
||||
.get_bangumi_info(GetBangumiInfoParams::EpId(ep_id))
|
||||
.await
|
||||
.context("获取番剧信息失败")?;
|
||||
EpisodeInfo::Bangumi(info, ep_id)
|
||||
}
|
||||
EpisodeType::Cheese => {
|
||||
let ep_id = ep_id.context("ep_id为None")?;
|
||||
let info = bili_client
|
||||
.get_cheese_info(GetCheeseInfoParams::EpId(ep_id))
|
||||
.await
|
||||
.context("获取课程信息失败")?;
|
||||
EpisodeInfo::Cheese(info, ep_id)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(self.insert(new_info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ pub mod audio_task;
|
||||
pub mod cover_task;
|
||||
pub mod danmaku_task;
|
||||
pub mod merge_task;
|
||||
pub mod nfo_task;
|
||||
pub mod subtitle_task;
|
||||
pub mod video_task;
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
use anyhow::{anyhow, Context};
|
||||
use chrono::{DateTime, Datelike, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use yaserde::{YaDeserialize, YaSerialize};
|
||||
|
||||
use crate::types::{
|
||||
bangumi_info::BangumiInfo, cheese_info::CheeseInfo, normal_info::NormalInfo, tags::Tags,
|
||||
};
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
pub struct NfoTask {
|
||||
pub selected: bool,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
impl NfoTask {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
!self.selected || self.completed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "movie")]
|
||||
struct Movie {
|
||||
title: String,
|
||||
plot: String,
|
||||
tagline: Option<String>,
|
||||
runtime: u64,
|
||||
premiered: String,
|
||||
year: i32,
|
||||
studio: Vec<String>,
|
||||
genre: Vec<String>,
|
||||
tag: Vec<String>,
|
||||
country: Vec<String>,
|
||||
set: Option<Set>,
|
||||
director: Vec<String>,
|
||||
actor: Vec<Actor>,
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "set")]
|
||||
struct Set {
|
||||
name: String,
|
||||
overview: String,
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "actor")]
|
||||
struct Actor {
|
||||
name: String,
|
||||
role: String,
|
||||
order: i64,
|
||||
thumb: String,
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "tvshow")]
|
||||
struct Tvshow {
|
||||
title: String,
|
||||
plot: String,
|
||||
tagline: Option<String>,
|
||||
premiered: String,
|
||||
year: i32,
|
||||
studio: Vec<String>,
|
||||
status: String,
|
||||
genre: Vec<String>,
|
||||
tag: Vec<String>,
|
||||
country: Vec<String>,
|
||||
director: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(YaSerialize, YaDeserialize)]
|
||||
#[yaserde(rename = "episodedetails")]
|
||||
struct EpisodeDetails {
|
||||
title: String,
|
||||
plot: String,
|
||||
tagline: Option<String>,
|
||||
runtime: u64,
|
||||
premiered: String,
|
||||
year: i32,
|
||||
episode: i64,
|
||||
studio: Vec<String>,
|
||||
genre: Vec<String>,
|
||||
tag: Vec<String>,
|
||||
country: Vec<String>,
|
||||
director: Vec<String>,
|
||||
}
|
||||
|
||||
impl NormalInfo {
|
||||
pub fn to_movie_nfo(&self, tags: Tags) -> anyhow::Result<String> {
|
||||
let genre = vec![
|
||||
"Bilibili视频".to_string(),
|
||||
self.tname.clone(),
|
||||
self.tname_v2.clone(),
|
||||
];
|
||||
|
||||
let tag: Vec<String> = tags
|
||||
.into_iter()
|
||||
.map(|t| t.tag_name)
|
||||
.filter(|tag_name| !tag_name.is_empty())
|
||||
.collect();
|
||||
|
||||
let ts = self.pubdate;
|
||||
let date_time = DateTime::from_timestamp(ts, 0)
|
||||
.context(format!("将视频发布时间戳转换为日期时间失败: {ts}"))?
|
||||
.with_timezone(&chrono::Local);
|
||||
|
||||
let set = self.ugc_season.as_ref().map(|ugc_season| Set {
|
||||
name: ugc_season.title.clone(),
|
||||
overview: ugc_season.intro.clone(),
|
||||
});
|
||||
|
||||
let actor = self.staff.as_ref().map_or(Vec::new(), |staff| {
|
||||
staff
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(order, staff)| Actor {
|
||||
name: staff.name.clone(),
|
||||
role: staff.title.clone(),
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
order: order as i64,
|
||||
thumb: staff.face.clone(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let movie = Movie {
|
||||
title: self.title.clone(),
|
||||
plot: self.desc.clone(),
|
||||
tagline: None,
|
||||
runtime: self.duration / 60,
|
||||
premiered: date_time.format("%Y-%m-%d").to_string(),
|
||||
year: date_time.year(),
|
||||
studio: vec!["Bilibili".to_string()],
|
||||
genre,
|
||||
tag,
|
||||
country: Vec::new(),
|
||||
set,
|
||||
director: vec![self.owner.name.clone()],
|
||||
actor,
|
||||
};
|
||||
|
||||
let cfg = yaserde::ser::Config {
|
||||
perform_indent: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let nfo = yaserde::ser::to_string_with_config(&movie, &cfg).map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok(nfo)
|
||||
}
|
||||
}
|
||||
|
||||
impl BangumiInfo {
|
||||
pub fn to_tvshow_nfo(&self) -> anyhow::Result<String> {
|
||||
let time_str = &self.publish.pub_time;
|
||||
let date_time = NaiveDateTime::parse_from_str(time_str, "%Y-%m-%d %H:%M:%S").context(
|
||||
format!("将番剧发布时间字符串转换为日期时间失败: {time_str}"),
|
||||
)?;
|
||||
|
||||
let status = match self.publish.is_finish {
|
||||
0 => "Continuing".to_string(),
|
||||
_ => "Ended".to_string(),
|
||||
};
|
||||
|
||||
let tv_show = Tvshow {
|
||||
title: self.title.clone(),
|
||||
plot: self.evaluate.clone(),
|
||||
tagline: Some(self.share_sub_title.clone()),
|
||||
premiered: date_time.format("%Y-%m-%d").to_string(),
|
||||
year: date_time.year(),
|
||||
studio: vec!["Bilibili".to_string()],
|
||||
status,
|
||||
genre: self.get_genre(),
|
||||
tag: Vec::new(),
|
||||
country: self.get_country(),
|
||||
director: self.get_director(),
|
||||
};
|
||||
|
||||
let cfg = yaserde::ser::Config {
|
||||
perform_indent: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let nfo = yaserde::ser::to_string_with_config(&tv_show, &cfg).map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok(nfo)
|
||||
}
|
||||
|
||||
pub fn to_episode_details_nfo(&self, ep_id: i64) -> anyhow::Result<String> {
|
||||
let (episode, episode_order) = self.get_episode_with_order(ep_id)?;
|
||||
|
||||
let ts = episode.pub_time;
|
||||
let date_time = DateTime::from_timestamp(ts, 0)
|
||||
.context(format!("将番剧发布时间戳转换为日期时间失败: {ts}"))?
|
||||
.with_timezone(&chrono::Local);
|
||||
|
||||
let title = episode
|
||||
.show_title
|
||||
.clone()
|
||||
.context("episode.show_title为None")?;
|
||||
|
||||
let plot = episode
|
||||
.share_copy
|
||||
.clone()
|
||||
.context("episode.share_copy为None")?;
|
||||
|
||||
let duration = episode.duration.context("episode.duration为None")?;
|
||||
|
||||
let episode_details = EpisodeDetails {
|
||||
title,
|
||||
plot,
|
||||
tagline: None,
|
||||
runtime: duration / 1000 / 60,
|
||||
premiered: date_time.format("%Y-%m-%d").to_string(),
|
||||
year: date_time.year(),
|
||||
episode: episode_order,
|
||||
studio: vec!["Bilibili".to_string()],
|
||||
genre: self.get_genre(),
|
||||
tag: Vec::new(),
|
||||
country: self.get_country(),
|
||||
director: self.get_director(),
|
||||
};
|
||||
|
||||
let cfg = yaserde::ser::Config {
|
||||
perform_indent: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let nfo =
|
||||
yaserde::ser::to_string_with_config(&episode_details, &cfg).map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok(nfo)
|
||||
}
|
||||
|
||||
fn get_director(&self) -> Vec<String> {
|
||||
if let Some(up_info) = &self.up_info {
|
||||
vec![up_info.uname.clone()]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn get_country(&self) -> Vec<String> {
|
||||
self.areas
|
||||
.iter()
|
||||
.filter(|area| !area.name.is_empty())
|
||||
.map(|area| area.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_genre(&self) -> Vec<String> {
|
||||
let type_name = match self.type_field {
|
||||
1 => "番剧",
|
||||
2 => "电影",
|
||||
3 => "纪录片",
|
||||
4 => "国创",
|
||||
5 => "电视剧",
|
||||
6 => "漫画",
|
||||
7 => "综艺",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
let mut genre = Vec::new();
|
||||
if !type_name.is_empty() {
|
||||
genre.push(format!("Bilibili{type_name}"));
|
||||
}
|
||||
|
||||
for style in &self.styles {
|
||||
if !style.is_empty() {
|
||||
genre.push(style.clone());
|
||||
}
|
||||
}
|
||||
|
||||
genre
|
||||
}
|
||||
}
|
||||
|
||||
impl CheeseInfo {
|
||||
pub fn to_tvshow_nfo(&self) -> anyhow::Result<String> {
|
||||
let episode = self.episodes.first().context("episodes列表为空")?;
|
||||
let ts = episode.release_date;
|
||||
let date_time = DateTime::from_timestamp(ts, 0)
|
||||
.context(format!("将课程的发布时间戳转换为日期时间失败: {ts}"))?
|
||||
.with_timezone(&chrono::Local);
|
||||
|
||||
let status = match self.release_status.as_str() {
|
||||
"已完结" => "Ended".to_string(),
|
||||
_ => "Continuing".to_string(),
|
||||
};
|
||||
|
||||
let tv_show = Tvshow {
|
||||
title: self.title.clone(),
|
||||
plot: self.subtitle.clone(),
|
||||
tagline: None,
|
||||
premiered: date_time.format("%Y-%m-%d").to_string(),
|
||||
year: date_time.year(),
|
||||
studio: vec!["Bilibili".to_string()],
|
||||
status,
|
||||
genre: vec!["Bilibili课程".to_string()],
|
||||
tag: Vec::new(),
|
||||
country: Vec::new(),
|
||||
director: vec![self.up_info.uname.clone()],
|
||||
};
|
||||
|
||||
let cfg = yaserde::ser::Config {
|
||||
perform_indent: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let nfo = yaserde::ser::to_string_with_config(&tv_show, &cfg).map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok(nfo)
|
||||
}
|
||||
|
||||
pub fn to_episode_details_nfo(&self, ep_id: i64) -> anyhow::Result<String> {
|
||||
let episode = self
|
||||
.episodes
|
||||
.iter()
|
||||
.find(|ep| ep.id == ep_id)
|
||||
.context(format!("找不到ep_id为`{ep_id}`的课程"))?;
|
||||
|
||||
let ts = episode.release_date;
|
||||
let date_time = DateTime::from_timestamp(ts, 0)
|
||||
.context(format!("将课程发布时间戳转换为日期时间失败: {ts}"))?
|
||||
.with_timezone(&chrono::Local);
|
||||
|
||||
let episode_details = EpisodeDetails {
|
||||
title: episode.title.clone(),
|
||||
plot: episode.subtitle.clone(),
|
||||
tagline: None,
|
||||
runtime: episode.duration / 60,
|
||||
premiered: date_time.format("%Y-%m-%d").to_string(),
|
||||
year: date_time.year(),
|
||||
episode: episode.index,
|
||||
studio: vec!["Bilibili".to_string()],
|
||||
genre: vec!["Bilibili课程".to_string()],
|
||||
tag: Vec::new(),
|
||||
country: Vec::new(),
|
||||
director: vec![self.up_info.uname.clone()],
|
||||
};
|
||||
|
||||
let cfg = yaserde::ser::Config {
|
||||
perform_indent: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let nfo =
|
||||
yaserde::ser::to_string_with_config(&episode_details, &cfg).map_err(|e| anyhow!(e))?;
|
||||
|
||||
Ok(nfo)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub mod player_info;
|
||||
pub mod qrcode_data;
|
||||
pub mod qrcode_status;
|
||||
pub mod subtitle;
|
||||
pub mod tags;
|
||||
pub mod user_info;
|
||||
pub mod video_quality;
|
||||
pub mod watch_later_info;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
pub type Tags = Vec<Tag>;
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Tag {
|
||||
pub tag_id: i64,
|
||||
pub tag_name: String,
|
||||
pub music_id: String,
|
||||
pub tag_type: String,
|
||||
pub jump_url: String,
|
||||
}
|
||||
+3
-2
@@ -240,7 +240,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 CodecType = "Unknown" | "Audio" | "AVC" | "HEVC" | "AV1"
|
||||
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; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; 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; download_xml_danmaku: boolean; download_ass_danmaku: boolean; download_json_danmaku: boolean; download_subtitle: boolean; download_cover: boolean; download_nfo: boolean; dir_fmt: string; dir_fmt_for_part: string; time_fmt: string; 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 ContentList = { bold: boolean; content: string; number: string }
|
||||
export type Cooperation = { link: string }
|
||||
@@ -259,7 +259,7 @@ export type DimensionInBangumi = { height: number; rotate: number; width: number
|
||||
export type DimensionInWatchLater = { width: number; height: number; rotate: number }
|
||||
export type Dolby = { type: number; audio: MediaInNormal[] | null }
|
||||
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; 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; subtitle_task: SubtitleTask; danmaku_task: DanmakuTask; cover_task: CoverTask; nfo_task: NfoTask; create_ts: number; completed_ts: number | null }
|
||||
export type DownloadTaskState = "Pending" | "Downloading" | "Paused" | "Completed" | "Failed"
|
||||
export type DurlDetailInBangumi = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
||||
export type DurlDetailInCheese = { size: number; ahead: string; length: number; vhead: string; backup_url: string[]; url: string; order: number; md5: string }
|
||||
@@ -308,6 +308,7 @@ export type MediaInWatchLater = { aid: number; videos: number; tid: number; tnam
|
||||
export type MergeTask = { selected: boolean; completed: boolean }
|
||||
export type NewEp = { desc: string; id: number; is_new: number; title: string }
|
||||
export type NewEpInSeason = { cover: string; id: number; index_show: string }
|
||||
export type NfoTask = { selected: boolean; completed: boolean }
|
||||
export type NormalInfo = { bvid: string; aid: number; videos: number; tid: number; tid_v2: number; tname: string; tname_v2: string; copyright: number; pic: string; title: string; pubdate: number; ctime: number; desc: string; desc_v2: DescV2[] | null; state: number; duration: number; rights: Rights; owner: OwnerInNormal; stat: StatInNormal; argue_info: ArgueInfo; dynamic: string; cid: number; dimension: Dimension; teenage_mode: number; is_chargeable_season: boolean; is_story: boolean; is_upower_exclusive: boolean; is_upower_play: boolean; is_upower_preview: boolean; enable_vt: number; vt_display: string; is_upower_exclusive_with_qa: boolean; no_cache: boolean; pages: PageInNormal[]; subtitle: SubtitleInNormal; staff: Staff[] | null; ugc_season: UgcSeason | null; is_season_display: boolean; user_garb: UserGarb; honor_reply: HonorReply; like_icon: string; need_jump_bv: boolean; disable_show_up_info: boolean; is_story_play: number; is_view_self: boolean }
|
||||
export type NormalMediaUrl = { from: string; result: string; message: string; quality: number; format: string; timelength: number; accept_format: string; accept_description: string[]; accept_quality: number[]; video_codecid: number; seek_param: string; seek_type: string; dash: DashInNormal; support_formats: SupportFormatInNormal[]; last_play_time: number; last_play_cid: number; play_conf: PlayConf }
|
||||
export type Official = { role: number; title: string; desc: string; type: number }
|
||||
|
||||
Reference in New Issue
Block a user