refactor(cli): single entry for native binary and main_loop (#1728)
* refactor(cli): single entry for native binary and main_loop Move argument parsing, logging setup and command dispatch from biliup-cli's main.rs into `biliup_cli::entry`, and make stream_gears.main_loop call the same entry instead of its own copy. - Both entries default to `server` when no subcommand is given (or the legacy `start`), expand `~` in paths, and log to `ds_update.log` (server only, no rotation) instead of `download.log` on the wheel. - The log filter honours `RUST_LOG`; an explicit `--rust-log` overrides it. - main_loop no longer calls process::exit on clap errors: `--help` and `--version` print and return, other parse errors raise SystemExit with clap's exit code. Logging uses try_init, so a second call in the same process does not panic. - Export `entry::serve(ServeOptions)` so a host can run the Web server on its own tokio runtime. - Document config_bindings as deprecated (behaviour unchanged). Ref #1721 * test(stream-gears): cover the shared entry from the installed wheel Exit codes of --help/--version/--bad-flag, main_loop called twice in one process, and biliup server writing ds_update.log with RUST_LOG and --rust-log precedence. Ref #1721 * test(stream-gears): update smoke test comment now that --help no longer exits * build(stream-gears): drop clap, unused after the shared entry * ci(docker): start the built image and check GET / returns 200 The test job only built the image. Also run it with the default CMD and require the Web UI to answer and ds_update.log to be written. Ref #1721
This commit is contained in:
11 files changed
+566
-424
No files matched your search
@@ -55,9 +55,25 @@ jobs:
|
||||
docker build \
|
||||
--build-arg repo_url=$REPO_URL \
|
||||
--build-arg branch_name=$BRANCH_NAME \
|
||||
--tag biliup-test \
|
||||
. --file Dockerfile
|
||||
fi
|
||||
|
||||
- name: Smoke test container
|
||||
if: hashFiles('docker-compose.test.yml') == ''
|
||||
run: |
|
||||
docker run -d --name biliup-test -p 19159:19159 biliup-test
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:19159/; then
|
||||
docker exec biliup-test test -s /opt/ds_update.log
|
||||
docker rm -f biliup-test
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
docker logs biliup-test
|
||||
exit 1
|
||||
|
||||
|
||||
build:
|
||||
needs: test
|
||||
|
||||
Generated
-1
@@ -4417,7 +4417,6 @@ dependencies = [
|
||||
"biliup",
|
||||
"biliup-cli",
|
||||
"bon",
|
||||
"clap",
|
||||
"danmaku",
|
||||
"error-stack",
|
||||
"nom 8.0.0",
|
||||
|
||||
@@ -31,9 +31,9 @@ pub struct Cli {
|
||||
#[arg(short, long, default_value = "cookies.json")]
|
||||
pub user_cookie: PathBuf,
|
||||
|
||||
// #[arg(long, default_value = "sqlx=debug,tower_http=debug,info")]
|
||||
#[arg(long, default_value = "tower_http=debug,info")]
|
||||
pub rust_log: String,
|
||||
/// 日志过滤规则,如 debug;不指定时读取环境变量 RUST_LOG,都没有则为 tower_http=debug,info
|
||||
#[arg(long)]
|
||||
pub rust_log: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Shared entry point for the native `biliup` binary and the Python
|
||||
//! `stream_gears.main_loop` wrapper, so both parse, log and dispatch the same way.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use biliup::uploader::util::SubmitOption;
|
||||
use clap::Parser;
|
||||
use error_stack::ResultExt;
|
||||
use time::macros::format_description;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, reload};
|
||||
|
||||
use crate::LogHandle;
|
||||
use crate::cli::{Cli, Commands, expand_path};
|
||||
use crate::downloader::{download, generate_json};
|
||||
use crate::server::errors::{AppError, AppResult};
|
||||
use crate::uploader::{
|
||||
append, comments, list, login, renew, reply, show, upload_by_command, upload_by_config,
|
||||
};
|
||||
|
||||
pub const DEFAULT_LOG_FILTER: &str = "tower_http=debug,info";
|
||||
pub const LOG_FILE: &str = "ds_update.log";
|
||||
|
||||
static LOG_HANDLE: OnceLock<LogHandle> = OnceLock::new();
|
||||
|
||||
/// Fills in the default subcommand: no subcommand, or the legacy `start`, means `server`.
|
||||
pub fn with_default_command<I, T>(args: I) -> Vec<OsString>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
match args.len() {
|
||||
0 => args.extend(["biliup".into(), "server".into()]),
|
||||
1 => args.push("server".into()),
|
||||
_ if args[1] == "start" => args[1] = "server".into(),
|
||||
_ => {}
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// Parses command-line arguments (including `argv[0]`) after applying [`with_default_command`].
|
||||
pub fn parse<I, T>(args: I) -> Result<Cli, clap::Error>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
Cli::try_parse_from(with_default_command(args))
|
||||
}
|
||||
|
||||
/// An explicit `--rust-log` wins over `RUST_LOG`, which wins over [`DEFAULT_LOG_FILTER`].
|
||||
pub fn log_filter_directives(cli_value: Option<&str>, env_value: Option<&str>) -> String {
|
||||
cli_value
|
||||
.or(env_value.filter(|value| !value.trim().is_empty()))
|
||||
.unwrap_or(DEFAULT_LOG_FILTER)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub fn writes_log_file(command: &Commands) -> bool {
|
||||
matches!(command, Commands::Server { .. })
|
||||
}
|
||||
|
||||
/// Keeps the log file writer alive; drop it only after the command has finished.
|
||||
pub struct Logging {
|
||||
handle: LogHandle,
|
||||
_file_guard: Option<WorkerGuard>,
|
||||
}
|
||||
|
||||
impl Logging {
|
||||
pub fn handle(&self) -> LogHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialises logging for `cli`: console output, plus `ds_update.log` in the
|
||||
/// working directory for the `server` command.
|
||||
pub fn init_tracing(cli: &Cli) -> Logging {
|
||||
let env = std::env::var("RUST_LOG").ok();
|
||||
init_tracing_with(
|
||||
&log_filter_directives(cli.rust_log.as_deref(), env.as_deref()),
|
||||
writes_log_file(&cli.command),
|
||||
)
|
||||
}
|
||||
|
||||
/// Installs the global subscriber on the first call. Later calls in the same
|
||||
/// process (e.g. `main_loop` called twice) keep the existing layers and only
|
||||
/// swap the filter.
|
||||
pub fn init_tracing_with(filter: &str, log_file: bool) -> Logging {
|
||||
if let Some(handle) = LOG_HANDLE.get() {
|
||||
let _ = handle.modify(|current| *current = EnvFilter::new(filter));
|
||||
return Logging {
|
||||
handle: handle.clone(),
|
||||
_file_guard: None,
|
||||
};
|
||||
}
|
||||
|
||||
let timer = tracing_subscriber::fmt::time::LocalTime::new(format_description!(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second]"
|
||||
));
|
||||
let (filter_layer, handle) = reload::Layer::new(EnvFilter::new(filter));
|
||||
|
||||
let (file_layer, file_guard) = if log_file {
|
||||
let file_appender = tracing_appender::rolling::never(".", LOG_FILE);
|
||||
let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender);
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_timer(timer.clone())
|
||||
.with_writer(file_writer)
|
||||
.with_ansi(false);
|
||||
(Some(file_layer), Some(file_guard))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let installed = tracing_subscriber::registry()
|
||||
.with(filter_layer)
|
||||
.with(tracing_subscriber::fmt::layer().with_timer(timer))
|
||||
.with(file_layer)
|
||||
.try_init()
|
||||
.is_ok();
|
||||
if installed {
|
||||
let _ = LOG_HANDLE.set(handle.clone());
|
||||
}
|
||||
Logging {
|
||||
handle,
|
||||
_file_guard: file_guard,
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialises logging and runs the command described by `cli`.
|
||||
pub async fn run(cli: Cli) -> AppResult<()> {
|
||||
let logging = init_tracing(&cli);
|
||||
dispatch(cli, logging.handle()).await
|
||||
}
|
||||
|
||||
/// Runs the command described by `cli`. Logging must already be initialised;
|
||||
/// `log_handle` lets the Web UI change the log level at runtime.
|
||||
pub async fn dispatch(cli: Cli, log_handle: LogHandle) -> AppResult<()> {
|
||||
let user_cookie = expand_path(cli.user_cookie);
|
||||
let proxy = cli.proxy.as_deref();
|
||||
|
||||
match cli.command {
|
||||
Commands::Login => login(user_cookie, proxy).await?,
|
||||
Commands::Renew => renew(user_cookie, proxy).await?,
|
||||
Commands::Upload {
|
||||
video_path,
|
||||
config: None,
|
||||
line,
|
||||
limit,
|
||||
studio,
|
||||
submit,
|
||||
} => {
|
||||
let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect();
|
||||
upload_by_command(
|
||||
studio,
|
||||
user_cookie,
|
||||
video_path,
|
||||
line,
|
||||
limit,
|
||||
submit.unwrap_or(SubmitOption::App),
|
||||
proxy,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Upload {
|
||||
video_path: _,
|
||||
config: Some(config),
|
||||
submit,
|
||||
..
|
||||
} => upload_by_config(expand_path(config), user_cookie, submit, proxy).await?,
|
||||
Commands::Append {
|
||||
video_path,
|
||||
vid,
|
||||
line,
|
||||
limit,
|
||||
studio: _,
|
||||
submit,
|
||||
} => {
|
||||
let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect();
|
||||
append(
|
||||
user_cookie,
|
||||
vid,
|
||||
video_path,
|
||||
line,
|
||||
limit,
|
||||
submit.unwrap_or(SubmitOption::App),
|
||||
proxy,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Show { vid } => show(user_cookie, vid, proxy).await?,
|
||||
Commands::Comments { vid, sort, pn, ps } => {
|
||||
comments(user_cookie, vid, sort, pn, ps, proxy).await?
|
||||
}
|
||||
Commands::Reply {
|
||||
vid,
|
||||
rpid,
|
||||
message,
|
||||
execute,
|
||||
} => reply(user_cookie, vid, rpid, message, execute, proxy).await?,
|
||||
Commands::DumpFlv { file_name } => generate_json(expand_path(file_name))?,
|
||||
Commands::Download {
|
||||
url,
|
||||
output,
|
||||
split_size,
|
||||
split_time,
|
||||
} => download(&url, output, split_size, split_time).await?,
|
||||
Commands::Server {
|
||||
bind,
|
||||
port,
|
||||
auth,
|
||||
secure_session_cookie,
|
||||
config,
|
||||
} => {
|
||||
serve(ServeOptions {
|
||||
bind,
|
||||
port,
|
||||
auth,
|
||||
secure_session_cookie,
|
||||
config,
|
||||
user_cookie,
|
||||
work_dir: None,
|
||||
log_handle,
|
||||
})
|
||||
.await?
|
||||
}
|
||||
Commands::User { action } => crate::web_user_cli::run(action).await?,
|
||||
Commands::List {
|
||||
is_pubing,
|
||||
pubed,
|
||||
not_pubed,
|
||||
from_page,
|
||||
max_pages,
|
||||
} => {
|
||||
list(
|
||||
user_cookie,
|
||||
is_pubing,
|
||||
pubed,
|
||||
not_pubed,
|
||||
proxy,
|
||||
from_page,
|
||||
max_pages,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Options for [`serve`]; the fields mirror `biliup server`.
|
||||
pub struct ServeOptions {
|
||||
pub bind: String,
|
||||
pub port: u16,
|
||||
pub auth: bool,
|
||||
pub secure_session_cookie: bool,
|
||||
pub config: Option<PathBuf>,
|
||||
pub user_cookie: PathBuf,
|
||||
/// The server keeps `data/`, `ds_update.log` and recordings relative to the
|
||||
/// working directory. When set, the directory is created and the whole
|
||||
/// process switches to it before the server starts.
|
||||
pub work_dir: Option<PathBuf>,
|
||||
pub log_handle: LogHandle,
|
||||
}
|
||||
|
||||
/// Runs the Web server until shutdown. It only needs a tokio runtime, so an
|
||||
/// embedding host (e.g. the desktop app) can spawn it on its own runtime after
|
||||
/// calling [`init_tracing_with`].
|
||||
pub async fn serve(opts: ServeOptions) -> AppResult<()> {
|
||||
if let Some(dir) = &opts.work_dir {
|
||||
std::fs::create_dir_all(dir)
|
||||
.and_then(|()| std::env::set_current_dir(dir))
|
||||
.change_context(AppError::Unknown)
|
||||
.attach_with(|| format!("could not switch to work dir {}", dir.display()))?;
|
||||
}
|
||||
crate::run_with_cookie(
|
||||
(&opts.bind, opts.port),
|
||||
opts.auth,
|
||||
opts.secure_session_cookie,
|
||||
opts.log_handle,
|
||||
opts.config,
|
||||
opts.user_cookie,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn normalized(args: &[&str]) -> Vec<String> {
|
||||
with_default_command(args.iter().copied())
|
||||
.into_iter()
|
||||
.map(|arg| arg.into_string().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_command_is_server() {
|
||||
assert_eq!(normalized(&[]), ["biliup", "server"]);
|
||||
assert_eq!(normalized(&["biliup"]), ["biliup", "server"]);
|
||||
assert_eq!(
|
||||
normalized(&["biliup", "start", "--port", "1"]),
|
||||
["biliup", "server", "--port", "1"]
|
||||
);
|
||||
assert_eq!(normalized(&["biliup", "renew"]), ["biliup", "renew"]);
|
||||
assert_eq!(normalized(&["biliup", "--help"]), ["biliup", "--help"]);
|
||||
assert_eq!(
|
||||
normalized(&["biliup", "-u", "start"]),
|
||||
["biliup", "-u", "start"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_without_arguments_starts_server() {
|
||||
let cli = parse(["biliup"]).unwrap();
|
||||
assert!(matches!(cli.command, Commands::Server { port: 19159, .. }));
|
||||
assert!(cli.rust_log.is_none());
|
||||
let cli = parse(["biliup", "start", "--port", "0"]).unwrap();
|
||||
assert!(matches!(cli.command, Commands::Server { port: 0, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_version_are_errors_with_zero_exit_code() {
|
||||
for flag in ["--help", "--version"] {
|
||||
let err = parse(["biliup", flag]).err().unwrap();
|
||||
assert_eq!(err.exit_code(), 0, "{flag}");
|
||||
}
|
||||
let err = parse(["biliup", "--bad-flag"]).err().unwrap();
|
||||
assert_ne!(err.exit_code(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_rust_log_overrides_env() {
|
||||
let cli = parse(["biliup", "--rust-log", "debug", "server"]).unwrap();
|
||||
assert_eq!(cli.rust_log.as_deref(), Some("debug"));
|
||||
assert_eq!(
|
||||
log_filter_directives(cli.rust_log.as_deref(), Some("warn")),
|
||||
"debug"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_rust_log_applies_without_flag() {
|
||||
assert_eq!(log_filter_directives(None, Some("warn")), "warn");
|
||||
assert_eq!(log_filter_directives(None, Some(" ")), DEFAULT_LOG_FILTER);
|
||||
assert_eq!(log_filter_directives(None, None), DEFAULT_LOG_FILTER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_file_is_written_only_by_the_server_command() {
|
||||
let server = parse(["biliup"]).unwrap();
|
||||
let renew = parse(["biliup", "renew"]).unwrap();
|
||||
let list = parse(["biliup", "list"]).unwrap();
|
||||
|
||||
assert!(writes_log_file(&server.command));
|
||||
assert!(!writes_log_file(&renew.command));
|
||||
assert!(!writes_log_file(&list.command));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod cli;
|
||||
pub mod downloader;
|
||||
pub mod entry;
|
||||
pub mod server;
|
||||
pub mod upload_lock;
|
||||
pub mod uploader;
|
||||
@@ -27,7 +28,7 @@ use tracing_subscriber::{EnvFilter, Registry, reload};
|
||||
// 定义 Handle 的类型别名,简化代码
|
||||
// EnvFilter: 我们使用的过滤器类型
|
||||
// Registry: 基础的 Subscriber 类型
|
||||
type LogHandle = reload::Handle<EnvFilter, Registry>;
|
||||
pub type LogHandle = reload::Handle<EnvFilter, Registry>;
|
||||
|
||||
pub async fn run(
|
||||
addr: (&str, u16),
|
||||
|
||||
@@ -1,211 +1,8 @@
|
||||
use time::macros::format_description;
|
||||
|
||||
use biliup::uploader::util::SubmitOption;
|
||||
use biliup_cli::cli::{Cli, Commands, expand_path};
|
||||
use biliup_cli::downloader::{download, generate_json};
|
||||
use biliup_cli::uploader::{
|
||||
append, comments, list, login, renew, reply, show, upload_by_command, upload_by_config,
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use biliup_cli::entry;
|
||||
use biliup_cli::server::errors::AppResult;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::reload;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
fn writes_web_log(command: &Commands) -> bool {
|
||||
matches!(command, Commands::Server { .. })
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
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.)
|
||||
// // will be written to stdout.
|
||||
// .with_max_level(Level::INFO)
|
||||
// // completes the builder.
|
||||
// .finish();
|
||||
|
||||
// tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
|
||||
let cli = Cli::parse();
|
||||
|
||||
// use of deprecated function `time::util::local_offset::set_soundness`: no longer needed; TZ is refreshed manually
|
||||
// unsafe {
|
||||
// time::util::local_offset::set_soundness(time::util::local_offset::Soundness::Unsound);
|
||||
// }
|
||||
|
||||
let timer = tracing_subscriber::fmt::time::LocalTime::new(format_description!(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second]"
|
||||
));
|
||||
|
||||
let console_filter = tracing_subscriber::EnvFilter::new(&cli.rust_log);
|
||||
// let (file_filter_layer, file_reload_handle) = reload::Layer::new(file_filter);
|
||||
let (console_filter_layer, console_reload_handle) = reload::Layer::new(console_filter);
|
||||
|
||||
// Only the Web server owns `ds_update.log`. Short-lived credential
|
||||
// commands still log to the console, but do not persist their output.
|
||||
let (file_layer, file_guard) = if writes_web_log(&cli.command) {
|
||||
let file_appender = tracing_appender::rolling::never(".", "ds_update.log");
|
||||
let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender);
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_timer(timer.clone())
|
||||
.with_writer(file_writer)
|
||||
.with_ansi(false);
|
||||
(Some(file_layer), Some(file_guard))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(console_filter_layer)
|
||||
.with(tracing_subscriber::fmt::layer().with_timer(timer))
|
||||
.with(file_layer)
|
||||
.init();
|
||||
|
||||
let user_cookie = expand_path(cli.user_cookie);
|
||||
|
||||
match cli.command {
|
||||
Commands::Login => login(user_cookie, cli.proxy.as_deref()).await?,
|
||||
Commands::Renew => {
|
||||
renew(user_cookie, cli.proxy.as_deref()).await?;
|
||||
}
|
||||
Commands::Upload {
|
||||
video_path,
|
||||
config: None,
|
||||
line,
|
||||
limit,
|
||||
studio,
|
||||
submit,
|
||||
} => {
|
||||
let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect();
|
||||
upload_by_command(
|
||||
studio,
|
||||
user_cookie,
|
||||
video_path,
|
||||
line,
|
||||
limit,
|
||||
submit.unwrap_or(SubmitOption::App),
|
||||
cli.proxy.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Upload {
|
||||
video_path: _,
|
||||
config: Some(config),
|
||||
submit,
|
||||
..
|
||||
} => {
|
||||
let config = expand_path(config);
|
||||
upload_by_config(config, user_cookie, submit, cli.proxy.as_deref()).await?;
|
||||
}
|
||||
Commands::Append {
|
||||
video_path,
|
||||
vid,
|
||||
line,
|
||||
limit,
|
||||
studio: _,
|
||||
submit,
|
||||
} => {
|
||||
let video_path: Vec<_> = video_path.into_iter().map(expand_path).collect();
|
||||
append(
|
||||
user_cookie,
|
||||
vid,
|
||||
video_path,
|
||||
line,
|
||||
limit,
|
||||
submit.unwrap_or(SubmitOption::App),
|
||||
cli.proxy.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Show { vid } => show(user_cookie, vid, cli.proxy.as_deref()).await?,
|
||||
Commands::Comments { vid, sort, pn, ps } => {
|
||||
comments(user_cookie, vid, sort, pn, ps, cli.proxy.as_deref()).await?
|
||||
}
|
||||
Commands::Reply {
|
||||
vid,
|
||||
rpid,
|
||||
message,
|
||||
execute,
|
||||
} => {
|
||||
reply(
|
||||
user_cookie,
|
||||
vid,
|
||||
rpid,
|
||||
message,
|
||||
execute,
|
||||
cli.proxy.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::DumpFlv { file_name } => {
|
||||
let file_name = expand_path(file_name);
|
||||
generate_json(file_name)?
|
||||
}
|
||||
Commands::Download {
|
||||
url,
|
||||
output,
|
||||
split_size,
|
||||
split_time,
|
||||
} => download(&url, output, split_size, split_time).await?,
|
||||
Commands::Server {
|
||||
bind,
|
||||
port,
|
||||
auth,
|
||||
secure_session_cookie,
|
||||
config,
|
||||
} => {
|
||||
biliup_cli::run_with_cookie(
|
||||
(&bind, port),
|
||||
auth,
|
||||
secure_session_cookie,
|
||||
console_reload_handle,
|
||||
config,
|
||||
user_cookie,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::User { action } => biliup_cli::web_user_cli::run(action).await?,
|
||||
Commands::List {
|
||||
is_pubing,
|
||||
pubed,
|
||||
not_pubed,
|
||||
from_page,
|
||||
max_pages,
|
||||
} => {
|
||||
list(
|
||||
user_cookie,
|
||||
is_pubing,
|
||||
pubed,
|
||||
not_pubed,
|
||||
cli.proxy.as_deref(),
|
||||
from_page,
|
||||
max_pages,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
// Keep the optional server writer alive until queued records are flushed.
|
||||
drop(file_guard);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::writes_web_log;
|
||||
use biliup_cli::cli::Cli;
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn persistent_log_is_enabled_only_for_the_server_command() {
|
||||
let server = Cli::try_parse_from(["biliup", "server"]).unwrap();
|
||||
let renew = Cli::try_parse_from(["biliup", "renew"]).unwrap();
|
||||
let list = Cli::try_parse_from(["biliup", "list"]).unwrap();
|
||||
|
||||
assert!(writes_web_log(&server.command));
|
||||
assert!(!writes_web_log(&renew.command));
|
||||
assert!(!writes_web_log(&list.command));
|
||||
}
|
||||
let cli = entry::parse(std::env::args_os()).unwrap_or_else(|e| e.exit());
|
||||
entry::run(cli).await
|
||||
}
|
||||
@@ -29,7 +29,6 @@ biliup-cli = { path = "../biliup-cli"}
|
||||
danmaku-client = { package = "danmaku", path = "../danmaku"}
|
||||
time = { workspace = true }
|
||||
bon = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
async-trait = { workspace = true }
|
||||
error-stack = { workspace = true }
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use biliup::client::StatelessClient;
|
||||
use biliup::downloader::flv_parser::header;
|
||||
use biliup::downloader::httpflv::Connection;
|
||||
use biliup_cli::server::common::construct_headers;
|
||||
use pyo3::exceptions::PyRuntimeError;
|
||||
use pyo3::exceptions::{PyRuntimeError, PySystemExit};
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -414,32 +414,28 @@ pub fn main_loop(py: Python<'_>) -> PyResult<()> {
|
||||
let bound = sys.getattr("argv")?;
|
||||
let argv: &Bound<PyList> = bound.cast()?;
|
||||
|
||||
let mut args: Vec<String> = argv
|
||||
let args: Vec<String> = argv
|
||||
.iter()
|
||||
.map(|x| x.extract::<String>())
|
||||
.collect::<PyResult<Vec<_>>>()?;
|
||||
|
||||
// if args.len() == 1 {
|
||||
// args.push("server".to_string());
|
||||
// }
|
||||
match args.as_slice() {
|
||||
&[] => {
|
||||
args.push("biliup".to_string());
|
||||
args.push("server".to_string());
|
||||
let cli = match biliup_cli::entry::parse(args) {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
// `--help` / `--version` exit with 0; the console script turns
|
||||
// SystemExit into the process exit code.
|
||||
let code = e.exit_code();
|
||||
e.print()
|
||||
.map_err(|err| PyRuntimeError::new_err(err.to_string()))?;
|
||||
return if code == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PySystemExit::new_err(code))
|
||||
};
|
||||
}
|
||||
&[_] => {
|
||||
args.push("server".to_string());
|
||||
}
|
||||
[_, command, ..] => {
|
||||
if command == "start" {
|
||||
args[1] = "server".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
py.detach(|| {
|
||||
server::_main(args.as_slice()).map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))
|
||||
})
|
||||
py.detach(|| server::run(cli).map_err(|e| PyRuntimeError::new_err(format!("{e:?}"))))
|
||||
}
|
||||
|
||||
/// A Python module implemented in Rust.
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
use biliup::uploader::util::SubmitOption;
|
||||
use biliup_cli::cli::{Cli, Commands};
|
||||
use biliup_cli::downloader::generate_json;
|
||||
use biliup_cli::cli::Cli;
|
||||
use biliup_cli::entry;
|
||||
use biliup_cli::server::config::Config;
|
||||
use biliup_cli::server::errors::AppResult;
|
||||
use biliup_cli::uploader::{
|
||||
append, comments, list, login, renew, reply, show, upload_by_command, upload_by_config,
|
||||
};
|
||||
use clap::Parser;
|
||||
use pyo3::prelude::PyAnyMethods;
|
||||
use pyo3::prelude::PyDictMethods;
|
||||
use pyo3::types::PyDict;
|
||||
@@ -15,12 +10,6 @@ use pyo3::{pyclass, pyfunction, pymethods};
|
||||
use pythonize::pythonize;
|
||||
use std::ops::Deref;
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
use time::macros::format_description;
|
||||
use tracing::info;
|
||||
use tracing_appender::rolling::Rotation;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::{EnvFilter, reload};
|
||||
|
||||
#[pyclass]
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -123,6 +112,8 @@ impl ConfigState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deprecated: always returns the built-in default configuration; the running
|
||||
/// server never reads or writes it. Kept for compatibility and will be removed.
|
||||
#[pyfunction]
|
||||
pub fn config_bindings() -> PyResult<ConfigState> {
|
||||
let state = ConfigState {
|
||||
@@ -144,179 +135,6 @@ fn cfg_arc() -> &'static Arc<RwLock<Config>> {
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
pub(crate) async fn _main(args: &[String]) -> AppResult<()> {
|
||||
let cli = match Cli::try_parse_from(args) {
|
||||
Ok(res) => res,
|
||||
Err(e) => e.exit(),
|
||||
};
|
||||
|
||||
let local_time = tracing_subscriber::fmt::time::LocalTime::new(format_description!(
|
||||
"[year]-[month]-[day] [hour]:[minute]:[second]"
|
||||
));
|
||||
|
||||
let console_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(false)
|
||||
.with_timer(local_time.clone())
|
||||
.with_file(true) // 打印文件名
|
||||
.with_line_number(true)
|
||||
.with_thread_ids(true);
|
||||
|
||||
// 按日期滚动,每天创建新文件
|
||||
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
|
||||
.rotation(Rotation::DAILY) // rotate log files once every hour
|
||||
.rotation(Rotation::NEVER) // rotate log files once every hour
|
||||
.filename_prefix("biliup") // log file names will be prefixed with `myapp.`
|
||||
.filename_prefix("download") // log file names will be prefixed with `myapp.`
|
||||
.filename_suffix("log") // log file names will be suffixed with `.log`
|
||||
// .max_log_files(3)
|
||||
// .build("logs") // try to build an appender that stores log files in `/var/log`
|
||||
.build("") // try to build an appender that stores log files in `/var/log`
|
||||
.expect("initializing rolling file appender failed");
|
||||
// 或者按小时滚动
|
||||
// let file_appender = tracing_appender::rolling::hourly("logs", "upload.log");
|
||||
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
let (filter_layer, reload_handle) = reload::Layer::new(filter);
|
||||
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_timer(local_time)
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_file(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false);
|
||||
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(filter_layer) // 这个是“总开关”,所有 layer 都会被它过滤
|
||||
// 控制台输出
|
||||
.with(console_layer)
|
||||
// 文件输出
|
||||
.with(
|
||||
file_layer, // .json() // 可选:使用 JSON 格式便于解析
|
||||
);
|
||||
|
||||
subscriber.init();
|
||||
|
||||
info!("Tracing initialized with daily rotation");
|
||||
|
||||
match cli.command {
|
||||
Commands::Login => login(cli.user_cookie, cli.proxy.as_deref()).await?,
|
||||
Commands::Renew => {
|
||||
renew(cli.user_cookie, cli.proxy.as_deref()).await?;
|
||||
}
|
||||
Commands::Upload {
|
||||
video_path,
|
||||
config: None,
|
||||
line,
|
||||
limit,
|
||||
studio,
|
||||
submit,
|
||||
} => {
|
||||
upload_by_command(
|
||||
studio,
|
||||
cli.user_cookie,
|
||||
video_path,
|
||||
line,
|
||||
limit,
|
||||
submit.unwrap_or(SubmitOption::App),
|
||||
cli.proxy.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Upload {
|
||||
video_path: _,
|
||||
config: Some(config),
|
||||
submit,
|
||||
..
|
||||
} => {
|
||||
upload_by_config(config, cli.user_cookie, submit, cli.proxy.as_deref()).await?;
|
||||
}
|
||||
Commands::Append {
|
||||
video_path,
|
||||
vid,
|
||||
line,
|
||||
limit,
|
||||
studio: _,
|
||||
submit,
|
||||
} => {
|
||||
append(
|
||||
cli.user_cookie,
|
||||
vid,
|
||||
video_path,
|
||||
line,
|
||||
limit,
|
||||
submit.unwrap_or(SubmitOption::App),
|
||||
cli.proxy.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::Show { vid } => show(cli.user_cookie, vid, cli.proxy.as_deref()).await?,
|
||||
Commands::Comments { vid, sort, pn, ps } => {
|
||||
comments(cli.user_cookie, vid, sort, pn, ps, cli.proxy.as_deref()).await?
|
||||
}
|
||||
Commands::Reply {
|
||||
vid,
|
||||
rpid,
|
||||
message,
|
||||
execute,
|
||||
} => {
|
||||
reply(
|
||||
cli.user_cookie,
|
||||
vid,
|
||||
rpid,
|
||||
message,
|
||||
execute,
|
||||
cli.proxy.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Commands::DumpFlv { file_name } => generate_json(file_name)?,
|
||||
Commands::Download {
|
||||
url,
|
||||
output,
|
||||
split_size,
|
||||
split_time,
|
||||
} => biliup_cli::downloader::download(&url, output, split_size, split_time).await?,
|
||||
Commands::Server {
|
||||
bind,
|
||||
port,
|
||||
auth,
|
||||
secure_session_cookie,
|
||||
config,
|
||||
} => {
|
||||
biliup_cli::run_with_cookie(
|
||||
(&bind, port),
|
||||
auth,
|
||||
secure_session_cookie,
|
||||
reload_handle,
|
||||
config,
|
||||
cli.user_cookie,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Commands::User { action } => biliup_cli::web_user_cli::run(action).await?,
|
||||
Commands::List {
|
||||
is_pubing,
|
||||
pubed,
|
||||
not_pubed,
|
||||
from_page,
|
||||
max_pages,
|
||||
} => {
|
||||
list(
|
||||
cli.user_cookie,
|
||||
is_pubing,
|
||||
pubed,
|
||||
not_pubed,
|
||||
cli.proxy.as_deref(),
|
||||
from_page,
|
||||
max_pages,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
pub(crate) async fn run(cli: Cli) -> AppResult<()> {
|
||||
entry::run(cli).await
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Entry-point behaviour of the installed wheel (``biliup`` / ``main_loop``).
|
||||
|
||||
Run against an installed wheel, not the source tree:
|
||||
``pytest crates/stream-gears/tests``. Server tests bind a free loopback port
|
||||
and run in a temp dir, so they stay offline.
|
||||
"""
|
||||
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
import textwrap
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
TIMEOUT = 60
|
||||
|
||||
|
||||
def biliup_script():
|
||||
name = "biliup.exe" if sys.platform == "win32" else "biliup"
|
||||
path = Path(sysconfig.get_path("scripts")) / name
|
||||
assert path.is_file(), f"console script not installed: {path}"
|
||||
return str(path)
|
||||
|
||||
|
||||
def run(args, cwd, **kwargs):
|
||||
return subprocess.run(
|
||||
args, cwd=cwd, capture_output=True, text=True, timeout=TIMEOUT, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def env_without_rust_log(**extra):
|
||||
env = {k: v for k, v in os.environ.items() if k != "RUST_LOG"}
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def run_server(tmp_path, global_args=(), env=None):
|
||||
"""Starts ``biliup server``, waits for ``GET /`` to return 200, stops it
|
||||
and returns the contents of ``ds_update.log``."""
|
||||
port = free_port()
|
||||
args = [biliup_script(), *global_args, "server", "--port", str(port)]
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
cwd=tmp_path,
|
||||
env=env or env_without_rust_log(),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
while True:
|
||||
assert proc.poll() is None, proc.stderr.read()
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as r:
|
||||
assert r.status == 200
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.send_signal(signal.SIGINT)
|
||||
try:
|
||||
proc.wait(timeout=TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
assert not (tmp_path / "download.log").exists()
|
||||
return (tmp_path / "ds_update.log").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def debug_lines(log):
|
||||
"""DEBUG records outside tower_http, which is at debug level by default."""
|
||||
return [
|
||||
line for line in log.splitlines() if " DEBUG " in line and "tower_http" not in line
|
||||
]
|
||||
|
||||
|
||||
def test_version_exits_zero(tmp_path):
|
||||
result = run([biliup_script(), "--version"], tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_help_exits_zero(tmp_path):
|
||||
result = run([biliup_script(), "--help"], tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "server" in result.stdout
|
||||
|
||||
|
||||
def test_bad_flag_exits_nonzero(tmp_path):
|
||||
result = run([biliup_script(), "--bad-flag"], tmp_path)
|
||||
assert result.returncode == 2
|
||||
assert "--bad-flag" in result.stderr
|
||||
|
||||
|
||||
def test_main_loop_returns_on_help_and_raises_system_exit_on_error(tmp_path):
|
||||
code = textwrap.dedent(
|
||||
"""
|
||||
import sys
|
||||
import stream_gears
|
||||
|
||||
sys.argv = ["biliup", "--version"]
|
||||
assert stream_gears.main_loop() is None
|
||||
assert stream_gears.main_loop() is None
|
||||
sys.argv = ["biliup", "--help"]
|
||||
assert stream_gears.main_loop() is None
|
||||
sys.argv = ["biliup", "--bad-flag"]
|
||||
try:
|
||||
stream_gears.main_loop()
|
||||
except SystemExit as e:
|
||||
assert e.code == 2, e.code
|
||||
else:
|
||||
raise AssertionError("expected SystemExit")
|
||||
print("still running")
|
||||
"""
|
||||
)
|
||||
result = run([sys.executable, "-c", code], tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.rstrip().endswith("still running")
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="uses SIGINT to stop the server")
|
||||
class TestServer:
|
||||
def test_writes_ds_update_log(self, tmp_path):
|
||||
log = run_server(tmp_path)
|
||||
assert "listening on" in log
|
||||
assert not debug_lines(log)
|
||||
|
||||
def test_rust_log_flag(self, tmp_path):
|
||||
assert debug_lines(run_server(tmp_path, ["--rust-log", "debug"]))
|
||||
|
||||
def test_rust_log_env(self, tmp_path):
|
||||
env = env_without_rust_log(RUST_LOG="debug")
|
||||
assert debug_lines(run_server(tmp_path, env=env))
|
||||
|
||||
def test_rust_log_flag_overrides_env(self, tmp_path):
|
||||
env = env_without_rust_log(RUST_LOG="debug")
|
||||
log = run_server(tmp_path, ["--rust-log", "warn"], env=env)
|
||||
assert " INFO " not in log
|
||||
assert not debug_lines(log)
|
||||
@@ -61,9 +61,9 @@ LOGIN_FUNCTIONS = [
|
||||
"send_sms",
|
||||
]
|
||||
|
||||
# The CLI exits the process (clap's `--help` / `--version`), so it must run in
|
||||
# a subprocess. cwd is a temp dir so the repo's `biliup/` source package can't
|
||||
# shadow the installed one.
|
||||
# The CLI tests go through the installed console script / `python -m biliup`,
|
||||
# so they run in a subprocess. cwd is a temp dir so the repo's `biliup/` source
|
||||
# package can't shadow the installed one.
|
||||
TIMEOUT = 60
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user