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:
Noah authored and GitHub committed 2026-09-24 03:58:52 +00:00
1 parent 8d482db4a6
commit 753aade9f4
11 files changed
+566 -424

No files matched your search

+16
View File
@@ -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
View File
@@ -4417,7 +4417,6 @@ dependencies = [
"biliup",
"biliup-cli",
"bon",
"clap",
"danmaku",
"error-stack",
"nom 8.0.0",
+3 -3
View File
@@ -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)]
+362
View File
@@ -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));
}
}
+2 -1
View File
@@ -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),
+3 -206
View File
@@ -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
}
-1
View File
@@ -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 }
+17 -21
View File
@@ -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.
+6 -188
View File
@@ -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
}
+154
View File
@@ -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)
+3 -3
View File
@@ -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