use super::{ BtFileInfo, BtRuntimeState, ControlFileVersion, ControlMetadata, DownloadEngine, DownloadId, DownloadRegistry, DownloadStatus, OptionKey, OptionValue, Path, PathBuf, RequestContext, RequestGroup, SessionFile, SessionFileEntry, StoredDownloadFile, StoredPieceIndex, StoredPieceState, infer_total_length, usize_to_u32, }; use std::collections::BTreeMap; use crate::{ResumeState, RetryAttempt}; impl DownloadEngine { /// Builds a serializable session file from every persistable group in the registry. pub(super) fn build_session_file(&self, session_path: &Path) -> SessionFile { let entries = self .registry .groups .values() .filter(|group| should_persist_group(*group.status())) .map(|group| build_session_entry(group, self.session.global_options(), session_path)) .collect(); SessionFile { entries } } /// Reconstructs the in-memory registry and waiting queue from a persisted session file. pub(super) fn rebuild_registry_from_session_file( &mut self, session_path: &Path, session_file: SessionFile, ) { let mut registry = DownloadRegistry::new(); let mut reserved_queue = Vec::new(); let mut max_gid = 0_u64; for entry in session_file.entries { let mut context = RequestContext::new(entry.uri.clone()); if entry.uris.is_empty() { context.replace_uris(vec![entry.uri.clone()]); } else { context.replace_uris(entry.uris.clone()); } let mut group = if let Some(gid) = DownloadId::parse_hex(&entry.gid) { max_gid = max_gid.max(gid.as_u64()); RequestGroup::with_context(gid, context) } else { let gid = registry.allocate_gid(); max_gid = max_gid.max(gid.as_u64()); RequestGroup::with_context(gid, context) }; restore_group_metadata(&mut group, entry.metadata); let control_path = entry .metadata_path .unwrap_or_else(|| control_path_for_session_entry(session_path, group.gid())); if let Ok(control) = aria2_rust_pro_storage::read_aria2_control_file(&control_path) { restore_control_metadata(&mut group, control); } if matches!( group.status(), DownloadStatus::Waiting | DownloadStatus::Paused ) { reserved_queue.push(group.gid()); } registry.insert(group); } registry.next_gid = max_gid.saturating_add(1).max(1); self.registry = registry; self.reserved_queue = reserved_queue; } } /// Returns whether a group status should be persisted into session artifacts. pub(super) fn should_persist_group(status: DownloadStatus) -> bool { !matches!(status, DownloadStatus::Complete | DownloadStatus::Removed) } /// Builds one persisted session entry from a request group. fn build_session_entry( group: &RequestGroup, global_options: &crate::session::GlobalOptions, session_path: &Path, ) -> SessionFileEntry { let target_path = resolve_target_path(group, global_options); let metadata = Some(build_group_metadata(group)); SessionFileEntry { gid: group.gid().to_string(), uri: group.uri().to_owned(), uris: group.uris().to_vec(), target_path, metadata_path: Some(control_path_for_session_entry(session_path, group.gid())), metadata, } } /// Serializes selected request-group runtime metadata into session-file string fields. fn build_group_metadata(group: &RequestGroup) -> BTreeMap { let mut metadata = BTreeMap::from([ ( "status".to_owned(), group.status().as_rpc_status().to_owned(), ), ( "num_connections".to_owned(), group.num_connections().to_string(), ), ( "download_speed".to_owned(), group.download_speed().to_string(), ), ( "upload_length".to_owned(), group.upload_length().to_string(), ), ( "completed_length".to_owned(), group.completed_length().to_string(), ), ("retry_count".to_owned(), group.retry_count().to_string()), ]); if let Some(resume_state) = group.resume_state() { metadata.insert( "resume_state".to_owned(), encode_resume_state_metadata(resume_state), ); } if !group.retry_attempts().is_empty() { metadata.insert( "retry_attempts".to_owned(), encode_retry_attempts_metadata(group.retry_attempts()), ); } for (key, value) in group.options().entries() { metadata.insert(format!("opt.{}", key.as_str()), option_value_text(value)); } if let Some(bt) = group.bt() { metadata.insert("bt.info_hash".to_owned(), bt.info_hash.clone()); metadata.insert("bt.metadata_only".to_owned(), bt.metadata_only.to_string()); if let Some(name) = &bt.name { metadata.insert("bt.name".to_owned(), escape_metadata_field(name)); } if let Some(magnet_uri) = &bt.magnet_uri { metadata.insert( "bt.magnet_uri".to_owned(), escape_metadata_field(magnet_uri), ); } if let Some(creation_date) = &bt.creation_date { metadata.insert( "bt.creation_date".to_owned(), escape_metadata_field(creation_date), ); } if let Some(comment) = &bt.comment { metadata.insert("bt.comment".to_owned(), escape_metadata_field(comment)); } metadata.insert("bt.files_count".to_owned(), bt.files.len().to_string()); for (index, file) in bt.files.iter().enumerate() { metadata.insert( format!("bt.file.{index}.path"), escape_metadata_field(&file.path), ); metadata.insert(format!("bt.file.{index}.length"), file.length.to_string()); metadata.insert( format!("bt.file.{index}.selected"), file.selected.to_string(), ); if let Some(piece_offset) = file.piece_offset { metadata.insert( format!("bt.file.{index}.piece_offset"), piece_offset.to_string(), ); } } } metadata } /// Resolves the output path that should be associated with a request group. pub(super) fn resolve_target_path( group: &RequestGroup, global_options: &crate::session::GlobalOptions, ) -> PathBuf { let dir = group .options() .get(&OptionKey::from("dir")) .or_else(|| global_options.get(&OptionKey::from("dir"))) .and_then(OptionValue::as_text) .map(PathBuf::from); let file_name = group .options() .get(&OptionKey::from("out")) .and_then(OptionValue::as_text) .map(str::to_owned) .or_else(|| uri_file_name(group.uri())) .unwrap_or_else(|| group.gid().to_string()); match dir { Some(dir) => dir.join(file_name), None => PathBuf::from(file_name), } } /// Extracts a best-effort file name from a URI path component. fn uri_file_name(uri: &str) -> Option { let trimmed = uri .split(['?', '#']) .next() .unwrap_or(uri) .trim_end_matches('/'); let candidate = trimmed.rsplit('/').next()?; if candidate.is_empty() { None } else { Some(candidate.to_owned()) } } /// Resolves the per-download control-file path relative to a session file path. pub(super) fn control_path_for_session_entry(session_path: &Path, gid: DownloadId) -> PathBuf { let parent = session_path .parent() .map_or_else(|| PathBuf::from("."), Path::to_path_buf); parent.join("control").join(format!("{gid}.aria2")) } /// Builds persisted control-file metadata for a request group. pub(super) fn build_control_metadata( group: &RequestGroup, target_path: &Path, piece_length: u64, ) -> ControlMetadata { let resolved_piece_length = group.piece_length().max(piece_length); let inferred_total_length = infer_total_length(group, resolved_piece_length); ControlMetadata { version: ControlFileVersion::CURRENT, files: vec![StoredDownloadFile { path: target_path.to_path_buf(), length: inferred_total_length, piece_length: resolved_piece_length, }], checksums: Vec::new(), completed_length: group.completed_length(), retry_count: group.retry_count(), last_error: group .retry_attempts() .last() .and_then(|attempt| attempt.error.clone()), last_error_at_unix_ms: None, last_retry_at_unix_ms: None, next_retry_at_unix_ms: None, consecutive_failure_count: Some(usize_to_u32(group.retry_attempts().len())), active_segment_count: Some(group.num_connections()), resume_verified_at_unix_ms: None, resume_generation: group .resume_state() .and_then(|resume_state| resume_state.persisted.then_some(1)), piece_states: group .piece_map() .iter() .map(|(piece, state)| { ( StoredPieceIndex(piece.0), map_piece_state_to_storage(*state), ) }) .collect(), } } /// Maps in-memory piece states into storage-layer piece states. fn map_piece_state_to_storage(state: crate::piece::PieceState) -> StoredPieceState { match state { crate::piece::PieceState::Verified => StoredPieceState::Verified, crate::piece::PieceState::Queued | crate::piece::PieceState::Downloading => { StoredPieceState::InFlight } crate::piece::PieceState::Pending | crate::piece::PieceState::Missing | crate::piece::PieceState::Skipped => StoredPieceState::Pending, } } /// Maps storage-layer piece states back into in-memory piece states. fn map_piece_state_from_storage(state: StoredPieceState) -> crate::piece::PieceState { match state { StoredPieceState::Pending => crate::piece::PieceState::Pending, StoredPieceState::InFlight => crate::piece::PieceState::Downloading, StoredPieceState::Verified => crate::piece::PieceState::Verified, StoredPieceState::Failed => crate::piece::PieceState::Missing, } } /// Serializes an option value into the session metadata text format. fn option_value_text(value: &OptionValue) -> String { match value { OptionValue::Bool(value) => value.to_string(), OptionValue::Int(value) => value.to_string(), OptionValue::UInt(value) => value.to_string(), OptionValue::Text(value) => value.clone(), OptionValue::List(value) => value.join(","), OptionValue::Map(value) => value .iter() .map(|(key, value)| format!("{key}={value}")) .collect::>() .join(","), OptionValue::Empty => String::default(), } } /// Restores request-group runtime metadata from persisted session metadata fields. fn restore_group_metadata(group: &mut RequestGroup, metadata: Option>) { let Some(metadata) = metadata else { return; }; let mut bt = BtRuntimeState::default(); let mut bt_seen = false; let mut bt_files: BTreeMap = BTreeMap::new(); for (key, value) in metadata { if restore_group_metadata_field(group, &key, &value) { continue; } if restore_bt_metadata_field(&mut bt, &mut bt_files, &key, &value) { bt_seen = true; } } if bt_seen { bt.files = bt_files.into_values().collect(); group.set_bt(bt); } } /// Restores one non-BitTorrent metadata field onto a request group. fn restore_group_metadata_field(group: &mut RequestGroup, key: &str, value: &str) -> bool { match key { "status" => { if let Some(status) = parse_status(value) { group.set_status(status); } true } "num_connections" => { if let Ok(parsed) = value.parse::() { group.set_num_connections(parsed); } true } "download_speed" => { if let Ok(parsed) = value.parse::() { group.set_download_speed(parsed); } true } "upload_length" => { if let Ok(parsed) = value.parse::() { group.set_upload_length(parsed); } true } "completed_length" => { if let Ok(parsed) = value.parse::() { group.set_completed_length(parsed); } true } "retry_count" => { if let Ok(parsed) = value.parse::() { group.set_retry_count(parsed); } true } "resume_state" => { if let Some(parsed) = decode_resume_state_metadata(value) { group.set_resume_state(parsed); } true } "retry_attempts" => { group.set_retry_attempts(decode_retry_attempts_metadata(value)); true } _ => key.strip_prefix("opt.").is_some_and(|option_key| { group.set_option(option_key.to_owned(), value.to_owned()); true }), } } /// Restores one `BitTorrent` metadata field. fn restore_bt_metadata_field( bt: &mut BtRuntimeState, bt_files: &mut BTreeMap, key: &str, value: &str, ) -> bool { match key { "bt.info_hash" => { value.clone_into(&mut bt.info_hash); true } "bt.metadata_only" => { bt.metadata_only = value.parse::().unwrap_or(false); true } "bt.name" => { bt.name = Some(unescape_metadata_field(value)); true } "bt.magnet_uri" => { bt.magnet_uri = Some(unescape_metadata_field(value)); true } "bt.creation_date" => { bt.creation_date = Some(unescape_metadata_field(value)); true } "bt.comment" => { bt.comment = Some(unescape_metadata_field(value)); true } _ => key .strip_prefix("bt.file.") .is_some_and(|rest| restore_bt_file_metadata_field(bt_files, rest, value)), } } /// Restores one `BitTorrent` file metadata field. fn restore_bt_file_metadata_field( bt_files: &mut BTreeMap, rest: &str, value: &str, ) -> bool { let mut parts = rest.split('.'); let Some(index_raw) = parts.next() else { return false; }; let Some(field) = parts.next() else { return false; }; if parts.next().is_some() { return false; } let Ok(index) = index_raw.parse::() else { return false; }; let file = bt_files.entry(index).or_default(); match field { "path" => file.path = unescape_metadata_field(value), "length" => { if let Ok(parsed) = value.parse::() { file.length = parsed; } } "selected" => { file.selected = value.parse::().unwrap_or(false); } "piece_offset" => { if let Ok(parsed) = value.parse::() { file.piece_offset = Some(parsed); } } _ => {} } true } /// Restores request-group progress and piece state from persisted control metadata. fn restore_control_metadata(group: &mut RequestGroup, control: ControlMetadata) { if let Some(file) = control.files.first() { group.set_total_length(file.length); group.set_piece_length(file.piece_length); } group.set_completed_length(control.completed_length); group.set_retry_count(control.retry_count); if control.retry_count > 0 && group.retry_attempts().is_empty() { let mut attempt = RetryAttempt::new(control.retry_count, control.completed_length); attempt.length = Some(control.completed_length); attempt.error.clone_from(&control.last_error); attempt.recoverable = true; group.push_retry_attempt(attempt); } if control.completed_length > 0 || control.resume_generation.is_some() { group.set_resume_state(ResumeState { persisted: control.resume_generation.is_some(), resume_offset: control.completed_length, validated_length: Some(control.completed_length), segment_cursor: None, }); } for (piece, state) in control.piece_states { group.set_piece_state( crate::piece::PieceId(piece.0), map_piece_state_from_storage(state), ); } } /// Encodes retry attempts into a compact session-metadata string. fn encode_retry_attempts_metadata(attempts: &[RetryAttempt]) -> String { attempts .iter() .map(|attempt| { let length = attempt .length .map_or_else(|| "-".to_owned(), |value| value.to_string()); let error = attempt.error.as_deref().unwrap_or("-"); format!( "{}:{}:{}:{}:{}", attempt.attempt, attempt.offset, length, u8::from(attempt.recoverable), escape_metadata_field(error) ) }) .collect::>() .join(",") } /// Decodes retry attempts from the compact session-metadata string. fn decode_retry_attempts_metadata(raw: &str) -> Vec { raw.split(',') .filter(|entry| !entry.trim().is_empty()) .filter_map(|entry| { let mut parts = entry.splitn(5, ':'); let attempt = parts.next()?.parse().ok()?; let offset = parts.next()?.parse().ok()?; let length = match parts.next()? { "-" => None, value => value.parse().ok(), }; let recoverable = matches!(parts.next()?, "1" | "true"); let error = match parts.next()? { "-" => None, value => Some(unescape_metadata_field(value)), }; Some(RetryAttempt { attempt, offset, length, error, recoverable, }) }) .collect() } /// Encodes resume-state metadata into a compact session-metadata string. fn encode_resume_state_metadata(resume_state: &ResumeState) -> String { let validated_length = resume_state .validated_length .map_or_else(|| "-".to_owned(), |value| value.to_string()); let segment_cursor = resume_state .segment_cursor .map_or_else(|| "-".to_owned(), |piece| piece.0.to_string()); format!( "{}:{}:{}:{}", u8::from(resume_state.persisted), resume_state.resume_offset, validated_length, segment_cursor ) } /// Decodes resume-state metadata from the compact session-metadata string. fn decode_resume_state_metadata(raw: &str) -> Option { let mut parts = raw.splitn(4, ':'); let persisted = matches!(parts.next()?, "1" | "true"); let resume_offset = parts.next()?.parse().ok()?; let validated_length = match parts.next()? { "-" => None, value => value.parse().ok(), }; let segment_cursor = match parts.next()? { "-" => None, value => value.parse().ok().map(crate::piece::PieceId), }; Some(ResumeState { persisted, resume_offset, validated_length, segment_cursor, }) } /// Escapes reserved delimiters used by compact metadata encodings. fn escape_metadata_field(raw: &str) -> String { raw.replace('\\', "\\\\") .replace(',', "\\c") .replace(':', "\\d") } /// Reverses `escape_metadata_field`. fn unescape_metadata_field(raw: &str) -> String { let mut out = String::new(); let mut chars = raw.chars(); while let Some(ch) = chars.next() { if ch == '\\' { match chars.next() { Some('c') => out.push(','), Some('d') => out.push(':'), Some('\\') | None => out.push('\\'), Some(other) => { out.push('\\'); out.push(other); } } } else { out.push(ch); } } out } /// Parses the persisted RPC-style download status string. fn parse_status(value: &str) -> Option { match value { "active" => Some(DownloadStatus::Active), "waiting" => Some(DownloadStatus::Waiting), "paused" => Some(DownloadStatus::Paused), "error" => Some(DownloadStatus::Error), "complete" => Some(DownloadStatus::Complete), "removed" => Some(DownloadStatus::Removed), _ => None, } }