mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 15:46:17 +00:00
feat(torrents): safely remove unselected files
This commit is contained in:
+10
-7
@@ -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
|
- Optional `bt-prioritize-piece` preview policy for the head, tail, or both
|
||||||
ends of every selected file. The constrained policy is validated, persisted,
|
ends of every selected file. The constrained policy is validated, persisted,
|
||||||
normalized, and reapplied when a Torrent starts or retries.
|
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
|
- Deterministic local Aria2 smoke coverage for metadata resolution, selected
|
||||||
output, piece priority, pause/resume, ownership, cancellation/removal,
|
output, piece priority, pause/resume, ownership, cancellation/removal,
|
||||||
unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior;
|
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
|
per-file priority option. Firelink therefore does not pretend that
|
||||||
`select-file` is file priority; this remains pending an engine capability or
|
`select-file` is file priority; this remains pending an engine capability or
|
||||||
a safe product-level model.
|
a safe product-level model.
|
||||||
2. **Safe removal of unselected files** — expose
|
2. **Encryption policy** — expose `bt-force-encryption`,
|
||||||
`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`,
|
|
||||||
`bt-require-crypto`, and `bt-min-crypto-level` as one validated policy so
|
`bt-require-crypto`, and `bt-min-crypto-level` as one validated policy so
|
||||||
users cannot accidentally select contradictory combinations.
|
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
|
timeout, and interval only when their effect on battery/network behavior is
|
||||||
explained and persisted.
|
explained and persisted.
|
||||||
|
|
||||||
@@ -77,4 +79,5 @@ No remaining Tier 0 items.
|
|||||||
|
|
||||||
The first implementation in this task was remote `.torrent` metadata intake;
|
The first implementation in this task was remote `.torrent` metadata intake;
|
||||||
follow-up implementations add stall-timeout control, bounded peer diagnostics,
|
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.
|
||||||
|
|||||||
@@ -629,8 +629,9 @@ async function main() {
|
|||||||
const finalDir = path.join(tempRoot, 'final');
|
const finalDir = path.join(tempRoot, 'final');
|
||||||
const integrityDir = path.join(tempRoot, 'integrity');
|
const integrityDir = path.join(tempRoot, 'integrity');
|
||||||
const cancelDir = path.join(tempRoot, 'cancel');
|
const cancelDir = path.join(tempRoot, 'cancel');
|
||||||
|
const removeUnselectedDir = path.join(tempRoot, 'remove-unselected');
|
||||||
const stallDir = path.join(tempRoot, 'stall');
|
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 seederListenPort = await findAvailablePort();
|
||||||
const clientListenPort = 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');
|
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');
|
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', [
|
const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
|
||||||
savedTorrentBytes.toString('base64'),
|
savedTorrentBytes.toString('base64'),
|
||||||
[],
|
[],
|
||||||
@@ -771,6 +803,8 @@ async function main() {
|
|||||||
dir: cancelDir,
|
dir: cancelDir,
|
||||||
'select-file': '1',
|
'select-file': '1',
|
||||||
'index-out': indexOut,
|
'index-out': indexOut,
|
||||||
|
'bt-remove-unselected-file': 'true',
|
||||||
|
'allow-overwrite': 'true',
|
||||||
'max-download-limit': '8K',
|
'max-download-limit': '8K',
|
||||||
'seed-time': '0',
|
'seed-time': '0',
|
||||||
'auto-file-renaming': 'false',
|
'auto-file-renaming': 'false',
|
||||||
@@ -784,7 +818,9 @@ async function main() {
|
|||||||
!fs.existsSync(canceledPath) || fs.statSync(canceledPath).size < torrent.files[0].data.length,
|
!fs.existsSync(canceledPath) || fs.statSync(canceledPath).size < torrent.files[0].data.length,
|
||||||
'canceled torrent produced a complete output',
|
'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', [
|
const stallGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [
|
||||||
trackerlessTorrentBytes.toString('base64'),
|
trackerlessTorrentBytes.toString('base64'),
|
||||||
|
|||||||
+129
-6
@@ -7,7 +7,7 @@ use std::sync::Mutex;
|
|||||||
const DATABASE_NAME: &str = "firelink.sqlite";
|
const DATABASE_NAME: &str = "firelink.sqlite";
|
||||||
const LEGACY_STORE_NAME: &str = "store.bin";
|
const LEGACY_STORE_NAME: &str = "store.bin";
|
||||||
const LEGACY_BUNDLE_IDENTIFIER: &str = "com.nima.tauri-app";
|
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(crate) const TOKEN_CHANGED_NOTICE: &str = "pairing-token-changed";
|
||||||
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
pub const PAIRING_TOKEN_KEYCHAIN_ID: &str = "extension-pairing-token";
|
||||||
// Development builds are a different executable identity from the packaged
|
// 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}"))?;
|
.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
|
transaction
|
||||||
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)
|
||||||
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
.map_err(|error| format!("failed to update database schema version: {error}"))?;
|
||||||
@@ -1265,12 +1278,23 @@ pub fn set_ownership_paths(
|
|||||||
id: &str,
|
id: &str,
|
||||||
primary_path: &str,
|
primary_path: &str,
|
||||||
paths: &[String],
|
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> {
|
) -> Result<(), String> {
|
||||||
let mut statement = connection
|
let mut statement = connection
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT ownership.id, ownership.primary_path, paths.paths
|
"SELECT ownership.id, ownership.primary_path, paths.paths, removal.paths
|
||||||
FROM download_ownership AS ownership
|
FROM download_ownership AS ownership
|
||||||
LEFT JOIN download_owned_paths AS paths ON paths.id = ownership.id
|
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",
|
WHERE ownership.id <> ?1",
|
||||||
)
|
)
|
||||||
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
|
.map_err(|error| format!("failed to prepare download ownership check: {error}"))?;
|
||||||
@@ -1281,15 +1305,22 @@ pub fn set_ownership_paths(
|
|||||||
.get::<_, Option<String>>(2)?
|
.get::<_, Option<String>>(2)?
|
||||||
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
|
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
|
||||||
.unwrap_or_else(|| vec![primary.clone()]);
|
.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}"))?;
|
.map_err(|error| format!("failed to check download ownership paths: {error}"))?;
|
||||||
for row in existing {
|
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}"))?;
|
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())
|
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| {
|
if new_paths.clone().any(|new_path| {
|
||||||
existing_paths
|
existing_paths
|
||||||
.clone()
|
.clone()
|
||||||
@@ -1318,7 +1349,39 @@ pub fn set_ownership_paths(
|
|||||||
Ok(())
|
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> {
|
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
|
connection
|
||||||
.execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id])
|
.execute("DELETE FROM download_owned_paths WHERE id = ?1", params![id])
|
||||||
.map_err(|error| format!("failed to delete download ownership paths: {error}"))?;
|
.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(())
|
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> {
|
pub fn has_user_data(connection: &Connection) -> Result<bool, String> {
|
||||||
connection
|
connection
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -2464,4 +2554,37 @@ mod tests {
|
|||||||
.expect_err("a torrent root must not be reused");
|
.expect_err("a torrent root must not be reused");
|
||||||
assert!(error.contains("already owned"));
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ fn truncate_utf8_to_bytes(value: &str, max_bytes: usize) -> String {
|
|||||||
value[..end].to_string()
|
value[..end].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expected_primary_path(
|
pub fn expected_primary_path<R: tauri::Runtime>(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
destination: &str,
|
destination: &str,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
) -> Result<PathBuf, String> {
|
) -> Result<PathBuf, String> {
|
||||||
@@ -110,8 +110,8 @@ pub fn expected_primary_path(
|
|||||||
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
.ok_or_else(|| "Download path could not be canonicalized".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_expected(
|
pub fn register_expected<R: tauri::Runtime>(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
id: &str,
|
id: &str,
|
||||||
destination: &str,
|
destination: &str,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
@@ -120,8 +120,8 @@ pub fn register_expected(
|
|||||||
set_primary_path(app_handle, id, &path)
|
set_primary_path(app_handle, id, &path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_primary_path(
|
pub fn set_primary_path<R: tauri::Runtime>(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
id: &str,
|
id: &str,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
) -> Result<(), String> {
|
) -> 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>(
|
fn canonical_owned_path<R: tauri::Runtime>(
|
||||||
app_handle: &tauri::AppHandle<R>,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
@@ -204,12 +274,34 @@ fn canonical_owned_path<R: tauri::Runtime>(
|
|||||||
Ok(canonical_path)
|
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 database = app_handle.state::<crate::db::DbState>();
|
||||||
let connection = database.lock()?;
|
let connection = database.lock()?;
|
||||||
crate::db::remove_ownership(&connection, id)
|
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>(
|
pub fn primary_path_for_id<R: tauri::Runtime>(
|
||||||
app_handle: &tauri::AppHandle<R>,
|
app_handle: &tauri::AppHandle<R>,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -231,7 +323,9 @@ pub fn owned_paths_for_id<R: tauri::Runtime>(
|
|||||||
.unwrap_or_default())
|
.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)?
|
let mut paths: Vec<PathBuf> = load_records(app_handle)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|record| {
|
.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 settings = crate::settings::load_settings(app_handle).ok();
|
||||||
|
|
||||||
let downloads = {
|
let downloads = {
|
||||||
|
|||||||
@@ -206,6 +206,9 @@ pub struct DownloadItem {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub torrent_prioritize_piece: Option<String>,
|
pub torrent_prioritize_piece: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
|
pub torrent_remove_unselected_file: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, TS)]
|
#[derive(Clone, Debug, Serialize, TS)]
|
||||||
|
|||||||
+128
-18
@@ -5816,10 +5816,24 @@ async fn validate_torrent_enqueue(
|
|||||||
item.torrent_info_hash.as_deref(),
|
item.torrent_info_hash.as_deref(),
|
||||||
&metadata.info_hash,
|
&metadata.info_hash,
|
||||||
)?;
|
)?;
|
||||||
crate::torrent::validate_selected_indices(
|
let selected = crate::torrent::validate_selected_indices(
|
||||||
item.torrent_file_indices.as_deref(),
|
item.torrent_file_indices.as_deref(),
|
||||||
metadata.files.len(),
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5830,14 +5844,24 @@ async fn validate_torrent_enqueue(
|
|||||||
if item.torrent_file_indices.is_some() {
|
if item.torrent_file_indices.is_some() {
|
||||||
return Err("magnet file selection requires resolved torrent metadata".to_string());
|
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)?;
|
let metadata = crate::torrent::inspect_source(&item.url)?;
|
||||||
crate::torrent::validate_info_hash(item.torrent_info_hash.as_deref(), &metadata.info_hash)
|
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(
|
fn expected_torrent_output_paths(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
item: &queue::EnqueueItem,
|
item: &queue::EnqueueItem,
|
||||||
) -> Result<Option<Vec<std::path::PathBuf>>, String> {
|
) -> Result<Option<ExpectedTorrentOutputPaths>, String> {
|
||||||
if !item.is_torrent.unwrap_or(false) {
|
if !item.is_torrent.unwrap_or(false) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
@@ -5858,8 +5882,10 @@ fn expected_torrent_output_paths(
|
|||||||
}
|
}
|
||||||
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
|
let canonical_destination = crate::canonicalize_with_missing_components(&destination)
|
||||||
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
|
.ok_or_else(|| "torrent destination could not be canonicalized".to_string())?;
|
||||||
let mut paths = Vec::new();
|
let selected_relative = crate::torrent::aria2_output_paths(&metadata, selected.as_deref());
|
||||||
for relative in 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);
|
let relative = std::path::PathBuf::from(relative);
|
||||||
if relative.is_absolute()
|
if relative.is_absolute()
|
||||||
|| relative.components().any(|component| {
|
|| relative.components().any(|component| {
|
||||||
@@ -5878,8 +5904,41 @@ fn expected_torrent_output_paths(
|
|||||||
return Err("torrent output path is outside its destination".to_string());
|
return Err("torrent output path is outside its destination".to_string());
|
||||||
}
|
}
|
||||||
paths.push(canonical_path);
|
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> {
|
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));
|
return Err(AppError::Internal(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary(
|
let ownership_result = if item.torrent_remove_unselected_file.unwrap_or(false) {
|
||||||
&app_handle,
|
crate::download_ownership::set_owned_paths_with_primary_and_removal(
|
||||||
&item.id,
|
&app_handle,
|
||||||
&primary,
|
&item.id,
|
||||||
&paths,
|
&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);
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
state
|
state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
@@ -6155,6 +6225,17 @@ async fn enqueue_download(
|
|||||||
return Err(AppError::Internal(error));
|
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
|
if let Err(error) = state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||||
@@ -6283,12 +6364,23 @@ async fn enqueue_many(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = crate::download_ownership::set_owned_paths_with_primary(
|
let ownership_result = if item.torrent_remove_unselected_file.unwrap_or(false) {
|
||||||
&app_handle,
|
crate::download_ownership::set_owned_paths_with_primary_and_removal(
|
||||||
&item.id,
|
&app_handle,
|
||||||
&primary,
|
&item.id,
|
||||||
&paths,
|
&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);
|
let _ = crate::download_ownership::remove(&app_handle, &id);
|
||||||
state
|
state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
@@ -6319,6 +6411,24 @@ async fn enqueue_many(
|
|||||||
continue;
|
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
|
if let Err(error) = state
|
||||||
.queue_manager
|
.queue_manager
|
||||||
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
.commit_reserved_enqueue(item.into_task(), lifecycle_generation)
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ pub struct SpawnPayload {
|
|||||||
pub torrent_exclude_trackers: Option<String>,
|
pub torrent_exclude_trackers: Option<String>,
|
||||||
pub torrent_stop_timeout: Option<u32>,
|
pub torrent_stop_timeout: Option<u32>,
|
||||||
pub torrent_prioritize_piece: Option<String>,
|
pub torrent_prioritize_piece: Option<String>,
|
||||||
|
pub torrent_remove_unselected_file: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
|
/// 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.
|
// from the previous lifecycle before releasing its permit.
|
||||||
self.next_aria2_control_epoch(id).await;
|
self.next_aria2_control_epoch(id).await;
|
||||||
self.cancel_aria2_retries(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 {
|
match outcome {
|
||||||
PendingOutcome::Complete => {
|
PendingOutcome::Complete => {
|
||||||
self.clear_aria2_retry_state(id).await;
|
self.clear_aria2_retry_state(id).await;
|
||||||
self.forget_aria2_gid(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_registered_id(id).await;
|
||||||
self.release_permit(id).await;
|
self.release_permit(id).await;
|
||||||
self.emit_state(id, DownloadStatus::Completed);
|
self.emit_state(id, DownloadStatus::Completed);
|
||||||
@@ -1931,6 +1973,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
|
|
||||||
self.clear_aria2_retry_state(id).await;
|
self.clear_aria2_retry_state(id).await;
|
||||||
self.forget_aria2_gid(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_registered_id(id).await;
|
||||||
self.release_permit(id).await;
|
self.release_permit(id).await;
|
||||||
self.emit_failed(id, error);
|
self.emit_failed(id, error);
|
||||||
@@ -3533,6 +3586,21 @@ fn apply_aria2_torrent_options(
|
|||||||
serde_json::json!(piece_priority),
|
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 {
|
if payload.torrent_check_integrity {
|
||||||
options.insert(
|
options.insert(
|
||||||
"check-integrity".to_string(),
|
"check-integrity".to_string(),
|
||||||
@@ -4153,6 +4221,9 @@ pub struct EnqueueItem {
|
|||||||
pub torrent_prioritize_piece: Option<String>,
|
pub torrent_prioritize_piece: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub torrent_remove_unselected_file: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
pub lifecycle_generation: Option<String>,
|
pub lifecycle_generation: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4205,6 +4276,9 @@ impl EnqueueItem {
|
|||||||
torrent_exclude_trackers: self.torrent_exclude_trackers,
|
torrent_exclude_trackers: self.torrent_exclude_trackers,
|
||||||
torrent_stop_timeout: self.torrent_stop_timeout,
|
torrent_stop_timeout: self.torrent_stop_timeout,
|
||||||
torrent_prioritize_piece: self.torrent_prioritize_piece,
|
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"));
|
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]
|
#[test]
|
||||||
fn torrent_peer_diagnostics_are_redacted_and_bounded() {
|
fn torrent_peer_diagnostics_are_redacted_and_bounded() {
|
||||||
let mut result = vec![serde_json::json!({
|
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]
|
#[test]
|
||||||
fn torrent_options_reject_invalid_seed_values() {
|
fn torrent_options_reject_invalid_seed_values() {
|
||||||
let mut options = serde_json::Map::new();
|
let mut options = serde_json::Map::new();
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
import type { DownloadCategory } from "./DownloadCategory";
|
import type { DownloadCategory } from "./DownloadCategory";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
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,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export const AddDownloadsModal = () => {
|
|||||||
const [torrentMaxPeers, setTorrentMaxPeers] = useState('');
|
const [torrentMaxPeers, setTorrentMaxPeers] = useState('');
|
||||||
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
|
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
|
||||||
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
||||||
|
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
|
||||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||||
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
||||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||||
@@ -997,6 +998,21 @@ export const AddDownloadsModal = () => {
|
|||||||
addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentStopTimeoutInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
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)) {
|
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
|
||||||
addToast({
|
addToast({
|
||||||
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
|
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
|
||||||
@@ -1476,6 +1492,9 @@ export const AddDownloadsModal = () => {
|
|||||||
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
|
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
|
||||||
: undefined,
|
: undefined,
|
||||||
torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined,
|
torrentCheckIntegrity: item.isTorrent ? torrentCheckIntegrity : undefined,
|
||||||
|
torrentRemoveUnselectedFile: item.isTorrent && torrentRemoveUnselectedFile && hasPartialTorrentSelection(item)
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
|
torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined,
|
||||||
torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined,
|
torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined,
|
||||||
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : 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 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 selectedItem = selectedItemIndex === null ? undefined : parsedItems[selectedItemIndex];
|
||||||
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
const selectedPlaylistSourceUrl = selectedItem?.playlistSourceUrl;
|
||||||
const selectedPlaylistRows = selectedPlaylistSourceUrl
|
const selectedPlaylistRows = selectedPlaylistSourceUrl
|
||||||
@@ -2140,6 +2164,21 @@ export const AddDownloadsModal = () => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<div className="pt-2 border-t border-border-modal/50">
|
||||||
<label htmlFor="torrent-trackers" className="block text-text-muted">
|
<label htmlFor="torrent-trackers" className="block text-text-muted">
|
||||||
{t($ => $.addDownloads.torrentTrackers)}
|
{t($ => $.addDownloads.torrentTrackers)}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export const PropertiesModal = () => {
|
|||||||
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
|
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
|
||||||
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
|
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
|
||||||
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
||||||
|
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
|
||||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||||
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
|
||||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||||
@@ -191,6 +192,7 @@ export const PropertiesModal = () => {
|
|||||||
);
|
);
|
||||||
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
|
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
|
||||||
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
|
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
|
||||||
|
setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true);
|
||||||
setTorrentTrackers(activeItem.torrentTrackers || '');
|
setTorrentTrackers(activeItem.torrentTrackers || '');
|
||||||
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
|
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
|
||||||
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
||||||
@@ -360,6 +362,18 @@ export const PropertiesModal = () => {
|
|||||||
setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid));
|
setErrorMessage(t($ => $.properties.torrentStopTimeoutInvalid));
|
||||||
return;
|
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> = {
|
const updates: Partial<DownloadItem> = {
|
||||||
url,
|
url,
|
||||||
@@ -381,6 +395,9 @@ export const PropertiesModal = () => {
|
|||||||
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
|
torrentExcludeTrackers: torrentExcludeTrackers.trim() || undefined,
|
||||||
torrentStopTimeout: normalizedStopTimeout,
|
torrentStopTimeout: normalizedStopTimeout,
|
||||||
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
|
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
|
||||||
|
torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined
|
||||||
|
? torrentRemoveUnselectedFile
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(connectionsDirty
|
...(connectionsDirty
|
||||||
@@ -972,6 +989,25 @@ export const PropertiesModal = () => {
|
|||||||
{t($ => $.properties.torrentVerifyIntegrityHint)}
|
{t($ => $.properties.torrentVerifyIntegrityHint)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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) && (
|
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const common = {
|
|||||||
torrentPrioritizePiece: 'Prioritize Torrent pieces',
|
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.',
|
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',
|
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}}',
|
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
|
||||||
category: 'Category',
|
category: 'Category',
|
||||||
lastTry: 'Last try',
|
lastTry: 'Last try',
|
||||||
@@ -530,6 +534,10 @@ const common = {
|
|||||||
torrentPrioritizePiece: 'Prioritize Torrent pieces',
|
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.',
|
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',
|
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',
|
required: 'Required',
|
||||||
free: 'Free',
|
free: 'Free',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const fa = {
|
|||||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
||||||
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام میتوان اندازهای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام میتوان اندازهای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||||
|
torrentRemoveUnselectedFile: 'حذف فایلهای انتخابنشده تورنت پس از تکمیل',
|
||||||
|
torrentRemoveUnselectedFileHint: 'فقط وقتی اعمال میشود که زیرمجموعهای از فایلها انتخاب شده باشد. آریا۲ فایلهای دیگر را پس از تکمیل تورنت برای همیشه حذف میکند.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: '{{count}} فایل انتخابنشده تورنت پس از تکمیل حذف شوند؟ این کار قابل بازگشت نیست.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعالکردن حذف فایلهای انتخابنشده، زیرمجموعهای از فایلهای تورنت را انتخاب کنید.',
|
||||||
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت بهروزرسانی نشد: {{detail}}',
|
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت بهروزرسانی نشد: {{detail}}',
|
||||||
category: 'دسته',
|
category: 'دسته',
|
||||||
lastTry: 'آخرین تلاش',
|
lastTry: 'آخرین تلاش',
|
||||||
@@ -530,6 +534,10 @@ const fa = {
|
|||||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
||||||
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
|
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
|
||||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||||
|
torrentRemoveUnselectedFile: 'حذف فایلهای انتخابنشده تورنت پس از تکمیل',
|
||||||
|
torrentRemoveUnselectedFileHint: 'فقط برای زیرمجموعه انتخابشده اعمال میشود. فایلهای انتخابنشده متعلق به Firelink نیستند و هنگام تکمیل تورنت برای همیشه حذف میشوند.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'حذف دائمی فایلهای انتخابنشده تورنت پس از تکمیل فعال شود؟ این کار قابل بازگشت نیست.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'پیش از فعالکردن حذف فایلهای انتخابنشده، زیرمجموعهای از فایلهای تورنت را انتخاب کنید.',
|
||||||
required: 'الزامی',
|
required: 'الزامی',
|
||||||
free: 'فضای آزاد',
|
free: 'فضای آزاد',
|
||||||
preview: 'پیشنمایش',
|
preview: 'پیشنمایش',
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const he = {
|
|||||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
||||||
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
|
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
|
||||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||||
|
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
|
||||||
|
torrentRemoveUnselectedFileHint: 'חל רק כאשר נבחרה קבוצת קבצים חלקית. Aria2 מוחק לצמיתות את שאר הקבצים לאחר השלמת ה-Torrent.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'למחוק {{count}} קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.',
|
||||||
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
|
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
|
||||||
category: 'קטגוריה',
|
category: 'קטגוריה',
|
||||||
lastTry: 'ניסיון אחרון',
|
lastTry: 'ניסיון אחרון',
|
||||||
@@ -530,6 +534,10 @@ const he = {
|
|||||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
||||||
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
|
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
|
||||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||||
|
torrentRemoveUnselectedFile: 'מחיקת קבצי Torrent שלא נבחרו לאחר השלמה',
|
||||||
|
torrentRemoveUnselectedFileHint: 'חל רק כאשר מוגדרת קבוצת קבצים חלקית. הקבצים שלא נבחרו אינם בבעלות Firelink ונמחקים לצמיתות כשה-Torrent מסתיים.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'להפעיל מחיקה לצמיתות של קבצי Torrent שלא נבחרו לאחר השלמה? אי אפשר לבטל פעולה זו.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'בחרו קבוצת קבצים חלקית לפני הפעלת מחיקת הקבצים שלא נבחרו.',
|
||||||
required: 'נדרש',
|
required: 'נדרש',
|
||||||
free: 'פנוי',
|
free: 'פנוי',
|
||||||
preview: 'תצוגה מקדימה',
|
preview: 'תצוגה מקדימה',
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const ru = {
|
|||||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
torrentPrioritizePiece: 'Приоритет частей торрента',
|
||||||
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
|
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
|
||||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||||
|
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
|
||||||
|
torrentRemoveUnselectedFileHint: 'Применяется только при выборе части файлов. Aria2 навсегда удалит остальные файлы после завершения Torrent.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'Удалить {{count}} невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.',
|
||||||
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
|
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
|
||||||
category: 'Категория',
|
category: 'Категория',
|
||||||
lastTry: 'Последняя попытка',
|
lastTry: 'Последняя попытка',
|
||||||
@@ -530,6 +534,10 @@ const ru = {
|
|||||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
torrentPrioritizePiece: 'Приоритет частей торрента',
|
||||||
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
|
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
|
||||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||||
|
torrentRemoveUnselectedFile: 'Удалять невыбранные файлы Torrent после завершения',
|
||||||
|
torrentRemoveUnselectedFileHint: 'Применяется при настроенном выборе части файлов. Невыбранные файлы не принадлежат Firelink и навсегда удаляются после завершения Torrent.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'Включить безвозвратное удаление невыбранных файлов Torrent после завершения? Это действие нельзя отменить.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'Выберите часть файлов Torrent перед включением удаления невыбранных файлов.',
|
||||||
required: 'Требуется',
|
required: 'Требуется',
|
||||||
free: 'Свободно',
|
free: 'Свободно',
|
||||||
preview: 'Предпросмотр',
|
preview: 'Предпросмотр',
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const uk = {
|
|||||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
||||||
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
|
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
|
||||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||||
|
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
|
||||||
|
torrentRemoveUnselectedFileHint: 'Застосовується лише після вибору частини файлів. Aria2 назавжди видалить решту файлів після завершення Torrent.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'Видалити {{count}} невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.',
|
||||||
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
|
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
|
||||||
category: 'Категорія',
|
category: 'Категорія',
|
||||||
lastTry: 'Остання спроба',
|
lastTry: 'Остання спроба',
|
||||||
@@ -530,6 +534,10 @@ const uk = {
|
|||||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
||||||
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
|
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
|
||||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||||
|
torrentRemoveUnselectedFile: 'Видаляти невибрані файли Torrent після завершення',
|
||||||
|
torrentRemoveUnselectedFileHint: 'Застосовується для налаштованого вибору частини файлів. Невибрані файли не належать Firelink і назавжди видаляються після завершення Torrent.',
|
||||||
|
torrentRemoveUnselectedFileConfirm: 'Увімкнути незворотне видалення невибраних файлів Torrent після завершення? Цю дію не можна скасувати.',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: 'Виберіть частину файлів Torrent перед увімкненням видалення невибраних файлів.',
|
||||||
required: 'Обов\'язково',
|
required: 'Обов\'язково',
|
||||||
free: 'Вільно',
|
free: 'Вільно',
|
||||||
preview: 'Попередній перегляд',
|
preview: 'Попередній перегляд',
|
||||||
|
|||||||
@@ -268,6 +268,10 @@ const zhCN = {
|
|||||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
||||||
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
|
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
|
||||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||||
|
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
|
||||||
|
torrentRemoveUnselectedFileHint: '仅在选择了部分文件时生效。Torrent 完成后,Aria2 会永久删除其余文件。',
|
||||||
|
torrentRemoveUnselectedFileConfirm: '完成后删除 {{count}} 个未选中的 Torrent 文件?此操作无法撤销。',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。',
|
||||||
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
|
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
|
||||||
category: '类别',
|
category: '类别',
|
||||||
lastTry: '上次尝试',
|
lastTry: '上次尝试',
|
||||||
@@ -530,6 +534,10 @@ const zhCN = {
|
|||||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
||||||
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
|
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
|
||||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||||
|
torrentRemoveUnselectedFile: '完成后删除未选中的 Torrent 文件',
|
||||||
|
torrentRemoveUnselectedFileHint: '仅适用于配置了部分文件选择的 Torrent。未选中的文件不属于 Firelink,并会在 Torrent 完成后永久删除。',
|
||||||
|
torrentRemoveUnselectedFileConfirm: '启用完成后永久删除未选中的 Torrent 文件?此操作无法撤销。',
|
||||||
|
torrentRemoveUnselectedFileSelectionRequired: '请先选择部分 Torrent 文件,再启用未选中文件删除功能。',
|
||||||
required: '必需',
|
required: '必需',
|
||||||
free: '可用空间',
|
free: '可用空间',
|
||||||
preview: '预览',
|
preview: '预览',
|
||||||
|
|||||||
@@ -857,7 +857,8 @@ describe('useDownloadStore', () => {
|
|||||||
torrentTrackers: 123 as unknown as string,
|
torrentTrackers: 123 as unknown as string,
|
||||||
torrentExcludeTrackers: 123 as unknown as string,
|
torrentExcludeTrackers: 123 as unknown as string,
|
||||||
torrentStopTimeout: 604801,
|
torrentStopTimeout: 604801,
|
||||||
torrentPrioritizePiece: 'head=1G'
|
torrentPrioritizePiece: 'head=1G',
|
||||||
|
torrentRemoveUnselectedFile: 'yes' as unknown as boolean
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(normalized.torrentMaxPeers).toBeUndefined();
|
expect(normalized.torrentMaxPeers).toBeUndefined();
|
||||||
@@ -867,6 +868,7 @@ describe('useDownloadStore', () => {
|
|||||||
expect(normalized.torrentExcludeTrackers).toBeUndefined();
|
expect(normalized.torrentExcludeTrackers).toBeUndefined();
|
||||||
expect(normalized.torrentStopTimeout).toBeUndefined();
|
expect(normalized.torrentStopTimeout).toBeUndefined();
|
||||||
expect(normalized.torrentPrioritizePiece).toBeUndefined();
|
expect(normalized.torrentPrioritizePiece).toBeUndefined();
|
||||||
|
expect(normalized.torrentRemoveUnselectedFile).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes proxy settings for download dispatch', async () => {
|
it('normalizes proxy settings for download dispatch', async () => {
|
||||||
@@ -1521,7 +1523,9 @@ describe('useDownloadStore', () => {
|
|||||||
torrentTrackers: 'https://tracker.example/announce',
|
torrentTrackers: 'https://tracker.example/announce',
|
||||||
torrentExcludeTrackers: '*',
|
torrentExcludeTrackers: '*',
|
||||||
torrentStopTimeout: 300,
|
torrentStopTimeout: 300,
|
||||||
torrentPrioritizePiece: 'head=1M,tail=1M'
|
torrentPrioritizePiece: 'head=1M,tail=1M',
|
||||||
|
torrentFileIndices: [1],
|
||||||
|
torrentRemoveUnselectedFile: true
|
||||||
}, { type: 'start-now' });
|
}, { type: 'start-now' });
|
||||||
|
|
||||||
const item = useDownloadStore.getState().downloads[0];
|
const item = useDownloadStore.getState().downloads[0];
|
||||||
@@ -1536,7 +1540,9 @@ describe('useDownloadStore', () => {
|
|||||||
torrent_trackers: 'https://tracker.example/announce',
|
torrent_trackers: 'https://tracker.example/announce',
|
||||||
torrent_exclude_trackers: '*',
|
torrent_exclude_trackers: '*',
|
||||||
torrent_stop_timeout: 300,
|
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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||||
torrent_stop_timeout: item.torrentStopTimeout,
|
torrent_stop_timeout: item.torrentStopTimeout,
|
||||||
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
|
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
|
||||||
|
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
|
||||||
lifecycle_generation: lifecycleGeneration.toString(),
|
lifecycle_generation: lifecycleGeneration.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -654,13 +655,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string'
|
const normalizedPrioritizePiece = typeof rawPrioritizePiece === 'string'
|
||||||
? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined
|
? normalizeTorrentPrioritizePiece(rawPrioritizePiece) || undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const rawRemoveUnselectedFile = download.torrentRemoveUnselectedFile as unknown;
|
||||||
|
const normalizedRemoveUnselectedFile = typeof rawRemoveUnselectedFile === 'boolean'
|
||||||
|
? rawRemoveUnselectedFile
|
||||||
|
: undefined;
|
||||||
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
||||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||||
rawTrackers !== normalizedTrackers ||
|
rawTrackers !== normalizedTrackers ||
|
||||||
rawExcludeTrackers !== normalizedExcludeTrackers ||
|
rawExcludeTrackers !== normalizedExcludeTrackers ||
|
||||||
rawStopTimeout !== normalizedStopTimeout ||
|
rawStopTimeout !== normalizedStopTimeout ||
|
||||||
rawPrioritizePiece !== normalizedPrioritizePiece
|
rawPrioritizePiece !== normalizedPrioritizePiece ||
|
||||||
|
rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile
|
||||||
? {
|
? {
|
||||||
...download,
|
...download,
|
||||||
torrentMaxPeers: normalizedMaxPeers,
|
torrentMaxPeers: normalizedMaxPeers,
|
||||||
@@ -669,7 +675,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
torrentTrackers: normalizedTrackers,
|
torrentTrackers: normalizedTrackers,
|
||||||
torrentExcludeTrackers: normalizedExcludeTrackers,
|
torrentExcludeTrackers: normalizedExcludeTrackers,
|
||||||
torrentStopTimeout: normalizedStopTimeout,
|
torrentStopTimeout: normalizedStopTimeout,
|
||||||
torrentPrioritizePiece: normalizedPrioritizePiece
|
torrentPrioritizePiece: normalizedPrioritizePiece,
|
||||||
|
torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile
|
||||||
}
|
}
|
||||||
: download;
|
: download;
|
||||||
|
|
||||||
@@ -2205,6 +2212,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||||
torrent_stop_timeout: item.torrentStopTimeout,
|
torrent_stop_timeout: item.torrentStopTimeout,
|
||||||
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
|
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
|
||||||
|
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
|
||||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user