mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-30 12:29:34 +00:00
feat(torrents): resolve magnet metadata before enqueue
This commit is contained in:
+264
-4
@@ -5688,17 +5688,191 @@ fn expected_torrent_output_paths(
|
|||||||
Ok(Some(paths))
|
Ok(Some(paths))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_magnet_metadata(
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
state: &AppState,
|
||||||
|
source: &str,
|
||||||
|
id: &str,
|
||||||
|
proxy: Option<&str>,
|
||||||
|
cache: bool,
|
||||||
|
) -> Result<crate::ipc::TorrentMetadata, String> {
|
||||||
|
let expected = crate::torrent::inspect_source(source)?;
|
||||||
|
let managed_path = crate::torrent::managed_torrent_path(app_handle, id)?;
|
||||||
|
let storage_root = managed_path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "torrent storage has no parent directory".to_string())?;
|
||||||
|
let probe_dir = storage_root.join(format!(".probe-{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
tokio::fs::create_dir_all(&probe_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("could not create torrent metadata probe: {error}"))?;
|
||||||
|
|
||||||
|
let port = state
|
||||||
|
.aria2_port
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let secret = state.aria2_secret.clone();
|
||||||
|
let metadata_path = probe_dir.join(format!("{}.torrent", expected.info_hash));
|
||||||
|
let mut gid = None;
|
||||||
|
let metadata_result = async {
|
||||||
|
let mut options = serde_json::Map::new();
|
||||||
|
options.insert(
|
||||||
|
"dir".to_string(),
|
||||||
|
serde_json::json!(probe_dir.to_string_lossy().to_string()),
|
||||||
|
);
|
||||||
|
options.insert("bt-metadata-only".to_string(), serde_json::json!("true"));
|
||||||
|
options.insert("bt-save-metadata".to_string(), serde_json::json!("true"));
|
||||||
|
options.insert("max-tries".to_string(), serde_json::json!("3"));
|
||||||
|
options.insert("retry-wait".to_string(), serde_json::json!("2"));
|
||||||
|
options.insert("connect-timeout".to_string(), serde_json::json!("20"));
|
||||||
|
options.insert("timeout".to_string(), serde_json::json!("60"));
|
||||||
|
options.insert("auto-file-renaming".to_string(), serde_json::json!("false"));
|
||||||
|
if let Some(proxy) = proxy {
|
||||||
|
if let Some(proxy) = crate::queue::aria2_all_proxy_value(proxy)? {
|
||||||
|
options.insert("all-proxy".to_string(), serde_json::json!(proxy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = crate::rpc_call(
|
||||||
|
port,
|
||||||
|
&secret,
|
||||||
|
"aria2.addUri",
|
||||||
|
serde_json::json!([[source], options]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Aria2 could not start magnet metadata resolution: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let added_gid = result
|
||||||
|
.as_str()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| "Aria2 returned an empty metadata probe GID".to_string())?
|
||||||
|
.to_string();
|
||||||
|
gid = Some(added_gid.clone());
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(60);
|
||||||
|
loop {
|
||||||
|
let status = match crate::rpc_call(
|
||||||
|
port,
|
||||||
|
&secret,
|
||||||
|
"aria2.tellStatus",
|
||||||
|
serde_json::json!([&added_gid, ["status", "errorCode", "errorMessage"]]),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(error) if aria2_gid_not_found(&error) => {
|
||||||
|
return Err(
|
||||||
|
"Aria2 removed the magnet metadata probe before metadata was saved"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Aria2 metadata resolution status failed: {}",
|
||||||
|
crate::redact_sensitive_text(&error)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match status.get("status").and_then(serde_json::Value::as_str) {
|
||||||
|
Some("complete") => break,
|
||||||
|
Some("error") | Some("removed") => {
|
||||||
|
let error_code = status
|
||||||
|
.get("errorCode")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let error_message = status
|
||||||
|
.get("errorMessage")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("metadata probe ended without a torrent file");
|
||||||
|
let detail = match error_code {
|
||||||
|
Some(code) => format!("aria2 error code {code}: {error_message}"),
|
||||||
|
None => error_message.to_string(),
|
||||||
|
};
|
||||||
|
return Err(format!(
|
||||||
|
"Aria2 could not resolve magnet metadata: {}",
|
||||||
|
crate::redact_sensitive_text(&detail)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some("active") | Some("waiting") | Some("paused") | None => {}
|
||||||
|
Some(status) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Aria2 returned an unsupported metadata probe status: {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err("Aria2 magnet metadata resolution timed out".to_string());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = tokio::fs::read(&metadata_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Aria2 did not save magnet metadata: {error}"))?;
|
||||||
|
let parsed = crate::torrent::parse_torrent_bytes(&bytes)?;
|
||||||
|
crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?;
|
||||||
|
Ok((parsed, bytes))
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let cleanup_result = if let Some(gid) = gid.as_deref() {
|
||||||
|
let removal = force_remove_aria2_gid(port, &secret, gid).await;
|
||||||
|
match removal {
|
||||||
|
Ok(()) => wait_for_aria2_stopped(port, &secret, gid).await,
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
if let Err(error) = cleanup_result {
|
||||||
|
if aria2_daemon_process_exited(app_handle) {
|
||||||
|
if let Err(remove_error) = tokio::fs::remove_dir_all(&probe_dir).await {
|
||||||
|
return Err(format!(
|
||||||
|
"could not clean up magnet metadata probe: {error}; could not remove probe after aria2 exit: {remove_error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"could not clean up magnet metadata probe after aria2 exit: {error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(format!("could not clean up magnet metadata probe: {error}"));
|
||||||
|
}
|
||||||
|
if let Err(error) = tokio::fs::remove_dir_all(&probe_dir).await {
|
||||||
|
return Err(format!("could not remove magnet metadata probe: {error}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (parsed, bytes) = metadata_result?;
|
||||||
|
let torrent_path = if cache {
|
||||||
|
Some(crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(crate::torrent::to_metadata(parsed, torrent_path))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn inspect_torrent(
|
async fn inspect_torrent(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
source: String,
|
source: String,
|
||||||
id: String,
|
id: String,
|
||||||
cache: Option<bool>,
|
cache: Option<bool>,
|
||||||
|
proxy: Option<String>,
|
||||||
) -> Result<crate::ipc::TorrentMetadata, AppError> {
|
) -> Result<crate::ipc::TorrentMetadata, AppError> {
|
||||||
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
|
if source.trim_start().to_ascii_lowercase().starts_with("magnet:") {
|
||||||
return crate::torrent::inspect_source(&source)
|
return resolve_magnet_metadata(
|
||||||
.map(|parsed| crate::torrent::to_metadata(parsed, None))
|
&app_handle,
|
||||||
.map_err(AppError::Internal);
|
state.inner(),
|
||||||
|
&source,
|
||||||
|
&id,
|
||||||
|
proxy.as_deref(),
|
||||||
|
cache == Some(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal);
|
||||||
}
|
}
|
||||||
if cache == Some(false) {
|
if cache == Some(false) {
|
||||||
return crate::torrent::inspect_source(&source)
|
return crate::torrent::inspect_source(&source)
|
||||||
@@ -5711,6 +5885,52 @@ async fn inspect_torrent(
|
|||||||
Ok(crate::torrent::to_metadata(parsed, Some(path)))
|
Ok(crate::torrent::to_metadata(parsed, Some(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn rekey_torrent_metadata(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
source_id: String,
|
||||||
|
target_id: String,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let source = crate::torrent::managed_torrent_path(&app_handle, &source_id)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let source = crate::torrent::validate_managed_torrent_path(
|
||||||
|
&app_handle,
|
||||||
|
&source_id,
|
||||||
|
&source.to_string_lossy(),
|
||||||
|
)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let bytes = tokio::fs::read(&source)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::Internal(format!("could not read cached torrent metadata: {error}"))
|
||||||
|
})?;
|
||||||
|
crate::torrent::parse_torrent_bytes(&bytes).map_err(AppError::Internal)?;
|
||||||
|
let target = crate::torrent::cache_torrent_bytes(&app_handle, &target_id, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
let target = crate::torrent::validate_managed_torrent_path(
|
||||||
|
&app_handle,
|
||||||
|
&target_id,
|
||||||
|
&target,
|
||||||
|
)
|
||||||
|
.map_err(AppError::Internal)?;
|
||||||
|
if source != target {
|
||||||
|
tokio::fs::remove_file(&source).await.map_err(|error| {
|
||||||
|
AppError::Internal(format!("could not remove temporary torrent metadata: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(target.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn remove_torrent_metadata(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
crate::torrent::remove_managed_torrent(&app_handle, &id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn enqueue_download(
|
async fn enqueue_download(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -9629,6 +9849,46 @@ pub fn run() {
|
|||||||
|
|
||||||
let database = crate::db::init(&storage_layout)
|
let database = crate::db::init(&storage_layout)
|
||||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||||
|
if let Err(error) = crate::torrent::remove_orphaned_probe_dirs(app.handle()) {
|
||||||
|
log::warn!("could not remove orphaned torrent probes: {error}");
|
||||||
|
}
|
||||||
|
let retained_torrent_ids = database
|
||||||
|
.lock()
|
||||||
|
.and_then(|connection| crate::db::load_downloads(&connection))
|
||||||
|
.and_then(|records| {
|
||||||
|
records
|
||||||
|
.into_iter()
|
||||||
|
.map(|record| {
|
||||||
|
serde_json::from_str::<crate::ipc::DownloadItem>(&record)
|
||||||
|
.map_err(|error| {
|
||||||
|
format!("could not decode persisted download metadata: {error}")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
})
|
||||||
|
.map(|downloads| {
|
||||||
|
downloads
|
||||||
|
.into_iter()
|
||||||
|
.filter(|download| {
|
||||||
|
download.is_torrent.unwrap_or(false)
|
||||||
|
&& download.torrent_path.is_some()
|
||||||
|
})
|
||||||
|
.map(|download| download.id)
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
});
|
||||||
|
match retained_torrent_ids {
|
||||||
|
Ok(retained_torrent_ids) => {
|
||||||
|
if let Err(error) = crate::torrent::remove_orphaned_cached_torrents(
|
||||||
|
app.handle(),
|
||||||
|
&retained_torrent_ids,
|
||||||
|
) {
|
||||||
|
log::warn!("could not remove orphaned torrent metadata: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("could not identify retained torrent metadata: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
let initial_pairing_token = {
|
let initial_pairing_token = {
|
||||||
// Generate a temporary session token for the extension server on startup.
|
// Generate a temporary session token for the extension server on startup.
|
||||||
// The frontend will hydrate the real token via IPC once it mounts,
|
// The frontend will hydrate the real token via IPC once it mounts,
|
||||||
@@ -10291,7 +10551,7 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
get_engine_status, get_aria2_engine_status, get_ytdlp_engine_status, get_ffmpeg_engine_status,
|
||||||
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
get_deno_engine_status, test_ytdlp, test_aria2c, test_ffmpeg, test_deno,
|
||||||
pause_download, resume_download, fetch_metadata, inspect_torrent, fetch_media_metadata, fetch_media_playlist_metadata,
|
pause_download, resume_download, fetch_metadata, inspect_torrent, rekey_torrent_metadata, remove_torrent_metadata, fetch_media_metadata, fetch_media_playlist_metadata,
|
||||||
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
|
begin_dock_badge_session, update_dock_badge, get_platform_info, approve_download_root, set_prevent_sleep, set_power_preferences, get_free_space, perform_system_action,
|
||||||
ack_schedule_trigger,
|
ack_schedule_trigger,
|
||||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||||
|
|||||||
@@ -2700,7 +2700,7 @@ fn proxy_scheme(proxy: &str) -> Option<String> {
|
|||||||
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
|
.map(|(scheme, _)| scheme.trim().to_ascii_lowercase())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, String> {
|
pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result<Option<String>, String> {
|
||||||
let proxy = proxy.trim();
|
let proxy = proxy.trim();
|
||||||
if proxy.is_empty() {
|
if proxy.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|||||||
@@ -457,12 +457,82 @@ pub fn managed_torrent_path<R: tauri::Runtime>(
|
|||||||
if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') {
|
if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') {
|
||||||
return Err("invalid torrent download id".to_string());
|
return Err("invalid torrent download id".to_string());
|
||||||
}
|
}
|
||||||
|
let root = managed_torrent_storage_root(app_handle)?;
|
||||||
|
Ok(root.join(format!("{id}.torrent")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn managed_torrent_storage_root<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
) -> Result<PathBuf, String> {
|
||||||
let root = app_handle
|
let root = app_handle
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|error| format!("could not resolve torrent storage: {error}"))?
|
.map_err(|error| format!("could not resolve torrent storage: {error}"))?
|
||||||
.join("torrents");
|
.join("torrents");
|
||||||
Ok(root.join(format!("{id}.torrent")))
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_orphaned_probe_dirs<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let root = managed_torrent_storage_root(app_handle)?;
|
||||||
|
let entries = match std::fs::read_dir(&root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||||
|
Err(error) => return Err(format!("could not inspect torrent probe storage: {error}")),
|
||||||
|
};
|
||||||
|
let mut removed = 0;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| format!("could not inspect torrent probe storage: {error}"))?;
|
||||||
|
let is_probe = entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.starts_with(".probe-"));
|
||||||
|
if !is_probe || !entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| format!("could not inspect torrent probe entry: {error}"))?
|
||||||
|
.is_dir()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(entry.path())
|
||||||
|
.map_err(|error| format!("could not remove orphaned torrent probe: {error}"))?;
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_orphaned_cached_torrents<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
retained_ids: &HashSet<String>,
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
let root = managed_torrent_storage_root(app_handle)?;
|
||||||
|
let entries = match std::fs::read_dir(&root) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||||
|
Err(error) => return Err(format!("could not inspect torrent metadata storage: {error}")),
|
||||||
|
};
|
||||||
|
let mut removed = 0;
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry.map_err(|error| format!("could not inspect torrent metadata storage: {error}"))?;
|
||||||
|
let file_type = entry
|
||||||
|
.file_type()
|
||||||
|
.map_err(|error| format!("could not inspect torrent metadata entry: {error}"))?;
|
||||||
|
if !file_type.is_file() || entry.path().extension().and_then(|ext| ext.to_str()) != Some("torrent") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if retained_ids.contains(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
.map_err(|error| format!("could not remove orphaned torrent metadata: {error}"))?;
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_selected_indices(
|
pub fn validate_selected_indices(
|
||||||
@@ -499,16 +569,30 @@ pub async fn prepare_local_torrent<R: tauri::Runtime>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| format!("could not read torrent file: {error}"))?;
|
.map_err(|error| format!("could not read torrent file: {error}"))?;
|
||||||
let parsed = parse_torrent_bytes(&bytes)?;
|
let parsed = parse_torrent_bytes(&bytes)?;
|
||||||
|
let destination = cache_torrent_bytes(app_handle, id, &bytes).await?;
|
||||||
|
Ok((parsed, destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cache_torrent_bytes<R: tauri::Runtime>(
|
||||||
|
app_handle: &tauri::AppHandle<R>,
|
||||||
|
id: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
|
||||||
|
return Err(format!(
|
||||||
|
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
|
||||||
|
));
|
||||||
|
}
|
||||||
let destination = managed_torrent_path(app_handle, id)?;
|
let destination = managed_torrent_path(app_handle, id)?;
|
||||||
if let Some(parent) = destination.parent() {
|
if let Some(parent) = destination.parent() {
|
||||||
tokio::fs::create_dir_all(parent)
|
tokio::fs::create_dir_all(parent)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("could not create torrent storage: {error}"))?;
|
.map_err(|error| format!("could not create torrent storage: {error}"))?;
|
||||||
}
|
}
|
||||||
tokio::fs::write(&destination, &bytes)
|
tokio::fs::write(&destination, bytes)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("could not cache torrent metadata: {error}"))?;
|
.map_err(|error| format!("could not cache torrent metadata: {error}"))?;
|
||||||
Ok((parsed, destination.to_string_lossy().to_string()))
|
Ok(destination.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_managed_torrent_path<R: tauri::Runtime>(
|
pub fn validate_managed_torrent_path<R: tauri::Runtime>(
|
||||||
|
|||||||
@@ -160,11 +160,47 @@ export const AddDownloadsModal = () => {
|
|||||||
const [urls, setUrls] = useState('');
|
const [urls, setUrls] = useState('');
|
||||||
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
|
||||||
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
const [parsedItems, setParsedItems] = useState<AddDownloadDraftRow[]>([]);
|
||||||
|
const parsedItemsRef = useRef<AddDownloadDraftRow[]>([]);
|
||||||
|
const addModalOpenRef = useRef(isAddModalOpen);
|
||||||
const metadataRequestsRef = useRef(new Set<string>());
|
const metadataRequestsRef = useRef(new Set<string>());
|
||||||
const playlistRequestsRef = useRef(new Set<string>());
|
const playlistRequestsRef = useRef(new Set<string>());
|
||||||
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
const latestPlaylistRequestRef = useRef(new Map<string, string>());
|
||||||
|
const cachedTorrentDraftIdsRef = useRef(new Set<string>());
|
||||||
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
|
const [playlistExpansions, setPlaylistExpansions] = useState<Record<string, MediaPlaylistMetadata>>({});
|
||||||
|
|
||||||
|
parsedItemsRef.current = parsedItems;
|
||||||
|
addModalOpenRef.current = isAddModalOpen;
|
||||||
|
|
||||||
|
const cleanupDraftTorrentCache = useCallback((ids?: Iterable<string>) => {
|
||||||
|
const idsToRemove = ids
|
||||||
|
? Array.from(ids)
|
||||||
|
: Array.from(cachedTorrentDraftIdsRef.current);
|
||||||
|
idsToRemove.forEach(id => cachedTorrentDraftIdsRef.current.delete(id));
|
||||||
|
idsToRemove.forEach(id => {
|
||||||
|
void invoke('remove_torrent_metadata', { id }).catch(error => {
|
||||||
|
console.warn('Failed to remove temporary torrent metadata:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAddModalOpen) cleanupDraftTorrentCache();
|
||||||
|
}, [cleanupDraftTorrentCache, isAddModalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeDraftIds = new Set<string>();
|
||||||
|
for (const row of parsedItems) {
|
||||||
|
if (!row.isTorrent) continue;
|
||||||
|
activeDraftIds.add(row.torrentCacheId || row.id);
|
||||||
|
activeDraftIds.add(`${row.id}-${row.generation}`);
|
||||||
|
}
|
||||||
|
const staleDraftIds = Array.from(cachedTorrentDraftIdsRef.current)
|
||||||
|
.filter(id => !activeDraftIds.has(id));
|
||||||
|
if (staleDraftIds.length > 0) cleanupDraftTorrentCache(staleDraftIds);
|
||||||
|
}, [cleanupDraftTorrentCache, parsedItems]);
|
||||||
|
|
||||||
|
useEffect(() => cleanupDraftTorrentCache, [cleanupDraftTorrentCache]);
|
||||||
|
|
||||||
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
|
||||||
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
const [showingDuplicates, setShowingDuplicates] = useState(false);
|
||||||
const modalRef = useModalFocus(isAddModalOpen);
|
const modalRef = useModalFocus(isAddModalOpen);
|
||||||
@@ -519,11 +555,30 @@ export const AddDownloadsModal = () => {
|
|||||||
const contextUrl = requestContextUrlForRow(row);
|
const contextUrl = requestContextUrlForRow(row);
|
||||||
const requestContext = requestContextForUrl(contextUrl);
|
const requestContext = requestContextForUrl(contextUrl);
|
||||||
if (row.isTorrent) {
|
if (row.isTorrent) {
|
||||||
|
const torrentCacheId = row.torrentCacheId || `${row.id}-${row.generation}`;
|
||||||
|
const proxy = row.sourceUrl.trim().toLowerCase().startsWith('magnet:')
|
||||||
|
? await getProxyArgs(settingsStore)
|
||||||
|
: undefined;
|
||||||
const torrentData = await invoke('inspect_torrent', {
|
const torrentData = await invoke('inspect_torrent', {
|
||||||
source: row.sourceUrl,
|
source: row.sourceUrl,
|
||||||
id: row.id,
|
id: torrentCacheId,
|
||||||
cache: false
|
cache: true,
|
||||||
|
proxy: proxy ?? undefined
|
||||||
});
|
});
|
||||||
|
const isCurrentTorrentDraft = addModalOpenRef.current
|
||||||
|
&& parsedItemsRef.current.some(currentRow =>
|
||||||
|
currentRow.id === row.id
|
||||||
|
&& currentRow.sourceUrl === row.sourceUrl
|
||||||
|
&& currentRow.generation === row.generation
|
||||||
|
&& (currentRow.torrentCacheId || `${currentRow.id}-${currentRow.generation}`) === torrentCacheId
|
||||||
|
);
|
||||||
|
if (torrentData.torrentPath && isCurrentTorrentDraft) {
|
||||||
|
cachedTorrentDraftIdsRef.current.add(torrentCacheId);
|
||||||
|
} else if (torrentData.torrentPath) {
|
||||||
|
void invoke('remove_torrent_metadata', { id: torrentCacheId }).catch(error => {
|
||||||
|
console.warn('Failed to remove stale torrent metadata:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
const totalBytes = torrentData.totalBytes || undefined;
|
const totalBytes = torrentData.totalBytes || undefined;
|
||||||
setParsedItems(current => updateRowIfCurrent(
|
setParsedItems(current => updateRowIfCurrent(
|
||||||
current,
|
current,
|
||||||
@@ -541,6 +596,7 @@ export const AddDownloadsModal = () => {
|
|||||||
status: 'ready',
|
status: 'ready',
|
||||||
isTorrent: true,
|
isTorrent: true,
|
||||||
torrentPath: torrentData.torrentPath,
|
torrentPath: torrentData.torrentPath,
|
||||||
|
torrentCacheId,
|
||||||
torrentInfoHash: torrentData.infoHash,
|
torrentInfoHash: torrentData.infoHash,
|
||||||
torrentFiles: torrentData.files,
|
torrentFiles: torrentData.files,
|
||||||
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
|
selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices
|
||||||
@@ -1280,20 +1336,32 @@ export const AddDownloadsModal = () => {
|
|||||||
|
|
||||||
for (const [itemIndex, item] of itemsToAdd.entries()) {
|
for (const [itemIndex, item] of itemsToAdd.entries()) {
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
|
let allocatedId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
allocatedId = id;
|
||||||
let torrentPath = item.torrentPath;
|
let torrentPath = item.torrentPath;
|
||||||
if (item.isTorrent && !item.sourceUrl.trim().toLowerCase().startsWith('magnet:')) {
|
if (item.isTorrent) {
|
||||||
// Cached torrent metadata is deliberately keyed by the download
|
if (item.torrentPath) {
|
||||||
// identity. The metadata row ID is temporary, so re-key the
|
torrentPath = await invoke('rekey_torrent_metadata', {
|
||||||
// cache after the final download ID is allocated (including
|
sourceId: item.torrentCacheId || item.id,
|
||||||
// replacement flows).
|
targetId: id
|
||||||
const torrentData = await invoke('inspect_torrent', {
|
});
|
||||||
source: item.sourceUrl,
|
cachedTorrentDraftIdsRef.current.delete(item.torrentCacheId || item.id);
|
||||||
id,
|
} else {
|
||||||
cache: true
|
// Keep a safe fallback for rows restored from an older draft
|
||||||
});
|
// shape that did not retain the preview cache identity.
|
||||||
torrentPath = torrentData.torrentPath;
|
const proxy = item.sourceUrl.trim().toLowerCase().startsWith('magnet:')
|
||||||
|
? await getProxyArgs(useSettingsStore.getState())
|
||||||
|
: undefined;
|
||||||
|
const torrentData = await invoke('inspect_torrent', {
|
||||||
|
source: item.sourceUrl,
|
||||||
|
id,
|
||||||
|
cache: true,
|
||||||
|
proxy: proxy ?? undefined
|
||||||
|
});
|
||||||
|
torrentPath = torrentData.torrentPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let finalFile = item.isMedia
|
let finalFile = item.isMedia
|
||||||
? mediaFileNameForSelectedFormat(item.file, item)
|
? mediaFileNameForSelectedFormat(item.file, item)
|
||||||
@@ -1342,6 +1410,11 @@ export const AddDownloadsModal = () => {
|
|||||||
}
|
}
|
||||||
addedCount += 1;
|
addedCount += 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (item.isTorrent && allocatedId) {
|
||||||
|
await invoke('remove_torrent_metadata', { id: allocatedId }).catch(error => {
|
||||||
|
console.warn('Failed to remove cached torrent metadata after add failure:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
console.error("Invalid URL or failed to add:", e);
|
console.error("Invalid URL or failed to add:", e);
|
||||||
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
|
failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -34,9 +34,17 @@ type CommandMap = {
|
|||||||
result: MediaPlaylistMetadata;
|
result: MediaPlaylistMetadata;
|
||||||
};
|
};
|
||||||
inspect_torrent: {
|
inspect_torrent: {
|
||||||
args: { source: string; id: string; cache?: boolean };
|
args: { source: string; id: string; cache?: boolean; proxy?: string };
|
||||||
result: TorrentMetadata;
|
result: TorrentMetadata;
|
||||||
};
|
};
|
||||||
|
rekey_torrent_metadata: {
|
||||||
|
args: { sourceId: string; targetId: string };
|
||||||
|
result: string;
|
||||||
|
};
|
||||||
|
remove_torrent_metadata: {
|
||||||
|
args: { id: string };
|
||||||
|
result: void;
|
||||||
|
};
|
||||||
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
get_aria2_engine_status: { args: undefined; result: EngineStatusItem };
|
||||||
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
get_ytdlp_engine_status: { args: undefined; result: EngineStatusItem };
|
||||||
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem };
|
||||||
|
|||||||
@@ -102,6 +102,40 @@ describe('add download metadata workflow', () => {
|
|||||||
sourceUrl: 'file:///tmp/Example.torrent',
|
sourceUrl: 'file:///tmp/Example.torrent',
|
||||||
status: 'loading'
|
status: 'loading'
|
||||||
});
|
});
|
||||||
|
expect(rows[0].torrentCacheId).toBe(`${rows[0].id}-1`);
|
||||||
|
expect(rows[1].torrentCacheId).toBe(`${rows[1].id}-1`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives refreshed torrent metadata a new cache identity', () => {
|
||||||
|
const existing = row({
|
||||||
|
id: 'torrent-row',
|
||||||
|
sourceUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||||
|
downloadUrl: 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||||
|
isTorrent: true,
|
||||||
|
torrentCacheId: 'torrent-row-1',
|
||||||
|
torrentPath: '/managed/torrent-row-1.torrent',
|
||||||
|
torrentInfoHash: '0123456789abcdef0123456789abcdef01234567',
|
||||||
|
generation: 1,
|
||||||
|
requestContextVersion: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = reconcileDownloadRows(
|
||||||
|
existing.sourceUrl,
|
||||||
|
[existing],
|
||||||
|
undefined,
|
||||||
|
new Set(),
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ [existing.sourceUrl]: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshed[0]).toMatchObject({
|
||||||
|
generation: 2,
|
||||||
|
torrentCacheId: 'torrent-row-2',
|
||||||
|
torrentPath: undefined,
|
||||||
|
torrentInfoHash: undefined,
|
||||||
|
torrentFiles: undefined
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
it('keeps a playlist as one loading row until discovery succeeds', () => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface AddDownloadDraftRow {
|
|||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
isTorrent?: boolean;
|
isTorrent?: boolean;
|
||||||
torrentPath?: string;
|
torrentPath?: string;
|
||||||
|
torrentCacheId?: string;
|
||||||
torrentInfoHash?: string;
|
torrentInfoHash?: string;
|
||||||
torrentFiles?: TorrentFile[];
|
torrentFiles?: TorrentFile[];
|
||||||
selectedTorrentFileIndices?: number[];
|
selectedTorrentFileIndices?: number[];
|
||||||
@@ -237,6 +238,7 @@ export const reconcileDownloadRows = (
|
|||||||
|| preserved.playlistCount !== input.playlistCount
|
|| preserved.playlistCount !== input.playlistCount
|
||||||
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
|| preserved.playlistEntryTitle !== input.playlistEntryTitle;
|
||||||
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) {
|
||||||
|
const nextGeneration = preserved.generation + 1;
|
||||||
const requestedFilename = input.playlistSourceUrl
|
const requestedFilename = input.playlistSourceUrl
|
||||||
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}`
|
||||||
: requestFilenames[input.sourceUrl];
|
: requestFilenames[input.sourceUrl];
|
||||||
@@ -246,7 +248,7 @@ export const reconcileDownloadRows = (
|
|||||||
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl))
|
||||||
: preserved.file,
|
: preserved.file,
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
generation: preserved.generation + 1,
|
generation: nextGeneration,
|
||||||
requestContextVersion,
|
requestContextVersion,
|
||||||
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl),
|
||||||
isTorrent: input.isTorrent,
|
isTorrent: input.isTorrent,
|
||||||
@@ -267,10 +269,11 @@ export const reconcileDownloadRows = (
|
|||||||
playlistEntryTitle: input.playlistEntryTitle,
|
playlistEntryTitle: input.playlistEntryTitle,
|
||||||
playlistError: undefined,
|
playlistError: undefined,
|
||||||
metadataBlockedReason: undefined,
|
metadataBlockedReason: undefined,
|
||||||
torrentPath: input.isTorrent ? preserved.torrentPath : undefined,
|
torrentPath: undefined,
|
||||||
torrentInfoHash: input.isTorrent ? preserved.torrentInfoHash : undefined,
|
torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined,
|
||||||
torrentFiles: input.isTorrent ? preserved.torrentFiles : undefined,
|
torrentInfoHash: undefined,
|
||||||
selectedTorrentFileIndices: input.isTorrent ? preserved.selectedTorrentFileIndices : undefined
|
torrentFiles: undefined,
|
||||||
|
selectedTorrentFileIndices: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return preserved;
|
return preserved;
|
||||||
@@ -284,13 +287,15 @@ export const reconcileDownloadRows = (
|
|||||||
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
requestedFilename || fileNameFromUrl(input.sourceUrl)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const id = createId();
|
||||||
|
const generation = input.valid ? 1 : 0;
|
||||||
return {
|
return {
|
||||||
id: createId(),
|
id,
|
||||||
sourceUrl: input.sourceUrl,
|
sourceUrl: input.sourceUrl,
|
||||||
downloadUrl: input.sourceUrl,
|
downloadUrl: input.sourceUrl,
|
||||||
file: fallback,
|
file: fallback,
|
||||||
status: input.valid ? 'loading' : 'invalid',
|
status: input.valid ? 'loading' : 'invalid',
|
||||||
generation: input.valid ? 1 : 0,
|
generation,
|
||||||
requestContextVersion: input.requestContextVersion,
|
requestContextVersion: input.requestContextVersion,
|
||||||
isMedia: input.valid && (
|
isMedia: input.valid && (
|
||||||
Boolean(input.isPlaylist)
|
Boolean(input.isPlaylist)
|
||||||
@@ -306,6 +311,7 @@ export const reconcileDownloadRows = (
|
|||||||
playlistCount: input.playlistCount,
|
playlistCount: input.playlistCount,
|
||||||
playlistEntryTitle: input.playlistEntryTitle,
|
playlistEntryTitle: input.playlistEntryTitle,
|
||||||
metadataBlockedReason: undefined,
|
metadataBlockedReason: undefined,
|
||||||
|
torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined,
|
||||||
selected: input.selected !== false
|
selected: input.selected !== false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user