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("所有测试用例已完成!")