diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index da313a1..8e58db3 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -37,6 +37,12 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar - Optional `bt-prioritize-piece` preview policy for the head, tail, or both ends of every selected file. The constrained policy is validated, persisted, normalized, and reapplied when a Torrent starts or retries. +- Optional `bt-remove-unselected-file` cleanup after completion when a + selected-file subset is configured. Firelink requires explicit confirmation, + reserves the unselected paths against competing downloads, keeps those + paths separate from removable download ownership, and clears the reservation + after observing Aria2's completion cleanup (or on terminal failure, + cancellation, or reconfiguration). - Deterministic local Aria2 smoke coverage for metadata resolution, selected output, piece priority, pause/resume, ownership, cancellation/removal, unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior; @@ -55,14 +61,10 @@ No remaining Tier 0 items. per-file priority option. Firelink therefore does not pretend that `select-file` is file priority; this remains pending an engine capability or a safe product-level model. -2. **Safe removal of unselected files** — expose - `bt-remove-unselected-file` only as an explicit destructive choice, with - ownership-aware confirmation and tests for cancellation, retry, and - reconfiguration. -3. **Encryption policy** — expose `bt-force-encryption`, +2. **Encryption policy** — expose `bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level` as one validated policy so users cannot accidentally select contradictory combinations. -4. **Tracker timing controls** — expose tracker connect timeout, request +3. **Tracker timing controls** — expose tracker connect timeout, request timeout, and interval only when their effect on battery/network behavior is explained and persisted. @@ -77,4 +79,5 @@ No remaining Tier 0 items. The first implementation in this task was remote `.torrent` metadata intake; follow-up implementations add stall-timeout control, bounded peer diagnostics, -persisted tracker exclusion, and piece-preview priority. +persisted tracker exclusion, piece-preview priority, and safe unselected-file +removal. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index c14f68e..71403e5 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -629,8 +629,9 @@ async function main() { const finalDir = path.join(tempRoot, 'final'); const integrityDir = path.join(tempRoot, 'integrity'); const cancelDir = path.join(tempRoot, 'cancel'); + const removeUnselectedDir = path.join(tempRoot, 'remove-unselected'); const stallDir = path.join(tempRoot, 'stall'); - for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir, stallDir]) fs.mkdirSync(directory, { recursive: true }); + for (const directory of [seedRoot, probeDir, finalDir, integrityDir, cancelDir, removeUnselectedDir, stallDir]) fs.mkdirSync(directory, { recursive: true }); const seederListenPort = await findAvailablePort(); const clientListenPort = await findAvailablePort(); @@ -764,6 +765,37 @@ async function main() { assert(fs.readFileSync(integrityPath).equals(torrent.files[0].data), 'integrity check did not replace corrupted torrent data'); console.log('[OK] check-integrity detected and repaired corrupted Torrent data'); + const removalSkippedPath = path.join(removeUnselectedDir, torrent.name, 'skipped.bin'); + fs.mkdirSync(path.dirname(removalSkippedPath), { recursive: true }); + fs.writeFileSync(removalSkippedPath, Buffer.from('pre-existing file owned outside the Torrent\n')); + const removalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ + savedTorrentBytes.toString('base64'), + [], + { + dir: removeUnselectedDir, + 'select-file': '1', + 'index-out': indexOut, + 'bt-remove-unselected-file': 'true', + 'allow-overwrite': 'true', + 'seed-time': '0', + 'auto-file-renaming': 'false', + }, + ]); + const removalOptions = await rpc(client.rpcPort, client.secret, 'aria2.getOption', [removalGid]); + assert( + removalOptions['bt-remove-unselected-file'] === 'true', + `Aria2 did not retain unselected-file removal: ${JSON.stringify(removalOptions['bt-remove-unselected-file'])}`, + ); + await waitForTerminal(client, removalGid, 30000); + const removalSelectedPath = path.join(removeUnselectedDir, torrent.name, 'selected.bin'); + assert(fs.existsSync(removalSelectedPath), 'selected Torrent output was not retained with removal enabled'); + assert(!fs.existsSync(removalSkippedPath), 'unselected Torrent file was not removed after completion'); + console.log('[OK] bt-remove-unselected-file deleted only the unselected pre-existing output after completion'); + + const canceledSkippedPath = path.join(cancelDir, torrent.name, 'skipped.bin'); + fs.mkdirSync(path.dirname(canceledSkippedPath), { recursive: true }); + const canceledSentinel = Buffer.from('cancellation must preserve this file\n'); + fs.writeFileSync(canceledSkippedPath, canceledSentinel); const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ savedTorrentBytes.toString('base64'), [], @@ -771,6 +803,8 @@ async function main() { dir: cancelDir, 'select-file': '1', 'index-out': indexOut, + 'bt-remove-unselected-file': 'true', + 'allow-overwrite': 'true', 'max-download-limit': '8K', 'seed-time': '0', 'auto-file-renaming': 'false', @@ -784,7 +818,9 @@ async function main() { !fs.existsSync(canceledPath) || fs.statSync(canceledPath).size < torrent.files[0].data.length, 'canceled torrent produced a complete output', ); - console.log('[OK] cancel/remove stopped the second torrent before completion'); + assert(fs.existsSync(canceledSkippedPath), 'canceled Torrent removed an unselected file before completion'); + assert(fs.readFileSync(canceledSkippedPath).equals(canceledSentinel), 'canceled Torrent changed the unselected file'); + console.log('[OK] cancel/remove stopped the second torrent before completion without deleting an unselected file'); const stallGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ trackerlessTorrentBytes.toString('base64'), diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 31b176c..78f2fae 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; const DATABASE_NAME: &str = "firelink.sqlite"; const LEGACY_STORE_NAME: &str = "store.bin"; const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app"; -const CURRENT_SCHEMA_VERSION: i64 = 2; +const CURRENT_SCHEMA_VERSION: i64 = 3; pub(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed"; pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token"; // Development builds are a different executable identity from the packaged @@ -193,6 +193,19 @@ fn migrate_schema(connection: &mut Connection, from_version: i64) -> Result<(), .map_err(|error| format!("failed to migrate download ownership paths: {error}"))?; } + if from_version < 3 { + transaction + .execute_batch( + " + CREATE TABLE IF NOT EXISTS download_removal_paths ( + id TEXT PRIMARY KEY, + paths TEXT NOT NULL + ); + ", + ) + .map_err(|error| format!("failed to migrate torrent removal paths: {error}"))?; + } + transaction .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) .map_err(|error| format!("failed to update database schema version: {error}"))?; @@ -1265,12 +1278,23 @@ pub fn set_ownership_paths( id: &str, primary_path: &str, paths: &[String], +) -> Result<(), String> { + set_ownership_paths_checked(connection, id, primary_path, paths, &[]) +} + +fn set_ownership_paths_checked( + connection: &Connection, + id: &str, + primary_path: &str, + paths: &[String], + removal_paths: &[String], ) -> Result<(), String> { let mut statement = connection .prepare( - "SELECT ownership.id, ownership.primary_path, paths.paths + "SELECT ownership.id, ownership.primary_path, paths.paths, removal.paths FROM download_ownership AS ownership LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id + LEFT JOIN download_removal_paths AS removal ON removal.id = ownership.id WHERE ownership.id <> ?1", ) .map_err(|error| format!("failed to prepare download ownership check: {error}"))?; @@ -1281,15 +1305,22 @@ pub fn set_ownership_paths( .get::<_, Option>(2)? .and_then(|value| serde_json::from_str::>(&value).ok()) .unwrap_or_else(|| vec![primary.clone()]); - Ok((primary, owned)) + let removal = row + .get::<_, Option>(3)? + .and_then(|value| serde_json::from_str::>(&value).ok()) + .unwrap_or_default(); + Ok((primary, owned, removal)) }) .map_err(|error| format!("failed to check download ownership paths: {error}"))?; for row in existing { - let (existing_primary, owned) = + let (existing_primary, owned, removal) = row.map_err(|error| format!("failed to read download ownership paths: {error}"))?; - let new_paths = std::iter::once(primary_path).chain(paths.iter().map(String::as_str)); + let new_paths = std::iter::once(primary_path) + .chain(paths.iter().map(String::as_str)) + .chain(removal_paths.iter().map(String::as_str)); let existing_paths = std::iter::once(existing_primary.as_str()) - .chain(owned.iter().map(String::as_str)); + .chain(owned.iter().map(String::as_str)) + .chain(removal.iter().map(String::as_str)); if new_paths.clone().any(|new_path| { existing_paths .clone() @@ -1318,7 +1349,39 @@ pub fn set_ownership_paths( Ok(()) } +pub fn set_ownership_and_removal_paths( + connection: &Connection, + id: &str, + primary_path: &str, + paths: &[String], + removal_paths: &[String], +) -> Result<(), String> { + set_ownership_paths_checked(connection, id, primary_path, paths, removal_paths)?; + if removal_paths.is_empty() { + connection + .execute( + "DELETE FROM download_removal_paths WHERE id = ?1", + params![id], + ) + .map_err(|error| format!("failed to clear torrent removal paths: {error}"))?; + } else { + let encoded_paths = serde_json::to_string(removal_paths) + .map_err(|error| format!("failed to encode torrent removal paths: {error}"))?; + connection + .execute( + "INSERT INTO download_removal_paths (id, paths) VALUES (?1, ?2) + ON CONFLICT(id) DO UPDATE SET paths = excluded.paths", + params![id, encoded_paths], + ) + .map_err(|error| format!("failed to save torrent removal paths: {error}"))?; + } + Ok(()) +} + pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> { + connection + .execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id]) + .map_err(|error| format!("failed to delete torrent removal paths: {error}"))?; connection .execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id]) .map_err(|error| format!("failed to delete download ownership paths: {error}"))?; @@ -1328,6 +1391,33 @@ pub fn remove_ownership(connection: &Connection, id: &str) -> Result<(), String> Ok(()) } +pub fn remove_torrent_removal_paths(connection: &Connection, id: &str) -> Result<(), String> { + connection + .execute("DELETE FROM download_removal_paths WHERE id = ?1", params![id]) + .map_err(|error| format!("failed to clear torrent removal paths: {error}"))?; + Ok(()) +} + +pub fn load_torrent_removal_paths( + connection: &Connection, + id: &str, +) -> Result, String> { + connection + .query_row( + "SELECT paths FROM download_removal_paths WHERE id = ?1", + params![id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("failed to read torrent removal paths: {error}"))? + .map(|value| { + serde_json::from_str::>(&value) + .map_err(|error| format!("failed to decode torrent removal paths: {error}")) + }) + .transpose() + .map(|paths| paths.unwrap_or_default()) +} + pub fn has_user_data(connection: &Connection) -> Result { connection .query_row( @@ -2464,4 +2554,37 @@ mod tests { .expect_err("a torrent root must not be reused"); assert!(error.contains("already owned")); } + + #[test] + fn removal_reservations_block_later_download_ownership_claims() { + let temp = TempDir::new().unwrap(); + let state = init_at_path(temp.path()).unwrap(); + let connection = state.lock().unwrap(); + + set_ownership_and_removal_paths( + &connection, + "torrent", + "/downloads/selected.bin", + &["/downloads/selected.bin".to_string()], + &["/downloads/unselected.bin".to_string()], + ) + .unwrap(); + let error = set_ownership_paths( + &connection, + "later", + "/downloads/unselected.bin", + &["/downloads/unselected.bin".to_string()], + ) + .expect_err("a planned Torrent deletion must reserve its path"); + + assert!(error.contains("already owned")); + remove_torrent_removal_paths(&connection, "torrent").unwrap(); + set_ownership_paths( + &connection, + "later", + "/downloads/unselected.bin", + &["/downloads/unselected.bin".to_string()], + ) + .unwrap(); + } } diff --git a/src-tauri/src/download_ownership.rs b/src-tauri/src/download_ownership.rs index 5fd3c55..a772043 100644 --- a/src-tauri/src/download_ownership.rs +++ b/src-tauri/src/download_ownership.rs @@ -91,8 +91,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String { value[..end].to_string() } -pub fn expected_primary_path( - app_handle: &tauri::AppHandle, +pub fn expected_primary_path( + app_handle: &tauri::AppHandle, destination: &str, filename: &str, ) -> Result { @@ -110,8 +110,8 @@ pub fn expected_primary_path( .ok_or_else(|| "Download path could not be canonicalized".to_string()) } -pub fn register_expected( - app_handle: &tauri::AppHandle, +pub fn register_expected( + app_handle: &tauri::AppHandle, id: &str, destination: &str, filename: &str, @@ -120,8 +120,8 @@ pub fn register_expected( set_primary_path(app_handle, id, &path) } -pub fn set_primary_path( - app_handle: &tauri::AppHandle, +pub fn set_primary_path( + app_handle: &tauri::AppHandle, id: &str, path: &Path, ) -> Result<(), String> { @@ -178,6 +178,76 @@ pub fn set_owned_paths_with_primary( ) } +pub fn set_owned_paths_with_primary_and_removal( + app_handle: &tauri::AppHandle, + id: &str, + primary: &Path, + paths: &[PathBuf], + removal_paths: &[PathBuf], +) -> Result<(), String> { + if paths.is_empty() { + return Err("Download ownership requires at least one path".to_string()); + } + + let canonical_primary = canonical_owned_path(app_handle, primary)?; + let canonical_paths = canonical_file_paths(app_handle, paths)?; + let canonical_removal_paths = canonical_file_paths(app_handle, removal_paths)?; + let mut current_paths = owned_paths_for_id(app_handle, id)?; + if let Some(primary) = primary_path_for_id(app_handle, id)? { + current_paths.push(primary); + } + let known_paths = known_primary_paths(app_handle)?; + if canonical_removal_paths.iter().any(|candidate| { + known_paths.iter().any(|known| { + crate::platform::paths_equal(candidate, known) + && !current_paths + .iter() + .any(|current| crate::platform::paths_equal(candidate, current)) + }) + }) { + return Err( + "Torrent removal would delete a file owned by another Firelink download".to_string(), + ); + } + let path_strings = canonical_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + let removal_strings = canonical_removal_paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::set_ownership_and_removal_paths( + &connection, + id, + &canonical_primary.to_string_lossy(), + &path_strings, + &removal_strings, + ) +} + +fn canonical_file_paths( + app_handle: &tauri::AppHandle, + paths: &[PathBuf], +) -> Result, String> { + let mut canonical_paths = Vec::with_capacity(paths.len()); + for path in paths { + if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) { + return Err("Download ownership file path is a directory".to_string()); + } + let canonical_path = canonical_owned_path(app_handle, path)?; + if !canonical_paths + .iter() + .any(|existing: &PathBuf| crate::platform::paths_equal(existing, &canonical_path)) + { + canonical_paths.push(canonical_path); + } + } + Ok(canonical_paths) +} + fn canonical_owned_path( app_handle: &tauri::AppHandle, path: &Path, @@ -204,12 +274,34 @@ fn canonical_owned_path( Ok(canonical_path) } -pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> { +pub fn remove( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result<(), String> { let database = app_handle.state::(); let connection = database.lock()?; crate::db::remove_ownership(&connection, id) } +pub fn clear_torrent_removal_paths( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result<(), String> { + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::remove_torrent_removal_paths(&connection, id) +} + +pub fn torrent_removal_paths_for_id( + app_handle: &tauri::AppHandle, + id: &str, +) -> Result, String> { + let database = app_handle.state::(); + let connection = database.lock()?; + crate::db::load_torrent_removal_paths(&connection, id) + .map(|paths| paths.into_iter().map(PathBuf::from).collect()) +} + pub fn primary_path_for_id( app_handle: &tauri::AppHandle, id: &str, @@ -231,7 +323,9 @@ pub fn owned_paths_for_id( .unwrap_or_default()) } -pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result, String> { +pub fn known_primary_paths( + app_handle: &tauri::AppHandle, +) -> Result, String> { let mut paths: Vec = load_records(app_handle)? .into_iter() .flat_map(|record| { @@ -267,7 +361,9 @@ fn load_records(app_handle: &tauri::AppHandle) -> Result Result, String> { +fn legacy_download_queue_paths( + app_handle: &tauri::AppHandle, +) -> Result, String> { let settings = crate::settings::load_settings(app_handle).ok(); let downloads = { diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 45c86aa..6d17d39 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -206,6 +206,9 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_prioritize_piece: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_remove_unselected_file: Option, } #[derive(Clone, Debug, Serialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 14dfdfb..14986bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5816,10 +5816,24 @@ async fn validate_torrent_enqueue( item.torrent_info_hash.as_deref(), &metadata.info_hash, )?; - crate::torrent::validate_selected_indices( + let selected = crate::torrent::validate_selected_indices( item.torrent_file_indices.as_deref(), metadata.files.len(), )?; + if item.torrent_remove_unselected_file.unwrap_or(false) { + let Some(selected) = selected else { + return Err( + "removing unselected Torrent files requires selecting a subset of files" + .to_string(), + ); + }; + if selected.len() >= metadata.files.len() { + return Err( + "removing unselected Torrent files requires at least one unselected file" + .to_string(), + ); + } + } return Ok(()); } @@ -5830,14 +5844,24 @@ async fn validate_torrent_enqueue( if item.torrent_file_indices.is_some() { return Err("magnet file selection requires resolved torrent metadata".to_string()); } + if item.torrent_remove_unselected_file.unwrap_or(false) { + return Err( + "removing unselected Torrent files requires resolved torrent metadata".to_string(), + ); + } let metadata = crate::torrent::inspect_source(&item.url)?; crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash) } +struct ExpectedTorrentOutputPaths { + selected: Vec, + unselected: Vec, +} + fn expected_torrent_output_paths( app_handle: &tauri::AppHandle, item: &queue::EnqueueItem, -) -> Result>, String> { +) -> Result, String> { if !item.is_torrent.unwrap_or(false) { return Ok(None); } @@ -5858,8 +5882,10 @@ fn expected_torrent_output_paths( } let canonical_destination = crate::canonicalize_with_missing_components(&destination) .ok_or_else(|| "torrent destination could not be canonicalized".to_string())?; - let mut paths = Vec::new(); - for relative in crate::torrent::aria2_output_paths(&metadata, selected.as_deref()) { + let selected_relative = crate::torrent::aria2_output_paths(&metadata, selected.as_deref()); + let resolve_paths = |relative_paths: Vec| -> Result, String> { + let mut paths = Vec::new(); + for relative in relative_paths { let relative = std::path::PathBuf::from(relative); if relative.is_absolute() || relative.components().any(|component| { @@ -5878,8 +5904,41 @@ fn expected_torrent_output_paths( return Err("torrent output path is outside its destination".to_string()); } paths.push(canonical_path); - } - Ok(Some(paths)) + } + Ok(paths) + }; + let selected_paths = resolve_paths(selected_relative)?; + let unselected_paths = if item.torrent_remove_unselected_file.unwrap_or(false) { + let selected_indices = selected + .as_deref() + .ok_or_else(|| "torrent file selection is required for unselected-file removal".to_string())?; + let selected_indices = selected_indices.iter().copied().collect::>(); + let unselected_relative = metadata + .files + .iter() + .filter(|file| !selected_indices.contains(&file.index)) + .map(|file| { + if metadata.files.len() == 1 { + file.path.clone() + } else { + format!("{}/{}", metadata.name, file.path) + } + }) + .collect::>(); + let paths = resolve_paths(unselected_relative)?; + if paths.iter().any(|path| { + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) + }) { + return Err("unselected Torrent output path is a directory".to_string()); + } + paths + } else { + Vec::new() + }; + Ok(Some(ExpectedTorrentOutputPaths { + selected: selected_paths, + unselected: unselected_paths, + })) } async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), String> { @@ -6131,12 +6190,23 @@ async fn enqueue_download( return Err(AppError::Internal(error)); } }; - if let Err(error) = crate::download_ownership::set_owned_paths_with_primary( - &app_handle, - &item.id, - &primary, - &paths, - ) { + let ownership_result = if item.torrent_remove_unselected_file.unwrap_or(false) { + crate::download_ownership::set_owned_paths_with_primary_and_removal( + &app_handle, + &item.id, + &primary, + &paths.selected, + &paths.unselected, + ) + } else { + crate::download_ownership::set_owned_paths_with_primary( + &app_handle, + &item.id, + &primary, + &paths.selected, + ) + }; + if let Err(error) = ownership_result { let _ = crate::download_ownership::remove(&app_handle, &id); state .queue_manager @@ -6155,6 +6225,17 @@ async fn enqueue_download( return Err(AppError::Internal(error)); } } + if !item.torrent_remove_unselected_file.unwrap_or(false) { + if let Err(error) = crate::download_ownership::clear_torrent_removal_paths(&app_handle, &id) + { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + return Err(AppError::Internal(error)); + } + } if let Err(error) = state .queue_manager .commit_reserved_enqueue(item.into_task(), lifecycle_generation) @@ -6283,12 +6364,23 @@ async fn enqueue_many( continue; } }; - if let Err(error) = crate::download_ownership::set_owned_paths_with_primary( - &app_handle, - &item.id, - &primary, - &paths, - ) { + let ownership_result = if item.torrent_remove_unselected_file.unwrap_or(false) { + crate::download_ownership::set_owned_paths_with_primary_and_removal( + &app_handle, + &item.id, + &primary, + &paths.selected, + &paths.unselected, + ) + } else { + crate::download_ownership::set_owned_paths_with_primary( + &app_handle, + &item.id, + &primary, + &paths.selected, + ) + }; + if let Err(error) = ownership_result { let _ = crate::download_ownership::remove(&app_handle, &id); state .queue_manager @@ -6319,6 +6411,24 @@ async fn enqueue_many( continue; } } + if !item.torrent_remove_unselected_file.unwrap_or(false) { + if let Err(error) = + crate::download_ownership::clear_torrent_removal_paths(&app_handle, &id) + { + let _ = crate::download_ownership::remove(&app_handle, &id); + state + .queue_manager + .rollback_enqueue_reservation(&id, lifecycle_generation, previous_generation) + .await; + results.push(crate::ipc::EnqueueResult { + id, + success: false, + filename: None, + error: Some(error), + }); + continue; + } + } if let Err(error) = state .queue_manager .commit_reserved_enqueue(item.into_task(), lifecycle_generation) diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 47ef403..672e8a8 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -220,6 +220,7 @@ pub struct SpawnPayload { pub torrent_exclude_trackers: Option, pub torrent_stop_timeout: Option, pub torrent_prioritize_piece: Option, + pub torrent_remove_unselected_file: bool, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -1907,10 +1908,51 @@ impl QueueManager { // from the previous lifecycle before releasing its permit. self.next_aria2_control_epoch(id).await; self.cancel_aria2_retries(id).await; + let torrent_removal_requested = self + .aria2_payloads + .lock() + .await + .get(id) + .is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file); match outcome { PendingOutcome::Complete => { self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; + if torrent_removal_requested { + match crate::download_ownership::torrent_removal_paths_for_id( + &self.app_handle, + id, + ) { + Ok(paths) if paths.iter().all(|path| !path.exists()) => { + if let Err(error) = + crate::download_ownership::clear_torrent_removal_paths( + &self.app_handle, + id, + ) + { + log::warn!( + "aria2 torrent removal reservation [{}]: could not clear after completion: {}", + id, + error + ); + } + } + Ok(paths) => { + log::warn!( + "aria2 torrent removal reservation [{}]: keeping {} path(s) reserved because Aria2 cleanup was not observed", + id, + paths.len() + ); + } + Err(error) => { + log::warn!( + "aria2 torrent removal reservation [{}]: could not verify cleanup: {}", + id, + error + ); + } + } + } self.release_registered_id(id).await; self.release_permit(id).await; self.emit_state(id, DownloadStatus::Completed); @@ -1931,6 +1973,17 @@ impl QueueManager { self.clear_aria2_retry_state(id).await; self.forget_aria2_gid(id).await; + if torrent_removal_requested { + if let Err(clear_error) = + crate::download_ownership::clear_torrent_removal_paths(&self.app_handle, id) + { + log::warn!( + "aria2 torrent removal reservation [{}]: could not clear after terminal failure: {}", + id, + clear_error + ); + } + } self.release_registered_id(id).await; self.release_permit(id).await; self.emit_failed(id, error); @@ -3533,6 +3586,21 @@ fn apply_aria2_torrent_options( serde_json::json!(piece_priority), ); } + if payload.torrent_remove_unselected_file { + let Some(indices) = payload.torrent_file_indices.as_deref() else { + return Err( + "removing unselected Torrent files requires selecting a subset of files" + .to_string(), + ); + }; + if indices.is_empty() { + return Err("torrent file selection is invalid".to_string()); + } + options.insert( + "bt-remove-unselected-file".to_string(), + serde_json::json!("true"), + ); + } if payload.torrent_check_integrity { options.insert( "check-integrity".to_string(), @@ -4153,6 +4221,9 @@ pub struct EnqueueItem { pub torrent_prioritize_piece: Option, #[serde(default)] #[ts(optional)] + pub torrent_remove_unselected_file: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -4205,6 +4276,9 @@ impl EnqueueItem { torrent_exclude_trackers: self.torrent_exclude_trackers, torrent_stop_timeout: self.torrent_stop_timeout, torrent_prioritize_piece: self.torrent_prioritize_piece, + torrent_remove_unselected_file: self + .torrent_remove_unselected_file + .unwrap_or(false), }, } } @@ -4596,6 +4670,52 @@ mod tests { assert!(!options.contains_key("bt-prioritize-piece")); } + #[test] + fn torrent_unselected_file_removal_requires_a_non_empty_file_selection() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_remove_unselected_file: true, + torrent_file_indices: Some(vec![]), + ..Default::default() + }; + + let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); + assert!(error.contains("file selection")); + assert!(!options.contains_key("bt-remove-unselected-file")); + } + + #[test] + fn torrent_unselected_file_removal_is_emitted_only_for_selected_torrent_files() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_remove_unselected_file: true, + torrent_file_indices: Some(vec![1]), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!( + options.get("bt-remove-unselected-file"), + Some(&serde_json::json!("true")) + ); + } + + #[test] + fn torrent_unselected_file_removal_is_not_applied_without_torrent_selection() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_remove_unselected_file: true, + ..Default::default() + }; + + let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); + assert!(error.contains("subset")); + } + #[test] fn torrent_peer_diagnostics_are_redacted_and_bounded() { let mut result = vec![serde_json::json!({ @@ -4710,6 +4830,24 @@ mod tests { ); } + #[test] + fn enqueue_item_carries_torrent_unselected_file_removal_into_the_spawn_payload() { + let item: EnqueueItem = serde_json::from_value(serde_json::json!({ + "id": "torrent-remove-unselected", + "queue_id": "main", + "url": "file:///tmp/payload.torrent", + "destination": "/tmp/downloads", + "filename": "payload", + "is_media": false, + "is_torrent": true, + "torrent_file_indices": [1], + "torrent_remove_unselected_file": true + })) + .expect("frontend enqueue payload should deserialize"); + + assert!(item.into_task().payload.torrent_remove_unselected_file); + } + #[test] fn torrent_options_reject_invalid_seed_values() { let mut options = serde_json::Map::new(); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 4b2e4e7..166bc02 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -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, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: 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, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 3e71407..ef7a88d 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.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 EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 6afd326..fdc52f4 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -233,6 +233,7 @@ export const AddDownloadsModal = () => { const [torrentMaxPeers, setTorrentMaxPeers] = useState(''); const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState(''); const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false); + const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false); const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); @@ -997,6 +998,21 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true }); return; } + const removableTorrentFileCount = parsedItems.reduce((total, item) => { + if (item.selected === false || !item.isTorrent || !item.torrentFiles?.length) return total; + const selected = item.selectedTorrentFileIndices; + if (!selected || selected.length === 0 || selected.length >= item.torrentFiles.length) return total; + return total + item.torrentFiles.length - selected.length; + }, 0); + if ( + torrentRemoveUnselectedFile + && removableTorrentFileCount > 0 + && !window.confirm(t($ => $.addDownloads.torrentRemoveUnselectedFileConfirm, { + count: removableTorrentFileCount + })) + ) { + return; + } if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { addToast({ message: t($ => $.addDownloads.dedicatedFolderNameRequired), @@ -1476,6 +1492,9 @@ export const AddDownloadsModal = () => { ? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined : undefined, torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined, + torrentRemoveUnselectedFile: item.isTorrent && torrentRemoveUnselectedFile && hasPartialTorrentSelection(item) + ? true + : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, @@ -1612,6 +1631,11 @@ export const AddDownloadsModal = () => { }; const selectedItems = parsedItems.filter(item => item.selected !== false); + const hasPartialTorrentSelection = (item: AddDownloadDraftRow): boolean => { + if (!item.isTorrent || !item.torrentFiles?.length) return false; + const selected = item.selectedTorrentFileIndices; + return Boolean(selected && selected.length > 0 && selected.length < item.torrentFiles.length); + }; const selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex]; const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl; const selectedPlaylistRows = selectedPlaylistSourceUrl @@ -2140,6 +2164,21 @@ export const AddDownloadsModal = () => { +
+ + )} {(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && ( diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index ce889b2..99f7b4f 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -268,6 +268,10 @@ const common = { torrentPrioritizePiece: 'Prioritize Torrent pieces', torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.', torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M', + torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion', + torrentRemoveUnselectedFileHint: 'Only applies when a subset of files is selected. Aria2 permanently deletes the other files after the Torrent completes.', + torrentRemoveUnselectedFileConfirm: 'Delete {{count}} unselected Torrent files after completion? This cannot be undone.', + torrentRemoveUnselectedFileSelectionRequired: 'Select a subset of Torrent files before enabling unselected-file removal.', liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}', category: 'Category', lastTry: 'Last try', @@ -530,6 +534,10 @@ const common = { torrentPrioritizePiece: 'Prioritize Torrent pieces', torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.', torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M', + torrentRemoveUnselectedFile: 'Delete unselected Torrent files after completion', + torrentRemoveUnselectedFileHint: 'Only applies when a selected subset is configured. The unselected files are not Firelink-owned and are permanently removed when the Torrent completes.', + torrentRemoveUnselectedFileConfirm: 'Enable permanent deletion of unselected Torrent files after completion? This cannot be undone.', + torrentRemoveUnselectedFileSelectionRequired: 'Select a subset of Torrent files before enabling unselected-file removal.', required: 'Required', free: 'Free', preview: 'Preview', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 802c7f1..c9d7991 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -268,6 +268,10 @@ const fa = { torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت', torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.', torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد', + torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل', + torrentRemoveUnselectedFileHint: 'فقط وقتی اعمال می‌شود که زیرمجموعه‌ای از فایل‌ها انتخاب شده باشد. آریا۲ فایل‌های دیگر را پس از تکمیل تورنت برای همیشه حذف می‌کند.', + torrentRemoveUnselectedFileConfirm: '{{count}} فایل انتخاب‌نشده تورنت پس از تکمیل حذف شوند؟ این کار قابل بازگشت نیست.', + torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعال‌کردن حذف فایل‌های انتخاب‌نشده، زیرمجموعه‌ای از فایل‌های تورنت را انتخاب کنید.', liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت به‌روزرسانی نشد: {{detail}}', category: 'دسته', lastTry: 'آخرین تلاش', @@ -530,6 +534,10 @@ const fa = { torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت', torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.', torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد', + torrentRemoveUnselectedFile: 'حذف فایل‌های انتخاب‌نشده تورنت پس از تکمیل', + torrentRemoveUnselectedFileHint: 'فقط برای زیرمجموعه انتخاب‌شده اعمال می‌شود. فایل‌های انتخاب‌نشده متعلق به Firelink نیستند و هنگام تکمیل تورنت برای همیشه حذف می‌شوند.', + torrentRemoveUnselectedFileConfirm: 'حذف دائمی فایل‌های انتخاب‌نشده تورنت پس از تکمیل فعال شود؟ این کار قابل بازگشت نیست.', + torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعال‌کردن حذف فایل‌های انتخاب‌نشده، زیرمجموعه‌ای از فایل‌های تورنت را انتخاب کنید.', required: 'الزامی', free: 'فضای آزاد', preview: 'پیش‌نمایش', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 8ee1b2f..60e288b 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -268,6 +268,10 @@ const he = { torrentPrioritizePiece: 'תעדוף חלקי טורנט', torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.', torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M', + torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה', + torrentRemoveUnselectedFileHint: 'חל רק כאשר נבחרה קבוצת קבצים חלקית. Aria2 מוחק לצמיתות את שאר הקבצים לאחר השלמת ה-Torrent.', + torrentRemoveUnselectedFileConfirm: 'למחוק {{count}} קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.', + torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.', liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}', category: 'קטגוריה', lastTry: 'ניסיון אחרון', @@ -530,6 +534,10 @@ const he = { torrentPrioritizePiece: 'תעדוף חלקי טורנט', torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.', torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M', + torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה', + torrentRemoveUnselectedFileHint: 'חל רק כאשר מוגדרת קבוצת קבצים חלקית. הקבצים שלא נבחרו אינם בבעלות Firelink ונמחקים לצמיתות כשה-Torrent מסתיים.', + torrentRemoveUnselectedFileConfirm: 'להפעיל מחיקה לצמיתות של קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.', + torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.', required: 'נדרש', free: 'פנוי', preview: 'תצוגה מקדימה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 1dea1c6..69429ec 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -268,6 +268,10 @@ const ru = { torrentPrioritizePiece: 'Приоритет частей торрента', torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.', torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M', + torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения', + torrentRemoveUnselectedFileHint: 'Применяется только при выборе части файлов. Aria2 навсегда удалит остальные файлы после завершения Torrent.', + torrentRemoveUnselectedFileConfirm: 'Удалить {{count}} невыбранных файлов Torrent после завершения? Это действие нельзя отменить.', + torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.', liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}', category: 'Категория', lastTry: 'Последняя попытка', @@ -530,6 +534,10 @@ const ru = { torrentPrioritizePiece: 'Приоритет частей торрента', torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.', torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M', + torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения', + torrentRemoveUnselectedFileHint: 'Применяется при настроенном выборе части файлов. Невыбранные файлы не принадлежат Firelink и навсегда удаляются после завершения Torrent.', + torrentRemoveUnselectedFileConfirm: 'Включить безвозвратное удаление невыбранных файлов Torrent после завершения? Это действие нельзя отменить.', + torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.', required: 'Требуется', free: 'Свободно', preview: 'Предпросмотр', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index cd08d93..ffb3456 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -268,6 +268,10 @@ const uk = { torrentPrioritizePiece: 'Пріоритет частин торрента', torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.', torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M', + torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення', + torrentRemoveUnselectedFileHint: 'Застосовується лише після вибору частини файлів. Aria2 назавжди видалить решту файлів після завершення Torrent.', + torrentRemoveUnselectedFileConfirm: 'Видалити {{count}} невибраних файлів Torrent після завершення? Цю дію не можна скасувати.', + torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.', liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}', category: 'Категорія', lastTry: 'Остання спроба', @@ -530,6 +534,10 @@ const uk = { torrentPrioritizePiece: 'Пріоритет частин торрента', torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.', torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M', + torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення', + torrentRemoveUnselectedFileHint: 'Застосовується для налаштованого вибору частини файлів. Невибрані файли не належать Firelink і назавжди видаляються після завершення Torrent.', + torrentRemoveUnselectedFileConfirm: 'Увімкнути незворотне видалення невибраних файлів Torrent після завершення? Цю дію не можна скасувати.', + torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.', required: 'Обов\'язково', free: 'Вільно', preview: 'Попередній перегляд', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index bf1d8d8..b1a412c 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -268,6 +268,10 @@ const zhCN = { torrentPrioritizePiece: '优先下载 Torrent 片段', torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。', torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小', + torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件', + torrentRemoveUnselectedFileHint: '仅在选择了部分文件时生效。Torrent 完成后,Aria2 会永久删除其余文件。', + torrentRemoveUnselectedFileConfirm: '完成后删除 {{count}} 个未选中的 Torrent 文件?此操作无法撤销。', + torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。', liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}', category: '类别', lastTry: '上次尝试', @@ -530,6 +534,10 @@ const zhCN = { torrentPrioritizePiece: '优先下载 Torrent 片段', torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。', torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小', + torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件', + torrentRemoveUnselectedFileHint: '仅适用于配置了部分文件选择的 Torrent。未选中的文件不属于 Firelink,并会在 Torrent 完成后永久删除。', + torrentRemoveUnselectedFileConfirm: '启用完成后永久删除未选中的 Torrent 文件?此操作无法撤销。', + torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。', required: '必需', free: '可用空间', preview: '预览', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index d0063fc..632ddf4 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -857,7 +857,8 @@ describe('useDownloadStore', () => { torrentTrackers: 123 as unknown as string, torrentExcludeTrackers: 123 as unknown as string, torrentStopTimeout: 604801, - torrentPrioritizePiece: 'head=1G' + torrentPrioritizePiece: 'head=1G', + torrentRemoveUnselectedFile: 'yes' as unknown as boolean }); expect(normalized.torrentMaxPeers).toBeUndefined(); @@ -867,6 +868,7 @@ describe('useDownloadStore', () => { expect(normalized.torrentExcludeTrackers).toBeUndefined(); expect(normalized.torrentStopTimeout).toBeUndefined(); expect(normalized.torrentPrioritizePiece).toBeUndefined(); + expect(normalized.torrentRemoveUnselectedFile).toBeUndefined(); }); it('normalizes proxy settings for download dispatch', async () => { @@ -1521,7 +1523,9 @@ describe('useDownloadStore', () => { torrentTrackers: 'https://tracker.example/announce', torrentExcludeTrackers: '*', torrentStopTimeout: 300, - torrentPrioritizePiece: 'head=1M,tail=1M' + torrentPrioritizePiece: 'head=1M,tail=1M', + torrentFileIndices: [1], + torrentRemoveUnselectedFile: true }, { type: 'start-now' }); const item = useDownloadStore.getState().downloads[0]; @@ -1536,7 +1540,9 @@ describe('useDownloadStore', () => { torrent_trackers: 'https://tracker.example/announce', torrent_exclude_trackers: '*', torrent_stop_timeout: 300, - torrent_prioritize_piece: 'head=1M,tail=1M' + torrent_prioritize_piece: 'head=1M,tail=1M', + torrent_file_indices: [1], + torrent_remove_unselected_file: true }) }) ); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index c493423..84cfd6b 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -355,6 +355,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): torrent_exclude_trackers: item.torrentExcludeTrackers || undefined, torrent_stop_timeout: item.torrentStopTimeout, torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, + torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, lifecycle_generation: lifecycleGeneration.toString(), }; @@ -654,13 +655,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string' ? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined : undefined; + const rawRemoveUnselectedFile = download.torrentRemoveUnselectedFile as unknown; + const normalizedRemoveUnselectedFile = typeof rawRemoveUnselectedFile === 'boolean' + ? rawRemoveUnselectedFile + : undefined; const normalizedOptions = rawMaxPeers !== normalizedMaxPeers || rawPeerSpeedLimit !== normalizedPeerSpeedLimit || rawCheckIntegrity !== normalizedCheckIntegrity || rawTrackers !== normalizedTrackers || rawExcludeTrackers !== normalizedExcludeTrackers || rawStopTimeout !== normalizedStopTimeout || - rawPrioritizePiece !== normalizedPrioritizePiece + rawPrioritizePiece !== normalizedPrioritizePiece || + rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile ? { ...download, torrentMaxPeers: normalizedMaxPeers, @@ -669,7 +675,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down torrentTrackers: normalizedTrackers, torrentExcludeTrackers: normalizedExcludeTrackers, torrentStopTimeout: normalizedStopTimeout, - torrentPrioritizePiece: normalizedPrioritizePiece + torrentPrioritizePiece: normalizedPrioritizePiece, + torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile } : download; @@ -2205,6 +2212,7 @@ export const useDownloadStore = create((set, get) => { torrent_exclude_trackers: item.torrentExcludeTrackers || undefined, torrent_stop_timeout: item.torrentStopTimeout, torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, + torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); }