feat(torrents): safely remove unselected files

This commit is contained in:
NimBold
2026-08-02 09:49:57 +03:30
parent 67023d3f0d
commit b4da68655a
19 changed files with 695 additions and 49 deletions
+10 -7
View File
@@ -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.
+38 -2
View File
@@ -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'),
+129 -6
View File
@@ -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<String>>(2)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_else(|| vec![primary.clone()]);
Ok((primary, owned))
let removal = row
.get::<_, Option<String>>(3)?
.and_then(|value| serde_json::from_str::<Vec<String>>(&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<Vec<String>, 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::<Vec<String>>(&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<bool, String> {
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();
}
}
+105 -9
View File
@@ -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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
destination: &str,
filename: &str,
) -> Result<PathBuf, String> {
@@ -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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
path: &Path,
) -> Result<(), String> {
@@ -178,6 +178,76 @@ pub fn set_owned_paths_with_primary<R: tauri::Runtime>(
)
}
pub fn set_owned_paths_with_primary_and_removal<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
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::<Vec<_>>();
let removal_strings = canonical_removal_paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
let database = app_handle.state::<crate::db::DbState>();
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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
paths: &[PathBuf],
) -> Result<Vec<PathBuf>, 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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
path: &Path,
@@ -204,12 +274,34 @@ fn canonical_owned_path<R: tauri::Runtime>(
Ok(canonical_path)
}
pub fn remove(app_handle: &tauri::AppHandle, id: &str) -> Result<(), String> {
pub fn remove<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<(), String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::remove_ownership(&connection, id)
}
pub fn clear_torrent_removal_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<(), String> {
let database = app_handle.state::<crate::db::DbState>();
let connection = database.lock()?;
crate::db::remove_torrent_removal_paths(&connection, id)
}
pub fn torrent_removal_paths_for_id<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
) -> Result<Vec<PathBuf>, String> {
let database = app_handle.state::<crate::db::DbState>();
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<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
id: &str,
@@ -231,7 +323,9 @@ pub fn owned_paths_for_id<R: tauri::Runtime>(
.unwrap_or_default())
}
pub fn known_primary_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
pub fn known_primary_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
let mut paths: Vec<PathBuf> = load_records(app_handle)?
.into_iter()
.flat_map(|record| {
@@ -267,7 +361,9 @@ fn load_records<R: tauri::Runtime>(app_handle: &tauri::AppHandle<R>) -> Result<V
})
}
fn legacy_download_queue_paths(app_handle: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
fn legacy_download_queue_paths<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
) -> Result<Vec<PathBuf>, String> {
let settings = crate::settings::load_settings(app_handle).ok();
let downloads = {
+3
View File
@@ -206,6 +206,9 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_prioritize_piece: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>,
}
#[derive(Clone, Debug, Serialize, TS)]
+128 -18
View File
@@ -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<std::path::PathBuf>,
unselected: Vec<std::path::PathBuf>,
}
fn expected_torrent_output_paths(
app_handle: &tauri::AppHandle,
item: &queue::EnqueueItem,
) -> Result<Option<Vec<std::path::PathBuf>>, String> {
) -> Result<Option<ExpectedTorrentOutputPaths>, 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<String>| -> Result<Vec<std::path::PathBuf>, 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::<std::collections::HashSet<_>>();
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::<Vec<_>>();
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)
+138
View File
@@ -220,6 +220,7 @@ pub struct SpawnPayload {
pub torrent_exclude_trackers: Option<String>,
pub torrent_stop_timeout: Option<u32>,
pub torrent_prioritize_piece: Option<String>,
pub torrent_remove_unselected_file: bool,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -1907,10 +1908,51 @@ impl<R: tauri::Runtime> QueueManager<R> {
// 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<R: tauri::Runtime> QueueManager<R> {
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<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_remove_unselected_file: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -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();
+1 -1
View File
@@ -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<number>, 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<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, };
+1 -1
View File
@@ -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<number>, 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<number>, 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, };
+39
View File
@@ -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 = () => {
</span>
</span>
</label>
<label className="flex items-start gap-2 text-text-primary pt-2 border-t border-border-modal/50">
<input
type="checkbox"
checked={torrentRemoveUnselectedFile}
onChange={event => setTorrentRemoveUnselectedFile(event.target.checked)}
disabled={parsedItems.every(item => !hasPartialTorrentSelection(item))}
className="accent-red-500 mt-0.5 disabled:opacity-50"
/>
<span>
<span className="block">{t($ => $.addDownloads.torrentRemoveUnselectedFile)}</span>
<span className="block text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentRemoveUnselectedFileHint)}
</span>
</span>
</label>
<div className="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-trackers" className="block text-text-muted">
{t($ => $.addDownloads.torrentTrackers)}
+36
View File
@@ -87,6 +87,7 @@ export const PropertiesModal = () => {
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
@@ -191,6 +192,7 @@ export const PropertiesModal = () => {
);
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true);
setTorrentTrackers(activeItem.torrentTrackers || '');
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
@@ -360,6 +362,18 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid));
return;
}
if (item.isTorrent && torrentRemoveUnselectedFile && !item.torrentFileIndices?.length) {
setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired));
return;
}
if (
item.isTorrent
&& torrentRemoveUnselectedFile
&& !item.torrentRemoveUnselectedFile
&& !window.confirm(t($ => $.properties.torrentRemoveUnselectedFileConfirm))
) {
return;
}
const updates: Partial<DownloadItem> = {
url,
@@ -381,6 +395,9 @@ export const PropertiesModal = () => {
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined
? torrentRemoveUnselectedFile
: undefined,
}
: {}),
...(connectionsDirty
@@ -972,6 +989,25 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentVerifyIntegrityHint)}
</span>
</label>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-remove-unselected-file">
{t($ => $.properties.torrentRemoveUnselectedFile)}
</label>
<label className="flex items-start gap-2 text-xs text-text-primary">
<input
id="torrent-remove-unselected-file"
type="checkbox"
checked={torrentRemoveUnselectedFile}
onChange={event => setTorrentRemoveUnselectedFile(event.currentTarget.checked)}
disabled={transferLocked || !item.torrentFileIndices?.length}
className="accent-red-500 mt-0.5 disabled:opacity-50"
aria-describedby="torrent-remove-unselected-file-hint"
/>
<span id="torrent-remove-unselected-file-hint" className="text-[11px] text-text-muted">
{item.torrentFileIndices?.length
? t($ => $.properties.torrentRemoveUnselectedFileHint)
: t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired)}
</span>
</label>
</>
)}
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
+8
View File
@@ -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',
+8
View File
@@ -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: 'پیش‌نمایش',
+8
View File
@@ -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: 'תצוגה מקדימה',
+8
View File
@@ -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: 'Предпросмотр',
+8
View File
@@ -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: 'Попередній перегляд',
+8
View File
@@ -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: '预览',
+9 -3
View File
@@ -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
})
})
);
+10 -2
View File
@@ -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<DownloadState>((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(),
});
}