| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| use anyhow::{Result, anyhow}; |
| use clap::Parser; |
| use std::thread; |
|
|
| mod base_system; |
| mod book_parser; |
| mod download; |
| mod network_parser; |
| mod prewarm_state; |
| mod third_party; |
| mod ui; |
|
|
| use base_system::config::{ConfigSpec, load_or_create, load_or_create_with_base}; |
| use base_system::context::Config; |
| use base_system::logging::{LogOptions, LogSystem}; |
| use tracing::info; |
| #[cfg(feature = "official-api")] |
| use tracing::warn; |
|
|
| #[cfg(all(feature = "official-api", feature = "no-official-api"))] |
| compile_error!( |
| "features 'official-api' and 'no-official-api' are mutually exclusive; use exactly one" |
| ); |
|
|
| #[cfg(feature = "official-api")] |
| use tomato_novel_official_api::prewarm_iid; |
|
|
| const VERSION: &str = env!("CARGO_PKG_VERSION"); |
|
|
| #[derive(Debug, Parser)] |
| #[command(name = "tomato-novel-downloader")] |
| #[command(about = "Tomato Novel Downloader (Rust TUI)")] |
| struct Cli { |
| |
| #[arg(long, default_value_t = false)] |
| debug: bool, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| server: bool, |
|
|
| |
| #[arg(long)] |
| password: Option<String>, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| cookie_secure: bool, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| version: bool, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| self_update: bool, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| self_update_yes: bool, |
|
|
| |
| #[arg(long)] |
| data_dir: Option<String>, |
|
|
| |
| #[arg(long, hide = true)] |
| download: Option<String>, |
|
|
| |
| #[arg(long)] |
| update: Option<String>, |
|
|
| |
| #[arg(long, default_value_t = false)] |
| retry_failed: bool, |
|
|
| |
| #[cfg(feature = "telegram")] |
| #[arg(long, env = "TOMATO_TELEGRAM_TOKEN")] |
| telegram_token: Option<String>, |
| } |
|
|
| fn main() -> Result<()> { |
| |
| if cfg!(feature = "telegram") { |
| let _ = tracing_subscriber::fmt().with_writer(std::io::stderr).with_max_level(tracing::Level::ERROR).try_init(); |
| let token = std::env::var("TOMATO_TELEGRAM_TOKEN") |
| .or_else(|_| std::env::var("TELEGRAM_TOKEN")) |
| .unwrap_or_default(); |
| if !token.is_empty() { |
| eprintln!("[BOT] Token OK, starting Telegram mode..."); |
| let rt = match tokio::runtime::Runtime::new() { |
| Ok(r) => r, |
| Err(e) => { eprintln!("[BOT] Runtime error: {e}"); std::thread::sleep(std::time::Duration::from_secs(3600)); return Ok(()); } |
| }; |
| loop { |
| let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| rt.block_on(ui::telegram::run(token.clone(), ".")) |
| })); |
| match result { |
| Ok(Ok(())) => { eprintln!("[BOT] run() OK"); break; } |
| Ok(Err(e)) => eprintln!("[BOT] run() error: {e}"), |
| Err(panic) => { |
| let msg = panic.downcast_ref::<&str>().unwrap_or(&"telegram panic"); |
| eprintln!("[BOT] run() panicked: {msg}"); |
| } |
| } |
| eprintln!("[BOT] retry in 30s..."); |
| std::thread::sleep(std::time::Duration::from_secs(30)); |
| } |
| } else { |
| eprintln!("[BOT] No token set, idle..."); |
| } |
| loop { std::thread::sleep(std::time::Duration::from_secs(3600)); } |
| } |
|
|
| let cli = Cli::parse(); |
|
|
| if cli.version { |
| println!("Tomato Novel Downloader v{}", VERSION); |
| return Ok(()); |
| } |
|
|
| let data_dir = cli.data_dir.as_ref().map(std::path::Path::new); |
| let _log = init_logging(cli.debug, data_dir)?; |
|
|
| if cli.self_update { |
| let _ = base_system::self_update::check_for_updates(VERSION, cli.self_update_yes); |
| return Ok(()); |
| } |
|
|
| if cli.download.is_some() && cli.update.is_some() { |
| return Err(anyhow!("--download 和 --update 不能同时使用")); |
| } |
|
|
| |
| |
| let _ = base_system::self_update::check_hotfix_and_apply(VERSION); |
|
|
| prewarm_state::mark_prewarm_start(); |
| thread::spawn(|| { |
| #[cfg(feature = "official-api")] |
| { |
| |
| |
| match prewarm_iid() { |
| Ok(_) => info!(target: "startup", "IID 预热完成"), |
| Err(err) => { |
| prewarm_state::mark_prewarm_failed(err.to_string()); |
| if let Some(message) = prewarm_state::prewarm_error() { |
| warn!(target: "startup", "{message}"); |
| } |
| return; |
| } |
| } |
| } |
|
|
| #[cfg(not(feature = "official-api"))] |
| { |
| info!(target: "startup", "no-official-api 构建:跳过 IID 预热"); |
| } |
| prewarm_state::mark_prewarm_done(); |
| }); |
|
|
| let mut config = load_config_from_data_dir(data_dir)?; |
|
|
| |
| if cli.download.is_some() || cli.update.is_some() { |
| info!(target: "startup", "当前版本: v{}", VERSION); |
|
|
| if cli.download.is_some() { |
| return Err(anyhow!( |
| "出于防滥用考虑,CLI 模式已禁用新建下载;请先使用 Web UI / TUI 下载书籍,后续仅可通过 --update 更新本地已有小说。" |
| )); |
| } |
|
|
| if let Some(book_id) = cli.update.as_deref() { |
| println!("更新指定书籍 book_id={}", book_id); |
| return ui::noui::update_existing_book_non_interactive( |
| book_id, |
| &config, |
| cli.retry_failed, |
| ); |
| } |
| } |
|
|
| if cli.server { |
| let password = cli |
| .password |
| .or_else(|| std::env::var("TOMATO_WEB_PASSWORD").ok()); |
| let cookie_secure = cli.cookie_secure |
| || parse_bool_env("TOMATO_WEB_COOKIE_SECURE") |
| || parse_bool_env("TOMATO_COOKIE_SECURE"); |
| return ui::web::run( |
| &mut config, |
| password, |
| config_path_from_data_dir(data_dir), |
| cookie_secure, |
| ); |
| } |
|
|
| loop { |
| if config.old_cli { |
| info!(target: "startup", "当前版本: v{}", VERSION); |
| return ui::noui::run(&mut config); |
| } |
|
|
| match ui::tui::run(config)? { |
| ui::tui::TuiExit::Quit => return Ok(()), |
| ui::tui::TuiExit::SwitchToOldCli => { |
| |
| config = load_config_from_data_dir(data_dir)?; |
| config.old_cli = true; |
| } |
| ui::tui::TuiExit::SelfUpdate { auto_yes } => { |
| let _ = base_system::self_update::check_for_updates(VERSION, auto_yes); |
| return Ok(()); |
| } |
| } |
| } |
| } |
|
|
| fn load_config_from_data_dir(data_dir: Option<&std::path::Path>) -> Result<Config> { |
| if let Some(dir) = data_dir { |
| load_or_create_with_base::<Config>(None, Some(dir)).map_err(|e| anyhow!(e.to_string())) |
| } else { |
| load_or_create::<Config>(None).map_err(|e| anyhow!(e.to_string())) |
| } |
| } |
|
|
| fn config_path_from_data_dir(data_dir: Option<&std::path::Path>) -> std::path::PathBuf { |
| if let Some(dir) = data_dir { |
| dir.join(<Config as ConfigSpec>::FILE_NAME) |
| } else { |
| std::path::PathBuf::from(<Config as ConfigSpec>::FILE_NAME) |
| } |
| } |
|
|
| fn parse_bool_env(key: &str) -> bool { |
| std::env::var(key) |
| .ok() |
| .map(|v| { |
| matches!( |
| v.trim().to_ascii_lowercase().as_str(), |
| "1" | "true" | "yes" | "on" |
| ) |
| }) |
| .unwrap_or(false) |
| } |
|
|
| fn init_logging(debug: bool, base_dir: Option<&std::path::Path>) -> Result<LogSystem> { |
| let opts = LogOptions { |
| debug, |
| use_color: true, |
| archive_on_exit: true, |
| console: false, |
| broadcast_to_ui: true, |
| }; |
| if let Some(base_dir) = base_dir { |
| LogSystem::init_with_base(opts, Some(base_dir)).map_err(|e| anyhow!(e)) |
| } else { |
| LogSystem::init(opts).map_err(|e| anyhow!(e)) |
| } |
| } |
|
|