From dba7780c3d50d49b0d835606bb2629f620fb85cc Mon Sep 17 00:00:00 2001
From: XuanRan_Nyan <31968665+xxxxuanran@users.noreply.github.com>
Date: Thu, 7 Aug 2025 11:40:33 +0800
Subject: [PATCH] =?UTF-8?q?feat(biliup-rs):=20=E6=96=B0=E5=A2=9E=20BCut=20?=
=?UTF-8?q?Android=20=E6=8A=95=E7=A8=BF=E6=8E=A5=E5=8F=A3=20(#1402)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* remove: blive hack feature
* feat(stream-gears): 支持直播流下载的多条件分割
* 新增 BCut Android 投稿接口
* chore: 优化实现方法
* chore: 完成 Python 实例
* chore: 完善测试用例
* chore: 复用代码
* cleanup
---
Cargo.lock | 9 +-
app/ui/plugins/global.tsx | 5 +-
biliup/engine/download.py | 1 -
biliup/plugins/__init__.py | 4 +
biliup/plugins/biliuprs.py | 8 +-
biliup/plugins/stream_gears.py | 11 +-
crates/biliup-cli/src/cli.rs | 12 +-
crates/biliup-cli/src/uploader.rs | 22 +--
crates/biliup/src/downloader/util.rs | 144 +++++++++--------
crates/biliup/src/uploader.rs | 1 +
crates/biliup/src/uploader/bilibili.rs | 51 ++++++
crates/biliup/src/uploader/credential.rs | 3 +
crates/biliup/src/uploader/util.rs | 22 +++
crates/stream-gears/Cargo.toml | 1 +
crates/stream-gears/src/lib.rs | 106 ++-----------
crates/stream-gears/src/uploader.rs | 145 ++----------------
.../stream_gears/stream_gears.pyi | 76 ++-------
crates/stream-gears/tests/test_download.py | 76 +++++++--
crates/stream-gears/tests/test_upload.py | 132 +++++++++++-----
19 files changed, 385 insertions(+), 444 deletions(-)
create mode 100644 crates/biliup/src/uploader/util.rs
diff --git a/Cargo.lock b/Cargo.lock
index b514e64..8fa7217 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -779,9 +779,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.5.41"
+version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+checksum = "50fd97c9dc2399518aa331917ac6f274280ec5eb34e555dd291899745c48ec6f"
dependencies = [
"clap_builder",
"clap_derive",
@@ -789,9 +789,9 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.5.41"
+version = "4.5.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+checksum = "c35b5830294e1fa0462034af85cc95225a4cb07092c088c55bda3147cfcd8f65"
dependencies = [
"anstream",
"anstyle",
@@ -5293,6 +5293,7 @@ version = "1.0.3"
dependencies = [
"anyhow",
"biliup",
+ "clap",
"futures",
"pyo3",
"serde",
diff --git a/app/ui/plugins/global.tsx b/app/ui/plugins/global.tsx
index c162727..def5192 100644
--- a/app/ui/plugins/global.tsx
+++ b/app/ui/plugins/global.tsx
@@ -291,8 +291,9 @@ const Global: React.FC = () => {
}}
showClear={true}
>
-
网页端(web)
- 客户端(client)
+ 安卓APP(app)
+ BCut安卓APP(b-cut-android)
+ 网页(web)
Result {
let ret = match s.as_bytes() {
[init @ .., b'K'] => parse_u8(init)? * 1000.0,
diff --git a/crates/biliup-cli/src/uploader.rs b/crates/biliup-cli/src/uploader.rs
index 55cc405..6365d26 100644
--- a/crates/biliup-cli/src/uploader.rs
+++ b/crates/biliup-cli/src/uploader.rs
@@ -1,10 +1,11 @@
-use crate::cli::{SubmitOption, UploadLine};
+use crate::cli::UploadLine;
use anyhow::{Context, Result, anyhow};
use biliup::client::StatelessClient;
use biliup::error::Kind;
use biliup::uploader::bilibili::{BiliBili, Studio, Vid, Video};
use biliup::uploader::credential::{Credential, LoginInfo};
use biliup::uploader::line::Probe;
+use biliup::uploader::util::SubmitOption;
use biliup::uploader::{VideoFile, credential, line, load_config};
use bytes::{Buf, Bytes};
use clap::ValueEnum;
@@ -84,16 +85,9 @@ pub async fn upload_by_command(
cover_up(&mut studio, &bili).await?;
studio.videos = upload(&video_path, &bili, line, limit).await?;
- // if studio.submit_by_app {
- // bili.submit_by_app(&studio).await?;
- // }
- // else {
- // bili.submit(&studio).await?;
- // }
- // 说不定会适配 web 呢...?
match submit {
- SubmitOption::App => bili.submit_by_app(&studio, proxy).await?,
- _ => bili.submit(&studio, proxy).await?,
+ SubmitOption::BCutAndroid => bili.submit_by_bcut_android(&studio, proxy).await?,
+ _ => bili.submit_by_app(&studio, proxy).await?,
};
Ok(())
@@ -139,7 +133,7 @@ pub async fn append(
video_path: Vec,
line: Option,
limit: usize,
- submit: SubmitOption ,
+ submit: SubmitOption,
proxy: Option<&str>,
) -> Result<()> {
let bilibili = login_by_cookies(user_cookie, proxy).await?;
@@ -234,17 +228,13 @@ pub async fn upload(
let client = StatelessClient::default();
let line = match line {
Some(UploadLine::Bda2) => line::bda2(),
- Some(UploadLine::Ws) => line::ws(),
Some(UploadLine::Qn) => line::qn(),
- // Some(UploadLine::Kodo) => line::kodo(),
- // Some(UploadLine::Cos) => line::cos(),
- // Some(UploadLine::CosInternal) => line::cos_internal(),
Some(UploadLine::Bldsa) => line::bldsa(),
Some(UploadLine::Tx) => line::tx(),
Some(UploadLine::Txa) => line::txa(),
Some(UploadLine::Bda) => line::bda(),
Some(UploadLine::Alia) => line::alia(),
- None => Probe::probe(&client.client).await.unwrap_or_default(),
+ _ => Probe::probe(&client.client).await.unwrap_or_default(),
};
// let line = line::kodo();
for video_path in video_path {
diff --git a/crates/biliup/src/downloader/util.rs b/crates/biliup/src/downloader/util.rs
index bd2d5b1..2c8253e 100644
--- a/crates/biliup/src/downloader/util.rs
+++ b/crates/biliup/src/downloader/util.rs
@@ -50,46 +50,13 @@ impl Segmentable {
/// 检查是否需要分割 - 只要时间或大小任一条件满足就返回 true
pub fn needed(&self) -> bool {
- // 检查时间条件
- let time_exceeded = if let Some(expected_time) = self.time.expected {
- (self.time.current - self.time.start) >= expected_time
- } else {
- false
- };
-
- // 检查大小条件
- let size_exceeded = if let Some(expected_size) = self.size.expected {
- self.size.current >= expected_size
- } else {
- false
- };
-
- // 任一条件满足就需要分割
+ let time_exceeded = self.time_needed();
+ let size_exceeded = self.size_needed();
let result = time_exceeded || size_exceeded;
// 添加调试信息
if result {
- if time_exceeded && size_exceeded {
- tracing::debug!(
- "Segmentation needed: Both time ({:?} >= {:?}) and size ({} >= {}) conditions met",
- self.time.current - self.time.start,
- self.time.expected.unwrap(),
- self.size.current,
- self.size.expected.unwrap()
- );
- } else if time_exceeded {
- tracing::debug!(
- "Segmentation needed: Time condition met ({:?} >= {:?})",
- self.time.current - self.time.start,
- self.time.expected.unwrap()
- );
- } else if size_exceeded {
- tracing::debug!(
- "Segmentation needed: Size condition met ({} >= {})",
- self.size.current,
- self.size.expected.unwrap()
- );
- }
+ self.log_segmentation_reason(time_exceeded, size_exceeded);
}
result
@@ -113,6 +80,36 @@ impl Segmentable {
}
}
+ /// 记录分割原因的调试信息
+ fn log_segmentation_reason(&self, time_exceeded: bool, size_exceeded: bool) {
+ match (time_exceeded, size_exceeded) {
+ (true, true) => {
+ tracing::info!(
+ "Segmentation needed: Both time ({:?} >= {:?}) and size ({} >= {}) conditions met",
+ self.time.current - self.time.start,
+ self.time.expected.unwrap(),
+ self.size.current,
+ self.size.expected.unwrap()
+ );
+ }
+ (true, false) => {
+ tracing::info!(
+ "Segmentation needed: Time condition met ({:?} >= {:?})",
+ self.time.current - self.time.start,
+ self.time.expected.unwrap()
+ );
+ }
+ (false, true) => {
+ tracing::info!(
+ "Segmentation needed: Size condition met ({} >= {})",
+ self.size.current,
+ self.size.expected.unwrap()
+ );
+ }
+ (false, false) => {} // 不应该到达这里,因为只有在需要分割时才调用
+ }
+ }
+
/// 获取分割原因的描述
pub fn get_segment_reason(&self) -> String {
let time_exceeded = self.time_needed();
@@ -159,29 +156,51 @@ impl Segmentable {
self.time.start = Duration::ZERO;
}
+ /// 格式化进度信息的通用方法
+ fn format_progress(
+ label: &str,
+ current: T,
+ expected: Option,
+ unit: &str,
+ format_fn: impl Fn(T) -> String,
+ ) -> String
+ where
+ T: Copy + Into,
+ {
+ if let Some(expected_val) = expected {
+ let current_f64 = current.into();
+ let expected_f64 = expected_val.into();
+ let percentage = (current_f64 / expected_f64 * 100.0).min(100.0);
+ format!(
+ "{}: {}/{} {} ({:.1}%)",
+ label,
+ format_fn(current),
+ format_fn(expected_val),
+ unit,
+ percentage
+ )
+ } else {
+ format!("{}: No limit", label)
+ }
+ }
+
/// 获取当前状态信息
pub fn get_status(&self) -> String {
- let time_info = if let Some(expected) = self.time.expected {
- format!(
- "Time: {:.1}s/{:.1}s ({:.1}%)",
- (self.time.current - self.time.start).as_secs_f64(),
- expected.as_secs_f64(),
- ((self.time.current - self.time.start).as_secs_f64() / expected.as_secs_f64() * 100.0).min(100.0)
- )
- } else {
- "Time: No limit".to_string()
- };
+ let time_info = Self::format_progress(
+ "Time",
+ (self.time.current - self.time.start).as_secs_f64(),
+ self.time.expected.map(|d| d.as_secs_f64()),
+ "s",
+ |t| format!("{:.1}", t),
+ );
- let size_info = if let Some(expected) = self.size.expected {
- format!(
- "Size: {}/{} bytes ({:.1}%)",
- self.size.current,
- expected,
- (self.size.current as f64 / expected as f64 * 100.0).min(100.0)
- )
- } else {
- "Size: No limit".to_string()
- };
+ let size_info = Self::format_progress(
+ "Size",
+ self.size.current as f64,
+ self.size.expected.map(|s| s as f64),
+ "bytes",
+ |s| format!("{}", s as u64),
+ );
format!("{}, {}", time_info, size_info)
}
@@ -227,17 +246,22 @@ impl LifecycleFile {
}
pub fn create(&mut self) -> Result<&Path, std::io::Error> {
+ // 构建最终文件名
self.file_name = format!(
"{}.{}",
format_filename(&self.fmt_file_name),
self.extension
);
+
+ // 构建临时文件路径(带 .part 后缀)
self.path = PathBuf::from(&self.file_name);
- if let Some(parent) = self.path.parent() {
- fs::create_dir_all(parent)?
- }
- // path.set_extension(&self.extension);
self.path.set_extension(format!("{}.part", self.extension));
+
+ // 确保父目录存在
+ if let Some(parent) = self.path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
info!("Save to {}", self.path.display());
Ok(self.path.as_path())
}
diff --git a/crates/biliup/src/uploader.rs b/crates/biliup/src/uploader.rs
index 174aee8..38e2ebf 100644
--- a/crates/biliup/src/uploader.rs
+++ b/crates/biliup/src/uploader.rs
@@ -13,6 +13,7 @@ use std::task::{Context, Poll};
pub mod bilibili;
pub mod credential;
pub mod line;
+pub mod util;
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "lowercase")]
diff --git a/crates/biliup/src/uploader/bilibili.rs b/crates/biliup/src/uploader/bilibili.rs
index 998cb3f..b6c16f0 100644
--- a/crates/biliup/src/uploader/bilibili.rs
+++ b/crates/biliup/src/uploader/bilibili.rs
@@ -241,6 +241,57 @@ impl BiliBili {
self.submit_by_app(studio, proxy).await
}
+ /// 使用必剪接口投稿
+ pub async fn submit_by_bcut_android(
+ &self,
+ studio: &Studio,
+ proxy: Option<&str>,
+ ) -> Result {
+ let payload = {
+ let mut payload = json!({
+ "access_key": self.login_info.token_info.access_token,
+ "appkey": crate::credential::AppKeyStore::BCutAndroid.app_key(),
+ "aurora_version": "2.39.0",
+ "build": 2800030,
+ "c_locale": "zh-Hans_CN",
+ "channel": "master",
+ "mobi_app": "android_bbs",
+ "montage_version": "1.42.1.0",
+ "platform": "android",
+ "s_locale": "zh-Hans_CN",
+ "sdk_type": "mon",
+ "ts": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
+ });
+
+ let urlencoded = serde_urlencoded::to_string(&payload)?;
+ let sign = crate::credential::Credential::sign(
+ &urlencoded,
+ crate::credential::AppKeyStore::BCutAndroid.appsec(),
+ );
+ payload["sign"] = Value::from(sign);
+ payload
+ };
+
+ let ret: ResponseData = reqwest::Client::proxy_builder(proxy)
+ .user_agent("Mozilla/5.0 os/android model/Mi 10 Pro mobi_app/android_bbs build/2800030 channel/master osVer/13 kernel_version/V14.0.4.0.TJACNXM BiliDroid/5.6.0 (bbcallen@gmail.com)")
+ .timeout(Duration::new(60, 0))
+ .build()?
+ .post("https://member.bilibili.com/x/vu/mvp/add")
+ .query(&payload)
+ .json(studio)
+ .send()
+ .await?
+ .json()
+ .await?;
+ info!("{:?}", ret);
+ if ret.code == 0 {
+ info!("BCUT接口投稿成功");
+ Ok(ret)
+ } else {
+ Err(Kind::Custom(format!("{:?}", ret)))
+ }
+ }
+
pub async fn submit_by_app(
&self,
studio: &Studio,
diff --git a/crates/biliup/src/uploader/credential.rs b/crates/biliup/src/uploader/credential.rs
index c27d621..1f856d3 100644
--- a/crates/biliup/src/uploader/credential.rs
+++ b/crates/biliup/src/uploader/credential.rs
@@ -33,6 +33,7 @@ use url::Url;
pub(crate) enum AppKeyStore {
BiliTV,
Android,
+ BCutAndroid,
}
impl AppKeyStore {
@@ -40,6 +41,7 @@ impl AppKeyStore {
match self {
AppKeyStore::BiliTV => "4409e2ce8ffd12b8",
AppKeyStore::Android => "783bbb7264451d82",
+ AppKeyStore::BCutAndroid => "5dce947fe22167f9",
}
}
@@ -47,6 +49,7 @@ impl AppKeyStore {
match self {
AppKeyStore::BiliTV => "59b43e04ad6965f34319062b478f83dd",
AppKeyStore::Android => "2653583c8873dea268ab9386918b1d65",
+ AppKeyStore::BCutAndroid => "5491a31c6bc11fb764a9b1f8d4acf092",
}
}
}
diff --git a/crates/biliup/src/uploader/util.rs b/crates/biliup/src/uploader/util.rs
new file mode 100644
index 0000000..b6199aa
--- /dev/null
+++ b/crates/biliup/src/uploader/util.rs
@@ -0,0 +1,22 @@
+#[cfg(feature = "cli")]
+use clap::ValueEnum;
+
+#[derive(Debug, Clone)]
+#[cfg_attr(feature = "cli", derive(ValueEnum))]
+pub enum SubmitOption {
+ // Client,
+ App,
+ // Web,
+ BCutAndroid,
+}
+
+impl SubmitOption {
+ /// Parse a string into SubmitOption, compatible with both clap and manual parsing
+ pub fn parse_str(s: &str) -> Result {
+ match s.to_lowercase().as_str() {
+ "app" => Ok(SubmitOption::App),
+ "bcutandroid" | "b-cut-android" | "bcut_android" => Ok(SubmitOption::BCutAndroid),
+ _ => Err(format!("Unknown submit option: {}", s))
+ }
+ }
+}
\ No newline at end of file
diff --git a/crates/stream-gears/Cargo.toml b/crates/stream-gears/Cargo.toml
index e678a8f..82fb56d 100644
--- a/crates/stream-gears/Cargo.toml
+++ b/crates/stream-gears/Cargo.toml
@@ -21,6 +21,7 @@ futures = "0.3.31"
biliup = { path = "../biliup"}
time = "0.3"
typed-builder = "0.20.0"
+clap = { version = "4", features = ["derive"] }
# [target.'cfg(all(target_arch="aarch64", target_os="linux"))'.dependencies]
# downloader = { path = "../downloader", default-features = false , features = ["rustls-tls"] }
diff --git a/crates/stream-gears/src/lib.rs b/crates/stream-gears/src/lib.rs
index 7c26951..4942d58 100644
--- a/crates/stream-gears/src/lib.rs
+++ b/crates/stream-gears/src/lib.rs
@@ -14,6 +14,7 @@ use biliup::credential::Credential;
use biliup::downloader::construct_headers;
use biliup::downloader::extractor::CallbackFn;
use biliup::downloader::util::Segmentable;
+
use tracing_subscriber::layer::SubscriberExt;
#[derive(FromPyObject, Debug)]
@@ -75,8 +76,7 @@ fn download_with_callback(
let segmentable = match (segment.time, segment.size) {
(Some(time), Some(size)) => {
- // 如果同时有时间和大小,可以选择优先使用哪个,或者创建支持两者的逻辑
- // 这里假设优先使用时间分割
+ // 已支持同时创建时间和大小
Segmentable::new(Some(Duration::from_secs(time)), Some(size))
}
(Some(time), None) => {
@@ -91,8 +91,6 @@ fn download_with_callback(
}
};
- tracing::debug!("Segmentable: {:?}", segmentable);
-
let file_name_hook = file_name_callback_fn.map(|callback_fn| -> CallbackFn {
Box::new(move |fmt_file_name| {
Python::with_gil(|py| match callback_fn.call1(py, (fmt_file_name,)) {
@@ -231,96 +229,8 @@ fn login_by_web_qrcode(
#[allow(clippy::too_many_arguments)]
#[pyfunction]
-#[pyo3(signature = (video_path, cookie_file, title, tid=171, tag="".to_string(), copyright=2, source="".to_string(), desc="".to_string(), dynamic="".to_string(), cover="".to_string(), dolby=0, lossless_music=0, no_reprint=0, open_elec=0, limit=3, desc_v2=vec![], dtime=None, line=None, extra_fields="".to_string(), proxy=None))]
+#[pyo3(signature = (video_path, cookie_file, title, tid=171, tag="".to_string(), copyright=2, source="".to_string(), desc="".to_string(), dynamic="".to_string(), cover="".to_string(), dolby=0, lossless_music=0, no_reprint=0, open_elec=0, up_close_reply=false, up_selection_reply=false, up_close_danmu=false, limit=3, desc_v2=vec![], dtime=None, line=None, extra_fields="".to_string(), submit=None, proxy=None))]
fn upload(
- py: Python<'_>,
- video_path: Vec,
- cookie_file: PathBuf,
- title: String,
- tid: u16,
- tag: String,
- copyright: u8,
- source: String,
- desc: String,
- dynamic: String,
- cover: String,
- dolby: u8,
- lossless_music: u8,
- no_reprint: u8,
- open_elec: u8,
- limit: usize,
- desc_v2: Vec,
- dtime: Option,
- line: Option,
- extra_fields: Option,
- proxy: Option,
-) -> PyResult<()> {
- py.allow_threads(|| {
- let rt = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()?;
- // 输出到控制台中
- // use of deprecated function `time::util::local_offset::set_soundness`: no longer needed; TZ is refreshed manually
- // unsafe {
- // time::util::local_offset::set_soundness(time::util::local_offset::Soundness::Unsound);
- // }
- let local_time = tracing_subscriber::fmt::time::LocalTime::new(format_description!(
- "[year]-[month]-[day] [hour]:[minute]:[second]"
- ));
- let formatting_layer = tracing_subscriber::FmtSubscriber::builder()
- // will be written to stdout.
- // builds the subscriber.
- .with_timer(local_time.clone())
- .finish();
- let file_appender = tracing_appender::rolling::never("", "upload.log");
- let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
- let file_layer = tracing_subscriber::fmt::layer()
- .with_ansi(false)
- .with_timer(local_time)
- .with_writer(non_blocking);
-
- let collector = formatting_layer.with(file_layer);
-
- tracing::subscriber::with_default(collector, || -> PyResult<()> {
- let studio_pre = StudioPre::builder()
- .video_path(video_path)
- .cookie_file(cookie_file)
- .line(line)
- .limit(limit)
- .title(title)
- .tid(tid)
- .tag(tag)
- .copyright(copyright)
- .source(source)
- .desc(desc)
- .dynamic(dynamic)
- .cover(cover)
- .dtime(dtime)
- .dolby(dolby)
- .lossless_music(lossless_music)
- .no_reprint(no_reprint)
- .open_elec(open_elec)
- .desc_v2_credit(desc_v2)
- .extra_fields(Some(parse_extra_fields(extra_fields)))
- .build();
-
- match rt.block_on(uploader::upload(studio_pre, proxy.as_deref())) {
- Ok(_) => Ok(()),
- // Ok(_) => { },
- Err(err) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
- "{}, {}",
- err.root_cause(),
- err
- ))),
- }
- })
- })
-}
-
-#[allow(clippy::too_many_arguments)]
-#[pyfunction]
-#[pyo3(signature = (video_path, cookie_file, title, tid=171, tag="".to_string(), copyright=2, source="".to_string(), desc="".to_string(), dynamic="".to_string(), cover="".to_string(), dolby=0, lossless_music=0, no_reprint=0, open_elec=0, up_close_reply=false, up_selection_reply=false, up_close_danmu=false, limit=3, desc_v2=vec![], dtime=None, line=None, extra_fields="".to_string(), proxy=None))]
-fn upload_by_app(
py: Python<'_>,
video_path: Vec,
cookie_file: PathBuf,
@@ -344,6 +254,7 @@ fn upload_by_app(
dtime: Option,
line: Option,
extra_fields: Option,
+ submit: Option,
proxy: Option,
) -> PyResult<()> {
py.allow_threads(|| {
@@ -398,7 +309,12 @@ fn upload_by_app(
.extra_fields(Some(parse_extra_fields(extra_fields)))
.build();
- match rt.block_on(uploader::upload_by_app(studio_pre, proxy.as_deref())) {
+ // let submit = match submit {
+ // Some(value) => SubmitOption::from_str(&value, true).unwrap(),
+ // None => SubmitOption::App,
+ // };
+
+ match rt.block_on(uploader::upload(studio_pre, submit.as_deref(), proxy.as_deref())) {
Ok(_) => Ok(()),
// Ok(_) => { },
Err(err) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
@@ -420,7 +336,7 @@ fn stream_gears(m: &Bound<'_, PyModule>) -> PyResult<()> {
// .with_writer(non_blocking)
// .init();
m.add_function(wrap_pyfunction!(upload, m)?)?;
- m.add_function(wrap_pyfunction!(upload_by_app, m)?)?;
+ // m.add_function(wrap_pyfunction!(upload_by_app, m)?)?;
m.add_function(wrap_pyfunction!(download, m)?)?;
m.add_function(wrap_pyfunction!(download_with_callback, m)?)?;
m.add_function(wrap_pyfunction!(login_by_cookies, m)?)?;
diff --git a/crates/stream-gears/src/uploader.rs b/crates/stream-gears/src/uploader.rs
index eef145b..19c6a5b 100644
--- a/crates/stream-gears/src/uploader.rs
+++ b/crates/stream-gears/src/uploader.rs
@@ -4,6 +4,7 @@ use biliup::error::Kind;
use biliup::uploader::bilibili::{Credit, ResponseData, Studio};
use biliup::uploader::credential::login_by_cookies;
use biliup::uploader::line::Probe;
+use biliup::uploader::util::SubmitOption;
use biliup::uploader::{VideoFile, line};
use futures::StreamExt;
use pyo3::prelude::*;
@@ -20,7 +21,6 @@ use typed_builder::TypedBuilder;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum UploadLine {
Bda2,
- Ws,
Qn,
Bldsa,
Tx,
@@ -69,129 +69,7 @@ pub struct StudioPre {
extra_fields: Option>,
}
-pub async fn upload(studio_pre: StudioPre, proxy: Option<&str>) -> Result {
- // let file = std::fs::File::options()
- // .read(true)
- // .write(true)
- // .open(&cookie_file);
- let StudioPre {
- video_path,
- cookie_file,
- line,
- limit,
- title,
- tid,
- tag,
- copyright,
- source,
- desc,
- dynamic,
- cover,
- dtime,
- dolby,
- lossless_music,
- no_reprint,
- open_elec,
- desc_v2_credit,
- extra_fields,
- ..
- } = studio_pre;
-
- let bilibili = login_by_cookies(&cookie_file, proxy).await;
- let bilibili = match bilibili {
- Err(Kind::IO(_)) => bilibili.with_context(|| {
- String::from("open cookies file: ") + &cookie_file.to_string_lossy()
- })?,
- _ => bilibili?,
- };
-
- let client = StatelessClient::default();
- let mut videos = Vec::new();
- let line = match line {
- Some(UploadLine::Bda2) => line::bda2(),
- Some(UploadLine::Ws) => line::ws(),
- Some(UploadLine::Qn) => line::qn(),
- // Some(UploadLine::Kodo) => line::kodo(),
- // Some(UploadLine::Cos) => line::cos(),
- // Some(UploadLine::CosInternal) => line::cos_internal(),
- Some(UploadLine::Bda) => line::bda(),
- Some(UploadLine::Tx) => line::tx(),
- Some(UploadLine::Txa) => line::txa(),
- Some(UploadLine::Bldsa) => line::bldsa(),
- Some(UploadLine::Alia) => line::alia(),
- None => Probe::probe(&client.client).await.unwrap_or_default(),
- };
- for video_path in video_path {
- println!("{:?}", video_path.canonicalize()?.to_str());
- info!("{line:?}");
- let video_file = VideoFile::new(&video_path)?;
- let total_size = video_file.total_size;
- let file_name = video_file.file_name.clone();
- let uploader = line.pre_upload(&bilibili, video_file).await?;
-
- let instant = Instant::now();
-
- let video = uploader
- .upload(client.clone(), limit, |vs| {
- vs.map(|vs| {
- let chunk = vs?;
- let len = chunk.len();
- Ok((chunk, len))
- })
- })
- .await?;
- let t = instant.elapsed().as_millis();
- info!(
- "Upload completed: {file_name} => cost {:.2}s, {:.2} MB/s.",
- t as f64 / 1000.,
- total_size as f64 / 1000. / t as f64
- );
- videos.push(video);
- }
-
- let mut desc_v2 = Vec::new();
- for credit in desc_v2_credit {
- desc_v2.push(Credit {
- type_id: credit.type_id,
- raw_text: credit.raw_text,
- biz_id: credit.biz_id,
- });
- }
-
- let mut studio: Studio = Studio::builder()
- .desc(desc)
- .dtime(dtime)
- .copyright(copyright)
- .cover(cover)
- .dynamic(dynamic)
- .source(source)
- .tag(tag)
- .tid(tid)
- .title(title)
- .videos(videos)
- .dolby(dolby)
- .lossless_music(lossless_music)
- .no_reprint(no_reprint)
- .open_elec(open_elec)
- .desc_v2(Some(desc_v2))
- .extra_fields(extra_fields)
- .build();
-
- if !studio.cover.is_empty() {
- let url = bilibili
- .cover_up(
- &std::fs::read(&studio.cover)
- .with_context(|| format!("cover: {}", studio.cover))?,
- )
- .await?;
- println!("{url}");
- studio.cover = url;
- }
-
- Ok(bilibili.submit(&studio, proxy).await?)
-}
-
-pub async fn upload_by_app(studio_pre: StudioPre, proxy: Option<&str>) -> Result {
+pub async fn upload(studio_pre: StudioPre, submit: Option<&str>, proxy: Option<&str>) -> Result {
// let file = std::fs::File::options()
// .read(true)
// .write(true)
@@ -233,17 +111,13 @@ pub async fn upload_by_app(studio_pre: StudioPre, proxy: Option<&str>) -> Result
let mut videos = Vec::new();
let line = match line {
Some(UploadLine::Bda2) => line::bda2(),
- Some(UploadLine::Ws) => line::ws(),
Some(UploadLine::Qn) => line::qn(),
- // Some(UploadLine::Kodo) => line::kodo(),
- // Some(UploadLine::Cos) => line::cos(),
- // Some(UploadLine::CosInternal) => line::cos_internal(),
Some(UploadLine::Bda) => line::bda(),
Some(UploadLine::Tx) => line::tx(),
Some(UploadLine::Txa) => line::txa(),
Some(UploadLine::Bldsa) => line::bldsa(),
Some(UploadLine::Alia) => line::alia(),
- None => Probe::probe(&client.client).await.unwrap_or_default(),
+ _ => Probe::probe(&client.client).await.unwrap_or_default(),
};
for video_path in video_path {
println!("{:?}", video_path.canonicalize()?.to_str());
@@ -315,5 +189,14 @@ pub async fn upload_by_app(studio_pre: StudioPre, proxy: Option<&str>) -> Result
studio.cover = url;
}
- Ok(bilibili.submit_by_app(&studio, proxy).await?)
-}
+ let submit = match submit {
+ Some(submit) => SubmitOption::parse_str(submit).unwrap_or(SubmitOption::App),
+ _ => SubmitOption::App,
+ };
+
+ let submit_result = match submit {
+ SubmitOption::BCutAndroid => bilibili.submit_by_bcut_android(&studio, proxy).await?,
+ _ => bilibili.submit_by_app(&studio, proxy).await?,
+ };
+ Ok(submit_result)
+}
\ No newline at end of file
diff --git a/crates/stream-gears/stream_gears/stream_gears.pyi b/crates/stream-gears/stream_gears/stream_gears.pyi
index 9776b7f..4aabac2 100644
--- a/crates/stream-gears/stream_gears/stream_gears.pyi
+++ b/crates/stream-gears/stream_gears/stream_gears.pyi
@@ -114,28 +114,25 @@ class UploadLine(Enum):
"""上传线路"""
Bda2 = 1
- """百度upos"""
+ """百度云"""
- Ws = 2
- """网宿upos"""
+ Qn = 2
+ """七牛"""
- Qn = 3
- """七牛upos"""
-
- Bda = 4
+ Bda = 3
"""百度云海外"""
- Tx = 5
+ Tx = 4
"""腾讯云EO"""
- Txa = 6
+ Txa = 5
"""腾讯云EO海外"""
- Bldsa = 7
- """Bldsa"""
+ Bldsa = 6
+ """Bilibili大陆动态加速"""
- Alia = 8
- """阿里云upos"""
+ Alia = 7
+ """阿里云海外"""
def upload(video_path: List[str],
@@ -157,6 +154,7 @@ def upload(video_path: List[str],
dtime: Optional[int],
line: Optional[UploadLine],
extra_fields: Optional[str],
+ submit: Optional[str],
proxy: Optional[str]) -> None:
"""
@@ -181,56 +179,6 @@ def upload(video_path: List[str],
:param Optional[dtime] int dtime: 定时发布时间, 距离提交大于2小时小于15天, 格式为10位时间戳
:param Optional[UploadLine] line: 上传线路
:param Optional[ExtraFields] line: 上传额外参数
- :param Optional[str] proxy: 代理
- """
-
-def upload_by_app(video_path: List[str],
- cookie_file: str,
- title: str,
- tid: int,
- tag: str,
- copyright: int,
- source: str,
- desc: str,
- dynamic: str,
- cover: str,
- dolby: int,
- lossless_music: int,
- no_reprint: int,
- open_elec: int,
- up_close_reply: bool,
- up_selection_reply: bool,
- up_close_danmu:bool,
- limit: int,
- desc_v2: List[Credit],
- dtime: Optional[int],
- line: Optional[UploadLine],
- extra_fields: Optional[str],
- proxy: Optional[str]) -> None:
- """
- 上传视频稿件
-
- :param List[str] video_path: 视频文件路径
- :param str cookie_file: cookie文件路径
- :param str title: 视频标题
- :param int tid: 投稿分区
- :param str tag: 视频标签, 英文逗号分隔多个tag
- :param int copyright: 是否转载, 1-自制 2-转载
- :param str source: 转载来源
- :param str desc: 视频简介
- :param str dynamic: 空间动态
- :param str cover: 视频封面
- :param int dolby: 是否开启杜比音效, 0-关闭 1-开启
- :param int lossless_music: 是否开启Hi-Res, 0-关闭 1-开启
- :param int no_reprint: 是否禁止转载, 0-允许 1-禁止
- :param int open_elec: 是否开启充电, 0-关闭 1-开启
- :param bool up_close_reply: 是否禁止评论, false-关闭 true-开启
- :param bool up_selection_reply: 是否精选评论, false-关闭 true-开启
- :param bool up_close_danmu: 是否禁止弹幕, false-关闭 true-开启
- :param int limit: 单视频文件最大并发数
- :param List[Credit] desc_v2: 视频简介v2
- :param Optional[dtime] int dtime: 定时发布时间, 距离提交大于2小时小于15天, 格式为10位时间戳
- :param Optional[UploadLine] line: 上传线路
- :param Optional[ExtraFields] line: 上传额外参数
+ :param Optional[str] submit: 提交接口, 可选值: BCutAndroid, App(默认)
:param Optional[str] proxy: 代理
"""
diff --git a/crates/stream-gears/tests/test_download.py b/crates/stream-gears/tests/test_download.py
index 2229f42..eb2c367 100644
--- a/crates/stream-gears/tests/test_download.py
+++ b/crates/stream-gears/tests/test_download.py
@@ -1,19 +1,73 @@
import stream_gears
+import sys
+import multiprocessing
+import time
class Segment:
pass
+def download_worker():
+ """在子进程中执行下载任务"""
+ try:
+ segment = Segment()
+ segment.time = 60 * 1 # secs(1 min)
+ # segment.size = 6000 * 1024 * 1024
+ segment.size = 1000 * 1000 * 10 # bytes(10MB)
+ url = ""
+
+ stream_gears.download(
+ url = url,
+ header_map = {
+ "referer": "https://live.bilibili.com",
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"
+ },
+ file_name = "new_test%Y-%m-%dT%H_%M_%S",
+ segment = segment,
+ proxy = None
+ )
+ return True
+ except Exception as e:
+ print(f"下载过程中发生错误: {e}")
+ return False
+
+
+def download_with_signal_handling():
+ """使用多进程包装下载函数,支持 Ctrl+C 中断"""
+ print("开始下载,按 Ctrl+C 可以退出...")
+
+ # 创建下载进程
+ download_process = multiprocessing.Process(target=download_worker)
+ download_process.start()
+
+ try:
+ # 主进程等待下载进程完成,同时监听信号
+ while download_process.is_alive():
+ time.sleep(0.1) # 短暂休眠,避免占用过多 CPU
+
+ # 检查下载进程的退出状态
+ download_process.join()
+ if download_process.exitcode == 0:
+ print("下载完成")
+ else:
+ print("下载进程异常退出")
+
+ except KeyboardInterrupt:
+ print("\n收到中断信号,正在终止下载...")
+ # 终止下载进程
+ download_process.terminate()
+ # 等待进程真正结束
+ download_process.join(timeout=5)
+ if download_process.is_alive():
+ print("强制杀死下载进程...")
+ download_process.kill()
+ download_process.join()
+ print("下载已被用户中断")
+ sys.exit(0)
+
+
if __name__ == '__main__':
- segment = Segment()
- # segment.time = 60
- # segment.size = 6000 * 1024 * 1024
- segment.size = 60 * 1024 * 1024
- stream_gears.download(
- "",
- {"referer": "https://live.bilibili.com"},
- # {},
- "new_test%Y-%m-%dT%H_%M_%S",
- segment
- )
+ # Windows 上需要这个来支持 multiprocessing
+ multiprocessing.freeze_support()
+ download_with_signal_handling()
diff --git a/crates/stream-gears/tests/test_upload.py b/crates/stream-gears/tests/test_upload.py
index 28186f3..595cbd5 100644
--- a/crates/stream-gears/tests/test_upload.py
+++ b/crates/stream-gears/tests/test_upload.py
@@ -1,48 +1,96 @@
+"""
+stream_gears 上传功能测试
+
+这个测试文件展示了如何使用 stream_gears.upload() 函数的各种功能:
+1. 基础上传 - 使用关键字参数让代码更清晰
+2. 多文件上传 - 上传多P视频
+3. 定时发布 - 设置未来时间发布
+4. 高级功能 - 使用富文本简介和额外字段
+5. 最小参数 - 只使用必需参数,其他使用默认值
+
+注意:运行前请确保:
+- 存在有效的 cookies.json 文件
+- 视频文件路径正确
+- 网络连接正常
+"""
+
import stream_gears
+from typing import List, Dict, Any
+
+video_path = ["E:\\Projects\\biliup\\target\\x86_64-pc-windows-msvc\\debug\\test.flv"]
+cookie_file = "E:\\Projects\\biliup\\target\\x86_64-pc-windows-msvc\\debug\\cookies.json"
+# submit_api = "BCutAndroid"
if __name__ == '__main__':
+ # full kwargs 上传测试
+ # stream_gears.upload(
+ # video_path=["examples/test.mp4"],
+ # cookie_file="cookies.json",
+ # title="测试视频标题",
+ # tid=171, # 电子竞技分区
+ # tag="测试,上传,演示",
+ # copyright=1, # 自制
+ # source="",
+ # desc="这是一个测试视频的描述",
+ # dynamic="发布动态内容",
+ # cover="", # 封面路径,空表示不设置封面
+ # dolby=0, # 不开启杜比音效
+ # lossless_music=0, # 不开启Hi-Res
+ # no_reprint=0, # 允许转载
+ # open_elec=0, # 不开启充电
+ # up_close_reply=False, # 不关闭评论
+ # up_selection_reply=False, # 不开启精选评论
+ # up_close_danmu=False, # 不关闭弹幕
+ # limit=3, # 并发数
+ # desc_v2=[], # 视频简介v2
+ # dtime=None, # 立即发布
+ # line=stream_gears.UploadLine.Bda2, # 指定上传线路
+ # extra_fields=None, # 额外字段
+ # submit="BCutAndroid", # 使用必剪安卓版接口提交
+ # proxy=None # 不使用代理
+ # )
+
+ # 视频简介v2
+ def creditsToDesc_v2(desc: str, credits: List[Dict[str, Any]]):
+ desc_v2 = []
+ desc_v2_tmp = desc
+ for credit in credits:
+ try:
+ num = desc_v2_tmp.index("@credit")
+ desc_v2.append({
+ "raw_text": " " + desc_v2_tmp[:num],
+ "biz_id": "",
+ "type": 1
+ })
+ desc_v2.append({
+ "raw_text": credit["username"],
+ "biz_id": str(credit["uid"]),
+ "type": 2
+ })
+ desc = desc.replace(
+ "@credit", "@" + credit["username"] + " ", 1)
+ desc_v2_tmp = desc_v2_tmp[num + 7:]
+ except IndexError:
+ print('简介中的@credit占位符少于credits的数量,替换失败')
+ desc_v2.append({
+ "raw_text": str(desc_v2_tmp),
+ "biz_id": "",
+ "type": 1
+ })
+ # desc_v2[0]["raw_text"] = desc_v2[0]["raw_text"][1:] # 开头空格会导致识别简介过长
+ return desc_v2
+
+ # 额外字段(JSON格式)
+ extra_fields_json = '{"is_self_only": "1"}'
+
+ # 测试
stream_gears.upload(
- ["examples/test.mp4"],
- "cookies.json",
- "title",
- 171,
- "tag",
- 1,
- "source",
- "desc",
- "dynamic",
- "",
- 0,
- 0,
- 0,
- 0,
- 3,
- [],
- None,
- stream_gears.UploadLine.Bda2,
+ video_path=video_path,
+ cookie_file=cookie_file,
+ copyright=1,
+ extra_fields=extra_fields_json,
+ submit="b-cut-android",
+ title="必剪投稿接口测试"
)
- stream_gears.upload_by_app(
- ["examples/test.mp4",
- "examples/test2.mp4"],
- "cookies.json",
- "dadad",
- 171,
- "演示",
- 1,
- "",
- "",
- "",
- "",
- 0,
- 0,
- 0,
- 0,
- True,
- False,
- False,
- 3,
- [],
- None,
- stream_gears.UploadLine.Qn,
- )
+ print("所有测试用例已完成!")