mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 00:48:05 +00:00
feat(torrents): safely remove unselected files
This commit is contained in:
+129
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user