diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8c351e7..9fe1b22 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,7 @@ "log:default", "notification:default", "notification:allow-is-permission-granted", - "clipboard-manager:allow-read-text" + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text" ] } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 80f8a2d..add486d 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -83,6 +83,8 @@ pub enum DownloadStatus { /// Aria2 is verifying already-present Torrent data before transfer or /// after an explicit integrity check. Verifying, + /// Firelink is moving owned Torrent data between managed destinations. + Moving, } impl DownloadStatus { @@ -100,6 +102,7 @@ impl DownloadStatus { Self::Queued => "queued", Self::Retrying => "retrying", Self::Verifying => "verifying", + Self::Moving => "moving", } } } @@ -219,10 +222,28 @@ pub struct DownloadItem { #[ts(optional)] pub torrent_seed_remaining: Option, #[serde(default)] + #[ts(optional, type = "number")] + pub torrent_uploaded_bytes: Option, + #[serde(default)] + #[ts(optional, type = "number")] + pub torrent_seeded_seconds: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_relocation_check_pending: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_move_destination: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_move_restore_status: Option, + #[serde(default)] #[ts(optional)] pub torrent_web_seeds: Option>, #[serde(default)] #[ts(optional)] + pub torrent_web_seeds_native: Option>, + #[serde(default)] + #[ts(optional)] pub torrent_upload_limit: Option, #[serde(default)] #[ts(optional)] @@ -372,6 +393,38 @@ pub struct TorrentDetails { pub web_seeds: Vec, } +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentAvailabilityBucket { + #[ts(type = "number")] + pub minimum_copies: u16, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentAvailabilitySnapshot { + #[ts(type = "number")] + pub piece_count: u64, + pub availability: f64, + #[ts(type = "number")] + pub connected_peers: u32, + pub buckets: Vec, +} + +#[derive(Clone, Debug, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../../src/bindings/")] +pub struct TorrentMoveProgressEvent { + pub id: String, + pub fraction: f64, + #[ts(type = "number")] + pub copied_bytes: u64, + #[ts(type = "number")] + pub total_bytes: u64, +} + #[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d5e226..a84c441 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1472,6 +1472,7 @@ fn emit_media_progress( uploaded_bytes: None, upload_speed: None, num_seeders: None, + torrent_seeded_seconds: None, }, ); state.last_progress_at = now; @@ -3167,6 +3168,8 @@ pub struct DownloadProgressEvent { upload_speed: Option, #[ts(optional)] num_seeders: Option, + #[ts(optional)] + torrent_seeded_seconds: Option, } #[derive(Debug, Clone, Serialize, TS)] @@ -4340,6 +4343,7 @@ pub(crate) async fn start_media_download_internal( uploaded_bytes: None, upload_speed: None, num_seeders: None, + torrent_seeded_seconds: None, }); } let lower = line.to_lowercase(); @@ -4416,6 +4420,7 @@ pub(crate) async fn start_media_download_internal( uploaded_bytes: None, upload_speed: None, num_seeders: None, + torrent_seeded_seconds: None, }); } } @@ -5136,6 +5141,7 @@ async fn remove_download( } state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_torrent_telemetry(&id).await; state.queue_manager.forget_aria2_gid(&id).await; state.queue_manager.release_permit(&id).await; log::info!("aria2 remove [{}]: gid {} stopped and forgotten", id, gid); @@ -5217,6 +5223,7 @@ async fn remove_download( } state.queue_manager.next_aria2_control_epoch(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_torrent_telemetry(&id).await; state.queue_manager.forget_aria2_gid(&id).await; } else if !state .queue_manager @@ -5230,6 +5237,7 @@ async fn remove_download( } state.queue_manager.release_permit(&id).await; state.queue_manager.clear_aria2_retry_state(&id).await; + state.queue_manager.forget_torrent_telemetry(&id).await; state.queue_manager.forget_aria2_gid(&id).await; } @@ -5906,6 +5914,7 @@ struct ExpectedTorrentOutputPaths { unselected: Vec, } +#[derive(Clone)] struct DownloadOwnershipSnapshot { primary: Option, owned: Vec, @@ -6707,6 +6716,14 @@ async fn get_torrent_peers( state.queue_manager.get_aria2_torrent_peers(&id).await } +#[tauri::command] +async fn get_torrent_availability( + state: tauri::State<'_, AppState>, + id: String, +) -> Result { + state.queue_manager.get_aria2_torrent_availability(&id).await +} + #[tauri::command] async fn get_torrent_file_progress( state: tauri::State<'_, AppState>, @@ -6763,6 +6780,137 @@ fn load_persisted_torrent_item( .ok_or_else(|| "download is not persisted".to_string()) } +fn persist_torrent_destination( + database: &crate::db::DbState, + id: &str, + destination: &str, + relocation_check_pending: bool, +) -> Result<(), String> { + let mut connection = database.lock()?; + let records = crate::db::load_downloads(&connection)?; + let mut changed = false; + let next_records = records + .into_iter() + .map(|record| { + let mut value: serde_json::Value = serde_json::from_str(&record) + .map_err(|error| format!("persisted download is malformed: {error}"))?; + if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { + let object = value + .as_object_mut() + .ok_or_else(|| "persisted download is not an object".to_string())?; + object.insert( + "destination".to_string(), + serde_json::Value::String(destination.to_string()), + ); + if relocation_check_pending { + object.insert( + "torrentRelocationCheckPending".to_string(), + serde_json::Value::Bool(true), + ); + } else { + object.remove("torrentRelocationCheckPending"); + } + object.insert( + "torrentMoveDestination".to_string(), + serde_json::Value::String(destination.to_string()), + ); + changed = true; + } + serde_json::to_string(&value) + .map_err(|error| format!("failed to encode persisted download: {error}")) + }) + .collect::, _>>()?; + if !changed { + return Err("download is no longer persisted".to_string()); + } + let data = serde_json::to_string(&next_records) + .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; + crate::db::replace_downloads(&mut connection, &data, database.is_portable()) +} + +fn persist_torrent_telemetry( + database: &crate::db::DbState, + id: &str, + snapshot: crate::queue::TorrentTelemetrySnapshot, +) -> Result<(), String> { + let mut connection = database.lock()?; + let records = crate::db::load_downloads(&connection)?; + let mut changed = false; + let next_records = records + .into_iter() + .map(|record| { + let mut value: serde_json::Value = serde_json::from_str(&record) + .map_err(|error| format!("persisted download is malformed: {error}"))?; + if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { + let object = value + .as_object_mut() + .ok_or_else(|| "persisted download is not an object".to_string())?; + let uploaded = serde_json::Value::from(snapshot.uploaded_bytes); + let seeded = serde_json::Value::from(snapshot.seeded_seconds); + if object.get("torrentUploadedBytes") != Some(&uploaded) { + object.insert("torrentUploadedBytes".to_string(), uploaded); + changed = true; + } + if object.get("torrentSeededSeconds") != Some(&seeded) { + object.insert("torrentSeededSeconds".to_string(), seeded); + changed = true; + } + } + serde_json::to_string(&value) + .map_err(|error| format!("failed to encode persisted download: {error}")) + }) + .collect::, _>>()?; + if !changed { + return Ok(()); + } + let data = serde_json::to_string(&next_records) + .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; + crate::db::replace_downloads(&mut connection, &data, database.is_portable()) +} + +fn persist_torrent_relocation_check( + database: &crate::db::DbState, + id: &str, + pending: bool, +) -> Result<(), String> { + let mut connection = database.lock()?; + let records = crate::db::load_downloads(&connection)?; + let mut changed = false; + let next_records = records + .into_iter() + .map(|record| { + let mut value: serde_json::Value = serde_json::from_str(&record) + .map_err(|error| format!("persisted download is malformed: {error}"))?; + if value.get("id").and_then(serde_json::Value::as_str) == Some(id) { + let object = value + .as_object_mut() + .ok_or_else(|| "persisted download is not an object".to_string())?; + if pending { + if object.get("torrentRelocationCheckPending") + != Some(&serde_json::Value::Bool(true)) + { + object.insert( + "torrentRelocationCheckPending".to_string(), + serde_json::Value::Bool(true), + ); + changed = true; + } + } else if object.remove("torrentRelocationCheckPending").is_some() { + changed = true; + } + } + serde_json::to_string(&value) + .map_err(|error| format!("failed to encode persisted download: {error}")) + }) + .collect::, _>>()?; + if !changed { + return Ok(()); + } + let data = serde_json::to_string(&next_records) + .map_err(|error| format!("failed to encode persisted downloads: {error}"))?; + crate::db::replace_downloads(&mut connection, &data, database.is_portable()) +} + fn persist_torrent_file_selection( database: &crate::db::DbState, id: &str, @@ -7026,6 +7174,1068 @@ async fn get_torrent_details( crate::torrent::torrent_details_from_bytes(&bytes) } +fn torrent_identity_magnet(details: &crate::ipc::TorrentDetails) -> Result { + let info_hash = crate::torrent::canonical_info_hash(&details.info_hash) + .ok_or_else(|| "Torrent metadata has an invalid info hash".to_string())?; + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("xt", &format!("urn:btih:{info_hash}")); + if let Some(name) = sanitize_metadata_filename(&details.display_name) { + let bounded = name.chars().take(255).collect::(); + if !bounded.is_empty() { + serializer.append_pair("dn", &bounded); + } + } + Ok(format!("magnet:?{}", serializer.finish())) +} + +#[tauri::command] +async fn get_torrent_magnet_link( + state: tauri::State<'_, crate::db::DbState>, + app_handle: tauri::AppHandle, + id: String, +) -> Result { + let item = load_persisted_torrent_item(state.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("magnet links are available only for Torrent downloads".to_string()); + } + let path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; + let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; + let bytes = tokio::fs::read(path) + .await + .map_err(|_| "Torrent metadata is unavailable".to_string())?; + let details = crate::torrent::torrent_details_from_bytes(&bytes)?; + crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &details.info_hash)?; + torrent_identity_magnet(&details) +} + +#[tauri::command] +async fn export_torrent_metadata( + state: tauri::State<'_, crate::db::DbState>, + app_handle: tauri::AppHandle, + id: String, + destination: String, +) -> Result<(), String> { + let destination = std::path::PathBuf::from(destination.trim()); + if !destination.is_absolute() + || destination.extension().and_then(|value| value.to_str()) + .is_none_or(|value| !value.eq_ignore_ascii_case("torrent")) + { + return Err("Choose an absolute .torrent destination".to_string()); + } + let metadata = std::fs::symlink_metadata(&destination); + if let Ok(metadata) = metadata { + if metadata.file_type().is_symlink() { + return Err("The export destination cannot be a symbolic link".to_string()); + } + return Err("The export destination already exists".to_string()); + } + if let Err(error) = metadata { + if error.kind() != std::io::ErrorKind::NotFound { + return Err("The export destination cannot be accessed".to_string()); + } + } + let parent = destination + .parent() + .filter(|path| path.is_dir()) + .ok_or_else(|| "The export destination folder is unavailable".to_string())?; + let _canonical_parent = std::fs::canonicalize(parent) + .map_err(|_| "The export destination folder is unavailable".to_string())?; + + let item = load_persisted_torrent_item(state.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("metadata export is available only for Torrent downloads".to_string()); + } + let path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; + let path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, path)?; + let bytes = tokio::fs::read(path) + .await + .map_err(|_| "Torrent metadata is unavailable".to_string())?; + let details = crate::torrent::torrent_details_from_bytes(&bytes)?; + crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &details.info_hash)?; + + use tokio::io::AsyncWriteExt; + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&destination) + .await + .map_err(|error| match error.kind() { + std::io::ErrorKind::AlreadyExists => "The export destination already exists".to_string(), + _ => "The Torrent metadata could not be exported".to_string(), + })?; + if file.write_all(&bytes).await.is_err() { + let _ = tokio::fs::remove_file(&destination).await; + return Err("The Torrent metadata could not be exported".to_string()); + } + if file.sync_all().await.is_err() { + let _ = tokio::fs::remove_file(&destination).await; + return Err("The Torrent metadata could not be exported".to_string()); + } + Ok(()) +} + +fn torrent_move_journal_path(state: &AppState, id: &str) -> Result { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err("invalid Torrent download id".to_string()); + } + Ok(state + .storage_layout + .data_dir() + .join("torrent-moves") + .join(format!("{id}.json"))) +} + +async fn write_torrent_move_journal( + path: &std::path::Path, + phase: &str, + id: &str, + old_destination: &std::path::Path, + new_destination: &std::path::Path, + old_primary: Option<&std::path::Path>, + old_removal_paths: &[std::path::PathBuf], + new_primary: &std::path::Path, + new_removal_paths: &[std::path::PathBuf], + staging_root: &std::path::Path, + staging_paths: &[std::path::PathBuf], + old_paths: &[std::path::PathBuf], + new_paths: &[std::path::PathBuf], + total_bytes: u64, +) -> Result<(), String> { + let data = serde_json::json!({ + "phase": phase, + "id": id, + "oldDestination": old_destination, + "newDestination": new_destination, + "oldPrimary": old_primary, + "oldRemovalPaths": old_removal_paths, + "newPrimary": new_primary, + "newRemovalPaths": new_removal_paths, + "stagingRoot": staging_root, + "stagingPaths": staging_paths, + "oldPaths": old_paths, + "newPaths": new_paths, + "totalBytes": total_bytes, + }); + let bytes = serde_json::to_vec_pretty(&data) + .map_err(|_| "could not encode Torrent move journal".to_string())?; + let temporary = path.with_extension("json.tmp"); + tokio::fs::write(&temporary, bytes) + .await + .map_err(|_| "could not write Torrent move journal".to_string())?; + tokio::fs::rename(&temporary, path) + .await + .map_err(|_| "could not commit Torrent move journal".to_string()) +} + +#[derive(serde::Deserialize)] +struct TorrentMoveJournal { + phase: String, + id: String, + old_destination: std::path::PathBuf, + new_destination: std::path::PathBuf, + #[serde(default)] + old_primary: Option, + #[serde(default)] + old_removal_paths: Vec, + #[serde(default)] + new_primary: Option, + #[serde(default)] + new_removal_paths: Vec, + #[serde(default)] + staging_root: Option, + #[serde(default)] + staging_paths: Vec, + old_paths: Vec, + new_paths: Vec, +} + +fn validate_torrent_move_recovery_path( + app_handle: &tauri::AppHandle, + path: &std::path::Path, +) -> Result<(), String> { + if !path.is_absolute() + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + || crate::path_has_symlink_component(path) + || !crate::is_safe_path(path, app_handle) + { + return Err("Torrent move journal contains an unsafe path".to_string()); + } + Ok(()) +} + +fn remove_torrent_move_file(path: &std::path::Path) -> Result<(), String> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("Torrent move recovery could not inspect a managed path".to_string()), + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("Torrent move recovery found a non-file managed path".to_string()) + } + Ok(_) => std::fs::remove_file(path) + .map_err(|_| "Torrent move recovery could not remove a managed file".to_string()), + } +} + +fn remove_torrent_move_staging_root(path: &std::path::Path) -> Result<(), String> { + match std::fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("Torrent move recovery could not inspect staging".to_string()), + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + Err("Torrent move recovery found an unsafe staging path".to_string()) + } + Ok(_) => { + let mut entries = std::fs::read_dir(path) + .map_err(|_| "Torrent move recovery could not inspect staging".to_string())?; + if entries.next().is_some() { + return Err("Torrent move recovery found unexpected staging data".to_string()); + } + std::fs::remove_dir(path) + .map_err(|_| "Torrent move recovery could not remove staging".to_string()) + } + } +} + +fn remove_empty_torrent_move_directories( + files: &[std::path::PathBuf], + boundary: &std::path::Path, +) -> Result<(), String> { + let mut directories = Vec::new(); + for file in files { + let mut current = file.parent(); + while let Some(directory) = current { + if crate::platform::paths_equal(directory, boundary) + || !crate::platform::path_is_within(directory, boundary) + { + break; + } + if !directories + .iter() + .any(|existing: &std::path::PathBuf| { + crate::platform::paths_equal(existing, directory) + }) + { + directories.push(directory.to_path_buf()); + } + current = directory.parent(); + } + } + directories.sort_by_key(|path| std::cmp::Reverse(path.components().count())); + for directory in directories { + match std::fs::symlink_metadata(&directory) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("Torrent move cleanup could not inspect a directory".to_string()), + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err("Torrent move cleanup found an unsafe directory".to_string()); + } + Ok(_) => { + let mut entries = std::fs::read_dir(&directory) + .map_err(|_| "Torrent move cleanup could not inspect a directory".to_string())?; + if entries.next().is_some() { + continue; + } + std::fs::remove_dir(&directory).map_err(|_| { + "Torrent move cleanup could not remove an empty directory".to_string() + })?; + } + } + } + Ok(()) +} + +async fn copy_torrent_move_file( + source: &std::path::Path, + target: &std::path::Path, +) -> Result<(), String> { + let source_metadata = tokio::fs::symlink_metadata(source) + .await + .map_err(|_| "Torrent source changed during relocation".to_string())?; + if !source_metadata.is_file() || source_metadata.file_type().is_symlink() { + return Err("Torrent source changed to a non-file during relocation".to_string()); + } + let mut input = tokio::fs::File::open(source) + .await + .map_err(|_| "Torrent source could not be opened".to_string())?; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut output = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(target) + .await + .map_err(|error| match error.kind() { + std::io::ErrorKind::AlreadyExists => { + "the Torrent move destination already contains managed output".to_string() + } + _ => "Torrent move destination could not be created".to_string(), + })?; + let mut buffer = [0u8; 1024 * 1024]; + loop { + let read = input + .read(&mut buffer) + .await + .map_err(|_| "Torrent source could not be read".to_string())?; + if read == 0 { + break; + } + output + .write_all(&buffer[..read]) + .await + .map_err(|_| "Torrent move destination could not be written".to_string())?; + } + output + .sync_all() + .await + .map_err(|_| "Torrent move destination could not be synchronized".to_string()) +} + +async fn cleanup_torrent_move_outputs( + new_paths: &[std::path::PathBuf], + staging_paths: &[std::path::PathBuf], + staging_root: &std::path::Path, +) { + for path in new_paths.iter().chain(staging_paths.iter()) { + let _ = tokio::fs::remove_file(path).await; + } + let _ = tokio::fs::remove_dir(staging_root).await; +} + +async fn digest_torrent_move_file(path: &std::path::Path) -> Result<[u8; 32], String> { + let metadata = tokio::fs::symlink_metadata(path) + .await + .map_err(|_| "Torrent move file could not be verified".to_string())?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err("Torrent move file is not a regular file".to_string()); + } + let mut file = tokio::fs::File::open(path) + .await + .map_err(|_| "Torrent move file could not be opened for verification".to_string())?; + use sha2::Digest; + use tokio::io::AsyncReadExt; + let mut digest = sha2::Sha256::new(); + let mut buffer = [0u8; 1024 * 1024]; + loop { + let read = file + .read(&mut buffer) + .await + .map_err(|_| "Torrent move file could not be read for verification".to_string())?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(digest.finalize().into()) +} + +fn recover_torrent_move_journals( + app_handle: &tauri::AppHandle, + database: &crate::db::DbState, + storage_layout: &crate::storage::StorageLayout, +) -> Result<(), String> { + let directory = storage_layout.data_dir().join("torrent-moves"); + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err("could not inspect Torrent move recovery".to_string()), + }; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let journal: TorrentMoveJournal = match std::fs::read(&path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + { + Some(journal) => journal, + None => { + log::warn!("leaving malformed Torrent move journal for manual recovery"); + continue; + } + }; + if journal.id.is_empty() + || path.file_stem().and_then(|value| value.to_str()) != Some(journal.id.as_str()) + || journal.old_paths.is_empty() + || journal.new_paths.len() != journal.old_paths.len() + { + log::warn!("leaving invalid Torrent move journal for manual recovery"); + continue; + } + let all_paths = journal + .old_paths + .iter() + .chain(journal.new_paths.iter()) + .chain(journal.old_removal_paths.iter()) + .chain(journal.new_removal_paths.iter()) + .chain(journal.old_primary.iter()) + .chain(journal.new_primary.iter()); + if all_paths + .chain([&journal.old_destination, &journal.new_destination]) + .any(|path| validate_torrent_move_recovery_path(app_handle, path).is_err()) + { + log::warn!("leaving unsafe Torrent move journal for manual recovery"); + continue; + } + if journal.staging_root.as_ref().is_some_and(|root| { + validate_torrent_move_recovery_path(app_handle, root).is_err() + || !crate::platform::path_is_within(root, &journal.new_destination) + }) || journal.staging_paths.iter().any(|path| { + validate_torrent_move_recovery_path(app_handle, path).is_err() + || journal + .staging_root + .as_ref() + .is_none_or(|root| !crate::platform::path_is_within(path, root)) + }) { + log::warn!("leaving unsafe Torrent staging journal for manual recovery"); + continue; + } + let mut old_owned_paths = journal + .old_paths + .iter() + .chain(journal.old_removal_paths.iter()) + .chain(journal.old_primary.iter()); + let mut new_owned_paths = journal + .new_paths + .iter() + .chain(journal.new_removal_paths.iter()) + .chain(journal.new_primary.iter()); + if old_owned_paths.any(|path| { + !crate::platform::path_is_within(path, &journal.old_destination) + }) || new_owned_paths.any(|path| { + !crate::platform::path_is_within(path, &journal.new_destination) + }) { + log::warn!("leaving Torrent move journal with paths outside its roots"); + continue; + } + let item = match load_persisted_torrent_item(database, &journal.id) { + Ok(item) => item, + Err(_) => { + log::warn!( + "leaving Torrent move journal {} because its download row is absent", + journal.id + ); + continue; + } + }; + let current_destination = item + .destination + .as_deref() + .map(|value| crate::resolve_path(value, app_handle)); + let database_committed = current_destination + .as_deref() + .is_some_and(|value| crate::platform::paths_equal(value, &journal.new_destination)); + if database_committed + || matches!(journal.phase.as_str(), "databaseCommitted" | "sourceCleanupPending") + { + let mut cleanup_error = None; + for old_path in &journal.old_paths { + if let Err(error) = remove_torrent_move_file(old_path) { + cleanup_error = Some(error); + break; + } + } + if cleanup_error.is_none() { + if let Err(error) = remove_empty_torrent_move_directories( + &journal.old_paths, + &journal.old_destination, + ) { + cleanup_error = Some(error); + } + } + if cleanup_error.is_none() { + for staging_path in &journal.staging_paths { + if let Err(error) = remove_torrent_move_file(staging_path) { + cleanup_error = Some(error); + break; + } + } + } + if cleanup_error.is_none() { + if let Some(root) = journal.staging_root.as_ref() { + if let Err(error) = remove_torrent_move_staging_root(root) { + cleanup_error = Some(error); + } + } + } + if let Some(error) = cleanup_error { + log::warn!( + "Torrent move recovery [{}] retained the journal: {}", + journal.id, + error + ); + let _ = persist_torrent_relocation_check(database, &journal.id, true); + continue; + } + let _ = std::fs::remove_file(&path); + continue; + } + let database_is_old = current_destination + .as_deref() + .is_some_and(|value| crate::platform::paths_equal(value, &journal.old_destination)); + if !database_is_old { + log::warn!( + "leaving Torrent move journal {} because its destination is neither transaction endpoint", + journal.id + ); + continue; + } + let mut cleanup_error = None; + for new_path in &journal.new_paths { + if let Err(error) = remove_torrent_move_file(new_path) { + cleanup_error = Some(error); + break; + } + } + if cleanup_error.is_none() { + for staging_path in &journal.staging_paths { + if let Err(error) = remove_torrent_move_file(staging_path) { + cleanup_error = Some(error); + break; + } + } + } + if cleanup_error.is_none() { + if let Some(root) = journal.staging_root.as_ref() { + if let Err(error) = remove_torrent_move_staging_root(root) { + cleanup_error = Some(error); + } + } + } + if let Some(error) = cleanup_error { + log::warn!( + "Torrent move recovery [{}] retained the journal: {}", + journal.id, + error + ); + continue; + } + let old_primary = journal + .old_primary + .as_ref() + .or_else(|| journal.old_paths.first()); + let old_owned_paths = journal + .old_paths + .iter() + .filter(|path| { + !journal + .old_removal_paths + .iter() + .any(|removal| crate::platform::paths_equal(path, removal)) + }) + .cloned() + .collect::>(); + if let Some(old_primary) = old_primary { + if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal( + app_handle, + &journal.id, + old_primary, + &old_owned_paths, + &journal.old_removal_paths, + ) { + log::warn!( + "Torrent move recovery [{}] could not restore ownership: {}", + journal.id, + error + ); + continue; + } + } + let _ = std::fs::remove_file(&path); + } + Ok(()) +} + +fn torrent_move_path_pair( + old_root: &std::path::Path, + new_root: &std::path::Path, + path: &std::path::Path, +) -> Result { + if crate::platform::paths_equal(path, old_root) { + if crate::path_has_symlink_component(new_root) { + return Err("Torrent move destination contains a symbolic link".to_string()); + } + return crate::canonicalize_with_missing_components(new_root) + .ok_or_else(|| "Torrent move destination could not be canonicalized".to_string()); + } + let relative = path + .strip_prefix(old_root) + .map_err(|_| "managed Torrent output is outside its current root".to_string())?; + if relative.as_os_str().is_empty() + || relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { + return Err("managed Torrent output path is unsafe".to_string()); + } + let target = new_root.join(relative); + if crate::path_has_symlink_component(&target) { + return Err("Torrent move destination contains a symbolic link".to_string()); + } + Ok(crate::canonicalize_with_missing_components(&target) + .ok_or_else(|| "Torrent move destination could not be canonicalized".to_string())?) +} + +#[tauri::command] +async fn move_torrent_data( + app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, + database: tauri::State<'_, crate::db::DbState>, + id: String, + destination: String, +) -> Result<(), String> { + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; + let item = load_persisted_torrent_item(database.inner(), &id)?; + if item.is_torrent != Some(true) { + return Err("data relocation is available only for Torrent downloads".to_string()); + } + if !matches!(item.status, crate::ipc::DownloadStatus::Paused | crate::ipc::DownloadStatus::Completed | crate::ipc::DownloadStatus::Failed) { + return Err("pause the Torrent before moving its data".to_string()); + } + if state.queue_manager.aria2_gid_for_download(&id).is_some() { + return Err("the Torrent still has an active daemon lifecycle".to_string()); + } + let old_destination = crate::resolve_path( + item.destination + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Torrent data has no current destination".to_string())?, + &app_handle, + ); + if !old_destination.is_absolute() + || crate::path_has_symlink_component(&old_destination) + || !crate::is_safe_path(&old_destination, &app_handle) + { + return Err("Torrent current destination is unsafe".to_string()); + } + let old_destination = std::fs::canonicalize(&old_destination) + .map_err(|_| "Torrent current destination is unavailable".to_string())?; + let new_destination = std::path::PathBuf::from(destination.trim()); + if !new_destination.is_absolute() + || !new_destination.is_dir() + || crate::path_has_symlink_component(&new_destination) + || !crate::is_safe_path(&new_destination, &app_handle) + { + return Err("choose an existing managed destination folder without symbolic links".to_string()); + } + let new_destination = std::fs::canonicalize(&new_destination) + .map_err(|_| "Torrent move destination could not be canonicalized".to_string())?; + if crate::platform::paths_equal(&old_destination, &new_destination) { + return Err("the Torrent is already in that destination".to_string()); + } + + let torrent_path = item + .torrent_path + .as_deref() + .ok_or_else(|| "Torrent metadata is not resolved yet".to_string())?; + let torrent_path = crate::torrent::validate_managed_torrent_path(&app_handle, &id, torrent_path)?; + let bytes = tokio::fs::read(&torrent_path) + .await + .map_err(|_| "Torrent metadata is unavailable".to_string())?; + let metadata = crate::torrent::parse_torrent_bytes(&bytes)?; + crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)?; + + let ownership = snapshot_download_ownership(&app_handle, &id)?; + let old_paths = ownership.owned.clone(); + if old_paths.is_empty() { + return Err("Torrent data has no managed output files".to_string()); + } + let old_root = if metadata.files.len() == 1 { + old_destination.clone() + } else { + old_destination.join(&item.file_name) + }; + let new_root = if metadata.files.len() == 1 { + new_destination.clone() + } else { + new_destination.join(&item.file_name) + }; + let new_paths = old_paths + .iter() + .map(|path| { + if !path.is_file() + || std::fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return Err("managed Torrent output is missing or symbolic".to_string()); + } + torrent_move_path_pair(&old_root, &new_root, path) + }) + .collect::, _>>()?; + let old_primary = ownership + .primary + .as_ref() + .ok_or_else(|| "Torrent ownership is unavailable".to_string())?; + let new_primary = torrent_move_path_pair(&old_root, &new_root, old_primary)?; + let new_removal_paths = ownership + .removal + .iter() + .map(|path| torrent_move_path_pair(&old_root, &new_root, path)) + .collect::, _>>()?; + let mut move_old_paths = old_paths.clone(); + let mut move_new_paths = new_paths.clone(); + for (old_path, new_path) in ownership.removal.iter().zip(new_removal_paths.iter()) { + match std::fs::symlink_metadata(old_path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + return Err("managed Torrent removal path is not a regular file".to_string()); + } + Ok(_) => { + if !move_old_paths + .iter() + .any(|path| crate::platform::paths_equal(path, old_path)) + { + move_old_paths.push(old_path.clone()); + move_new_paths.push(new_path.clone()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err("managed Torrent removal path could not be inspected".to_string()), + } + } + if new_paths.iter().any(|path| std::fs::symlink_metadata(path).is_ok()) + || new_removal_paths + .iter() + .any(|path| std::fs::symlink_metadata(path).is_ok()) + { + return Err("the Torrent move destination already contains managed output".to_string()); + } + let total_bytes = move_old_paths + .iter() + .map(|path| std::fs::metadata(path).map(|metadata| metadata.len())) + .collect::, _>>() + .map_err(|_| "could not read Torrent data size".to_string())? + .into_iter() + .try_fold(0u64, u64::checked_add) + .ok_or_else(|| "Torrent move size overflowed".to_string())?; + let staging_root = new_destination.join(format!(".firelink-torrent-move-{id}")); + if crate::path_has_symlink_component(&staging_root) + || std::fs::symlink_metadata(&staging_root).is_ok() + { + return Err("the Torrent move staging location is unavailable".to_string()); + } + let staging_paths = move_new_paths + .iter() + .enumerate() + .map(|(index, _)| staging_root.join(format!("{index:08}.part"))) + .collect::>(); + for path in &move_new_paths { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|_| "could not prepare the Torrent move destination".to_string())?; + } + } + + let journal = torrent_move_journal_path(&state, &id)?; + if let Some(parent) = journal.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|_| "could not prepare Torrent move recovery".to_string())?; + } + state.queue_manager.begin_torrent_move(&id).await; + if let Err(error) = write_torrent_move_journal( + &journal, + "reserved", + &id, + &old_destination, + &new_destination, + ownership.primary.as_deref(), + &ownership.removal, + &new_primary, + &new_removal_paths, + &staging_root, + &staging_paths, + &move_old_paths, + &move_new_paths, + total_bytes, + ) + .await + { + state.queue_manager.finish_torrent_move(&id).await; + return Err(error); + } + if let Err(error) = tokio::fs::create_dir(&staging_root).await { + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + return Err(format!("could not prepare Torrent move staging: {error}")); + } + if let Err(error) = crate::download_ownership::set_owned_paths_with_primary_and_removal( + &app_handle, + &id, + &new_primary, + &new_paths, + &new_removal_paths, + ) { + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + return Err(error); + } + + use tauri::Emitter; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, crate::ipc::DownloadStatus::Moving)); + let mut copied_bytes = 0u64; + for (source, target) in move_old_paths.iter().zip(staging_paths.iter()) { + if state.queue_manager.torrent_move_cancelled(&id).await { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + return Err("Torrent move canceled".to_string()); + } + if let Err(error) = copy_torrent_move_file(source, target).await { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + return Err(error); + } + let source_size = match tokio::fs::metadata(source).await { + Ok(metadata) => metadata.len(), + Err(_) => { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err("Torrent source changed during relocation".to_string()); + } + }; + let target_size = match tokio::fs::metadata(target).await { + Ok(metadata) => metadata.len(), + Err(_) => { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err("Torrent destination could not be verified".to_string()); + } + }; + if source_size != target_size { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + return Err("Torrent data changed during relocation".to_string()); + } + let source_digest = digest_torrent_move_file(source).await; + let target_digest = digest_torrent_move_file(target).await; + if source_digest.is_err() || target_digest.is_err() || source_digest.ok() != target_digest.ok() { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + return Err("Torrent data changed during relocation".to_string()); + } + copied_bytes = copied_bytes.saturating_add(target_size); + let _ = app_handle.emit("torrent-move-progress", crate::ipc::TorrentMoveProgressEvent { + id: id.clone(), + fraction: if total_bytes == 0 { 1.0 } else { copied_bytes as f64 / total_bytes as f64 }, + copied_bytes, + total_bytes, + }); + } + if state.queue_manager.torrent_move_cancelled(&id).await { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + return Err("Torrent move canceled".to_string()); + } + for (staged, target) in staging_paths.iter().zip(move_new_paths.iter()) { + if tokio::fs::rename(staged, target).await.is_err() { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err("Torrent move destination could not be published".to_string()); + } + } + if tokio::fs::remove_dir(&staging_root).await.is_err() { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err("Torrent move staging could not be finalized".to_string()); + } + if let Err(error) = write_torrent_move_journal( + &journal, + "copied", + &id, + &old_destination, + &new_destination, + ownership.primary.as_deref(), + &ownership.removal, + &new_primary, + &new_removal_paths, + &staging_root, + &staging_paths, + &move_old_paths, + &move_new_paths, + total_bytes, + ) + .await + { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err(error); + } + let relocation_check_pending = item.torrent_relocation_check_pending == Some(true) + || matches!( + item.status, + crate::ipc::DownloadStatus::Paused | crate::ipc::DownloadStatus::Failed + ); + if let Err(error) = persist_torrent_destination( + database.inner(), + &id, + &new_destination.to_string_lossy(), + relocation_check_pending, + ) { + cleanup_torrent_move_outputs(&move_new_paths, &staging_paths, &staging_root).await; + let _ = restore_download_ownership(&app_handle, &id, ownership.clone()); + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + return Err(error); + } + if let Err(error) = write_torrent_move_journal( + &journal, + "databaseCommitted", + &id, + &old_destination, + &new_destination, + ownership.primary.as_deref(), + &ownership.removal, + &new_primary, + &new_removal_paths, + &staging_root, + &staging_paths, + &move_old_paths, + &move_new_paths, + total_bytes, + ) + .await + { + // The database is already authoritative. Keep the prior journal and + // let startup recovery finish old-source cleanup from the committed + // destination rather than rolling the row back after commit. + let _ = persist_torrent_relocation_check(database.inner(), &id, true); + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err(format!("Torrent data moved; cleanup recovery remains pending: {error}")); + } + let mut cleanup_error: Option = None; + for path in &move_old_paths { + if let Err(error) = tokio::fs::remove_file(path).await { + if error.kind() != std::io::ErrorKind::NotFound { + cleanup_error = Some(error.to_string()); + } + } + } + if cleanup_error.is_none() { + if let Err(error) = remove_empty_torrent_move_directories( + &move_old_paths, + &old_destination, + ) { + cleanup_error = Some(error); + } + } + if let Some(error) = cleanup_error { + let _ = persist_torrent_destination(database.inner(), &id, &new_destination.to_string_lossy(), true); + if let Err(journal_error) = write_torrent_move_journal( + &journal, + "sourceCleanupPending", + &id, + &old_destination, + &new_destination, + ownership.primary.as_deref(), + &ownership.removal, + &new_primary, + &new_removal_paths, + &staging_root, + &staging_paths, + &move_old_paths, + &move_new_paths, + total_bytes, + ).await { + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit( + "download-state", + crate::ipc::DownloadStateEvent::new(&id, item.status), + ); + return Err(format!( + "Torrent data moved, but cleanup recovery could not be recorded: {journal_error}" + )); + } + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + drop(control_guard); + return Err(format!("Torrent data moved, but old files need cleanup: {error}")); + } + let _ = tokio::fs::remove_file(&journal).await; + state.queue_manager.finish_torrent_move(&id).await; + let _ = app_handle.emit("download-state", crate::ipc::DownloadStateEvent::new(&id, item.status)); + drop(control_guard); + Ok(()) +} + +#[tauri::command] +async fn cancel_torrent_move_data( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + if id.trim().is_empty() { + return Err("invalid Torrent download id".to_string()); + } + state.queue_manager.cancel_torrent_move(&id).await; + Ok(()) +} + fn verification_destination( app_handle: &tauri::AppHandle, item: &crate::ipc::DownloadItem, @@ -7310,6 +8520,7 @@ fn replace_persisted_torrent_web_seeds( .ok_or_else(|| "persisted download is not an object".to_string())?; previous_seeds = object.get("torrentWebSeeds").cloned(); object.insert("torrentWebSeeds".to_string(), next_seeds.clone()); + object.insert("torrentWebSeedsNative".to_string(), next_seeds.clone()); changed = true; } next.push( @@ -7365,6 +8576,7 @@ fn restore_persisted_torrent_web_seeds( object.remove("torrentWebSeeds"); } } + object.remove("torrentWebSeedsNative"); changed = true; } else { log::warn!( @@ -7424,10 +8636,14 @@ async fn get_torrent_web_seeds( state: tauri::State<'_, AppState>, id: String, ) -> Result, String> { + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; if state.queue_manager.is_registered(&id).await && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)) { - return state.queue_manager.get_aria2_torrent_web_seeds(&id).await; + return state + .queue_manager + .get_aria2_torrent_web_seeds_locked(&id, &control_guard) + .await; } let persisted_seeds = { let connection = database.lock()?; @@ -7463,12 +8679,13 @@ async fn set_torrent_web_seeds( id: String, seeds: Vec, ) -> Result, String> { + let control_guard = state.queue_manager.acquire_aria2_control(&id).await; let active = state.queue_manager.is_registered(&id).await && matches!(state.queue_manager.active_kind(&id).await, Some(crate::queue::TaskKind::Aria2)); let normalized = if active { state .queue_manager - .normalize_aria2_torrent_web_seeds(&id, &seeds) + .normalize_aria2_torrent_web_seeds_locked(&id, &seeds, &control_guard) .await? } else { normalize_persisted_torrent_web_seeds( @@ -7492,7 +8709,7 @@ async fn set_torrent_web_seeds( } match state .queue_manager - .set_aria2_torrent_web_seeds(&id, normalized.clone()) + .set_aria2_torrent_web_seeds_locked(&id, normalized.clone(), &control_guard) .await { Ok((result, previous_live_seeds)) => { @@ -7505,7 +8722,11 @@ async fn set_torrent_web_seeds( // persistence failure to the caller. if let Err(rollback_error) = state .queue_manager - .set_aria2_torrent_web_seeds(&id, previous_live_seeds) + .set_aria2_torrent_web_seeds_locked( + &id, + previous_live_seeds, + &control_guard, + ) .await { log::error!( @@ -8449,9 +9670,133 @@ fn db_replace_downloads( ) -> Result<(), String> { let portable = state.is_portable(); let mut connection = state.lock()?; + let existing = crate::db::load_downloads(&connection)?; + let data = merge_durable_torrent_telemetry(&existing, &data)?; crate::db::replace_downloads(&mut connection, &data, portable) } +fn persisted_destinations_equal(left: &str, right: &str) -> bool { + let left = std::path::Path::new(left.trim()); + let right = std::path::Path::new(right.trim()); + crate::platform::paths_equal(left, right) + || std::fs::canonicalize(left) + .ok() + .zip(std::fs::canonicalize(right).ok()) + .is_some_and(|(left, right)| crate::platform::paths_equal(&left, &right)) +} + +/// The renderer saves complete download snapshots, while native commands and +/// the poller checkpoint Torrent state independently. Merge these writers at +/// the persistence boundary so an older renderer snapshot cannot lower native +/// lifetime totals or roll back a just-committed relocation. +fn merge_durable_torrent_telemetry(existing: &[String], data: &str) -> Result { + let mut native_state: HashMap< + String, + (u64, u64, Option, Option), + > = HashMap::new(); + for record in existing { + let Ok(value) = serde_json::from_str::(record) else { + continue; + }; + let Some(object) = value.as_object() else { + continue; + }; + let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + let uploaded = object + .get("torrentUploadedBytes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let seeded = object + .get("torrentSeededSeconds") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + native_state.insert( + id.to_string(), + ( + uploaded, + seeded, + object + .get("torrentMoveDestination") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string), + object.get("torrentWebSeedsNative").cloned(), + ), + ); + } + + let mut values: serde_json::Value = serde_json::from_str(data) + .map_err(|error| format!("failed to decode downloads: {error}"))?; + let records = values + .as_array_mut() + .ok_or_else(|| "persisted downloads must be an array".to_string())?; + for value in records { + let Some(object) = value.as_object_mut() else { + continue; + }; + let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + let Some((existing_uploaded, existing_seeded, native_destination, native_web_seeds)) = + native_state.get(id).cloned() + else { + continue; + }; + if let Some(native_destination) = native_destination { + let incoming_destination = object + .get("destination") + .and_then(serde_json::Value::as_str); + if incoming_destination + .is_some_and(|value| persisted_destinations_equal(value, &native_destination)) + { + object.remove("torrentMoveDestination"); + } else { + object.insert( + "destination".to_string(), + serde_json::Value::String(native_destination.clone()), + ); + object.insert( + "torrentMoveDestination".to_string(), + serde_json::Value::String(native_destination), + ); + } + } + if let Some(native_web_seeds) = native_web_seeds { + if object.get("torrentWebSeeds") == Some(&native_web_seeds) { + object.remove("torrentWebSeedsNative"); + } else { + object.insert("torrentWebSeeds".to_string(), native_web_seeds.clone()); + object.insert("torrentWebSeedsNative".to_string(), native_web_seeds); + } + } + let uploaded = object + .get("torrentUploadedBytes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + .max(existing_uploaded); + let seeded = object + .get("torrentSeededSeconds") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) + .max(existing_seeded); + if existing_uploaded > 0 || object.contains_key("torrentUploadedBytes") { + object.insert( + "torrentUploadedBytes".to_string(), + serde_json::Value::from(uploaded), + ); + } + if existing_seeded > 0 || object.contains_key("torrentSeededSeconds") { + object.insert( + "torrentSeededSeconds".to_string(), + serde_json::Value::from(seeded), + ); + } + } + serde_json::to_string(&values) + .map_err(|error| format!("failed to encode downloads: {error}")) +} + #[tauri::command] fn db_get_all_queues(state: tauri::State<'_, crate::db::DbState>) -> Result, String> { let connection = state.lock()?; @@ -8894,6 +10239,7 @@ mod tests { validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id, retained_torrent_id_from_persisted_record, retained_torrent_info_hash_from_persisted_record, + merge_durable_torrent_telemetry, torrent_identity_magnet, torrent_move_path_pair, }; #[cfg(target_os = "macos")] use super::should_apply_dock_badge_update; @@ -8909,6 +10255,128 @@ mod tests { assert!(validate_keychain_grant_request_id(&"x".repeat(129)).is_err()); } + #[test] + fn renderer_download_snapshots_cannot_lower_native_torrent_totals() { + let existing = vec![ + json!({ + "id": "torrent-1", + "torrentUploadedBytes": 900, + "torrentSeededSeconds": 45 + }) + .to_string(), + ]; + let merged = merge_durable_torrent_telemetry( + &existing, + &json!([ + { "id": "torrent-1", "torrentUploadedBytes": 12, "torrentSeededSeconds": 90 }, + { "id": "file-1" } + ]) + .to_string(), + ) + .unwrap(); + let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); + assert_eq!(records[0]["torrentUploadedBytes"], 900); + assert_eq!(records[0]["torrentSeededSeconds"], 90); + } + + #[test] + fn renderer_download_snapshots_cannot_roll_back_a_native_relocation() { + let existing = vec![ + json!({ + "id": "torrent-1", + "destination": "/downloads/new", + "torrentMoveDestination": "/downloads/new" + }) + .to_string(), + ]; + let merged = merge_durable_torrent_telemetry( + &existing, + &json!([{ "id": "torrent-1", "destination": "/downloads/old" }]).to_string(), + ) + .unwrap(); + let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); + assert_eq!(records[0]["destination"], "/downloads/new"); + assert_eq!(records[0]["torrentMoveDestination"], "/downloads/new"); + + let acknowledged = merge_durable_torrent_telemetry( + &existing, + &json!([{ "id": "torrent-1", "destination": "/downloads/new" }]).to_string(), + ) + .unwrap(); + let acknowledged: serde_json::Value = serde_json::from_str(&acknowledged).unwrap(); + assert!(acknowledged[0].get("torrentMoveDestination").is_none()); + } + + #[test] + fn renderer_download_snapshots_cannot_roll_back_native_web_seed_changes() { + let native_seeds = json!([{ "fileIndex": 0, "uri": "https://mirror.example/file" }]); + let existing = vec![ + json!({ + "id": "torrent-1", + "torrentWebSeeds": native_seeds.clone(), + "torrentWebSeedsNative": native_seeds.clone() + }) + .to_string(), + ]; + let merged = merge_durable_torrent_telemetry( + &existing, + &json!([{ "id": "torrent-1", "torrentWebSeeds": [] }]).to_string(), + ) + .unwrap(); + let records: serde_json::Value = serde_json::from_str(&merged).unwrap(); + assert_eq!(records[0]["torrentWebSeeds"][0]["uri"], "https://mirror.example/file"); + assert!(records[0].get("torrentWebSeedsNative").is_some()); + + let acknowledged = merge_durable_torrent_telemetry( + &existing, + &json!([{ "id": "torrent-1", "torrentWebSeeds": native_seeds }]).to_string(), + ) + .unwrap(); + let acknowledged: serde_json::Value = serde_json::from_str(&acknowledged).unwrap(); + assert!(acknowledged[0].get("torrentWebSeedsNative").is_none()); + } + + #[test] + fn torrent_identity_magnet_is_bounded_and_credential_free() { + let details = crate::ipc::TorrentDetails { + info_hash: "0123456789abcdef0123456789abcdef01234567".to_string(), + display_name: "folder/preview & name".to_string(), + total_bytes: 1, + file_count: 1, + piece_length: 1, + piece_count: 1, + private: true, + creation_date: None, + creator: None, + comment: None, + trackers: vec!["https://tracker.example/passkey".to_string()], + web_seeds: vec!["https://cdn.example/file".to_string()], + }; + let magnet = torrent_identity_magnet(&details).unwrap(); + assert_eq!( + magnet, + "magnet:?xt=urn%3Abtih%3A0123456789abcdef0123456789abcdef01234567&dn=preview+%26+name" + ); + assert!(!magnet.contains("tracker")); + assert!(!magnet.contains("passkey")); + } + + #[test] + fn torrent_move_root_mapping_handles_single_file_output() { + let root = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); + let old_root = root.path().join("old"); + let new_root = root.path().join("new"); + std::fs::create_dir_all(&old_root).unwrap(); + std::fs::create_dir_all(&new_root).unwrap(); + let mapped = torrent_move_path_pair(&old_root, &new_root, &old_root).unwrap(); + assert_eq!(mapped, new_root.canonicalize().unwrap()); + let child = old_root.join("file.bin"); + assert_eq!( + torrent_move_path_pair(&old_root, &new_root, &child).unwrap(), + new_root.join("file.bin") + ); + } + #[test] fn aria2_torrent_peer_discovery_options_are_explicit_and_launch_scoped() { let mut command = std::process::Command::new("aria2c"); @@ -11623,6 +13091,13 @@ pub fn run() { let database = crate::db::init(&storage_layout) .map_err(|error| format!("failed to initialize persistence: {error}"))?; + if let Err(error) = recover_torrent_move_journals( + app.handle(), + &database, + &storage_layout, + ) { + log::warn!("Torrent move recovery did not complete: {error}"); + } // Establish Firelink-owned Aria2 routing-table paths after the // existing data-root initializer has created the selected storage // directory, but before the daemon launcher is scheduled. A @@ -12153,6 +13628,12 @@ pub fn run() { let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000)); let mut observations: HashMap = HashMap::new(); let mut missing_gid_recovery_at: HashMap = HashMap::new(); + let mut telemetry_hydrated = HashSet::new(); + let mut telemetry_persisted: HashMap< + String, + crate::queue::TorrentTelemetrySnapshot, + > = HashMap::new(); + let mut relocation_checks = HashSet::new(); loop { interval.tick().await; // Terminal cleanup removes a download's GID mapping. Do @@ -12165,6 +13646,9 @@ pub fn run() { .collect(); observations.retain(|id, _| mapped_ids.contains(id)); missing_gid_recovery_at.retain(|id, _| mapped_ids.contains(id)); + telemetry_hydrated.retain(|id| mapped_ids.contains(id)); + telemetry_persisted.retain(|id, _| mapped_ids.contains(id)); + relocation_checks.retain(|id| mapped_ids.contains(id)); let params = serde_json::json!([[ "gid", "status", @@ -12269,6 +13753,78 @@ pub fn run() { { continue; } + let torrent_telemetry = if is_torrent { + if !telemetry_hydrated.contains(&id) { + match load_persisted_torrent_item( + &app_handle_poll.state::(), + &id, + ) { + Ok(item) => { + poll_mgr + .hydrate_torrent_telemetry( + &id, + item.torrent_uploaded_bytes.unwrap_or(0), + item.torrent_seeded_seconds.unwrap_or(0), + ) + .await; + if item.torrent_relocation_check_pending + == Some(true) + { + relocation_checks.insert(id.clone()); + } + telemetry_hydrated.insert(id.clone()); + } + Err(error) => { + log::debug!( + "aria2 telemetry [{}]: could not hydrate durable totals: {}", + id, + error + ); + } + } + } + let seed_permit_owned = poll_mgr + .aria2_torrent_seed_permit_owned(&id) + .await; + Some( + poll_mgr + .observe_torrent_telemetry( + &id, + gid, + control_epoch, + uploaded_bytes, + is_seeder && seed_permit_owned, + Instant::now(), + ) + .await, + ) + } else { + None + }; + if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) + || !poll_mgr + .is_aria2_control_epoch_current(&id, control_epoch) + .await + { + continue; + } + if let Some(snapshot) = torrent_telemetry { + if telemetry_persisted.get(&id) != Some(&snapshot) { + if let Err(error) = persist_torrent_telemetry( + &app_handle_poll.state::(), + &id, + snapshot, + ) { + log::debug!( + "aria2 telemetry [{}]: could not persist durable totals: {}", + id, + error + ); + } else { + telemetry_persisted.insert(id.clone(), snapshot); + } + } + } let is_verifying = is_torrent && (verify_pending || (status == "active" @@ -12278,6 +13834,31 @@ pub fn run() { let entering_verifying = is_verifying && !observation.verifying; let leaving_verifying = !is_verifying && observation.verifying; observation.verifying = is_verifying; + if relocation_checks.contains(&id) + && leaving_verifying + && matches!(status, "active" | "waiting") + && poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) + && poll_mgr + .is_aria2_control_epoch_current(&id, control_epoch) + .await + { + if let Err(error) = persist_torrent_relocation_check( + &app_handle_poll.state::(), + &id, + false, + ) { + log::debug!( + "aria2 relocation check [{}]: could not clear one-shot marker: {}", + id, + error + ); + } else { + let _ = poll_mgr + .clear_torrent_relocation_check(&id, control_epoch) + .await; + relocation_checks.remove(&id); + } + } if is_verification && leaving_verifying && total > 0 @@ -12359,9 +13940,13 @@ pub fn run() { total_is_estimate: Some(false), active_connections: Some(active_connections), requested_connections: Some(requested_connections), - uploaded_bytes: uploaded_bytes.map(|value| value as f64), + uploaded_bytes: torrent_telemetry + .map(|value| value.uploaded_bytes as f64) + .or_else(|| uploaded_bytes.map(|value| value as f64)), upload_speed, num_seeders, + torrent_seeded_seconds: torrent_telemetry + .map(|value| value.seeded_seconds as f64), }); if entering_seeding @@ -12472,6 +14057,59 @@ pub fn run() { continue; } }; + if let Some(mapping) = poll_mgr + .aria2_gid_mapping(&gid) + .filter(|mapping| mapping.id == id) + { + let is_torrent = poll_mgr.aria2_is_torrent(&id).await; + if is_torrent + && poll_mgr + .is_aria2_control_epoch_current(&id, mapping.epoch) + .await + { + let upload_length = status + .get("uploadLength") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()); + let is_seeder = status.get("seeder").is_some_and(|value| { + value.as_str() == Some("true") + || value.as_bool() == Some(true) + }); + let seed_permit_owned = poll_mgr + .aria2_torrent_seed_permit_owned(&id) + .await; + let snapshot = poll_mgr + .observe_torrent_telemetry( + &id, + &gid, + mapping.epoch, + upload_length, + is_seeder && seed_permit_owned, + Instant::now(), + ) + .await; + if poll_mgr.is_current_aria2_gid_mapping(&gid, &mapping) + && poll_mgr + .is_aria2_control_epoch_current(&id, mapping.epoch) + .await + && telemetry_persisted.get(&id) != Some(&snapshot) + { + if let Err(error) = persist_torrent_telemetry( + &app_handle_poll.state::(), + &id, + snapshot, + ) { + log::debug!( + "aria2 terminal telemetry [{}]: could not persist durable totals: {}", + id, + error + ); + } else { + telemetry_persisted.insert(id.clone(), snapshot); + } + } + } + } let status_name = status .get("status") .and_then(|value| value.as_str()) @@ -12602,7 +14240,7 @@ pub fn run() { authorize_keychain_access, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, - get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path, + get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path, detach_download_for_reconfigure, enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order, commands::reveal_in_file_manager, commands::open_downloaded_file, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 0fb7d31..7a57a12 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -10,7 +10,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore}; use ts_rs::TS; @@ -37,6 +37,10 @@ pub const MAX_TORRENT_NETWORK_VALUE_LENGTH: usize = 256; pub const MAX_TORRENT_PEER_ID_PREFIX_BYTES: usize = 20; pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128; pub const MAX_TORRENT_PIECES_FOR_PROGRESS: u64 = 10_000_000; +pub const MAX_TORRENT_AVAILABILITY_PEERS: usize = 4_096; +/// Poller gaps beyond this bounded interval are treated conservatively. In +/// particular, a suspended machine must not accrue wall-clock seed time. +pub const MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS: u64 = 5; pub const MAX_TORRENT_WEB_SEEDS: usize = 256; pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048; pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024; @@ -479,6 +483,97 @@ pub struct Aria2GidMapping { pub epoch: u64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TorrentTelemetrySnapshot { + pub uploaded_bytes: u64, + pub seeded_seconds: u64, +} + +#[derive(Debug, Clone)] +struct TorrentTelemetryState { + gid: String, + epoch: u64, + last_upload_length: Option, + uploaded_bytes: u64, + seeded_seconds: u64, + last_observed_at: Option, + last_was_seeding: bool, +} + +impl TorrentTelemetryState { + fn new(gid: &str, epoch: u64, now: Instant) -> Self { + Self { + gid: gid.to_string(), + epoch, + last_upload_length: None, + uploaded_bytes: 0, + seeded_seconds: 0, + last_observed_at: Some(now), + last_was_seeding: false, + } + } + + fn snapshot(&self) -> TorrentTelemetrySnapshot { + TorrentTelemetrySnapshot { + uploaded_bytes: self.uploaded_bytes, + seeded_seconds: self.seeded_seconds, + } + } + + fn observe( + &mut self, + gid: &str, + epoch: u64, + upload_length: Option, + is_seeding: bool, + now: Instant, + ) -> TorrentTelemetrySnapshot { + if self.gid != gid || self.epoch != epoch { + // A new GID or control epoch is a new daemon counter lifecycle. + // Preserve Firelink totals, but never interpret the new counter + // as a continuation of the old one. + if self.last_was_seeding { + if let Some(previous) = self.last_observed_at { + let seconds = now + .saturating_duration_since(previous) + .min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS)) + .as_secs(); + self.seeded_seconds = self.seeded_seconds.saturating_add(seconds); + } + } + self.gid = gid.to_string(); + self.epoch = epoch; + self.last_upload_length = None; + self.last_observed_at = Some(now); + self.last_was_seeding = false; + } else if self.last_was_seeding { + if let Some(previous) = self.last_observed_at { + let seconds = now + .saturating_duration_since(previous) + .min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS)) + .as_secs(); + self.seeded_seconds = self.seeded_seconds.saturating_add(seconds); + } + } + + if let Some(current) = upload_length { + if let Some(previous) = self.last_upload_length { + if current >= previous { + self.uploaded_bytes = self + .uploaded_bytes + .saturating_add(current.saturating_sub(previous)); + } + // A decreased Aria2 counter is a daemon/lifecycle reset. The + // current value becomes the new baseline, without adding it. + } + self.last_upload_length = Some(current); + } + self.last_observed_at = Some(now); + self.last_was_seeding = is_seeding; + self.snapshot() + } +} + /// Owns one per-download control lock and removes its idle map entry when the /// last operation for that download finishes. pub struct Aria2ControlGuard { @@ -786,6 +881,11 @@ pub struct QueueManager { /// alive, but release the download semaphore while they own a seed slot. seed_capacity: StdMutex, seed_budgets: StdMutex>, + /// Firelink lifetime Torrent upload/seed accounting. Raw Aria2 counters + /// are scoped to the current GID and control epoch and never leave this + /// process as durable state. + torrent_telemetry: Mutex>, + torrent_move_cancellations: Mutex>, /// aria2 gid -> download id map (shared with the WS poller). pub aria2_gids: Arc>>, @@ -882,6 +982,8 @@ impl QueueManager { ..SeedCapacityState::default() }), seed_budgets: StdMutex::new(HashMap::new()), + torrent_telemetry: Mutex::new(HashMap::new()), + torrent_move_cancellations: Mutex::new(HashSet::new()), aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())), pending_completion: Arc::new(Mutex::new(HashMap::new())), aria2_payloads: Mutex::new(HashMap::new()), @@ -907,6 +1009,86 @@ impl QueueManager { Arc::clone(&self.power_manager) } + /// Accept one lifecycle-fenced Aria2 status sample and return Firelink's + /// monotonic lifetime counters. Poller callers must already have checked + /// the mapping; the key and epoch checks here provide a second fence at + /// the accounting owner itself. + pub async fn observe_torrent_telemetry( + &self, + id: &str, + gid: &str, + epoch: u64, + upload_length: Option, + is_seeding: bool, + now: Instant, + ) -> TorrentTelemetrySnapshot { + let mut telemetry = self.torrent_telemetry.lock().await; + let state = telemetry + .entry(id.to_string()) + .or_insert_with(|| TorrentTelemetryState::new(gid, epoch, now)); + state.observe(gid, epoch, upload_length, is_seeding, now) + } + + /// Restore the durable Firelink totals before the first raw Aria2 sample + /// for a download. Raw upload counters are intentionally not restored; + /// the next observation establishes a fresh GID/epoch baseline. + pub async fn hydrate_torrent_telemetry( + &self, + id: &str, + uploaded_bytes: u64, + seeded_seconds: u64, + ) { + let mut telemetry = self.torrent_telemetry.lock().await; + let state = telemetry + .entry(id.to_string()) + .or_insert_with(|| TorrentTelemetryState::new("", 0, Instant::now())); + state.uploaded_bytes = state.uploaded_bytes.max(uploaded_bytes); + state.seeded_seconds = state.seeded_seconds.max(seeded_seconds); + } + + /// Clear the one-shot integrity override only for the still-current + /// lifecycle. A normal user integrity preference is never changed here. + pub async fn clear_torrent_relocation_check(&self, id: &str, epoch: u64) -> bool { + if !self.is_aria2_control_epoch_current(id, epoch).await { + return false; + } + let mut payloads = self.aria2_payloads.lock().await; + let Some(payload) = payloads.get_mut(id) else { + return false; + }; + if !payload.is_torrent { + return false; + } + payload.torrent_check_integrity = false; + true + } + + pub async fn begin_torrent_move(&self, id: &str) { + self.torrent_move_cancellations.lock().await.remove(id); + } + + pub async fn cancel_torrent_move(&self, id: &str) { + self.torrent_move_cancellations + .lock() + .await + .insert(id.to_string()); + } + + pub async fn torrent_move_cancelled(&self, id: &str) -> bool { + self.torrent_move_cancellations.lock().await.contains(id) + } + + pub async fn finish_torrent_move(&self, id: &str) { + self.torrent_move_cancellations.lock().await.remove(id); + } + + /// Drop counters after terminal cleanup/removal. Persisted lifetime + /// totals remain owned by the DownloadItem row; this only removes raw + /// process-local lifecycle state. + pub async fn forget_torrent_telemetry(&self, id: &str) { + self.torrent_telemetry.lock().await.remove(id); + } + pub fn app_handle(&self) -> AppHandle { self.app_handle.clone() } @@ -1603,6 +1785,17 @@ impl QueueManager { .is_some_and(torrent_seeding_requested) } + /// Whether a currently seeding Torrent owns the Firelink permit that + /// allows seed-time accounting. Separate seed slots require explicit + /// ownership; legacy single-pool mode keeps the transfer permit live. + pub async fn aria2_torrent_seed_permit_owned(&self, id: &str) -> bool { + if self.seed_capacity_enabled() { + self.seed_owner(id) + } else { + self.aria2_torrent_seeding_requested(id).await + } + } + pub async fn aria2_is_torrent(&self, id: &str) -> bool { self.aria2_payloads .lock() @@ -1740,7 +1933,16 @@ impl QueueManager { &self, id: &str, ) -> Result, String> { - let _control_guard = self.acquire_aria2_control(id).await; + let control_guard = self.acquire_aria2_control(id).await; + self.get_aria2_torrent_web_seeds_locked(id, &control_guard) + .await + } + + pub async fn get_aria2_torrent_web_seeds_locked( + &self, + id: &str, + _control_guard: &Aria2ControlGuard, + ) -> Result, String> { let payload = self .aria2_payloads .lock() @@ -1776,7 +1978,17 @@ impl QueueManager { id: &str, seeds: &[crate::ipc::TorrentWebSeed], ) -> Result, String> { - let _control_guard = self.acquire_aria2_control(id).await; + let control_guard = self.acquire_aria2_control(id).await; + self.normalize_aria2_torrent_web_seeds_locked(id, seeds, &control_guard) + .await + } + + pub async fn normalize_aria2_torrent_web_seeds_locked( + &self, + id: &str, + seeds: &[crate::ipc::TorrentWebSeed], + _control_guard: &Aria2ControlGuard, + ) -> Result, String> { let payload = self .aria2_payloads .lock() @@ -1847,7 +2059,23 @@ impl QueueManager { ), String, > { - let _control_guard = self.acquire_aria2_control(id).await; + let control_guard = self.acquire_aria2_control(id).await; + self.set_aria2_torrent_web_seeds_locked(id, seeds, &control_guard) + .await + } + + pub async fn set_aria2_torrent_web_seeds_locked( + &self, + id: &str, + seeds: Vec, + _control_guard: &Aria2ControlGuard, + ) -> Result< + ( + Vec, + Vec, + ), + String, + > { let old_payload = self .aria2_payloads .lock() @@ -2349,6 +2577,87 @@ impl QueueManager { Ok(diagnostics) } + /// Compute bounded, anonymized swarm availability for the current + /// Torrent lifecycle. The raw local/peer bitfields are consumed in native + /// memory and never returned to the frontend. + pub async fn get_aria2_torrent_availability( + &self, + id: &str, + ) -> Result { + let _control_guard = self.acquire_aria2_control(id).await; + if !self.is_registered(id).await + || !matches!(self.active_kind(id).await, Some(TaskKind::Aria2)) + { + return Err("Torrent availability is unavailable for this lifecycle".to_string()); + } + if !self + .aria2_payloads + .lock() + .await + .get(id) + .is_some_and(|payload| payload.is_torrent) + { + return Err("download is not a Torrent transfer".to_string()); + } + let gid = self + .aria2_gid_for_download(id) + .ok_or_else(|| "active Torrent has no gid".to_string())?; + let expected_mapping = self + .aria2_gid_mapping(&gid) + .ok_or_else(|| "active Torrent has no current gid mapping".to_string())?; + if expected_mapping.id != id + || !self + .is_aria2_control_epoch_current(id, expected_mapping.epoch) + .await + { + return Err("active Torrent has a stale control epoch".to_string()); + } + let state = self.app_handle.state::(); + let status = crate::rpc_call( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, + "aria2.tellStatus", + serde_json::json!([gid, ["bitfield", "numPieces"]]), + ) + .await + .map_err(|error| { + format!( + "aria2.tellStatus failed: {}", + crate::redact_sensitive_text(&error) + ) + })?; + if !self.is_current_aria2_gid_mapping(&gid, &expected_mapping) + || !self + .is_aria2_control_epoch_current(id, expected_mapping.epoch) + .await + { + return Err("Torrent lifecycle changed while reading availability".to_string()); + } + let peers = crate::rpc_call( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, + "aria2.getPeers", + serde_json::json!([gid]), + ) + .await + .map_err(|error| { + format!( + "aria2.getPeers failed: {}", + crate::redact_sensitive_text(&error) + ) + })?; + let snapshot = parse_torrent_availability(status, peers)?; + if !self.is_registered(id).await + || !self.is_current_aria2_gid_mapping(&gid, &expected_mapping) + || !self + .is_aria2_control_epoch_current(id, expected_mapping.epoch) + .await + { + return Err("Torrent lifecycle changed while reading availability".to_string()); + } + Ok(snapshot) + } + /// Return a lifecycle-fenced, metadata-derived projection of Aria2's /// per-file progress. The daemon's absolute paths and URI lists are never /// copied across the boundary. @@ -3491,6 +3800,7 @@ impl QueueManager { .is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file); match outcome { PendingOutcome::Complete => { + self.forget_torrent_telemetry(id).await; self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; if torrent_removal_requested { @@ -3535,6 +3845,7 @@ impl QueueManager { self.emit_state(id, restored_status); } PendingOutcome::Error(error) => { + self.forget_torrent_telemetry(id).await; if !verification_only && error.to_ascii_lowercase().contains("checksum") { log::warn!("Checksum error detected for {}, cleaning up assets", id); if let Ok(paths) = @@ -4967,6 +5278,140 @@ fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool { } } +fn parse_torrent_availability_decimal( + object: &serde_json::Map, + field: &str, +) -> Result { + let value = object + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("aria2.tellStatus returned an invalid {field}"))?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("aria2.tellStatus returned an invalid {field}")); + } + value + .parse::() + .map_err(|_| format!("aria2.tellStatus returned an invalid {field}")) +} + +fn decode_torrent_availability_bitfield( + value: &str, + piece_count: u64, +) -> Result, String> { + if piece_count == 0 || piece_count > MAX_TORRENT_PIECES_FOR_PROGRESS { + return Err("Torrent availability has an unsupported piece count".to_string()); + } + let byte_count = piece_count + .checked_add(7) + .and_then(|value| value.checked_div(8)) + .ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?; + let expected_hex_length = byte_count + .checked_mul(2) + .ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?; + if value.len() != usize::try_from(expected_hex_length).unwrap_or(usize::MAX) + || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("Torrent availability bitfield is malformed".to_string()); + } + let mut bytes = Vec::with_capacity(byte_count as usize); + for pair in value.as_bytes().chunks_exact(2) { + let high = char::from(pair[0]) + .to_digit(16) + .ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?; + let low = char::from(pair[1]) + .to_digit(16) + .ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?; + bytes.push(((high << 4) | low) as u8); + } + if piece_count % 8 != 0 { + let overflow_mask = (1u8 << (8 - piece_count as u8 % 8)) - 1; + if bytes.last().is_some_and(|byte| byte & overflow_mask != 0) { + return Err("Torrent availability bitfield has overflow bits".to_string()); + } + } + Ok(bytes) +} + +fn torrent_availability_piece_is_set(bitfield: &[u8], index: usize) -> bool { + bitfield[index / 8] & (1 << (7 - index % 8)) != 0 +} + +pub(crate) fn parse_torrent_availability( + status: serde_json::Value, + peers: serde_json::Value, +) -> Result { + let status = status + .as_object() + .ok_or_else(|| "aria2.tellStatus returned malformed Torrent availability".to_string())?; + let piece_count = parse_torrent_availability_decimal(status, "numPieces")?; + let local_bitfield = status + .get("bitfield") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "aria2.tellStatus has no Torrent availability bitfield yet".to_string())?; + let local_bitfield = decode_torrent_availability_bitfield(local_bitfield, piece_count)?; + let peers = peers + .as_array() + .ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?; + if peers.len() > MAX_TORRENT_AVAILABILITY_PEERS { + return Err("aria2.getPeers returned too many peers for availability".to_string()); + } + let mut copies = vec![0u16; piece_count as usize]; + for index in 0..piece_count as usize { + if torrent_availability_piece_is_set(&local_bitfield, index) { + copies[index] = 1; + } + } + for peer in peers { + let Some(peer) = peer.as_object() else { + // Peer projections are network-derived and may be incomplete + // while Aria2 refreshes its peer table. Preserve the connected + // count but omit an unusable contribution rather than failing + // availability for the whole swarm. + continue; + }; + let Some(bitfield_value) = peer.get("bitfield") else { + // Aria2 can report a connected peer before the handshake has + // supplied its piece bitfield. Keep that peer in the connected + // count, but do not let it make the whole availability snapshot + // unavailable. A present, non-string bitfield remains malformed. + continue; + }; + let Some(bitfield) = bitfield_value.as_str() else { + continue; + }; + let Ok(bitfield) = decode_torrent_availability_bitfield(bitfield, piece_count) else { + continue; + }; + for index in 0..piece_count as usize { + if torrent_availability_piece_is_set(&bitfield, index) { + copies[index] = copies[index].saturating_add(1); + } + } + } + + let minimum = copies.iter().copied().min().unwrap_or(0); + let above_minimum = copies.iter().filter(|count| **count > minimum).count(); + let availability = minimum as f64 + above_minimum as f64 / piece_count as f64; + let bucket_count = piece_count.min(256) as usize; + let mut buckets = Vec::with_capacity(bucket_count); + for bucket_index in 0..bucket_count { + let start = piece_count * bucket_index as u64 / bucket_count as u64; + let end = piece_count * (bucket_index as u64 + 1) / bucket_count as u64; + let minimum_copies = copies[start as usize..end as usize] + .iter() + .copied() + .min() + .unwrap_or(0); + buckets.push(crate::ipc::TorrentAvailabilityBucket { minimum_copies }); + } + Ok(crate::ipc::TorrentAvailabilitySnapshot { + piece_count, + availability, + connected_peers: peers.len().try_into().unwrap_or(u32::MAX), + buckets, + }) +} + pub(crate) fn parse_torrent_peer_diagnostics( result: serde_json::Value, ) -> Result { @@ -7907,4 +8352,170 @@ mod tests { assert_eq!(automatic_retry_limit(Some(0)), 0); assert_eq!(automatic_retry_limit(Some(2)), 2); } + + #[test] + fn torrent_availability_aggregates_local_and_peer_copies_without_exposing_bitfields() { + let snapshot = parse_torrent_availability( + serde_json::json!({ "numPieces": "4", "bitfield": "f0" }), + serde_json::json!([ + { "bitfield": "30", "ip": "192.0.2.1" } + ]), + ) + .expect("availability should parse"); + assert_eq!(snapshot.piece_count, 4); + assert_eq!(snapshot.connected_peers, 1); + assert!((snapshot.availability - 1.5).abs() < f64::EPSILON); + assert_eq!(snapshot.buckets.len(), 4); + assert_eq!(snapshot.buckets[0].minimum_copies, 1); + } + + #[test] + fn torrent_availability_rejects_malformed_and_overflow_bitfields() { + assert!(parse_torrent_availability( + serde_json::json!({ "numPieces": "4", "bitfield": "f1" }), + serde_json::json!([]), + ) + .is_err()); + let snapshot = parse_torrent_availability( + serde_json::json!({ "numPieces": "4", "bitfield": "f0" }), + serde_json::json!([{ "bitfield": "0" }]), + ) + .expect("malformed peer data should be omitted"); + assert_eq!(snapshot.connected_peers, 1); + assert_eq!(snapshot.availability, 1.0); + } + + #[test] + fn torrent_availability_ignores_peers_before_their_bitfield_handshake() { + let snapshot = parse_torrent_availability( + serde_json::json!({ "numPieces": "4", "bitfield": "f0" }), + serde_json::json!([ + { "ip": "192.0.2.1" }, + { "bitfield": "30" } + ]), + ) + .expect("a peer without a handshake bitfield is not malformed"); + assert_eq!(snapshot.connected_peers, 2); + assert!((snapshot.availability - 1.5).abs() < f64::EPSILON); + } + + #[test] + fn torrent_telemetry_counts_only_monotonic_upload_deltas() { + let start = Instant::now(); + let mut state = TorrentTelemetryState::new("gid-1", 7, start); + assert_eq!( + state.observe("gid-1", 7, Some(100), false, start), + TorrentTelemetrySnapshot { + uploaded_bytes: 0, + seeded_seconds: 0 + } + ); + assert_eq!( + state.observe( + "gid-1", + 7, + Some(180), + false, + start + Duration::from_secs(1) + ) + .uploaded_bytes, + 80 + ); + // A daemon counter reset establishes a new baseline and contributes + // no bytes from the reset itself. + assert_eq!( + state.observe( + "gid-1", + 7, + Some(12), + false, + start + Duration::from_secs(2) + ) + .uploaded_bytes, + 80 + ); + assert_eq!( + state.observe( + "gid-1", + 7, + Some(20), + false, + start + Duration::from_secs(3) + ) + .uploaded_bytes, + 88 + ); + } + + #[test] + fn torrent_telemetry_restarts_baseline_on_gid_or_epoch_replacement() { + let start = Instant::now(); + let mut state = TorrentTelemetryState::new("gid-1", 1, start); + state.observe("gid-1", 1, Some(500), false, start); + state.observe("gid-1", 1, Some(525), false, start + Duration::from_secs(1)); + let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(2)); + assert_eq!(snapshot.uploaded_bytes, 25); + assert_eq!( + state.observe("gid-2", 2, Some(9), false, start + Duration::from_secs(3)) + .uploaded_bytes, + 30 + ); + } + + #[test] + fn torrent_telemetry_closes_the_previous_seed_interval_on_lifecycle_replacement() { + let start = Instant::now(); + let mut state = TorrentTelemetryState::new("gid-1", 1, start); + state.observe("gid-1", 1, Some(0), true, start); + let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(3)); + assert_eq!(snapshot.seeded_seconds, 3); + assert_eq!(snapshot.uploaded_bytes, 0); + } + + #[test] + fn torrent_telemetry_counts_seed_seconds_only_for_the_previous_seed_interval() { + let start = Instant::now(); + let mut state = TorrentTelemetryState::new("gid-1", 1, start); + state.observe("gid-1", 1, Some(0), true, start); + assert_eq!( + state.observe( + "gid-1", + 1, + Some(10), + true, + start + Duration::from_secs(4) + ) + .seeded_seconds, + 4 + ); + assert_eq!( + state.observe( + "gid-1", + 1, + Some(10), + false, + start + Duration::from_secs(9) + ) + .seeded_seconds, + 9 + ); + } + + #[test] + fn torrent_telemetry_caps_long_observer_gaps() { + let start = Instant::now(); + let mut state = TorrentTelemetryState::new("gid-1", 1, start); + state.observe("gid-1", 1, Some(0), true, start); + let snapshot = state.observe( + "gid-1", + 1, + Some(1), + true, + start + Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS + 3600), + ); + assert_eq!( + snapshot.seeded_seconds, + MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS + ); + } } diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 1cc17fe..b3a05a0 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -3,4 +3,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; import type { TorrentWebSeed } from "./TorrentWebSeed"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentWebSeeds?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentSeedRemaining?: number, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array, torrentWebSeedsNative?: Array, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: string, }; diff --git a/src/bindings/DownloadProgressEvent.ts b/src/bindings/DownloadProgressEvent.ts index c633c14..bb5e0ea 100644 --- a/src/bindings/DownloadProgressEvent.ts +++ b/src/bindings/DownloadProgressEvent.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, }; +export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, }; diff --git a/src/bindings/DownloadStatus.ts b/src/bindings/DownloadStatus.ts index 2fa3cc5..9ca744b 100644 --- a/src/bindings/DownloadStatus.ts +++ b/src/bindings/DownloadStatus.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying"; +export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying" | "moving"; diff --git a/src/bindings/TorrentAvailabilityBucket.ts b/src/bindings/TorrentAvailabilityBucket.ts new file mode 100644 index 0000000..5ca0f94 --- /dev/null +++ b/src/bindings/TorrentAvailabilityBucket.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentAvailabilityBucket = { minimumCopies: number, }; diff --git a/src/bindings/TorrentAvailabilitySnapshot.ts b/src/bindings/TorrentAvailabilitySnapshot.ts new file mode 100644 index 0000000..005647f --- /dev/null +++ b/src/bindings/TorrentAvailabilitySnapshot.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TorrentAvailabilityBucket } from "./TorrentAvailabilityBucket"; + +export type TorrentAvailabilitySnapshot = { pieceCount: number, availability: number, connectedPeers: number, buckets: Array, }; diff --git a/src/bindings/TorrentMoveProgressEvent.ts b/src/bindings/TorrentMoveProgressEvent.ts new file mode 100644 index 0000000..0954904 --- /dev/null +++ b/src/bindings/TorrentMoveProgressEvent.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentMoveProgressEvent = { id: string, fraction: number, copiedBytes: number, totalBytes: number, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 29fecea..e188eeb 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -52,6 +52,7 @@ import { type MediaSelection } from '../utils/addDownloadMetadata'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; +import { TorrentWebSeedEditor } from './TorrentWebSeedEditor'; const formatBytes = (bytes: number) => { const k = 1024; @@ -241,7 +242,17 @@ export const AddDownloadsModal = () => { const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState(''); const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); - const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState(''); + const [torrentFileAllocation, setTorrentFileAllocation] = useState('prealloc'); + const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false); + const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M'); + const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false); + const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M'); + const torrentPreviewPriority = serializeTorrentPreviewPriority( + torrentPreviewHeadEnabled, + torrentPreviewHeadSize, + torrentPreviewTailEnabled, + torrentPreviewTailSize + ); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); @@ -389,6 +400,11 @@ export const AddDownloadsModal = () => { setTorrentTrackerTimeout(''); setTorrentTrackerInterval('0'); setTorrentStopTimeout('0'); + setTorrentFileAllocation('prealloc'); + setTorrentPreviewHeadEnabled(false); + setTorrentPreviewHeadSize('1M'); + setTorrentPreviewTailEnabled(false); + setTorrentPreviewTailSize('1M'); setUseAuth(false); setUsername(''); setPassword(''); @@ -1005,10 +1021,18 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true }); return; } - if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) { + if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) { addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true }); return; } + for (const item of selectedItems) { + if (!item.isTorrent || !item.torrentFiles?.length) continue; + const rows = item.torrentWebSeedRows ?? []; + if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) { + addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true }); + return; + } + } if ( hasSelectedTorrent && torrentStopTimeout.trim() @@ -1529,7 +1553,11 @@ export const AddDownloadsModal = () => { ? Number(torrentTrackerInterval) : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, - torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined, + torrentPrioritizePiece: item.isTorrent ? torrentPreviewPriority || undefined : undefined, + torrentFileAllocation: item.isTorrent ? torrentFileAllocation : undefined, + torrentWebSeeds: item.isTorrent && item.torrentFiles + ? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined + : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -2112,6 +2140,24 @@ export const AddDownloadsModal = () => { )} + {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && ( +
+
+ {t($ => $.properties.torrentWebSeeds)} +
+

{t($ => $.properties.torrentWebSeedsHint)}

+ setParsedItems(items => items.map((item, index) => index === selectedItemIndex + ? { ...item, torrentWebSeedRows: rows } + : item + ))} + idPrefix="add-torrent-web-seed" + /> +
+ )} + {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
@@ -2195,6 +2241,64 @@ export const AddDownloadsModal = () => { +
+ + +

+ {t($ => $.properties.torrentFileAllocationHint)} +

+
+
+ {t($ => $.properties.torrentPrioritizePiece)} + + +

+ {t($ => $.properties.torrentPrioritizePieceHint)} +

+
-
- - setTorrentPrioritizePiece(event.currentTarget.value)} - placeholder="head=1M,tail=1M" - aria-describedby="torrent-prioritize-piece-hint" - className="app-control mt-1 w-full px-2.5 py-1.5 text-xs font-mono" - /> -

- {t($ => $.addDownloads.torrentPrioritizePieceHint)} -

-
)} diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index cc8cfc5..1c29092 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -13,6 +13,7 @@ import { useSettingsStore } from '../store/useSettingsStore'; import { formatDateTime } from '../utils/dateTime'; import { downloadProgressColorClass, + formatTorrentDuration, formatDownloadTotal, resolveDownloadSizeDisplay } from '../utils/downloadProgress'; @@ -72,6 +73,7 @@ export const DownloadItem = React.memo(({ const { t, i18n } = useTranslation(); const calendarPreference = useSettingsStore(state => state.calendarPreference); const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]); + const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]); const rowRef = React.useRef(null); const [isRowHovered, setIsRowHovered] = React.useState(false); const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false); @@ -178,7 +180,9 @@ export const DownloadItem = React.memo(({ }; }, [isActionVisible, updateActionPosition]); - const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding' + const displayFraction = download.status === 'moving' + ? moveProgress ?? download.fraction ?? 0 + : download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding' ? liveProgress?.fraction ?? download.fraction ?? 0 : download.fraction ?? 0; const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; @@ -190,7 +194,9 @@ export const DownloadItem = React.memo(({ ? t($ => $.downloads.values.processing) : '-'; const displayEta = download.status === 'seeding' - ? '-' + ? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0 + ? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language) + : '-' : download.status === 'downloading' || download.status === 'verifying' ? liveProgress?.eta ?? download.eta : download.status === 'processing' @@ -298,6 +304,7 @@ export const DownloadItem = React.memo(({ download.status === 'seeding' ? 'seeding' : download.status === 'processing' ? 'processing' : download.status === 'verifying' ? 'processing' : + download.status === 'moving' ? 'processing' : download.status === 'queued' || download.status === 'staged' ? 'queued' : download.status === 'retrying' ? 'retrying' : '' }`} @@ -322,6 +329,7 @@ export const DownloadItem = React.memo(({ download.status === 'failed' ? 'download-status-failed' : download.status === 'processing' ? 'download-status-processing' : download.status === 'verifying' ? 'download-status-processing' : + download.status === 'moving' ? 'download-status-processing' : download.status === 'downloading' ? 'download-status-downloading' : download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' : download.status === 'retrying' ? 'download-status-retrying' : '' @@ -334,7 +342,7 @@ export const DownloadItem = React.memo(({ {downloadStatusLabel} #{queueIndex + 1} - ) : download.status === 'downloading' || download.status === 'verifying' ? ( + ) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? ( displayPercent ) : download.status === 'seeding' ? ( displayPercent diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 1765ae9..2ade306 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -25,6 +25,7 @@ import { import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads'; import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary'; import { readClipboardDownloadUrls } from '../utils/clipboard'; +import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; import { useTranslation } from 'react-i18next'; import { sortDownloads, @@ -2552,6 +2553,23 @@ export const DownloadTable: React.FC = ({ filter, onSummaryC {t($ => $.downloadTable.copyAddress)} + {contextItem.isTorrent && ( + + )} + {contextItem.status === 'completed' && ( + +

{t($ => $.properties.torrentAvailabilityHint)}

+ {!isTorrentAvailabilityStatus(item.status) && ( +

{t($ => $.properties.torrentAvailabilityUnavailable)}

+ )} + {torrentAvailabilityError && ( +

{t($ => $.properties.torrentAvailabilityFailed)}

+ )} + {torrentAvailability && ( + <> +
+ {t($ => $.properties.torrentAvailabilitySummary, { + availability: new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 2 }).format(torrentAvailability.availability), + peers: torrentAvailability.connectedPeers, + pieces: torrentAvailability.pieceCount + })} +
+
$.properties.torrentAvailabilityMap)} + > + {torrentAvailability.buckets.map((bucket, index) => ( + $.properties.torrentAvailabilityBucket, { copies: bucket.minimumCopies })} + /> + ))} +
+ + )} +
@@ -1610,21 +1819,45 @@ export const PropertiesModal = () => { {t($ => $.properties.torrentStopTimeoutHint)}

- -
- setTorrentPrioritizePiece(event.currentTarget.value)} - placeholder="head=1M,tail=1M" - disabled={transferLocked} - aria-describedby="torrent-prioritize-piece-properties-hint" - className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50" - /> -

+

+ {t($ => $.properties.torrentPrioritizePiece)} + + +

{t($ => $.properties.torrentPrioritizePieceHint)}

@@ -1904,6 +2137,29 @@ export const PropertiesModal = () => {

{t($ => $.properties.torrentDetails)}

+
+ + + {(isTorrentMovePending || ['paused', 'completed', 'failed'].includes(item.status)) && ( + + )} + {isTorrentMovePending && moveProgress !== undefined && ( + + {Math.round(moveProgress * 100)}% + + )} + {torrentShareMessage && {torrentShareMessage}} +
{isTorrentDetailsPending && (

{t($ => $.properties.torrentDetailsLoading)}

)} @@ -1972,13 +2228,12 @@ export const PropertiesModal = () => { {t($ => $.properties.torrentWebSeeds)}

{t($ => $.properties.torrentWebSeedsHint)}

-