| |
| |
| |
|
|
| use std::ffi::OsStr; |
| use std::fs; |
| use std::io::{self, Write}; |
| use std::path::{Path, PathBuf}; |
|
|
| use serde::{Deserialize, Serialize}; |
|
|
| use super::config::{ConfigSpec, FieldMeta}; |
|
|
| pub const OUTPUT_FORMAT_TXT: &str = "txt"; |
| pub const OUTPUT_FORMAT_EPUB: &str = "epub"; |
| pub const OUTPUT_FORMAT_PDF: &str = "pdf"; |
| pub const OUTPUT_FORMAT_BULK_TXT: &str = "bulk_txt"; |
| pub const OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD: &str = "ask_after_download"; |
|
|
| pub fn output_format_choices() -> &'static [(&'static str, &'static str)] { |
| static CHOICES: [(&str, &str); 5] = [ |
| (OUTPUT_FORMAT_TXT, "txt 格式"), |
| (OUTPUT_FORMAT_EPUB, "epub 格式"), |
| (OUTPUT_FORMAT_PDF, "pdf 格式"), |
| (OUTPUT_FORMAT_BULK_TXT, "散装文件"), |
| (OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD, "下载后选择"), |
| ]; |
| &CHOICES |
| } |
|
|
| pub fn output_format_label(choice: &str) -> &'static str { |
| let normalized = choice.trim().to_ascii_lowercase(); |
| output_format_choices() |
| .iter() |
| .find(|(value, _)| *value == normalized) |
| .map(|(_, label)| *label) |
| .unwrap_or("txt 格式") |
| } |
|
|
| pub fn output_format_value_from_label(label: &str) -> Option<&'static str> { |
| output_format_choices() |
| .iter() |
| .find(|(_, candidate)| *candidate == label) |
| .map(|(value, _)| *value) |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct Config { |
| |
| #[serde(default = "default_false")] |
| pub old_cli: bool, |
|
|
| |
| #[serde(default = "default_max_workers")] |
| pub max_workers: usize, |
| #[serde(default = "default_request_timeout")] |
| pub request_timeout: u64, |
| #[serde(default = "default_max_retries")] |
| pub max_retries: u32, |
| #[serde(default = "default_max_wait_time")] |
| pub max_wait_time: u64, |
| #[serde(default = "default_min_wait_time")] |
| pub min_wait_time: u64, |
| #[serde(default = "default_min_connect_timeout")] |
| pub min_connect_timeout: f64, |
|
|
| |
| #[serde(default = "default_novel_format")] |
| pub novel_format: String, |
| #[serde(default = "default_false")] |
| pub bulk_files: bool, |
| #[serde(default = "default_true")] |
| pub auto_clear_dump: bool, |
| #[serde(default = "default_false")] |
| pub auto_open_downloaded_files: bool, |
| #[serde(default = "default_false")] |
| pub enable_audiobook: bool, |
| #[serde(default = "default_audiobook_voice")] |
| pub audiobook_voice: String, |
| #[serde(default = "default_audiobook_rate")] |
| pub audiobook_rate: String, |
| #[serde(default = "default_audiobook_volume")] |
| pub audiobook_volume: String, |
| #[serde(default = "default_audiobook_pitch")] |
| pub audiobook_pitch: String, |
| #[serde(default = "default_audiobook_format")] |
| pub audiobook_format: String, |
| #[serde(default = "default_audiobook_concurrency")] |
| pub audiobook_concurrency: usize, |
| #[serde(default = "default_audiobook_tts_provider")] |
| pub audiobook_tts_provider: String, |
| #[serde(default = "default_string")] |
| pub audiobook_tts_api_url: String, |
| #[serde(default = "default_string")] |
| pub audiobook_tts_api_token: String, |
| #[serde(default = "default_string")] |
| pub audiobook_tts_model: String, |
|
|
| |
| #[serde(default)] |
| pub save_path: String, |
|
|
| |
| #[serde(default = "default_true")] |
| pub use_official_api: bool, |
| #[serde(default)] |
| pub api_endpoints: Vec<String>, |
|
|
| |
| #[serde(default = "default_false")] |
| pub enable_segment_comments: bool, |
| #[serde(default = "default_segment_comments_top_n")] |
| pub segment_comments_top_n: usize, |
| #[serde(default = "default_segment_comments_workers")] |
| pub segment_comments_workers: usize, |
|
|
| |
| #[serde(default = "default_true")] |
| pub download_comment_images: bool, |
| #[serde(default = "default_true")] |
| pub download_comment_avatars: bool, |
| #[serde(default = "default_media_download_workers")] |
| pub media_download_workers: usize, |
| #[serde(default = "default_blocked_media_domains")] |
| pub blocked_media_domains: Vec<String>, |
| #[serde(default = "default_false")] |
| pub force_convert_images_to_jpeg: bool, |
| #[serde(default = "default_true")] |
| pub jpeg_retry_convert: bool, |
| #[serde(default = "default_jpeg_quality")] |
| pub jpeg_quality: u8, |
| #[serde(default = "default_true")] |
| pub convert_heic_to_jpeg: bool, |
| #[serde(default = "default_false")] |
| pub keep_heic_original: bool, |
| #[serde(default = "default_first_line_indent_em")] |
| pub first_line_indent_em: f32, |
| #[serde(default = "default_media_limit_per_chapter")] |
| pub media_limit_per_chapter: usize, |
| #[serde(default = "default_media_max_dimension_px")] |
| pub media_max_dimension_px: u32, |
|
|
| |
| #[serde(default = "default_true")] |
| pub allow_overwrite_files: bool, |
| #[serde(default = "default_preferred_book_name_field")] |
| pub preferred_book_name_field: String, |
| |
| #[serde(default = "default_false")] |
| pub ask_format_after_download: bool, |
| |
| #[serde(default)] |
| pub pdf_font_path: Option<String>, |
| #[serde(skip)] |
| folder_path: Option<PathBuf>, |
| #[serde(skip)] |
| last_status_was_new: bool, |
| #[serde(skip)] |
| last_status_claimed: bool, |
| #[serde(skip)] |
| status_registry: Vec<StatusEntry>, |
| } |
|
|
| #[derive(Debug, Clone)] |
| struct StatusEntry { |
| path: PathBuf, |
| is_new: bool, |
| claimed: bool, |
| } |
|
|
| impl Default for Config { |
| fn default() -> Self { |
| Self { |
| old_cli: default_false(), |
| max_workers: default_max_workers(), |
| request_timeout: default_request_timeout(), |
| max_retries: default_max_retries(), |
| max_wait_time: default_max_wait_time(), |
| min_wait_time: default_min_wait_time(), |
| min_connect_timeout: default_min_connect_timeout(), |
| novel_format: default_novel_format(), |
| bulk_files: default_false(), |
| auto_clear_dump: default_true(), |
| auto_open_downloaded_files: default_false(), |
| enable_audiobook: default_false(), |
| audiobook_voice: default_audiobook_voice(), |
| audiobook_rate: default_audiobook_rate(), |
| audiobook_volume: default_audiobook_volume(), |
| audiobook_pitch: default_audiobook_pitch(), |
| audiobook_format: default_audiobook_format(), |
| audiobook_concurrency: default_audiobook_concurrency(), |
| audiobook_tts_provider: default_audiobook_tts_provider(), |
| audiobook_tts_api_url: default_string(), |
| audiobook_tts_api_token: default_string(), |
| audiobook_tts_model: default_string(), |
| save_path: String::new(), |
| use_official_api: default_true(), |
| api_endpoints: Vec::new(), |
| enable_segment_comments: default_false(), |
| segment_comments_top_n: default_segment_comments_top_n(), |
| segment_comments_workers: default_segment_comments_workers(), |
| download_comment_images: default_true(), |
| download_comment_avatars: default_true(), |
| media_download_workers: default_media_download_workers(), |
| blocked_media_domains: default_blocked_media_domains(), |
| force_convert_images_to_jpeg: default_false(), |
| jpeg_retry_convert: default_true(), |
| jpeg_quality: default_jpeg_quality(), |
| convert_heic_to_jpeg: default_true(), |
| keep_heic_original: default_false(), |
| first_line_indent_em: default_first_line_indent_em(), |
| media_limit_per_chapter: default_media_limit_per_chapter(), |
| media_max_dimension_px: default_media_max_dimension_px(), |
| pdf_font_path: None, |
| allow_overwrite_files: default_true(), |
| preferred_book_name_field: default_preferred_book_name_field(), |
| ask_format_after_download: default_false(), |
| folder_path: None, |
| last_status_was_new: false, |
| last_status_claimed: false, |
| status_registry: Vec::new(), |
| } |
| } |
| } |
|
|
| impl ConfigSpec for Config { |
| const FILE_NAME: &'static str = "config.yml"; |
|
|
| fn fields() -> &'static [FieldMeta] { |
| static FIELDS: [FieldMeta; 44] = [ |
| FieldMeta { |
| name: "old_cli", |
| description: "是否使用老版本命令行界面", |
| }, |
| FieldMeta { |
| name: "max_workers", |
| description: "最大并发线程数", |
| }, |
| FieldMeta { |
| name: "request_timeout", |
| description: "请求超时时间(秒)", |
| }, |
| FieldMeta { |
| name: "max_retries", |
| description: "最大重试次数", |
| }, |
| FieldMeta { |
| name: "max_wait_time", |
| description: "最大冷却时间, 单位ms", |
| }, |
| FieldMeta { |
| name: "min_wait_time", |
| description: "最小冷却时间, 单位ms", |
| }, |
| FieldMeta { |
| name: "min_connect_timeout", |
| description: "最小连接超时时间", |
| }, |
| FieldMeta { |
| name: "novel_format", |
| description: "保存小说格式, 可选: [txt, epub, pdf]", |
| }, |
| FieldMeta { |
| name: "bulk_files", |
| description: "是否以散装形式保存小说", |
| }, |
| FieldMeta { |
| name: "auto_clear_dump", |
| description: "是否自动清理缓存文件", |
| }, |
| FieldMeta { |
| name: "auto_open_downloaded_files", |
| description: "下载完成后自动用默认应用打开生成的小说文件/文件夹(txt/epub)", |
| }, |
| FieldMeta { |
| name: "enable_audiobook", |
| description: "是否使用 Edge TTS 生成有声小说", |
| }, |
| FieldMeta { |
| name: "audiobook_voice", |
| description: "Edge TTS 发音人", |
| }, |
| FieldMeta { |
| name: "audiobook_rate", |
| description: "Edge TTS 语速调整,例如 +0%、-10%", |
| }, |
| FieldMeta { |
| name: "audiobook_volume", |
| description: "Edge TTS 音量调整,例如 +0%、-10%", |
| }, |
| FieldMeta { |
| name: "audiobook_pitch", |
| description: "Edge TTS 音调调整(留空表示默认)", |
| }, |
| FieldMeta { |
| name: "audiobook_format", |
| description: "有声小说输出格式,可选 mp3 或 wav", |
| }, |
| FieldMeta { |
| name: "audiobook_concurrency", |
| description: "Edge TTS 有声小说并发生成的最大章节数", |
| }, |
| FieldMeta { |
| name: "audiobook_tts_provider", |
| description: "TTS 服务类型,可选 edge/third_party", |
| }, |
| FieldMeta { |
| name: "audiobook_tts_api_url", |
| description: "第三方 TTS API 地址(可填写本地服务,如 http://localhost:8000)", |
| }, |
| FieldMeta { |
| name: "audiobook_tts_api_token", |
| description: "第三方 TTS API Token(如无可留空)", |
| }, |
| FieldMeta { |
| name: "audiobook_tts_model", |
| description: "第三方 TTS 模型名称或 ID", |
| }, |
| FieldMeta { |
| name: "save_path", |
| description: "保存路径", |
| }, |
| FieldMeta { |
| name: "use_official_api", |
| description: "使用官方API", |
| }, |
| FieldMeta { |
| name: "api_endpoints", |
| description: "API列表", |
| }, |
| FieldMeta { |
| name: "enable_segment_comments", |
| description: "是否下载段评(段落评论)", |
| }, |
| FieldMeta { |
| name: "segment_comments_top_n", |
| description: "每段最多保存的评论数", |
| }, |
| FieldMeta { |
| name: "segment_comments_workers", |
| description: "段评抓取的并发线程数(每章内)", |
| }, |
| FieldMeta { |
| name: "download_comment_images", |
| description: "是否下载评论区图片(不含头像)", |
| }, |
| FieldMeta { |
| name: "download_comment_avatars", |
| description: "是否下载评论区头像", |
| }, |
| FieldMeta { |
| name: "media_download_workers", |
| description: "评论图片/头像下载并发线程数", |
| }, |
| FieldMeta { |
| name: "blocked_media_domains", |
| description: "拒绝下载的图片域名(包含匹配)", |
| }, |
| FieldMeta { |
| name: "force_convert_images_to_jpeg", |
| description: "是否强制将所有下载图片转码为 JPEG", |
| }, |
| FieldMeta { |
| name: "jpeg_retry_convert", |
| description: "若返回非 JPEG 且可解码则转码为 JPEG 保存", |
| }, |
| FieldMeta { |
| name: "jpeg_quality", |
| description: "JPEG 转码质量 (0-100)", |
| }, |
| FieldMeta { |
| name: "convert_heic_to_jpeg", |
| description: "检测到 HEIC/HEIF 时转码为 JPEG", |
| }, |
| FieldMeta { |
| name: "keep_heic_original", |
| description: "无法转码时是否保留 .heic/.heif", |
| }, |
| FieldMeta { |
| name: "first_line_indent_em", |
| description: "EPUB 段落首行缩进 em 数", |
| }, |
| FieldMeta { |
| name: "media_limit_per_chapter", |
| description: "每章最多下载的媒体数(0 表示不限制)", |
| }, |
| FieldMeta { |
| name: "media_max_dimension_px", |
| description: "图片最长边像素上限,>0 时缩放并转成 JPEG", |
| }, |
| FieldMeta { |
| name: "allow_overwrite_files", |
| description: "是否允许覆盖已存在的文件", |
| }, |
| FieldMeta { |
| name: "preferred_book_name_field", |
| description: "优先使用的书名字段 (book_name/original_book_name/book_short_name/ask_after_download)", |
| }, |
| FieldMeta { |
| name: "pdf_font_path", |
| description: "PDF 字体文件路径, 留空自动检测系统 CJK 字体", |
| }, |
| FieldMeta { |
| name: "ask_format_after_download", |
| description: "是否在下载完成后询问用户选择输出格式(true 时可选 txt/epub/pdf/散装文件)", |
| }, |
| ]; |
| &FIELDS |
| } |
| } |
|
|
| impl Config { |
| pub fn configured_output_format_choice(&self) -> &'static str { |
| if self.bulk_files && self.novel_format.eq_ignore_ascii_case(OUTPUT_FORMAT_TXT) { |
| return OUTPUT_FORMAT_BULK_TXT; |
| } |
|
|
| match self.novel_format.trim().to_ascii_lowercase().as_str() { |
| OUTPUT_FORMAT_EPUB => OUTPUT_FORMAT_EPUB, |
| OUTPUT_FORMAT_PDF => OUTPUT_FORMAT_PDF, |
| _ => OUTPUT_FORMAT_TXT, |
| } |
| } |
|
|
| pub fn current_output_format_choice(&self) -> &'static str { |
| if self.ask_format_after_download { |
| return OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD; |
| } |
| self.configured_output_format_choice() |
| } |
|
|
| pub fn apply_output_format_choice(&mut self, choice: &str) -> Result<(), String> { |
| let normalized = choice.trim().to_ascii_lowercase(); |
| match normalized.as_str() { |
| OUTPUT_FORMAT_TXT => { |
| self.novel_format = OUTPUT_FORMAT_TXT.to_string(); |
| self.bulk_files = false; |
| self.ask_format_after_download = false; |
| } |
| OUTPUT_FORMAT_EPUB => { |
| self.novel_format = OUTPUT_FORMAT_EPUB.to_string(); |
| self.bulk_files = false; |
| self.ask_format_after_download = false; |
| } |
| OUTPUT_FORMAT_PDF => { |
| self.novel_format = OUTPUT_FORMAT_PDF.to_string(); |
| self.bulk_files = false; |
| self.ask_format_after_download = false; |
| } |
| OUTPUT_FORMAT_BULK_TXT => { |
| self.novel_format = OUTPUT_FORMAT_TXT.to_string(); |
| self.bulk_files = true; |
| self.ask_format_after_download = false; |
| } |
| OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD => { |
| self.ask_format_after_download = true; |
| self.bulk_files = false; |
| if self.novel_format.trim().is_empty() { |
| self.novel_format = default_novel_format(); |
| } |
| } |
| _ => { |
| return Err("保存格式仅支持 txt/epub/pdf/散装文件/下载后选择".to_string()); |
| } |
| } |
|
|
| self.normalize_output_format_fields(); |
| Ok(()) |
| } |
|
|
| pub fn normalize_output_format_fields(&mut self) { |
| let mut normalized = self.novel_format.trim().to_ascii_lowercase(); |
| if normalized == OUTPUT_FORMAT_BULK_TXT { |
| normalized = OUTPUT_FORMAT_TXT.to_string(); |
| self.bulk_files = true; |
| } |
| if normalized != OUTPUT_FORMAT_TXT |
| && normalized != OUTPUT_FORMAT_EPUB |
| && normalized != OUTPUT_FORMAT_PDF |
| { |
| normalized = default_novel_format(); |
| } |
|
|
| self.novel_format = normalized; |
| if self.ask_format_after_download || self.novel_format != OUTPUT_FORMAT_TXT { |
| self.bulk_files = false; |
| } |
| } |
|
|
| |
| pub fn resolve_pdf_font_path(&self) -> Option<PathBuf> { |
| if let Some(ref p) = self.pdf_font_path { |
| let p = PathBuf::from(p); |
| if p.exists() { |
| return Some(p); |
| } |
| } |
| |
| let candidates: &[&str] = if cfg!(target_os = "windows") { |
| |
| &[ |
| r"C:\Windows\Fonts\simhei.ttf", |
| r"C:\Windows\Fonts\simkai.ttf", |
| r"C:\Windows\Fonts\msyh.ttf", |
| r"C:\Windows\Fonts\simsun.ttf", |
| ] |
| } else if cfg!(target_os = "macos") { |
| &[ |
| "/System/Library/Fonts/PingFang.ttc", |
| "/Library/Fonts/Arial Unicode.ttf", |
| ] |
| } else { |
| &[ |
| "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", |
| "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", |
| "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", |
| "/usr/share/fonts/wenquanyi/wqy-microhei.ttc", |
| "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", |
| ] |
| }; |
| candidates.iter().map(PathBuf::from).find(|p| p.exists()) |
| } |
|
|
| pub fn default_save_dir(&self) -> PathBuf { |
| if self.save_path.trim().is_empty() { |
| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) |
| } else { |
| PathBuf::from(&self.save_path) |
| } |
| } |
|
|
| pub fn find_existing_status_folder_by_book_id( |
| &self, |
| book_id: &str, |
| save_dir: Option<&Path>, |
| ) -> io::Result<Option<PathBuf>> { |
| let save_dir = save_dir |
| .map(PathBuf::from) |
| .unwrap_or_else(|| self.default_save_dir()); |
| if !save_dir.exists() { |
| return Ok(None); |
| } |
|
|
| let canonical_path = Self::canonical_status_folder_path(&save_dir, book_id); |
| if canonical_path.is_dir() && Self::status_folder_has_book_record(&canonical_path, book_id) |
| { |
| return Ok(Some(canonical_path)); |
| } |
|
|
| Ok(Self::legacy_status_folders_by_book_id(&save_dir, book_id)? |
| .into_iter() |
| .next()) |
| } |
|
|
| pub fn migrate_status_folder_to_stable( |
| &self, |
| book_id: &str, |
| save_dir: Option<&Path>, |
| ) -> io::Result<PathBuf> { |
| let save_dir = save_dir |
| .map(PathBuf::from) |
| .unwrap_or_else(|| self.default_save_dir()); |
| let canonical_path = Self::canonical_status_folder_path(&save_dir, book_id); |
| let legacy_folders = if save_dir.exists() { |
| Self::legacy_status_folders_by_book_id(&save_dir, book_id)? |
| } else { |
| Vec::new() |
| }; |
|
|
| if canonical_path.exists() { |
| if canonical_path.is_dir() { |
| for legacy in legacy_folders { |
| if legacy != canonical_path { |
| let _ = merge_dir_contents(&legacy, &canonical_path); |
| } |
| } |
| } |
| return Ok(canonical_path); |
| } |
|
|
| let mut legacy_iter = legacy_folders.into_iter(); |
| let Some(first_legacy) = legacy_iter.next() else { |
| return Ok(canonical_path); |
| }; |
|
|
| match fs::rename(&first_legacy, &canonical_path) { |
| Ok(_) => { |
| for legacy in legacy_iter { |
| let _ = merge_dir_contents(&legacy, &canonical_path); |
| } |
| Ok(canonical_path) |
| } |
| Err(_) => Ok(first_legacy), |
| } |
| } |
|
|
| fn canonical_status_folder_path(save_dir: &Path, book_id: &str) -> PathBuf { |
| let safe_book_id = safe_fs_name(book_id, "_", 120); |
| save_dir.join(safe_book_id) |
| } |
|
|
| fn legacy_status_folders_by_book_id( |
| save_dir: &Path, |
| book_id: &str, |
| ) -> io::Result<Vec<PathBuf>> { |
| if !save_dir.exists() { |
| return Ok(Vec::new()); |
| } |
|
|
| let safe_book_id = safe_fs_name(book_id, "_", 120); |
| let prefix = format!("{}_", safe_book_id); |
| let mut folders = Vec::new(); |
|
|
| for entry in fs::read_dir(save_dir)? { |
| let entry = entry?; |
| let path = entry.path(); |
| if !path.is_dir() { |
| continue; |
| } |
|
|
| let Some(name) = path.file_name().and_then(OsStr::to_str) else { |
| continue; |
| }; |
|
|
| if !name.starts_with(&prefix) { |
| continue; |
| } |
|
|
| if Self::status_folder_has_book_record(&path, book_id) { |
| folders.push(path); |
| } |
| } |
|
|
| folders.sort(); |
| Ok(folders) |
| } |
|
|
| fn status_folder_has_book_record(path: &Path, book_id: &str) -> bool { |
| let status = path.join("status.json"); |
| if status.exists() { |
| if let Ok(raw) = fs::read_to_string(&status) |
| && let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) |
| && let Some(id) = value.get("book_id").and_then(|v| v.as_str()) |
| { |
| return id == book_id; |
| } |
| return true; |
| } |
|
|
| path.join(format!("chapter_status_{}.json", book_id)) |
| .exists() |
| || path.join("downloaded_chapters.jsonl").exists() |
| } |
|
|
| |
| pub fn pick_preferred_book_name( |
| &self, |
| book_meta: &crate::download::downloader::BookMeta, |
| ) -> Option<String> { |
| match self.preferred_book_name_field.as_str() { |
| "original_book_name" => book_meta |
| .original_book_name |
| .clone() |
| .or_else(|| book_meta.book_name.clone()), |
| "book_short_name" => book_meta |
| .book_short_name |
| .clone() |
| .or_else(|| book_meta.book_name.clone()), |
| |
| "ask_after_download" => book_meta.book_name.clone(), |
| _ => book_meta.book_name.clone(), |
| } |
| } |
|
|
| |
| pub fn is_ask_after_download(&self) -> bool { |
| self.preferred_book_name_field == "ask_after_download" |
| } |
|
|
| pub fn get_status_folder_path(&self) -> Option<PathBuf> { |
| self.folder_path.clone() |
| } |
|
|
| pub fn mark_status_folder_claimed(&mut self, path: &Path) { |
| for entry in &mut self.status_registry { |
| if entry.path == path { |
| entry.claimed = true; |
| } |
| } |
| if let Some(last) = &self.folder_path |
| && last == path |
| { |
| self.last_status_claimed = true; |
| } |
| } |
|
|
| pub fn mark_status_folder_removed(&mut self, path: &Path) { |
| self.status_registry.retain(|e| e.path != path); |
| if let Some(last) = &self.folder_path |
| && last == path |
| { |
| self.folder_path = None; |
| self.last_status_was_new = false; |
| self.last_status_claimed = false; |
| } |
| } |
|
|
| pub fn status_folder_was_created_this_session(&self, path: &Path) -> bool { |
| for entry in &self.status_registry { |
| if entry.path == path { |
| return entry.is_new; |
| } |
| } |
| if let Some(last) = &self.folder_path |
| && last == path |
| { |
| return self.last_status_was_new; |
| } |
| false |
| } |
|
|
| pub fn status_folder_path( |
| &mut self, |
| _book_name: &str, |
| book_id: &str, |
| save_dir: Option<&Path>, |
| ) -> io::Result<PathBuf> { |
| let save_dir = save_dir |
| .map(PathBuf::from) |
| .unwrap_or_else(|| self.default_save_dir()); |
| let path = self.migrate_status_folder_to_stable(book_id, Some(&save_dir))?; |
| let existed_before = path.exists() && Self::status_folder_has_book_record(&path, book_id); |
| if !path.exists() { |
| fs::create_dir_all(&path)?; |
| } |
|
|
| if let Some(prev) = &self.folder_path |
| && self.last_status_was_new |
| && !self.last_status_claimed |
| && prev != &path |
| { |
| let _ = fs::remove_dir_all(prev); |
| } |
|
|
| self.folder_path = Some(path.clone()); |
| self.register_status_folder(&path, existed_before); |
| Ok(path) |
| } |
|
|
| fn register_status_folder(&mut self, path: &Path, existed_before: bool) { |
| let entry = self.status_registry.iter_mut().find(|e| e.path == path); |
|
|
| let is_new_this_session = |
| (!existed_before) || entry.as_ref().is_some_and(|e| e.is_new && !e.claimed); |
|
|
| match entry { |
| Some(e) => { |
| if is_new_this_session { |
| e.is_new = true; |
| e.claimed = false; |
| } |
| } |
| None => self.status_registry.push(StatusEntry { |
| path: path.to_path_buf(), |
| is_new: is_new_this_session, |
| claimed: false, |
| }), |
| } |
|
|
| self.last_status_was_new = is_new_this_session; |
| self.last_status_claimed = false; |
| } |
| } |
|
|
| fn merge_dir_contents(src: &Path, dst: &Path) -> io::Result<()> { |
| merge_dir_contents_at_depth(src, dst, true) |
| } |
|
|
| fn merge_dir_contents_at_depth(src: &Path, dst: &Path, root_level: bool) -> io::Result<()> { |
| fs::create_dir_all(dst)?; |
|
|
| for entry in fs::read_dir(src)? { |
| let entry = entry?; |
| let src_path = entry.path(); |
| let dst_path = dst.join(entry.file_name()); |
|
|
| if root_level && src_path.is_file() && is_cover_image_file(&src_path) { |
| merge_root_cover_file(&src_path, dst)?; |
| continue; |
| } |
|
|
| if !dst_path.exists() { |
| if fs::rename(&src_path, &dst_path).is_err() { |
| if src_path.is_dir() { |
| fs::create_dir_all(&dst_path)?; |
| merge_dir_contents_at_depth(&src_path, &dst_path, false)?; |
| let _ = fs::remove_dir_all(&src_path); |
| } else { |
| fs::copy(&src_path, &dst_path)?; |
| fs::remove_file(&src_path)?; |
| } |
| } |
| continue; |
| } |
|
|
| if src_path.is_dir() && dst_path.is_dir() { |
| merge_dir_contents_at_depth(&src_path, &dst_path, false)?; |
| let _ = fs::remove_dir(&src_path); |
| } else if src_path.is_file() && dst_path.is_file() { |
| merge_conflicting_file(&src_path, &dst_path)?; |
| } else if src_path.is_dir() { |
| let _ = fs::remove_dir_all(&src_path); |
| } else { |
| let _ = fs::remove_file(&src_path); |
| } |
| } |
|
|
| let _ = fs::remove_dir(src); |
| Ok(()) |
| } |
|
|
| fn is_cover_image_file(path: &Path) -> bool { |
| let Some(ext) = path.extension().and_then(OsStr::to_str) else { |
| return false; |
| }; |
| matches!( |
| ext.to_ascii_lowercase().as_str(), |
| "jpg" | "jpeg" | "png" | "webp" | "gif" | "heic" | "heif" |
| ) |
| } |
|
|
| fn merge_root_cover_file(src: &Path, dst_folder: &Path) -> io::Result<()> { |
| let ext = src.extension().and_then(OsStr::to_str).unwrap_or("jpg"); |
| let canonical = dst_folder.join(format!("cover.{ext}")); |
| if canonical.exists() { |
| fs::remove_file(src)?; |
| return Ok(()); |
| } |
|
|
| if fs::rename(src, &canonical).is_err() { |
| fs::copy(src, &canonical)?; |
| fs::remove_file(src)?; |
| } |
| Ok(()) |
| } |
|
|
| fn merge_conflicting_file(src: &Path, dst: &Path) -> io::Result<()> { |
| let file_name = src.file_name().and_then(OsStr::to_str).unwrap_or_default(); |
|
|
| if file_name == "downloaded_chapters.jsonl" { |
| let bytes = fs::read(src)?; |
| let mut out = fs::OpenOptions::new().append(true).open(dst)?; |
| if !bytes.is_empty() { |
| out.write_all(b"\n")?; |
| out.write_all(&bytes)?; |
| } |
| fs::remove_file(src)?; |
| return Ok(()); |
| } |
|
|
| if file_name == "status.json" && status_downloaded_count(src) > status_downloaded_count(dst) { |
| fs::copy(src, dst)?; |
| } |
|
|
| let _ = fs::remove_file(src); |
| Ok(()) |
| } |
|
|
| fn status_downloaded_count(path: &Path) -> usize { |
| let Ok(raw) = fs::read_to_string(path) else { |
| return 0; |
| }; |
| let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) else { |
| return 0; |
| }; |
| value |
| .get("downloaded") |
| .and_then(|v| v.as_object()) |
| .map(|m| m.len()) |
| .unwrap_or(0) |
| } |
|
|
| #[cfg(test)] |
| #[allow(clippy::items_after_test_module)] |
| mod tests { |
| use super::{ |
| Config, OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD, OUTPUT_FORMAT_BULK_TXT, OUTPUT_FORMAT_TXT, |
| }; |
|
|
| #[test] |
| fn status_folder_path_migrates_legacy_folder_when_only_book_name_changes() { |
| let temp_dir = tempfile::tempdir().unwrap(); |
| let mut config = Config { |
| save_path: temp_dir.path().display().to_string(), |
| ..Config::default() |
| }; |
|
|
| let old_folder = temp_dir.path().join("123_旧书名"); |
| std::fs::create_dir_all(&old_folder).unwrap(); |
| std::fs::write(old_folder.join("status.json"), "{}\n").unwrap(); |
|
|
| let resolved = config.status_folder_path("新书名", "123", None).unwrap(); |
| let stable_folder = temp_dir.path().join("123"); |
| assert_eq!(resolved, stable_folder); |
| assert!(stable_folder.join("status.json").exists()); |
| assert!(!old_folder.exists()); |
| assert!(!temp_dir.path().join("123_新书名").exists()); |
| } |
|
|
| #[test] |
| fn status_folder_path_merges_legacy_status_into_preview_cover_folder() { |
| let temp_dir = tempfile::tempdir().unwrap(); |
| let mut config = Config { |
| save_path: temp_dir.path().display().to_string(), |
| ..Config::default() |
| }; |
|
|
| let preview_folder = temp_dir.path().join("123"); |
| std::fs::create_dir_all(&preview_folder).unwrap(); |
| std::fs::write(preview_folder.join("cover.jpg"), b"cover").unwrap(); |
|
|
| let old_folder = temp_dir.path().join("123_旧书名"); |
| std::fs::create_dir_all(&old_folder).unwrap(); |
| std::fs::write( |
| old_folder.join("status.json"), |
| r#"{"book_id":"123","downloaded":{"1":["第一章","内容"]}}"#, |
| ) |
| .unwrap(); |
|
|
| let resolved = config.status_folder_path("新书名", "123", None).unwrap(); |
|
|
| assert_eq!(resolved, preview_folder); |
| assert!(preview_folder.join("cover.jpg").exists()); |
| assert!(preview_folder.join("status.json").exists()); |
| assert!(!old_folder.exists()); |
| } |
|
|
| #[test] |
| fn bulk_output_mode_is_mapped_to_txt_plus_bulk_flag() { |
| let mut config = Config::default(); |
| config |
| .apply_output_format_choice(OUTPUT_FORMAT_BULK_TXT) |
| .unwrap(); |
|
|
| assert_eq!(config.novel_format, OUTPUT_FORMAT_TXT); |
| assert!(config.bulk_files); |
| assert!(!config.ask_format_after_download); |
| assert_eq!( |
| config.current_output_format_choice(), |
| OUTPUT_FORMAT_BULK_TXT |
| ); |
| } |
|
|
| #[test] |
| fn ask_after_download_clears_bulk_flag() { |
| let mut config = Config { |
| bulk_files: true, |
| novel_format: OUTPUT_FORMAT_TXT.to_string(), |
| ..Config::default() |
| }; |
|
|
| config |
| .apply_output_format_choice(OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD) |
| .unwrap(); |
|
|
| assert!(config.ask_format_after_download); |
| assert!(!config.bulk_files); |
| assert_eq!( |
| config.current_output_format_choice(), |
| OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD |
| ); |
| } |
|
|
| #[test] |
| fn configured_output_format_choice_preserves_real_default_when_asking_later() { |
| let mut config = Config::default(); |
| config |
| .apply_output_format_choice(OUTPUT_FORMAT_BULK_TXT) |
| .unwrap(); |
| config |
| .apply_output_format_choice(OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD) |
| .unwrap(); |
|
|
| assert_eq!( |
| config.current_output_format_choice(), |
| OUTPUT_FORMAT_ASK_AFTER_DOWNLOAD |
| ); |
| assert_eq!(config.configured_output_format_choice(), OUTPUT_FORMAT_TXT); |
|
|
| config.bulk_files = true; |
| assert_eq!( |
| config.configured_output_format_choice(), |
| OUTPUT_FORMAT_BULK_TXT |
| ); |
| } |
|
|
| #[test] |
| fn safe_fs_name_replaces_windows_double_quote() { |
| let sanitized = super::safe_fs_name("第1章 \"你好\"", "_", 120); |
|
|
| assert!(!sanitized.contains('"')); |
| assert!(sanitized.contains('"')); |
| } |
| } |
|
|
| pub fn safe_fs_name(name: &str, replacement: &str, max_len: usize) -> String { |
| let mut cleaned: String = name |
| .chars() |
| .map(|ch| match ch { |
| |
| ':' => ':', |
| '"' => '"', |
| '<' => '《', |
| '>' => '》', |
| '/' | '\\' => '、', |
| '|' => '|', |
| '?' => '?', |
| '*' => '*', |
| c if (c as u32) < 32 => replacement.chars().next().unwrap_or('_'), |
| _ => ch, |
| }) |
| .collect(); |
|
|
| while cleaned.ends_with(' ') || cleaned.ends_with('.') { |
| cleaned.pop(); |
| } |
|
|
| if cleaned.is_empty() { |
| cleaned.push_str("unnamed"); |
| } |
|
|
| const RESERVED: [&str; 22] = [ |
| "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", |
| "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", |
| ]; |
| let upper = cleaned.to_uppercase(); |
| if RESERVED.contains(&upper.as_str()) { |
| cleaned = format!("_{}", cleaned); |
| } |
|
|
| if cleaned.len() > max_len { |
| |
| let mut end = max_len; |
| while !cleaned.is_char_boundary(end) && end > 0 { |
| end -= 1; |
| } |
| cleaned.truncate(end); |
| while cleaned.ends_with(' ') || cleaned.ends_with('.') { |
| cleaned.pop(); |
| } |
| if cleaned.is_empty() { |
| cleaned.push_str("unnamed"); |
| } |
| } |
|
|
| cleaned |
| } |
|
|
| fn default_false() -> bool { |
| false |
| } |
|
|
| fn default_true() -> bool { |
| true |
| } |
|
|
| fn default_max_workers() -> usize { |
| 1 |
| } |
|
|
| fn default_request_timeout() -> u64 { |
| 15 |
| } |
|
|
| fn default_max_retries() -> u32 { |
| 3 |
| } |
|
|
| fn default_max_wait_time() -> u64 { |
| 1200 |
| } |
|
|
| fn default_min_wait_time() -> u64 { |
| 1000 |
| } |
|
|
| fn default_min_connect_timeout() -> f64 { |
| 3.05 |
| } |
|
|
| fn default_novel_format() -> String { |
| "epub".to_string() |
| } |
|
|
| fn default_audiobook_voice() -> String { |
| "zh-CN-XiaoxiaoNeural".to_string() |
| } |
|
|
| fn default_audiobook_rate() -> String { |
| "+0%".to_string() |
| } |
|
|
| fn default_audiobook_volume() -> String { |
| "+0%".to_string() |
| } |
|
|
| fn default_audiobook_pitch() -> String { |
| String::new() |
| } |
|
|
| fn default_audiobook_format() -> String { |
| "mp3".to_string() |
| } |
|
|
| fn default_audiobook_concurrency() -> usize { |
| 24 |
| } |
|
|
| fn default_audiobook_tts_provider() -> String { |
| "edge".to_string() |
| } |
|
|
| fn default_string() -> String { |
| String::new() |
| } |
|
|
| fn default_segment_comments_top_n() -> usize { |
| 10 |
| } |
|
|
| fn default_segment_comments_workers() -> usize { |
| 32 |
| } |
|
|
| fn default_media_download_workers() -> usize { |
| 8 |
| } |
|
|
| fn default_blocked_media_domains() -> Vec<String> { |
| vec!["p-passport-sign.bytedance.net".to_string()] |
| } |
|
|
| fn default_jpeg_quality() -> u8 { |
| 90 |
| } |
|
|
| fn default_first_line_indent_em() -> f32 { |
| 2.0 |
| } |
|
|
| fn default_media_limit_per_chapter() -> usize { |
| 0 |
| } |
|
|
| fn default_media_max_dimension_px() -> u32 { |
| 1280 |
| } |
|
|
| fn default_preferred_book_name_field() -> String { |
| "book_name".to_string() |
| } |
|
|