//! Tomato Novel Downloader(番茄小说下载器)Rust 实现。 //! //! 本 crate 负责:配置加载、交互界面(TUI/CLI)、下载调度、内容解析与导出(txt/epub/有声书等)。 //! //! 代码结构(读代码入口): //! - `base_system`:配置/日志/重试/路径等基础设施 //! - `download`:下载流程编排(拉目录、拉内容、冷却/重试等) //! - `book_parser`:解析与导出(epub/txt/媒体/有声书) //! - `ui`:TUI 与无 UI(old cli)两套交互 //! - `prewarm_state`:启动预热状态(与 UI 协作显示) 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, /// 启用服务器模式(Web UI) #[arg(long, default_value_t = false)] server: bool, /// Web UI 密码(启用锁模式,防止陌生人使用) #[arg(long)] password: Option, /// 为 Web UI 登录 Cookie 添加 Secure 标志(HTTPS/反代部署建议开启) #[arg(long, default_value_t = false)] cookie_secure: bool, /// 显示版本信息后退出 #[arg(long, default_value_t = false)] version: bool, /// 检查并执行程序自更新(从 GitHub Releases 下载并替换当前可执行文件) #[arg(long, default_value_t = false)] self_update: bool, /// 自更新时自动确认(等价于提示输入 Y) #[arg(long, default_value_t = false)] self_update_yes: bool, /// 数据目录路径(用于存放 config.yml 和 logs 等文件,方便 Docker 挂载) #[arg(long)] data_dir: Option, /// 已禁用:为防止滥用,CLI 模式不再支持新建下载(保留参数仅用于输出友好报错) #[arg(long, hide = true)] download: Option, /// 更新指定 book_id 的已下载小说(非交互模式) #[arg(long)] update: Option, /// 非交互模式下失败章节重试一次 #[arg(long, default_value_t = false)] retry_failed: bool, /// 以 Telegram Bot 模式启动 #[cfg(feature = "telegram")] #[arg(long, env = "TOMATO_TELEGRAM_TOKEN")] telegram_token: Option, } fn main() -> Result<()> { // Telegram Bot 模式 — 必须最先执行 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 不能同时使用")); } // 启动时强制热更新(仅当 SHA256 不同且 tag 相同)。 // 例外:cargo run/开发态运行时跳过。 let _ = base_system::self_update::check_hotfix_and_apply(VERSION); prewarm_state::mark_prewarm_start(); thread::spawn(|| { #[cfg(feature = "official-api")] { // 注意:这里只应“预热/确保可用”,不得在每次启动时强制更换 IID。 // `prewarm_iid()` 现在会优先复用本地文件缓存,仅在缓存缺失或过期时才注册新的 IID。 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)?; // Handle command-line download/update modes 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 => { // 模拟“重启”:重新从磁盘加载配置,然后进入 noui 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 { if let Some(dir) = data_dir { load_or_create_with_base::(None, Some(dir)).map_err(|e| anyhow!(e.to_string())) } else { load_or_create::(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(::FILE_NAME) } else { std::path::PathBuf::from(::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 { 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)) } }