fqnd / src /main.rs
Haruka041
fix: retry loop every 30s after panic, auto-reconnect on network recovery
0bbdc4d
Raw
History Blame Contribute Delete
9.69 kB
//! 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<String>,
/// 为 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<String>,
/// 已禁用:为防止滥用,CLI 模式不再支持新建下载(保留参数仅用于输出友好报错)
#[arg(long, hide = true)]
download: Option<String>,
/// 更新指定 book_id 的已下载小说(非交互模式)
#[arg(long)]
update: Option<String>,
/// 非交互模式下失败章节重试一次
#[arg(long, default_value_t = false)]
retry_failed: bool,
/// 以 Telegram Bot 模式启动
#[cfg(feature = "telegram")]
#[arg(long, env = "TOMATO_TELEGRAM_TOKEN")]
telegram_token: Option<String>,
}
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<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))
}
}