chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
use super::{
|
||||
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
|
||||
BtPieceBlockUpdate, BtPieceMutationResult, BtRuntimeTickResult, BtTrackerInfo, CoreError,
|
||||
DownloadEngine, DownloadId, Result,
|
||||
};
|
||||
|
||||
impl DownloadEngine {
|
||||
/// Replaces the full BT peer snapshot for a download.
|
||||
pub fn apply_bt_peer_snapshot(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
peers: Vec<BtPeerInfo>,
|
||||
) -> Result<()> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
let _ = group.replace_bt_peer_snapshot(peers);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies an incremental BT peer update to a download.
|
||||
pub fn apply_bt_peer_update(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
peer: BtPeerInfo,
|
||||
) -> Result<BtPeerMutationResult> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
Ok(group.apply_bt_peer_update(peer))
|
||||
}
|
||||
|
||||
/// Applies an incremental BT piece availability update to a download.
|
||||
pub fn apply_bt_piece_availability_update(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
update: BtPieceAvailabilityUpdate,
|
||||
) -> Result<BtPieceAvailabilityMutationResult> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
Ok(group.apply_bt_piece_availability_update(update))
|
||||
}
|
||||
|
||||
/// Applies an incremental BT block-completion update to a download.
|
||||
pub fn apply_bt_piece_block_update(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
update: BtPieceBlockUpdate,
|
||||
) -> Result<BtPieceMutationResult> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
Ok(group.apply_bt_piece_block_update(update))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "BT runtime tick mirrors the RPC-visible counters updated together by one event"
|
||||
)]
|
||||
/// Applies a full BT runtime tick, including byte deltas, speeds, timers, and connection count.
|
||||
pub fn apply_bt_runtime_tick(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
downloaded_delta: u64,
|
||||
uploaded_delta: u64,
|
||||
download_speed: u64,
|
||||
upload_speed: u64,
|
||||
share_time_delta_secs: u64,
|
||||
seeding_time_delta_secs: u64,
|
||||
seeding: bool,
|
||||
num_connections: Option<u32>,
|
||||
) -> Result<BtRuntimeTickResult> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
Ok(group.apply_bt_runtime_tick(
|
||||
downloaded_delta,
|
||||
uploaded_delta,
|
||||
download_speed,
|
||||
upload_speed,
|
||||
share_time_delta_secs,
|
||||
seeding_time_delta_secs,
|
||||
seeding,
|
||||
num_connections,
|
||||
))
|
||||
}
|
||||
|
||||
/// Advances BT share/seeding timers to `now_unix_secs`.
|
||||
pub fn tick_bt_runtime_clock(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
now_unix_secs: u64,
|
||||
seeding: bool,
|
||||
) -> Result<BtRuntimeTickResult> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
Ok(group.tick_bt_runtime_clock(now_unix_secs, seeding))
|
||||
}
|
||||
|
||||
/// Toggles BT seeding state while preserving share-runtime accounting.
|
||||
pub fn set_bt_seeding_state(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
seeding: bool,
|
||||
at_unix_secs: Option<u64>,
|
||||
) -> Result<BtRuntimeTickResult> {
|
||||
let group = self.group_mut(gid)?;
|
||||
if group.bt().is_none() {
|
||||
return Err(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
));
|
||||
}
|
||||
Ok(group.set_bt_seeding_state(seeding, at_unix_secs))
|
||||
}
|
||||
|
||||
/// Upserts a BT tracker runtime snapshot keyed by tracker URL.
|
||||
pub fn apply_bt_tracker_snapshot(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
tracker_url: &str,
|
||||
tracker_id: Option<String>,
|
||||
seeders: Option<u32>,
|
||||
leechers: Option<u32>,
|
||||
) -> Result<()> {
|
||||
let group = self.group_mut(gid)?;
|
||||
let bt = group.bt_mut().ok_or(CoreError::InvalidState(
|
||||
"bt runtime state is not initialized",
|
||||
))?;
|
||||
if let Some(tracker) = bt
|
||||
.trackers
|
||||
.iter_mut()
|
||||
.find(|tracker| tracker.url == tracker_url)
|
||||
{
|
||||
if let Some(tracker_id) = tracker_id {
|
||||
tracker.id = Some(tracker_id);
|
||||
}
|
||||
if seeders.is_some() {
|
||||
tracker.seeders = seeders;
|
||||
}
|
||||
if leechers.is_some() {
|
||||
tracker.leechers = leechers;
|
||||
}
|
||||
} else {
|
||||
bt.trackers.push(BtTrackerInfo {
|
||||
url: tracker_url.to_owned(),
|
||||
tier: None,
|
||||
id: tracker_id,
|
||||
seeders,
|
||||
leechers,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records BT byte deltas and timer deltas without changing live speed counters.
|
||||
pub fn record_bt_runtime_tick(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
downloaded_delta: u64,
|
||||
uploaded_delta: u64,
|
||||
share_time_delta_secs: u64,
|
||||
seeding_time_delta_secs: u64,
|
||||
seeding: bool,
|
||||
) -> Result<()> {
|
||||
let _ = self.apply_bt_runtime_tick(
|
||||
gid,
|
||||
downloaded_delta,
|
||||
uploaded_delta,
|
||||
0,
|
||||
0,
|
||||
share_time_delta_secs,
|
||||
seeding_time_delta_secs,
|
||||
seeding,
|
||||
None,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use super::{
|
||||
BtPressureSnapshot, BtRuntimeState, CoreError, DownloadEngine, DownloadId, DownloadRegistry,
|
||||
DownloadStatus, GlobalStat, ProgressSnapshot, RequestGroup, Result, RuntimeConfig,
|
||||
SchedulerActivityCounters, SchedulerPlanningObservation, SchedulerState, SegmentRuntimeStats,
|
||||
usize_to_u32, usize_to_u64,
|
||||
};
|
||||
|
||||
/// Runtime metrics for a single download at a specific sampling point.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DownloadRuntimeSnapshot {
|
||||
/// Download identifier.
|
||||
pub gid: DownloadId,
|
||||
/// Current download state.
|
||||
pub status: DownloadStatus,
|
||||
/// Total payload length tracked by this snapshot.
|
||||
pub total_length: u64,
|
||||
/// Persisted completed length for the download.
|
||||
pub completed_length: u64,
|
||||
/// Remaining bytes derived from the tracked total and completed lengths.
|
||||
pub remaining_length: u64,
|
||||
/// Effective download throughput after engine-side capping.
|
||||
pub download_speed: u64,
|
||||
/// Effective upload throughput after engine-side capping.
|
||||
pub upload_speed: u64,
|
||||
/// Effective per-download download cap after global and local policy are merged.
|
||||
pub effective_download_limit: Option<u64>,
|
||||
/// Effective per-download upload cap after global and local policy are merged.
|
||||
pub effective_upload_limit: Option<u64>,
|
||||
/// Retry counter recorded on the request group.
|
||||
pub retry_count: u32,
|
||||
/// Number of active connections the request currently reports.
|
||||
pub num_connections: u32,
|
||||
/// Segment planner metrics exported from the request group.
|
||||
pub segment_stats: SegmentRuntimeStats,
|
||||
/// `BitTorrent` pressure metrics when the request has BT runtime state.
|
||||
pub bt_pressure: Option<BtPressureSnapshot>,
|
||||
}
|
||||
|
||||
/// Cross-download runtime counters used by diagnostics and pressure tests.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RuntimeInstrumentationSnapshot {
|
||||
/// Number of registered downloads.
|
||||
pub download_count: usize,
|
||||
/// Number of active downloads.
|
||||
pub active_download_count: usize,
|
||||
/// Number of waiting downloads.
|
||||
pub waiting_download_count: usize,
|
||||
/// Number of paused or removed downloads still retained in memory.
|
||||
pub stopped_download_count: usize,
|
||||
/// Number of errored downloads.
|
||||
pub error_download_count: usize,
|
||||
/// Number of completed downloads.
|
||||
pub complete_download_count: usize,
|
||||
/// Total count of active or retrying segments across all groups.
|
||||
pub total_active_segments: usize,
|
||||
/// Total bytes planned across all runtime segment assignments.
|
||||
pub total_planned_bytes: u64,
|
||||
/// Total remaining bytes across all runtime segment assignments.
|
||||
pub total_remaining_segment_bytes: u64,
|
||||
/// Aggregate number of requestable BT pieces.
|
||||
pub total_requestable_pieces: usize,
|
||||
/// Aggregate number of scarce requestable BT pieces.
|
||||
pub total_scarce_requestable_pieces: usize,
|
||||
/// Aggregate number of observed BT peers.
|
||||
pub total_bt_peers: usize,
|
||||
/// Configured disk cache capacity in bytes.
|
||||
pub configured_disk_cache_bytes: u64,
|
||||
/// Global overall download limit configured in the runtime.
|
||||
pub max_overall_download_limit: Option<u64>,
|
||||
/// Global per-download download limit configured in the runtime.
|
||||
pub max_download_limit: Option<u64>,
|
||||
/// Global overall upload limit configured in the runtime.
|
||||
pub max_overall_upload_limit: Option<u64>,
|
||||
/// Global per-download upload limit configured in the runtime.
|
||||
pub max_upload_limit: Option<u64>,
|
||||
/// Current scheduler state snapshot.
|
||||
pub scheduler_state: SchedulerState,
|
||||
/// Scheduler activity counters accumulated so far.
|
||||
pub scheduler_counters: SchedulerActivityCounters,
|
||||
/// Last recorded planning observation, when available.
|
||||
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
|
||||
}
|
||||
|
||||
impl DownloadEngine {
|
||||
/// Returns the current status of a specific download.
|
||||
pub fn tell_status(&self, gid: DownloadId) -> Result<DownloadStatus> {
|
||||
self.registry
|
||||
.get(gid)
|
||||
.map(|group| *group.status())
|
||||
.ok_or(CoreError::UnknownDownloadId(gid))
|
||||
}
|
||||
|
||||
/// Aggregates global counters and capped runtime speeds across all downloads.
|
||||
#[must_use]
|
||||
pub fn get_global_stat(&self) -> GlobalStat {
|
||||
let mut stat = self.global_stat;
|
||||
let active_count = active_runtime_group_count(&self.registry);
|
||||
stat.num_active = 0;
|
||||
stat.num_waiting = 0;
|
||||
stat.num_stopped = 0;
|
||||
stat.num_error = 0;
|
||||
stat.num_complete = 0;
|
||||
stat.total_length = 0;
|
||||
stat.completed_length = 0;
|
||||
stat.download_speed = 0;
|
||||
stat.upload_speed = 0;
|
||||
|
||||
for group in self.registry.groups.values() {
|
||||
let snapshot = build_progress_snapshot(group, self.runtime(), active_count);
|
||||
match group.status() {
|
||||
DownloadStatus::Active => stat.num_active += 1,
|
||||
DownloadStatus::Waiting => stat.num_waiting += 1,
|
||||
DownloadStatus::Paused | DownloadStatus::Removed => stat.num_stopped += 1,
|
||||
DownloadStatus::Error => stat.num_error += 1,
|
||||
DownloadStatus::Complete => stat.num_complete += 1,
|
||||
}
|
||||
stat.total_length = stat.total_length.saturating_add(snapshot.total_length);
|
||||
stat.completed_length = stat
|
||||
.completed_length
|
||||
.saturating_add(snapshot.completed_length);
|
||||
stat.download_speed = stat.download_speed.saturating_add(snapshot.download_speed);
|
||||
stat.upload_speed = stat.upload_speed.saturating_add(snapshot.upload_speed);
|
||||
}
|
||||
|
||||
if let Some(limit) = self.runtime().max_overall_download_limit {
|
||||
stat.download_speed = stat.download_speed.min(limit);
|
||||
}
|
||||
if let Some(limit) = self.runtime().max_overall_upload_limit {
|
||||
stat.upload_speed = stat.upload_speed.min(limit);
|
||||
}
|
||||
|
||||
stat
|
||||
}
|
||||
|
||||
/// Returns the number of registered downloads.
|
||||
#[must_use]
|
||||
pub fn download_count(&self) -> usize {
|
||||
self.registry.len()
|
||||
}
|
||||
|
||||
/// Returns the number of tasks exposed by the engine, matching `download_count`.
|
||||
#[must_use]
|
||||
pub fn task_count(&self) -> usize {
|
||||
self.download_count()
|
||||
}
|
||||
|
||||
/// Returns the gids of currently active downloads.
|
||||
#[must_use]
|
||||
pub fn active_downloads(&self) -> Vec<DownloadId> {
|
||||
self.registry
|
||||
.groups
|
||||
.iter()
|
||||
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a progress snapshot using runtime caps, piece state, and BT-derived metrics.
|
||||
pub(super) fn build_progress_snapshot(
|
||||
group: &RequestGroup,
|
||||
runtime: &RuntimeConfig,
|
||||
active_count: usize,
|
||||
) -> ProgressSnapshot {
|
||||
let piece_length = effective_piece_length(group);
|
||||
let total_length = infer_total_length(group, piece_length);
|
||||
let completed_length = completed_length(group, piece_length, total_length);
|
||||
let bt_selected_payload_length = group.bt_effective_target_length().unwrap_or(0);
|
||||
let bt_remaining_payload_length = group.bt_remaining_work_length().unwrap_or(0);
|
||||
let bt_true_seeding = group.bt_is_true_seeding();
|
||||
let peer_metrics = group.bt_peer_runtime_stats();
|
||||
let (_, queued, downloading, verified, missing, _) = group.piece_state_counts();
|
||||
let (download_cap, upload_cap) = effective_speed_caps(group, runtime, active_count);
|
||||
let download_speed = clamp_speed(
|
||||
group
|
||||
.download_speed()
|
||||
.max(peer_metrics.total_download_speed),
|
||||
download_cap,
|
||||
);
|
||||
let eta_seconds = if download_speed > 0 && total_length > completed_length {
|
||||
Some((total_length - completed_length).div_ceil(download_speed))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ProgressSnapshot {
|
||||
gid: group.gid(),
|
||||
status: *group.status(),
|
||||
total_length,
|
||||
completed_length,
|
||||
upload_length: group.upload_length(),
|
||||
upload_speed: clamp_speed(
|
||||
group.upload_speed().max(peer_metrics.total_upload_speed),
|
||||
upload_cap,
|
||||
),
|
||||
download_speed,
|
||||
num_connections: group
|
||||
.num_connections()
|
||||
.max(usize_to_u32(peer_metrics.peer_count)),
|
||||
eta_seconds,
|
||||
seeding: bt_true_seeding,
|
||||
share_ratio_milli: compute_share_ratio_milli(group, completed_length),
|
||||
share_time_secs: group.bt_share_time_secs(),
|
||||
seeding_time_secs: group.bt_seeding_time_secs(),
|
||||
bt_selected_payload_length,
|
||||
bt_remaining_payload_length,
|
||||
bt_true_seeding,
|
||||
bt_total_peers: usize_to_u32(peer_metrics.peer_count),
|
||||
bt_seeders: usize_to_u32(peer_metrics.seeder_count),
|
||||
bt_leechers: usize_to_u32(peer_metrics.leecher_count),
|
||||
bt_available_pieces: usize_to_u32(group.bt_available_piece_count()),
|
||||
bt_verified_pieces: usize_to_u32(verified),
|
||||
bt_downloading_pieces: usize_to_u32(downloading),
|
||||
bt_queued_pieces: usize_to_u32(queued),
|
||||
bt_missing_pieces: usize_to_u32(missing),
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts active runtime groups, returning at least `1` for cap sharing math.
|
||||
pub(super) fn active_runtime_group_count(registry: &DownloadRegistry) -> usize {
|
||||
registry
|
||||
.groups
|
||||
.values()
|
||||
.filter(|group| {
|
||||
matches!(
|
||||
group.status(),
|
||||
DownloadStatus::Active | DownloadStatus::Waiting
|
||||
)
|
||||
})
|
||||
.count()
|
||||
.max(1)
|
||||
}
|
||||
|
||||
/// Computes effective download and upload caps by combining global and per-group settings.
|
||||
pub(super) fn effective_speed_caps(
|
||||
group: &RequestGroup,
|
||||
runtime: &RuntimeConfig,
|
||||
active_count: usize,
|
||||
) -> (Option<u64>, Option<u64>) {
|
||||
let active_count = usize_to_u64(active_count.max(1));
|
||||
let overall_download_share = runtime
|
||||
.max_overall_download_limit
|
||||
.and_then(|limit| limit.checked_div(active_count))
|
||||
.map(|limit| limit.max(1));
|
||||
let overall_upload_share = runtime
|
||||
.max_overall_upload_limit
|
||||
.and_then(|limit| limit.checked_div(active_count))
|
||||
.map(|limit| limit.max(1));
|
||||
|
||||
let download_cap = combine_caps(
|
||||
overall_download_share,
|
||||
group
|
||||
.option_limit("max-download-limit")
|
||||
.or(runtime.max_download_limit),
|
||||
);
|
||||
let upload_cap = combine_caps(
|
||||
overall_upload_share,
|
||||
group
|
||||
.option_limit("max-upload-limit")
|
||||
.or(runtime.max_upload_limit),
|
||||
);
|
||||
(download_cap, upload_cap)
|
||||
}
|
||||
|
||||
/// Intersects two optional bandwidth caps.
|
||||
fn combine_caps(left: Option<u64>, right: Option<u64>) -> Option<u64> {
|
||||
match (left, right) {
|
||||
(Some(left), Some(right)) => Some(left.min(right)),
|
||||
(Some(left), None) => Some(left),
|
||||
(None, Some(right)) => Some(right),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an optional cap to a runtime speed sample.
|
||||
pub(super) fn clamp_speed(value: u64, cap: Option<u64>) -> u64 {
|
||||
cap.map_or(value, |limit| value.min(limit))
|
||||
}
|
||||
|
||||
/// Returns the piece length used for segment and progress math, clamped to at least `1`.
|
||||
pub(super) fn effective_piece_length(group: &RequestGroup) -> u64 {
|
||||
group.piece_length().max(1)
|
||||
}
|
||||
|
||||
/// Infers a request's total length from explicit metadata or known piece state.
|
||||
pub(super) fn infer_total_length(group: &RequestGroup, piece_length: u64) -> u64 {
|
||||
if group.total_length() > 0 {
|
||||
return group.total_length();
|
||||
}
|
||||
group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.map(|(piece, _)| {
|
||||
u64::from(piece.0)
|
||||
.saturating_add(1)
|
||||
.saturating_mul(piece_length)
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Computes the most trustworthy completed length for progress reporting.
|
||||
fn completed_length(group: &RequestGroup, piece_length: u64, total_length: u64) -> u64 {
|
||||
let reported = if total_length > 0 {
|
||||
group.completed_length().min(total_length)
|
||||
} else {
|
||||
group.completed_length()
|
||||
};
|
||||
let from_verified = group
|
||||
.piece_map()
|
||||
.iter()
|
||||
.filter(|(_, state)| **state == crate::piece::PieceState::Verified)
|
||||
.map(|(piece, _)| {
|
||||
let start = u64::from(piece.0).saturating_mul(piece_length);
|
||||
if total_length == 0 {
|
||||
piece_length
|
||||
} else {
|
||||
total_length.saturating_sub(start).min(piece_length)
|
||||
}
|
||||
})
|
||||
.sum::<u64>();
|
||||
let merged = reported.max(from_verified);
|
||||
match group.status() {
|
||||
DownloadStatus::Complete if total_length > 0 && completion_is_trustworthy(group) => {
|
||||
total_length
|
||||
}
|
||||
_ => merged.min(total_length.max(merged)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a completed group can safely report its entire total length as finished.
|
||||
fn completion_is_trustworthy(group: &RequestGroup) -> bool {
|
||||
if let Some(bt) = group.bt() {
|
||||
if bt.metadata_only {
|
||||
return false;
|
||||
}
|
||||
let selected_total = bt_selected_total_length(bt);
|
||||
if selected_total > 0 && group.completed_length() < selected_total {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Sums the selected BT payload length across all torrent files.
|
||||
fn bt_selected_total_length(bt: &BtRuntimeState) -> u64 {
|
||||
bt.files
|
||||
.iter()
|
||||
.filter(|file| file.selected)
|
||||
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
|
||||
}
|
||||
|
||||
/// Computes the BT share ratio in milli-units from live upload and effective payload size.
|
||||
fn compute_share_ratio_milli(group: &RequestGroup, completed_length: u64) -> Option<u64> {
|
||||
if let Some(ratio) = group.bt_share_ratio_milli() {
|
||||
return Some(ratio);
|
||||
}
|
||||
let bt = group.bt()?;
|
||||
let denominator = group
|
||||
.bt_share_ratio_base_length()
|
||||
.unwrap_or_else(|| bt_selected_total_length(bt).max(completed_length));
|
||||
if denominator == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
group
|
||||
.upload_length()
|
||||
.saturating_mul(1000)
|
||||
.saturating_div(denominator),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use super::{
|
||||
CoreError, DownloadEngine, DownloadId, DownloadStatus, QueuePositionMode, Result, RuntimeEvent,
|
||||
RuntimeEventKind, usize_to_i64,
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
impl DownloadEngine {
|
||||
/// Pauses an active or waiting download and keeps it in the reserved queue.
|
||||
pub fn pause(&mut self, gid: DownloadId) -> Result<()> {
|
||||
let status = self
|
||||
.registry
|
||||
.get(gid)
|
||||
.map(|group| *group.status())
|
||||
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||
let was_active = matches!(status, DownloadStatus::Active);
|
||||
if !matches!(status, DownloadStatus::Active | DownloadStatus::Waiting) {
|
||||
return Err(CoreError::InvalidState("download cannot be paused now"));
|
||||
}
|
||||
let group = self.group_mut(gid)?;
|
||||
group.set_status(DownloadStatus::Paused);
|
||||
if was_active {
|
||||
self.enqueue_reserved_front(gid);
|
||||
} else if !self.is_in_reserved_queue(gid) {
|
||||
self.enqueue_reserved_back(gid);
|
||||
}
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadPaused).with_gid(gid));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resumes a paused download by moving it back into the waiting state.
|
||||
pub fn resume(&mut self, gid: DownloadId) -> Result<()> {
|
||||
let status = self
|
||||
.registry
|
||||
.get(gid)
|
||||
.map(|group| *group.status())
|
||||
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||
if !matches!(status, DownloadStatus::Paused) {
|
||||
return Err(CoreError::InvalidState("download cannot be unpaused now"));
|
||||
}
|
||||
let group = self.group_mut(gid)?;
|
||||
group.set_status(DownloadStatus::Waiting);
|
||||
if !self.is_in_reserved_queue(gid) {
|
||||
self.enqueue_reserved_back(gid);
|
||||
}
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadResumed).with_gid(gid));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks a download as removed and assigns it a stopped sequence.
|
||||
pub fn remove(&mut self, gid: DownloadId) -> Result<()> {
|
||||
self.remove_from_reserved_queue(gid);
|
||||
self.transition_to_stopped_status(gid, DownloadStatus::Removed)?;
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadRemoved).with_gid(gid));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Permanently removes a stopped download result from the registry.
|
||||
pub fn remove_download_result(&mut self, gid: DownloadId) -> Result<()> {
|
||||
let Some(group) = self.registry.get(gid) else {
|
||||
return Err(CoreError::UnknownDownloadId(gid));
|
||||
};
|
||||
if !matches!(
|
||||
group.status(),
|
||||
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
|
||||
) {
|
||||
return Err(CoreError::InvalidState(
|
||||
"download result is not available for active or waiting downloads",
|
||||
));
|
||||
}
|
||||
self.registry.remove(gid);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Repositions a waiting download within the reserved queue.
|
||||
pub fn change_position(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
offset: i64,
|
||||
mode: QueuePositionMode,
|
||||
) -> Result<usize> {
|
||||
let Some(current_index) = self
|
||||
.reserved_queue
|
||||
.iter()
|
||||
.position(|candidate| *candidate == gid)
|
||||
else {
|
||||
return Err(CoreError::InvalidState(
|
||||
"download is not in the waiting queue",
|
||||
));
|
||||
};
|
||||
let size = usize_to_i64(self.reserved_queue.len());
|
||||
let current = usize_to_i64(current_index);
|
||||
let mut dest = match mode {
|
||||
QueuePositionMode::Set => offset,
|
||||
QueuePositionMode::Cur => current.saturating_add(offset),
|
||||
QueuePositionMode::End => size.saturating_sub(1).saturating_add(offset),
|
||||
};
|
||||
dest = dest.clamp(0, size.saturating_sub(1));
|
||||
let dest_index = usize::try_from(dest).unwrap_or_default();
|
||||
match current_index.cmp(&dest_index) {
|
||||
Ordering::Less => {
|
||||
let Some(window) = self.reserved_queue.get_mut(current_index..=dest_index) else {
|
||||
return Err(CoreError::InvalidState(
|
||||
"download is not in the waiting queue",
|
||||
));
|
||||
};
|
||||
window.rotate_left(1);
|
||||
}
|
||||
Ordering::Greater => {
|
||||
let Some(window) = self.reserved_queue.get_mut(dest_index..=current_index) else {
|
||||
return Err(CoreError::InvalidState(
|
||||
"download is not in the waiting queue",
|
||||
));
|
||||
};
|
||||
window.rotate_right(1);
|
||||
}
|
||||
Ordering::Equal => {}
|
||||
}
|
||||
Ok(dest_index)
|
||||
}
|
||||
|
||||
/// Removes every stopped download result and returns the number removed.
|
||||
pub fn purge_download_results(&mut self) -> usize {
|
||||
let stopped = self
|
||||
.registry
|
||||
.groups
|
||||
.iter()
|
||||
.filter_map(|(gid, group)| {
|
||||
matches!(
|
||||
group.status(),
|
||||
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
|
||||
)
|
||||
.then_some(*gid)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let removed = stopped.len();
|
||||
for gid in stopped {
|
||||
let _ = self.registry.remove(gid);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// Marks a download as complete and emits the completion event.
|
||||
pub fn complete(&mut self, gid: DownloadId) -> Result<()> {
|
||||
self.remove_from_reserved_queue(gid);
|
||||
self.transition_to_stopped_status(gid, DownloadStatus::Complete)?;
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(gid));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks a download as errored and emits the failure event.
|
||||
pub fn fail(&mut self, gid: DownloadId) -> Result<()> {
|
||||
self.remove_from_reserved_queue(gid);
|
||||
self.transition_to_stopped_status(gid, DownloadStatus::Error)?;
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadErrored).with_gid(gid));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assigns a stopped sequence and terminal status to a request group.
|
||||
fn transition_to_stopped_status(
|
||||
&mut self,
|
||||
gid: DownloadId,
|
||||
status: DownloadStatus,
|
||||
) -> Result<()> {
|
||||
let sequence = self.next_stopped_sequence;
|
||||
self.next_stopped_sequence = self.next_stopped_sequence.saturating_add(1);
|
||||
let group = self.group_mut(gid)?;
|
||||
group.set_stopped_sequence(Some(sequence));
|
||||
group.set_status(status);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns whether `gid` currently appears in the reserved queue.
|
||||
pub(super) fn is_in_reserved_queue(&self, gid: DownloadId) -> bool {
|
||||
self.reserved_queue.contains(&gid)
|
||||
}
|
||||
|
||||
/// Removes `gid` from the reserved queue when present.
|
||||
pub(super) fn remove_from_reserved_queue(&mut self, gid: DownloadId) {
|
||||
if let Some(index) = self
|
||||
.reserved_queue
|
||||
.iter()
|
||||
.position(|candidate| *candidate == gid)
|
||||
{
|
||||
self.reserved_queue.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Places `gid` at the back of the reserved queue, removing older duplicates first.
|
||||
pub(super) fn enqueue_reserved_back(&mut self, gid: DownloadId) {
|
||||
self.remove_from_reserved_queue(gid);
|
||||
self.reserved_queue.push(gid);
|
||||
}
|
||||
|
||||
/// Places `gid` at the front of the reserved queue, removing older duplicates first.
|
||||
pub(super) fn enqueue_reserved_front(&mut self, gid: DownloadId) {
|
||||
self.remove_from_reserved_queue(gid);
|
||||
self.reserved_queue.insert(0, gid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{
|
||||
CoreError, DownloadEngine, DownloadId, DownloadStatus, RequestGroup, Result, RetryAttempt,
|
||||
RetryHistoryEntry, RuntimeConfig, RuntimeEvent, RuntimeEventKind, ScheduleDecision,
|
||||
SegmentAssignment, SegmentState, effective_piece_length, infer_total_length, usize_to_u64,
|
||||
};
|
||||
|
||||
impl DownloadEngine {
|
||||
/// Advances the scheduler clock and emits a scheduler tick event.
|
||||
pub fn scheduler_tick(&mut self) -> Result<()> {
|
||||
let _ = self.scheduler.tick();
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepares a single HTTP download by applying the scheduler decision for its current state.
|
||||
pub fn prepare_http_download(&mut self, gid: DownloadId) -> Result<RequestGroup> {
|
||||
let decision = {
|
||||
let group = self
|
||||
.registry
|
||||
.get(gid)
|
||||
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||
match group.status() {
|
||||
DownloadStatus::Waiting => ScheduleDecision::Queue(gid),
|
||||
DownloadStatus::Active => ScheduleDecision::RunNow(gid),
|
||||
DownloadStatus::Error => ScheduleDecision::RetryLater(gid),
|
||||
DownloadStatus::Paused | DownloadStatus::Complete | DownloadStatus::Removed => {
|
||||
ScheduleDecision::Noop
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.apply_schedule_decision(gid, &decision);
|
||||
self.registry
|
||||
.get(gid)
|
||||
.cloned()
|
||||
.ok_or(CoreError::UnknownDownloadId(gid))
|
||||
}
|
||||
|
||||
/// Runs one scheduler pass and returns the first actionable decision.
|
||||
#[must_use]
|
||||
pub fn schedule_once(&mut self) -> ScheduleDecision {
|
||||
self.scheduler.record_schedule_run();
|
||||
let _ = self.scheduler.tick();
|
||||
let mut gids = self
|
||||
.registry
|
||||
.groups
|
||||
.iter()
|
||||
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
|
||||
.collect::<Vec<_>>();
|
||||
gids.sort_by_key(|gid| gid.as_u64());
|
||||
gids.extend(
|
||||
self.reserved_queue
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|gid| self.registry.get(*gid).is_some()),
|
||||
);
|
||||
let mut retry_gids = self
|
||||
.registry
|
||||
.groups
|
||||
.iter()
|
||||
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Error).then_some(*gid))
|
||||
.collect::<Vec<_>>();
|
||||
retry_gids.sort_by_key(|gid| gid.as_u64());
|
||||
gids.extend(retry_gids);
|
||||
|
||||
for gid in gids {
|
||||
let Some(group) = self.registry.get(gid) else {
|
||||
continue;
|
||||
};
|
||||
let decision = self.scheduler.decide(group);
|
||||
match decision {
|
||||
ScheduleDecision::RunNow(_)
|
||||
| ScheduleDecision::Queue(_)
|
||||
| ScheduleDecision::RetryLater(_) => {
|
||||
self.apply_schedule_decision(gid, &decision);
|
||||
self.events
|
||||
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick).with_gid(gid));
|
||||
return decision;
|
||||
}
|
||||
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) => {
|
||||
self.scheduler.record_decision(&decision);
|
||||
return decision;
|
||||
}
|
||||
ScheduleDecision::Noop => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.scheduler.record_decision(&ScheduleDecision::Noop);
|
||||
ScheduleDecision::Noop
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts retry attempts into scheduler-facing retry-history entries.
|
||||
pub(super) fn retry_history_from_group(attempts: &[RetryAttempt]) -> Vec<RetryHistoryEntry> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |duration| duration.as_secs());
|
||||
attempts
|
||||
.iter()
|
||||
.map(|attempt| RetryHistoryEntry {
|
||||
at_unix_secs: now,
|
||||
reason: attempt.error.clone().unwrap_or_else(|| "retry".to_string()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds runtime segment assignments for the scheduler-selected active segment count.
|
||||
pub(super) fn build_segment_assignments(
|
||||
group: &RequestGroup,
|
||||
runtime: &RuntimeConfig,
|
||||
desired_segments: usize,
|
||||
) -> Vec<SegmentAssignment> {
|
||||
if desired_segments == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let total_length = infer_total_length(group, effective_piece_length(group));
|
||||
let start_offset = group
|
||||
.resume_state()
|
||||
.map_or(0, |state| state.resume_offset)
|
||||
.max(group.completed_length())
|
||||
.min(total_length);
|
||||
let remaining = total_length.saturating_sub(start_offset);
|
||||
if remaining == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let piece_length = effective_piece_length(group);
|
||||
let alignment = piece_length.max(runtime.min_split_size.max(1));
|
||||
let segment_count = desired_segments
|
||||
.min(
|
||||
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
|
||||
.unwrap_or(usize::MAX),
|
||||
)
|
||||
.max(1);
|
||||
let target_span = remaining.div_ceil(usize_to_u64(segment_count));
|
||||
|
||||
let mut cursor = start_offset;
|
||||
let mut assignments = Vec::with_capacity(segment_count);
|
||||
for slot in 0..segment_count {
|
||||
if cursor >= total_length {
|
||||
break;
|
||||
}
|
||||
|
||||
let end = if slot + 1 == segment_count {
|
||||
total_length
|
||||
} else {
|
||||
let raw_end = cursor.saturating_add(target_span).min(total_length);
|
||||
let aligned_end = raw_end
|
||||
.div_ceil(alignment)
|
||||
.saturating_mul(alignment)
|
||||
.min(total_length);
|
||||
aligned_end.max(cursor.saturating_add(1))
|
||||
};
|
||||
|
||||
let mut assignment =
|
||||
SegmentAssignment::new(slot, crate::piece::PieceRange::new(cursor, end));
|
||||
assignment.state = SegmentState::Active;
|
||||
assignments.push(assignment);
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
assignments
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.join(","),
|
||||
OptionValue::Empty => String::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores request-group runtime metadata from persisted session metadata fields.
|
||||
fn restore_group_metadata(group: &mut RequestGroup, metadata: Option<BTreeMap<String, String>>) {
|
||||
let Some(metadata) = metadata else {
|
||||
return;
|
||||
};
|
||||
let mut bt = BtRuntimeState::default();
|
||||
let mut bt_seen = false;
|
||||
let mut bt_files: BTreeMap<usize, BtFileInfo> = 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::<u32>() {
|
||||
group.set_num_connections(parsed);
|
||||
}
|
||||
true
|
||||
}
|
||||
"download_speed" => {
|
||||
if let Ok(parsed) = value.parse::<u64>() {
|
||||
group.set_download_speed(parsed);
|
||||
}
|
||||
true
|
||||
}
|
||||
"upload_length" => {
|
||||
if let Ok(parsed) = value.parse::<u64>() {
|
||||
group.set_upload_length(parsed);
|
||||
}
|
||||
true
|
||||
}
|
||||
"completed_length" => {
|
||||
if let Ok(parsed) = value.parse::<u64>() {
|
||||
group.set_completed_length(parsed);
|
||||
}
|
||||
true
|
||||
}
|
||||
"retry_count" => {
|
||||
if let Ok(parsed) = value.parse::<u32>() {
|
||||
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<usize, BtFileInfo>,
|
||||
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::<bool>().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<usize, BtFileInfo>,
|
||||
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::<usize>() 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::<u64>() {
|
||||
file.length = parsed;
|
||||
}
|
||||
}
|
||||
"selected" => {
|
||||
file.selected = value.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
"piece_offset" => {
|
||||
if let Ok(parsed) = value.parse::<u64>() {
|
||||
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::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// Decodes retry attempts from the compact session-metadata string.
|
||||
fn decode_retry_attempts_metadata(raw: &str) -> Vec<RetryAttempt> {
|
||||
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<ResumeState> {
|
||||
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<DownloadStatus> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user