mirror of
https://github.com/amtoaer/bili-sync.git
synced 2026-09-05 23:49:53 +08:00
feat: 支持弹幕下载 (#58)
* chore: 简单引入字幕模块(WIP) * feat: 初步支持弹幕下载 * feat: 尝试在维持视频比例的基础上对齐视频高度,通过标记 'static 移除生命周期参数 * chore: 在数据库中记录视频页的宽高和长度 * fix: 修复各种错误,移除无用代码
This commit is contained in:
Generated
+42
-1
@@ -423,12 +423,15 @@ dependencies = [
|
|||||||
"entity",
|
"entity",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"filenamify",
|
"filenamify",
|
||||||
|
"float-ord",
|
||||||
"futures",
|
"futures",
|
||||||
"handlebars",
|
"handlebars",
|
||||||
"hex",
|
"hex",
|
||||||
"log",
|
"log",
|
||||||
|
"memchr",
|
||||||
"migration",
|
"migration",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"prost",
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"rand",
|
"rand",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -1001,6 +1004,12 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-ord"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
@@ -1528,6 +1537,15 @@ version = "2.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
|
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.10.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2149,6 +2167,29 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.12.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0f5d036824e4761737860779c906171497f6d55681139d8312388f8fe398922"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.12.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19de2de2a00075bf566bee3bd4db014b11587e84184d3f7a791bc17f1a8e9e48"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools 0.10.5",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.58",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psl-types"
|
name = "psl-types"
|
||||||
version = "2.0.11"
|
version = "2.0.11"
|
||||||
@@ -2857,7 +2898,7 @@ version = "0.2.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c"
|
checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itertools",
|
"itertools 0.12.1",
|
||||||
"nom",
|
"nom",
|
||||||
"unicode_categories",
|
"unicode_categories",
|
||||||
]
|
]
|
||||||
|
|||||||
+10
-7
@@ -13,26 +13,29 @@ dirs = "5.0.1"
|
|||||||
entity = { path = "entity" }
|
entity = { path = "entity" }
|
||||||
env_logger = "0.11.3"
|
env_logger = "0.11.3"
|
||||||
filenamify = "0.1.0"
|
filenamify = "0.1.0"
|
||||||
|
float-ord = "0.3.2"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
handlebars = "5.1.2"
|
handlebars = "5.1.2"
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
log = "0.4.21"
|
log = "0.4.21"
|
||||||
|
memchr = "2.5.0"
|
||||||
migration = { path = "migration" }
|
migration = { path = "migration" }
|
||||||
once_cell = "1.19.0"
|
once_cell = "1.19.0"
|
||||||
|
prost = "0.12.4"
|
||||||
quick-xml = { version = "0.31.0", features = ["async-tokio"] }
|
quick-xml = { version = "0.31.0", features = ["async-tokio"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
regex = "1.10.3"
|
regex = "1.10.3"
|
||||||
reqwest = { version = "0.12.0", features = [
|
reqwest = { version = "0.12.0", features = [
|
||||||
"json",
|
"json",
|
||||||
"stream",
|
"stream",
|
||||||
"cookies",
|
"cookies",
|
||||||
"gzip",
|
"gzip",
|
||||||
] }
|
] }
|
||||||
rsa = { version = "0.9.6", features = ["sha2"] }
|
rsa = { version = "0.9.6", features = ["sha2"] }
|
||||||
sea-orm = { version = "0.12", features = [
|
sea-orm = { version = "0.12", features = [
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
"runtime-tokio-native-tls",
|
"runtime-tokio-native-tls",
|
||||||
"macros",
|
"macros",
|
||||||
] }
|
] }
|
||||||
serde = { version = "1.0.197", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ pub struct Model {
|
|||||||
pub cid: i32,
|
pub cid: i32,
|
||||||
pub pid: i32,
|
pub pid: i32,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub duration: u32,
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
pub download_status: u32,
|
pub download_status: u32,
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ impl MigrationTrait for Migration {
|
|||||||
.col(ColumnDef::new(Page::Cid).unsigned().not_null())
|
.col(ColumnDef::new(Page::Cid).unsigned().not_null())
|
||||||
.col(ColumnDef::new(Page::Pid).unsigned().not_null())
|
.col(ColumnDef::new(Page::Pid).unsigned().not_null())
|
||||||
.col(ColumnDef::new(Page::Name).string().not_null())
|
.col(ColumnDef::new(Page::Name).string().not_null())
|
||||||
|
.col(ColumnDef::new(Page::Width).unsigned())
|
||||||
|
.col(ColumnDef::new(Page::Height).unsigned())
|
||||||
|
.col(ColumnDef::new(Page::Duration).unsigned().not_null())
|
||||||
.col(ColumnDef::new(Page::Path).string())
|
.col(ColumnDef::new(Page::Path).string())
|
||||||
.col(ColumnDef::new(Page::Image).string())
|
.col(ColumnDef::new(Page::Image).string())
|
||||||
.col(ColumnDef::new(Page::DownloadStatus).unsigned().not_null())
|
.col(ColumnDef::new(Page::DownloadStatus).unsigned().not_null())
|
||||||
@@ -173,6 +176,9 @@ enum Page {
|
|||||||
Cid,
|
Cid,
|
||||||
Pid,
|
Pid,
|
||||||
Name,
|
Name,
|
||||||
|
Width,
|
||||||
|
Height,
|
||||||
|
Duration,
|
||||||
Path,
|
Path,
|
||||||
Image,
|
Image,
|
||||||
DownloadStatus,
|
DownloadStatus,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub enum AudioQuality {
|
|||||||
Quality192k = 30280,
|
Quality192k = 30280,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::upper_case_acronyms)]
|
||||||
#[derive(Debug, strum::EnumString, strum::Display, PartialEq, PartialOrd, Serialize, Deserialize)]
|
#[derive(Debug, strum::EnumString, strum::Display, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||||
pub enum VideoCodecs {
|
pub enum VideoCodecs {
|
||||||
#[strum(serialize = "hev")]
|
#[strum(serialize = "hev")]
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
use std::fmt;
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::io::{AsyncWrite, AsyncWriteExt, BufWriter};
|
||||||
|
|
||||||
|
use super::canvas::CanvasConfig;
|
||||||
|
use crate::bilibili::danmaku::{DrawEffect, Drawable};
|
||||||
|
|
||||||
|
struct TimePoint {
|
||||||
|
t: f64,
|
||||||
|
}
|
||||||
|
impl fmt::Display for TimePoint {
|
||||||
|
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})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::DanmakuOption {
|
||||||
|
pub fn ass_styles(&self) -> Vec<String> {
|
||||||
|
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 = self.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 = self.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 = self.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: AsyncWrite> {
|
||||||
|
f: Pin<Box<BufWriter<W>>>,
|
||||||
|
title: String,
|
||||||
|
canvas_config: CanvasConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: AsyncWrite> AssWriter<W> {
|
||||||
|
pub fn new(f: W, title: String, canvas_config: CanvasConfig) -> Self {
|
||||||
|
AssWriter {
|
||||||
|
// 对于 HDD、docker 之类的场景,磁盘 IO 是非常大的瓶颈。使用大缓存
|
||||||
|
f: Box::pin(BufWriter::with_capacity(10 << 20, f)),
|
||||||
|
title,
|
||||||
|
canvas_config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn construct(f: W, title: String, canvas_config: CanvasConfig) -> Result<Self> {
|
||||||
|
let mut res = Self::new(f, title, canvas_config);
|
||||||
|
res.init().await?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init(&mut self) -> Result<()> {
|
||||||
|
self.f
|
||||||
|
.write_all(
|
||||||
|
format!(
|
||||||
|
"\
|
||||||
|
[Script Info]\n\
|
||||||
|
; Script generated by danmu2ass\n\
|
||||||
|
Title: {title}\n\
|
||||||
|
Script Updated By: danmu2ass (https://github.com/gwy15/danmu2ass)\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.danmaku_option.ass_styles()),
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
.as_slice(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write(&mut self, drawable: Drawable) -> Result<()> {
|
||||||
|
self.f
|
||||||
|
.write_all(
|
||||||
|
format!(
|
||||||
|
// 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}\n",
|
||||||
|
start = TimePoint {
|
||||||
|
t: drawable.danmu.timeline_s
|
||||||
|
},
|
||||||
|
end = TimePoint {
|
||||||
|
t: drawable.danmu.timeline_s + drawable.duration
|
||||||
|
},
|
||||||
|
style = drawable.style_name,
|
||||||
|
effect = AssEffect {
|
||||||
|
effect: drawable.effect
|
||||||
|
},
|
||||||
|
b = drawable.danmu.rgb.2,
|
||||||
|
g = drawable.danmu.rgb.1,
|
||||||
|
r = drawable.danmu.rgb.0,
|
||||||
|
text = escape_text(&drawable.danmu.content),
|
||||||
|
// text = (0..drawable.danmu.content.chars().count()).map(|_| '晚').collect::<String>(),
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
.as_slice(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn flush(&mut self) -> Result<()> {
|
||||||
|
Ok(self.f.flush().await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn time_point_fmt() {
|
||||||
|
assert_eq!(format!("{}", TimePoint { t: 0.0 }), "0:00:00.00");
|
||||||
|
assert_eq!(format!("{}", TimePoint { t: 1.0 }), "0:00:01.00");
|
||||||
|
assert_eq!(format!("{}", TimePoint { t: 60.0 }), "0:01:00.00");
|
||||||
|
assert_eq!(format!("{}", TimePoint { t: 3600.0 }), "1:00:00.00");
|
||||||
|
assert_eq!(format!("{}", TimePoint { t: 3600.0 + 60.0 }), "1:01:00.00");
|
||||||
|
assert_eq!(format!("{}", TimePoint { t: 3600.0 + 60.0 + 1.0 }), "1:01:01.00");
|
||||||
|
assert_eq!(
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
TimePoint {
|
||||||
|
t: 3600.0 + 60.0 + 1.0 + 0.5
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"1:01:01.50"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
TimePoint {
|
||||||
|
t: 3600.0 + 1.0 + 0.01234
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"1:00:01.01"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_escape_text() {
|
||||||
|
assert_eq!(
|
||||||
|
escape_text("呵\n呵\n比\n你\n们\n更\n喜\n欢\n晚\n晚").as_ref(),
|
||||||
|
r"呵\N呵\N比\N你\N们\N更\N喜\N欢\N晚\N晚"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use super::CanvasConfig;
|
||||||
|
use crate::bilibili::danmaku::Danmu;
|
||||||
|
|
||||||
|
pub enum Collision {
|
||||||
|
// 会越来越远
|
||||||
|
Separate,
|
||||||
|
// 时间够可以追上,但是时间不够
|
||||||
|
NotEnoughTime,
|
||||||
|
// 需要额外的时间才可以避免碰撞
|
||||||
|
Collide { time_needed: f64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 表示一个弹幕槽位
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Lane {
|
||||||
|
last_shoot_time: f64,
|
||||||
|
last_length: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lane {
|
||||||
|
pub fn draw(danmu: &Danmu, config: &CanvasConfig) -> Self {
|
||||||
|
Lane {
|
||||||
|
last_shoot_time: danmu.timeline_s,
|
||||||
|
last_length: danmu.length(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这个槽位是否可以发射另外一条弹幕,返回可能的情形
|
||||||
|
pub fn available_for(&self, other: &Danmu, config: &CanvasConfig) -> Collision {
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let T = config.danmaku_option.duration;
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
let W = config.width as f64;
|
||||||
|
let gap = config.danmaku_option.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
|
||||||
|
} else {
|
||||||
|
// 需要算追击问题了,
|
||||||
|
// l1 已经消失,但是 l2 可能追上,我们计算 l1 刚好消失的时候:
|
||||||
|
// 此刻是 t1 + T
|
||||||
|
// l2 的头部应该在距离起点 v2 * (t1 + T - t2) 处
|
||||||
|
let pos = v2 * (T - delta_t);
|
||||||
|
if pos < (W - gap) {
|
||||||
|
Collision::NotEnoughTime
|
||||||
|
} else {
|
||||||
|
// 需要额外的时间
|
||||||
|
Collision::Collide {
|
||||||
|
time_needed: (pos - (W - gap)) / v2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! 决定绘画策略
|
||||||
|
mod lane;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use float_ord::FloatOrd;
|
||||||
|
use lane::Lane;
|
||||||
|
|
||||||
|
use super::{Danmu, Drawable};
|
||||||
|
use crate::bilibili::danmaku::canvas::lane::Collision;
|
||||||
|
use crate::bilibili::danmaku::danmu::DanmuType;
|
||||||
|
use crate::bilibili::danmaku::DrawEffect;
|
||||||
|
use crate::bilibili::PageInfo;
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct DanmakuOption {
|
||||||
|
pub duration: f64,
|
||||||
|
pub font: String,
|
||||||
|
pub font_size: u32,
|
||||||
|
pub width_ratio: f64,
|
||||||
|
/// 两条弹幕之间最小的水平距离
|
||||||
|
pub horizontal_gap: f64,
|
||||||
|
/// lane 大小
|
||||||
|
pub lane_size: u32,
|
||||||
|
/// 屏幕上滚动弹幕最多高度百分比
|
||||||
|
pub float_percentage: f64,
|
||||||
|
/// 屏幕上底部弹幕最多高度百分比
|
||||||
|
pub bottom_percentage: f64,
|
||||||
|
/// 透明度(0-255)
|
||||||
|
pub opacity: u8,
|
||||||
|
/// 是否加粗,1代表是,0代表否
|
||||||
|
pub bold: bool,
|
||||||
|
/// 描边
|
||||||
|
pub outline: f64,
|
||||||
|
/// 时间轴偏移
|
||||||
|
pub time_offset: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DanmakuOption {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
duration: 15.0,
|
||||||
|
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,
|
||||||
|
opacity: (0.3 * 255.0) as u8,
|
||||||
|
bold: true,
|
||||||
|
outline: 0.8,
|
||||||
|
time_offset: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct CanvasConfig {
|
||||||
|
pub width: u64,
|
||||||
|
pub height: u64,
|
||||||
|
pub danmaku_option: &'static DanmakuOption,
|
||||||
|
}
|
||||||
|
impl CanvasConfig {
|
||||||
|
pub fn new(danmaku_option: &'static DanmakuOption, page: &PageInfo) -> Self {
|
||||||
|
let (width, height) = Self::dimension(page);
|
||||||
|
Self {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
danmaku_option,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取画布的宽高
|
||||||
|
fn dimension(page: &PageInfo) -> (u64, u64) {
|
||||||
|
let (width, height) = match &page.dimension {
|
||||||
|
Some(d) => {
|
||||||
|
if d.rotate == 0 {
|
||||||
|
(d.width, d.height)
|
||||||
|
} else {
|
||||||
|
(d.height, d.width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (1280, 720),
|
||||||
|
};
|
||||||
|
// 对于指定的字体大小,画布的大小同样会影响到字体的实际显示大小
|
||||||
|
// 怀疑字体的大小会根据 height 缩放,尝试将视频的 height 对齐到 720
|
||||||
|
((720.0 / height as f64 * width as f64) as u64, 720)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn canvas(self) -> Canvas {
|
||||||
|
let float_lanes_cnt =
|
||||||
|
(self.danmaku_option.float_percentage * self.height as f64 / self.danmaku_option.lane_size as f64) as usize;
|
||||||
|
|
||||||
|
Canvas {
|
||||||
|
config: self,
|
||||||
|
float_lanes: vec![None; float_lanes_cnt],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Canvas {
|
||||||
|
pub config: CanvasConfig,
|
||||||
|
pub float_lanes: Vec<Option<Lane>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Canvas {
|
||||||
|
pub fn draw(&mut self, mut danmu: Danmu) -> Result<Option<Drawable>> {
|
||||||
|
danmu.timeline_s += self.config.danmaku_option.time_offset;
|
||||||
|
if danmu.timeline_s < 0.0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match danmu.r#type {
|
||||||
|
DanmuType::Float => Ok(self.draw_float(danmu)),
|
||||||
|
DanmuType::Bottom | DanmuType::Top | DanmuType::Reverse => {
|
||||||
|
// 不喜欢底部弹幕,直接转成 Bottom
|
||||||
|
// 这是 feature 不是 bug
|
||||||
|
danmu.r#type = DanmuType::Float;
|
||||||
|
Ok(self.draw_float(danmu))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_float(&mut self, mut danmu: Danmu) -> 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(danmu, idx));
|
||||||
|
}
|
||||||
|
Some(l) => {
|
||||||
|
let col = l.available_for(&danmu, &self.config);
|
||||||
|
match col {
|
||||||
|
Collision::Separate | Collision::NotEnoughTime => {
|
||||||
|
return Some(self.draw_float_in_lane(danmu, 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 {
|
||||||
|
debug!("延迟弹幕 {} 秒", time_need);
|
||||||
|
// 只允许延迟 1s
|
||||||
|
danmu.timeline_s += time_need + 0.01; // 间隔也不要太小了
|
||||||
|
return Some(self.draw_float_in_lane(danmu, lane_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!("skipping danmu: {}", danmu.content);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_float_in_lane(&mut self, danmu: Danmu, lane_idx: usize) -> Drawable {
|
||||||
|
self.float_lanes[lane_idx] = Some(Lane::draw(&danmu, &self.config));
|
||||||
|
let y = lane_idx as i32 * self.config.danmaku_option.lane_size as i32;
|
||||||
|
let l = danmu.length(&self.config);
|
||||||
|
Drawable::new(
|
||||||
|
danmu,
|
||||||
|
self.config.danmaku_option.duration,
|
||||||
|
"Float",
|
||||||
|
DrawEffect::Move {
|
||||||
|
start: (self.config.width as i32, y),
|
||||||
|
end: (-(l as i32), y),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! 一个弹幕实例,但是没有位置信息
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::canvas::CanvasConfig;
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum DanmuType {
|
||||||
|
#[default]
|
||||||
|
Float,
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
Reverse,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DanmuType {
|
||||||
|
pub fn from_num(num: i32) -> Result<Self> {
|
||||||
|
Ok(match num {
|
||||||
|
1 => DanmuType::Float,
|
||||||
|
4 => DanmuType::Bottom,
|
||||||
|
5 => DanmuType::Top,
|
||||||
|
6 => DanmuType::Reverse,
|
||||||
|
_ => unreachable!(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct Danmu {
|
||||||
|
pub timeline_s: f64,
|
||||||
|
pub content: String,
|
||||||
|
pub r#type: DanmuType,
|
||||||
|
/// 虽然这里有 fontsize,但是我们实际上使用 canvas config 的 font size,
|
||||||
|
/// 否在在调节分辨率的时候字体会发生变化。
|
||||||
|
pub fontsize: u32,
|
||||||
|
pub rgb: (u8, u8, u8),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Danmu {
|
||||||
|
/// 计算弹幕的“像素长度”,会乘上一个缩放因子
|
||||||
|
///
|
||||||
|
/// 汉字算一个全宽,英文算2/3宽
|
||||||
|
pub fn length(&self, config: &CanvasConfig) -> f64 {
|
||||||
|
let pts = config.danmaku_option.font_size
|
||||||
|
* self
|
||||||
|
.content
|
||||||
|
.chars()
|
||||||
|
.map(|ch| if ch.is_ascii() { 2 } else { 3 })
|
||||||
|
.sum::<u32>()
|
||||||
|
/ 3;
|
||||||
|
|
||||||
|
pts as f64 * config.danmaku_option.width_ratio
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//! 可以绘制的实体
|
||||||
|
|
||||||
|
use crate::bilibili::danmaku::Danmu;
|
||||||
|
|
||||||
|
/// 弹幕开始绘制的时间就是 danmu 的时间
|
||||||
|
pub struct Drawable {
|
||||||
|
pub danmu: Danmu,
|
||||||
|
/// 弹幕一共绘制的时间
|
||||||
|
pub duration: f64,
|
||||||
|
/// 弹幕的绘制 style
|
||||||
|
pub style_name: &'static str,
|
||||||
|
/// 绘制的“特效”
|
||||||
|
pub effect: DrawEffect,
|
||||||
|
}
|
||||||
|
impl Drawable {
|
||||||
|
pub fn new(danmu: Danmu, duration: f64, style_name: &'static str, effect: DrawEffect) -> Self {
|
||||||
|
Drawable {
|
||||||
|
danmu,
|
||||||
|
duration,
|
||||||
|
style_name,
|
||||||
|
effect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum DrawEffect {
|
||||||
|
Move { start: (i32, i32), end: (i32, i32) },
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
mod ass_writer;
|
||||||
|
mod canvas;
|
||||||
|
mod danmu;
|
||||||
|
mod drawable;
|
||||||
|
mod model;
|
||||||
|
mod writer;
|
||||||
|
|
||||||
|
pub use ass_writer::AssWriter;
|
||||||
|
pub use canvas::DanmakuOption;
|
||||||
|
pub use danmu::Danmu;
|
||||||
|
pub use drawable::{DrawEffect, Drawable};
|
||||||
|
pub use model::{DanmakuElem, DmSegMobileReply};
|
||||||
|
pub use writer::DanmakuWriter;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! 出于减少编译引入考虑,直接翻译了一下 pb,不引入 prost-build
|
||||||
|
//!
|
||||||
|
//! 可以看旁边的 dm.proto
|
||||||
|
|
||||||
|
use prost::Message;
|
||||||
|
|
||||||
|
use crate::bilibili::danmaku::danmu::{Danmu, DanmuType};
|
||||||
|
/// 弹幕 pb 定义
|
||||||
|
#[derive(Clone, Message)]
|
||||||
|
pub struct DanmakuElem {
|
||||||
|
/// 弹幕 dmid
|
||||||
|
#[prost(int64, tag = "1")]
|
||||||
|
pub id: i64,
|
||||||
|
|
||||||
|
/// 弹幕出现位置(单位 ms)
|
||||||
|
#[prost(int32, tag = "2")]
|
||||||
|
pub progress: i32,
|
||||||
|
|
||||||
|
/// 弹幕类型
|
||||||
|
#[prost(int32, tag = "3")]
|
||||||
|
pub mode: i32,
|
||||||
|
|
||||||
|
/// 弹幕字号
|
||||||
|
#[prost(int32, tag = "4")]
|
||||||
|
pub fontsize: i32,
|
||||||
|
|
||||||
|
/// 弹幕颜色
|
||||||
|
#[prost(uint32, tag = "5")]
|
||||||
|
pub color: u32,
|
||||||
|
|
||||||
|
/// 发送者 mid hash
|
||||||
|
#[prost(string, tag = "6")]
|
||||||
|
pub mid_hash: String,
|
||||||
|
|
||||||
|
/// 弹幕正文
|
||||||
|
#[prost(string, tag = "7")]
|
||||||
|
pub content: String,
|
||||||
|
|
||||||
|
/// 弹幕发送时间
|
||||||
|
#[prost(int64, tag = "8")]
|
||||||
|
pub ctime: i64,
|
||||||
|
|
||||||
|
/// 弹幕权重
|
||||||
|
#[prost(int32, tag = "9")]
|
||||||
|
pub weight: i32,
|
||||||
|
|
||||||
|
/// 动作?
|
||||||
|
#[prost(string, tag = "10")]
|
||||||
|
pub action: String,
|
||||||
|
|
||||||
|
/// 弹幕池
|
||||||
|
#[prost(int32, tag = "11")]
|
||||||
|
pub pool: i32,
|
||||||
|
|
||||||
|
/// 弹幕 dmid str
|
||||||
|
#[prost(string, tag = "12")]
|
||||||
|
pub dmid_str: String,
|
||||||
|
|
||||||
|
/// 弹幕属性
|
||||||
|
#[prost(int32, tag = "13")]
|
||||||
|
pub attr: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Message)]
|
||||||
|
pub struct DmSegMobileReply {
|
||||||
|
#[prost(message, repeated, tag = "1")]
|
||||||
|
pub elems: Vec<DanmakuElem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DanmakuElem> for Danmu {
|
||||||
|
fn from(elem: DanmakuElem) -> Self {
|
||||||
|
Self {
|
||||||
|
timeline_s: elem.progress as f64 / 1000.0,
|
||||||
|
content: elem.content,
|
||||||
|
r#type: DanmuType::from_num(elem.mode).unwrap_or_default(),
|
||||||
|
fontsize: elem.fontsize as u32,
|
||||||
|
rgb: (
|
||||||
|
((elem.color >> 16) & 0xFF) as u8,
|
||||||
|
((elem.color >> 8) & 0xFF) as u8,
|
||||||
|
(elem.color & 0xFF) as u8,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::fs::{self, File};
|
||||||
|
|
||||||
|
use super::canvas::CanvasConfig;
|
||||||
|
use super::{AssWriter, Danmu};
|
||||||
|
use crate::bilibili::PageInfo;
|
||||||
|
use crate::config::CONFIG;
|
||||||
|
|
||||||
|
pub struct DanmakuWriter<'a> {
|
||||||
|
page: &'a PageInfo,
|
||||||
|
danmaku: Vec<Danmu>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> DanmakuWriter<'a> {
|
||||||
|
pub fn new(page: &'a PageInfo, danmaku: Vec<Danmu>) -> Self {
|
||||||
|
DanmakuWriter { page, danmaku }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write(self, path: PathBuf) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
let canvas_config = CanvasConfig::new(&CONFIG.danmaku_option, self.page);
|
||||||
|
let mut writer =
|
||||||
|
AssWriter::construct(File::create(path).await?, self.page.name.clone(), canvas_config.clone()).await?;
|
||||||
|
let mut canvas = canvas_config.canvas();
|
||||||
|
for danmuku in self.danmaku {
|
||||||
|
if let Some(drawable) = canvas.draw(danmuku)? {
|
||||||
|
writer.write(drawable).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -1,13 +1,15 @@
|
|||||||
pub use analyzer::{BestStream, FilterOption};
|
pub use analyzer::{BestStream, FilterOption};
|
||||||
pub use client::{BiliClient, Client};
|
pub use client::{BiliClient, Client};
|
||||||
pub use credential::Credential;
|
pub use credential::Credential;
|
||||||
|
pub use danmaku::DanmakuOption;
|
||||||
pub use error::BiliError;
|
pub use error::BiliError;
|
||||||
pub use favorite_list::{FavoriteList, FavoriteListInfo, VideoInfo};
|
pub use favorite_list::{FavoriteList, FavoriteListInfo, VideoInfo};
|
||||||
pub use video::{PageInfo, Video};
|
pub use video::{Dimension, PageInfo, Video};
|
||||||
|
|
||||||
mod analyzer;
|
mod analyzer;
|
||||||
mod client;
|
mod client;
|
||||||
mod credential;
|
mod credential;
|
||||||
|
mod danmaku;
|
||||||
mod error;
|
mod error;
|
||||||
mod favorite_list;
|
mod favorite_list;
|
||||||
mod video;
|
mod video;
|
||||||
|
|||||||
+38
-1
@@ -1,8 +1,13 @@
|
|||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
|
use futures::stream::FuturesUnordered;
|
||||||
|
use futures::TryStreamExt;
|
||||||
|
use prost::Message;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
|
|
||||||
|
use super::danmaku::{DanmakuElem, DanmakuWriter};
|
||||||
use crate::bilibili::analyzer::PageAnalyzer;
|
use crate::bilibili::analyzer::PageAnalyzer;
|
||||||
use crate::bilibili::client::BiliClient;
|
use crate::bilibili::client::BiliClient;
|
||||||
|
use crate::bilibili::danmaku::DmSegMobileReply;
|
||||||
use crate::bilibili::error::BiliError;
|
use crate::bilibili::error::BiliError;
|
||||||
|
|
||||||
static MASK_CODE: u64 = 2251799813685247;
|
static MASK_CODE: u64 = 2251799813685247;
|
||||||
@@ -33,14 +38,22 @@ impl serde::Serialize for Tag {
|
|||||||
serializer.serialize_str(&self.tag_name)
|
serializer.serialize_str(&self.tag_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, Default)]
|
#[derive(Debug, serde::Deserialize, Default)]
|
||||||
pub struct PageInfo {
|
pub struct PageInfo {
|
||||||
pub cid: i32,
|
pub cid: i32,
|
||||||
pub page: i32,
|
pub page: i32,
|
||||||
#[serde(rename = "part")]
|
#[serde(rename = "part")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub duration: u32,
|
||||||
pub first_frame: Option<String>,
|
pub first_frame: Option<String>,
|
||||||
|
pub dimension: Option<Dimension>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, Default)]
|
||||||
|
pub struct Dimension {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rotate: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Video<'a> {
|
impl<'a> Video<'a> {
|
||||||
@@ -89,6 +102,30 @@ impl<'a> Video<'a> {
|
|||||||
Ok(serde_json::from_value(res["data"].take())?)
|
Ok(serde_json::from_value(res["data"].take())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_danmaku_writer(&self, page: &'a PageInfo) -> Result<DanmakuWriter> {
|
||||||
|
let tasks = FuturesUnordered::new();
|
||||||
|
for i in 1..=(page.duration + 359) / 360 {
|
||||||
|
tasks.push(self.get_danmaku_segment(page, i as i32));
|
||||||
|
}
|
||||||
|
let result: Vec<Vec<DanmakuElem>> = tasks.try_collect().await?;
|
||||||
|
let mut result: Vec<DanmakuElem> = result.into_iter().flatten().collect();
|
||||||
|
result.sort_by_key(|d| d.progress);
|
||||||
|
Ok(DanmakuWriter::new(page, result.into_iter().map(|x| x.into()).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_danmaku_segment(&self, page: &PageInfo, segment_idx: i32) -> Result<Vec<DanmakuElem>> {
|
||||||
|
let res = self
|
||||||
|
.client
|
||||||
|
.request(Method::GET, "http://api.bilibili.com/x/v2/dm/web/seg.so")
|
||||||
|
.query(&[("type", 1), ("oid", page.cid), ("segment_index", segment_idx)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.bytes()
|
||||||
|
.await?;
|
||||||
|
Ok(DmSegMobileReply::decode(res)?.elems)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_page_analyzer(&self, page: &PageInfo) -> Result<PageAnalyzer> {
|
pub async fn get_page_analyzer(&self, page: &PageInfo) -> Result<PageAnalyzer> {
|
||||||
let mut res = self
|
let mut res = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
+7
-5
@@ -7,16 +7,15 @@ use arc_swap::ArcSwapOption;
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::bilibili::{Credential, FilterOption};
|
use crate::bilibili::{Credential, DanmakuOption, FilterOption};
|
||||||
|
|
||||||
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
pub static CONFIG: Lazy<Config> = Lazy::new(|| {
|
||||||
let config = Config::load().unwrap_or_else(|err| {
|
let config = Config::load().unwrap_or_else(|err| {
|
||||||
warn!("Failed loading config: {err}");
|
warn!("Failed loading config: {err}");
|
||||||
let new_config = Config::new();
|
Config::new()
|
||||||
// 保存一次,确保配置文件存在
|
|
||||||
new_config.save().unwrap();
|
|
||||||
new_config
|
|
||||||
});
|
});
|
||||||
|
// 放到外面,确保新的配置项被保存
|
||||||
|
config.save().unwrap();
|
||||||
// 检查配置文件内容
|
// 检查配置文件内容
|
||||||
config.check();
|
config.check();
|
||||||
config
|
config
|
||||||
@@ -29,6 +28,8 @@ pub static CONFIG_DIR: Lazy<PathBuf> =
|
|||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub credential: ArcSwapOption<Credential>,
|
pub credential: ArcSwapOption<Credential>,
|
||||||
pub filter_option: FilterOption,
|
pub filter_option: FilterOption,
|
||||||
|
#[serde(default)]
|
||||||
|
pub danmaku_option: DanmakuOption,
|
||||||
pub favorite_list: HashMap<String, PathBuf>,
|
pub favorite_list: HashMap<String, PathBuf>,
|
||||||
pub video_name: Cow<'static, str>,
|
pub video_name: Cow<'static, str>,
|
||||||
pub page_name: Cow<'static, str>,
|
pub page_name: Cow<'static, str>,
|
||||||
@@ -47,6 +48,7 @@ impl Config {
|
|||||||
Self {
|
Self {
|
||||||
credential: ArcSwapOption::empty(),
|
credential: ArcSwapOption::empty(),
|
||||||
filter_option: FilterOption::default(),
|
filter_option: FilterOption::default(),
|
||||||
|
danmaku_option: DanmakuOption::default(),
|
||||||
favorite_list: HashMap::new(),
|
favorite_list: HashMap::new(),
|
||||||
video_name: Cow::Borrowed("{{bvid}}"),
|
video_name: Cow::Borrowed("{{bvid}}"),
|
||||||
page_name: Cow::Borrowed("{{bvid}}"),
|
page_name: Cow::Borrowed("{{bvid}}"),
|
||||||
|
|||||||
+51
-12
@@ -18,7 +18,7 @@ use super::status::{PageStatus, VideoStatus};
|
|||||||
use super::utils::{
|
use super::utils::{
|
||||||
unhandled_videos_pages, update_pages_model, update_videos_model, ModelWrapper, NFOMode, NFOSerializer, TEMPLATE,
|
unhandled_videos_pages, update_pages_model, update_videos_model, ModelWrapper, NFOMode, NFOSerializer, TEMPLATE,
|
||||||
};
|
};
|
||||||
use crate::bilibili::{BestStream, BiliClient, BiliError, FavoriteList, FilterOption, PageInfo, Video};
|
use crate::bilibili::{BestStream, BiliClient, BiliError, Dimension, FavoriteList, FilterOption, PageInfo, Video};
|
||||||
use crate::config::CONFIG;
|
use crate::config::CONFIG;
|
||||||
use crate::core::utils::{
|
use crate::core::utils::{
|
||||||
create_video_pages, create_videos, exist_labels, filter_unfilled_videos, handle_favorite_info, total_video_count,
|
create_video_pages, create_videos, exist_labels, filter_unfilled_videos, handle_favorite_info, total_video_count,
|
||||||
@@ -199,13 +199,11 @@ pub async fn download_video_pages(
|
|||||||
}
|
}
|
||||||
let mut status = VideoStatus::new(video_model.download_status);
|
let mut status = VideoStatus::new(video_model.download_status);
|
||||||
let seprate_status = status.should_run();
|
let seprate_status = status.should_run();
|
||||||
|
|
||||||
let base_path = Path::new(&video_model.path);
|
let base_path = Path::new(&video_model.path);
|
||||||
let upper_id = video_model.upper_id.to_string();
|
let upper_id = video_model.upper_id.to_string();
|
||||||
let base_upper_path = upper_path
|
let base_upper_path = upper_path
|
||||||
.join(upper_id.chars().next().unwrap().to_string())
|
.join(upper_id.chars().next().unwrap().to_string())
|
||||||
.join(upper_id);
|
.join(upper_id);
|
||||||
|
|
||||||
let is_single_page = video_model.single_page.unwrap();
|
let is_single_page = video_model.single_page.unwrap();
|
||||||
// 对于单页视频,page 的下载已经足够
|
// 对于单页视频,page 的下载已经足够
|
||||||
// 对于多页视频,page 下载仅包含了分集内容,需要额外补上视频的 poster 的 tvshow.nfo
|
// 对于多页视频,page 下载仅包含了分集内容,需要额外补上视频的 poster 的 tvshow.nfo
|
||||||
@@ -352,11 +350,12 @@ pub async fn download_page(
|
|||||||
"pid": page_model.pid,
|
"pid": page_model.pid,
|
||||||
}),
|
}),
|
||||||
)?);
|
)?);
|
||||||
let (poster_path, video_path, nfo_path) = if is_single_page {
|
let (poster_path, video_path, nfo_path, danmaku_path) = if is_single_page {
|
||||||
(
|
(
|
||||||
base_path.join(format!("{}-poster.jpg", &base_name)),
|
base_path.join(format!("{}-poster.jpg", &base_name)),
|
||||||
base_path.join(format!("{}.mp4", &base_name)),
|
base_path.join(format!("{}.mp4", &base_name)),
|
||||||
base_path.join(format!("{}.nfo", &base_name)),
|
base_path.join(format!("{}.nfo", &base_name)),
|
||||||
|
base_path.join(format!("{}.zh-CN.default.ass", &base_name)),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(
|
(
|
||||||
@@ -369,10 +368,27 @@ pub async fn download_page(
|
|||||||
base_path
|
base_path
|
||||||
.join("Season 1")
|
.join("Season 1")
|
||||||
.join(format!("{} - S01E{:0>2}.nfo", &base_name, page_model.pid)),
|
.join(format!("{} - S01E{:0>2}.nfo", &base_name, page_model.pid)),
|
||||||
|
base_path
|
||||||
|
.join("Season 1")
|
||||||
|
.join(format!("{} - S01E{:0>2}.zh-CN.default.ass", &base_name, page_model.pid)),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let dimension = if page_model.width.is_some() && page_model.height.is_some() {
|
||||||
|
Some(Dimension {
|
||||||
|
width: page_model.width.unwrap(),
|
||||||
|
height: page_model.height.unwrap(),
|
||||||
|
rotate: 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let page_info = PageInfo {
|
||||||
|
cid: page_model.cid,
|
||||||
|
duration: page_model.duration,
|
||||||
|
dimension,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let tasks: Vec<Pin<Box<dyn Future<Output = Result<()>>>>> = vec![
|
let tasks: Vec<Pin<Box<dyn Future<Output = Result<()>>>>> = vec![
|
||||||
// 暂时不支持下载字幕
|
|
||||||
Box::pin(fetch_page_poster(
|
Box::pin(fetch_page_poster(
|
||||||
seprate_status[0],
|
seprate_status[0],
|
||||||
video_model,
|
video_model,
|
||||||
@@ -384,18 +400,25 @@ pub async fn download_page(
|
|||||||
seprate_status[1],
|
seprate_status[1],
|
||||||
bili_client,
|
bili_client,
|
||||||
video_model,
|
video_model,
|
||||||
&page_model,
|
|
||||||
downloader,
|
downloader,
|
||||||
|
&page_info,
|
||||||
video_path.clone(),
|
video_path.clone(),
|
||||||
)),
|
)),
|
||||||
Box::pin(generate_page_nfo(seprate_status[2], video_model, &page_model, nfo_path)),
|
Box::pin(generate_page_nfo(seprate_status[2], video_model, &page_model, nfo_path)),
|
||||||
|
Box::pin(fetch_page_danmaku(
|
||||||
|
seprate_status[3],
|
||||||
|
bili_client,
|
||||||
|
video_model,
|
||||||
|
&page_info,
|
||||||
|
danmaku_path,
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
let tasks: FuturesOrdered<_> = tasks.into_iter().collect();
|
let tasks: FuturesOrdered<_> = tasks.into_iter().collect();
|
||||||
let results: Vec<Result<()>> = tasks.collect().await;
|
let results: Vec<Result<()>> = tasks.collect().await;
|
||||||
status.update_status(&results);
|
status.update_status(&results);
|
||||||
results
|
results
|
||||||
.iter()
|
.iter()
|
||||||
.zip(["poster", "video", "nfo"])
|
.zip(["poster", "video", "nfo", "danmaku"])
|
||||||
.for_each(|(res, task_name)| {
|
.for_each(|(res, task_name)| {
|
||||||
if res.is_err() {
|
if res.is_err() {
|
||||||
error!(
|
error!(
|
||||||
@@ -447,8 +470,8 @@ pub async fn fetch_page_video(
|
|||||||
should_run: bool,
|
should_run: bool,
|
||||||
bili_client: &BiliClient,
|
bili_client: &BiliClient,
|
||||||
video_model: &video::Model,
|
video_model: &video::Model,
|
||||||
page_model: &page::Model,
|
|
||||||
downloader: &Downloader,
|
downloader: &Downloader,
|
||||||
|
page_info: &PageInfo,
|
||||||
page_path: PathBuf,
|
page_path: PathBuf,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !should_run {
|
if !should_run {
|
||||||
@@ -456,10 +479,7 @@ pub async fn fetch_page_video(
|
|||||||
}
|
}
|
||||||
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||||
let streams = bili_video
|
let streams = bili_video
|
||||||
.get_page_analyzer(&PageInfo {
|
.get_page_analyzer(page_info)
|
||||||
cid: page_model.cid,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await?
|
.await?
|
||||||
.best_stream(&FilterOption::default())?;
|
.best_stream(&FilterOption::default())?;
|
||||||
match streams {
|
match streams {
|
||||||
@@ -488,6 +508,25 @@ pub async fn fetch_page_video(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_page_danmaku(
|
||||||
|
should_run: bool,
|
||||||
|
bili_client: &BiliClient,
|
||||||
|
video_model: &video::Model,
|
||||||
|
page_info: &PageInfo,
|
||||||
|
danmaku_path: PathBuf,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !should_run {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let bili_video = Video::new(bili_client, video_model.bvid.clone());
|
||||||
|
bili_video
|
||||||
|
.get_danmaku_writer(page_info)
|
||||||
|
.await?
|
||||||
|
.write(danmaku_path)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn generate_page_nfo(
|
pub async fn generate_page_nfo(
|
||||||
should_run: bool,
|
should_run: bool,
|
||||||
video_model: &video::Model,
|
video_model: &video::Model,
|
||||||
|
|||||||
+4
-45
@@ -1,5 +1,3 @@
|
|||||||
use core::fmt;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
static STATUS_MAX_RETRY: u32 = 0b100;
|
static STATUS_MAX_RETRY: u32 = 0b100;
|
||||||
@@ -94,16 +92,6 @@ impl Status {
|
|||||||
let helper = !0u32;
|
let helper = !0u32;
|
||||||
(self.0 & (helper << (offset * 3)) & (helper >> (32 - 3 * offset - 3))) >> (offset * 3)
|
(self.0 & (helper << (offset * 3)) & (helper >> (32 - 3 * offset - 3))) >> (offset * 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn display_status(status: u32) -> String {
|
|
||||||
if status < STATUS_MAX_RETRY {
|
|
||||||
format!("failed {} times", status)
|
|
||||||
} else if status == STATUS_OK {
|
|
||||||
"ok".to_string()
|
|
||||||
} else {
|
|
||||||
"failed".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Status> for u32 {
|
impl From<Status> for u32 {
|
||||||
@@ -136,20 +124,6 @@ impl VideoStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for VideoStatus {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"Video Cover: {}, Video NFO: {}, Up Avatar: {}, Up NFO: {}, Page Download: {}",
|
|
||||||
Status::display_status(self.0.get_status(0)),
|
|
||||||
Status::display_status(self.0.get_status(1)),
|
|
||||||
Status::display_status(self.0.get_status(2)),
|
|
||||||
Status::display_status(self.0.get_status(3)),
|
|
||||||
Status::display_status(self.0.get_status(4))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<VideoStatus> for u32 {
|
impl From<VideoStatus> for u32 {
|
||||||
fn from(status: VideoStatus) -> Self {
|
fn from(status: VideoStatus) -> Self {
|
||||||
status.0.into()
|
status.0.into()
|
||||||
@@ -166,32 +140,17 @@ impl PageStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_mask(&mut self, clear: &[bool]) {
|
pub fn set_mask(&mut self, clear: &[bool]) {
|
||||||
assert!(clear.len() == 3, "PageStatus should have 3 status");
|
assert!(clear.len() == 4, "PageStatus should have 4 status");
|
||||||
self.0.set_mask(clear)
|
self.0.set_mask(clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn should_run(&self) -> Vec<bool> {
|
pub fn should_run(&self) -> Vec<bool> {
|
||||||
self.0.should_run(3)
|
self.0.should_run(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_status(&mut self, result: &[Result<()>]) {
|
pub fn update_status(&mut self, result: &[Result<()>]) {
|
||||||
assert!(
|
assert!(result.len() == 4, "PageStatus should have 4 status");
|
||||||
result.len() >= 3,
|
self.0.update_status(&result)
|
||||||
"PageStatus should have at least 3 status, more status will be ignored"
|
|
||||||
);
|
|
||||||
self.0.update_status(&result[..3])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for PageStatus {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"Page Cover: {}, Page Content: {}, Page NFO: {}",
|
|
||||||
Status::display_status(self.0.get_status(0)),
|
|
||||||
Status::display_status(self.0.get_status(1)),
|
|
||||||
Status::display_status(self.0.get_status(2))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -28,6 +28,7 @@ pub static TEMPLATE: Lazy<handlebars::Handlebars> = Lazy::new(|| {
|
|||||||
handlebars
|
handlebars
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#[allow(clippy::upper_case_acronyms)]
|
||||||
pub enum NFOMode {
|
pub enum NFOMode {
|
||||||
MOVIE,
|
MOVIE,
|
||||||
TVSHOW,
|
TVSHOW,
|
||||||
@@ -182,14 +183,29 @@ pub async fn create_video_pages(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let page_models = pages_info
|
let page_models = pages_info
|
||||||
.iter()
|
.iter()
|
||||||
.map(move |p| page::ActiveModel {
|
.map(move |p| {
|
||||||
video_id: Set(video_model.id),
|
let (width, height) = match &p.dimension {
|
||||||
cid: Set(p.cid),
|
Some(d) => {
|
||||||
pid: Set(p.page),
|
if d.rotate == 0 {
|
||||||
name: Set(p.name.clone()),
|
(Some(d.width), Some(d.height))
|
||||||
image: Set(p.first_frame.clone()),
|
} else {
|
||||||
download_status: Set(0),
|
(Some(d.height), Some(d.width))
|
||||||
..Default::default()
|
}
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
page::ActiveModel {
|
||||||
|
video_id: Set(video_model.id),
|
||||||
|
cid: Set(p.cid),
|
||||||
|
pid: Set(p.page),
|
||||||
|
name: Set(p.name.clone()),
|
||||||
|
width: Set(width),
|
||||||
|
height: Set(height),
|
||||||
|
duration: Set(p.duration),
|
||||||
|
image: Set(p.first_frame.clone()),
|
||||||
|
download_status: Set(0),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<page::ActiveModel>>();
|
.collect::<Vec<page::ActiveModel>>();
|
||||||
page::Entity::insert_many(page_models)
|
page::Entity::insert_many(page_models)
|
||||||
|
|||||||
Reference in New Issue
Block a user