feat(biliup-rs): 新增 BCut Android 投稿接口 (#1402)

* remove: blive hack feature

* feat(stream-gears): 支持直播流下载的多条件分割

* 新增 BCut Android 投稿接口

* chore: 优化实现方法

* chore: 完成 Python 实例

* chore: 完善测试用例

* chore: 复用代码

* cleanup
This commit is contained in:
XuanRan_Nyan authored and GitHub committed 2025-08-07 03:40:33 +00:00
1 parent ce732944f4
commit dba7780c3d
19 files changed
+385 -444

No files matched your search

Generated
+5 -4
View File
@@ -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",
+3 -2
View File
@@ -291,8 +291,9 @@ const Global: React.FC = () => {
}}
showClear={true}
>
<Form.Select.Option value="web">网页端(web)</Form.Select.Option>
<Form.Select.Option value="client">客户端(client)</Form.Select.Option>
<Form.Select.Option value="app">安卓APP(app)</Form.Select.Option>
<Form.Select.Option value="b-cut-android">BCut安卓APP(b-cut-android)</Form.Select.Option>
<Form.Select.Option value="web">网页(web)</Form.Select.Option>
</Form.Select>
<Form.Select
field="uploader"
-1
View File
@@ -536,7 +536,6 @@ def stream_gears_download(url, headers, file_name, segment_time=None, file_size=
segment.size = file_size
if file_size is None and segment_time is None:
segment.size = 8 * 1024 * 1024 * 1024
print('Python segment.__dict__:', segment.__dict__)
# FIXME: 下载时如出现403,这里不会回到上层方法获取新链接
if file_name_callback:
stream_gears.download_with_callback(
+4
View File
@@ -62,6 +62,10 @@ def generate_fake_buvid3():
formatted = f"{u_str[0:8]}-{u_str[8:12]}-{u_str[12:16]}-{u_str[16:20]}-{u_str[20:]}infoc"
return formatted
class SubmitOption:
App = "app"
BCutAndroid = "b-cut-android"
class Wbi:
WTS = "wts"
W_RID = "w_rid"
+5 -3
View File
@@ -1,9 +1,10 @@
import multiprocessing as mp
import time
from typing import List
import stream_gears
from typing import List, Optional, Union
from . import SubmitOption
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
@@ -11,7 +12,8 @@ from ..engine.upload import UploadBase, logger
@Plugin.upload(platform="biliup-rs")
class BiliWeb(UploadBase):
def __init__(
self, principal, data, submit_api=None, copyright=2, postprocessor=None, dtime=None,
self, principal, data, submit_api: Optional[Union[SubmitOption, str]] = None,
copyright=2, postprocessor=None, dtime=None,
dynamic='', lines='AUTO', threads=3, tid=122, tags=None, cover_path=None, description='',
dolby=0, hires=0, no_reprint=0, is_only_self=0, open_elec=0, credits=None,
user_cookie='cookies.json', copyright_source=None, extra_fields = ""
@@ -137,6 +139,6 @@ def stream_gears_upload(ex_conn, lines, *args, **kwargs):
elif lines == 'bldsa':
kwargs['line'] = stream_gears.UploadLine.Bldsa
stream_gears.upload_by_app(*args, **kwargs)
stream_gears.upload(*args, **kwargs)
except Exception as e:
ex_conn.send(e)
+7 -4
View File
@@ -1,8 +1,9 @@
import time
import stream_gears
from typing import List
from typing import List, Optional, Union
from . import SubmitOption
from ..engine import Plugin
from ..engine.upload import UploadBase, logger
@@ -10,7 +11,8 @@ from ..engine.upload import UploadBase, logger
@Plugin.upload(platform="stream_gears")
class BiliWeb(UploadBase):
def __init__(
self, principal, data, submit_api=None, copyright=2, postprocessor=None, dtime=None,
self, principal, data, submit_api: Optional[Union[SubmitOption, str]] = None,
copyright=2, postprocessor=None, dtime=None,
dynamic='', lines='AUTO', threads=3, tid=122, tags=None, cover_path=None, description='',
dolby=0, hires=0, no_reprint=0, open_elec=0, credits=None,
user_cookie='cookies.json', copyright_source=None, extra_fields = ""
@@ -78,7 +80,7 @@ class BiliWeb(UploadBase):
dtime = None
if self.dtime:
dtime = int(time.time() + self.dtime)
stream_gears.upload_by_app(
stream_gears.upload(
video_path=file_list,
cookie_file=self.user_cookie,
title=self.data["format_title"][:80],
@@ -97,7 +99,8 @@ class BiliWeb(UploadBase):
desc_v2=desc_v2,
dtime=dtime,
line=line,
extra_fields=self.extra_fields
extra_fields=self.extra_fields,
submit=self.submit_api,
)
logger.info(f"上传成功: {self.principal}")
return file_list
+1 -11
View File
@@ -1,4 +1,5 @@
use biliup::uploader::bilibili::{Studio, Vid};
use biliup::uploader::util::SubmitOption;
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
@@ -151,11 +152,7 @@ pub enum Commands {
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum UploadLine {
Bda2,
Ws,
Qn,
// Kodo,
// Cos,
// CosInternal,
Bldsa,
Tx,
Txa,
@@ -163,13 +160,6 @@ pub enum UploadLine {
Alia,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum SubmitOption {
Client,
App,
Web,
}
fn human_size(s: &str) -> Result<u64, String> {
let ret = match s.as_bytes() {
[init @ .., b'K'] => parse_u8(init)? * 1000.0,
+6 -16
View File
@@ -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<PathBuf>,
line: Option<UploadLine>,
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 {
+84 -60
View File
@@ -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<T>(
label: &str,
current: T,
expected: Option<T>,
unit: &str,
format_fn: impl Fn(T) -> String,
) -> String
where
T: Copy + Into<f64>,
{
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())
}
+1
View File
@@ -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")]
+51
View File
@@ -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<ResponseData> {
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,
+3
View File
@@ -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",
}
}
}
+22
View File
@@ -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<Self, String> {
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))
}
}
}
+1
View File
@@ -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"] }
+11 -95
View File
@@ -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<PathBuf>,
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<PyCredit>,
dtime: Option<u32>,
line: Option<UploadLine>,
extra_fields: Option<String>,
proxy: Option<String>,
) -> 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<PathBuf>,
cookie_file: PathBuf,
@@ -344,6 +254,7 @@ fn upload_by_app(
dtime: Option<u32>,
line: Option<UploadLine>,
extra_fields: Option<String>,
submit: Option<String>,
proxy: Option<String>,
) -> 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)?)?;
+14 -131
View File
@@ -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<HashMap<String, serde_json::Value>>,
}
pub async fn upload(studio_pre: StudioPre, proxy: Option<&str>) -> Result<ResponseData> {
// 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<ResponseData> {
pub async fn upload(studio_pre: StudioPre, submit: Option<&str>, proxy: Option<&str>) -> Result<ResponseData> {
// 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)
}
@@ -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: 代理
"""
+65 -11
View File
@@ -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()
+90 -42
View File
@@ -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("所有测试用例已完成!")