Refactor server code and migrate modules to biliup-cli

Moved and refactored server-related modules from stream-gears to biliup-cli, including API endpoints, configuration, core logic, and infrastructure. Replaced usage of anyhow with error-stack and custom AppError/AppResult types throughout the codebase. Updated dependencies in Cargo.toml and Cargo.lock, and adjusted code to use new error handling and module paths. Removed obsolete modules and files from stream-gears, and updated README and workspace version.
This commit is contained in:
ForgQi committed 2025-09-21 16:29:33 +08:00
1 parent 0bdcf14a00
commit fb4dc2b50a
77 files changed
+1387 -4539

No files matched your search

Generated
+12 -4
View File
@@ -359,7 +359,6 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
name = "biliup"
version = "0.2.4"
dependencies = [
"anyhow",
"async-trait",
"base64",
"byteorder",
@@ -395,14 +394,17 @@ dependencies = [
name = "biliup-cli"
version = "0.2.4"
dependencies = [
"anyhow",
"async-channel",
"async-trait",
"axum",
"axum-login",
"biliup",
"bon",
"bytes",
"chrono",
"clap",
"dialoguer",
"error-stack",
"futures",
"glob",
"humantime",
@@ -410,7 +412,9 @@ dependencies = [
"indexmap",
"indicatif",
"mime_guess",
"nom 8.0.0",
"ormlite",
"password-auth",
"qrcode",
"regex",
"reqwest",
@@ -424,9 +428,13 @@ dependencies = [
"time",
"tokio",
"toml",
"tower",
"tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"tracing-subscriber",
"url",
]
[[package]]
@@ -3617,14 +3625,14 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "stream-gears"
version = "1.1.4"
version = "1.1.5"
dependencies = [
"anyhow",
"async-channel",
"async-trait",
"axum",
"axum-login",
"biliup",
"biliup-cli",
"bon",
"bytes",
"chrono",
+1 -2
View File
@@ -7,7 +7,7 @@ members = [
resolver = "3"
[workspace.package]
version = "1.1.4"
version = "1.1.5"
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
@@ -23,7 +23,6 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["local-time"] }
tracing-appender = "0.2"
anyhow = "1.0"
thiserror = "2"
error-stack = "0.6.0"
+1
View File
@@ -75,6 +75,7 @@
## 💴捐赠
<img src=".github/resource/Image.jpg" width="200" />
[爱发电 »](https://afdian.com/a/biliup)
## ⭐Stars
+14 -3
View File
@@ -23,7 +23,7 @@ bytes = { workspace = true }
futures = { workspace = true }
clap = { version = "4", features = ["derive"] }
time = { workspace = true }
anyhow = { workspace = true }
glob = "0.3.2"
qrcode = "0.14"
indicatif = "0.17.11"
@@ -33,7 +33,7 @@ chrono = { workspace = true, features = ["serde"] }
indexmap = { version = "2.8.0" }
async-trait = { workspace = true }
humantime = "2.2.0"
axum = { version = "0.8" }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6.2", features = ["cors"] }
reqwest = { workspace = true }
sqlx = { version = "0.8", features = [ "runtime-tokio-rustls", "sqlite" ] }
@@ -44,7 +44,18 @@ tempfile = "3.14"
rust-embed = "8.7.2"
mime_guess = "2.0.5"
ormlite = { version = "0.24.1", features = ["sqlite", "chrono"] }
url = { workspace = true }
error-stack = { workspace = true }
bon = { workspace = true }
axum-login = "0.18.0"
password-auth = "1.0.0"
tower = "0.5.2"
tower-sessions = { version = "0.14.0", default-features = false, features = [
"signed",
] }
tower-sessions-sqlx-store = { version = "0.15.0", features = ["sqlite"] }
async-channel = { version = "2.5.0" }
nom = "8"
[features]
default = []
python-bridge = []
+34 -15
View File
@@ -1,4 +1,3 @@
use anyhow::{Context, Result};
use biliup::downloader::extractor::find_extractor;
use biliup::downloader::flv_parser::{
CodecId, SoundFormat, TagData, aac_audio_packet_header, avc_video_packet_header, header,
@@ -8,10 +7,11 @@ use biliup::downloader::flv_writer;
use biliup::downloader::flv_writer::{FlvTag, TagDataHeader};
use biliup::downloader::httpflv::map_parse_err;
use biliup::downloader::util::Segmentable;
use biliup_cli::server::errors::{AppError, AppResult};
use bytes::{Buf, BufMut, Bytes, BytesMut};
use error_stack::ResultExt;
use std::io::{BufReader, BufWriter, ErrorKind, Read};
use std::path::PathBuf;
use tracing::{error, info, warn};
pub async fn download(
@@ -19,22 +19,27 @@ pub async fn download(
output: String,
split_size: Option<u64>,
split_time: Option<humantime::Duration>,
) -> Result<()> {
) -> AppResult<()> {
let segmentable = Segmentable::new(split_time.map(|t| t.into()), split_size);
let client = Default::default();
if let Some(extractor) = find_extractor(url) {
let mut site = extractor.get_site(url, client).await?;
site.download(&output, segmentable, None).await?;
let mut site = extractor
.get_site(url, client)
.await
.change_context_lazy(|| AppError::Unknown)?;
site.download(&output, segmentable, None)
.await
.change_context_lazy(|| AppError::Unknown)?;
} else {
warn!("not find extractor for {url}")
}
Ok(())
}
pub fn generate_json(mut file_name: PathBuf) -> Result<()> {
pub fn generate_json(mut file_name: PathBuf) -> AppResult<()> {
// let args: Vec<String> = env::args().collect();
// let file_name = &args[1];
let flv_file = std::fs::File::open(&file_name)?;
let flv_file = std::fs::File::open(&file_name).change_context_lazy(|| AppError::Unknown)?;
let buf_reader = BufReader::new(flv_file);
let mut reader = Reader::new(buf_reader);
@@ -43,10 +48,13 @@ pub fn generate_json(mut file_name: PathBuf) -> Result<()> {
let mut video_tag_count = 0;
let mut tag_count = 0;
let _err_count = 0;
let flv_header = reader.read_frame(9)?;
let flv_header = reader
.read_frame(9)
.change_context_lazy(|| AppError::Unknown)?;
// file_name.parent().and_then(|p|p + file_name.file_name()+".json");
// Vec::clear()
let (_, header) = map_parse_err(header(&flv_header), "flv header")?;
let (_, header) = map_parse_err(header(&flv_header), "flv header")
.change_context_lazy(|| AppError::Unknown)?;
let mut os_string = file_name.extension().unwrap_or_default().to_owned();
os_string.push(".json");
file_name.set_extension(os_string);
@@ -56,13 +64,22 @@ pub fn generate_json(mut file_name: PathBuf) -> Result<()> {
.create_new(true)
.write(true)
.open(&file_name)
.with_context(|| format!("file name: {}", file_name.canonicalize().unwrap().display()))?;
.change_context_lazy(|| {
AppError::Custom(format!(
"file name: {}",
file_name.canonicalize().unwrap().display()
))
})?;
let mut writer = BufWriter::new(file);
flv_writer::to_json(&mut writer, &header)?;
flv_writer::to_json(&mut writer, &header).change_context_lazy(|| AppError::Unknown)?;
loop {
let _previous_tag_size = reader.read_frame(4)?;
let _previous_tag_size = reader
.read_frame(4)
.change_context_lazy(|| AppError::Unknown)?;
let t_header = reader.read_frame(11)?;
let t_header = reader
.read_frame(11)
.change_context_lazy(|| AppError::Unknown)?;
if t_header.is_empty() {
break;
}
@@ -74,7 +91,9 @@ pub fn generate_json(mut file_name: PathBuf) -> Result<()> {
}
};
tag_count += 1;
let bytes = reader.read_frame(tag_header.data_size as usize)?;
let bytes = reader
.read_frame(tag_header.data_size as usize)
.change_context_lazy(|| AppError::Unknown)?;
let (i, flv_tag_data) = match map_parse_err(
tag_data(tag_header.tag_type, tag_header.data_size as usize)(&bytes),
"tag data",
@@ -144,7 +163,7 @@ pub fn generate_json(mut file_name: PathBuf) -> Result<()> {
}
}
};
flv_writer::to_json(&mut writer, &flv_tag)?;
flv_writer::to_json(&mut writer, &flv_tag).change_context_lazy(|| AppError::Unknown)?;
}
info!("tag count: {tag_count}");
info!("script tag count: {script_tag_count}");
+22 -7
View File
@@ -1,13 +1,17 @@
pub mod server;
use anyhow::{Context, Result};
use crate::server::api::router::ApplicationController;
// use crate::server::api::router::ApplicationController;
use crate::server::app::ApplicationController;
use crate::server::core::download_manager::ActorHandle;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionManager;
use crate::server::infrastructure::repositories;
use crate::server::infrastructure::service_register::ServiceRegister;
use error_stack::ResultExt;
use std::net::ToSocketAddrs;
use std::sync::{Arc, RwLock};
pub async fn run(addr: (&str, u16)) -> Result<()> {
pub async fn run(addr: (&str, u16)) -> AppResult<()> {
// let config = Arc::new(AppConfig::parse());
tracing::info!(
@@ -17,12 +21,23 @@ pub async fn run(addr: (&str, u16)) -> Result<()> {
.await
.expect("could not initialize the database connection pool");
let service_register = ServiceRegister::new(conn_pool);
let config = Arc::new(RwLock::new(repositories::get_config(&conn_pool).await?));
let actor_handle = Arc::new(ActorHandle::new(
config.read().unwrap().pool1_size,
config.read().unwrap().pool2_size,
));
let vec = Vec::new();
let service_register = ServiceRegister::new(conn_pool, config.clone(), actor_handle, vec);
tracing::info!("migrations successfully ran, initializing axum server...");
let addr = addr.to_socket_addrs()?.next().unwrap();
let addr = addr
.to_socket_addrs()
.change_context(AppError::Unknown)?
.next()
.unwrap();
ApplicationController::serve(&addr, service_register)
.await
.context("could not initialize application routes")?;
.attach("could not initialize application routes")?;
Ok(())
}
+2 -2
View File
@@ -2,7 +2,6 @@ mod cli;
mod downloader;
mod uploader;
use anyhow::Result;
use time::macros::format_description;
use crate::cli::{Cli, Commands};
@@ -12,11 +11,12 @@ use biliup::uploader::util::SubmitOption;
use clap::Parser;
use biliup_cli::server::errors::AppResult;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
#[tokio::main]
async fn main() -> Result<()> {
async fn main() -> AppResult<()> {
// a builder for `FmtSubscriber`.
// let subscriber = FmtSubscriber::builder()
// // all spans/events with a level higher than TRACE (e.g, debug, info, warn, etc.)
+19
View File
@@ -0,0 +1,19 @@
pub mod api;
pub mod app;
pub mod config;
pub mod core;
pub mod errors;
pub mod infrastructure;
mod router;
pub mod util;
use error_stack::ResultExt;
use futures::TryFutureExt;
use std::net::ToSocketAddrs;
// use tokio::sync::mpsc::Receiver;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionManager;
use crate::server::infrastructure::repositories;
use crate::server::infrastructure::service_register::ServiceRegister;
use tracing::info;
@@ -30,7 +30,7 @@ mod post {
use tracing::log::info;
/// Handler for the "POST /signup" endpoint.
pub(crate) async fn signup(
pub async fn signup(
mut auth_session: AuthSession,
Json(creds): Json<Credentials>,
) -> impl IntoResponse {
@@ -1,51 +1,72 @@
use crate::server::errors::{AppError, AppResult};
use crate::server::errors::{AppError, report_to_response};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::repositories::models::Configuration;
use crate::server::infrastructure::models::Configuration;
use axum::extract::{Query, State};
use axum::response::Response;
use axum::{Extension, Json};
use biliup::client::StatelessClient;
use biliup::uploader::credential::login_by_cookies;
use bytes::Bytes;
use error_stack::{Report, ResultExt};
use ormlite::Model;
use std::collections::HashMap;
use tracing::info;
pub async fn archive_pre_endpoint(
Query(params): Query<HashMap<String, String>>,
State(pool): State<ConnectionPool>,
) -> AppResult<Json<serde_json::Value>> {
) -> Result<Json<serde_json::Value>, Response> {
let configurations = Configuration::select()
.where_("key = 'bilibili-cookies'")
.fetch_all(&pool)
.await?;
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
for cookies in configurations {
if let Ok(bili) = login_by_cookies(cookies.value, None).await {
return Ok(Json(bili.archive_pre().await?));
return Ok(Json(
bili.archive_pre()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
));
}
}
Err(AppError::InternalServerErrorWithContext(
Err(report_to_response(Report::from(AppError::Custom(
"无可用 cookie 文件".to_string(),
))
))))
}
pub async fn get_myinfo_endpoint(
Query(params): Query<HashMap<String, String>>,
) -> AppResult<Json<serde_json::Value>> {
println!("{}", &params["user"]);
let bili = login_by_cookies(&params["user"], None).await?;
Ok(Json(bili.my_info().await?))
) -> Result<Json<serde_json::Value>, Response> {
let bili = login_by_cookies(&params["user"], None)
.await
.change_context(AppError::Custom(params["user"].to_string()))
.map_err(report_to_response)?;
Ok(Json(
bili.my_info()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
}
pub async fn get_proxy_endpoint(
Extension(client): Extension<StatelessClient>,
State(client): State<StatelessClient>,
Query(params): Query<HashMap<String, String>>,
) -> AppResult<Bytes> {
println!("{}", &params["url"]);
) -> Result<Bytes, Response> {
info!(params = &params["url"]);
// let bili = login_by_cookies(&params["user"]).await?;
Ok(client
client
.client
.get(&params["url"])
.send()
.await?
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
.bytes()
.await?)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)
}
+380 -83
View File
@@ -1,45 +1,235 @@
use crate::server::core::download_actor::DownloadActorHandle;
use crate::server::core::live_streamers::{
AddLiveStreamerDto, DynLiveStreamersService, LiveStreamerDto, LiveStreamerEntity,
};
use crate::server::core::upload_streamers::{DynUploadStreamersRepository, StudioEntity};
use crate::server::core::users::User;
use crate::server::errors::{AppError, AppResult};
use crate::server::config::Config;
use crate::server::errors::{AppError, AppResult, report_to_response};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::repositories::models::{
Configuration, FileItem, LiveStreamer, StreamerInfo, UploadStreamer,
use crate::server::infrastructure::context::Worker;
use crate::server::infrastructure::dto::LiveStreamerResponse;
use crate::server::infrastructure::models::{
Configuration, FileItem, InsertConfiguration, InsertLiveStreamer, InsertUploadStreamer,
LiveStreamer, StreamerInfo, UploadStreamer,
};
use crate::server::infrastructure::repositories::{del_streamer, get_all_streamer};
use crate::server::infrastructure::service_register::ServiceRegister;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use ormlite::Model;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Json, debug_handler};
use biliup::credential::{Credential, LoginInfo};
use error_stack::{Report, ResultExt};
use ormlite::{Insert, Model};
use serde_json::json;
use std::sync::{Arc, RwLock};
use std::time::{Duration, UNIX_EPOCH};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tracing::info;
pub async fn get_streamers_endpoint(
State(pool): State<ConnectionPool>,
) -> AppResult<Json<Vec<LiveStreamer>>> {
let live_streamers = LiveStreamer::select().fetch_all(&pool).await?;
State(workers): State<Arc<RwLock<Vec<Arc<Worker>>>>>,
) -> Result<Json<Vec<LiveStreamerResponse>>, Response> {
let live_streamers = get_all_streamer(&pool).await.map_err(report_to_response)?;
info!(
"get_streamers_endpoint found {} live streamers",
live_streamers.len()
);
Ok(Json(
live_streamers
.into_iter()
.map(|x| LiveStreamerResponse {
status: workers
.read()
.unwrap()
.iter()
.find_map(|worker| {
if worker.id == x.id {
Some(*worker.downloader_status.read().unwrap())
} else {
None
}
})
.unwrap_or_default(),
inner: x,
})
.collect(),
))
}
pub async fn post_streamers_endpoint(
State(service_register): State<ServiceRegister>,
State(pool): State<ConnectionPool>,
Json(payload): Json<InsertLiveStreamer>,
) -> Result<Json<LiveStreamer>, Response> {
let Some(manager) = service_register.get_manager(&payload.url) else {
info!("not supported url: {}", &payload.url);
return Err((StatusCode::BAD_REQUEST, "Not supported url").into_response());
};
let monitor = manager.ensure_monitor();
/// You can insert the model directly.
let live_streamers = payload
.insert(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
service_register
.add_room(live_streamers.id, &live_streamers.url, monitor)
.await
.map_err(report_to_response)?;
info!(workers=?service_register.workers, "successfully inserted new live streamers");
Ok(Json(live_streamers))
}
pub async fn put_streamers_endpoint(
State(service_register): State<ServiceRegister>,
State(pool): State<ConnectionPool>,
Json(payload): Json<LiveStreamer>,
) -> Result<Json<LiveStreamer>, Response> {
let streamer = payload
.update_all_fields(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let Some(manager) = service_register.get_manager(&streamer.url) else {
info!("not supported url: {}", &streamer.url);
return Err((StatusCode::BAD_REQUEST, "Not supported url").into_response());
};
let monitor = manager.ensure_monitor();
service_register
.del_room(streamer.id)
.await
.map_err(report_to_response)?;
service_register
.add_room(streamer.id, &streamer.url, monitor)
.await
.map_err(report_to_response)?;
info!(workers=?streamer, "successfully update live streamers");
Ok(Json(streamer))
}
pub async fn delete_streamers_endpoint(
State(service_register): State<ServiceRegister>,
State(pool): State<ConnectionPool>,
State(workers): State<Arc<RwLock<Vec<Arc<Worker>>>>>,
Path(id): Path<i64>,
) -> Result<Json<LiveStreamer>, Response> {
service_register
.del_room(id)
.await
.map_err(report_to_response)?;
let live_streamers = del_streamer(&pool, id).await.map_err(report_to_response)?;
info!(workers=?service_register.workers, "successfully inserted new live streamers");
Ok(Json(live_streamers))
}
pub async fn get_configuration(
State(pool): State<ConnectionPool>,
) -> AppResult<Json<serde_json::Value>> {
let configurations = Configuration::select()
.where_("key = 'config'")
.fetch_one(&pool)
.await?;
State(config): State<Arc<RwLock<Config>>>,
) -> Result<Json<Config>, Response> {
Ok(Json(config.read().unwrap().clone()))
}
Ok(Json(serde_json::from_str(&configurations.value)?))
// #[axum_macros::debug_handler(state = ServiceRegister)]
pub async fn put_configuration(
State(config): State<Arc<RwLock<Config>>>,
State(pool): State<ConnectionPool>,
Json(json_data): Json<Config>,
) -> Result<Json<Config>, Response> {
// 将 JSON 序列化为 TEXT 存库
let value_txt = serde_json::to_string(&json_data)
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let mut tx = pool
.begin()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// 最多取 2 条判断是否多行
let ids: Vec<i64> =
sqlx::query_scalar::<_, i64>("SELECT id FROM configuration WHERE key = ?1 LIMIT 2")
.bind("config")
.fetch_all(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let saved: Configuration = if ids.is_empty() {
// 插入
sqlx::query("INSERT INTO configuration (key, value) VALUES (?1, ?2)")
.bind("config")
.bind(&value_txt)
.execute(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// 取 last_insert_rowid 并读回整行
let id: i64 = sqlx::query_scalar::<_, i64>("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
sqlx::query_as::<_, Configuration>("SELECT id, key, value FROM configuration WHERE id = ?1")
.bind(id)
.fetch_one(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
} else if ids.len() == 1 {
// 更新
let id = ids[0];
sqlx::query("UPDATE configuration SET value = ?1 WHERE id = ?2")
.bind(&value_txt)
.bind(id)
.execute(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
sqlx::query_as::<_, Configuration>("SELECT id, key, value FROM configuration WHERE id = ?1")
.bind(id)
.fetch_one(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
} else {
// 多行报错
return Err(report_to_response(Report::new(AppError::Custom(
format!("有多个空间配置同时存在 (key='config'): {} 行", ids.len()).to_string(),
))));
};
tx.commit()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// 提交后从 DB 重新加载配置
let saved_config: Config = serde_json::from_str(&saved.value)
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
*config.write().unwrap() = saved_config;
Ok(Json(config.read().unwrap().clone()))
}
pub async fn get_streamer_info(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
) -> AppResult<Json<Vec<StreamerInfo>>> {
let streamer_infos = StreamerInfo::select().fetch_all(&pool).await?;
let file_items = FileItem::select().fetch_all(&pool).await?;
) -> Result<Json<Vec<StreamerInfo>>, Response> {
let streamer_infos = StreamerInfo::select()
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let file_items = FileItem::select()
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
println!("{:?}", file_items);
Ok(Json(streamer_infos))
@@ -48,83 +238,88 @@ pub async fn get_streamer_info(
pub async fn get_upload_streamers_endpoint(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
) -> AppResult<Json<Vec<UploadStreamer>>> {
let uploader_streamers = UploadStreamer::select().fetch_all(&pool).await?;
) -> Result<Json<Vec<UploadStreamer>>, Response> {
let uploader_streamers = UploadStreamer::select()
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(uploader_streamers))
}
pub async fn add_upload_streamer_endpoint(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
Json(upload_streamer): Json<InsertUploadStreamer>,
) -> Result<Json<serde_json::Value>, Response> {
if upload_streamer.id.is_none() {
Ok(Json(
serde_json::to_value(
ormlite::Insert::insert(upload_streamer, &pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
)
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
} else {
Ok(Json(
serde_json::to_value(
upload_streamer
.update_all_fields(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
)
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
}
}
pub async fn get_upload_streamer_endpoint(
State(pool): State<ConnectionPool>,
Path(id): Path<i64>,
) -> AppResult<Json<UploadStreamer>> {
) -> Result<Json<UploadStreamer>, Response> {
let uploader_streamers = UploadStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_one(&pool)
.await?;
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(uploader_streamers))
}
pub async fn get_streamer_endpoint(
Extension(streamers_service): Extension<DynLiveStreamersService>,
Extension(download_actor_handle): Extension<DownloadActorHandle>,
Path(id): Path<i64>,
) -> AppResult<Json<LiveStreamerDto>> {
Ok(Json(streamers_service.get_streamer_by_id(id).await?))
}
pub async fn add_streamer_endpoint(
Extension(streamers_service): Extension<DynLiveStreamersService>,
Extension(download_actor_handle): Extension<DownloadActorHandle>,
Json(request): Json<AddLiveStreamerDto>,
) -> AppResult<Json<LiveStreamerDto>> {
download_actor_handle.add_streamer(&request.url);
Ok(Json(streamers_service.add_streamer(request).await?))
}
pub async fn delete_streamer_endpoint(
Extension(download_actor_handle): Extension<DownloadActorHandle>,
Path(id): Path<i64>,
) -> AppResult<Json<()>> {
Ok(Json(()))
}
pub async fn update_streamer_endpoint(
Extension(download_actor_handle): Extension<DownloadActorHandle>,
Json(request): Json<LiveStreamerEntity>,
) -> AppResult<Json<LiveStreamerDto>> {
download_actor_handle.update_streamer(&request.url);
Err(AppError::InvalidLoginAttempt)
}
pub async fn add_upload_streamer_endpoint(
Extension(streamers_service): Extension<DynUploadStreamersRepository>,
Json(request): Json<StudioEntity>,
) -> AppResult<Json<StudioEntity>> {
Ok(Json(streamers_service.create_streamer(request).await?))
}
pub async fn delete_template_endpoint(
Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
Path(id): Path<i64>,
) -> AppResult<Json<()>> {
Ok(Json(streamers_service.delete_streamer(id).await?))
}
pub async fn update_template_endpoint(
Extension(streamers_service): Extension<DynUploadStreamersRepository>,
Json(request): Json<StudioEntity>,
) -> AppResult<Json<StudioEntity>> {
Ok(Json(streamers_service.update_streamer(request).await?))
) -> Result<Json<()>, Response> {
let uploader_streamers = UploadStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_one(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(
uploader_streamers
.delete(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
}
pub async fn get_users_endpoint(
State(pool): State<ConnectionPool>,
) -> AppResult<Json<Vec<serde_json::Value>>> {
) -> Result<Json<Vec<serde_json::Value>>, Response> {
let configurations = Configuration::select()
.where_("key = 'bilibili-cookies'")
.fetch_all(&pool)
.await?;
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let mut res = Vec::new();
for cookies in configurations {
res.push(json!({
@@ -137,10 +332,112 @@ pub async fn get_users_endpoint(
Ok(Json(res))
}
pub async fn add_user_endpoint(Json(request): Json<User>) -> AppResult<Json<User>> {
Err(AppError::InternalServerError)
pub async fn add_user_endpoint(
State(pool): State<ConnectionPool>,
Json(user): Json<InsertConfiguration>,
) -> Result<Json<Configuration>, Response> {
let res = user
.insert(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(res))
}
pub async fn delete_user_endpoint(Path(id): Path<i64>) -> AppResult<Json<()>> {
pub async fn delete_user_endpoint(
Path(id): Path<i64>,
State(pool): State<ConnectionPool>,
) -> Result<Json<()>, Response> {
let x = sqlx::query("DELETE FROM configuration WHERE id = ?")
.bind(id)
.execute(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
info!("{:?}", x);
Ok(Json(()))
}
pub async fn get_qrcode() -> Result<Json<serde_json::Value>, Response> {
let qrcode = Credential::new(None)
.get_qrcode()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(qrcode))
}
pub async fn login_by_qrcode(
Json(value): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
let info = tokio::time::timeout(
Duration::from_secs(300),
Credential::new(None).login_by_qrcode(value),
// std::future::pending::<AppResult<LoginInfo>>(),
)
.await
.change_context(AppError::Custom("deadline has elapsed".to_string()))
.map_err(report_to_response)?
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// extract mid
let mid = info.token_info.mid;
let filename = format!("data/{}.json", mid);
let mut file = fs::File::create(&filename)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
file.write_all(&serde_json::to_vec_pretty(&info).unwrap())
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(json!({ "filename": filename })))
}
pub async fn get_videos() -> Result<Json<Vec<serde_json::Value>>, Response> {
let media_extensions = [".mp4", ".flv", ".3gp", ".webm", ".mkv", ".ts"];
let blacklist = ["next-env.d.ts"];
let mut file_list = Vec::new();
let mut index = 1;
// **use tokio::fs::read_dir**
if let Ok(mut entries) = fs::read_dir("../../../../..").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().into_owned();
if blacklist.contains(&file_name.as_str()) {
continue;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if media_extensions
.iter()
.any(|allowed| &ext == &allowed.trim_start_matches('.'))
{
if let Ok(metadata) = entry.metadata().await {
let mtime = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
file_list.push(serde_json::json!({
"key": index,
"name": file_name,
"updateTime": mtime,
"size": metadata.len(),
}));
index += 1;
}
}
}
}
}
Ok(Json(file_list))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod bilibili_endpoints;
pub mod endpoints;
pub mod spa;
-125
View File
@@ -1,125 +0,0 @@
use biliup::client::StatelessClient;
use crate::server::api::endpoints::{
add_streamer_endpoint, add_upload_streamer_endpoint, add_user_endpoint,
delete_streamer_endpoint, delete_template_endpoint, delete_user_endpoint, get_configuration,
get_streamer_endpoint, get_streamer_info, get_streamers_endpoint, get_upload_streamer_endpoint,
get_upload_streamers_endpoint, get_users_endpoint, update_streamer_endpoint,
update_template_endpoint,
};
use crate::server::core::download_actor::DownloadActorHandle;
use crate::server::infrastructure::service_register::ServiceRegister;
use anyhow::Context;
use axum::routing::{delete, get, post, put};
use axum::{Extension, Router, http};
use crate::server::api::bilibili_endpoints::{
archive_pre_endpoint, get_myinfo_endpoint, get_proxy_endpoint,
};
use crate::server::core::main_loop::spawn_main_loop;
use axum::http::HeaderValue;
use std::net::SocketAddr;
use crate::server::api::spa::static_handler;
use tower_http::cors::{AllowMethods, CorsLayer};
use tracing::info;
pub struct ApplicationController;
impl ApplicationController {
pub async fn serve(addr: &SocketAddr, service_register: ServiceRegister) -> anyhow::Result<()> {
let client = StatelessClient::default();
// let vec = service_register.streamers_service.get_streamers().await?;
let (main_loop, _) = spawn_main_loop();
let actor_handle = DownloadActorHandle::new(vec![], client.clone());
// build our application with a route
let app = Router::new()
// `GET /` goes to `root`
.route("/v1/streamers", get(get_streamers_endpoint))
.route("/v1/configuration", get(get_configuration))
.route("/v1/streamer-info", get(get_streamer_info))
.route("/v1/upload/streamers", get(get_upload_streamers_endpoint))
.route(
"/v1/upload/streamers/{id}",
delete(delete_template_endpoint)
.put(update_template_endpoint)
.get(get_upload_streamer_endpoint),
)
.route("/v1/streamers/{id}", get(get_streamer_endpoint))
.route("/v1/streamers/{id}", delete(delete_streamer_endpoint))
.route("/v1/streamers/{id}", put(update_streamer_endpoint))
.route("/v1/streamers", post(add_streamer_endpoint))
// .route(
// "/v1/upload/streamers/:id",
// ,
// )
// .route("/v1/upload/streamers/:id", )
.route("/v1/upload/streamers", post(add_upload_streamer_endpoint))
.route("/v1/users", get(get_users_endpoint).post(add_user_endpoint))
.route("/v1/users/{id}", delete(delete_user_endpoint))
.route("/bili/archive/pre", get(archive_pre_endpoint))
.route("/bili/space/myinfo", get(get_myinfo_endpoint))
.route("/bili/proxy", get(get_proxy_endpoint))
.fallback(static_handler)
// .route("/", get(root))
.layer(
// see https://docs.rs/tower-http/latest/tower_http/cors/index.html
// for more details
//
// pay attention that for some request types like posting content-type: application/json
// it is required to add ".allow_headers([http::header::CONTENT_TYPE])"
// or see this issue https://github.com/tokio-rs/axum/issues/849
CorsLayer::new()
.allow_headers([http::header::CONTENT_TYPE])
.allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
.allow_methods(AllowMethods::any()),
)
.layer(Extension(actor_handle))
// .layer(Extension(client.clone()))
.layer(Extension(client.clone()))
.with_state(service_register);
// `POST /users` goes to `create_user`
// .route("/users", post(create_user));
// let mut test = Cycle::new(indexmap![
// "1".to_string() => StreamStatus::Idle,
// "2".to_string() => StreamStatus::Idle,
// "3".to_string() => StreamStatus::Idle,
// "4".to_string() => StreamStatus::Idle,
// ]);
// let testget = test.clone();
// tokio::spawn(async move {
// tokio::time::sleep(Duration::from_secs(31)).await;
// actor_handle.remove_streamer("https://www.huya.com/superrabbit");
// println!("yesssss")
// });
// let mut n = 0;
// loop {
// let string = testget.get(&mut n);
// println!("yoooo {string:?}");
// tokio::time::sleep(Duration::from_secs(2)).await;
// }
// extensions.get().and_then(|actor| {
// actor
// });
// println!("nonono");
// Ok::<_, anyhow::Error>(())
// tokio::spawn(async move {
// tokio::time::sleep(Duration::from_secs(10)).await;
// test.replace(indexmap!["10".to_string() => StreamStatus::Idle]);
// });
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
info!("routes initialized, listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.await
.context("error while starting API server")?;
Ok(())
}
}
+6 -5
View File
@@ -27,11 +27,12 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
}
None => {
if path.contains('.') {
return not_found().await;
}
index_html().await
// if path.contains('.') {
// return not_found().await;
// }
//
// index_html().await
not_found().await
}
}
}
File renamed without changes.
@@ -1,11 +1,7 @@
use crate::server::core::downloader::DownloaderType;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::models::hook_step::HookStep;
use anyhow::{Context, bail};
use pyo3::prelude::PyDictMethods;
use pyo3::sync::OnceLockExt;
use pyo3::types::{PyAnyMethods, PyDict};
use pyo3::{Bound, FromPyObject, PyAny, PyResult, Python, pyclass, pyfunction, pymethods};
use pythonize::pythonize;
use error_stack::bail;
use serde::{Deserialize, Serialize};
use std::cell::LazyCell;
use std::ops::Deref;
@@ -255,7 +251,7 @@ pub struct Credit {
pub uid: String,
}
#[derive(bon::Builder, FromPyObject, Debug, Clone, Serialize, Deserialize, Default)]
#[derive(bon::Builder, Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserConfig {
// B 站
#[serde(default)]
@@ -324,67 +320,10 @@ fn default_check_sourcecode() -> u64 {
}
impl Config {
pub fn load_or_create<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
bail!("load_or_create: {:?}", path.as_ref().display())
pub fn load_or_create<P: AsRef<Path>>(path: P) -> AppResult<Self> {
bail!(AppError::Custom(format!(
"load_or_create: {:?}",
path.as_ref().display()
)))
}
}
// 进程级全局单例(安全):OnceLock + Arc + RwLock
pub(crate) static CONFIG: LazyLock<Arc<RwLock<Config>>> = LazyLock::new(|| {
Arc::new(RwLock::new(
Config::builder().streamers(Default::default()).build(),
))
});
fn cfg_arc() -> &'static Arc<RwLock<Config>> {
&*CONFIG
}
#[pyclass]
struct ConfigState {
// 用 PyObject 存,方便保持任意 Python 对象
map: Arc<RwLock<Config>>,
}
#[pymethods]
impl ConfigState {
/// 获取:config.get("k", default=None)
/// - 若 key 存在,返回保存的对象
/// - 若不存在,返回 default(默认 None)
#[pyo3(signature = (key, default=None))]
fn get<'py>(
&self,
py: Python<'py>,
key: &str,
default: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
let guard = self.map.read().unwrap();
// serde_json::to_value(guard.deref())
if let Some(bound) = pythonize(py, guard.deref())?
.extract::<Bound<PyDict>>()?
.get_item(key)?
{
if bound.is_none()
&& let Some(d) = default
{
return Ok(d);
}
return Ok(bound);
};
let Some(default) = default else {
return Err(pyo3::exceptions::PyAttributeError::new_err(format!(
"object has no attribute '{key}'"
)));
};
Ok(default)
}
}
#[pyfunction]
pub fn config_bindings(py: Python<'_>) -> PyResult<ConfigState> {
let state = ConfigState {
map: cfg_arc().clone(),
};
// pythonize(py, &config)
Ok(state)
}
@@ -1,464 +0,0 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{info, warn};
/// 配置格式枚举
#[derive(Debug, Clone)]
pub enum ConfigFormat {
Yaml,
Toml,
Json,
}
/// 流配置结构,对应Python版本的streamers配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamerConfig {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_range: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_keywords: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uploader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tid: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub copyright: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cover_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preprocessor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub downloaded_processor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub postprocessor: Option<Vec<PostprocessorConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_processor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opt_args: Option<Vec<String>>,
// 其他字段作为额外的键值对存储
#[serde(flatten)]
pub extra_fields: HashMap<String, serde_json::Value>,
}
/// 后处理器配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PostprocessorConfig {
Simple(String),
Complex {
#[serde(skip_serializing_if = "Option::is_none")]
rm: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
mv: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
run: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
webhook: Option<String>,
},
}
/// 用户配置结构,对应Python版本的user配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub cookies: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
}
/// 主配置结构,对应Python版本的config
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BiliUpConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub streamers: Option<HashMap<String, StreamerConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<UserConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub threads: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lines: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pool1_size: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pool2_size: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_loop_interval: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub check_sourcecode: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delay: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_upload_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filtering_threshold: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub downloader: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename_prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub use_live_cover: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub segment_processor_parallel: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dolby: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hires: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub charging_pay: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_reprint: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub submit_api: Option<String>,
// 日志配置
#[serde(skip_serializing_if = "Option::is_none")]
pub logging: Option<HashMap<String, serde_json::Value>>,
// 其他字段作为额外的键值对存储
#[serde(flatten)]
pub extra_fields: HashMap<String, serde_json::Value>,
}
impl Default for BiliUpConfig {
fn default() -> Self {
Self {
streamers: None,
user: None,
threads: Some(2),
lines: Some("AUTO".to_string()),
pool1_size: Some(5),
pool2_size: Some(3),
event_loop_interval: Some(30),
check_sourcecode: Some(15),
delay: Some(0),
max_upload_limit: Some(999),
filtering_threshold: Some(0),
downloader: Some("stream-gears".to_string()),
segment_time: Some("01:00:00".to_string()),
file_size: None,
filename_prefix: None,
use_live_cover: Some(false),
segment_processor_parallel: Some(false),
dolby: Some(0),
hires: Some(0),
charging_pay: Some(0),
no_reprint: Some(0),
submit_api: None,
logging: None,
extra_fields: HashMap::new(),
}
}
}
/// 配置管理器,对应Python版本的Config类
pub struct ConfigManager {
config: BiliUpConfig,
config_path: Option<PathBuf>,
format: Option<ConfigFormat>,
}
impl ConfigManager {
pub fn new() -> Self {
Self {
config: BiliUpConfig::default(),
config_path: None,
format: None,
}
}
/// 从文件加载配置,对应Python的load方法
pub async fn load<P: AsRef<Path>>(&mut self, path: Option<P>) -> Result<()> {
let config_path = if let Some(path) = path {
path.as_ref().to_path_buf()
} else {
// 自动检测配置文件
self.find_config_file().await?
};
info!("Loading config from: {:?}", config_path);
let content = fs::read_to_string(&config_path).await?;
let format = self.detect_format(&config_path)?;
self.config = match format {
ConfigFormat::Yaml => serde_yaml::from_str(&content)?,
ConfigFormat::Toml => toml::from_str(&content)?,
ConfigFormat::Json => serde_json::from_str(&content)?,
};
self.config_path = Some(config_path);
self.format = Some(format);
info!("Config loaded successfully");
Ok(())
}
/// 保存配置到文件,对应Python的save方法
pub async fn save(&self) -> Result<()> {
let config_path = self
.config_path
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No config path set"))?;
let format = self
.format
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No config format set"))?;
let content = match format {
ConfigFormat::Yaml => serde_yaml::to_string(&self.config)?,
ConfigFormat::Toml => toml::to_string_pretty(&self.config)?,
ConfigFormat::Json => serde_json::to_string_pretty(&self.config)?,
};
fs::write(config_path, content).await?;
info!("Config saved to: {:?}", config_path);
Ok(())
}
/// 保存到指定路径,对应Python的dump方法
pub async fn dump<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let path = path.as_ref();
let format = self.detect_format(path)?;
// 创建备份
if path.exists() {
let backup_path = format!(
"{}.backup.{}",
path.to_string_lossy(),
chrono::Utc::now().format("%Y%m%d%H%M%S")
);
if let Err(e) = fs::copy(path, &backup_path).await {
warn!("Failed to create backup: {}", e);
} else {
info!("Created backup: {}", backup_path);
}
}
let content = match format {
ConfigFormat::Yaml => serde_yaml::to_string(&self.config)?,
ConfigFormat::Toml => toml::to_string_pretty(&self.config)?,
ConfigFormat::Json => serde_json::to_string_pretty(&self.config)?,
};
fs::write(path, content).await?;
info!("Config dumped to: {:?}", path);
Ok(())
}
/// 自动查找配置文件
async fn find_config_file(&self) -> Result<PathBuf> {
let candidates = vec!["config.yaml", "config.yml", "config.toml", "config.json"];
for candidate in candidates {
let path = PathBuf::from(candidate);
if path.exists() {
return Ok(path);
}
}
Err(anyhow::anyhow!("No config file found"))
}
/// 检测配置文件格式
fn detect_format<P: AsRef<Path>>(&self, path: P) -> Result<ConfigFormat> {
let path = path.as_ref();
let extension = path
.extension()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("Cannot determine file format"))?;
match extension.to_lowercase().as_str() {
"yaml" | "yml" => Ok(ConfigFormat::Yaml),
"toml" => Ok(ConfigFormat::Toml),
"json" => Ok(ConfigFormat::Json),
_ => Err(anyhow::anyhow!("Unsupported config format: {}", extension)),
}
}
/// 获取配置的只读引用
pub fn get_config(&self) -> &BiliUpConfig {
&self.config
}
/// 获取配置的可变引用
pub fn get_config_mut(&mut self) -> &mut BiliUpConfig {
&mut self.config
}
/// 获取流配置
pub fn get_streamer_config(&self, name: &str) -> Option<&StreamerConfig> {
self.config.streamers.as_ref()?.get(name)
}
/// 设置流配置
pub fn set_streamer_config(&mut self, name: String, config: StreamerConfig) {
if self.config.streamers.is_none() {
self.config.streamers = Some(HashMap::new());
}
if let Some(ref mut streamers) = self.config.streamers {
streamers.insert(name, config);
}
}
/// 删除流配置
pub fn remove_streamer_config(&mut self, name: &str) -> Option<StreamerConfig> {
self.config.streamers.as_mut()?.remove(name)
}
/// 获取所有流名称
pub fn get_streamer_names(&self) -> Vec<String> {
self.config
.streamers
.as_ref()
.map(|streamers| streamers.keys().cloned().collect())
.unwrap_or_default()
}
/// 获取配置值(通用方法)
pub fn get<T>(&self, key: &str) -> Option<T>
where
T: for<'de> Deserialize<'de> + Clone,
{
// 这是一个简化版本,实际实现需要处理嵌套的键路径
// 例如:get("streamers.streamer1.url")
None
}
/// 从cookies文件加载,对应Python的load_cookies
pub async fn load_cookies<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
let content = fs::read_to_string(path).await?;
let cookies_data: serde_json::Value = serde_json::from_str(&content)?;
if let Some(cookie_info) = cookies_data.get("cookie_info")
&& let Some(cookies) = cookie_info.get("cookies").and_then(|c| c.as_array())
{
let mut cookie_map = HashMap::new();
for cookie in cookies {
if let (Some(name), Some(value)) = (
cookie.get("name").and_then(|n| n.as_str()),
cookie.get("value").and_then(|v| v.as_str()),
) {
cookie_map.insert(name.to_string(), value.to_string());
}
}
if self.config.user.is_none() {
self.config.user = Some(UserConfig {
cookies: None,
access_token: None,
});
}
if let Some(ref mut user) = self.config.user {
user.cookies = Some(cookie_map);
}
}
if let Some(token_info) = cookies_data.get("token_info")
&& let Some(access_token) = token_info.get("access_token").and_then(|t| t.as_str())
&& let Some(ref mut user) = self.config.user
{
user.access_token = Some(access_token.to_string());
}
Ok(())
}
/// 合并配置,用于处理覆盖字段
pub fn merge(&mut self, other: BiliUpConfig) {
// TODO: 实现深度合并逻辑
if other.streamers.is_some() {
self.config.streamers = other.streamers;
}
if other.user.is_some() {
self.config.user = other.user;
}
// 合并其他字段...
}
}
impl Default for ConfigManager {
fn default() -> Self {
Self::new()
}
}
// 全局配置管理器实例
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
use tokio::io::AsyncWriteExt;
#[tokio::test]
async fn test_config_yaml_loading() {
let yaml_content = r#"
streamers:
test_streamer:
url: "https://live.bilibili.com/12345"
format: "mp4"
tags: ["测试", "直播"]
threads: 4
pool1_size: 8
"#;
let mut temp_file = NamedTempFile::new().unwrap();
let mut manager = ConfigManager::new();
manager.load(Some(temp_file.path())).await.unwrap();
let config = manager.get_config();
assert_eq!(config.threads, Some(4));
assert_eq!(config.pool1_size, Some(8));
let streamer = config
.streamers
.as_ref()
.unwrap()
.get("test_streamer")
.unwrap();
assert_eq!(streamer.url, "https://live.bilibili.com/12345");
assert_eq!(streamer.format, Some("mp4".to_string()));
}
#[tokio::test]
async fn test_config_toml_loading() {
let toml_content = r#"
threads = 4
pool1_size = 8
[streamers.test_streamer]
url = "https://live.bilibili.com/12345"
format = "mp4"
tags = ["测试", "直播"]
"#;
let mut temp_file = NamedTempFile::new().unwrap();
let mut manager = ConfigManager::new();
manager.load(Some(temp_file.path())).await.unwrap();
let config = manager.get_config();
assert_eq!(config.threads, Some(4));
assert_eq!(config.pool1_size, Some(8));
}
}
-32
View File
@@ -1,32 +0,0 @@
use serde::{Deserialize, Serialize};
pub mod download_actor;
pub mod live_streamers;
pub mod main_loop;
pub mod upload_actor;
pub mod upload_streamers;
pub mod users;
pub mod util;
/// Status of the live stream
pub enum LiveStatus {
/// Stream is online.
Online,
/// Stream is offline.
Offline,
/// The status of the stream could not be determined.
Unknown,
}
/// Status of the live stream
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Default, PartialEq)]
pub enum StreamStatus {
/// Stream is online.
Working,
/// Stream is offline.
Inspecting,
/// The status of the stream could not be determined.
#[default]
Pending,
Idle,
}
@@ -1,210 +0,0 @@
use biliup::client::StatelessClient;
use crate::server::core::StreamStatus;
use crate::server::core::live_streamers::LiveStreamerDto;
use crate::server::core::util::{AnyMap, Cycle};
use biliup::downloader::extractor::{SiteDefinition, find_extractor};
use indexmap::indexmap;
use std::collections::HashMap;
use std::error::Error;
use std::ops::DerefMut;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::debug;
async fn start_monitor(
task: Cycle<StreamStatus>,
extractor: &(dyn SiteDefinition + Send + Sync),
client: StatelessClient,
// live_streamers_service: DynLiveStreamersService,
) {
let n = &mut 0;
loop {
let (url, status) = task.get(n);
status != StreamStatus::Working;
match status {
StreamStatus::Working => {}
StreamStatus::Inspecting => {}
StreamStatus::Pending => {}
StreamStatus::Idle => {
task.change(&url, StreamStatus::Inspecting);
}
}
match (extractor.get_site(&url, client.clone()).await, status) {
(Ok(site), StreamStatus::Idle | StreamStatus::Inspecting) => {
println!("Idle\n {url} \n{site}");
// let (filename, split_size, split_time) = if let Ok(LiveStreamerDto {
// filename,
// split_size,
// split_time,
// ..
// }) =
// live_streamers_service.get_streamer_by_url(&url).await
// {
// (filename, split_size, split_time.map(Duration::from_secs))
// } else {
// ("./video/%Y-%m-%d/%H_%M_%S{title}".to_string(), None, None)
// };
// let live_streamers_service = live_streamers_service.clone();
// {
// let client = client.clone();
// let url = url.clone();
// let task = task.clone();
// logging_spawn(async move {
// let hook = live_streamers_service
// .get_studio_by_url(&url)
// .await
// .unwrap_or_default()
// .map(|studio| -> Box<dyn Fn(&str) + Send> {
// let handle = UploadActorHandle::new(client, studio);
// Box::new(move |file_name| {
// if let Ok(metadata) = std::fs::metadata(file_name)
// .map_err(|err| error!("{}", err))
// {
// if metadata.len() > 10 * 1024 * 1024 {
// info!("开始上传: {}", file_name);
// handle.send_file_path(file_name);
// }
// }
// })
// });
// if hook.is_none() {
// debug!(url = %url, "upload template not set.");
// }
//
// let segmentable = Segmentable::new(split_time, split_size);
// // let segmentable = Segmentable::new( None, Some(16*1024*1024));
// site.download(&filename, segmentable, hook).await?;
// task.change(&url, StreamStatus::Idle);
// Ok::<_, Box<dyn Error + Send + Sync>>(())
// });
// }
task.change(&url, StreamStatus::Working);
}
(Ok(_site), StreamStatus::Pending) => {
println!("Pending");
}
(Ok(_site), StreamStatus::Working) => {
debug!("Working");
}
(Err(e), _) => {
task.change(&url, StreamStatus::Idle);
debug!(url, "{e}")
}
}
tokio::time::sleep(Duration::from_secs(30)).await;
}
}
#[derive(Clone)]
struct DownloadActor {
client: StatelessClient,
}
impl DownloadActor {
fn new(client: StatelessClient) -> Self {
Self { client }
}
fn run(
&mut self,
list: Vec<LiveStreamerDto>,
extensions: StreamActorMap,
// client: StatelessClient,
) {
for streamer in list {
// let Some(extractor) = find_extractor(&streamer.url) else { continue; };
let mut guard = extensions.write().unwrap();
self.add_streamer(guard.deref_mut(), streamer.url)
}
println!("{:?}", extensions);
}
fn add_streamer(
&self,
map: &mut AnyMap<(Cycle<StreamStatus>, JoinHandle<()>)>,
url: String,
// client: StatelessClient,
) {
let Some(extractor) = find_extractor(&url) else {
return;
};
let _entry = map
.entry(extractor.as_any().type_id())
.and_modify(|(cy, _)| cy.insert(url.clone(), StreamStatus::Idle))
.or_insert_with(|| {
let cycle = Cycle::new(indexmap![url => StreamStatus::Idle]);
let task = cycle.clone();
let client = self.client.clone();
let handle =
tokio::spawn(async move { start_monitor(task, extractor, client).await });
(cycle, handle)
});
}
}
type StreamActorMap = Arc<RwLock<AnyMap<(Cycle<StreamStatus>, JoinHandle<()>)>>>;
#[derive(Clone)]
pub struct DownloadActorHandle {
platform_map: StreamActorMap,
// client: StatelessClient,
actor: DownloadActor,
}
impl DownloadActorHandle {
pub fn new(list: Vec<LiveStreamerDto>, client: StatelessClient) -> Self {
let mut actor = DownloadActor::new(client);
let platform_map = Arc::new(RwLock::new(HashMap::default()));
let platform = Arc::clone(&platform_map);
// let client_c = client.clone();
actor.run(list, platform);
Self {
platform_map,
actor,
}
}
pub fn add_streamer(&self, url: &str) {
self.actor.add_streamer(
self.platform_map.write().unwrap().deref_mut(),
url.to_string(),
// self.client.clone(),
);
}
pub fn get_streamers(&self) -> HashMap<String, StreamStatus> {
let read_guard = self.platform_map.read().unwrap();
let mut map = HashMap::new();
for (key, val) in read_guard.iter() {
map.extend(val.0.get_all());
}
map
}
pub fn update_streamer(&self, url: &str) {
self.remove_streamer(url);
self.add_streamer(url);
}
pub fn remove_streamer(&self, url: &str) {
find_extractor(url).and_then(|extractor| {
self.platform_map
.read()
.unwrap()
.get(&extractor.as_any().type_id())
.and_then(|(cy, join_handle)| {
let mut guard = cy.write();
if guard.len() <= 1 {
join_handle.abort()
}
guard.swap_remove(url)
})
});
}
}
@@ -3,6 +3,7 @@ use crate::server::core::monitor::{Monitor, RoomsHandle};
use crate::server::core::plugin::{DownloadPlugin, StreamInfo};
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::context::{Worker, WorkerStatus};
use crate::server::infrastructure::models::hook_step::process_video;
use async_channel::{Receiver, Sender, bounded};
use biliup::bilibili::{BiliBili, Studio, Video};
use biliup::client::StatelessClient;
@@ -19,7 +20,6 @@ use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::task::JoinHandle;
use tracing::{error, info, warn};
use crate::server::infrastructure::models::hook_step::process_video;
pub struct DownloadManager {
pub monitor: Mutex<Option<Arc<Monitor>>>,
@@ -39,7 +39,7 @@ impl DownloadManager {
}
}
pub(crate) fn ensure_monitor(&self) -> Arc<Monitor> {
pub fn ensure_monitor(&self) -> Arc<Monitor> {
// Monitor::new(self.plugin.name(), url)
self.monitor
.lock()
@@ -91,10 +91,10 @@ impl DActor {
move |event: SegmentEvent| {
if event.segment_index == 0 {
match sender.force_send(UploaderMessage::SegmentEvent(
event,
rx.clone(),
room.clone(),
)) {
event,
rx.clone(),
room.clone(),
)) {
Ok(Some(ret)) => {
warn!(SegmentEvent = ?ret, "replace an existing message in the channel");
}
@@ -175,7 +175,8 @@ impl UActor {
&line,
&event.file_path,
)
.await.map_err(|e| error!(e=?e))
.await
.map_err(|e| error!(e=?e))
{
videos.push(video);
}
@@ -189,7 +190,8 @@ impl UActor {
&line,
&se.file_path,
)
.await.map_err(|e| error!(e=?e))
.await
.map_err(|e| error!(e=?e))
{
videos.push(video);
}
@@ -254,7 +256,9 @@ impl UActor {
let streamer = worker.get_streamer().await.unwrap();
if let Some(post_processor) = streamer.postprocessor {
for video_path in &video_paths {
process_video(video_path, &post_processor).await.map_err(|e| error!(e=?e));
process_video(video_path, &post_processor)
.await
.map_err(|e| error!(e=?e));
}
}
}
@@ -1,124 +0,0 @@
use crate::server::core::StreamStatus;
use async_trait::async_trait;
use biliup::uploader::bilibili::Studio;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::sync::Arc;
/// Similar to above, we want to keep a reference count across threads so we can manage our connection pool.
pub type DynLiveStreamersRepository = Arc<dyn LiveStreamersRepository + Send + Sync>;
pub type DynDownloadRecordsRepository = Arc<dyn DownloadRecordsRepository + Send + Sync>;
pub type DynVideosRepository = Arc<dyn VideosRepository + Send + Sync>;
#[async_trait]
pub trait LiveStreamersRepository {
async fn create_streamer(
&self,
entity: AddLiveStreamerDto,
) -> anyhow::Result<LiveStreamerEntity>;
async fn delete_streamer(&self, id: i64) -> anyhow::Result<()>;
async fn update_streamer(
&self,
entity: LiveStreamerEntity,
) -> anyhow::Result<LiveStreamerEntity>;
async fn get_streamers(&self) -> anyhow::Result<Vec<LiveStreamerEntity>>;
async fn get_streamer_by_url(&self, url: &str) -> anyhow::Result<LiveStreamerEntity>;
async fn get_streamer_by_id(&self, id: i64) -> anyhow::Result<LiveStreamerEntity>;
}
#[async_trait]
pub trait DownloadRecordsRepository {
async fn create(&self, entity: DownloadRecords) -> anyhow::Result<DownloadRecords>;
// async fn delete(&self, id: i64) -> anyhow::Result<()>;
// async fn update(&self, entity: DownloadRecords) -> anyhow::Result<DownloadRecords>;
async fn get_all(&self) -> anyhow::Result<Vec<DownloadRecords>>;
// async fn get_by_id(&self, id: i64) -> anyhow::Result<DownloadRecords>;
}
#[async_trait]
pub trait VideosRepository {
async fn create(&self, entity: Videos) -> anyhow::Result<Videos>;
// async fn delete(&self, id: i64) -> anyhow::Result<()>;
async fn update(&self, entity: Videos) -> anyhow::Result<Videos>;
// async fn get_all(&self) -> anyhow::Result<Vec<Videos>>;
async fn get_by_id(&self, id: i64) -> anyhow::Result<Videos>;
}
#[derive(Serialize, Deserialize, FromRow)]
pub struct LiveStreamerEntity {
pub id: i64,
pub url: String,
pub remark: String,
pub filename: String,
pub split_time: Option<i64>,
pub split_size: Option<i64>,
pub upload_id: Option<i64>,
}
#[derive(FromRow)]
pub struct DownloadRecords {
pub id: i64,
pub title: String,
pub live_id: i64,
pub upload_id: Option<i64>,
pub status: String,
}
#[derive(FromRow)]
pub struct Videos {
pub id: i64,
pub start_time: chrono::DateTime<Utc>,
pub end_time: chrono::DateTime<Utc>,
pub path: String,
pub record_id: i64,
pub status: String,
}
impl LiveStreamerEntity {
pub fn into_dto(self) -> LiveStreamerDto {
LiveStreamerDto {
id: self.id,
url: self.url,
remark: self.remark,
filename: self.filename,
split_time: self.split_time.map(|t| t as u64),
split_size: self.split_size.map(|s| s as u64),
status: Default::default(),
}
}
}
/// A reference counter for our user service allows us safely pass instances user utils
/// around which themselves depend on the user repostiory, and ultimately, our Posgres connection pool.
pub type DynLiveStreamersService = Arc<dyn LiveStreamersService + Send + Sync>;
#[async_trait]
pub trait LiveStreamersService {
async fn add_streamer(&self, request: AddLiveStreamerDto) -> anyhow::Result<LiveStreamerDto>;
async fn get_streamer_by_url(&self, url: &str) -> anyhow::Result<LiveStreamerDto>;
async fn get_streamer_by_id(&self, id: i64) -> anyhow::Result<LiveStreamerDto>;
async fn get_streamers(&self) -> anyhow::Result<Vec<LiveStreamerDto>>;
async fn get_studio_by_url(&self, url: &str) -> anyhow::Result<Option<Studio>>;
}
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct AddLiveStreamerDto {
pub url: String,
pub remark: String,
pub filename: String,
pub split_time: Option<u64>,
pub split_size: Option<u64>,
pub upload_id: Option<i64>,
}
#[derive(Serialize, Deserialize, Default, Debug)]
pub struct LiveStreamerDto {
pub id: i64,
pub url: String,
pub remark: String,
pub filename: String,
pub split_time: Option<u64>,
pub split_size: Option<u64>,
pub status: StreamStatus,
}
@@ -1,115 +0,0 @@
use crate::server::core::StreamStatus;
use crate::server::core::live_streamers::Videos;
use crate::server::core::util::Cycle;
use anyhow::Result;
use biliup::downloader::extractor::Site;
use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::{Receiver, channel};
use tokio::task::JoinHandle;
/// This struct is used by client actors to send messages to the main loop. The
/// message type is `ToServer`.
#[derive(Clone, Debug)]
pub struct ServerHandle {
chan: Sender<ToMain>,
}
impl ServerHandle {
pub async fn send(&mut self, msg: ToMain) {
if self.chan.send(msg).await.is_err() {
panic!("Main loop has shut down.");
}
}
}
/// The message type used when a client actor sends messages to the main loop.
pub enum ToMain {
NewRecording(Site, Cycle<StreamStatus>),
FileClosed(Videos),
// FatalError(io::Error),
}
pub fn spawn_main_loop() -> (ServerHandle, JoinHandle<()>) {
let (send, recv) = channel(64);
let handle = ServerHandle { chan: send };
let join = tokio::spawn(async move {
let res = main_loop(recv).await;
match res {
Ok(()) => {}
Err(err) => {
eprintln!("Oops {}.", err);
}
}
});
(handle, join)
}
async fn main_loop(mut recv: Receiver<ToMain>) -> Result<()> {
while let Some(msg) = recv.recv().await {
match msg {
ToMain::NewRecording(_site, _task) => {}
ToMain::FileClosed(_) => {}
}
}
Ok(())
}
// async fn recording(
// url: &str,
// mut site: Site,
// task: Cycle<StreamStatus>,
// client: StatelessClient,
// live_streamers_service: DynLiveStreamersService,
// ) {
// println!("Idle\n {url} \n{site}");
// let (filename, split_size, split_time) = if let Ok(LiveStreamerDto {
// filename,
// split_size,
// split_time,
// ..
// }) =
// live_streamers_service.get_streamer_by_url(url).await
// {
// (filename, split_size, split_time.map(Duration::from_secs))
// } else {
// ("./video/%Y-%m-%d/%H_%M_%S{title}".to_string(), None, None)
// };
//
// logging_spawn({
// // let client = client.clone();
// let url = url.to_string();
// let task = task.clone();
// // let live_streamers_service = live_streamers_service.clone();
// async move {
// let hook = live_streamers_service
// .get_studio_by_url(&url)
// .await
// .unwrap_or_default()
// .map(|studio| -> Box<dyn Fn(&str) + Send + Sync> {
// let handle = UploadActorHandle::new(client, studio);
// Box::new(move |file_name| {
// if let Ok(metadata) =
// std::fs::metadata(file_name).map_err(|err| error!("{}", err))
// {
// if metadata.len() > 10 * 1024 * 1024 {
// info!("开始上传: {}", file_name);
// handle.send_file_path(file_name);
// }
// }
// })
// });
// if hook.is_none() {
// debug!(url = %url, "upload template not set.");
// }
// let segmentable = Segmentable::new(split_time, split_size);
// // let segmentable = Segmentable::new( None, Some(16*1024*1024));
// site.download(&filename, segmentable, hook).await?;
// task.change(&url, StreamStatus::Idle);
// Ok::<_, Box<dyn Error + Send + Sync>>(())
// }
// });
// task.change(url, StreamStatus::Working);
// }
@@ -0,0 +1,61 @@
use crate::server::core::download_manager::{ActorHandle, DownloadManager};
use crate::server::core::downloader::ffmpeg_downloader::FfmpegDownloader;
use crate::server::core::downloader::stream_gears::StreamGears;
use crate::server::core::downloader::{DownloadConfig, Downloader, DownloaderType};
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::context::Worker;
use crate::server::util::{Recorder, media_ext_from_url, parse_time};
use async_trait::async_trait;
use biliup::downloader::util::Segmentable;
use error_stack::{Report, ResultExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tracing::{debug, info};
// Stream information structures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamInfo {
pub name: String,
pub url: String,
pub raw_stream_url: String,
pub title: String,
#[serde(with = "time::serde::rfc3339")]
pub date: OffsetDateTime, // 保存 Python 的时间对象(如 time.struct_time)
// pub end_time: PyObject, // 同上
pub live_cover_path: Option<String>,
pub platform: String,
pub stream_headers: HashMap<String, String>,
pub suffix: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamStatus {
Live { stream_info: StreamInfo },
Offline,
Unknown,
}
#[async_trait]
pub trait DownloadBase: Send + Sync {
async fn check_stream(&self) -> Result<bool, Report<AppError>>;
async fn get_stream_info(&self) -> Result<StreamInfo, Report<AppError>>;
async fn download(&self, output_path: impl AsRef<Path>) -> Result<(), Report<AppError>>;
fn should_record(&self, room_title: &str) -> bool;
fn get_platform_name(&self) -> &'static str;
}
#[async_trait]
pub trait DownloadPlugin {
fn matches(&self, url: &str) -> bool;
async fn check_status(&self, url: &str) -> Result<StreamStatus, Report<AppError>>;
async fn create_downloader(
&self,
stream_info: &StreamInfo,
worker: &Worker,
) -> AppResult<Box<dyn Downloader>>;
fn name(&self) -> &str;
}
@@ -1,156 +0,0 @@
use biliup::client::StatelessClient;
use biliup::error::Kind;
use biliup::uploader::VideoFile;
use biliup::uploader::bilibili::{BiliBili, Studio, Vid, Video};
use biliup::uploader::credential::login_by_cookies;
use biliup::uploader::line::Line;
use futures::StreamExt;
use std::path::{Path, PathBuf};
use std::time::Instant;
use tokio::sync::mpsc;
use tracing::{error, info};
struct UploadActor {
receiver: mpsc::UnboundedReceiver<ActorMessage>,
client: StatelessClient,
studio: Studio,
vid: Option<Vid>,
}
enum ActorMessage {
Upload { path: PathBuf },
}
impl UploadActor {
fn new(
studio: Studio,
client: StatelessClient,
receiver: mpsc::UnboundedReceiver<ActorMessage>,
) -> Self {
UploadActor {
receiver,
client,
studio,
vid: None,
}
}
async fn upload(
&self,
video_paths: &[&Path],
bili: &BiliBili,
line: Line,
limit: usize,
) -> biliup::error::Result<Vec<Video>> {
let mut videos = Vec::new();
for video_path in video_paths {
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(bili, video_file).await?;
let instant = Instant::now();
let video = uploader
.upload(self.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);
}
Ok(videos)
}
async fn handle_message(&mut self, msg: ActorMessage) -> biliup::error::Result<()> {
match msg {
ActorMessage::Upload { path } => {
let bili = login_by_cookies("cookies.json", None).await?;
let videos = self
.upload(&[path.as_path()], &bili, Default::default(), 3)
.await?;
if let Some(vid) = &self.vid {
let mut studio = bili.studio_data(vid, None).await?;
studio.videos.extend(videos);
bili.edit(&studio, None).await?;
} else {
let studio = &mut self.studio;
studio.videos.extend(videos);
if studio.title.is_empty() {
studio.title = path
.file_stem()
.and_then(|fname| fname.to_str())
.unwrap_or_default()
.to_string();
}
if studio.tag.is_empty() {
studio.tag = bili
.recommend_tag(studio.tid, &studio.title, "")
.await?
.get(0)
.and_then(|tag| tag.get("tag"))
.and_then(|tag| tag.as_str())
.unwrap_or("录播")
.to_string();
}
let result = bili.submit(studio, None).await?;
self.vid = Some(
result
.data
.as_ref()
.and_then(|data| data.get("bvid"))
.and_then(|vid| vid.as_str())
.map(|vid| Vid::Bvid(String::from(vid)))
.ok_or_else(|| Kind::Custom(format!("{:?}", result)))?,
);
}
}
}
Ok(())
}
}
async fn run_download_actor(mut actor: UploadActor) {
while let Some(msg) = actor.receiver.recv().await {
match actor.handle_message(msg).await {
Ok(_) => {}
Err(e) => {
error!("{}", e)
}
};
}
}
#[derive(Clone)]
pub struct UploadActorHandle {
sender: mpsc::UnboundedSender<ActorMessage>,
}
impl UploadActorHandle {
pub fn new(client: StatelessClient, studio: Studio) -> Self {
let (sender, receiver) = mpsc::unbounded_channel();
let actor = UploadActor::new(studio, client, receiver);
tokio::spawn(run_download_actor(actor));
Self { sender }
}
pub fn send_file_path<T: AsRef<Path>>(&self, path: T) {
let msg = ActorMessage::Upload {
path: PathBuf::from(path.as_ref()),
};
let _ = self.sender.send(msg);
}
}
@@ -1,129 +0,0 @@
use async_trait::async_trait;
use biliup::uploader::bilibili::Studio;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::sync::Arc;
pub type DynUploadStreamersRepository = Arc<dyn UploadStreamersRepository + Send + Sync>;
pub type DynUploadRecordsRepository = Arc<dyn UploadRecordsRepository + Send + Sync>;
#[async_trait]
pub trait UploadStreamersRepository {
async fn create_streamer(&self, studio: StudioEntity) -> anyhow::Result<StudioEntity>;
async fn delete_streamer(&self, id: i64) -> anyhow::Result<()>;
async fn update_streamer(&self, studio: StudioEntity) -> anyhow::Result<StudioEntity>;
async fn get_streamers(&self) -> anyhow::Result<Vec<StudioEntity>>;
async fn get_streamer_by_id(&self, id: i64) -> anyhow::Result<StudioEntity>;
}
#[async_trait]
pub trait UploadRecordsRepository {
async fn create(&self, entity: UploadRecords) -> anyhow::Result<UploadRecords>;
async fn delete(&self, id: i64) -> anyhow::Result<()>;
// async fn update(&self, entity: UploadRecords) -> anyhow::Result<UploadRecords>;
async fn get_all(&self) -> anyhow::Result<Vec<UploadRecords>>;
async fn get_by_id(&self, id: i64) -> anyhow::Result<UploadRecords>;
}
// #[serde(default)]
#[derive(FromRow, Serialize, Deserialize)]
pub struct StudioEntity {
#[serde(default)]
pub id: i64,
pub template_name: String,
pub user: i64,
pub copyright: u8,
pub source: String,
pub tid: u16,
pub cover: String,
pub title: String,
pub desc: String,
pub dynamic: String,
pub tag: String,
pub dtime: Option<u32>,
pub interactive: u8,
pub mission_id: Option<u32>,
pub dolby: u8,
pub lossless_music: u8,
pub no_reprint: u8,
pub charging_pay: u8,
pub up_selection_reply: bool,
pub up_close_reply: bool,
pub up_close_danmu: bool,
}
impl StudioEntity {
pub fn into_dto(self) -> Studio {
Studio {
copyright: self.copyright,
source: self.source,
tid: self.tid,
cover: self.cover,
title: self.title,
desc_format_id: 0,
desc: self.desc,
dynamic: self.dynamic,
subtitle: Default::default(),
tag: self.tag,
videos: vec![],
desc_v2: None,
dtime: self.dtime,
open_subtitle: false,
interactive: self.interactive,
mission_id: self.mission_id,
dolby: self.dolby,
lossless_music: self.lossless_music,
no_reprint: self.no_reprint,
charging_pay: self.charging_pay,
aid: None,
up_selection_reply: self.up_selection_reply,
up_close_reply: self.up_close_reply,
up_close_danmu: self.up_close_danmu,
extra_fields: None,
}
}
// pub fn into_profile(self, following: bool) -> ProfileDto {
// ProfileDto {
// username: self.username,
// bio: self.bio,
// image: self.image,
// following,
// }
// }
}
#[derive(FromRow, Serialize, Deserialize)]
pub struct UploadRecords {
pub id: i64,
pub identity: String,
pub status: String,
}
impl Default for StudioEntity {
fn default() -> Self {
StudioEntity {
id: 0,
template_name: "".to_string(),
user: 0,
copyright: 1,
source: "".to_string(),
tid: 0,
cover: "".to_string(),
title: "".to_string(),
desc: "".to_string(),
dynamic: "".to_string(),
tag: "".to_string(),
dtime: None,
interactive: 0,
mission_id: None,
dolby: 0,
lossless_music: 0,
no_reprint: 0,
charging_pay: 0,
up_selection_reply: false,
up_close_reply: false,
up_close_danmu: false,
}
}
}
@@ -1,23 +0,0 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::sync::Arc;
pub type DynUsersRepository = Arc<dyn UsersRepository + Send + Sync>;
#[derive(FromRow, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub name: String,
pub value: String,
pub platform: String,
}
#[async_trait]
pub trait UsersRepository {
async fn create_user(&self, user: User) -> anyhow::Result<User>;
async fn get_users(&self) -> anyhow::Result<Vec<User>>;
async fn delete_user(&self, id: i64) -> anyhow::Result<()>;
async fn update_user(&self, user: User) -> anyhow::Result<User>;
async fn get_user_by_id(&self, id: i64) -> anyhow::Result<User>;
}
-114
View File
@@ -1,114 +0,0 @@
use indexmap::IndexMap;
use std::any::TypeId;
use std::collections::HashMap;
use std::error::Error;
use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::hash::{BuildHasherDefault, Hasher};
use std::sync::{Arc, RwLock, RwLockWriteGuard};
use tokio::task::JoinHandle;
use tracing::error;
pub type AnyMap<T> = HashMap<TypeId, T, BuildHasherDefault<IdHasher>>;
// With TypeIds as keys, there's no need to hash them. They are already hashes
// themselves, coming from the compiler. The IdHasher just holds the u64 of
// the TypeId, and then returns it, instead of doing any bit fiddling.
#[derive(Default)]
pub struct IdHasher(u64);
impl Hasher for IdHasher {
#[inline]
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, _: &[u8]) {
unreachable!("TypeId calls write_u64");
}
#[inline]
fn write_u64(&mut self, id: u64) {
self.0 = id;
}
}
#[derive(Clone)]
pub struct Cycle<T> {
map: Arc<RwLock<IndexMap<String, T>>>,
}
impl<T: Copy> Cycle<T> {
pub fn new(map: IndexMap<String, T>) -> Self {
if map.is_empty() {
unreachable!("list must not be empty")
}
Self {
map: Arc::new(RwLock::new(map)),
}
}
pub fn get(&self, i: &mut usize) -> (String, T) {
match self.map.read().unwrap().get_index(*i) {
Some((k, v)) => {
*i += 1;
(k.clone(), *v)
}
None => {
*i = 0;
self.get(i)
}
}
}
pub fn get_all(&self) -> IndexMap<String, T> {
let read_guard = self.map.read().unwrap();
read_guard.clone()
}
pub fn replace(&mut self, map: IndexMap<String, T>) {
if map.is_empty() {
unreachable!("list must not be empty")
}
*self.map.write().unwrap() = map;
}
pub fn write(&self) -> RwLockWriteGuard<'_, IndexMap<String, T>> {
self.map.write().unwrap()
}
pub fn change(&self, key: &str, value: T) {
self.write()
.entry(String::from(key))
.and_modify(|mt| *mt = value);
}
pub fn insert(&self, key: String, value: T) {
self.map.write().unwrap().insert(key, value);
}
}
impl<T: Debug> Debug for Cycle<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{{")?;
let mut temp = Vec::new();
for (k, v) in self.map.read().unwrap().iter() {
temp.push(format!(" {{ {}: {:?} }}", k, v));
}
write!(f, "{}", temp.join(","))?;
write!(f, " }}")
}
}
pub fn logging_spawn<T, O: 'static>(future: T) -> JoinHandle<T::Output>
where
T: Future<Output = Result<O, Box<dyn Error + Send + Sync>>> + Send + 'static,
T::Output: Send + 'static,
{
tokio::spawn(async move {
future.await.map_err(|e| {
error!("spawn error: {}", e);
e
})
})
}
+23 -90
View File
@@ -1,103 +1,34 @@
use std::borrow::Cow;
use std::{collections::HashMap, fmt::Debug};
use axum::response::Response;
use axum::{Json, http::StatusCode, response::IntoResponse};
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use error_stack::Report;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
use tracing::log::error;
pub type AppResult<T> = Result<T, AppError>;
pub type ConduitErrorMap = HashMap<Cow<'static, str>, Vec<Cow<'static, str>>>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("authentication is required to access this resource")]
Unauthorized,
#[error("username or password is incorrect")]
InvalidLoginAttempt,
#[error("user does not have privilege to access this resource")]
Forbidden,
#[error("Unknown Error")]
Unknown,
#[error("{0}")]
NotFound(String),
#[error("{0}")]
ApplicationStartup(String),
#[error("{0}")]
BadRequest(String),
#[error("unexpected error has occurred")]
InternalServerError,
#[error("{0}")]
InternalServerErrorWithContext(String),
#[error("{0}")]
ObjectConflict(String),
#[error("unprocessable request has occurred")]
UnprocessableEntity { errors: ConduitErrorMap },
#[error(transparent)]
AxumJsonRejection(#[from] axum::extract::rejection::JsonRejection),
#[error(transparent)]
AnyhowError(#[from] anyhow::Error),
#[error(transparent)]
DownloadError(#[from] biliup::downloader::error::Error),
#[error(transparent)]
UploadError(#[from] biliup::error::Kind),
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
#[error(transparent)]
OrmliteError(#[from] ormlite::Error),
#[error(transparent)]
SerdeJsonError(#[from] serde_json::Error),
Custom(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
error!("AppError{}", AppError = self);
let (status, error_message) = match self {
Self::InternalServerErrorWithContext(err) => (StatusCode::INTERNAL_SERVER_ERROR, err),
Self::NotFound(err) => (StatusCode::NOT_FOUND, err),
Self::ObjectConflict(err) => (StatusCode::CONFLICT, err),
Self::InvalidLoginAttempt => (
StatusCode::BAD_REQUEST,
Self::InvalidLoginAttempt.to_string(),
),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, Self::Unauthorized.to_string()),
Self::AnyhowError(err) => 'e: {
for cause in err.chain() {
if let Some(error) = cause.downcast_ref::<Box<dyn sqlx::error::DatabaseError>>()
{
println!("121212cause: {}", error.message());
break 'e (StatusCode::BAD_REQUEST, error.to_string());
}
println!("{}", cause);
}
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
AppError::Forbidden
| AppError::ApplicationStartup(_)
| AppError::BadRequest(_)
| AppError::InternalServerError
| AppError::UnprocessableEntity { .. }
| AppError::AxumJsonRejection(_)
| AppError::DownloadError(_)
| AppError::UploadError(_)
| AppError::SerdeJsonError(_)
| AppError::ReqwestError(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
String::from("unexpected error occurred"),
),
AppError::OrmliteError(ormlite) => (
StatusCode::INTERNAL_SERVER_ERROR,
String::from("OrmliteError occurred"),
),
};
pub fn report_to_response(report: impl Into<Report<AppError>>) -> Response {
let report = report.into();
let (status, error_message) = match report.downcast_ref::<AppError>() {
Some(AppError::Unknown) => (StatusCode::INTERNAL_SERVER_ERROR, report.to_string()),
Some(AppError::Custom(msg)) => (StatusCode::INTERNAL_SERVER_ERROR, msg.into()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, report.to_string()),
};
tracing::error!(error = ?report);
// I'm not a fan of the error specification, so for the sake of consistency,
// serialize singular errors as a map of vectors similar to the 422 validation responses
let body = Json(ApiError::new(error_message));
// I'm not a fan of the error specification, so for the sake of consistency,
// serialize singular errors as a map of vectors similar to the 422 validation responses
let body = Json(ApiError::new(error_message));
(status, body).into_response()
}
(status, body).into_response()
}
#[derive(Debug, Deserialize, Serialize)]
@@ -121,3 +52,5 @@ impl ApiError {
Self { errors, message }
}
}
pub type AppResult<T> = Result<T, Report<AppError>>;
@@ -1,251 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, mpsc};
use tracing::{debug, error, info};
/// 事件类型枚举,对应Python版本的事件
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EventType {
PreDownload,
Download,
Downloaded,
Upload,
Uploaded,
}
/// 事件数据结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BiliUpEvent {
PreDownload { name: String, url: String },
Download { name: String, url: String },
Downloaded { stream_info: StreamInfo },
Upload { stream_info: StreamInfo },
Uploaded { files: Vec<FileInfo> },
}
/// 流信息结构,对应Python版本的stream_info
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamInfo {
pub name: String,
pub url: String,
pub title: Option<String>,
pub date: chrono::DateTime<chrono::Utc>,
pub end_time: Option<chrono::DateTime<chrono::Utc>>,
pub live_cover_path: Option<String>,
pub is_download: bool,
pub platform: String,
pub database_row_id: Option<i64>,
}
/// 文件信息结构
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileInfo {
pub video: String,
pub danmaku: Option<String>,
}
/// 事件处理器trait
#[async_trait]
pub trait EventHandler: Send + Sync {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>>;
fn event_type(&self) -> EventType;
}
/// 事件管理器上下文,对应Python版本的context
#[derive(Debug, Default)]
pub struct EventContext {
pub url_upload_count: HashMap<String, i32>,
pub upload_filename: Vec<String>,
pub file_upload_count: HashMap<String, i32>,
pub url_status: HashMap<String, i32>, // 0: idle, 1: downloading, 2: stopped
}
/// 事件管理器,对应Python版本的EventManager
pub struct EventManager {
context: Arc<RwLock<EventContext>>,
handlers: Arc<RwLock<HashMap<EventType, Vec<Box<dyn EventHandler>>>>>,
event_tx: mpsc::UnboundedSender<BiliUpEvent>,
event_rx: Arc<RwLock<Option<mpsc::UnboundedReceiver<BiliUpEvent>>>>,
}
impl EventManager {
pub fn new() -> Self {
let (event_tx, event_rx) = mpsc::unbounded_channel();
Self {
context: Arc::new(RwLock::new(EventContext::default())),
handlers: Arc::new(RwLock::new(HashMap::new())),
event_tx,
event_rx: Arc::new(RwLock::new(Some(event_rx))),
}
}
/// 注册事件处理器
pub async fn register_handler(&self, handler: Box<dyn EventHandler>) {
let event_type = handler.event_type();
let mut handlers = self.handlers.write().await;
handlers
.entry(event_type)
.or_insert_with(Vec::new)
.push(handler);
}
/// 发送事件
pub async fn send_event(&self, event: BiliUpEvent) -> Result<()> {
self.event_tx
.send(event)
.map_err(|e| anyhow::anyhow!("Failed to send event: {}", e))?;
Ok(())
}
/// 获取上下文的只读访问
pub async fn get_context(&self) -> tokio::sync::RwLockReadGuard<EventContext> {
self.context.read().await
}
/// 获取上下文的写访问
pub async fn get_context_mut(&self) -> tokio::sync::RwLockWriteGuard<EventContext> {
self.context.write().await
}
/// 启动事件循环
pub async fn start(&self) -> Result<()> {
let mut event_rx = {
let mut rx_guard = self.event_rx.write().await;
rx_guard
.take()
.ok_or_else(|| anyhow::anyhow!("Event manager already started"))?
};
info!("Event manager started");
while let Some(event) = event_rx.recv().await {
if let Err(e) = self.process_event(event).await {
error!("Failed to process event: {}", e);
}
}
Ok(())
}
/// 处理单个事件
async fn process_event(&self, event: BiliUpEvent) -> Result<()> {
let event_type = match &event {
BiliUpEvent::PreDownload { .. } => EventType::PreDownload,
BiliUpEvent::Download { .. } => EventType::Download,
BiliUpEvent::Downloaded { .. } => EventType::Downloaded,
BiliUpEvent::Upload { .. } => EventType::Upload,
BiliUpEvent::Uploaded { .. } => EventType::Uploaded,
};
debug!("Processing event: {:?}", event_type);
let handlers = self.handlers.read().await;
if let Some(event_handlers) = handlers.get(&event_type) {
for handler in event_handlers {
match handler.handle(event.clone()).await {
Ok(Some(next_event)) => {
// 如果处理器返回了新事件,继续发送
if let Err(e) = self.send_event(next_event).await {
error!("Failed to send follow-up event: {}", e);
}
}
Ok(None) => {
// 事件处理完成,无后续事件
}
Err(e) => {
error!("Handler failed to process event: {}", e);
}
}
}
}
Ok(())
}
/// 更新URL状态
pub async fn update_url_status(&self, url: &str, status: i32) {
let mut context = self.context.write().await;
context.url_status.insert(url.to_string(), status);
}
/// 获取URL状态
pub async fn get_url_status(&self, url: &str) -> i32 {
let context = self.context.read().await;
context.url_status.get(url).copied().unwrap_or(0)
}
/// 增加上传计数
pub async fn increment_upload_count(&self, url: &str) {
let mut context = self.context.write().await;
*context.url_upload_count.entry(url.to_string()).or_insert(0) += 1;
}
/// 减少上传计数
pub async fn decrement_upload_count(&self, url: &str) {
let mut context = self.context.write().await;
if let Some(count) = context.url_upload_count.get_mut(url) {
*count = (*count - 1).max(0);
}
}
/// 检查是否正在上传
pub async fn is_uploading(&self, url: &str) -> bool {
let context = self.context.read().await;
context.url_upload_count.get(url).copied().unwrap_or(0) > 0
}
}
impl Default for EventManager {
fn default() -> Self {
Self::new()
}
}
// 全局事件管理器实例
#[cfg(test)]
mod tests {
use super::*;
use tokio::time::{Duration, sleep};
struct TestHandler {
event_type: EventType,
}
#[async_trait]
impl EventHandler for TestHandler {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>> {
println!("Handling event: {:?}", event);
Ok(None)
}
fn event_type(&self) -> EventType {
self.event_type.clone()
}
}
#[tokio::test]
async fn test_event_manager() {
let event_manager = EventManager::new();
// 注册处理器
let handler = Box::new(TestHandler {
event_type: EventType::PreDownload,
});
event_manager.register_handler(handler).await;
// 发送事件
let event = BiliUpEvent::PreDownload {
name: "test".to_string(),
url: "http://test.com".to_string(),
};
event_manager.send_event(event).await.unwrap();
// 等待处理
sleep(Duration::from_millis(100)).await;
}
}
-357
View File
@@ -1,357 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use tokio::fs;
use tracing::{debug, error, info, warn};
use crate::server::core::live_streamers::DynLiveStreamersService;
use crate::server::event_manager::{BiliUpEvent, EventHandler, EventType, FileInfo, StreamInfo};
/// 预下载处理器,对应Python的pre_processor
pub struct PreDownloadHandler {
live_streamers_service: DynLiveStreamersService,
}
impl PreDownloadHandler {
pub fn new(live_streamers_service: DynLiveStreamersService) -> Self {
Self {
live_streamers_service,
}
}
async fn execute_preprocessor(&self, name: &str, url: &str) -> Result<()> {
// TODO: 实现预处理器执行逻辑
// 这里应该调用配置中的preprocessor命令
info!("Executing preprocessor for {} - {}", name, url);
Ok(())
}
}
#[async_trait]
impl EventHandler for PreDownloadHandler {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>> {
if let BiliUpEvent::PreDownload { name, url } = event {
// 检查URL状态
debug!("{} - {} 开播了准备下载", name, url);
// 执行预处理器
if let Err(e) = self.execute_preprocessor(&name, &url).await {
error!("Preprocessor execution failed: {}", e);
}
// 发送下载事件
return Ok(Some(BiliUpEvent::Download { name, url }));
}
Ok(None)
}
fn event_type(&self) -> EventType {
EventType::PreDownload
}
}
/// 下载处理器,对应Python的process
pub struct DownloadHandler {
live_streamers_service: DynLiveStreamersService,
}
impl DownloadHandler {
pub fn new(live_streamers_service: DynLiveStreamersService) -> Self {
Self {
live_streamers_service,
}
}
async fn perform_download(&self, name: &str, url: &str) -> Result<StreamInfo> {
// TODO: 实现实际的下载逻辑
// 这里应该调用biliup_download等价函数
info!("Starting download for {} - {}", name, url);
// 模拟下载过程
let stream_info = StreamInfo {
name: name.to_string(),
url: url.to_string(),
title: Some(format!("{} 的直播", name)),
date: Utc::now(),
end_time: None,
live_cover_path: None,
is_download: false,
platform: "unknown".to_string(),
database_row_id: None,
};
Ok(stream_info)
}
async fn check_time_range(&self, name: &str) -> bool {
// TODO: 实现时间范围检查
// 对应Python版本的check_timerange函数
true
}
}
#[async_trait]
impl EventHandler for DownloadHandler {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>> {
if let BiliUpEvent::Download { name, url } = event {
// 设置下载状态
let result = async {
let stream_info = self.perform_download(&name, &url).await?;
Ok::<StreamInfo, anyhow::Error>(stream_info)
}
.await;
// 重置下载状态
let final_status = if self.check_time_range(&name).await {
0
} else {
2
};
match result {
Ok(stream_info) => {
return Ok(Some(BiliUpEvent::Downloaded { stream_info }));
}
Err(e) => {
error!("下载错误: {} - {}", name, e);
}
}
}
Ok(None)
}
fn event_type(&self) -> EventType {
EventType::Download
}
}
/// 下载完成处理器,对应Python的processed
pub struct DownloadedHandler {}
impl Default for DownloadedHandler {
fn default() -> Self {
Self::new()
}
}
impl DownloadedHandler {
pub fn new() -> Self {
Self {}
}
async fn execute_downloaded_processor(&self, stream_info: &StreamInfo) -> Result<()> {
// TODO: 实现下载后处理器执行
info!("Executing downloaded processor for {}", stream_info.name);
Ok(())
}
async fn get_file_list(&self, name: &str) -> Vec<FileInfo> {
// TODO: 实现文件列表获取,对应Python的UploadBase.file_list
vec![]
}
}
#[async_trait]
impl EventHandler for DownloadedHandler {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>> {
if let BiliUpEvent::Downloaded { stream_info } = event {
let name = &stream_info.name;
let url = &stream_info.url;
// 执行下载后处理器
if let Err(e) = self.execute_downloaded_processor(&stream_info).await {
error!("Downloaded processor execution failed: {}", e);
}
// 发送上传事件
return Ok(Some(BiliUpEvent::Upload { stream_info }));
}
Ok(None)
}
fn event_type(&self) -> EventType {
EventType::Downloaded
}
}
/// 上传处理器,对应Python的process_upload
pub struct UploadHandler {
live_streamers_service: DynLiveStreamersService,
}
impl UploadHandler {
pub fn new(live_streamers_service: DynLiveStreamersService) -> Self {
Self {
live_streamers_service,
}
}
async fn get_file_list(&self, name: &str) -> Vec<FileInfo> {
// TODO: 实现文件列表获取
vec![]
}
async fn perform_upload(&self, stream_info: &StreamInfo) -> Result<Vec<FileInfo>> {
// TODO: 实现上传逻辑
info!("Starting upload for {}", stream_info.name);
Ok(vec![])
}
async fn execute_webhook(&self, stream_info: &StreamInfo, error: Option<&str>) -> Result<()> {
// TODO: 实现webhook执行
Ok(())
}
}
#[async_trait]
impl EventHandler for UploadHandler {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>> {
if let BiliUpEvent::Upload { stream_info } = event {
let url = &stream_info.url;
let name = &stream_info.name;
// 检查是否已在上传
// 增加上传计数
let result = async {
let file_list = self.get_file_list(name).await;
if file_list.is_empty() {
debug!("无需上传");
return Ok(vec![]);
}
// 检查上传延迟
// TODO: 实现延迟检测逻辑
info!("开始上传: {}", name);
self.perform_upload(&stream_info).await
}
.await;
// 减少上传计数
match result {
Ok(files) => {
// 执行webhook
if let Err(e) = self.execute_webhook(&stream_info, None).await {
error!("Webhook execution failed: {}", e);
}
if !files.is_empty() {
return Ok(Some(BiliUpEvent::Uploaded { files }));
}
}
Err(e) => {
error!("上传错误: {} - {}", name, e);
// 执行错误webhook
if let Err(webhook_err) = self
.execute_webhook(&stream_info, Some(&e.to_string()))
.await
{
error!("Error webhook execution failed: {}", webhook_err);
}
}
}
}
Ok(None)
}
fn event_type(&self) -> EventType {
EventType::Upload
}
}
/// 上传完成处理器,对应Python的uploaded函数
pub struct UploadedHandler {}
impl Default for UploadedHandler {
fn default() -> Self {
Self::new()
}
}
impl UploadedHandler {
pub fn new() -> Self {
Self {}
}
async fn execute_postprocessor(&self, files: &[FileInfo]) -> Result<()> {
// TODO: 实现后处理器执行逻辑
info!("Executing postprocessor for {} files", files.len());
for file in files {
// 处理每个文件的后处理
self.process_file(&file.video).await?;
if let Some(ref danmaku) = file.danmaku {
self.process_file(danmaku).await?;
}
}
Ok(())
}
async fn process_file(&self, file_path: &str) -> Result<()> {
// TODO: 实现文件后处理(移动、删除、运行命令等)
info!("Processing file: {}", file_path);
Ok(())
}
async fn remove_files(&self, files: &[FileInfo]) -> Result<()> {
for file in files {
if let Err(e) = fs::remove_file(&file.video).await {
warn!("Failed to remove file {}: {}", file.video, e);
} else {
info!("删除 - {}", file.video);
}
if let Some(ref danmaku) = file.danmaku {
if let Err(e) = fs::remove_file(danmaku).await {
warn!("Failed to remove danmaku file {}: {}", danmaku, e);
} else {
info!("删除 - {}", danmaku);
}
}
}
Ok(())
}
}
#[async_trait]
impl EventHandler for UploadedHandler {
async fn handle(&self, event: BiliUpEvent) -> Result<Option<BiliUpEvent>> {
if let BiliUpEvent::Uploaded { files } = event {
// 执行后处理器
if let Err(e) = self.execute_postprocessor(&files).await {
error!("Postprocessor execution failed: {}", e);
}
// TODO: 根据配置决定是否删除文件
// if let Err(e) = self.remove_files(&files).await {
// error!("Failed to remove files: {}", e);
// }
}
Ok(None)
}
fn event_type(&self) -> EventType {
EventType::Uploaded
}
}
/// 处理器注册器,用于注册所有处理器
pub struct HandlerRegistry;
impl HandlerRegistry {
pub async fn register_all_handlers(
live_streamers_service: DynLiveStreamersService,
) -> Result<()> {
// 注册所有处理器
info!("All event handlers registered successfully");
Ok(())
}
}
@@ -1,3 +0,0 @@
pub mod connection_pool;
pub mod repositories;
pub mod service_register;
@@ -1,7 +1,8 @@
use anyhow::{Context, Result};
use ormlite::model::*;
use crate::server::errors::{AppError, AppResult};
use error_stack::ResultExt;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
use std::path::Path;
use tracing::info;
pub type ConnectionPool = Pool<Sqlite>;
@@ -9,18 +10,27 @@ pub type ConnectionPool = Pool<Sqlite>;
pub struct ConnectionManager;
impl ConnectionManager {
pub async fn new_pool(path: &str) -> Result<ConnectionPool> {
pub async fn new_pool(path: &str) -> AppResult<ConnectionPool> {
// 创建所有父级目录(如果不存在)
if let Some(parent) = Path::new(path).parent() {
std::fs::create_dir_all(parent)
.change_context(AppError::Unknown)
.attach_lazy(|| path.to_string())?; // 创建 data/ 目录
}
let db_url = format!("sqlite://{path}");
/// Start by making a database connection.
std::fs::OpenOptions::new()
.write(true)
.create(true)
.open(path)?;
.open(path)
.change_context(AppError::Unknown)?;
let pool = SqlitePoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
.context("error while initializing the database connection pool")?;
.change_context(AppError::Custom(
"error while initializing the database connection pool".to_string(),
))?;
/// Query builder syntax closely follows SQL syntax, translated into chained function calls.
@@ -28,7 +38,9 @@ impl ConnectionManager {
sqlx::migrate!()
.run(&pool)
.await
.context("error while running database migrations")?;
.change_context(AppError::Custom(
"error while running database migrations".to_string(),
))?;
Ok(pool)
}
@@ -28,7 +28,7 @@ impl Context {
#[derive(Debug)]
pub struct Worker {
pub(crate) id: i64,
pub id: i64,
pub url: String,
pub context: Context,
pub downloader_status: RwLock<WorkerStatus>,
@@ -3,5 +3,5 @@ pub mod context;
pub mod dto;
pub mod models;
pub mod repositories;
pub(crate) mod service_register;
pub mod service_register;
pub mod users;
@@ -105,7 +105,10 @@ impl HookStep {
}
/// 处理所有后处理器
pub async fn process_video(video_path: &Path, processors: &[HookStep]) -> Result<(), Box<dyn std::error::Error>> {
pub async fn process_video(
video_path: &Path,
processors: &[HookStep],
) -> Result<(), Box<dyn std::error::Error>> {
for processor in processors {
processor.execute(video_path).await?;
}
@@ -1,7 +1,67 @@
pub mod download_records_repository;
pub mod live_streamers_repository;
pub mod models;
pub mod upload_records_repository;
pub mod upload_streamers_repository;
pub mod users_repository;
pub mod videos_repository;
use crate::server::config::Config;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::models::{
Configuration, InsertConfiguration, LiveStreamer, UploadStreamer,
};
use error_stack::ResultExt;
use ormlite::{Insert, Model};
pub async fn get_streamer(pool: &ConnectionPool, id: i64) -> AppResult<LiveStreamer> {
LiveStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_one(pool)
.await
.change_context(AppError::Unknown)
}
pub async fn del_streamer(pool: &ConnectionPool, id: i64) -> AppResult<LiveStreamer> {
let streamer = get_streamer(pool, id).await?;
streamer
.clone()
.delete(pool)
.await
.change_context(AppError::Unknown)?;
Ok(streamer)
}
pub async fn get_all_streamer(pool: &ConnectionPool) -> AppResult<Vec<LiveStreamer>> {
LiveStreamer::select()
.fetch_all(pool)
.await
.change_context(AppError::Unknown)
}
pub async fn get_config(pool: &ConnectionPool) -> AppResult<Config> {
let configuration = Configuration::select()
.where_("key = 'config'")
.fetch_optional(pool)
.await
.change_context(AppError::Unknown)?;
if let Some(configuration) = configuration {
let json: Config =
serde_json::from_str(&configuration.value).change_context(AppError::Unknown)?;
Ok(json)
} else {
let config = Config::builder().streamers(Default::default()).build();
Ok(config)
}
}
pub async fn insert_config(pool: &ConnectionPool, config: &Config) -> AppResult<Configuration> {
let configuration = InsertConfiguration {
key: "config".to_string(),
value: serde_json::to_string(config).unwrap(),
}
.insert(pool)
.await
.change_context(AppError::Unknown)?;
Ok(configuration)
}
pub async fn get_all_uploader(pool: &ConnectionPool) -> AppResult<Vec<UploadStreamer>> {
UploadStreamer::select()
.fetch_all(pool)
.await
.change_context(AppError::Unknown)
}
@@ -1,25 +0,0 @@
use crate::server::core::live_streamers::{DownloadRecords, DownloadRecordsRepository};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use async_trait::async_trait;
pub struct SqliteDownloadRecordsRepository {
pool: ConnectionPool,
}
impl SqliteDownloadRecordsRepository {
pub fn new(pool: ConnectionPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl DownloadRecordsRepository for SqliteDownloadRecordsRepository {
async fn create(&self, entity: DownloadRecords) -> anyhow::Result<DownloadRecords> {
todo!()
}
async fn get_all(&self) -> anyhow::Result<Vec<DownloadRecords>> {
todo!()
}
}
@@ -1,12 +0,0 @@
use crate::server::infrastructure::connection_pool::ConnectionPool;
#[derive(Clone)]
pub struct SqliteLiveStreamersRepository {
pool: ConnectionPool,
}
impl SqliteLiveStreamersRepository {
pub fn new(pool: ConnectionPool) -> Self {
Self { pool }
}
}
@@ -1,79 +0,0 @@
use chrono::NaiveDateTime;
use ormlite::Model;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Model, Debug, Clone, Serialize, Deserialize)]
#[ormlite(table = "uploadstreamers")]
pub struct UploadStreamer {
pub id: i64,
pub template_name: String,
pub title: Option<String>,
pub tid: Option<u16>,
pub copyright: Option<u8>,
pub copyright_source: Option<String>,
pub cover_path: Option<String>,
pub description: Option<String>,
pub dynamic: Option<String>,
pub dtime: Option<u32>,
pub dolby: Option<u8>,
pub hires: Option<u8>,
pub charging_pay: Option<u8>,
pub no_reprint: Option<u8>,
pub uploader: Option<String>,
pub user_cookie: Option<String>,
pub tags: Value, // not null
pub credits: Option<Value>,
pub up_selection_reply: Option<bool>,
pub up_close_reply: Option<bool>,
pub up_close_danmu: Option<bool>,
pub extra_fields: Option<String>,
pub is_only_self: Option<i64>,
}
#[derive(Model, Debug, Clone, Serialize, Deserialize)]
#[ormlite(table = "streamerinfo")]
pub struct StreamerInfo {
pub id: i64,
pub name: String,
pub url: String,
pub title: String,
pub date: NaiveDateTime,
pub live_cover_path: String,
}
#[derive(Model, Debug, Clone, Serialize, Deserialize)]
#[ormlite(table = "livestreamers")]
pub struct LiveStreamer {
pub id: i64,
pub url: String,
pub remark: String,
pub filename_prefix: Option<String>,
pub time_range: Option<String>,
pub upload_streamers_id: Option<i64>, // FK,可空
pub format: Option<String>,
#[ormlite(column = "override")]
pub override_: Option<Value>, // "override" 为关键字,字段名避让
pub preprocessor: Option<Value>,
pub segment_processor: Option<Value>,
pub downloaded_processor: Option<Value>,
pub postprocessor: Option<Value>,
pub opt_args: Option<Value>,
pub excluded_keywords: Option<Value>,
}
#[derive(Model, Debug, Clone, Serialize, Deserialize)]
#[ormlite(table = "filelist")]
pub struct FileItem {
pub id: i64,
pub file: String,
pub streamer_info_id: i64, // FK not null
}
#[derive(Model, Debug, Clone, Serialize, Deserialize)]
#[ormlite(table = "configuration")]
pub struct Configuration {
pub id: i64,
pub key: String,
pub value: String, // TEXT
}
@@ -1,34 +0,0 @@
use crate::server::core::upload_streamers::{UploadRecords, UploadRecordsRepository};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use async_trait::async_trait;
#[derive(Clone)]
pub struct SqliteUploadRecordsRepository {
pool: ConnectionPool,
}
impl SqliteUploadRecordsRepository {
pub fn new(pool: ConnectionPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UploadRecordsRepository for SqliteUploadRecordsRepository {
async fn create(&self, _entity: UploadRecords) -> anyhow::Result<UploadRecords> {
todo!()
}
async fn delete(&self, _id: i64) -> anyhow::Result<()> {
todo!()
}
async fn get_all(&self) -> anyhow::Result<Vec<UploadRecords>> {
todo!()
}
async fn get_by_id(&self, _id: i64) -> anyhow::Result<UploadRecords> {
todo!()
}
}
@@ -1,12 +0,0 @@
use crate::server::infrastructure::connection_pool::ConnectionPool;
#[derive(Clone)]
pub struct SqliteUploadStreamersRepository {
pool: ConnectionPool,
}
impl SqliteUploadStreamersRepository {
pub fn new(pool: ConnectionPool) -> Self {
Self { pool }
}
}
@@ -1,12 +0,0 @@
use crate::server::infrastructure::connection_pool::ConnectionPool;
#[derive(Clone)]
pub struct SqliteUsersStreamersRepository {
pool: ConnectionPool,
}
impl SqliteUsersStreamersRepository {
pub fn new(pool: ConnectionPool) -> Self {
Self { pool }
}
}
@@ -1,30 +0,0 @@
use crate::server::core::live_streamers::{Videos, VideosRepository};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use async_trait::async_trait;
#[derive(Clone)]
pub struct SqliteVideosRepository {
pool: ConnectionPool,
}
impl SqliteVideosRepository {
pub fn new(pool: ConnectionPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl VideosRepository for SqliteVideosRepository {
async fn create(&self, entity: Videos) -> anyhow::Result<Videos> {
todo!()
}
async fn update(&self, entity: Videos) -> anyhow::Result<Videos> {
todo!()
}
async fn get_by_id(&self, id: i64) -> anyhow::Result<Videos> {
todo!()
}
}
@@ -1,51 +1,120 @@
use crate::server::core::live_streamers::{DynDownloadRecordsRepository, DynVideosRepository};
use crate::server::core::upload_streamers::DynUploadRecordsRepository;
use crate::server::config;
use crate::server::config::Config;
use crate::server::core::download_manager::{ActorHandle, DownloadManager};
use crate::server::core::monitor::Monitor;
use crate::server::core::plugin;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::repositories::download_records_repository::SqliteDownloadRecordsRepository;
use crate::server::infrastructure::repositories::upload_records_repository::SqliteUploadRecordsRepository;
use crate::server::infrastructure::repositories::videos_repository::SqliteVideosRepository;
use crate::server::infrastructure::context::{Context, Worker};
use crate::server::infrastructure::repositories;
use axum::extract::FromRef;
use std::sync::Arc;
use biliup::client::StatelessClient;
use biliup::credential::Credential;
use error_stack::bail;
use ormlite::Model;
use std::sync::{Arc, RwLock};
use tracing::info;
#[derive(Clone)]
#[derive(FromRef, Clone)]
pub struct ServiceRegister {
pub pool: ConnectionPool,
pub upload_records_repository: DynUploadRecordsRepository,
pub videos_repository: DynVideosRepository,
pub download_records_repository: DynDownloadRecordsRepository,
pub workers: Arc<RwLock<Vec<Arc<Worker>>>>,
pub managers: Arc<Vec<DownloadManager>>,
pub config: Arc<RwLock<Config>>,
pub actor_handle: Arc<ActorHandle>,
pub client: StatelessClient,
}
/// A simple service container responsible for managing the various services our API endpoints will pull from through axum extensions.
impl ServiceRegister {
pub fn new(pool: ConnectionPool) -> Self {
pub fn new(
pool: ConnectionPool,
config: Arc<RwLock<Config>>,
actor_handle: Arc<ActorHandle>,
download_manager: Vec<DownloadManager>,
) -> Self {
info!("initializing utility services...");
let client = StatelessClient::default();
info!(config=?config);
info!("utility services initialized, building feature services...");
let upload_records_repository = Arc::new(SqliteUploadRecordsRepository::new(pool.clone()))
as DynUploadRecordsRepository;
let videos_repository =
Arc::new(SqliteVideosRepository::new(pool.clone())) as DynVideosRepository;
let download_records_repository =
Arc::new(SqliteDownloadRecordsRepository::new(pool.clone()))
as DynDownloadRecordsRepository;
info!("feature services successfully initialized!");
ServiceRegister {
pool,
upload_records_repository,
videos_repository,
download_records_repository,
workers: Arc::new(Default::default()),
managers: Arc::new(download_manager),
config: config.clone(),
actor_handle,
client,
}
}
pub fn get_manager(&self, url: &str) -> Option<&DownloadManager> {
self.managers
.iter()
.find(|&manager| manager.matches(url))
.map(|v| v as _)
}
pub async fn add_room(
&self,
id: i64,
url: &str,
monitor: Arc<Monitor>,
) -> AppResult<Option<()>> {
let worker = Arc::new(Worker::new(
id,
url,
Context::new(self.pool.clone(), self.client.clone()),
));
monitor.rooms_handle.add(worker.clone()).await;
self.workers.write().unwrap().push(worker);
info!("add {url} success");
Ok(Some(()))
}
pub async fn del_room(&self, id: i64) -> AppResult<()> {
let Some(i) = self.workers.read().unwrap().iter().position(|x| x.id == id) else {
return Err(error_stack::Report::new(AppError::Unknown));
};
let removed = self.workers.write().unwrap().swap_remove(i);
let url = &removed.url;
let Some(manager) = self.get_manager(url) else {
info!("not found url: {url}");
bail!(AppError::Unknown)
};
let monitor = manager.ensure_monitor();
let len = monitor.rooms_handle.del(id).await;
info!("{id} removed, remained len {len}");
if len == 0 {
*manager.monitor.lock().unwrap() = None;
}
Ok(())
}
}
impl FromRef<ServiceRegister> for ConnectionPool {
fn from_ref(app_state: &ServiceRegister) -> ConnectionPool {
app_state.pool.clone()
}
}
// impl FromRef<ServiceRegister> for ConnectionPool {
// fn from_ref(app_state: &ServiceRegister) -> ConnectionPool {
// app_state.pool.clone()
// }
// }
// pub(crate) async fn add_room(&self, id: i64, pool: ConnectionPool) -> Arc<Worker> {
// let arc = self.ensure_monitor().rooms_handle.clone();
// let worker = Arc::new(Worker::new(id, pool, arc.clone()));
// arc.add(worker.clone()).await;
// worker
// }
//
// pub async fn del_room(&self, id: i64) {
// let len = self.ensure_monitor().rooms_handle.del(id).await;
// info!("{id} removed, remained len {len}");
// if len == 0 {
// *self.monitor.lock().unwrap() = None;
// }
// }
@@ -72,7 +72,7 @@ impl Backend {
Ok(user.is_some())
}
pub(crate) async fn create_user(&mut self, creds: Credentials) -> AppResult<User> {
pub async fn create_user(&mut self, creds: Credentials) -> AppResult<User> {
// create the new user account...
// 验证输入
if creds.username.is_empty() || creds.password.is_empty() {
-181
View File
@@ -1,181 +0,0 @@
use anyhow::Result;
use tokio::task::JoinHandle;
use tracing::{error, info};
use crate::server::{
core::live_streamers::DynLiveStreamersService, plugin_registry::initialize_plugins,
};
/// 集成管理器,负责协调所有组件
pub struct IntegrationManager {
event_loop_handle: Option<JoinHandle<()>>,
}
impl IntegrationManager {
pub fn new() -> Self {
Self {
event_loop_handle: None,
}
}
/// 初始化整个系统,对应Python版本的启动流程
pub async fn initialize(&mut self) -> Result<()> {
info!("Initializing BiliUp Rust server...");
// 1. 初始化配置管理器
self.initialize_config().await?;
// 2. 初始化插件注册表
initialize_plugins().await?;
// 7. 初始化直播源监控
info!("BiliUp Rust server initialized successfully");
Ok(())
}
/// 初始化配置管理器
async fn initialize_config(&self) -> Result<()> {
info!("Loading configuration...");
// 尝试加载配置文件
// 尝试加载cookies文件
info!("Configuration loaded");
Ok(())
}
/// 启动事件循环
async fn start_event_loop(&mut self) -> Result<()> {
info!("Starting event loop...");
info!("Event loop started");
Ok(())
}
/// 初始化直播源监控
async fn initialize_live_monitoring(
&self,
live_streamers_service: DynLiveStreamersService,
) -> Result<()> {
info!("Starting live stream monitoring...");
// 从配置中获取直播源列表
info!("Live stream monitoring initialized");
Ok(())
}
/// 为单个直播源启动监控
async fn start_streamer_monitoring(
&self,
name: &str,
config: &crate::server::config_manager::StreamerConfig,
plugin_name: &str,
) -> Result<()> {
// 创建监控任务
let name = name.to_string();
let url = config.url.clone();
let plugin_name = plugin_name.to_string();
tokio::spawn(async move {
loop {
// 检查流状态
match Self::check_single_stream(&name, &url, &plugin_name).await {
Ok(true) => {
info!("Stream {} is online, starting download", name);
// 发送预下载事件
}
Ok(false) => {
// 流不在线,继续监控
}
Err(e) => {
error!("Failed to check stream {}: {}", name, e);
}
}
// 等待下一次检查(从配置获取间隔)
let interval = std::time::Duration::from_secs(30); // TODO: 从配置读取
tokio::time::sleep(interval).await;
}
});
Ok(())
}
/// 检查单个流的状态
async fn check_single_stream(name: &str, url: &str, plugin_name: &str) -> Result<bool> {
// TODO: 使用插件注册表中的插件检查流状态
// 这里需要实际调用对应插件的check_stream方法
// 模拟检查
Ok(false)
}
/// 停止所有服务
pub async fn shutdown(&mut self) -> Result<()> {
info!("Shutting down BiliUp server...");
// 停止事件循环
if let Some(handle) = self.event_loop_handle.take() {
handle.abort();
info!("Event loop stopped");
}
// TODO: 停止其他服务
info!("BiliUp server shutdown complete");
Ok(())
}
/// 重新加载配置
pub async fn reload_config(&self) -> Result<()> {
info!("Reloading configuration...");
info!("Configuration reloaded");
Ok(())
}
}
impl Default for IntegrationManager {
fn default() -> Self {
Self::new()
}
}
/// 系统状态结构
#[derive(Debug, serde::Serialize)]
pub struct SystemStatus {
pub running: bool,
pub streamers_count: usize,
pub downloaders_count: usize,
pub uploaders_count: usize,
pub event_loop_running: bool,
}
/// 全局集成管理器实例
/// 便捷函数:初始化整个系统
/// 便捷函数:获取系统状态
/// 便捷函数:关闭系统
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_integration_manager_initialization() {
let mut manager = IntegrationManager::new();
// 注意:这个测试可能需要数据库环境
// 在实际测试中,应该使用测试数据库或模拟
match manager.initialize().await {
Ok(_) => println!("Integration manager initialized successfully"),
Err(e) => println!("Integration manager initialization failed: {}", e),
}
}
}
-19
View File
@@ -1,19 +0,0 @@
pub mod errors;
pub mod api {
pub mod bilibili_endpoints;
pub mod endpoints;
pub mod router;
pub mod spa;
}
pub mod core;
pub mod infrastructure;
// 新增的模块
pub mod config_manager;
pub mod event_manager;
pub mod handlers;
pub mod integration;
pub mod plugin_registry;
@@ -1,388 +0,0 @@
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::server::event_manager::StreamInfo;
/// 插件类型枚举
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
pub enum PluginType {
Downloader,
Uploader,
}
/// 插件信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginInfo {
pub name: String,
pub plugin_type: PluginType,
pub valid_url_pattern: String,
pub priority: i32,
}
/// 下载器插件trait,对应Python版本的DownloadBase
#[async_trait]
pub trait DownloaderPlugin: Send + Sync {
fn name(&self) -> &str;
fn valid_url_pattern(&self) -> &str;
fn priority(&self) -> i32 {
0
}
/// 检查流状态,对应Python的acheck_stream
async fn check_stream(&self, url: &str, is_check: bool) -> Result<bool>;
/// 获取流信息
async fn get_stream_info(&self, url: &str) -> Result<Option<StreamInfo>>;
/// 开始下载,对应Python的start方法
async fn start_download(
&self,
name: &str,
url: &str,
kwargs: HashMap<String, String>,
) -> Result<StreamInfo>;
/// 检查是否应该录制,对应Python的should_record
fn should_record(&self, stream_info: &StreamInfo) -> bool {
// TODO: 实现关键词和时间范围检查
true
}
}
/// 上传器插件trait,对应Python版本的UploadBase
#[async_trait]
pub trait UploaderPlugin: Send + Sync {
fn name(&self) -> &str;
fn priority(&self) -> i32 {
0
}
/// 上传文件列表
async fn upload(&self, files: Vec<String>, stream_info: &StreamInfo) -> Result<Vec<String>>;
/// 获取上传配置
fn get_upload_config(&self) -> HashMap<String, String> {
HashMap::new()
}
}
/// Python插件包装器,用于调用Python插件
#[cfg(feature = "python-bridge")]
pub struct PythonPluginWrapper {
name: String,
plugin_type: PluginType,
module_path: String,
class_name: String,
// Python对象引用将在实际实现时添加
}
#[cfg(feature = "python-bridge")]
impl PythonPluginWrapper {
pub fn new(
name: String,
plugin_type: PluginType,
module_path: String,
class_name: String,
) -> Self {
Self {
name,
plugin_type,
module_path,
class_name,
}
}
}
#[cfg(feature = "python-bridge")]
#[async_trait]
impl DownloaderPlugin for PythonPluginWrapper {
fn name(&self) -> &str {
&self.name
}
fn valid_url_pattern(&self) -> &str {
// TODO: 从Python模块获取VALID_URL_BASE
""
}
async fn check_stream(&self, url: &str, is_check: bool) -> Result<bool> {
// TODO: 使用PyO3调用Python插件的acheck_stream方法
Ok(false)
}
async fn get_stream_info(&self, url: &str) -> Result<Option<StreamInfo>> {
// TODO: 从Python插件获取流信息
Ok(None)
}
async fn start_download(
&self,
name: &str,
url: &str,
kwargs: HashMap<String, String>,
) -> Result<StreamInfo> {
// TODO: 调用Python插件的start方法
Err(anyhow::anyhow!("Not implemented"))
}
}
/// 插件注册表
pub struct PluginRegistry {
downloaders: Arc<RwLock<HashMap<String, Box<dyn DownloaderPlugin>>>>,
uploaders: Arc<RwLock<HashMap<String, Box<dyn UploaderPlugin>>>>,
url_patterns: Arc<RwLock<Vec<(regex::Regex, String)>>>, // 正则表达式和插件名的映射
}
impl PluginRegistry {
pub fn new() -> Self {
Self {
downloaders: Arc::new(RwLock::new(HashMap::new())),
uploaders: Arc::new(RwLock::new(HashMap::new())),
url_patterns: Arc::new(RwLock::new(Vec::new())),
}
}
/// 注册下载器插件
pub async fn register_downloader(&self, plugin: Box<dyn DownloaderPlugin>) -> Result<()> {
let name = plugin.name().to_string();
let pattern = plugin.valid_url_pattern().to_string();
info!("Registering downloader plugin: {}", name);
// 编译正则表达式
if let Ok(regex) = regex::Regex::new(&pattern) {
let mut patterns = self.url_patterns.write().await;
patterns.push((regex, name.clone()));
} else {
warn!("Invalid URL pattern for plugin {}: {}", name, pattern);
}
let mut downloaders = self.downloaders.write().await;
downloaders.insert(name, plugin);
Ok(())
}
/// 注册上传器插件
pub async fn register_uploader(&self, plugin: Box<dyn UploaderPlugin>) -> Result<()> {
let name = plugin.name().to_string();
info!("Registering uploader plugin: {}", name);
let mut uploaders = self.uploaders.write().await;
uploaders.insert(name, plugin);
Ok(())
}
/// 根据URL找到对应的下载器插件
pub async fn find_downloader(&self, url: &str) -> Option<String> {
let patterns = self.url_patterns.read().await;
for (regex, plugin_name) in patterns.iter() {
if regex.is_match(url) {
debug!("Found downloader plugin {} for URL: {}", plugin_name, url);
return Some(plugin_name.clone());
}
}
None
}
/// 获取下载器插件
pub async fn get_downloader(
&self,
name: &str,
) -> Option<tokio::sync::RwLockReadGuard<HashMap<String, Box<dyn DownloaderPlugin>>>> {
let downloaders = self.downloaders.read().await;
if downloaders.contains_key(name) {
Some(downloaders)
} else {
None
}
}
/// 获取上传器插件
pub async fn get_uploader(
&self,
name: &str,
) -> Option<tokio::sync::RwLockReadGuard<HashMap<String, Box<dyn UploaderPlugin>>>> {
let uploaders = self.uploaders.read().await;
if uploaders.contains_key(name) {
Some(uploaders)
} else {
None
}
}
/// 列出所有插件
pub async fn list_plugins(&self) -> (Vec<String>, Vec<String>) {
let downloaders = self.downloaders.read().await;
let uploaders = self.uploaders.read().await;
(
downloaders.keys().cloned().collect(),
uploaders.keys().cloned().collect(),
)
}
/// 从Python插件目录加载插件
#[cfg(feature = "python-bridge")]
pub async fn load_python_plugins(&self, plugin_dir: &str) -> Result<()> {
use std::fs;
use std::path::Path;
info!("Loading Python plugins from: {}", plugin_dir);
let plugin_path = Path::new(plugin_dir);
if !plugin_path.exists() {
return Err(anyhow::anyhow!(
"Plugin directory does not exist: {}",
plugin_dir
));
}
// 扫描Python文件
for entry in fs::read_dir(plugin_path)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("py") {
if let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) {
// 跳过特殊文件
if file_name.starts_with("__") {
continue;
}
// TODO: 实际的Python插件加载逻辑
info!("Found Python plugin file: {}", file_name);
// 创建Python插件包装器
let plugin = PythonPluginWrapper::new(
file_name.to_string(),
PluginType::Downloader,
format!("biliup.plugins.{}", file_name),
"Plugin".to_string(),
);
// self.register_downloader(Box::new(plugin)).await?;
}
}
}
Ok(())
}
/// 检查所有URL的流状态,对应Python的batch_check
pub async fn batch_check(&self, urls: &[String]) -> Result<Vec<String>> {
let mut online_urls = Vec::new();
for url in urls {
if let Some(plugin_name) = self.find_downloader(url).await {
let downloaders = self.downloaders.read().await;
if let Some(plugin) = downloaders.get(&plugin_name) {
match plugin.check_stream(url, true).await {
Ok(true) => {
debug!("Stream online: {}", url);
online_urls.push(url.clone());
}
Ok(false) => {
debug!("Stream offline: {}", url);
}
Err(e) => {
warn!("Failed to check stream {}: {}", url, e);
}
}
}
}
}
Ok(online_urls)
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
// 全局插件注册表实例
/// 插件初始化函数,对应Python版本的插件发现机制
pub async fn initialize_plugins() -> Result<()> {
// TODO: 注册内置的Rust插件
info!("Registering built-in plugins...");
// TODO: 加载Python插件(如果启用了python-bridge特性)
#[cfg(feature = "python-bridge")]
{
info!("Loading Python plugins...");
let python_plugin_dir =
std::env::var("BILIUP_PLUGIN_DIR").unwrap_or_else(|_| "biliup/plugins".to_string());
if let Err(e) = registry.load_python_plugins(&python_plugin_dir).await {
warn!("Failed to load Python plugins: {}", e);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDownloader {
name: String,
}
#[async_trait]
impl DownloaderPlugin for TestDownloader {
fn name(&self) -> &str {
&self.name
}
fn valid_url_pattern(&self) -> &str {
r"https://test\.com/.*"
}
async fn check_stream(&self, _url: &str, _is_check: bool) -> Result<bool> {
Ok(true)
}
async fn get_stream_info(&self, _url: &str) -> Result<Option<StreamInfo>> {
Ok(None)
}
async fn start_download(
&self,
_name: &str,
_url: &str,
_kwargs: HashMap<String, String>,
) -> Result<StreamInfo> {
Err(anyhow::anyhow!("Test plugin"))
}
}
#[tokio::test]
async fn test_plugin_registry() {
let registry = PluginRegistry::new();
let plugin = Box::new(TestDownloader {
name: "test".to_string(),
});
registry.register_downloader(plugin).await.unwrap();
let plugin_name = registry.find_downloader("https://test.com/stream").await;
assert_eq!(plugin_name, Some("test".to_string()));
let (downloaders, uploaders) = registry.list_plugins().await;
assert_eq!(downloaders.len(), 1);
assert_eq!(uploaders.len(), 0);
}
}
File renamed without changes.
+149 -71
View File
@@ -1,5 +1,4 @@
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};
@@ -7,11 +6,13 @@ 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 biliup_cli::server::errors::{AppError, AppResult};
use bytes::{Buf, Bytes};
use clap::ValueEnum;
use dialoguer::Input;
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use error_stack::ResultExt;
use futures::{Stream, StreamExt};
use image::Luma;
use indicatif::{ProgressBar, ProgressStyle};
@@ -26,7 +27,7 @@ use std::task::Poll;
use std::time::Instant;
use tracing::{info, warn};
pub async fn login(user_cookie: PathBuf, proxy: Option<&str>) -> Result<()> {
pub async fn login(user_cookie: PathBuf, proxy: Option<&str>) -> AppResult<()> {
let client = Credential::new(proxy);
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("选择一种登录方式")
@@ -37,7 +38,8 @@ pub async fn login(user_cookie: PathBuf, proxy: Option<&str>) -> Result<()> {
.item("浏览器登录")
.item("网页Cookie登录1")
.item("网页Cookie登录2")
.interact()?;
.interact()
.change_context_lazy(|| AppError::Unknown)?;
let info = match selection {
0 => login_by_password(client).await?,
1 => login_by_sms(client).await?,
@@ -47,20 +49,25 @@ pub async fn login(user_cookie: PathBuf, proxy: Option<&str>) -> Result<()> {
5 => login_by_webqr_cookies(client).await?,
_ => panic!(),
};
let file = std::fs::File::create(user_cookie)?;
serde_json::to_writer_pretty(&file, &info)?;
let file = std::fs::File::create(user_cookie).change_context_lazy(|| AppError::Unknown)?;
serde_json::to_writer_pretty(&file, &info).change_context_lazy(|| AppError::Unknown)?;
info!("登录成功,数据保存在{:?}", file);
Ok(())
}
pub async fn renew(user_cookie: PathBuf, proxy: Option<&str>) -> Result<()> {
pub async fn renew(user_cookie: PathBuf, proxy: Option<&str>) -> AppResult<()> {
let client = Credential::new(proxy);
let mut file = fopen_rw(user_cookie)?;
let login_info: LoginInfo = serde_json::from_reader(&file)?;
let new_info = client.renew_tokens(login_info).await?;
file.rewind()?;
file.set_len(0)?;
serde_json::to_writer_pretty(std::io::BufWriter::new(&file), &new_info)?;
let login_info: LoginInfo =
serde_json::from_reader(&file).change_context_lazy(|| AppError::Unknown)?;
let new_info = client
.renew_tokens(login_info)
.await
.change_context_lazy(|| AppError::Unknown)?;
file.rewind().change_context_lazy(|| AppError::Unknown)?;
file.set_len(0).change_context_lazy(|| AppError::Unknown)?;
serde_json::to_writer_pretty(std::io::BufWriter::new(&file), &new_info)
.change_context_lazy(|| AppError::Unknown)?;
info!("{new_info:?}");
Ok(())
}
@@ -73,7 +80,7 @@ pub async fn upload_by_command(
limit: usize,
submit: SubmitOption,
proxy: Option<&str>,
) -> Result<()> {
) -> AppResult<()> {
let bili = login_by_cookies(user_cookie, proxy).await?;
if studio.title.is_empty() {
studio.title = video_path[0]
@@ -86,8 +93,14 @@ pub async fn upload_by_command(
studio.videos = upload(&video_path, &bili, line, limit).await?;
match submit {
SubmitOption::BCutAndroid => bili.submit_by_bcut_android(&studio, proxy).await?,
_ => bili.submit_by_app(&studio, proxy).await?,
SubmitOption::BCutAndroid => bili
.submit_by_bcut_android(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
_ => bili
.submit_by_app(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
};
Ok(())
@@ -98,13 +111,16 @@ pub async fn upload_by_config(
user_cookie: PathBuf,
submit_override: Option<SubmitOption>,
proxy: Option<&str>,
) -> Result<()> {
) -> AppResult<()> {
// println!("number of concurrent futures: {limit}");
let bilibili = login_by_cookies(user_cookie, proxy).await?;
let config = load_config(&config)?;
let config = load_config(&config).change_context_lazy(|| AppError::Unknown)?;
for (filename_patterns, mut studio) in config.streamers {
let mut paths = Vec::new();
for entry in glob::glob(&filename_patterns)?.filter_map(Result::ok) {
for entry in glob::glob(&filename_patterns)
.change_context_lazy(|| AppError::Unknown)?
.filter_map(Result::ok)
{
paths.push(entry);
}
if paths.is_empty() {
@@ -126,8 +142,14 @@ pub async fn upload_by_config(
// 命令行参数优先,如果没有提供则使用配置文件中的设置
let submit_option = submit_override.clone().unwrap_or(config.submit.clone());
match submit_option {
SubmitOption::BCutAndroid => bilibili.submit_by_bcut_android(&studio, proxy).await?,
_ => bilibili.submit_by_app(&studio, proxy).await?,
SubmitOption::BCutAndroid => bilibili
.submit_by_bcut_android(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
_ => bilibili
.submit_by_app(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
};
}
Ok(())
@@ -141,23 +163,38 @@ pub async fn append(
limit: usize,
submit: SubmitOption,
proxy: Option<&str>,
) -> Result<()> {
) -> AppResult<()> {
let bilibili = login_by_cookies(user_cookie, proxy).await?;
let mut uploaded_videos = upload(&video_path, &bilibili, line, limit).await?;
let mut studio = bilibili.studio_data(&vid, proxy).await?;
let mut studio = bilibili
.studio_data(&vid, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?;
studio.videos.append(&mut uploaded_videos);
match submit {
SubmitOption::App => bilibili.edit_by_app(&studio, proxy).await?,
_ => bilibili.edit_by_web(&studio).await?,
SubmitOption::App => bilibili
.edit_by_app(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
_ => bilibili
.edit_by_web(&studio)
.await
.change_context_lazy(|| AppError::Unknown)?,
};
// studio.edit(&login_info).await?;
Ok(())
}
pub async fn show(user_cookie: PathBuf, vid: Vid, proxy: Option<&str>) -> Result<()> {
pub async fn show(user_cookie: PathBuf, vid: Vid, proxy: Option<&str>) -> AppResult<()> {
let bilibili = login_by_cookies(user_cookie, proxy).await?;
let video_info = bilibili.video_data(&vid, proxy).await?;
println!("{}", serde_json::to_string_pretty(&video_info)?);
let video_info = bilibili
.video_data(&vid, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?;
println!(
"{}",
serde_json::to_string_pretty(&video_info).change_context_lazy(|| AppError::Unknown)?
);
Ok(())
}
@@ -169,7 +206,7 @@ pub async fn list(
proxy: Option<&str>,
from_page: u32,
max_pages: Option<u32>,
) -> Result<()> {
) -> AppResult<()> {
let status = match (is_pubing, pubed, not_pubed) {
(true, false, false) => "is_pubing",
(false, true, false) => "pubed",
@@ -184,23 +221,26 @@ pub async fn list(
let bilibili = login_by_cookies(user_cookie, proxy).await?;
bilibili
.recent_archives(status, from_page, max_pages)
.await?
.await
.change_context_lazy(|| AppError::Unknown)?
.iter()
.for_each(|arc| println!("{}", arc.to_string_pretty()));
Ok(())
}
async fn login_by_cookies(user_cookie: PathBuf, proxy: Option<&str>) -> Result<BiliBili> {
async fn login_by_cookies(user_cookie: PathBuf, proxy: Option<&str>) -> AppResult<BiliBili> {
let result = credential::login_by_cookies(&user_cookie, proxy).await;
Ok(match result {
Err(Kind::IO(_)) => result.with_context(|| {
String::from("open cookies file: ") + &user_cookie.to_string_lossy()
Err(Kind::IO(_)) => result.change_context_lazy(|| {
AppError::Custom(String::from("open cookies file: ") + &user_cookie.to_string_lossy())
})?,
_ => {
let bili = result?;
let bili = result.change_context_lazy(|| AppError::Unknown)?;
info!(
"user: {}",
bili.my_info().await?["data"]["name"]
bili.my_info()
.await
.change_context_lazy(|| AppError::Unknown)?["data"]["name"]
.as_str()
.unwrap_or_default()
);
@@ -209,14 +249,15 @@ async fn login_by_cookies(user_cookie: PathBuf, proxy: Option<&str>) -> Result<B
})
}
pub async fn cover_up(studio: &mut Studio, bili: &BiliBili) -> Result<()> {
pub async fn cover_up(studio: &mut Studio, bili: &BiliBili) -> AppResult<()> {
if !studio.cover.is_empty() {
let url = bili
.cover_up(
&std::fs::read(Path::new(&studio.cover))
.with_context(|| format!("cover: {}", studio.cover))?,
.change_context_lazy(|| AppError::Custom(format!("cover: {}", studio.cover)))?,
)
.await?;
.await
.change_context_lazy(|| AppError::Unknown)?;
info!("{url}");
studio.cover = url;
}
@@ -228,7 +269,7 @@ pub async fn upload(
bili: &BiliBili,
line: Option<UploadLine>,
limit: usize,
) -> Result<Vec<Video>> {
) -> AppResult<Vec<Video>> {
info!("number of concurrent futures: {limit}");
let mut videos = Vec::new();
let client = StatelessClient::default();
@@ -253,15 +294,19 @@ pub async fn upload(
// let line = line::kodo();
for video_path in video_path {
info!("{line:?}");
let video_file = VideoFile::new(video_path)
.with_context(|| format!("file {}", video_path.to_string_lossy()))?;
let video_file = VideoFile::new(video_path).change_context_lazy(|| {
AppError::Custom(format!("file {}", video_path.to_string_lossy()))
})?;
let total_size = video_file.total_size;
let file_name = video_file.file_name.clone();
let uploader = line.pre_upload(bili, video_file).await?;
let uploader = line
.pre_upload(bili, video_file)
.await
.change_context_lazy(|| AppError::Unknown)?;
//Progress bar
let pb = ProgressBar::new(total_size);
pb.set_style(ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")?);
.template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").change_context_lazy(|| AppError::Unknown)?);
// pb.enable_steady_tick(Duration::from_secs(1));
// pb.tick()
@@ -276,7 +321,8 @@ pub async fn upload(
Ok((Progressbar::new(chunk, pb), len))
})
})
.await?;
.await
.change_context_lazy(|| AppError::Unknown)?;
pb.finish_and_clear();
let t = instant.elapsed().as_millis();
info!(
@@ -289,24 +335,31 @@ pub async fn upload(
Ok(videos)
}
pub async fn login_by_password(credential: Credential) -> Result<LoginInfo> {
pub async fn login_by_password(credential: Credential) -> AppResult<LoginInfo> {
let username: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入账号")
.interact()?;
.interact()
.change_context_lazy(|| AppError::Unknown)?;
let password: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入密码")
.interact()?;
Ok(credential.login_by_password(&username, &password).await?)
.interact()
.change_context_lazy(|| AppError::Unknown)?;
Ok(credential
.login_by_password(&username, &password)
.await
.change_context_lazy(|| AppError::Unknown)?)
}
pub async fn login_by_sms(credential: Credential) -> Result<LoginInfo> {
pub async fn login_by_sms(credential: Credential) -> AppResult<LoginInfo> {
let country_code: u32 = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入手机国家代码")
.default(86)
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
let phone: u64 = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入手机号")
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
let res = credential
.send_sms_handle_recaptcha(phone, country_code, |url| async move {
println!("{url}");
@@ -324,16 +377,24 @@ pub async fn login_by_sms(credential: Credential) -> Result<LoginInfo> {
Ok((challenge, valiate))
})
.await?;
.await
.change_context_lazy(|| AppError::Unknown)?;
let input: u32 = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入验证码")
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
// println!("{}", payload);
Ok(credential.login_by_sms(input, res).await?)
Ok(credential
.login_by_sms(input, res)
.await
.change_context_lazy(|| AppError::Unknown)?)
}
pub async fn login_by_qrcode(credential: Credential) -> Result<LoginInfo> {
let value = credential.get_qrcode().await?;
pub async fn login_by_qrcode(credential: Credential) -> AppResult<LoginInfo> {
let value = credential
.get_qrcode()
.await
.change_context_lazy(|| AppError::Unknown)?;
let code = QrCode::new(
value["data"]["url"]
.as_str()
@@ -354,43 +415,58 @@ pub async fn login_by_qrcode(credential: Credential) -> Result<LoginInfo> {
);
// Save the image.
image.save("qrcode.png").unwrap();
Ok(credential.login_by_qrcode(value).await?)
Ok(credential
.login_by_qrcode(value)
.await
.change_context_lazy(|| AppError::Unknown)?)
}
pub async fn login_by_browser(credential: Credential) -> Result<LoginInfo> {
let value = credential.get_qrcode().await?;
pub async fn login_by_browser(credential: Credential) -> AppResult<LoginInfo> {
let value = credential
.get_qrcode()
.await
.change_context_lazy(|| AppError::Unknown)?;
println!(
"{}",
value["data"]["url"]
.as_str()
.ok_or_else(|| anyhow!("{}", value))?
.ok_or_else(|| AppError::Custom(value.to_string()))?
);
println!("请复制此链接至浏览器中完成登录");
Ok(credential.login_by_qrcode(value).await?)
Ok(credential
.login_by_qrcode(value)
.await
.change_context_lazy(|| AppError::Unknown)?)
}
pub async fn login_by_web_cookies(credential: Credential) -> Result<LoginInfo> {
pub async fn login_by_web_cookies(credential: Credential) -> AppResult<LoginInfo> {
let sess_data: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入SESSDATA")
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
let bili_jct: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入bili_jct")
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
Ok(credential
.login_by_web_cookies(&sess_data, &bili_jct)
.await?)
.await
.change_context_lazy(|| AppError::Unknown)?)
}
pub async fn login_by_webqr_cookies(credential: Credential) -> Result<LoginInfo> {
pub async fn login_by_webqr_cookies(credential: Credential) -> AppResult<LoginInfo> {
let sess_data: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入SESSDATA")
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
let dede_user_id: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("请输入DedeUserID")
.interact_text()?;
.interact_text()
.change_context_lazy(|| AppError::Unknown)?;
Ok(credential
.login_by_web_qrcode(&sess_data, &dede_user_id)
.await?)
.await
.change_context_lazy(|| AppError::Unknown)?)
}
impl From<Progressbar> for Body {
@@ -400,13 +476,15 @@ impl From<Progressbar> for Body {
}
#[inline]
pub fn fopen_rw<P: AsRef<Path>>(path: P) -> Result<std::fs::File> {
pub fn fopen_rw<P: AsRef<Path>>(path: P) -> AppResult<std::fs::File> {
let path = path.as_ref();
std::fs::File::options()
.read(true)
.write(true)
.open(path)
.with_context(|| String::from("open cookies file: ") + &path.to_string_lossy())
.change_context_lazy(|| {
AppError::Custom(String::from("open cookies file: ") + &path.to_string_lossy())
})
}
#[derive(Clone)]
@@ -420,7 +498,7 @@ impl Progressbar {
Self { bytes, pb }
}
pub fn progress(&mut self) -> Result<Option<Bytes>> {
pub fn progress(&mut self) -> AppResult<Option<Bytes>> {
let pb = &self.pb;
let content_bytes = &mut self.bytes;
@@ -442,7 +520,7 @@ impl Progressbar {
}
impl Stream for Progressbar {
type Item = Result<Bytes>;
type Item = AppResult<Bytes>;
fn poll_next(
mut self: Pin<&mut Self>,
-1
View File
@@ -32,7 +32,6 @@ reqwest-middleware = { version = "0.4", features = ["json"] }
reqwest-retry = "0.7.0"
clap = { version = "4", features = ["derive"], optional = true }
time = "0.3"
anyhow = { workspace = true }
m3u8-rs = "6"
nom = "8"
chrono = { workspace = true }
+2 -3
View File
@@ -153,11 +153,10 @@ impl Drop for TsFile {
#[cfg(test)]
mod tests {
use anyhow::Result;
use reqwest::Url;
#[test]
fn test_url() -> Result<()> {
fn test_url() -> Result<(), Box<dyn std::error::Error>> {
let url = Url::parse("h://host.path/to/remote/resource.m3u8")?;
let scheme = url.scheme();
let new_url = url.join("http://path.host/remote/resource.ts")?;
@@ -167,7 +166,7 @@ mod tests {
}
#[test]
fn it_works() -> Result<()> {
fn it_works() -> Result<(), Box<dyn std::error::Error>> {
// download(
// "test.ts")?;
Ok(())
+2 -3
View File
@@ -281,11 +281,10 @@ impl Connection {
#[cfg(test)]
mod tests {
use anyhow::Result;
use bytes::{Buf, BufMut, BytesMut};
#[test]
fn byte_it_works() -> Result<()> {
fn byte_it_works() -> Result<(), Box<dyn std::error::Error>> {
let mut bb = bytes::BytesMut::with_capacity(10);
println!("chunk {:?}", bb.chunk());
println!("capacity {}", bb.capacity());
@@ -317,7 +316,7 @@ mod tests {
}
#[test]
fn it_works() -> Result<()> {
fn it_works() -> Result<(), Box<dyn std::error::Error>> {
// download(
// "test.flv")?;
Ok(())
+2 -3
View File
@@ -290,11 +290,10 @@ pub fn format_filename(file_name: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[test]
fn it_works() -> Result<()> {
fn it_works() -> Result<(), Box<dyn std::error::Error>> {
let mut p = PathBuf::from("/feel/the");
p.set_extension("force");
@@ -307,7 +306,7 @@ mod tests {
}
#[test]
fn test_segmentation_logic() -> Result<()> {
fn test_segmentation_logic() -> Result<(), Box<dyn std::error::Error>> {
// 测试时间分割
let mut seg = Segmentable::new(Some(Duration::from_secs(10)), None);
assert!(!seg.needed());
-3
View File
@@ -33,9 +33,6 @@ pub enum Kind {
#[error(transparent)]
SerdeUrl(#[from] serde_urlencoded::ser::Error),
// source and Display delegate to anyhow::Error
#[error(transparent)]
AnyhowError(#[from] anyhow::Error),
#[error("need recaptcha")]
NeedRecaptcha(String),
}
+2 -1
View File
@@ -25,11 +25,12 @@ tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
tracing-appender = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
futures = { workspace = true }
async-channel = { version = "2.5.0" }
biliup = { path = "../biliup"}
biliup-cli = { path = "../biliup-cli"}
url = { workspace = true }
time = { workspace = true }
chrono = { workspace = true }
+11 -15
View File
@@ -1,9 +1,8 @@
extern crate core;
mod login;
mod uploader;
mod server;
mod uploader;
use pyo3::prelude::*;
use time::macros::format_description;
@@ -86,12 +85,12 @@ pub async fn download_with_hook(
segment: Segmentable,
file_name_hook: CallbackFn,
proxy: Option<&str>,
) -> anyhow::Result<()> {
) -> PyResult<()> {
let client = StatelessClient::new(headers, proxy);
let response = client.retryable(url).await?;
let response = client.retryable(url).await.unwrap();
let mut connection = Connection::new(response);
// let buf = &mut [0u8; 9];
let bytes = connection.read_frame(9).await?;
let bytes = connection.read_frame(9).await.unwrap();
// response.read_exact(buf)?;
// let out = File::create(format!("{}.flv", file_name)).expect("Unable to create file.");
// let mut writer = BufWriter::new(out);
@@ -111,7 +110,7 @@ pub async fn download_with_hook(
Err(e) => {
error!("{e}");
let file = LifecycleFile::with_hook(file_name, "ts", file_name_hook);
hls::download(url, &client, file, segment).await?;
hls::download(url, &client, file, segment).await.unwrap();
}
}
Ok(())
@@ -233,8 +232,7 @@ fn download_with_callback(
) {
Ok(res) => Ok(res),
Err(err) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"{}, {}",
err.root_cause(),
"{}",
err
))),
}
@@ -249,8 +247,7 @@ fn login_by_cookies(file: String, proxy: Option<String>) -> PyResult<bool> {
match result {
Ok(_) => Ok(true),
Err(err) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"{}, {}",
err.root_cause(),
"{}",
err
))),
}
@@ -302,8 +299,8 @@ fn login_by_qrcode(ret: String, proxy: Option<String>) -> PyResult<String> {
let info = Credential::new(proxy.as_deref())
.login_by_qrcode(serde_json::from_str(&ret).unwrap())
.await?;
let res = serde_json::to_string_pretty(&info)?;
Ok::<_, anyhow::Error>(res)
let res = serde_json::to_string_pretty(&info).unwrap();
Ok::<_, biliup::error::Kind>(res)
})
.map_err(|err| pyo3::exceptions::PyRuntimeError::new_err(format!("{:#?}", err)))
}
@@ -441,8 +438,7 @@ fn upload(
Ok(_) => Ok(()),
// Ok(_) => { },
Err(err) => Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"{}, {}",
err.root_cause(),
"{}",
err
))),
}
@@ -471,7 +467,7 @@ fn stream_gears(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(login_by_web_cookies, m)?)?;
m.add_function(wrap_pyfunction!(login_by_web_qrcode, m)?)?;
m.add_function(wrap_pyfunction!(server::main_loop, m)?)?;
m.add_function(wrap_pyfunction!(server::config::config_bindings, m)?)?;
m.add_function(wrap_pyfunction!(server::config_bindings, m)?)?;
m.add_class::<UploadLine>()?;
m.add_class::<PySegment>()?;
Ok(())
+37 -19
View File
@@ -1,9 +1,12 @@
use anyhow::Result;
use biliup::uploader::bilibili::BiliBili;
use biliup::uploader::credential::Credential;
use biliup_cli::server::errors::{AppError, AppResult};
use error_stack::ResultExt;
pub async fn login_by_cookies(file: &str, proxy: Option<&str>) -> Result<BiliBili> {
let login_info = biliup::uploader::credential::login_by_cookies(file, proxy).await?;
pub async fn login_by_cookies(file: &str, proxy: Option<&str>) -> AppResult<BiliBili> {
let login_info = biliup::uploader::credential::login_by_cookies(file, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?;
Ok(login_info)
}
@@ -11,20 +14,33 @@ pub async fn send_sms(
country_code: u32,
phone: u64,
proxy: Option<&str>,
) -> Result<serde_json::Value> {
let ret = Credential::new(proxy).send_sms(phone, country_code).await?;
) -> AppResult<serde_json::Value> {
let ret = Credential::new(proxy)
.send_sms(phone, country_code)
.await
.change_context_lazy(|| AppError::Unknown)?;
Ok(ret)
}
pub async fn login_by_sms(code: u32, res: serde_json::Value, proxy: Option<&str>) -> Result<bool> {
let info = Credential::new(proxy).login_by_sms(code, res).await?;
let file = std::fs::File::create("cookies.json")?;
serde_json::to_writer_pretty(&file, &info)?;
pub async fn login_by_sms(
code: u32,
res: serde_json::Value,
proxy: Option<&str>,
) -> AppResult<bool> {
let info = Credential::new(proxy)
.login_by_sms(code, res)
.await
.change_context_lazy(|| AppError::Unknown)?;
let file = std::fs::File::create("cookies.json").change_context_lazy(|| AppError::Unknown)?;
serde_json::to_writer_pretty(&file, &info).change_context_lazy(|| AppError::Unknown)?;
Ok(true)
}
pub async fn get_qrcode(proxy: Option<&str>) -> Result<serde_json::Value> {
let qrcode = Credential::new(proxy).get_qrcode().await?;
pub async fn get_qrcode(proxy: Option<&str>) -> AppResult<serde_json::Value> {
let qrcode = Credential::new(proxy)
.get_qrcode()
.await
.change_context_lazy(|| AppError::Unknown)?;
Ok(qrcode)
}
@@ -32,12 +48,13 @@ pub async fn login_by_web_cookies(
sess_data: &str,
bili_jct: &str,
proxy: Option<&str>,
) -> Result<bool> {
) -> AppResult<bool> {
let info = Credential::new(proxy)
.login_by_web_cookies(sess_data, bili_jct)
.await?;
let file = std::fs::File::create("cookies.json")?;
serde_json::to_writer_pretty(&file, &info)?;
.await
.change_context_lazy(|| AppError::Unknown)?;
let file = std::fs::File::create("cookies.json").change_context_lazy(|| AppError::Unknown)?;
serde_json::to_writer_pretty(&file, &info).change_context_lazy(|| AppError::Unknown)?;
Ok(true)
}
@@ -45,11 +62,12 @@ pub async fn login_by_web_qrcode(
sess_data: &str,
dede_user_id: &str,
proxy: Option<&str>,
) -> Result<bool> {
) -> AppResult<bool> {
let info = Credential::new(proxy)
.login_by_web_qrcode(sess_data, dede_user_id)
.await?;
let file = std::fs::File::create("cookies.json")?;
serde_json::to_writer_pretty(&file, &info)?;
.await
.change_context_lazy(|| AppError::Unknown)?;
let file = std::fs::File::create("cookies.json").change_context_lazy(|| AppError::Unknown)?;
serde_json::to_writer_pretty(&file, &info).change_context_lazy(|| AppError::Unknown)?;
Ok(true)
}
+318 -23
View File
@@ -1,25 +1,315 @@
mod api;
mod app;
pub(crate) mod config;
mod core;
mod errors;
pub mod infrastructure;
mod router;
mod util;
use error_stack::ResultExt;
use crate::construct_headers;
use async_trait::async_trait;
use biliup::downloader::util::Segmentable;
use biliup_cli::server::app::ApplicationController;
use biliup_cli::server::config;
use biliup_cli::server::config::Config;
use biliup_cli::server::core::download_manager::{ActorHandle, DownloadManager};
use biliup_cli::server::core::downloader::ffmpeg_downloader::FfmpegDownloader;
use biliup_cli::server::core::downloader::stream_gears::StreamGears;
use biliup_cli::server::core::downloader::{DownloadConfig, Downloader, DownloaderType};
use biliup_cli::server::core::plugin::{DownloadPlugin, StreamInfo, StreamStatus};
use biliup_cli::server::errors::{AppError, AppResult};
use biliup_cli::server::infrastructure::connection_pool::ConnectionManager;
use biliup_cli::server::infrastructure::context::Worker;
use biliup_cli::server::infrastructure::repositories;
use biliup_cli::server::infrastructure::service_register::ServiceRegister;
use biliup_cli::server::util::{Recorder, media_ext_from_url, parse_time};
use error_stack::{Report, ResultExt};
use fancy_regex::Regex;
use futures::TryFutureExt;
use pyo3::exceptions::PyRuntimeError;
use pyo3::prelude::PyListMethods;
use pyo3::{PyResult, Python, pyfunction};
use pyo3::prelude::PyDictMethods;
use pyo3::prelude::{PyAnyMethods, PyListMethods, PyModule};
use pyo3::sync::OnceLockExt;
use pyo3::types::PyDict;
use pyo3::types::{PyList, PyType};
use pyo3::{Bound, Py, PyAny, PyResult, Python};
use pyo3::{FromPyObject, pyclass, pyfunction, pymethods};
use pythonize::pythonize;
use std::collections::HashMap;
use std::net::ToSocketAddrs;
// use tokio::sync::mpsc::Receiver;
use crate::server::app::ApplicationController;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionManager;
use crate::server::infrastructure::repositories;
use crate::server::infrastructure::service_register::ServiceRegister;
use tracing::info;
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock};
use time::OffsetDateTime;
use tracing::{debug, info};
#[derive(Debug)]
pub struct PyPlugin {
plugin: Arc<Py<PyType>>,
pattern: Regex,
name: String,
}
impl PyPlugin {
pub fn from_pytype(class: &Bound<PyType>) -> PyResult<Self> {
let pattern = class.getattr("VALID_URL_BASE")?.to_string();
// info!("{pattern}");
let re = Regex::new(&pattern).unwrap();
let plugin = class.clone();
let name: String = class.getattr("__name__")?.extract()?;
// info!("发现插件类: {name}");
Ok(Self {
plugin: Arc::new(plugin.unbind()),
pattern: re,
name,
})
}
}
#[async_trait]
impl DownloadPlugin for PyPlugin {
fn matches(&self, url: &str) -> bool {
if self.pattern.is_match(url).unwrap() {
// 找到匹配的部分
if let Some(mat) = self.pattern.find(url).unwrap() {
debug!(" 匹配内容: {}", mat.as_str());
return true;
}
}
false
}
async fn check_status(&self, url: &str) -> Result<StreamStatus, Report<AppError>> {
info!("Checking status");
match call_via_threads(self.plugin.clone(), url)
.await
.change_context(AppError::Unknown)?
{
Some(info) => Ok(StreamStatus::Live { stream_info: info }),
None => Ok(StreamStatus::Offline),
}
}
async fn create_downloader(
&self,
stream_info: &StreamInfo,
worker: &Worker,
) -> AppResult<Box<dyn Downloader>> {
let config = worker.get_config().await?;
let streamer = worker.get_streamer().await?;
// info!(stream_info=?stream_info, "Create downloader");
let raw_stream_url = &stream_info.raw_stream_url;
let suffix = streamer
.format
.unwrap_or_else(|| stream_info.suffix.clone());
println!("suffix: {suffix}");
let recorder = Recorder::new(
streamer.filename_prefix,
&streamer.remark,
&stream_info.title,
&suffix,
);
match config.downloader {
Some(DownloaderType::Ffmpeg) => {
let config = DownloadConfig {
format: suffix,
segment_time: config.segment_time,
file_size: Some(config.file_size), // 2GB
headers: stream_info.stream_headers.clone(),
extra_args: vec![],
downloader_type: DownloaderType::FfmpegInternal,
filename_prefix: recorder.generate_filename(),
};
Ok(Box::new(FfmpegDownloader::new(
raw_stream_url,
config,
PathBuf::from("./downloads"),
)))
}
// Some(DownloaderType::StreamGears) => {
//
// },
_ => Ok(Box::new(StreamGears::new(
raw_stream_url,
construct_headers(&stream_info.stream_headers),
recorder.filename_template(),
Segmentable::new(
config.segment_time.as_deref().map(parse_time),
Some(config.file_size),
),
None,
))),
}
}
fn name(&self) -> &str {
&self.name
}
}
async fn call_via_threads(obj: Arc<Py<PyType>>, url: &str) -> PyResult<Option<StreamInfo>> {
let url = url.to_string();
// obj.
tokio::task::spawn_blocking(move || {
Python::attach(|py| -> PyResult<Option<StreamInfo>> {
// 从 biliup.util 获取 loop(按你项目里真实的名字来取)
let util = PyModule::import(py, "biliup.common.util")?;
// 下面两行二选一(取决于 biliup.util 的 API):
// let loop_obj: Py<PyAny> = util.getattr("loop")?.into_py(py);
// 或:
// let loop_obj: Py<PyAny> = util.call_method0("get_loop")?.into_py(py);
// 这里假设是直接暴露了 util.loop
let loop_obj = util.getattr("loop")?;
let asyncio = PyModule::import(py, "asyncio")?;
// 生成协程 self.acheck_stream()
let instance = obj.bind(py).call1(("fname", url))?;
let coro = instance.call_method0("acheck_stream")?;
// 调度到指定 loop
let fut = asyncio
.getattr("run_coroutine_threadsafe")?
.call1((coro, loop_obj))?;
let res = fut.call_method0("result")?;
let is_live = res.unbind().extract(py)?;
let info = if is_live {
Some(stream_info_from_py(py, &instance)?)
} else {
None
};
Ok(info)
})
})
.await
.expect("spawn_blocking panicked")
}
pub fn from_py(actor_handle: Arc<ActorHandle>) -> PyResult<Vec<DownloadManager>> {
let classes: Vec<DownloadManager> = Python::attach(|py| -> PyResult<Vec<DownloadManager>> {
let plugins = py.import("biliup.plugins")?;
let decorators = py.import("biliup.engine.decorators")?;
// 获取 Plugin 类
let plugin_class = decorators.getattr("Plugin")?;
let instance = plugin_class.call1((plugins,))?;
// 如果要获取类属性(而不是实例属性)
let bound = plugin_class.getattr("download_plugins")?;
let plugin_list: &Bound<PyList> = bound.downcast()?;
plugin_list
.iter()
.map(|x| {
let download = x.downcast::<PyType>()?;
let py_plugin = PyPlugin::from_pytype(download)?;
Ok(DownloadManager::new(py_plugin, actor_handle.clone()))
})
.collect::<PyResult<_>>()
// 基类 DownloadBase
// py.import("biliup.engine.download")?.getattr("DownloadBase")
})?;
// println!("类属性值: {:?}", class_attr);
// let download_base: &Bound<PyType> = download_base.downcast()?;
Ok(classes)
}
/// 从 Python 的 `self`(Bound<PyAny>)与 start_time / end_time 构造 StreamInfo
/// end_time 的语义与 Python 中一致:若未提供或为“假值”,则使用 time.localtime()
pub fn stream_info_from_py(py: Python<'_>, self_obj: &Bound<'_, PyAny>) -> PyResult<StreamInfo> {
// 从 self 上获取属性并抽取为 Rust 类型
let name: String = self_obj.getattr("fname")?.extract()?;
let url: String = self_obj.getattr("url")?.extract()?;
let raw_stream_url: String = self_obj.getattr("raw_stream_url")?.extract()?;
let title: String = self_obj.getattr("room_title")?.extract()?;
let live_cover_path: Option<String> = self_obj.getattr("live_cover_path")?.extract()?;
let is_download: bool = self_obj.getattr("is_download")?.extract()?;
let platform: String = self_obj.getattr("platform")?.extract()?;
let stream_headers: HashMap<String, String> = self_obj.getattr("stream_headers")?.extract()?;
self_obj.call_method1("update_headers", (&stream_headers,))?;
// date 直接使用传入的 start_time(保留为 Python 对象)
let date = OffsetDateTime::now_utc();
// end_time: 若传入 None 或“假值”,则使用 time.localtime()
// let end_time_obj: PyObject = match end_time {
// Some(et) if et.is_true()? => et.to_object(py),
// _ => {
// let time_mod = py.import("time")?;
// let lt = time_mod.getattr("localtime")?.call0()?;
// lt.to_object(py)
// }
// };self.update_headers(self.stream_headers)
Ok(StreamInfo {
name,
url,
suffix: media_ext_from_url(&raw_stream_url).unwrap(),
raw_stream_url,
title,
date,
live_cover_path,
platform,
stream_headers,
})
}
#[pyclass]
struct ConfigState {
// 用 PyObject 存,方便保持任意 Python 对象
map: Arc<RwLock<Config>>,
}
#[pymethods]
impl ConfigState {
/// 获取:config.get("k", default=None)
/// - 若 key 存在,返回保存的对象
/// - 若不存在,返回 default(默认 None)
#[pyo3(signature = (key, default=None))]
fn get<'py>(
&self,
py: Python<'py>,
key: &str,
default: Option<Bound<'py, PyAny>>,
) -> PyResult<Bound<'py, PyAny>> {
let guard = self.map.read().unwrap();
// serde_json::to_value(guard.deref())
if let Some(bound) = pythonize(py, guard.deref())?
.extract::<Bound<PyDict>>()?
.get_item(key)?
{
if bound.is_none()
&& let Some(d) = default
{
return Ok(d);
}
return Ok(bound);
};
let Some(default) = default else {
return Err(pyo3::exceptions::PyAttributeError::new_err(format!(
"object has no attribute '{key}'"
)));
};
Ok(default)
}
}
#[pyfunction]
pub fn config_bindings(py: Python<'_>) -> PyResult<ConfigState> {
let state = ConfigState {
map: cfg_arc().clone(),
};
// pythonize(py, &config)
Ok(state)
}
// 进程级全局单例(安全):OnceLock + Arc + RwLock
pub static CONFIG: LazyLock<Arc<RwLock<Config>>> = LazyLock::new(|| {
Arc::new(RwLock::new(
Config::builder().streamers(Default::default()).build(),
))
});
fn cfg_arc() -> &'static Arc<RwLock<Config>> {
&*CONFIG
}
#[pyfunction]
pub fn main_loop(py: Python<'_>) -> PyResult<()> {
@@ -56,12 +346,17 @@ async fn _main() -> AppResult<()> {
let conn_pool = ConnectionManager::new_pool("data/data.sqlite3")
.await
.expect("could not initialize the database connection pool");
*config::CONFIG.write().unwrap() = repositories::get_config(&conn_pool).await?;
let service_register = ServiceRegister::new(conn_pool, config::CONFIG.clone());
*CONFIG.write().unwrap() = repositories::get_config(&conn_pool).await?;
let actor_handle = Arc::new(ActorHandle::new(
CONFIG.read().unwrap().pool1_size,
CONFIG.read().unwrap().pool2_size,
));
let vec = from_py(actor_handle.clone()).unwrap();
let service_register = ServiceRegister::new(conn_pool, CONFIG.clone(), actor_handle, vec);
let all_streamer = repositories::get_all_streamer(&service_register.pool).await?;
let all_uploader = repositories::get_all_uploader(&service_register.pool).await?;
// let mut monitors = Vec::new();
for streamer in all_streamer {
// workers.push(Arc::new(Worker::new(streamer.id, service_register.pool.clone())));
@@ -1,72 +0,0 @@
use crate::server::errors::{AppError, report_to_response};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::models::Configuration;
use axum::extract::{Query, State};
use axum::response::Response;
use axum::{Extension, Json};
use biliup::client::StatelessClient;
use biliup::uploader::credential::login_by_cookies;
use bytes::Bytes;
use error_stack::{Report, ResultExt};
use ormlite::Model;
use std::collections::HashMap;
use tracing::info;
pub async fn archive_pre_endpoint(
Query(params): Query<HashMap<String, String>>,
State(pool): State<ConnectionPool>,
) -> Result<Json<serde_json::Value>, Response> {
let configurations = Configuration::select()
.where_("key = 'bilibili-cookies'")
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
for cookies in configurations {
if let Ok(bili) = login_by_cookies(cookies.value, None).await {
return Ok(Json(
bili.archive_pre()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
));
}
}
Err(report_to_response(Report::from(AppError::Custom(
"无可用 cookie 文件".to_string(),
))))
}
pub async fn get_myinfo_endpoint(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, Response> {
let bili = login_by_cookies(&params["user"], None)
.await
.change_context(AppError::Custom(params["user"].to_string()))
.map_err(report_to_response)?;
Ok(Json(
bili.my_info()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
}
pub async fn get_proxy_endpoint(
State(client): State<StatelessClient>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Bytes, Response> {
info!(params = &params["url"]);
// let bili = login_by_cookies(&params["user"]).await?;
client
.client
.get(&params["url"])
.send()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
.bytes()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)
}
@@ -1,443 +0,0 @@
use crate::server::config::Config;
use crate::server::errors::{AppError, AppResult, report_to_response};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::context::Worker;
use crate::server::infrastructure::dto::LiveStreamerResponse;
use crate::server::infrastructure::models::{
Configuration, FileItem, InsertConfiguration, InsertLiveStreamer, InsertUploadStreamer,
LiveStreamer, StreamerInfo, UploadStreamer,
};
use crate::server::infrastructure::repositories::{del_streamer, get_all_streamer};
use crate::server::infrastructure::service_register::ServiceRegister;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Json, debug_handler};
use biliup::credential::{Credential, LoginInfo};
use error_stack::{Report, ResultExt};
use ormlite::{Insert, Model};
use serde_json::json;
use std::sync::{Arc, RwLock};
use std::time::{Duration, UNIX_EPOCH};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tracing::info;
pub async fn get_streamers_endpoint(
State(pool): State<ConnectionPool>,
State(workers): State<Arc<RwLock<Vec<Arc<Worker>>>>>,
) -> Result<Json<Vec<LiveStreamerResponse>>, Response> {
let live_streamers = get_all_streamer(&pool).await.map_err(report_to_response)?;
info!(
"get_streamers_endpoint found {} live streamers",
live_streamers.len()
);
Ok(Json(
live_streamers
.into_iter()
.map(|x| LiveStreamerResponse {
status: workers
.read()
.unwrap()
.iter()
.find_map(|worker| {
if worker.id == x.id {
Some(*worker.downloader_status.read().unwrap())
} else {
None
}
})
.unwrap_or_default(),
inner: x,
})
.collect(),
))
}
pub async fn post_streamers_endpoint(
State(service_register): State<ServiceRegister>,
State(pool): State<ConnectionPool>,
Json(payload): Json<InsertLiveStreamer>,
) -> Result<Json<LiveStreamer>, Response> {
let Some(manager) = service_register.get_manager(&payload.url) else {
info!("not supported url: {}", &payload.url);
return Err((StatusCode::BAD_REQUEST, "Not supported url").into_response());
};
let monitor = manager.ensure_monitor();
/// You can insert the model directly.
let live_streamers = payload
.insert(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
service_register
.add_room(live_streamers.id, &live_streamers.url, monitor)
.await
.map_err(report_to_response)?;
info!(workers=?service_register.workers, "successfully inserted new live streamers");
Ok(Json(live_streamers))
}
pub async fn put_streamers_endpoint(
State(service_register): State<ServiceRegister>,
State(pool): State<ConnectionPool>,
Json(payload): Json<LiveStreamer>,
) -> Result<Json<LiveStreamer>, Response> {
let streamer = payload
.update_all_fields(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let Some(manager) = service_register.get_manager(&streamer.url) else {
info!("not supported url: {}", &streamer.url);
return Err((StatusCode::BAD_REQUEST, "Not supported url").into_response());
};
let monitor = manager.ensure_monitor();
service_register
.del_room(streamer.id)
.await
.map_err(report_to_response)?;
service_register
.add_room(streamer.id, &streamer.url, monitor)
.await
.map_err(report_to_response)?;
info!(workers=?streamer, "successfully update live streamers");
Ok(Json(streamer))
}
pub async fn delete_streamers_endpoint(
State(service_register): State<ServiceRegister>,
State(pool): State<ConnectionPool>,
State(workers): State<Arc<RwLock<Vec<Arc<Worker>>>>>,
Path(id): Path<i64>,
) -> Result<Json<LiveStreamer>, Response> {
service_register
.del_room(id)
.await
.map_err(report_to_response)?;
let live_streamers = del_streamer(&pool, id).await.map_err(report_to_response)?;
info!(workers=?service_register.workers, "successfully inserted new live streamers");
Ok(Json(live_streamers))
}
pub async fn get_configuration(
State(config): State<Arc<RwLock<Config>>>,
) -> Result<Json<Config>, Response> {
Ok(Json(config.read().unwrap().clone()))
}
// #[axum_macros::debug_handler(state = ServiceRegister)]
pub async fn put_configuration(
State(config): State<Arc<RwLock<Config>>>,
State(pool): State<ConnectionPool>,
Json(json_data): Json<Config>,
) -> Result<Json<Config>, Response> {
// 将 JSON 序列化为 TEXT 存库
let value_txt = serde_json::to_string(&json_data)
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let mut tx = pool
.begin()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// 最多取 2 条判断是否多行
let ids: Vec<i64> =
sqlx::query_scalar::<_, i64>("SELECT id FROM configuration WHERE key = ?1 LIMIT 2")
.bind("config")
.fetch_all(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let saved: Configuration = if ids.is_empty() {
// 插入
sqlx::query("INSERT INTO configuration (key, value) VALUES (?1, ?2)")
.bind("config")
.bind(&value_txt)
.execute(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// 取 last_insert_rowid 并读回整行
let id: i64 = sqlx::query_scalar::<_, i64>("SELECT last_insert_rowid()")
.fetch_one(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
sqlx::query_as::<_, Configuration>("SELECT id, key, value FROM configuration WHERE id = ?1")
.bind(id)
.fetch_one(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
} else if ids.len() == 1 {
// 更新
let id = ids[0];
sqlx::query("UPDATE configuration SET value = ?1 WHERE id = ?2")
.bind(&value_txt)
.bind(id)
.execute(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
sqlx::query_as::<_, Configuration>("SELECT id, key, value FROM configuration WHERE id = ?1")
.bind(id)
.fetch_one(&mut *tx)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?
} else {
// 多行报错
return Err(report_to_response(Report::new(AppError::Custom(
format!("有多个空间配置同时存在 (key='config'): {} 行", ids.len()).to_string(),
))));
};
tx.commit()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// 提交后从 DB 重新加载配置
let saved_config: Config = serde_json::from_str(&saved.value)
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
*config.write().unwrap() = saved_config;
Ok(Json(config.read().unwrap().clone()))
}
pub async fn get_streamer_info(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
) -> Result<Json<Vec<StreamerInfo>>, Response> {
let streamer_infos = StreamerInfo::select()
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let file_items = FileItem::select()
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
println!("{:?}", file_items);
Ok(Json(streamer_infos))
}
pub async fn get_upload_streamers_endpoint(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
) -> Result<Json<Vec<UploadStreamer>>, Response> {
let uploader_streamers = UploadStreamer::select()
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(uploader_streamers))
}
pub async fn add_upload_streamer_endpoint(
// Extension(streamers_service): Extension<DynUploadStreamersRepository>,
State(pool): State<ConnectionPool>,
Json(upload_streamer): Json<InsertUploadStreamer>,
) -> Result<Json<serde_json::Value>, Response> {
if upload_streamer.id.is_none() {
Ok(Json(
serde_json::to_value(
ormlite::Insert::insert(upload_streamer, &pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
)
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
} else {
Ok(Json(
serde_json::to_value(
upload_streamer
.update_all_fields(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
)
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
}
}
pub async fn get_upload_streamer_endpoint(
State(pool): State<ConnectionPool>,
Path(id): Path<i64>,
) -> Result<Json<UploadStreamer>, Response> {
let uploader_streamers = UploadStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_one(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(uploader_streamers))
}
pub async fn delete_template_endpoint(
State(pool): State<ConnectionPool>,
Path(id): Path<i64>,
) -> Result<Json<()>, Response> {
let uploader_streamers = UploadStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_one(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(
uploader_streamers
.delete(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?,
))
}
pub async fn get_users_endpoint(
State(pool): State<ConnectionPool>,
) -> Result<Json<Vec<serde_json::Value>>, Response> {
let configurations = Configuration::select()
.where_("key = 'bilibili-cookies'")
.fetch_all(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
let mut res = Vec::new();
for cookies in configurations {
res.push(json!({
"id": cookies.id,
"name": cookies.value,
"value": cookies.value,
"platform": cookies.key,
}))
}
Ok(Json(res))
}
pub async fn add_user_endpoint(
State(pool): State<ConnectionPool>,
Json(user): Json<InsertConfiguration>,
) -> Result<Json<Configuration>, Response> {
let res = user
.insert(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(res))
}
pub async fn delete_user_endpoint(
Path(id): Path<i64>,
State(pool): State<ConnectionPool>,
) -> Result<Json<()>, Response> {
let x = sqlx::query("DELETE FROM configuration WHERE id = ?")
.bind(id)
.execute(&pool)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
info!("{:?}", x);
Ok(Json(()))
}
pub async fn get_qrcode() -> Result<Json<serde_json::Value>, Response> {
let qrcode = Credential::new(None)
.get_qrcode()
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(qrcode))
}
pub async fn login_by_qrcode(
Json(value): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, Response> {
let info = tokio::time::timeout(
Duration::from_secs(300),
Credential::new(None).login_by_qrcode(value),
// std::future::pending::<AppResult<LoginInfo>>(),
)
.await
.change_context(AppError::Custom("deadline has elapsed".to_string()))
.map_err(report_to_response)?
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
// extract mid
let mid = info.token_info.mid;
let filename = format!("data/{}.json", mid);
let mut file = fs::File::create(&filename)
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
file.write_all(&serde_json::to_vec_pretty(&info).unwrap())
.await
.change_context(AppError::Unknown)
.map_err(report_to_response)?;
Ok(Json(json!({ "filename": filename })))
}
pub async fn get_videos() -> Result<Json<Vec<serde_json::Value>>, Response> {
let media_extensions = [".mp4", ".flv", ".3gp", ".webm", ".mkv", ".ts"];
let blacklist = ["next-env.d.ts"];
let mut file_list = Vec::new();
let mut index = 1;
// **use tokio::fs::read_dir**
if let Ok(mut entries) = fs::read_dir(".").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().into_owned();
if blacklist.contains(&file_name.as_str()) {
continue;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if media_extensions
.iter()
.any(|allowed| &ext == &allowed.trim_start_matches('.'))
{
if let Ok(metadata) = entry.metadata().await {
let mtime = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
file_list.push(serde_json::json!({
"key": index,
"name": file_name,
"updateTime": mtime,
"size": metadata.len(),
}));
index += 1;
}
}
}
}
}
Ok(Json(file_list))
}
@@ -1,4 +0,0 @@
pub mod auth;
pub(crate) mod bilibili_endpoints;
pub mod endpoints;
pub(crate) mod spa;
-49
View File
@@ -1,49 +0,0 @@
use axum::http::{StatusCode, Uri, header};
use axum::response::{Html, IntoResponse, Response};
use rust_embed::Embed;
static INDEX_HTML: &str = "index.html";
#[derive(Embed)]
#[folder = "../../out/"]
struct Assets;
pub async fn static_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
if path.is_empty() || path == INDEX_HTML {
return index_html().await;
}
let guess_html = path.to_owned() + ".html";
if let Some(html) = Assets::get(&guess_html) {
return Html(html.data).into_response();
}
match Assets::get(path) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
}
None => {
// if path.contains('.') {
// return not_found().await;
// }
//
// index_html().await
not_found().await
}
}
}
async fn index_html() -> Response {
match Assets::get(INDEX_HTML) {
Some(content) => Html(content.data).into_response(),
None => not_found().await,
}
}
async fn not_found() -> Response {
(StatusCode::NOT_FOUND, "404").into_response()
}
@@ -1,285 +0,0 @@
use crate::construct_headers;
use crate::server::core::download_manager::{ActorHandle, DownloadManager};
use crate::server::core::downloader::ffmpeg_downloader::FfmpegDownloader;
use crate::server::core::downloader::stream_gears::StreamGears;
use crate::server::core::downloader::{DownloadConfig, Downloader, DownloaderType};
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::context::Worker;
use crate::server::util::{Recorder, media_ext_from_url, parse_time};
use async_trait::async_trait;
use biliup::downloader::util::Segmentable;
use error_stack::{Report, ResultExt};
use fancy_regex::Regex;
use pyo3::prelude::{PyAnyMethods, PyListMethods, PyModule};
use pyo3::types::{PyList, PyType};
use pyo3::{Bound, Py, PyAny, PyResult, Python};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tracing::{debug, info};
// Stream information structures
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamInfo {
pub name: String,
pub url: String,
pub raw_stream_url: String,
pub title: String,
#[serde(with = "time::serde::rfc3339")]
pub date: OffsetDateTime, // 保存 Python 的时间对象(如 time.struct_time)
// pub end_time: PyObject, // 同上
pub live_cover_path: Option<String>,
pub platform: String,
pub stream_headers: HashMap<String, String>,
pub suffix: String,
}
impl StreamInfo {
/// 从 Python 的 `self`(Bound<PyAny>)与 start_time / end_time 构造 StreamInfo
/// end_time 的语义与 Python 中一致:若未提供或为“假值”,则使用 time.localtime()
pub fn from_py(py: Python<'_>, self_obj: &Bound<'_, PyAny>) -> PyResult<Self> {
// 从 self 上获取属性并抽取为 Rust 类型
let name: String = self_obj.getattr("fname")?.extract()?;
let url: String = self_obj.getattr("url")?.extract()?;
let raw_stream_url: String = self_obj.getattr("raw_stream_url")?.extract()?;
let title: String = self_obj.getattr("room_title")?.extract()?;
let live_cover_path: Option<String> = self_obj.getattr("live_cover_path")?.extract()?;
let is_download: bool = self_obj.getattr("is_download")?.extract()?;
let platform: String = self_obj.getattr("platform")?.extract()?;
let stream_headers: HashMap<String, String> =
self_obj.getattr("stream_headers")?.extract()?;
self_obj.call_method1("update_headers", (&stream_headers,))?;
// date 直接使用传入的 start_time(保留为 Python 对象)
let date = OffsetDateTime::now_utc();
// end_time: 若传入 None 或“假值”,则使用 time.localtime()
// let end_time_obj: PyObject = match end_time {
// Some(et) if et.is_true()? => et.to_object(py),
// _ => {
// let time_mod = py.import("time")?;
// let lt = time_mod.getattr("localtime")?.call0()?;
// lt.to_object(py)
// }
// };self.update_headers(self.stream_headers)
Ok(Self {
name,
url,
suffix: media_ext_from_url(&raw_stream_url).unwrap(),
raw_stream_url,
title,
date,
live_cover_path,
platform,
stream_headers,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamStatus {
Live { stream_info: StreamInfo },
Offline,
Unknown,
}
#[async_trait]
pub trait DownloadBase: Send + Sync {
async fn check_stream(&self) -> Result<bool, Report<AppError>>;
async fn get_stream_info(&self) -> Result<StreamInfo, Report<AppError>>;
async fn download(&self, output_path: impl AsRef<Path>) -> Result<(), Report<AppError>>;
fn should_record(&self, room_title: &str) -> bool;
fn get_platform_name(&self) -> &'static str;
}
#[async_trait]
pub trait DownloadPlugin {
fn matches(&self, url: &str) -> bool;
async fn check_status(&self, url: &str) -> Result<StreamStatus, Report<AppError>>;
async fn create_downloader(
&self,
stream_info: &StreamInfo,
worker: &Worker,
) -> AppResult<Box<dyn Downloader>>;
fn name(&self) -> &str;
}
#[derive(Debug)]
pub struct PyPlugin {
plugin: Arc<Py<PyType>>,
pattern: Regex,
name: String,
}
impl PyPlugin {
pub fn from_pytype(class: &Bound<PyType>) -> PyResult<Self> {
let pattern = class.getattr("VALID_URL_BASE")?.to_string();
// info!("{pattern}");
let re = Regex::new(&pattern).unwrap();
let plugin = class.clone();
let name: String = class.getattr("__name__")?.extract()?;
// info!("发现插件类: {name}");
Ok(Self {
plugin: Arc::new(plugin.unbind()),
pattern: re,
name,
})
}
}
#[async_trait]
impl DownloadPlugin for PyPlugin {
fn matches(&self, url: &str) -> bool {
if self.pattern.is_match(url).unwrap() {
// 找到匹配的部分
if let Some(mat) = self.pattern.find(url).unwrap() {
debug!(" 匹配内容: {}", mat.as_str());
return true;
}
}
false
}
async fn check_status(&self, url: &str) -> Result<StreamStatus, Report<AppError>> {
info!("Checking status");
match call_via_threads(self.plugin.clone(), url)
.await
.change_context(AppError::Unknown)?
{
Some(info) => Ok(StreamStatus::Live { stream_info: info }),
None => Ok(StreamStatus::Offline),
}
}
async fn create_downloader(
&self,
stream_info: &StreamInfo,
worker: &Worker,
) -> AppResult<Box<dyn Downloader>> {
let config = worker.get_config().await?;
let streamer = worker.get_streamer().await?;
// info!(stream_info=?stream_info, "Create downloader");
let raw_stream_url = &stream_info.raw_stream_url;
let suffix = streamer
.format
.unwrap_or_else(|| stream_info.suffix.clone());
println!("suffix: {suffix}");
let recorder = Recorder::new(
streamer.filename_prefix,
&streamer.remark,
&stream_info.title,
&suffix,
);
match config.downloader {
Some(DownloaderType::Ffmpeg) => {
let config = DownloadConfig {
format: suffix,
segment_time: config.segment_time,
file_size: Some(config.file_size), // 2GB
headers: stream_info.stream_headers.clone(),
extra_args: vec![],
downloader_type: DownloaderType::FfmpegInternal,
filename_prefix: recorder.generate_filename(),
};
Ok(Box::new(FfmpegDownloader::new(
raw_stream_url,
config,
PathBuf::from("./downloads"),
)))
}
// Some(DownloaderType::StreamGears) => {
//
// },
_ => Ok(Box::new(StreamGears::new(
raw_stream_url,
construct_headers(&stream_info.stream_headers),
recorder.filename_template(),
Segmentable::new(
config.segment_time.as_deref().map(parse_time),
Some(config.file_size),
),
None,
))),
}
}
fn name(&self) -> &str {
&self.name
}
}
async fn call_via_threads(obj: Arc<Py<PyType>>, url: &str) -> PyResult<Option<StreamInfo>> {
let url = url.to_string();
// obj.
tokio::task::spawn_blocking(move || {
Python::attach(|py| -> PyResult<Option<StreamInfo>> {
// 从 biliup.util 获取 loop(按你项目里真实的名字来取)
let util = PyModule::import(py, "biliup.common.util")?;
// 下面两行二选一(取决于 biliup.util 的 API):
// let loop_obj: Py<PyAny> = util.getattr("loop")?.into_py(py);
// 或:
// let loop_obj: Py<PyAny> = util.call_method0("get_loop")?.into_py(py);
// 这里假设是直接暴露了 util.loop
let loop_obj = util.getattr("loop")?;
let asyncio = PyModule::import(py, "asyncio")?;
// 生成协程 self.acheck_stream()
let instance = obj.bind(py).call1(("fname", url))?;
let coro = instance.call_method0("acheck_stream")?;
// 调度到指定 loop
let fut = asyncio
.getattr("run_coroutine_threadsafe")?
.call1((coro, loop_obj))?;
let res = fut.call_method0("result")?;
let is_live = res.unbind().extract(py)?;
let info = if is_live {
Some(StreamInfo::from_py(py, &instance)?)
} else {
None
};
Ok(info)
})
})
.await
.expect("spawn_blocking panicked")
}
pub fn from_py(actor_handle: Arc<ActorHandle>) -> PyResult<Vec<DownloadManager>> {
let classes: Vec<DownloadManager> = Python::attach(|py| -> PyResult<Vec<DownloadManager>> {
let plugins = py.import("biliup.plugins")?;
let decorators = py.import("biliup.engine.decorators")?;
// 获取 Plugin 类
let plugin_class = decorators.getattr("Plugin")?;
let instance = plugin_class.call1((plugins,))?;
// 如果要获取类属性(而不是实例属性)
let bound = plugin_class.getattr("download_plugins")?;
let plugin_list: &Bound<PyList> = bound.downcast()?;
plugin_list
.iter()
.map(|x| {
let download = x.downcast::<PyType>()?;
let py_plugin = PyPlugin::from_pytype(download)?;
Ok(DownloadManager::new(py_plugin, actor_handle.clone()))
})
.collect::<PyResult<_>>()
// 基类 DownloadBase
// py.import("biliup.engine.download")?.getattr("DownloadBase")
})?;
// println!("类属性值: {:?}", class_attr);
// let download_base: &Bound<PyType> = download_base.downcast()?;
Ok(classes)
}
-56
View File
@@ -1,56 +0,0 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use error_stack::Report;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
use tracing::log::error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Unknown Error")]
Unknown,
#[error("{0}")]
Custom(String),
}
pub fn report_to_response(report: impl Into<Report<AppError>>) -> Response {
let report = report.into();
let (status, error_message) = match report.downcast_ref::<AppError>() {
Some(AppError::Unknown) => (StatusCode::INTERNAL_SERVER_ERROR, report.to_string()),
Some(AppError::Custom(msg)) => (StatusCode::INTERNAL_SERVER_ERROR, msg.into()),
_ => (StatusCode::INTERNAL_SERVER_ERROR, report.to_string()),
};
tracing::error!(error = ?report);
// I'm not a fan of the error specification, so for the sake of consistency,
// serialize singular errors as a map of vectors similar to the 422 validation responses
let body = Json(ApiError::new(error_message));
(status, body).into_response()
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ApiError {
pub errors: Vec<HashMap<String, String>>,
pub message: String,
}
impl ApiError {
pub fn new(message: String) -> Self {
let errors: Vec<HashMap<String, String>> = Vec::new();
// errors.push(HashMap::from([
// (String::from("resource"), "Issue".to_string()),
// (String::from("field"), "title".to_string()),
// (String::from("code"), "missing_field".to_string()),
// (String::from("code"), "unprocessable".to_string()),
// (String::from("code"), "already_exists".to_string()),
// (String::from("code"), "invalid".to_string()),
// (String::from("code"), "missing".to_string()),
// ]));
Self { errors, message }
}
}
pub(crate) type AppResult<T> = Result<T, Report<AppError>>;
@@ -1,39 +0,0 @@
use anyhow::{Context, Result};
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
use std::path::Path;
use tracing::info;
pub type ConnectionPool = Pool<Sqlite>;
pub struct ConnectionManager;
impl ConnectionManager {
pub async fn new_pool(path: &str) -> Result<ConnectionPool> {
// 创建所有父级目录(如果不存在)
if let Some(parent) = Path::new(path).parent() {
std::fs::create_dir_all(parent)?; // 创建 data/ 目录
}
let db_url = format!("sqlite://{path}");
/// Start by making a database connection.
std::fs::OpenOptions::new()
.write(true)
.create(true)
.open(path)?;
let pool = SqlitePoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
.context("error while initializing the database connection pool")?;
/// Query builder syntax closely follows SQL syntax, translated into chained function calls.
info!("migrations enabled, running...");
sqlx::migrate!()
.run(&pool)
.await
.context("error while running database migrations")?;
Ok(pool)
}
}
@@ -1,67 +0,0 @@
use crate::server::config::Config;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::models::{
Configuration, InsertConfiguration, LiveStreamer, UploadStreamer,
};
use error_stack::ResultExt;
use ormlite::{Insert, Model};
pub async fn get_streamer(pool: &ConnectionPool, id: i64) -> AppResult<LiveStreamer> {
LiveStreamer::select()
.where_("id = ?")
.bind(id)
.fetch_one(pool)
.await
.change_context(AppError::Unknown)
}
pub async fn del_streamer(pool: &ConnectionPool, id: i64) -> AppResult<LiveStreamer> {
let streamer = get_streamer(pool, id).await?;
streamer
.clone()
.delete(pool)
.await
.change_context(AppError::Unknown)?;
Ok(streamer)
}
pub async fn get_all_streamer(pool: &ConnectionPool) -> AppResult<Vec<LiveStreamer>> {
LiveStreamer::select()
.fetch_all(pool)
.await
.change_context(AppError::Unknown)
}
pub async fn get_config(pool: &ConnectionPool) -> AppResult<Config> {
let configuration = Configuration::select()
.where_("key = 'config'")
.fetch_optional(pool)
.await
.change_context(AppError::Unknown)?;
if let Some(configuration) = configuration {
let json: Config =
serde_json::from_str(&configuration.value).change_context(AppError::Unknown)?;
Ok(json)
} else {
let config = Config::builder().streamers(Default::default()).build();
Ok(config)
}
}
pub async fn insert_config(pool: &ConnectionPool, config: &Config) -> AppResult<Configuration> {
let configuration = InsertConfiguration {
key: "config".to_string(),
value: serde_json::to_string(config).unwrap(),
}
.insert(pool)
.await
.change_context(AppError::Unknown)?;
Ok(configuration)
}
pub async fn get_all_uploader(pool: &ConnectionPool) -> AppResult<Vec<UploadStreamer>> {
UploadStreamer::select()
.fetch_all(pool)
.await
.change_context(AppError::Unknown)
}
@@ -1,121 +0,0 @@
use crate::server::config;
use crate::server::config::Config;
use crate::server::core::download_manager::{ActorHandle, DownloadManager};
use crate::server::core::monitor::Monitor;
use crate::server::core::plugin;
use crate::server::errors::{AppError, AppResult};
use crate::server::infrastructure::connection_pool::ConnectionPool;
use crate::server::infrastructure::context::{Context, Worker};
use crate::server::infrastructure::repositories;
use axum::extract::FromRef;
use biliup::client::StatelessClient;
use biliup::credential::Credential;
use error_stack::bail;
use ormlite::Model;
use std::sync::{Arc, RwLock};
use tracing::info;
#[derive(FromRef, Clone)]
pub struct ServiceRegister {
pub pool: ConnectionPool,
pub workers: Arc<RwLock<Vec<Arc<Worker>>>>,
pub managers: Arc<Vec<DownloadManager>>,
pub config: Arc<RwLock<Config>>,
pub actor_handle: Arc<ActorHandle>,
pub client: StatelessClient,
}
/// A simple service container responsible for managing the various services our API endpoints will pull from through axum extensions.
impl ServiceRegister {
pub fn new(pool: ConnectionPool, config: Arc<RwLock<Config>>) -> Self {
info!("initializing utility services...");
let client = StatelessClient::default();
info!(config=?config);
let actor_handle = Arc::new(ActorHandle::new(
config.read().unwrap().pool1_size,
config.read().unwrap().pool2_size,
));
info!("utility services initialized, building feature services...");
let vec = plugin::from_py(actor_handle.clone()).unwrap();
info!("feature services successfully initialized!");
ServiceRegister {
pool,
workers: Arc::new(Default::default()),
managers: Arc::new(vec),
config: config.clone(),
actor_handle,
client,
}
}
pub fn get_manager(&self, url: &str) -> Option<&DownloadManager> {
self.managers
.iter()
.find(|&manager| manager.matches(url))
.map(|v| v as _)
}
pub async fn add_room(
&self,
id: i64,
url: &str,
monitor: Arc<Monitor>,
) -> AppResult<Option<()>> {
let worker = Arc::new(Worker::new(
id,
url,
Context::new(self.pool.clone(), self.client.clone()),
));
monitor.rooms_handle.add(worker.clone()).await;
self.workers.write().unwrap().push(worker);
info!("add {url} success");
Ok(Some(()))
}
pub async fn del_room(&self, id: i64) -> AppResult<()> {
let Some(i) = self.workers.read().unwrap().iter().position(|x| x.id == id) else {
return Err(error_stack::Report::new(AppError::Unknown));
};
let removed = self.workers.write().unwrap().swap_remove(i);
let url = &removed.url;
let Some(manager) = self.get_manager(url) else {
info!("not found url: {url}");
bail!(AppError::Unknown)
};
let monitor = manager.ensure_monitor();
let len = monitor.rooms_handle.del(id).await;
info!("{id} removed, remained len {len}");
if len == 0 {
*manager.monitor.lock().unwrap() = None;
}
Ok(())
}
}
// impl FromRef<ServiceRegister> for ConnectionPool {
// fn from_ref(app_state: &ServiceRegister) -> ConnectionPool {
// app_state.pool.clone()
// }
// }
// pub(crate) async fn add_room(&self, id: i64, pool: ConnectionPool) -> Arc<Worker> {
// let arc = self.ensure_monitor().rooms_handle.clone();
// let worker = Arc::new(Worker::new(id, pool, arc.clone()));
// arc.add(worker.clone()).await;
// worker
// }
//
// pub async fn del_room(&self, id: i64) {
// let len = self.ensure_monitor().rooms_handle.del(id).await;
// info!("{id} removed, remained len {len}");
// if len == 0 {
// *self.monitor.lock().unwrap() = None;
// }
// }
+35 -13
View File
@@ -1,4 +1,3 @@
use anyhow::{Context, Result};
use biliup::client::StatelessClient;
use biliup::error::Kind;
use biliup::uploader::bilibili::{Credit, ResponseData, Studio};
@@ -10,12 +9,14 @@ use futures::StreamExt;
use pyo3::prelude::*;
use pyo3::pyclass;
use error_stack::ResultExt;
use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Instant;
use tracing::info;
use biliup_cli::server::errors::{AppError, AppResult};
use typed_builder::TypedBuilder;
#[pyclass]
@@ -82,7 +83,7 @@ pub async fn upload(
studio_pre: StudioPre,
submit: Option<&str>,
proxy: Option<&str>,
) -> Result<ResponseData> {
) -> AppResult<ResponseData> {
// let file = std::fs::File::options()
// .read(true)
// .write(true)
@@ -114,10 +115,13 @@ pub async fn upload(
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()
Err(Kind::IO(_)) => bilibili.change_context_lazy(|| {
AppError::Custom(format!(
"open cookies file: {}",
&cookie_file.to_string_lossy()
))
})?,
_ => bilibili?,
_ => bilibili.change_context_lazy(|| AppError::Unknown)?,
};
let client = StatelessClient::default();
@@ -141,12 +145,21 @@ pub async fn upload(
_ => Probe::probe(&client.client).await.unwrap_or_default(),
};
for video_path in video_path {
println!("{:?}", video_path.canonicalize()?.to_str());
println!(
"{:?}",
video_path
.canonicalize()
.change_context_lazy(|| AppError::Unknown)?
.to_str()
);
info!("{line:?}");
let video_file = VideoFile::new(&video_path)?;
let video_file = VideoFile::new(&video_path).change_context_lazy(|| AppError::Unknown)?;
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 uploader = line
.pre_upload(&bilibili, video_file)
.await
.change_context_lazy(|| AppError::Unknown)?;
let instant = Instant::now();
@@ -158,7 +171,8 @@ pub async fn upload(
Ok((chunk, len))
})
})
.await?;
.await
.change_context_lazy(|| AppError::Unknown)?;
let t = instant.elapsed().as_millis();
info!(
"Upload completed: {file_name} => cost {:.2}s, {:.2} MB/s.",
@@ -203,9 +217,11 @@ pub async fn upload(
let url = bilibili
.cover_up(
&std::fs::read(&studio.cover)
.with_context(|| format!("cover: {}", studio.cover))?,
.change_context_lazy(|| AppError::Unknown)
.attach_with(|| format!("cover: {}", studio.cover))?,
)
.await?;
.await
.change_context_lazy(|| AppError::Unknown)?;
println!("{url}");
studio.cover = url;
}
@@ -216,8 +232,14 @@ pub async fn upload(
};
let submit_result = match submit {
SubmitOption::BCutAndroid => bilibili.submit_by_bcut_android(&studio, proxy).await?,
_ => bilibili.submit_by_app(&studio, proxy).await?,
SubmitOption::BCutAndroid => bilibili
.submit_by_bcut_android(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
_ => bilibili
.submit_by_app(&studio, proxy)
.await
.change_context_lazy(|| AppError::Unknown)?,
};
Ok(submit_result)
}