diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 418f3ec..7eed208 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5688,17 +5688,191 @@ fn expected_torrent_output_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 { + 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] async fn inspect_torrent( app_handle: tauri::AppHandle, + state: tauri::State<'_, AppState>, source: String, id: String, cache: Option, + proxy: Option, ) -> Result { if source.trim_start().to_ascii_lowercase().starts_with("magnet:") { - return crate::torrent::inspect_source(&source) - .map(|parsed| crate::torrent::to_metadata(parsed, None)) - .map_err(AppError::Internal); + return resolve_magnet_metadata( + &app_handle, + state.inner(), + &source, + &id, + proxy.as_deref(), + cache == Some(true), + ) + .await + .map_err(AppError::Internal); } if cache == Some(false) { return crate::torrent::inspect_source(&source) @@ -5711,6 +5885,52 @@ async fn inspect_torrent( 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 { + 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] async fn enqueue_download( app_handle: tauri::AppHandle, @@ -9629,6 +9849,46 @@ pub fn run() { let database = crate::db::init(&storage_layout) .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::(&record) + .map_err(|error| { + format!("could not decode persisted download metadata: {error}") + }) + }) + .collect::, _>>() + }) + .map(|downloads| { + downloads + .into_iter() + .filter(|download| { + download.is_torrent.unwrap_or(false) + && download.torrent_path.is_some() + }) + .map(|download| download.id) + .collect::>() + }); + 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 = { // Generate a temporary session token for the extension server on startup. // The frontend will hydrate the real token via IPC once it mounts, @@ -10291,7 +10551,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ 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, - 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, ack_schedule_trigger, check_automation_permission, request_automation_permission, open_automation_settings, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index a80df57..dcf595f 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -2700,7 +2700,7 @@ fn proxy_scheme(proxy: &str) -> Option { .map(|(scheme, _)| scheme.trim().to_ascii_lowercase()) } -fn aria2_all_proxy_value(proxy: &str) -> Result, String> { +pub(crate) fn aria2_all_proxy_value(proxy: &str) -> Result, String> { let proxy = proxy.trim(); if proxy.is_empty() { return Ok(None); diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs index f261b23..b240c07 100644 --- a/src-tauri/src/torrent.rs +++ b/src-tauri/src/torrent.rs @@ -457,12 +457,82 @@ pub fn managed_torrent_path( if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') { 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( + app_handle: &tauri::AppHandle, +) -> Result { let root = app_handle .path() .app_data_dir() .map_err(|error| format!("could not resolve torrent storage: {error}"))? .join("torrents"); - Ok(root.join(format!("{id}.torrent"))) + Ok(root) +} + +pub fn remove_orphaned_probe_dirs( + app_handle: &tauri::AppHandle, +) -> Result { + 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( + app_handle: &tauri::AppHandle, + retained_ids: &HashSet, +) -> Result { + 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( @@ -499,16 +569,30 @@ pub async fn prepare_local_torrent( .await .map_err(|error| format!("could not read torrent file: {error}"))?; 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( + app_handle: &tauri::AppHandle, + id: &str, + bytes: &[u8], +) -> Result { + 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)?; if let Some(parent) = destination.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|error| format!("could not create torrent storage: {error}"))?; } - tokio::fs::write(&destination, &bytes) + tokio::fs::write(&destination, bytes) .await .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( diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 0378417..4bdaabb 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -160,11 +160,47 @@ export const AddDownloadsModal = () => { const [urls, setUrls] = useState(''); const [selectedItemIndex, setSelectedItemIndex] = useState(null); const [parsedItems, setParsedItems] = useState([]); + const parsedItemsRef = useRef([]); + const addModalOpenRef = useRef(isAddModalOpen); const metadataRequestsRef = useRef(new Set()); const playlistRequestsRef = useRef(new Set()); const latestPlaylistRequestRef = useRef(new Map()); + const cachedTorrentDraftIdsRef = useRef(new Set()); const [playlistExpansions, setPlaylistExpansions] = useState>({}); + parsedItemsRef.current = parsedItems; + addModalOpenRef.current = isAddModalOpen; + + const cleanupDraftTorrentCache = useCallback((ids?: Iterable) => { + 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(); + 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([]); const [showingDuplicates, setShowingDuplicates] = useState(false); const modalRef = useModalFocus(isAddModalOpen); @@ -519,11 +555,30 @@ export const AddDownloadsModal = () => { const contextUrl = requestContextUrlForRow(row); const requestContext = requestContextForUrl(contextUrl); 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', { source: row.sourceUrl, - id: row.id, - cache: false + id: torrentCacheId, + 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; setParsedItems(current => updateRowIfCurrent( current, @@ -541,6 +596,7 @@ export const AddDownloadsModal = () => { status: 'ready', isTorrent: true, torrentPath: torrentData.torrentPath, + torrentCacheId, torrentInfoHash: torrentData.infoHash, torrentFiles: torrentData.files, selectedTorrentFileIndices: currentRow.selectedTorrentFileIndices @@ -1280,20 +1336,32 @@ export const AddDownloadsModal = () => { for (const [itemIndex, item] of itemsToAdd.entries()) { if (!item) continue; + let allocatedId: string | null = null; try { const id = crypto.randomUUID(); + allocatedId = id; let torrentPath = item.torrentPath; - if (item.isTorrent && !item.sourceUrl.trim().toLowerCase().startsWith('magnet:')) { - // Cached torrent metadata is deliberately keyed by the download - // identity. The metadata row ID is temporary, so re-key the - // cache after the final download ID is allocated (including - // replacement flows). - const torrentData = await invoke('inspect_torrent', { - source: item.sourceUrl, - id, - cache: true - }); - torrentPath = torrentData.torrentPath; + if (item.isTorrent) { + if (item.torrentPath) { + torrentPath = await invoke('rekey_torrent_metadata', { + sourceId: item.torrentCacheId || item.id, + targetId: id + }); + cachedTorrentDraftIdsRef.current.delete(item.torrentCacheId || item.id); + } else { + // Keep a safe fallback for rows restored from an older draft + // shape that did not retain the preview cache identity. + 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 ? mediaFileNameForSelectedFormat(item.file, item) @@ -1342,6 +1410,11 @@ export const AddDownloadsModal = () => { } addedCount += 1; } 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); failures.push(`${item.file}: ${e instanceof Error ? e.message : String(e)}`); } diff --git a/src/ipc.ts b/src/ipc.ts index 610016b..b9984ee 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -34,9 +34,17 @@ type CommandMap = { result: MediaPlaylistMetadata; }; inspect_torrent: { - args: { source: string; id: string; cache?: boolean }; + args: { source: string; id: string; cache?: boolean; proxy?: string }; 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_ytdlp_engine_status: { args: undefined; result: EngineStatusItem }; get_ffmpeg_engine_status: { args: undefined; result: EngineStatusItem }; diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index 084a489..527aab3 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -102,6 +102,40 @@ describe('add download metadata workflow', () => { sourceUrl: 'file:///tmp/Example.torrent', 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', () => { diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 02901e0..d1c3b63 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -55,6 +55,7 @@ export interface AddDownloadDraftRow { selected?: boolean; isTorrent?: boolean; torrentPath?: string; + torrentCacheId?: string; torrentInfoHash?: string; torrentFiles?: TorrentFile[]; selectedTorrentFileIndices?: number[]; @@ -237,6 +238,7 @@ export const reconcileDownloadRows = ( || preserved.playlistCount !== input.playlistCount || preserved.playlistEntryTitle !== input.playlistEntryTitle; if ((forcedMedia && !preserved.isMedia) || contextChanged || playlistContextChanged) { + const nextGeneration = preserved.generation + 1; const requestedFilename = input.playlistSourceUrl ? `${playlistFilePrefix(input.playlistIndex, input.playlistCount)}${input.playlistEntryTitle || 'video'}` : requestFilenames[input.sourceUrl]; @@ -246,7 +248,7 @@ export const reconcileDownloadRows = ( ? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl)) : preserved.file, status: 'loading', - generation: preserved.generation + 1, + generation: nextGeneration, requestContextVersion, isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), isTorrent: input.isTorrent, @@ -267,10 +269,11 @@ export const reconcileDownloadRows = ( playlistEntryTitle: input.playlistEntryTitle, playlistError: undefined, metadataBlockedReason: undefined, - torrentPath: input.isTorrent ? preserved.torrentPath : undefined, - torrentInfoHash: input.isTorrent ? preserved.torrentInfoHash : undefined, - torrentFiles: input.isTorrent ? preserved.torrentFiles : undefined, - selectedTorrentFileIndices: input.isTorrent ? preserved.selectedTorrentFileIndices : undefined + torrentPath: undefined, + torrentCacheId: input.isTorrent ? `${preserved.id}-${nextGeneration}` : undefined, + torrentInfoHash: undefined, + torrentFiles: undefined, + selectedTorrentFileIndices: undefined }; } return preserved; @@ -284,13 +287,15 @@ export const reconcileDownloadRows = ( requestedFilename || fileNameFromUrl(input.sourceUrl) ); + const id = createId(); + const generation = input.valid ? 1 : 0; return { - id: createId(), + id, sourceUrl: input.sourceUrl, downloadUrl: input.sourceUrl, file: fallback, status: input.valid ? 'loading' : 'invalid', - generation: input.valid ? 1 : 0, + generation, requestContextVersion: input.requestContextVersion, isMedia: input.valid && ( Boolean(input.isPlaylist) @@ -306,6 +311,7 @@ export const reconcileDownloadRows = ( playlistCount: input.playlistCount, playlistEntryTitle: input.playlistEntryTitle, metadataBlockedReason: undefined, + torrentCacheId: input.valid && input.isTorrent ? `${id}-${generation}` : undefined, selected: input.selected !== false }; });