| use std::collections::{HashMap, HashSet, VecDeque}; |
| use std::net::IpAddr; |
| use std::net::SocketAddr; |
| use std::path::PathBuf; |
| use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; |
| use std::sync::{Arc, Mutex}; |
| use std::time::{SystemTime, UNIX_EPOCH}; |
|
|
| use serde::Serialize; |
| use sha2::{Digest, Sha256}; |
| use uuid::Uuid; |
|
|
| use crate::base_system::context::Config; |
| use crate::download::downloader::{BookNameOption, ProgressSnapshot}; |
|
|
| #[derive(Clone, Debug)] |
| pub(crate) struct ConfigView { |
| pub(crate) old_cli: bool, |
| pub(crate) use_official_api: bool, |
| pub(crate) save_path: String, |
| pub(crate) api_endpoints_len: usize, |
| } |
|
|
| #[derive(Clone)] |
| pub(crate) struct AppState { |
| pub(crate) bind_addrs: Arc<Vec<SocketAddr>>, |
| pub(crate) config_view: Arc<ConfigView>, |
| pub(crate) config: Arc<Mutex<Config>>, |
| pub(crate) config_path: Arc<PathBuf>, |
| pub(crate) library_root: Arc<PathBuf>, |
| pub(crate) jobs: Arc<JobStore>, |
| pub(crate) self_update: Arc<SelfUpdateStore>, |
| pub(crate) library_scan: Arc<LibraryScanStore>, |
| pub(crate) update_scan: Arc<UpdateScanStore>, |
| pub(crate) auth: Option<AuthState>, |
| |
| |
| #[cfg(feature = "official-api")] |
| pub(crate) api_semaphore: Arc<tokio::sync::Semaphore>, |
| } |
|
|
| #[derive(Debug, Clone, Copy, Serialize)] |
| #[serde(rename_all = "snake_case")] |
| pub(crate) enum SelfUpdateState { |
| Idle, |
| Running, |
| Done, |
| Failed, |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub(crate) struct SelfUpdateInfo { |
| pub(crate) state: SelfUpdateState, |
| pub(crate) stage: String, |
| pub(crate) percent: u8, |
| pub(crate) message: String, |
| pub(crate) updated_ms: u64, |
| } |
|
|
| #[derive(Debug)] |
| pub(crate) struct SelfUpdateStore { |
| running: AtomicBool, |
| inner: Mutex<SelfUpdateInfo>, |
| } |
|
|
| impl Default for SelfUpdateStore { |
| fn default() -> Self { |
| Self { |
| running: AtomicBool::new(false), |
| inner: Mutex::new(SelfUpdateInfo { |
| state: SelfUpdateState::Idle, |
| stage: "idle".to_string(), |
| percent: 0, |
| message: "尚未开始".to_string(), |
| updated_ms: now_ms(), |
| }), |
| } |
| } |
| } |
|
|
| impl SelfUpdateStore { |
| pub(crate) fn try_start(&self) -> bool { |
| if self |
| .running |
| .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) |
| .is_err() |
| { |
| return false; |
| } |
| self.set(SelfUpdateState::Running, "prepare", 2, "准备开始自更新…"); |
| true |
| } |
|
|
| pub(crate) fn snapshot(&self) -> SelfUpdateInfo { |
| self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone() |
| } |
|
|
| pub(crate) fn set( |
| &self, |
| state: SelfUpdateState, |
| stage: impl Into<String>, |
| percent: u8, |
| message: impl Into<String>, |
| ) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.state = state; |
| g.stage = stage.into(); |
| g.percent = percent.min(100); |
| g.message = message.into(); |
| g.updated_ms = now_ms(); |
| } |
|
|
| pub(crate) fn tick_running(&self) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| if !matches!(g.state, SelfUpdateState::Running) { |
| return; |
| } |
| if g.percent < 92 { |
| g.percent = (g.percent + 1).min(92); |
| g.updated_ms = now_ms(); |
| } |
| } |
|
|
| pub(crate) fn finish_done(&self, stage: impl Into<String>, message: impl Into<String>) { |
| self.set(SelfUpdateState::Done, stage, 100, message); |
| self.running.store(false, Ordering::SeqCst); |
| } |
|
|
| pub(crate) fn finish_failed(&self, stage: impl Into<String>, message: impl Into<String>) { |
| self.set(SelfUpdateState::Failed, stage, 100, message); |
| self.running.store(false, Ordering::SeqCst); |
| } |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub(crate) struct LibraryScanRow { |
| pub(crate) kind: String, |
| pub(crate) name: String, |
| pub(crate) rel_path: String, |
| pub(crate) ext: String, |
| pub(crate) size: u64, |
| #[serde(skip_serializing_if = "Option::is_none")] |
| pub(crate) file_count: Option<u64>, |
| pub(crate) modified_ms: Option<u64>, |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub(crate) struct LibraryScanInfo { |
| pub(crate) path: String, |
| pub(crate) running: bool, |
| pub(crate) scanned: usize, |
| pub(crate) items: Vec<LibraryScanRow>, |
| pub(crate) error: Option<String>, |
| pub(crate) started_ms: u64, |
| pub(crate) updated_ms: u64, |
| } |
|
|
| #[derive(Debug, Default)] |
| struct LibraryScanState { |
| infos: HashMap<String, LibraryScanInfo>, |
| running: HashSet<String>, |
| } |
|
|
| #[derive(Debug, Default)] |
| pub(crate) struct LibraryScanStore { |
| inner: Mutex<LibraryScanState>, |
| } |
|
|
| impl LibraryScanStore { |
| pub(crate) fn try_start(&self, path: String) -> bool { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| if !g.running.insert(path.clone()) { |
| return false; |
| } |
|
|
| let now = now_ms(); |
| g.infos.insert( |
| path.clone(), |
| LibraryScanInfo { |
| path, |
| running: true, |
| scanned: 0, |
| items: Vec::new(), |
| error: None, |
| started_ms: now, |
| updated_ms: now, |
| }, |
| ); |
| true |
| } |
|
|
| pub(crate) fn push_item(&self, path: &str, item: LibraryScanRow, scanned: usize) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| if let Some(info) = g.infos.get_mut(path) { |
| info.running = true; |
| info.scanned = scanned; |
| info.items.push(item); |
| info.updated_ms = now_ms(); |
| } |
| } |
|
|
| pub(crate) fn finish(&self, path: &str, mut items: Vec<LibraryScanRow>) { |
| items.sort_by(|a, b| { |
| b.modified_ms |
| .cmp(&a.modified_ms) |
| .then_with(|| a.name.cmp(&b.name)) |
| }); |
|
|
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.running.remove(path); |
| let now = now_ms(); |
| let started_ms = g.infos.get(path).map(|info| info.started_ms).unwrap_or(now); |
| g.infos.insert( |
| path.to_string(), |
| LibraryScanInfo { |
| path: path.to_string(), |
| running: false, |
| scanned: items.len(), |
| items, |
| error: None, |
| started_ms, |
| updated_ms: now, |
| }, |
| ); |
| } |
|
|
| pub(crate) fn finish_failed(&self, path: &str, error: String) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.running.remove(path); |
| let now = now_ms(); |
| let info = g.infos.entry(path.to_string()).or_insert(LibraryScanInfo { |
| path: path.to_string(), |
| running: false, |
| scanned: 0, |
| items: Vec::new(), |
| error: None, |
| started_ms: now, |
| updated_ms: now, |
| }); |
| info.running = false; |
| info.error = Some(error); |
| info.updated_ms = now; |
| } |
|
|
| pub(crate) fn snapshot(&self, path: &str) -> Option<LibraryScanInfo> { |
| self.inner |
| .lock() |
| .unwrap_or_else(|e| e.into_inner()) |
| .infos |
| .get(path) |
| .cloned() |
| } |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub(crate) struct UpdateScanRow { |
| pub(crate) book_id: String, |
| pub(crate) book_name: String, |
| pub(crate) folder: String, |
| pub(crate) local_total: usize, |
| pub(crate) local_failed: usize, |
| pub(crate) remote_total: usize, |
| pub(crate) new_count: usize, |
| pub(crate) has_update: bool, |
| pub(crate) is_ignored: bool, |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub(crate) struct UpdateScanInfo { |
| pub(crate) running: bool, |
| pub(crate) scanned: usize, |
| pub(crate) total: usize, |
| pub(crate) save_dir: String, |
| pub(crate) updates: Vec<UpdateScanRow>, |
| pub(crate) no_updates: Vec<UpdateScanRow>, |
| pub(crate) error: Option<String>, |
| pub(crate) started_ms: u64, |
| pub(crate) updated_ms: u64, |
| } |
|
|
| #[derive(Debug)] |
| pub(crate) struct UpdateScanStore { |
| running: AtomicBool, |
| inner: Mutex<UpdateScanInfo>, |
| } |
|
|
| impl Default for UpdateScanStore { |
| fn default() -> Self { |
| Self { |
| running: AtomicBool::new(false), |
| inner: Mutex::new(UpdateScanInfo { |
| running: false, |
| scanned: 0, |
| total: 0, |
| save_dir: String::new(), |
| updates: Vec::new(), |
| no_updates: Vec::new(), |
| error: None, |
| started_ms: 0, |
| updated_ms: now_ms(), |
| }), |
| } |
| } |
| } |
|
|
| impl UpdateScanStore { |
| pub(crate) fn try_start(&self, save_dir: String) -> bool { |
| if self |
| .running |
| .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) |
| .is_err() |
| { |
| return false; |
| } |
|
|
| let now = now_ms(); |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| *g = UpdateScanInfo { |
| running: true, |
| scanned: 0, |
| total: 0, |
| save_dir, |
| updates: Vec::new(), |
| no_updates: Vec::new(), |
| error: None, |
| started_ms: now, |
| updated_ms: now, |
| }; |
| true |
| } |
|
|
| pub(crate) fn push_progress(&self, row: UpdateScanRow, scanned: usize, total: usize) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.running = true; |
| g.scanned = scanned; |
| g.total = total; |
| g.updated_ms = now_ms(); |
| if row.is_ignored || !row.has_update { |
| g.no_updates.push(row); |
| } else { |
| g.updates.push(row); |
| } |
| } |
|
|
| pub(crate) fn finish( |
| &self, |
| save_dir: String, |
| updates: Vec<UpdateScanRow>, |
| no_updates: Vec<UpdateScanRow>, |
| ) { |
| let total = updates.len() + no_updates.len(); |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.running = false; |
| g.scanned = total; |
| g.total = total; |
| g.save_dir = save_dir; |
| g.updates = updates; |
| g.no_updates = no_updates; |
| g.error = None; |
| g.updated_ms = now_ms(); |
| self.running.store(false, Ordering::SeqCst); |
| } |
|
|
| pub(crate) fn finish_failed(&self, error: String) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.running = false; |
| g.error = Some(error); |
| g.updated_ms = now_ms(); |
| self.running.store(false, Ordering::SeqCst); |
| } |
|
|
| pub(crate) fn snapshot(&self) -> UpdateScanInfo { |
| self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone() |
| } |
| } |
|
|
| #[derive(Clone)] |
| pub(crate) struct AuthState { |
| pub(crate) password_sha256: [u8; 32], |
| pub(crate) session_secret: [u8; 32], |
| cookie_secure: bool, |
| login_attempts: Arc<Mutex<HashMap<IpAddr, LoginAttemptState>>>, |
| } |
|
|
| const SESSION_TTL_SECS: u64 = 24 * 60 * 60; |
| const LOGIN_RATE_WINDOW_SECS: u64 = 1; |
| const LOGIN_RATE_MAX_ATTEMPTS: usize = 5; |
| const LOGIN_LOCK_AFTER_FAILURES: u32 = 10; |
| const LOGIN_LOCK_SECS: u64 = 5 * 60; |
|
|
| #[derive(Debug, Default)] |
| struct LoginAttemptState { |
| recent_attempts: VecDeque<u64>, |
| failed_count: u32, |
| locked_until: Option<u64>, |
| } |
|
|
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| pub(crate) enum LoginLimitDecision { |
| Allowed, |
| RateLimited { retry_after_secs: u64 }, |
| Locked { retry_after_secs: u64 }, |
| } |
|
|
| impl AuthState { |
| pub(crate) fn from_password(password: &str, cookie_secure: bool) -> Self { |
| let mut h = Sha256::new(); |
| h.update(password.as_bytes()); |
| let out = h.finalize(); |
| let mut password_sha256 = [0u8; 32]; |
| password_sha256.copy_from_slice(&out); |
|
|
| let nonce = Uuid::new_v4(); |
| let now = now_secs(); |
| let mut s = Sha256::new(); |
| s.update(password_sha256); |
| s.update(now.to_le_bytes()); |
| s.update(nonce.as_bytes()); |
| let secret = s.finalize(); |
| let mut session_secret = [0u8; 32]; |
| session_secret.copy_from_slice(&secret); |
|
|
| Self { |
| password_sha256, |
| session_secret, |
| cookie_secure, |
| login_attempts: Arc::new(Mutex::new(HashMap::new())), |
| } |
| } |
|
|
| pub(crate) fn check_login_allowed(&self, ip: IpAddr) -> LoginLimitDecision { |
| let now = now_secs(); |
| let mut attempts = self |
| .login_attempts |
| .lock() |
| .unwrap_or_else(|e| e.into_inner()); |
| let state = attempts.entry(ip).or_default(); |
|
|
| if let Some(locked_until) = state.locked_until { |
| if locked_until > now { |
| return LoginLimitDecision::Locked { |
| retry_after_secs: locked_until.saturating_sub(now).max(1), |
| }; |
| } |
| state.locked_until = None; |
| state.failed_count = 0; |
| state.recent_attempts.clear(); |
| } |
|
|
| let cutoff = now.saturating_sub(LOGIN_RATE_WINDOW_SECS); |
| while state |
| .recent_attempts |
| .front() |
| .map(|ts| *ts <= cutoff) |
| .unwrap_or(false) |
| { |
| state.recent_attempts.pop_front(); |
| } |
|
|
| if state.recent_attempts.len() >= LOGIN_RATE_MAX_ATTEMPTS { |
| let oldest = *state.recent_attempts.front().unwrap_or(&now); |
| let retry_after_secs = oldest |
| .saturating_add(LOGIN_RATE_WINDOW_SECS) |
| .saturating_sub(now) |
| .max(1); |
| return LoginLimitDecision::RateLimited { retry_after_secs }; |
| } |
|
|
| state.recent_attempts.push_back(now); |
| LoginLimitDecision::Allowed |
| } |
|
|
| pub(crate) fn record_login_failure(&self, ip: IpAddr) -> Option<u64> { |
| let now = now_secs(); |
| let mut attempts = self |
| .login_attempts |
| .lock() |
| .unwrap_or_else(|e| e.into_inner()); |
| let state = attempts.entry(ip).or_default(); |
| state.failed_count = state.failed_count.saturating_add(1); |
| if state.failed_count >= LOGIN_LOCK_AFTER_FAILURES { |
| let locked_until = now.saturating_add(LOGIN_LOCK_SECS); |
| state.locked_until = Some(locked_until); |
| state.recent_attempts.clear(); |
| Some(LOGIN_LOCK_SECS) |
| } else { |
| None |
| } |
| } |
|
|
| pub(crate) fn record_login_success(&self, ip: IpAddr) { |
| let mut attempts = self |
| .login_attempts |
| .lock() |
| .unwrap_or_else(|e| e.into_inner()); |
| attempts.remove(&ip); |
| } |
|
|
| pub(crate) fn issue_session_token(&self) -> String { |
| let exp = now_secs().saturating_add(SESSION_TTL_SECS); |
| let nonce = Uuid::new_v4().simple().to_string(); |
| let payload = format!("{exp}.{nonce}"); |
| let sig = self.sign_payload(&payload); |
| format!("{payload}.{sig}") |
| } |
|
|
| pub(crate) fn verify_session_token(&self, token: &str) -> bool { |
| let mut parts = token.split('.'); |
| let Some(exp_raw) = parts.next() else { |
| return false; |
| }; |
| let Some(nonce_raw) = parts.next() else { |
| return false; |
| }; |
| let Some(sig_raw) = parts.next() else { |
| return false; |
| }; |
| if parts.next().is_some() { |
| return false; |
| } |
|
|
| let Ok(exp) = exp_raw.parse::<u64>() else { |
| return false; |
| }; |
| if now_secs() > exp { |
| return false; |
| } |
|
|
| let payload = format!("{exp_raw}.{nonce_raw}"); |
| let expected = self.sign_payload(&payload); |
| constant_time_eq(sig_raw.as_bytes(), expected.as_bytes()) |
| } |
|
|
| pub(crate) fn session_ttl_secs(&self) -> u64 { |
| SESSION_TTL_SECS |
| } |
|
|
| pub(crate) fn cookie_secure(&self) -> bool { |
| self.cookie_secure |
| } |
|
|
| fn sign_payload(&self, payload: &str) -> String { |
| let mut h = Sha256::new(); |
| h.update(self.session_secret); |
| h.update(payload.as_bytes()); |
| hex::encode(h.finalize()) |
| } |
| } |
|
|
| fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { |
| if a.len() != b.len() { |
| return false; |
| } |
| let mut diff = 0u8; |
| for (x, y) in a.iter().zip(b.iter()) { |
| diff |= x ^ y; |
| } |
| diff == 0 |
| } |
|
|
| #[cfg(test)] |
| mod auth_tests { |
| use super::*; |
| use std::net::IpAddr; |
|
|
| #[test] |
| fn login_rate_limit_allows_five_attempts_per_second() { |
| let auth = AuthState::from_password("secret", false); |
| let ip = IpAddr::from([127, 0, 0, 1]); |
|
|
| for _ in 0..LOGIN_RATE_MAX_ATTEMPTS { |
| assert_eq!(auth.check_login_allowed(ip), LoginLimitDecision::Allowed); |
| } |
| assert!(matches!( |
| auth.check_login_allowed(ip), |
| LoginLimitDecision::RateLimited { .. } |
| )); |
| } |
|
|
| #[test] |
| fn repeated_failures_lock_ip_and_success_resets_state() { |
| let auth = AuthState::from_password("secret", false); |
| let ip = IpAddr::from([127, 0, 0, 2]); |
|
|
| for _ in 1..LOGIN_LOCK_AFTER_FAILURES { |
| assert_eq!(auth.record_login_failure(ip), None); |
| } |
| assert_eq!(auth.record_login_failure(ip), Some(LOGIN_LOCK_SECS)); |
| assert!(matches!( |
| auth.check_login_allowed(ip), |
| LoginLimitDecision::Locked { .. } |
| )); |
|
|
| auth.record_login_success(ip); |
| assert_eq!(auth.check_login_allowed(ip), LoginLimitDecision::Allowed); |
| } |
|
|
| #[test] |
| fn session_defaults_are_short_lived_and_secure_flag_is_configurable() { |
| let insecure = AuthState::from_password("secret", false); |
| let secure = AuthState::from_password("secret", true); |
|
|
| assert_eq!(insecure.session_ttl_secs(), 24 * 60 * 60); |
| assert!(!insecure.cookie_secure()); |
| assert!(secure.cookie_secure()); |
| assert!(secure.verify_session_token(&secure.issue_session_token())); |
| } |
| } |
|
|
| pub(crate) const RECENT_DONE_JOB_RETENTION_MS: u64 = 2 * 60 * 60 * 1000; |
|
|
| #[derive(Debug, Clone, Copy, Serialize)] |
| #[serde(rename_all = "snake_case")] |
| pub(crate) enum JobState { |
| Queued, |
| Running, |
| Done, |
| Failed, |
| Canceled, |
| } |
|
|
| impl JobState { |
| fn is_auto_prunable(self) -> bool { |
| matches!(self, JobState::Done) |
| } |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub(crate) struct JobInfo { |
| pub(crate) id: u64, |
| pub(crate) book_id: String, |
| pub(crate) title: Option<String>, |
| pub(crate) author: Option<String>, |
| pub(crate) state: JobState, |
| pub(crate) progress: Option<ProgressSnapshot>, |
| pub(crate) message: Option<String>, |
| pub(crate) book_name_options: Option<Vec<BookNameOption>>, |
| pub(crate) format_options: Option<Vec<BookNameOption>>, |
| pub(crate) created_ms: u64, |
| pub(crate) updated_ms: u64, |
| } |
|
|
| #[derive(Debug, Clone)] |
| pub(crate) struct JobHandle { |
| pub(crate) id: u64, |
| pub(crate) cancel: Arc<AtomicBool>, |
| } |
|
|
| #[derive(Debug)] |
| struct JobEntry { |
| info: JobInfo, |
| cancel: Arc<AtomicBool>, |
| book_name_sender: Option<std::sync::mpsc::Sender<Option<String>>>, |
| format_sender: Option<std::sync::mpsc::Sender<Option<String>>>, |
| } |
|
|
| #[derive(Debug, Default)] |
| pub(crate) struct JobStore { |
| next_id: AtomicU64, |
| inner: Mutex<HashMap<u64, JobEntry>>, |
| } |
|
|
| impl JobStore { |
| pub(crate) fn create(&self, book_id: String) -> JobHandle { |
| let id = self.next_id.fetch_add(1, Ordering::Relaxed) + 1; |
| let now = now_ms(); |
| let cancel = Arc::new(AtomicBool::new(false)); |
|
|
| let info = JobInfo { |
| id, |
| book_id, |
| title: None, |
| author: None, |
| state: JobState::Queued, |
| progress: None, |
| message: None, |
| book_name_options: None, |
| format_options: None, |
| created_ms: now, |
| updated_ms: now, |
| }; |
|
|
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.insert( |
| id, |
| JobEntry { |
| info, |
| cancel: cancel.clone(), |
| book_name_sender: None, |
| format_sender: None, |
| }, |
| ); |
|
|
| JobHandle { id, cancel } |
| } |
|
|
| pub(crate) fn list(&self) -> Vec<JobInfo> { |
| let g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let mut v: Vec<JobInfo> = g.values().map(|e| e.info.clone()).collect(); |
| v.sort_by(|a, b| { |
| b.updated_ms |
| .cmp(&a.updated_ms) |
| .then_with(|| b.id.cmp(&a.id)) |
| }); |
| v |
| } |
|
|
| pub(crate) fn prune_done_older_than(&self, retention_ms: u64) { |
| let cutoff = now_ms().saturating_sub(retention_ms); |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.retain(|_, e| !e.info.state.is_auto_prunable() || e.info.updated_ms >= cutoff); |
| } |
|
|
| |
| pub(crate) fn count_active(&self) -> usize { |
| let g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| g.values() |
| .filter(|e| matches!(e.info.state, JobState::Queued | JobState::Running)) |
| .count() |
| } |
|
|
| pub(crate) fn set_running(&self, id: u64) { |
| self.update(id, |j| { |
| j.state = JobState::Running; |
| j.message = None; |
| j.book_name_options = None; |
| j.format_options = None; |
| }); |
| } |
|
|
| pub(crate) fn set_meta(&self, id: u64, title: Option<String>, author: Option<String>) { |
| self.update(id, |j| { |
| j.title = title; |
| j.author = author; |
| }); |
| } |
|
|
| pub(crate) fn set_progress(&self, id: u64, snap: ProgressSnapshot) { |
| self.update(id, |j| { |
| j.progress = Some(snap); |
| }); |
| } |
|
|
| pub(crate) fn set_done(&self, id: u64) { |
| self.update(id, |j| { |
| j.state = JobState::Done; |
| j.message = None; |
| j.book_name_options = None; |
| j.format_options = None; |
| }); |
| } |
|
|
| pub(crate) fn set_failed(&self, id: u64, msg: String) { |
| self.update(id, |j| { |
| j.state = JobState::Failed; |
| j.message = Some(msg); |
| j.book_name_options = None; |
| j.format_options = None; |
| }); |
| } |
|
|
| pub(crate) fn request_cancel(&self, id: u64) -> bool { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(e) = g.get_mut(&id) else { |
| return false; |
| }; |
| e.cancel.store(true, Ordering::Relaxed); |
| e.info.state = JobState::Canceled; |
| e.info.message = Some("cancel requested".to_string()); |
| if let Some(tx) = e.book_name_sender.take() { |
| let _ = tx.send(None); |
| } |
| if let Some(tx) = e.format_sender.take() { |
| let _ = tx.send(None); |
| } |
| e.info.book_name_options = None; |
| e.info.format_options = None; |
| e.info.updated_ms = now_ms(); |
| true |
| } |
|
|
| pub(crate) fn request_cancel_and_remove(&self, id: u64) -> bool { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(mut e) = g.remove(&id) else { |
| return false; |
| }; |
| e.cancel.store(true, Ordering::Relaxed); |
| if let Some(tx) = e.book_name_sender.take() { |
| let _ = tx.send(None); |
| } |
| if let Some(tx) = e.format_sender.take() { |
| let _ = tx.send(None); |
| } |
| true |
| } |
|
|
| pub(crate) fn remove(&self, id: u64) -> bool { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(mut e) = g.remove(&id) else { |
| return false; |
| }; |
| if let Some(tx) = e.book_name_sender.take() { |
| let _ = tx.send(None); |
| } |
| if let Some(tx) = e.format_sender.take() { |
| let _ = tx.send(None); |
| } |
| true |
| } |
|
|
| pub(crate) fn set_book_name_options( |
| &self, |
| id: u64, |
| options: Vec<BookNameOption>, |
| sender: std::sync::mpsc::Sender<Option<String>>, |
| ) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(e) = g.get_mut(&id) else { |
| return; |
| }; |
| e.info.book_name_options = Some(options); |
| e.info.message = Some("等待选择书名".to_string()); |
| e.book_name_sender = Some(sender); |
| e.info.updated_ms = now_ms(); |
| } |
|
|
| pub(crate) fn submit_book_name_choice(&self, id: u64, choice: Option<String>) -> bool { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(e) = g.get_mut(&id) else { |
| return false; |
| }; |
| if let Some(tx) = e.book_name_sender.take() { |
| let _ = tx.send(choice); |
| e.info.book_name_options = None; |
| e.info.message = None; |
| e.info.updated_ms = now_ms(); |
| return true; |
| } |
| false |
| } |
|
|
| pub(crate) fn set_format_options( |
| &self, |
| id: u64, |
| options: Vec<BookNameOption>, |
| sender: std::sync::mpsc::Sender<Option<String>>, |
| ) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(e) = g.get_mut(&id) else { |
| return; |
| }; |
| e.info.format_options = Some(options); |
| e.info.message = Some("等待选择输出格式".to_string()); |
| e.format_sender = Some(sender); |
| e.info.updated_ms = now_ms(); |
| } |
|
|
| pub(crate) fn submit_format_choice(&self, id: u64, choice: Option<String>) -> bool { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(e) = g.get_mut(&id) else { |
| return false; |
| }; |
| if let Some(tx) = e.format_sender.take() { |
| let _ = tx.send(choice); |
| e.info.format_options = None; |
| e.info.message = None; |
| e.info.updated_ms = now_ms(); |
| return true; |
| } |
| false |
| } |
|
|
| fn update<F: FnOnce(&mut JobInfo)>(&self, id: u64, f: F) { |
| let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner()); |
| let Some(e) = g.get_mut(&id) else { |
| return; |
| }; |
| f(&mut e.info); |
| e.info.updated_ms = now_ms(); |
| } |
| } |
|
|
| fn now_ms() -> u64 { |
| SystemTime::now() |
| .duration_since(UNIX_EPOCH) |
| .unwrap_or_default() |
| .as_millis() as u64 |
| } |
|
|
| fn now_secs() -> u64 { |
| SystemTime::now() |
| .duration_since(UNIX_EPOCH) |
| .unwrap_or_default() |
| .as_secs() |
| } |
|
|