Skip to content

Commit fd458a9

Browse files
committed
[bilibili.playback] Add support for uploading playback videos
1 parent 92f933e commit fd458a9

15 files changed

Lines changed: 1970 additions & 300 deletions

File tree

Cargo.lock

Lines changed: 333 additions & 85 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ bytes = "1.7.2"
1010
chrono = "0.4.39"
1111
clap = { version = "4.5.19", features = ["derive"] }
1212
const_format = "0.2.34"
13-
futures = "0.3.31"
1413
headless_chrome = "1.0.15"
14+
http = "1.3.1"
15+
http-serde = "2.1.1"
16+
humansize = "2.1.3"
1517
humantime-serde = "1.1.1"
1618
image = "0.25.2"
1719
itertools = "0.13.0"
@@ -23,8 +25,10 @@ serde = { version = "1.0.210", features = ["derive", "rc"] }
2325
serde_json = "1.0.128"
2426
shadow-rs = "0.38.0"
2527
spdlog-rs = { version = "0.4.0", features = ["source-location"] }
26-
tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "fs", "time", "sync"] }
28+
tempfile = "3.19.1"
29+
tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "fs", "time", "sync", "process"] }
2730
toml = "0.8.19"
31+
warp = "0.3.7"
2832

2933
[build-dependencies]
3034
shadow-rs = "0.38.0"

src/config/mod.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ pub struct PlatformGlobal {
147147
pub telegram: Accessor<Option<notify::platform::telegram::ConfigGlobal>>,
148148
#[serde(rename = "Twitter")]
149149
pub twitter: Accessor<Option<source::platform::twitter::ConfigGlobal>>,
150+
#[serde(rename = "bilibili")]
151+
pub bilibili: Accessor<Option<source::platform::bilibili::ConfigGlobal>>,
150152
}
151153

152154
impl Validator for PlatformGlobal {
@@ -155,6 +157,7 @@ impl Validator for PlatformGlobal {
155157
self.qq.validate()?;
156158
self.telegram.validate()?;
157159
self.twitter.validate()?;
160+
self.bilibili.validate()?;
158161
Ok(())
159162
}
160163
}
@@ -185,6 +188,10 @@ pub struct Notifications {
185188
pub post: bool,
186189
#[serde(default = "helper::refl_bool::<true>")]
187190
pub log: bool,
191+
#[serde(default = "helper::refl_bool::<true>")]
192+
pub playback: bool,
193+
#[serde(default = "helper::refl_bool::<true>")]
194+
pub document: bool,
188195
}
189196

190197
serde_impl_default_for!(Notifications);
@@ -201,6 +208,8 @@ impl Overridable for Notifications {
201208
live_title: new.live_title.unwrap_or(self.live_title),
202209
post: new.post.unwrap_or(self.post),
203210
log: new.log.unwrap_or(self.log),
211+
playback: new.playback.unwrap_or(self.playback),
212+
document: new.document.unwrap_or(self.document),
204213
}
205214
}
206215
}
@@ -211,6 +220,8 @@ pub struct NotificationsOverride {
211220
pub live_title: Option<bool>,
212221
pub post: Option<bool>,
213222
pub log: Option<bool>,
223+
pub playback: Option<bool>,
224+
pub document: Option<bool>,
214225
}
215226

216227
#[derive(Debug, PartialEq, Deserialize)]
@@ -288,6 +299,9 @@ token = "ttt"
288299
[platform.Twitter]
289300
auth = { cookies = "a=b;c=d;ct0=blah" }
290301
302+
[platform.bilibili]
303+
playback = { bililive_recorder = { listen_webhook = { host = "127.0.0.1", port = 8888 }, working_directory = "/brec/" } }
304+
291305
[notify]
292306
meow = { platform = "Telegram", id = 1234, thread_id = 123, token = "xxx" }
293307
woof = { platform = "Telegram", id = 5678, thread_id = 900, notifications = { post = false } }
@@ -330,11 +344,23 @@ notify = ["meow", "woof", { ref = "woof", id = 123 }]
330344
})),
331345
telegram: Accessor::new(Some(notify::platform::telegram::ConfigGlobal {
332346
token: Some(notify::platform::telegram::ConfigToken::with_raw("ttt")),
347+
api_server: None,
333348
experimental: Default::default()
334349
})),
335350
twitter: Accessor::new(Some(source::platform::twitter::ConfigGlobal {
336351
auth: source::platform::twitter::ConfigCookies::with_raw("a=b;c=d;ct0=blah")
337-
}))
352+
})),
353+
bilibili: Accessor::new(Some(source::platform::bilibili::ConfigGlobal {
354+
playback: Accessor::new(Some(source::platform::bilibili::playback::ConfigGlobal {
355+
bililive_recorder: Accessor::new(source::platform::bilibili::playback::bililive_recorder::ConfigBililiveRecorder {
356+
listen_webhook: source::platform::bilibili::playback::bililive_recorder::ConfigListen {
357+
host: "127.0.0.1".into(),
358+
port: 8888
359+
},
360+
working_directory: "/brec/".into()
361+
})
362+
}))
363+
})),
338364
}),
339365
notify_map: Accessor::new(NotifyMap(HashMap::from_iter([
340366
(
@@ -354,6 +380,8 @@ notify = ["meow", "woof", { ref = "woof", id = 123 }]
354380
live_title: false,
355381
post: false,
356382
log: true,
383+
playback: true,
384+
document: true
357385
},
358386
chat: notify::platform::telegram::ConfigChat::Id(5678),
359387
thread_id: Some(900),

src/helper.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
use std::{convert::identity, time::Duration};
1+
use std::{convert::identity, path::Path, time::Duration};
22

3-
use anyhow::anyhow;
3+
use anyhow::{anyhow, ensure};
44
use humantime_serde::re::humantime;
55
use reqwest::header::{self, HeaderMap, HeaderValue};
6+
use tokio::process::Command;
67

78
use crate::prop;
89

@@ -65,6 +66,54 @@ pub fn format_duration_in_min(dur: Duration) -> String {
6566
humantime::format_duration(Duration::from_secs(mins * 60)).to_string()
6667
}
6768

69+
pub async fn ffmpeg_copy(from: &Path, to: &Path) -> anyhow::Result<()> {
70+
let mut cmd = Command::new("ffmpeg");
71+
cmd.arg("-i").arg(from).arg("-c").arg("copy").arg(to);
72+
73+
let output = cmd
74+
.output()
75+
.await
76+
.map_err(|err| anyhow!("failed to run ffmpeg-copy: {err}"))?;
77+
ensure!(
78+
output.status.success(),
79+
"ffmpeg-copy failed with status: {}. stderr: {}",
80+
output.status,
81+
String::from_utf8_lossy(&output.stderr)
82+
);
83+
Ok(())
84+
}
85+
86+
pub async fn ffprob_resolution(video: &Path) -> anyhow::Result<(u32, u32)> {
87+
let mut cmd = Command::new("ffprobe");
88+
cmd.arg("-v")
89+
.arg("error")
90+
.arg("-select_streams")
91+
.arg("v:0")
92+
.arg("-show_entries")
93+
.arg("stream=width,height")
94+
.arg("-of")
95+
.arg("csv=p=0:s=x")
96+
.arg(video);
97+
98+
let output = cmd
99+
.output()
100+
.await
101+
.map_err(|err| anyhow!("failed to run ffprobe: {err}"))?;
102+
ensure!(
103+
output.status.success(),
104+
"ffprobe failed with status: {}. stderr: {}",
105+
output.status,
106+
String::from_utf8_lossy(&output.stderr)
107+
);
108+
let resolution = String::from_utf8_lossy(&output.stdout);
109+
let (w, h) = resolution
110+
.trim()
111+
.split_once('x')
112+
.and_then(|(w, h)| w.parse().ok().zip(h.parse().ok()))
113+
.ok_or_else(|| anyhow!("failed to parse ffprobe resolution '{resolution}'"))?;
114+
Ok((w, h))
115+
}
116+
68117
#[cfg(test)]
69118
mod tests {
70119
use super::*;

src/notify/platform/qq/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ impl Notifier {
141141
}
142142
NotificationKind::Posts(posts) => self.notify_posts(posts, notification.source).await,
143143
NotificationKind::Log(message) => self.notify_log(message).await,
144+
NotificationKind::Playback(_) => unimplemented!(),
145+
NotificationKind::Document(_) => unimplemented!(),
144146
}
145147
}
146148

src/notify/platform/telegram/mod.rs

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ mod request;
33
use std::{borrow::Cow, collections::VecDeque, fmt, future::Future, pin::Pin, time::SystemTime};
44

55
use anyhow::{anyhow, bail, ensure};
6+
use http::Uri;
67
use request::*;
78
use serde::Deserialize;
89
use serde_json as json;
@@ -16,8 +17,9 @@ use crate::{
1617
platform::{PlatformMetadata, PlatformTrait},
1718
secret_enum, serde_impl_default_for,
1819
source::{
19-
LiveStatus, LiveStatusKind, Notification, NotificationKind, Post, PostAttachment, PostUrl,
20-
PostsRef, RepostFrom, StatusSource,
20+
DocumentRef, FileRef, LiveStatus, LiveStatusKind, Notification, NotificationKind,
21+
PlaybackFormat, PlaybackRef, Post, PostAttachment, PostUrl, PostsRef, RepostFrom,
22+
StatusSource,
2123
},
2224
};
2325

@@ -26,6 +28,8 @@ pub struct ConfigGlobal {
2628
#[serde(flatten)]
2729
pub token: Option<ConfigToken>,
2830
#[serde(default)]
31+
pub api_server: Option<ConfigApiServer>,
32+
#[serde(default)]
2933
pub experimental: ConfigExperimental,
3034
}
3135

@@ -42,6 +46,17 @@ impl config::Validator for ConfigGlobal {
4246
}
4347
}
4448

49+
#[derive(Clone, Debug, PartialEq, Deserialize)]
50+
#[serde(untagged)]
51+
pub enum ConfigApiServer {
52+
Url(#[serde(with = "http_serde::uri")] Uri),
53+
UrlOpts {
54+
#[serde(with = "http_serde::uri")]
55+
url: Uri,
56+
as_necessary: bool,
57+
},
58+
}
59+
4560
#[derive(Clone, Debug, PartialEq, Deserialize)]
4661
pub struct ConfigExperimental {
4762
#[deprecated = "enabled by default"]
@@ -213,6 +228,12 @@ impl Notifier {
213228
}
214229
NotificationKind::Posts(posts) => self.notify_posts(posts, notification.source).await,
215230
NotificationKind::Log(message) => self.notify_log(message).await,
231+
NotificationKind::Playback(playback) => {
232+
self.notify_playback(playback, notification.source).await
233+
}
234+
NotificationKind::Document(document) => {
235+
self.notify_document(document, notification.source).await
236+
}
216237
}
217238
}
218239

@@ -566,6 +587,110 @@ impl Notifier {
566587

567588
Ok(())
568589
}
590+
591+
// TODO: Parallel notify
592+
async fn notify_playback(
593+
&self,
594+
playback: &PlaybackRef<'_>,
595+
source: &StatusSource,
596+
) -> anyhow::Result<()> {
597+
if !self.params.notifications.playback {
598+
info!("playback notification is disabled, skip notifying");
599+
return Ok(());
600+
}
601+
602+
const FORMAT: PlaybackFormat = PlaybackFormat::Mp4;
603+
604+
let file = playback.get(FORMAT).await?;
605+
606+
let token = self.token()?;
607+
608+
// Send "uploading" message
609+
610+
let resp = Request::new(&token)
611+
.send_message(&self.params.chat, make_file_text("⏳", &file, source))
612+
.thread_id_opt(self.params.thread_id)
613+
.link_preview(LinkPreview::Disabled)
614+
// .disable_notification() // TODO: Make it configurable
615+
.send()
616+
.await
617+
.map_err(|err| anyhow!("failed to send request to Telegram: {err}"))?;
618+
ensure!(
619+
resp.ok,
620+
"response contains error, description '{}'",
621+
resp.description
622+
.unwrap_or_else(|| "*no description*".into())
623+
);
624+
625+
// Edit the media
626+
627+
trace!("uploading playback to Telegram '{file}'");
628+
629+
let resp = Request::new(&token)
630+
.edit_message_media(
631+
&self.params.chat,
632+
resp.result.unwrap().message_id,
633+
Media::Video(MediaVideo {
634+
input: MediaInput::Memory {
635+
data: file.data.clone(),
636+
filename: Some(&file.name),
637+
},
638+
resolution: Some(file.resolution),
639+
has_spoiler: false,
640+
}),
641+
)
642+
.text(make_file_text("🎥", &file, source))
643+
.prefer_self_host()
644+
.send()
645+
.await
646+
.map_err(|err| anyhow!("failed to send request to Telegram: {err}"))?;
647+
ensure!(
648+
resp.ok,
649+
"response contains error, description '{}'",
650+
resp.description
651+
.unwrap_or_else(|| "*no description*".into())
652+
);
653+
654+
Ok(())
655+
}
656+
657+
async fn notify_document(
658+
&self,
659+
document: &DocumentRef<'_>,
660+
source: &StatusSource,
661+
) -> anyhow::Result<()> {
662+
if !self.params.notifications.document {
663+
info!("document notification is disabled, skip notifying");
664+
return Ok(());
665+
}
666+
667+
let token = self.token()?;
668+
669+
let resp = Request::new(&token)
670+
.send_document(
671+
&self.params.chat,
672+
MediaDocument {
673+
input: MediaInput::Memory {
674+
data: document.file.data.clone(),
675+
filename: Some(&document.file.name),
676+
},
677+
},
678+
)
679+
.text(make_file_text("📊", &document.file, source))
680+
.thread_id_opt(self.params.thread_id)
681+
// .disable_notification() // TODO: Make it configurable
682+
.send()
683+
.await
684+
.map_err(|err| anyhow!("failed to send request to Telegram: {err}"))?;
685+
ensure!(
686+
resp.ok,
687+
"response contains error, description '{}'",
688+
resp.description
689+
.unwrap_or_else(|| "*no description*".into())
690+
);
691+
692+
Ok(())
693+
}
569694
}
570695

571696
fn make_live_text<'a>(
@@ -597,6 +722,15 @@ fn make_live_text<'a>(
597722
Text::link(text, &live_status.live_url)
598723
}
599724

725+
fn make_file_text<'a>(emoji: &'a str, file: &FileRef<'a>, source: &'a StatusSource) -> Text<'a> {
726+
Text::plain(format!(
727+
"[{}] {emoji} {} ({})",
728+
source.platform.display_name,
729+
file.name,
730+
humansize::format_size(file.size, humansize::BINARY)
731+
))
732+
}
733+
600734
struct CurrentLive {
601735
start_time: SystemTime,
602736
message_id: i64,

0 commit comments

Comments
 (0)