feat: 后端支持弹幕下载

This commit is contained in:
lanyeeee
2025-07-25 05:58:21 +08:00
parent 9c311ef3e1
commit 874494d15d
19 changed files with 2790 additions and 6 deletions
+93
View File
@@ -271,10 +271,13 @@ dependencies = [
"byteorder",
"bytes",
"chrono",
"float-ord",
"fs4",
"memchr",
"notify",
"num_enum",
"parking_lot 0.12.4",
"prost",
"reqwest",
"reqwest-middleware",
"reqwest-retry",
@@ -292,6 +295,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"uuid",
"yaserde",
]
[[package]]
@@ -867,6 +871,12 @@ version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.4"
@@ -996,6 +1006,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "float-ord"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1897,6 +1913,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -3065,6 +3090,29 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "quick-xml"
version = "0.38.0"
@@ -3587,6 +3635,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_tokenstream"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.104",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -5594,6 +5654,39 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xml-rs"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
[[package]]
name = "yaserde"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bfa0d2b420fd005aa9b6f99f9584ebd964e6865d7ca787304cc1a3366c39231"
dependencies = [
"log",
"xml-rs",
"yaserde_derive",
]
[[package]]
name = "yaserde_derive"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f785831c0e09e0f1a83f917054fd59c088f6561db5b2a42c1c3e1687329325f"
dependencies = [
"heck 0.5.0",
"log",
"proc-macro2",
"quote",
"serde",
"serde_tokenstream",
"syn 2.0.104",
"xml-rs",
]
[[package]]
name = "yoke"
version = "0.8.0"
+4
View File
@@ -45,6 +45,10 @@ bytes = { version = "1.10.1" }
fs4 = { version = "0.13.1" }
num_enum = { version = "0.7.4" }
chrono = { version = "0.4.41" }
prost = { version = "0.14.1" }
yaserde = { version = "0.12.0", features = ["yaserde_derive"] }
float-ord = { version = "0.3.2" }
memchr = { version = "2.7.5" }
[profile.release]
strip = true
+53
View File
@@ -3,6 +3,7 @@ use std::time::Duration;
use anyhow::{anyhow, Context};
use bytes::Bytes;
use parking_lot::RwLock;
use prost::Message;
use reqwest::{Client, StatusCode};
use reqwest_middleware::ClientWithMiddleware;
use reqwest_retry::{policies::ExponentialBackoff, Jitter, RetryTransientMiddleware};
@@ -16,6 +17,7 @@ use tokio::task::JoinSet;
use crate::{
extensions::AppHandleExt,
protobuf::DmSegMobileReply,
types::{
bangumi_info::BangumiInfo, bangumi_media_url::BangumiMediaUrl, cheese_info::CheeseInfo,
cheese_media_url::CheeseMediaUrl, fav_folders::FavFolders, fav_info::FavInfo,
@@ -617,6 +619,57 @@ impl BiliClient {
url_with_content_length
}
pub async fn get_danmaku(
&self,
aid: i64,
cid: i64,
duration: u64,
) -> anyhow::Result<Vec<DmSegMobileReply>> {
let client = self.api_client.read().clone();
// 以6分钟为单位分段
let segment_count = duration.div_ceil(360);
let mut join_set = JoinSet::new();
for segment_index in 1..=segment_count {
let client = client.clone();
let cookie = self.get_cookie();
join_set.spawn(async move {
// 发送获取分段弹幕的请求
let params = json!({
"type": 1,
"oid": cid,
"pid": aid,
"segment_index": segment_index,
});
let http_resp = client
.get("https://api.bilibili.com/x/v2/dm/web/seg.so")
.query(&params)
.header("cookie", cookie)
.send()
.await?;
let status = http_resp.status();
if status != StatusCode::OK {
let body = http_resp.text().await?;
return Err(anyhow!("预料之外的状态码({status}): {body}"));
}
let body = http_resp.bytes().await?;
let reply =
DmSegMobileReply::decode(body).context("将body解析为DmSegMobileReply失败")?;
Ok(reply)
});
}
let mut replies = Vec::new();
while let Some(Ok(res)) = join_set.join_next().await {
let reply = res?;
replies.push(reply);
}
Ok(replies)
}
fn get_cookie(&self) -> String {
let sessdata = self.app.get_config().read().sessdata.clone();
format!("SESSDATA={sessdata}")
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -5,8 +5,10 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use tauri::{AppHandle, Manager};
use crate::danmaku_xml_to_ass::canvas::CanvasConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[allow(clippy::struct_excessive_bools)]
pub struct Config {
pub download_dir: PathBuf,
pub enable_file_logger: bool,
@@ -17,6 +19,9 @@ pub struct Config {
pub download_video: bool,
pub download_audio: bool,
pub auto_merge: bool,
pub download_xml_danmaku: bool,
pub download_ass_danmaku: bool,
pub download_json_danmaku: bool,
pub dir_fmt: String,
pub dir_fmt_for_part: String,
pub time_fmt: String,
@@ -24,6 +29,7 @@ pub struct Config {
pub task_download_interval_sec: u64,
pub chunk_concurrency: usize,
pub chunk_download_interval_sec: u64,
pub danmaku_config: CanvasConfig,
}
impl Config {
@@ -90,6 +96,9 @@ impl Config {
download_video: true,
download_audio: true,
auto_merge: true,
download_xml_danmaku: true,
download_ass_danmaku: true,
download_json_danmaku: 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(),
@@ -97,6 +106,7 @@ impl Config {
task_download_interval_sec: 0,
chunk_concurrency: 16,
chunk_download_interval_sec: 0,
danmaku_config: CanvasConfig::default(),
}
}
}
@@ -0,0 +1,181 @@
use anyhow::Result;
use std::borrow::Cow;
use std::fmt;
use std::io::{BufWriter, Write};
use super::canvas::CanvasConfig;
use super::drawable::{DrawEffect, Drawable};
struct TimePoint {
t: f64,
}
impl fmt::Display for TimePoint {
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_lossless)]
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let secs = self.t.floor() as u32;
let hour = secs / 3600;
let minutes = (secs % 3600) / 60;
let left = self.t - (hour * 3600) as f64 - (minutes * 60) as f64;
write!(f, "{hour}:{minutes:02}:{left:05.2}")
}
}
struct AssEffect {
effect: DrawEffect,
}
impl fmt::Display for AssEffect {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self.effect {
DrawEffect::Move { start, end } => {
let (x0, y0) = start;
let (x1, y1) = end;
write!(f, "\\move({x0}, {y0}, {x1}, {y1})")
}
DrawEffect::Fixed {} => fmt::Result::Err(fmt::Error),
}
}
}
impl CanvasConfig {
#[allow(clippy::cast_lossless)]
pub fn ass_styles(&self) -> Vec<String> {
let opacity = self.get_opacity();
vec![
// Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, \
// Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, \
// Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
format!(
"Style: Float,{font},{font_size},&H{a:02x}FFFFFF,&H00FFFFFF,&H{a:02x}000000,&H00000000,\
{bold}, 0, 0, 0, 100, 100, 0.00, 0.00, 1, \
{outline}, 0, 7, 0, 0, 0, 1",
a = opacity,
font = self.font,
font_size = self.font_size,
bold = self.bold as u8,
outline = self.outline,
),
format!(
"Style: Bottom,{font},{font_size},&H{a:02x}FFFFFF,&H00FFFFFF,&H{a:02x}000000,&H00000000,\
{bold}, 0, 0, 0, 100, 100, 0.00, 0.00, 1, \
{outline}, 0, 7, 0, 0, 0, 1",
a = opacity,
font = self.font,
font_size = self.font_size,
bold = self.bold as u8,
outline = self.outline,
),
format!(
"Style: Top,{font},{font_size},&H{a:02x}FFFFFF,&H00FFFFFF,&H{a:02x}000000,&H00000000,\
{bold}, 0, 0, 0, 100, 100, 0.00, 0.00, 1, \
{outline}, 0, 7, 0, 0, 0, 1",
a = opacity,
font = self.font,
font_size = self.font_size,
bold = self.bold as u8,
outline = self.outline,
),
]
}
}
struct CanvasStyles(Vec<String>);
impl fmt::Display for CanvasStyles {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
for style in &self.0 {
writeln!(f, "{style}")?;
}
Ok(())
}
}
pub struct AssWriter<W: Write> {
f: BufWriter<W>,
title: String,
canvas_config: CanvasConfig,
}
impl<W: Write> AssWriter<W> {
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
let mut this = AssWriter {
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
f: BufWriter::with_capacity(10 << 20, f),
title,
canvas_config,
};
this.init()?;
Ok(this)
}
pub fn init(&mut self) -> Result<()> {
write!(
self.f,
"\
[Script Info]\n\
; Script generated by bilibili-video-downloader (https://github.com/lanyeeee/bilibili-video-downloader)\n\
Title: {title}\n\
Script Updated By: bilibili-video-downloader (https://github.com/lanyeeee/bilibili-video-downloader)\n\
ScriptType: v4.00+\n\
PlayResX: {width}\n\
PlayResY: {height}\n\
Aspect Ratio: {width}:{height}\n\
Collisions: Normal\n\
WrapStyle: 2\n\
ScaledBorderAndShadow: yes\n\
YCbCr Matrix: TV.601\n\
\n\
\n\
[V4+ Styles]\n\
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, \
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, \
Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n\
{styles}\
\n\
[Events]\n\
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n\
",
title = self.title,
width = self.canvas_config.width,
height = self.canvas_config.height,
styles = CanvasStyles(self.canvas_config.ass_styles()),
)?;
Ok(())
}
pub fn write(&mut self, drawable: Drawable) -> Result<()> {
writeln!(
self.f,
// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"Dialogue: 2,{start},{end},{style},,0,0,0,,{{{effect}\\c&H{b:02x}{g:02x}{r:02x}&}}{text}",
start = TimePoint {
t: drawable.danmaku.timeline_s
},
end = TimePoint {
t: drawable.danmaku.timeline_s + drawable.duration
},
style = drawable.style_name,
effect = AssEffect {
effect: drawable.effect
},
b = drawable.danmaku.rgb.2,
g = drawable.danmaku.rgb.1,
r = drawable.danmaku.rgb.0,
text = escape_text(&drawable.danmaku.content),
)?;
Ok(())
}
}
fn escape_text(text: &str) -> Cow<str> {
let text = text.trim();
if memchr::memchr(b'\n', text.as_bytes()).is_some() {
Cow::from(text.replace('\n', "\\N"))
} else {
Cow::from(text)
}
}
@@ -0,0 +1,110 @@
use crate::danmaku_xml_to_ass::danmaku::Danmaku;
use super::CanvasConfig;
pub enum Collision {
// 会越来越远
#[allow(dead_code)]
Separate {
closest_dis: f64,
},
// 时间够可以追上,但是时间不够
#[allow(dead_code)]
NotEnoughTime {
closest_dis: f64,
},
// 需要额外的时间才可以避免碰撞
Collide {
time_needed: f64,
},
}
/// 表示一个弹幕槽位
#[derive(Debug, Clone)]
pub struct Lane {
last_shoot_time: f64,
last_length: f64,
}
impl Lane {
pub fn draw(danmaku: &Danmaku, config: &CanvasConfig) -> Self {
Lane {
last_shoot_time: danmaku.timeline_s,
last_length: danmaku.length(config),
}
}
/// 如底部弹幕等不需要记录长度的
#[allow(dead_code)]
pub fn draw_fixed(danmaku: &Danmaku) -> Self {
Lane {
last_shoot_time: danmaku.timeline_s,
last_length: 0.0,
}
}
/// 这个槽位是否可以发射另外一条弹幕,返回可能的情形
#[allow(clippy::cast_lossless)]
pub fn available_for(&self, other: &Danmaku, config: &super::CanvasConfig) -> Collision {
#[allow(non_snake_case)]
let T = config.duration;
#[allow(non_snake_case)]
let W = config.width as f64;
let gap = config.horizontal_gap;
// 先计算我的速度
let t1 = self.last_shoot_time;
let t2 = other.timeline_s;
let l1 = self.last_length;
let l2 = other.length(config);
let v1 = (W + l1) / T;
let v2 = (W + l2) / T;
let delta_t = t2 - t1;
// 第一条弹幕右边到屏幕右边的距离
let delta_x = v1 * delta_t - l1;
// 没有足够的空间,必定碰撞
if delta_x < gap {
if l2 <= l1 {
// l2 比 l1 短,因此比它慢
// 只需要把 l2 安排在 l1 之后就可以避免碰撞
Collision::Collide {
time_needed: (gap - delta_x) / v1,
}
} else {
// 需要延长额外的时间,使得当第一条消失的时候,第二条也有足够的距离
// 第一条消失的时间点是 (t1 + T)
// 这个时候第二条的左侧应该在距离出发点 W - gap 处,
// 第二条已经出发 (W - gap) / v2 时间,因此在 t1 + T - (W - gap) / v2 出发
// 所需要的额外时间就 - t2
// let time_needed = (t1 + T - (W - gap) / v2) - t2;
let time_needed = (T - (W - gap) / v2) - delta_t;
Collision::Collide { time_needed }
}
} else {
// 第一条已经发射
if l2 <= l1 {
// 如果 l2 < l1,则它永远追不上前者,可以发射
Collision::Separate {
closest_dis: delta_x - gap,
}
} else {
// 需要算追击问题了,
// l1 已经消失,但是 l2 可能追上,我们计算 l1 刚好消失的时候:
// 此刻是 t1 + T
// l2 的头部应该在距离起点 v2 * (t1 + T - t2) 处
let pos = v2 * (T - delta_t);
if pos < (W - gap) {
Collision::NotEnoughTime {
closest_dis: (W - gap) - pos,
}
} else {
// 需要额外的时间
Collision::Collide {
time_needed: (pos - (W - gap)) / v2,
}
}
}
}
}
}
@@ -0,0 +1,169 @@
//! 决定绘画策略
use float_ord::FloatOrd;
use lane::{Collision, Lane};
use serde::{Deserialize, Serialize};
use specta::Type;
use super::{
danmaku::Danmaku,
drawable::{DrawEffect, Drawable},
};
mod lane;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
pub struct CanvasConfig {
/// 弹幕在屏幕上的【持续时间】,单位为秒,可以有小数
pub duration: f64,
/// 渲染的屏幕分辨率,这个并不会影响渲染区域的大小,只是字体的相对大小,可以不用改动
pub width: u32,
/// 渲染的屏幕分辨率,这个并不会影响渲染区域的大小,只是字体的相对大小,可以不用改动
pub height: u32,
/// 使用字体名称
pub font: String,
/// 弹幕字体大小
pub font_size: u32,
/// 是一个比例数,用来计算平衡不同字体的宽度
/// 有的字体比较粗、比较宽,可以适当调大(如 1.4、1.6)
/// 有的字体比较细、比较窄,可以适当调小(如 1.0、1.2)
pub width_ratio: f64,
/// 用来调整弹幕时间的水平距离,单位是像素,如果想拉开弹幕之间的距离,可以调大
pub horizontal_gap: f64,
/// 计算弹幕高度的数值,即【行高度/行间距】。数值越大,弹幕的垂直距离越大
pub lane_size: u32,
/// 【正常弹幕的屏幕填充占比】,默认为 50%,即“半屏填充”。
pub float_percentage: f64,
/// 屏幕上底部弹幕最多高度百分比
#[serde(skip)]
pub bottom_percentage: f64,
/// 弹幕的不透明度,越小越透明,越大越不透明
pub alpha: f64,
/// 字体是否加粗
pub bold: bool,
/// 弹幕的描边宽度,单位为像素
pub outline: f64,
/// 弹幕时间轴偏移,>0 会让弹幕延后,<0 会让弹幕提前,单位为秒
pub time_offset: f64,
}
impl Default for CanvasConfig {
fn default() -> Self {
CanvasConfig {
duration: 15.0,
width: 1280,
height: 720,
font: "黑体".to_string(),
font_size: 25,
width_ratio: 1.2,
horizontal_gap: 20.0,
lane_size: 32,
float_percentage: 0.5,
bottom_percentage: 0.3,
alpha: 0.7,
bold: false,
outline: 0.8,
time_offset: 0.0,
}
}
}
impl CanvasConfig {
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_lossless)]
#[allow(clippy::cast_sign_loss)]
pub fn canvas(self) -> Canvas {
let float_lanes_cnt =
(self.float_percentage * self.height as f64 / self.lane_size as f64) as usize;
let bottom_lanes_cnt =
(self.bottom_percentage * self.height as f64 / self.lane_size as f64) as usize;
Canvas {
config: self,
float_lanes: vec![None; float_lanes_cnt],
bottom_lanes: vec![None; bottom_lanes_cnt],
}
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_sign_loss)]
pub fn get_opacity(&self) -> u8 {
255 - (self.alpha * 255.) as u8
}
}
pub struct Canvas {
pub config: CanvasConfig,
pub float_lanes: Vec<Option<Lane>>,
#[allow(dead_code)]
pub bottom_lanes: Vec<Option<Lane>>,
}
impl Canvas {
pub fn draw(&mut self, mut danmaku: Danmaku) -> Option<Drawable> {
use super::danmaku::DanmakuType::{Bottom, Float, Reverse, Top};
danmaku.timeline_s += self.config.time_offset;
if danmaku.timeline_s < 0.0 {
return None;
}
match danmaku.r#type {
Float => self.draw_float(danmaku),
Bottom | Top | Reverse => {
// 不喜欢底部弹幕,直接转成 Float
// 这是 feature 不是 bug
danmaku.r#type = Float;
self.draw_float(danmaku)
}
}
}
fn draw_float(&mut self, mut danmaku: Danmaku) -> Option<Drawable> {
let mut collisions = Vec::with_capacity(self.float_lanes.len());
for (idx, lane) in self.float_lanes.iter_mut().enumerate() {
match lane {
// 优先画不存在的槽位
None => {
return Some(self.draw_float_in_lane(danmaku, idx));
}
Some(l) => {
let col = l.available_for(&danmaku, &self.config);
match col {
Collision::Separate { .. } | Collision::NotEnoughTime { .. } => {
return Some(self.draw_float_in_lane(danmaku, idx));
}
Collision::Collide { time_needed } => {
collisions.push((FloatOrd(time_needed), idx));
}
}
}
}
}
// 允许部分弹幕在延迟后填充
if !collisions.is_empty() {
collisions.sort_unstable();
let (FloatOrd(time_need), lane_idx) = collisions[0];
if time_need < 1.0 {
// 只允许延迟 1s
danmaku.timeline_s += time_need + 0.01; // 间隔也不要太小了
return Some(self.draw_float_in_lane(danmaku, lane_idx));
}
}
None
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::cast_possible_wrap)]
fn draw_float_in_lane(&mut self, danmaku: Danmaku, lane_idx: usize) -> Drawable {
self.float_lanes[lane_idx] = Some(Lane::draw(&danmaku, &self.config));
let y = lane_idx as i32 * self.config.lane_size as i32;
let l = danmaku.length(&self.config);
Drawable::new(
danmaku,
self.config.duration,
"Float",
DrawEffect::Move {
start: (self.config.width as i32, y),
end: (-(l as i32), y),
},
)
}
}
@@ -0,0 +1,41 @@
//! 一个弹幕实例,但是没有位置信息
use super::canvas::CanvasConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DanmakuType {
#[default]
Float,
Top,
Bottom,
Reverse,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Danmaku {
pub timeline_s: f64,
pub content: String,
pub r#type: DanmakuType,
/// 虽然这里有 fontsize,但是我们实际上使用 canvas config 的 font size
/// 否在在调节分辨率的时候字体会发生变化。
pub fontsize: u32,
pub rgb: (u8, u8, u8),
}
impl Danmaku {
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
///
/// 汉字算一个全宽,英文算2/3宽
#[allow(clippy::cast_lossless)]
pub fn length(&self, config: &CanvasConfig) -> f64 {
let pts = config.font_size
* self
.content
.chars()
.map(|ch| if ch.is_ascii() { 2 } else { 3 })
.sum::<u32>()
/ 3;
pts as f64 * config.width_ratio
}
}
@@ -0,0 +1,38 @@
//! 可以绘制的实体
use super::danmaku::Danmaku;
/// 弹幕开始绘制的时间就是 danmaku 的时间
pub struct Drawable {
pub danmaku: Danmaku,
/// 弹幕一共绘制的时间
pub duration: f64,
/// 弹幕的绘制 style
pub style_name: &'static str,
/// 绘制的“特效”
pub effect: DrawEffect,
}
impl Drawable {
pub fn new(
danmaku: Danmaku,
duration: f64,
style_name: &'static str,
effect: DrawEffect,
) -> Self {
Drawable {
danmaku,
duration,
style_name,
effect,
}
}
}
pub enum DrawEffect {
Move {
start: (i32, i32),
end: (i32, i32),
},
#[allow(dead_code)]
Fixed {},
}
+136
View File
@@ -0,0 +1,136 @@
pub mod ass_writer;
pub mod canvas;
pub mod danmaku;
pub mod drawable;
use std::{cmp::Ordering, fs::File};
use anyhow::anyhow;
use ass_writer::AssWriter;
use canvas::CanvasConfig;
use danmaku::{Danmaku, DanmakuType};
use yaserde::{YaDeserialize, YaSerialize};
#[derive(YaSerialize, YaDeserialize)]
#[yaserde(rename = "d")]
pub struct DamakuXmlDTag {
#[yaserde(attribute = true)]
pub p: String,
#[yaserde(text = true)]
pub body: Option<String>,
}
#[derive(YaSerialize, YaDeserialize)]
#[yaserde(rename = "i")]
pub struct DanmakuXmlITag {
pub chatid: i64,
#[yaserde(rename = "d")]
pub elems: Vec<DamakuXmlDTag>,
}
pub fn xml_to_ass(
xml: &str,
ass_file: File,
title: String,
config: CanvasConfig,
) -> anyhow::Result<()> {
let mut writer = AssWriter::new(ass_file, title, config.clone())?;
let mut canvas = config.canvas();
let mut danmakus: Vec<Danmaku> = xml_to_danmakus(xml)?;
danmakus.sort_by(|a, b| {
a.timeline_s
.partial_cmp(&b.timeline_s)
.unwrap_or(Ordering::Equal)
});
for danmaku in danmakus {
if let Some(drawable) = canvas.draw(danmaku) {
writer.write(drawable)?;
}
}
Ok(())
}
trait ToDanmakuType {
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType>;
}
impl ToDanmakuType for u32 {
fn to_danmaku_type(&self) -> anyhow::Result<DanmakuType> {
match self {
1 => Ok(DanmakuType::Float),
4 => Ok(DanmakuType::Bottom),
5 => Ok(DanmakuType::Top),
6 => Ok(DanmakuType::Reverse),
_ => Err(anyhow!("未知的弹幕类型:{self}")),
}
}
}
pub fn xml_to_danmakus(xml: &str) -> anyhow::Result<Vec<Danmaku>> {
let xml = sanitize_xml(xml);
let i_tag: DanmakuXmlITag = yaserde::de::from_str(&xml).map_err(|e| anyhow!(e))?;
let mut danmakus = Vec::new();
for elem in i_tag.elems {
let Some(content) = elem.body else {
continue;
};
let mut p_attr = elem.p.split(',');
let Some(timeline_s) = p_attr.next().and_then(|s| s.parse::<f64>().ok()) else {
return Err(anyhow!("弹幕`{content}`的p属性中没有时间"));
};
let Some(r#type) = p_attr
.next()
.and_then(|s| s.parse::<u32>().ok())
.and_then(|num| num.to_danmaku_type().ok())
else {
return Err(anyhow!("弹幕`{content}`的p属性中没有弹幕类型"));
};
let Some(fontsize) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
return Err(anyhow!("弹幕`{content}`的p属性中没有字体大小"));
};
let Some(rgb) = p_attr.next().and_then(|s| s.parse::<u32>().ok()) else {
return Err(anyhow!("弹幕`{content}`的p属性中没有颜色"));
};
// rgb 是个数字,类似 0x010203
let r = (rgb >> 16) & 0xff;
let g = (rgb >> 8) & 0xff;
let b = rgb & 0xff;
let danmaku = Danmaku {
timeline_s,
content,
r#type,
fontsize,
rgb: (r as u8, g as u8, b as u8),
};
danmakus.push(danmaku);
}
Ok(danmakus)
}
fn sanitize_xml(s: &str) -> String {
fn is_valid_xml_char(c: char) -> bool {
matches!(c,
'\u{0009}' |
'\u{000A}' |
'\u{000D}' |
'\u{0020}'..='\u{D7FF}' |
'\u{E000}'..='\u{FFFD}' |
'\u{10000}'..='\u{10FFFF}'
)
}
s.chars().filter(|&c| is_valid_xml_char(c)).collect()
}
+24 -1
View File
@@ -11,7 +11,10 @@ use uuid::Uuid;
use crate::{
config::Config,
downloader::tasks::{audio_task::AudioTask, merge_task::MergeTask, video_task::VideoTask},
downloader::tasks::{
audio_task::AudioTask, danmaku_task::DanmakuTask, merge_task::MergeTask,
video_task::VideoTask,
},
extensions::AppHandleExt,
types::{
audio_quality::AudioQuality,
@@ -49,6 +52,7 @@ pub struct DownloadProgress {
pub video_task: VideoTask,
pub audio_task: AudioTask,
pub merge_task: MergeTask,
pub danmaku_task: DanmakuTask,
pub create_ts: u64,
pub completed_ts: Option<u64>,
}
@@ -116,6 +120,7 @@ impl DownloadProgress {
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
danmaku_task: tasks.danmaku,
create_ts,
completed_ts: None,
};
@@ -162,6 +167,7 @@ impl DownloadProgress {
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
danmaku_task: tasks.danmaku,
create_ts,
completed_ts: None,
};
@@ -297,12 +303,14 @@ impl DownloadProgress {
self.video_task.is_completed()
&& self.audio_task.is_completed()
&& self.merge_task.is_completed()
&& self.danmaku_task.is_completed()
}
pub fn mark_uncompleted(&mut self) {
self.video_task.mark_uncompleted();
self.audio_task.mark_uncompleted();
self.merge_task.completed = false;
self.danmaku_task.completed = false;
}
pub fn get_ids_string(&self) -> String {
@@ -351,6 +359,7 @@ fn create_normal_progresses_for_single(
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
danmaku_task: tasks.danmaku,
create_ts,
completed_ts: None,
};
@@ -386,6 +395,7 @@ fn create_normal_progresses_for_single(
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
danmaku_task: tasks.danmaku,
create_ts,
completed_ts: None,
};
@@ -421,6 +431,7 @@ fn create_normal_progresses_for_single(
video_task: tasks.video.clone(),
audio_task: tasks.audio.clone(),
merge_task: tasks.merge.clone(),
danmaku_task: tasks.danmaku.clone(),
create_ts,
completed_ts: None,
};
@@ -488,6 +499,7 @@ fn create_normal_progresses_for_season(
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
danmaku_task: tasks.danmaku,
create_ts,
completed_ts: None,
};
@@ -523,6 +535,7 @@ fn create_normal_progresses_for_season(
video_task: tasks.video,
audio_task: tasks.audio,
merge_task: tasks.merge,
danmaku_task: tasks.danmaku,
create_ts,
completed_ts: None,
};
@@ -559,6 +572,7 @@ fn create_normal_progresses_for_season(
video_task: tasks.video.clone(),
audio_task: tasks.audio.clone(),
merge_task: tasks.merge.clone(),
danmaku_task: tasks.danmaku.clone(),
create_ts,
completed_ts: None,
};
@@ -576,6 +590,7 @@ struct Tasks {
video: VideoTask,
audio: AudioTask,
merge: MergeTask,
danmaku: DanmakuTask,
}
impl Tasks {
@@ -604,10 +619,18 @@ impl Tasks {
completed: false,
};
let danmaku = DanmakuTask {
xml_selected: config.download_xml_danmaku,
ass_selected: config.download_ass_danmaku,
json_selected: config.download_json_danmaku,
completed: false,
};
Self {
video,
audio,
merge,
danmaku,
}
}
}
+49 -1
View File
@@ -17,10 +17,11 @@ use tokio::{
};
use crate::{
danmaku_xml_to_ass::xml_to_ass,
events::DownloadEvent,
extensions::{AnyhowErrorToStringChain, AppHandleExt},
types::create_download_task_params::CreateDownloadTaskParams,
utils::{self},
utils::{self, ToXml},
};
use super::{download_progress::DownloadProgress, download_task_state::DownloadTaskState};
@@ -303,6 +304,13 @@ impl DownloadTask {
tracing::debug!("{ids_string} `{filename}`视频和音频合并完成");
}
if !progress.danmaku_task.is_completed() {
self.download_danmaku(&progress)
.await
.context(format!("{ids_string} `{filename}`下载弹幕失败"))?;
tracing::debug!("{ids_string} `{filename}`弹幕下载完成");
}
let completed_ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
@@ -620,6 +628,46 @@ impl DownloadTask {
Ok(())
}
async fn download_danmaku(&self, progress: &DownloadProgress) -> anyhow::Result<()> {
let (aid, cid, duration) = (progress.aid, progress.cid, progress.duration);
let danmaku_task = &progress.danmaku_task;
let (episode_dir, filename) = (&progress.episode_dir, &progress.filename);
let bili_client = self.app.get_bili_client();
let replies = bili_client
.get_danmaku(aid, cid, duration)
.await
.context("获取弹幕失败")?;
let xml = replies.to_xml(cid).context("将弹幕转换为XML失败")?;
if danmaku_task.xml_selected {
let xml_path = episode_dir.join(format!("{filename}.弹幕.xml"));
std::fs::write(&xml_path, &xml)
.context(format!("保存弹幕XML到`{}`失败", xml_path.display()))?;
}
if danmaku_task.ass_selected {
let config = self.app.get_config().read().danmaku_config.clone();
let ass_path = episode_dir.join(format!("{filename}.弹幕.ass"));
let ass_file = File::create(&ass_path)
.context(format!("创建弹幕ASS文件`{}`失败", ass_path.display()))?;
let title = filename.to_string();
xml_to_ass(&xml, ass_file, title, config).context("将弹幕XML转换为ASS失败")?;
}
if danmaku_task.json_selected {
let json_path = episode_dir.join(format!("{filename}.弹幕.json"));
let json_string = serde_json::to_string(&replies).context("将弹幕转换为JSON失败")?;
std::fs::write(&json_path, json_string)
.context(format!("保存弹幕JSON到`{}`失败", json_path.display()))?;
}
self.update_progress(|p| p.danmaku_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;
@@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, Type)]
#[allow(clippy::struct_excessive_bools)]
pub struct DanmakuTask {
pub xml_selected: bool,
pub ass_selected: bool,
pub json_selected: bool,
pub completed: bool,
}
impl DanmakuTask {
pub fn is_completed(&self) -> bool {
!self.xml_selected && !self.ass_selected && !self.json_selected || self.completed
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod audio_task;
pub mod danmaku_task;
pub mod merge_task;
pub mod video_task;
-1
View File
@@ -10,7 +10,6 @@ use crate::{
};
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
#[serde(rename_all = "camelCase")]
pub struct LogEvent {
pub timestamp: String,
pub level: LogLevel,
+4
View File
@@ -1,6 +1,7 @@
mod bili_client;
mod commands;
mod config;
mod danmaku_xml_to_ass;
mod downloader;
mod errors;
mod events;
@@ -8,6 +9,9 @@ mod extensions;
mod logger;
mod types;
mod utils;
mod protobuf {
include!("./bilibili.community.service.dm.v1.rs");
}
use anyhow::Context;
use commands::*;
+38
View File
@@ -7,6 +7,11 @@ use std::{
use anyhow::{anyhow, Context};
use byteorder::{BigEndian, ReadBytesExt};
use crate::{
danmaku_xml_to_ass::{DamakuXmlDTag, DanmakuXmlITag},
protobuf::DmSegMobileReply,
};
pub fn filename_filter(s: &str) -> String {
s.chars()
.map(|c| match c {
@@ -124,3 +129,36 @@ pub fn is_mp4_complete(file_path: &Path) -> anyhow::Result<bool> {
Ok(real_size == total_size && has_moov_box)
}
pub trait ToXml {
fn to_xml(&self, cid: i64) -> anyhow::Result<String>;
}
impl ToXml for Vec<DmSegMobileReply> {
fn to_xml(&self, cid: i64) -> anyhow::Result<String> {
let elems = self
.iter()
.flat_map(|reply| &reply.elems)
.map(|elem| DamakuXmlDTag {
p: format!(
"{},{},{},{},{},{},{},{}",
elem.progress / 1000,
elem.mode,
elem.fontsize,
elem.color,
elem.ctime,
elem.pool,
elem.mid_hash.clone(),
elem.id_str.clone(),
),
body: Some(elem.content.clone()),
})
.collect();
let i_tag = DanmakuXmlITag { chatid: cid, elems };
let xml = yaserde::ser::to_string(&i_tag).map_err(|e| anyhow!(e))?;
Ok(xml)
}
}