feat(queue): implement concurrent deduplication and safe backend detachment

- Add backendRegisteredIds and backendDispatchPromises for single dispatch enforcement

- Add detach_download_for_reconfigure to safely modify properties of active downloads

- Add applyProperties logic handling completed, failed, paused, and active queues

- Add extractValidDownloadUrls and fix multi-url paste handling

- Setup vitest and add useDownloadStore unit tests for backend registration lifecycle
This commit is contained in:
NimBold
2026-06-20 11:41:55 +03:30
parent 1660dd112d
commit 894c238bb7
17 changed files with 1058 additions and 312 deletions
+166 -107
View File
@@ -1,121 +1,180 @@
# Firelink interaction/functionality review
# Firelink UI Interaction Inventory
Date: 2026-06-19
## 1. Main Window & Sidebar
## Review method
### Sidebar Actions
- **Select Filter/Category/Queue**
- **Location**: Sidebar item clicks (`NavItem`, `QueueItem`).
- **Component**: `Sidebar.tsx` (`onSelectFilter`)
- **Store Action**: `useSettingsStore.setActiveView('downloads')`, updates local state filter.
- **Expected**: Main table filters to show matching downloads.
- **Add Queue**
- **Location**: Sidebar '+' button.
- **Component**: `Sidebar.tsx` (`handleAddQueueSubmit`)
- **Store Action**: `useDownloadStore.addQueue()`
- **Expected**: A new custom queue is created.
- **Queue Context Menu**
- **Location**: Right-click on a custom queue.
- **Component**: `Sidebar.tsx` (`handleQueueContextMenu`)
- **Actions**:
- **Start Queue**: `useDownloadStore.startQueue()` -> IPC `start_download` (for active items)
- **Pause Queue**: `useDownloadStore.pauseQueue()` -> IPC `pause_download`
- **Rename Queue**: `useDownloadStore.renameQueue()`
- **Delete Queue**: `useDownloadStore.removeQueue()` -> IPC `db_delete_queue`
- **Expected**: Context menu opens, performing the respective queue manipulation.
- **Resize Sidebar**
- **Location**: Sidebar drag handle.
- **Component**: `App.tsx` (`startSidebarResize`)
- **Expected**: Drags and resizes the sidebar width dynamically.
Code-traced visible UI actions through React components, Zustand stores, typed IPC wrappers, registered Tauri commands, Rust side effects, and frontend update/error paths. Build and Rust tests were run after fixes.
### Main Toolbar
- **Toggle Sidebar**
- **Location**: Top-left icon (when sidebar is hidden).
- **Component**: `DownloadTable.tsx` / `Sidebar.tsx`
- **Store Action**: `useSettingsStore.toggleSidebar()`
- **Add Download**
- **Location**: '+' Button.
- **Component**: `DownloadTable.tsx`
- **Store Action**: `useDownloadStore.toggleAddModal(true)`
- **Resume All / Pause All**
- **Location**: Toolbar Play/Pause icons.
- **Component**: `DownloadTable.tsx`
- **Store Action**: `handleResume` -> `resumeDownload`, `handlePause` -> IPC `pause_download`
- **Rust**: `commands::pause_download`
- **Expected**: Iterates through visible items and pauses/resumes them.
## Root causes found
## 2. Download Table
1. The handwritten frontend IPC map had drifted from generated Rust bindings.
- Queue reordering sent `Up`/`Down`, but Rust deserializes `QueueDirection` as lowercase `up`/`down`.
- **Table Column Resize**
- **Location**: Table headers drag handle.
- **Component**: `DownloadTable.tsx` (`startColumnResize`)
- **Expected**: Modifies local state grid template widths.
- **Row Double Click**
- **Location**: Table row.
- **Component**: `DownloadTable.tsx` (`handleDownloadDoubleClick`)
- **Expected**: If completed, opens file (IPC `open_downloaded_file`). Otherwise, opens properties (`useDownloadStore.setSelectedPropertiesDownloadId`).
- **Row Context Menu (Right Click)**
- **Location**: Table row.
- **Component**: `DownloadTable.tsx`
- **Actions**:
- **Open**: IPC `open_downloaded_file` -> Rust `commands::open_downloaded_file`
- **Show in Finder**: IPC `reveal_in_file_manager` -> Rust `commands::reveal_in_file_manager`
- **Pause**: IPC `pause_download` -> Rust `commands::pause_download`
- **Resume**: `useDownloadStore.resumeDownload()` -> IPC `resume_download` -> Rust `commands::resume_download`
- **Redownload**: `useDownloadStore.redownload()` -> IPC `remove_download` -> Rust `commands::enqueue_download`
- **Copy Address**: `navigator.clipboard.writeText(url)`
- **Copy File Path**: `navigator.clipboard.writeText(fullPath)`
- **Remove**: `useDownloadStore.openDeleteModal()`
- **Properties**: `useDownloadStore.setSelectedPropertiesDownloadId()`
2. Some UI buttons were visually present but not wired.
- Add Download `Refresh Metadata` had no click handler.
## 3. Add Downloads Window
3. Some UI promises did not match the backend side effect.
- Settings global speed limit was labeled KiB/s, but bare numeric input was passed as bytes/s.
- Download Properties allowed editing speed for active transfers even though no backend command applied a per-download active speed change.
- **Input URLs**
- **Location**: Main textarea.
- **Component**: `AddDownloadsModal.tsx`
- **Expected**: Parses text, fetches metadata (IPC `fetch_metadata` or `fetchMediaMetadataDeduped`), and populates preview list.
- **Refresh Metadata**
- **Location**: "Refresh Metadata" button.
- **Component**: `AddDownloadsModal.tsx`
- **Expected**: Re-triggers metadata fetch for URLs.
- **Preview List Selection**
- **Location**: Preview items list.
- **Component**: `AddDownloadsModal.tsx` (`setSelectedItemIndex`)
- **Expected**: Updates the right-side form to reflect the selected item's specific options.
- **Media Format Selection**
- **Location**: Format list (if media).
- **Component**: `AddDownloadsModal.tsx` (`selectMediaFormat`)
- **Expected**: Updates the selected format, updating estimated sizes and file extensions.
- **Browse Save Location**
- **Location**: "Select" button next to save location.
- **Component**: `AddDownloadsModal.tsx` (`handleBrowse`)
- **Expected**: Opens Tauri native directory picker.
- **Options Toggles & Inputs**
- **Location**: Right-side pane.
- **Actions**: Change connections, toggle speed limit, set username/password, expand advanced, toggle checksum, edit headers/cookies/mirrors.
- **Expected**: Mutates local component state for the download configuration.
- **Start / Add to Queue**
- **Location**: Bottom right buttons.
- **Component**: `AddDownloadsModal.tsx` (`handleStart`)
- **Store Action**: `useDownloadStore.addDownload()`
- **IPC Command**: `enqueue_download` (called by store)
- **Rust**: `commands::enqueue_download`
- **Validation**: Checks for duplicates on disk (IPC `check_file_exists`) and in store, potentially opening `DuplicateResolutionModal`.
4. Duplicate-resolution semantics were too broad.
- The duplicate dialog offered `Replace` for URL duplicates, but replacement only makes sense for destination-file conflicts.
## 4. Download Properties Window
5. File-path actions used inconsistent destination resolution.
- `Copy File Path` bypassed the same effective destination logic used by Open/Reveal.
- **Browse Save Location**
- **Location**: "Select" button.
- **Component**: `PropertiesModal.tsx`
- **Expected**: Opens Tauri native directory picker.
- **Form Inputs**
- **Location**: Text inputs and selects.
- **Component**: `PropertiesModal.tsx`
- **Expected**: Adjusts URL, filename, connections, speed limits, auth (matches/custom/none), advanced settings. Disabled if the download is locked/active.
- **Save Changes**
- **Location**: "Save" button.
- **Component**: `PropertiesModal.tsx`
- **Store Action**: `useDownloadStore.updateDownload()`
- **Expected**: Validates inputs, saves to store, syncs to DB.
6. Clipboard actions lacked visible error handling.
- Copy failures were previously silent.
## 5. Settings Window
## Fixed
- **Tab Navigation**
- **Location**: Top horizontal tabs.
- **Store Action**: `useSettingsStore.setActiveSettingsTab()`
- **Downloads Settings**
- **Location**: "Downloads" tab.
- **Actions**: Change default connections, parallel downloads (IPC `set_concurrent_limit`), global speed limit (IPC `set_global_speed_limit`), max retries, notification toggles.
- **Look and feel**
- **Location**: "Look and feel" tab.
- **Actions**: Theme switch, font size, density, dock badge toggle (IPC `update_dock_badge`), menu bar icon (IPC `toggle_tray_icon`).
- **Network**
- **Location**: "Network" tab.
- **Actions**: Change proxy mode/host/port, custom user agent.
- **Locations**
- **Location**: "Locations" tab.
- **Actions**:
- Browse Default Path
- Toggle "Ask where to save"
- Browse "All Categories Base" -> Tauri Picker -> IPC `create_category_directories` -> Rust `commands::create_category_directories`
- Browse specific category paths.
- Reset Defaults: `useSettingsStore.resetCategoryDirectories()`
- **Site Logins**
- **Location**: "Site Logins" tab.
- **Actions**:
- Add Login: IPC `set_keychain_password` -> `useSettingsStore.addSiteLogin()`
- Delete Login (Trash icon): IPC `delete_keychain_password` -> `useSettingsStore.removeSiteLogin()`
- **Engine**
- **Location**: "Engine" tab (when mounted).
- **Component**: `SettingsView.tsx` (`runEngineChecks`)
- **IPC**: `get_aria2_engine_status`, `get_ytdlp_engine_status`, `get_ffmpeg_engine_status`, `get_deno_engine_status`.
- **Expected**: Polls backend for binary status and updates UI. Re-triggered with "Refresh" button.
### Main window
## 6. Other Modals & Actions
| Surface/action | Status after review | Notes |
| --- | --- | --- |
| Start/resume paused or failed download | Works | Store enqueues through `enqueue_download`; existing aria2 GID resume path is handled by backend. |
| Pause active/queued/retrying download | Works | Frontend calls `pause_download`; backend removes pending task and pauses/removes active work as appropriate. |
| Remove download | Works | Frontend calls `remove_download`, then removes local persisted item. |
| Delete file with remove | Works | Uses `trash_download_assets` with backend ownership/path authorization. |
| Redownload | Works | Creates a new queued item with preserved metadata and enqueues through queue manager. |
| Open downloaded file | Works | Uses owned-path `open_downloaded_file`. |
| Show in Finder | Works for completed files/partials | Uses owned-path `reveal_in_file_manager`; non-completed double-click/menu behavior opens properties. |
| Copy URL/address | Fixed | Now reports clipboard errors. |
| Copy file path | Fixed | Now uses the same effective path resolution as file actions and reports clipboard errors. |
| Queue up/down ordering | Fixed | Sends lowercase `up`/`down` to match Rust-generated `QueueDirection`. |
| Double-click completed download | Works | Opens file; otherwise opens Properties. |
| Status/progress/speed/ETA updates | Works | Store listener handles progress/state events; existing media-size finalization behavior is preserved. |
| Column resize/layout | Works | Local UI-only state; no backend side effect. |
### Add Download window
| Surface/action | Status after review | Notes |
| --- | --- | --- |
| URL/multiple URL input | Works | Lines are parsed and invalid URLs surface per-item error state. |
| Paste/deep-link/extension prefill | Works | App-level paste/deep-link/extension handlers open the modal with URLs. |
| Metadata detection | Works | HTTP metadata and media metadata go through IPC. |
| Refresh Metadata | Fixed | Button now re-runs the existing parser/metadata flow. |
| Media format selection | Works | Selected format selector/ext is applied before enqueue. |
| File name/category/destination detection | Works | Category-specific destination routing is preserved. |
| Destination folder selection | Works | Uses Tauri directory picker. |
| Auth/site-login credentials | Works | Keychain-backed site-login password lookup is preserved. |
| Add paused / start queued | Works | Adds local item and enqueues when start is requested. |
| Duplicate URL conflict | Fixed | `Replace` is no longer offered for URL-only duplicates. |
| Duplicate file conflict | Works | Rename/replace/skip flows remain available for file conflicts. |
| Cancel/close | Works | Modal state is reset through store. |
### Download Properties
| Surface/action | Status after review | Notes |
| --- | --- | --- |
| Opens for queued/downloading/paused/failed/completed | Works | Selected item is read live from store. |
| Live progress/speed/ETA/size summary | Works | Summary uses current store item, not stale initial-only form state. |
| Save editable queued/paused/failed settings | Works | Updates local persisted item used by resume/redownload/enqueue paths. |
| Completed download settings for redownload | Works | Identity remains read-only; transfer settings can be saved for redownload. |
| Active-transfer speed controls | Fixed | Controls are locked while transfer is active; copy now states that current backend options remain active until pause/stop. |
| Browse destination | Works when not locked | Uses directory picker. |
### Settings
| Tab/action | Status after review | Notes |
| --- | --- | --- |
| Downloads tab settings | Fixed/Works | Bare global speed-limit values now normalize as KiB/s for live backend calls and newly queued downloads. |
| Look and feel tab | Works | Theme, font size, row density, dock badge, menu bar icon persist and drive app effects. |
| Network tab | Works | Proxy mode/host/port/user-agent values are used for enqueue payloads. |
| Locations tab | Works | Default/category paths persist; bulk category creation calls backend. |
| Site Logins tab | Works | Add/remove uses settings plus keychain password storage/deletion. |
| Power tab | Works | Prevent-sleep setting drives backend keep-awake effect. |
| Engine tab diagnostics | Works | Recheck calls registered engine status commands and surfaces errors/details. |
| Integrations tab | Works | Token copy/regenerate and extension pairing token updates are wired; token remains keychain-backed. |
| About tab updates | Works | Check Now calls update check and surfaces result via toast. |
### Menus/context menus/dialog buttons
| Surface/action | Status after review | Notes |
| --- | --- | --- |
| Download row context menu | Fixed/Works | Copy actions now resolve correctly and surface errors; start/pause/remove/redownload/properties remain wired. |
| Sidebar queue context menu | Works | Start/pause/rename/remove queues are wired through store. |
| Delete confirmation dialog | Works | Remove-only and remove+trash paths preserved. |
| Duplicate resolution dialog | Fixed | Replacement is now file-conflict-only. |
### Backend IPC coverage
| Check | Status |
| --- | --- |
| Frontend IPC command names exist in Rust registration | Works |
| Queue direction argument casing | Fixed |
| Global speed-limit unit handling | Fixed in frontend and backend startup/live command handling |
| File open/reveal/trash path authorization | Preserved |
| Generic duplicate-check/delete path guards | Preserved; no security loosening introduced |
## Not fixed / follow-up
- Full packaged GUI/manual click-through was not launched in this pass; validation was code-trace plus `npm run build` and Rust tests.
- Existing generic `check_file_exists` / `delete_file` remain scoped by `is_safe_path`, but they are broader than ownership-based open/reveal/trash commands. I did not loosen them. A future hardening pass should replace duplicate-file replacement with an explicit user-selected-destination trash command or ownership-aware pre-registration flow.
- Per-download speed changes for already-running transfers are not implemented because the current backend has no active-transfer per-item speed-limit IPC. The UI now stops promising that behavior.
## Validation
- `npm run build`: pass
- `cd src-tauri && cargo test --quiet`: pass
- **Delete Confirmation Modal**
- **Location**: Prompt when removing a download.
- **Component**: `DeleteConfirmationModal.tsx`
- **Actions**:
- Remove from list: `useDownloadStore.removeDownload()` -> IPC `db_delete_download`
- Delete file & remove: `useDownloadStore.removeDownload()` + IPC `trash_download_assets` -> Rust `commands::trash_download_assets`
- **Duplicate Resolution Modal**
- **Location**: Triggered when adding a download that already exists.
- **Component**: `DuplicateResolutionModal.tsx`
- **Actions**: Select "Rename", "Replace", or "Skip".
- **Expected**: Modifies the batch of downloads added via `executeAddDownloads`.
- **Quality Modal**
- **Location**: Triggered when an active media download requires format resolution.
- **Component**: `QualityModal.tsx`
- **Expected**: Approves formats via local store or cancels metadata request.
- **Global Keyboard Shortcuts**
- **Location**: `App.tsx` (paste event listener)
- **Expected**: Pasting URLs anywhere outside inputs triggers `useDownloadStore.openAddModalWithUrls(text)`.
- **System Sleep / Power**
- **Location**: "Power" tab.
- **Expected**: Interacts with IPC `set_prevent_sleep` -> Rust `commands::set_prevent_sleep`.
## Likely Validation Methods
- **UI State**: Checking Zustand store (`useDownloadStore`, `useSettingsStore`) and React DevTools.
- **Rust Backend**: Monitoring standard output/stderr for `Invoke command` errors, and inspecting `src-tauri/src/commands.rs`.
- **File System**: Checking disk for queue persistence and newly created directories (e.g. `create_category_directories`).
+377 -1
View File
@@ -30,7 +30,8 @@
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "~5.8.3",
"vite": "^8.0.16"
"vite": "^8.0.16",
"vitest": "^4.1.9"
},
"engines": {
"node": ">=22"
@@ -405,6 +406,13 @@
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
@@ -971,6 +979,31 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -1017,6 +1050,129 @@
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.9",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"@vitest/utils": "4.1.9",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.9",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -1122,6 +1278,23 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1158,6 +1331,13 @@
"node": ">=10.13.0"
}
},
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1168,6 +1348,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1535,6 +1735,27 @@
"node": ">=18"
}
},
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1648,6 +1869,13 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1657,6 +1885,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -1676,6 +1918,23 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1692,6 +1951,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1821,6 +2090,113 @@
}
}
},
"node_modules/vitest": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.9",
"@vitest/mocker": "4.1.9",
"@vitest/pretty-format": "4.1.9",
"@vitest/runner": "4.1.9",
"@vitest/snapshot": "4.1.9",
"@vitest/spy": "4.1.9",
"@vitest/utils": "4.1.9",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.9",
"@vitest/browser-preview": "4.1.9",
"@vitest/browser-webdriverio": "4.1.9",
"@vitest/coverage-istanbul": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"@vitest/ui": "4.1.9",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
+4 -2
View File
@@ -31,7 +31,8 @@
"bindings": "cd src-tauri && cargo test export_bindings --lib",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.0",
@@ -55,6 +56,7 @@
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "~5.8.3",
"vite": "^8.0.16"
"vite": "^8.0.16",
"vitest": "^4.1.9"
}
}
+34
View File
@@ -25,6 +25,7 @@ const WRITE_BUFFER_CAPACITY: usize = 256 * 1024;
pub enum DownloadCmd {
Start(Box<DownloadPayload>),
Pause(Uuid),
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
Cancel(Uuid),
CaptureUrls(Vec<String>),
FrontendReady(bool),
@@ -116,6 +117,13 @@ impl DownloadCoordinator {
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn pause_media_with_ack(&self, id: String, ack: tokio::sync::oneshot::Sender<()>) -> Result<(), String> {
self.media_tx
.send(MediaCmd::PauseWithAck(id, ack))
.await
.map_err(|_| "download coordinator is unavailable".to_string())
}
pub async fn finish_media(&self, id: String) {
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
}
@@ -243,6 +251,7 @@ enum MediaCmd {
cancel_tx: watch::Sender<bool>,
},
Pause(String),
PauseWithAck(String, tokio::sync::oneshot::Sender<()>),
Finished(String),
}
@@ -281,6 +290,8 @@ async fn run_coordinator(
let (worker_tx, mut worker_rx) = mpsc::channel(128);
let mut active = HashMap::<Uuid, ActiveDownload>::new();
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
let mut pending_acks = HashMap::<Uuid, tokio::sync::oneshot::Sender<()>>::new();
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
let mut pending_captured_urls = Vec::<String>::new();
let mut frontend_ready = false;
let mut next_generation = 0_u64;
@@ -319,6 +330,14 @@ async fn run_coordinator(
let _ = download.control_tx.send(DownloadControl::Pause).await;
}
}
DownloadCmd::PauseWithAck(id, ack) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Pause).await;
pending_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
DownloadCmd::Cancel(id) => {
if let Some(download) = active.remove(&id) {
let _ = download.control_tx.send(DownloadControl::Cancel).await;
@@ -356,6 +375,10 @@ async fn run_coordinator(
active.remove(&id);
}
if let Some(ack) = pending_acks.remove(&id) {
let _ = ack.send(());
}
match (is_current, outcome) {
(true, DownloadOutcome::Completed) => {
events.emit_completed(id);
@@ -381,8 +404,19 @@ async fn run_coordinator(
let _ = cancel_tx.send(true);
}
}
MediaCmd::PauseWithAck(id, ack) => {
if let Some(cancel_tx) = active_media.remove(&id) {
let _ = cancel_tx.send(true);
pending_media_acks.insert(id, ack);
} else {
let _ = ack.send(());
}
}
MediaCmd::Finished(id) => {
active_media.remove(&id);
if let Some(ack) = pending_media_acks.remove(&id) {
let _ = ack.send(());
}
}
}
}
+27 -2
View File
@@ -268,6 +268,7 @@ async fn enqueue_extension_download(
is_media: Some(false),
media_format_selector: None,
queue_id: MAIN_QUEUE_ID.to_string(),
has_been_dispatched: Some(true),
};
let task = crate::queue::EnqueueItem {
id,
@@ -292,12 +293,36 @@ async fn enqueue_extension_download(
is_media: Some(false),
}
.into_task();
if let Err(e) = crate::download_ownership::register_expected(
app_handle,
&item.id,
item.destination.as_deref().unwrap_or(""),
&item.file_name,
) {
log::warn!("extension: ownership registration failed: {}", e);
continue;
}
created_items.push(item);
tasks.push(task);
}
state.queue_manager.enqueue_many(tasks).await;
let _ = app_handle.emit("extension-downloads-queued", created_items);
let results = state.queue_manager.enqueue_many(tasks).await;
let mut emitted_items = Vec::new();
for (item, result) in created_items.into_iter().zip(results) {
if result.success {
emitted_items.push(item);
} else {
let _ = crate::download_ownership::remove(app_handle, &item.id);
state.queue_manager.release_registered_id(&item.id).await;
}
}
if !emitted_items.is_empty() {
let _ = app_handle.emit("extension-downloads-queued", emitted_items);
}
Ok(())
}
+12
View File
@@ -95,6 +95,18 @@ pub struct DownloadItem {
#[ts(optional)]
pub media_format_selector: Option<String>,
pub queue_id: String,
#[ts(optional)]
pub has_been_dispatched: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct EnqueueResult {
pub id: String,
pub success: bool,
#[ts(optional)]
pub error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+87 -5
View File
@@ -2172,6 +2172,7 @@ async fn pause_download(
state.queue_manager.clear_aria2_retry_state(&id).await;
state.queue_manager.forget_aria2_gid(&id).await;
state.queue_manager.release_permit(&id).await;
state.queue_manager.release_registered_id(&id).await;
return Err(format!(
"cannot pause aria2 gid {gid} in terminal state {terminal}"
));
@@ -2214,11 +2215,13 @@ async fn resume_download(
) -> Result<bool, String> {
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
log::info!("aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id);
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
};
if gid.starts_with("native:") {
state.queue_manager.forget_aria2_gid(&id).await;
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
state.queue_manager.release_registered_id(&id).await;
return Ok(false);
}
@@ -2263,6 +2266,7 @@ async fn resume_download(
state.queue_manager.clear_aria2_retry_state(&id).await;
state.queue_manager.forget_aria2_gid(&id).await;
state.queue_manager.release_permit(&id).await;
state.queue_manager.release_registered_id(&id).await;
log::info!(
"aria2 resume [{}]: gid {} is {}; re-enqueue is permitted",
id,
@@ -2293,6 +2297,7 @@ async fn remove_download(
) -> Result<(), String> {
log::info!("remove_download called for id: {}", id);
let _ = crate::download_ownership::remove(&app_handle, &id);
state.queue_manager.release_registered_id(&id).await;
let active_kind = state.queue_manager.active_kind(&id).await;
state.queue_manager.remove_from_pending(&id).await;
@@ -2355,6 +2360,69 @@ async fn remove_download(
Ok(())
}
#[tauri::command]
async fn detach_download_for_reconfigure(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
id: String,
) -> Result<(), String> {
log::info!("detach_download_for_reconfigure called for id: {}", id);
let active_kind = state.queue_manager.active_kind(&id).await;
state.queue_manager.remove_from_pending(&id).await;
state.queue_manager.cancel_aria2_retries(&id).await;
let retry_add_guard = state.queue_manager.lock_aria2_retry_add().await;
let gid = state.queue_manager.aria2_gid_for_download(&id);
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
let removal_result = async {
rpc_call(
state.aria2_port,
&state.aria2_secret,
"aria2.forcePause",
serde_json::json!([gid]),
)
.await?;
wait_for_aria2_stopped(state.aria2_port, &state.aria2_secret, gid).await
}
.await;
if let Err(error) = removal_result {
state.queue_manager.allow_aria2_retries(&id).await;
return Err(error);
}
state.queue_manager.clear_aria2_retry_state(&id).await;
state.queue_manager.forget_aria2_gid(&id).await;
state.queue_manager.release_permit(&id).await;
state.queue_manager.release_registered_id(&id).await;
log::info!("aria2 detach [{}]: gid {} stopped and forgotten", id, gid);
} else {
drop(retry_add_guard);
let (tx, rx) = tokio::sync::oneshot::channel();
if matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
state.download_coordinator.pause_media_with_ack(id.clone(), tx).await?;
} else if let Ok(download_id) = Uuid::parse_str(&id) {
state.download_coordinator.send(crate::download::DownloadCmd::PauseWithAck(download_id, tx)).await?;
} else {
let _ = tx.send(()); // Fallback if no task exists
}
let _ = rx.await; // Wait for the writer to stop
if !matches!(active_kind, Some(crate::queue::TaskKind::Media)) {
state.queue_manager.release_permit(&id).await;
}
state.queue_manager.clear_aria2_retry_state(&id).await;
state.queue_manager.forget_aria2_gid(&id).await;
state.queue_manager.release_registered_id(&id).await;
}
use tauri::Emitter;
let _ = app_handle.emit(
"download-state",
crate::ipc::DownloadStateEvent::new(id.clone(), crate::ipc::DownloadStatus::Paused),
);
Ok(())
}
fn ensure_aria2_gid_result(
method: &str,
expected_gid: &str,
@@ -2506,7 +2574,11 @@ async fn enqueue_download(
&item.destination,
&item.filename,
)?;
state.queue_manager.push(item.into_task()).await;
if let Err(e) = state.queue_manager.push(item.into_task()).await {
let _ = crate::download_ownership::remove(&app_handle, &id);
state.queue_manager.release_registered_id(&id).await;
return Err(AppError::Internal(e));
}
Ok(id)
}
@@ -2515,7 +2587,7 @@ async fn enqueue_many(
app_handle: tauri::AppHandle,
state: tauri::State<'_, AppState>,
items: Vec<queue::EnqueueItem>,
) -> Result<(), AppError> {
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
for item in &items {
crate::download_ownership::register_expected(
&app_handle,
@@ -2525,8 +2597,16 @@ async fn enqueue_many(
)?;
}
let tasks = items.into_iter().map(queue::EnqueueItem::into_task).collect();
state.queue_manager.enqueue_many(tasks).await;
Ok(())
let results = state.queue_manager.enqueue_many(tasks).await;
for result in &results {
if !result.success {
let _ = crate::download_ownership::remove(&app_handle, &result.id);
state.queue_manager.release_registered_id(&result.id).await;
}
}
Ok(results)
}
#[tauri::command]
@@ -2546,7 +2626,8 @@ async fn remove_from_queue(
) -> Result<bool, AppError> {
let removed = state.queue_manager.remove_from_pending(&id).await;
if removed {
crate::download_ownership::remove(&app_handle, &id)?;
let _ = crate::download_ownership::remove(&app_handle, &id);
state.queue_manager.release_registered_id(&id).await;
}
Ok(removed)
}
@@ -3475,6 +3556,7 @@ pub fn run() {
set_keychain_password, get_keychain_password, delete_keychain_password,
check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token,
set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
detach_download_for_reconfigure,
enqueue_download, enqueue_many, move_in_queue, remove_from_queue, get_pending_order,
commands::reveal_in_file_manager, commands::open_downloaded_file, commands::trash_download_assets,
parity::get_system_proxy, parity::get_file_category, parity::check_for_updates, parity::is_supported_media, parity::get_supported_media_domains,
+45 -5
View File
@@ -86,6 +86,7 @@ pub trait SidecarSpawner: Send + Sync + 'static {
/// The centralized concurrency gatekeeper. One instance lives in AppState.
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
registered_ids: Mutex<HashSet<String>>,
pending: Mutex<VecDeque<QueuedTask>>,
semaphore: Arc<Semaphore>,
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
@@ -136,6 +137,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
spawner: Arc<dyn SidecarSpawner>,
) -> Self {
Self {
registered_ids: Mutex::new(HashSet::new()),
pending: Mutex::new(VecDeque::new()),
semaphore: Arc::new(Semaphore::new(capacity)),
active_permits: Mutex::new(HashMap::new()),
@@ -164,12 +166,25 @@ impl<R: tauri::Runtime> QueueManager<R> {
.collect()
}
/// Enqueue a task. Notifies the dispatcher. Emits download-state{queued}.
pub async fn push(&self, task: QueuedTask) {
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
pub async fn release_registered_id(&self, id: &str) {
self.registered_ids.lock().await.remove(id);
}
/// Enqueue a task. Checks the centralized `registered_ids` for deduplication.
pub async fn push(&self, task: QueuedTask) -> Result<(), String> {
let id = task.id.clone();
let mut registered = self.registered_ids.lock().await;
if registered.contains(&id) {
return Err("Duplicate task".to_string());
}
registered.insert(id.clone());
drop(registered);
self.pending.lock().await.push_back(task);
self.emit_state(id, DownloadStatus::Queued);
self.notify.notify_one();
Ok(())
}
/// Pop the next task, or None if empty.
@@ -391,16 +406,20 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
}
/// Called when a Media runner exits. Releases the permit and emits
/// Terminal handler for non-aria2 transfers. Emits state and frees the permit.
/// Does not emit or release anything on intentional MEDIA_RUN_CANCELLED.
/// Note: `id` is the frontend download UUID, which survives indefinitely as
/// the terminal state.
async fn finish_runner(self: Arc<Self>, id: &str, outcome: Result<(), String>) {
match outcome {
Ok(()) => {
self.emit_state(id, DownloadStatus::Completed);
self.release_registered_id(id).await;
}
Err(error) if error == MEDIA_RUN_CANCELLED => {}
Err(error) => {
self.emit_failed(id, error);
self.release_registered_id(id).await;
}
}
self.release_permit(id).await;
@@ -445,11 +464,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
self.emit_state(id, DownloadStatus::Completed);
self.release_registered_id(id).await;
}
PendingOutcome::Error(error) => {
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
self.emit_failed(id, error);
self.release_registered_id(id).await;
}
}
self.release_permit(id).await;
@@ -736,15 +757,34 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
/// Bulk enqueue by appending tasks. Used by startup and start-all.
pub async fn enqueue_many(&self, tasks: Vec<QueuedTask>) {
pub async fn enqueue_many(&self, tasks: Vec<QueuedTask>) -> Vec<crate::ipc::EnqueueResult> {
let mut results = Vec::new();
let mut registered = self.registered_ids.lock().await;
let mut pending = self.pending.lock().await;
for task in tasks {
let id = task.id.clone();
if registered.contains(&id) {
results.push(crate::ipc::EnqueueResult {
id: id.clone(),
success: false,
error: Some("Duplicate task".to_string()),
});
continue;
}
registered.insert(id.clone());
pending.push_back(task);
self.emit_state(id, DownloadStatus::Queued);
self.emit_state(id.clone(), DownloadStatus::Queued);
results.push(crate::ipc::EnqueueResult {
id,
success: true,
error: None,
});
}
drop(pending);
drop(registered);
self.notify.notify_one();
results
}
}
+5 -3
View File
@@ -5,8 +5,8 @@ import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal";
import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal";
import { QualityModal } from './components/QualityModal';
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
import { extractValidDownloadUrls } from './utils/url';
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { initDownloadListener } from './store/downloadStore';
@@ -165,7 +165,10 @@ function App() {
}
const text = e.clipboardData?.getData('text/plain');
if (text && text.trim().length > 0) {
useDownloadStore.getState().openAddModalWithUrls(text.trim());
const urls = extractValidDownloadUrls(text);
if (urls.length > 0) {
useDownloadStore.getState().openAddModalWithUrls(urls.join('\n'));
}
}
};
window.addEventListener('paste', handlePaste);
@@ -310,7 +313,6 @@ function App() {
<AddDownloadsModal />
<PropertiesModal />
<QualityModal />
<DeleteConfirmationModal />
</div>
);
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, hasBeenDispatched?: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueResult = { id: string, success: boolean, error?: string, };
+13 -8
View File
@@ -9,7 +9,6 @@ type LoginMode = 'matching' | 'custom' | 'none';
export const PropertiesModal = () => {
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
const updateDownload = useDownloadStore(state => state.updateDownload);
const item = useDownloadStore(state =>
selectedPropertiesDownloadId
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
@@ -106,7 +105,7 @@ export const PropertiesModal = () => {
}
};
const handleSave = () => {
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage("Enter a valid URL.");
return;
@@ -130,17 +129,22 @@ export const PropertiesModal = () => {
mirrors: mirrors.trim() || undefined,
};
updateDownload(item.id, updates);
setSelectedPropertiesDownloadId(null);
try {
setErrorMessage('');
await useDownloadStore.getState().applyProperties(item.id, updates);
setSelectedPropertiesDownloadId(null);
} catch (e) {
setErrorMessage(e instanceof Error ? e.message : String(e));
}
};
const isLocked = ['downloading', 'processing', 'completed'].includes(item.status);
const isTransferLocked = item.status === 'downloading' || item.status === 'processing';
const isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status);
const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying';
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
else if (item.status === 'downloading') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'downloading' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
@@ -339,7 +343,8 @@ export const PropertiesModal = () => {
</button>
<button
onClick={handleSave}
className="app-button app-button-primary px-4 text-xs"
disabled={isTransferLocked}
className={`app-button app-button-primary px-4 text-xs ${isTransferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
Save
+2 -4
View File
@@ -73,6 +73,7 @@ type CommandMap = {
pause_download: { args: { id: string }; result: void };
resume_download: { args: { id: string }; result: boolean };
remove_download: { args: { id: string; filepath: string | null }; result: void };
detach_download_for_reconfigure: { args: { id: string }; result: void };
update_dock_badge: { args: { count: number }; result: void };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
perform_system_action: { args: { action: PostQueueAction }; result: void };
@@ -101,14 +102,11 @@ type CommandMap = {
result: void;
};
db_delete_download: { args: { id: string }; result: void };
db_get_all_queues: { args: undefined; result: string[] };
db_save_queue: { args: { id: string; data: string }; result: void };
db_delete_queue: { args: { id: string }; result: void };
create_category_directories: { args: { paths: string[] }; result: void };
export_logs: { args: { destPath: string }; result: string };
get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: any }; result: string };
enqueue_many: { args: { items: any[] }; result: void };
enqueue_many: { args: { items: any[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
};
+6
View File
@@ -66,6 +66,12 @@ export async function initDownloadListener() {
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
}
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
}
}
});
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useDownloadStore } from './useDownloadStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn(),
}));
// Mock window.__TAURI_INTERNALS__ and log to prevent errors
vi.mock('@tauri-apps/plugin-log', () => ({
info: vi.fn(),
error: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-store', () => {
return {
LazyStore: class {
get = vi.fn().mockResolvedValue([]);
set = vi.fn();
save = vi.fn();
}
};
});
vi.mock('./useSettingsStore', () => ({
useSettingsStore: {
getState: vi.fn(() => ({
proxyMode: 'none',
siteLogins: [],
globalSpeedLimit: '',
perServerConnections: 16,
customUserAgent: '',
maxAutomaticRetries: 3,
mediaCookieSource: 'none',
})),
}
}));
describe('useDownloadStore', () => {
beforeEach(() => {
vi.clearAllMocks();
useDownloadStore.setState({
downloads: [],
backendRegisteredIds: new Set(),
pendingOrder: [],
});
});
it('Start Queue dispatches exactly once for mixed dispatched/undispatched items', async () => {
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
{ id: '2', url: 'http://test2', fileName: 'f2', status: 'queued', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
backendRegisteredIds: new Set(['1']), // 1 is already registered, so it skips dispatch
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['1', '2'];
return undefined;
});
const dispatched = await useDownloadStore.getState().startQueue('MAIN');
expect(dispatched).toBe(2); // Both items counted as dispatched/handled
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
const enqueues = calls.filter(c => c[0] === 'enqueue_download');
expect(enqueues.length).toBe(1);
expect((enqueues[0] as any)[1].item.id).toBe('2');
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', status: 'paused', category: 'General', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: true },
] as any[],
backendRegisteredIds: new Set(['1']),
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'resume_download') return false; // Not resumable
if (cmd === 'get_pending_order') return ['1'];
return undefined;
});
await useDownloadStore.getState().resumeDownload('1');
// It should have called resume_download, then unregistered, then enqueue_download
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
expect(calls.some(c => c[0] === 'resume_download')).toBe(true);
expect(calls.some(c => c[0] === 'enqueue_download')).toBe(true);
expect(useDownloadStore.getState().backendRegisteredIds.has('1')).toBe(true); // Re-registered by dispatchItem
});
});
+158 -174
View File
@@ -16,6 +16,66 @@ import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
export type { DownloadCategory } from '../utils/downloads';
const backendDispatchPromises = new Map<string, Promise<boolean>>();
export async function dispatchItem(id: string): Promise<boolean> {
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
const promise = (async () => {
try {
const state = useDownloadStore.getState();
const item = state.downloads.find(d => d.id === id);
if (!item) return false;
if (state.backendRegisteredIds.has(id)) return true;
const settings = useSettingsStore.getState();
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {}
}
const enqueueItem = {
id: item.id,
url: item.url,
destination: item.destination,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
useDownloadStore.getState().setPendingOrder(order);
useDownloadStore.getState().registerBackendIds([id]);
return true;
} catch (e) {
console.error(`Failed to dispatch ${id}:`, e);
useDownloadStore.getState().updateDownload(id, { status: 'failed' });
return false;
} finally {
backendDispatchPromises.delete(id);
}
})();
backendDispatchPromises.set(id, promise);
return promise;
}
const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getState>) => {
if (settings.proxyMode === 'system') {
try {
@@ -98,6 +158,12 @@ interface DownloadState {
queues: Queue[];
pendingOrder: string[];
setPendingOrder: (order: string[]) => void;
backendRegisteredIds: Set<string>;
registerBackendIds: (ids: string[]) => void;
unregisterBackendIds: (ids: string[]) => void;
activeDownloadId: string | null;
setActiveDownloadId: (id: string | null) => void;
applyProperties: (id: string, updates: Partial<DownloadItem>) => Promise<void>;
moveInQueue: (id: string, direction: 'up' | 'down') => Promise<void>;
removeFromQueue: (id: string) => Promise<void>;
isAddModalOpen: boolean;
@@ -137,6 +203,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }),
activeDownloadId: null,
setActiveDownloadId: (id) => set({ activeDownloadId: id }),
moveInQueue: async (id, direction) => {
try {
const order = await invoke('move_in_queue', { id, direction });
@@ -155,6 +223,17 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
console.error("Failed to remove item from queue:", e);
}
},
backendRegisteredIds: new Set(),
registerBackendIds: (ids) => set((state) => {
const nextSet = new Set(state.backendRegisteredIds);
for (const id of ids) nextSet.add(id);
return { backendRegisteredIds: nextSet };
}),
unregisterBackendIds: (ids) => set((state) => {
const nextSet = new Set(state.backendRegisteredIds);
for (const id of ids) nextSet.delete(id);
return { backendRegisteredIds: nextSet };
}),
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
@@ -214,48 +293,62 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
addDownload: async (item) => {
info(`Download ${item.id} added to queue`);
try {
const settings = useSettingsStore.getState();
const destPath = effectiveDestinationForItem(item, settings);
const ownedItem = { ...item, destination: destPath };
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
const settings = useSettingsStore.getState();
const destPath = effectiveDestinationForItem(item, settings);
const ownedItem = { ...item, destination: destPath, hasBeenDispatched: false };
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
if (item.status === 'queued') {
const order = useDownloadStore.getState().pendingOrder;
if (!order.includes(item.id)) {
useDownloadStore.getState().setPendingOrder([...order, item.id]);
}
} else {
// Immediate dispatch (e.g., "Start immediately" from UI where status isn't just 'queued')
if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true });
}
}
},
applyProperties: async (id, updates) => {
const state = get();
const item = state.downloads.find(d => d.id === id);
if (!item) return;
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') {
throw new Error("Cannot change properties while transfer is active. Pause it first.");
}
if (item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates);
return;
}
// Queued or Paused
const isRegistered = state.backendRegisteredIds.has(id);
if (item.status === 'queued') {
if (isRegistered) {
await invoke('remove_from_queue', { id });
state.unregisterBackendIds([id]);
}
state.updateDownload(id, updates);
if (isRegistered) {
if (!await dispatchItem(id)) {
state.removeFromQueue(id);
}
}
const enqueueItem = {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to enqueue download:", e);
get().updateDownload(item.id, { status: 'failed' });
} else if (item.status === 'paused') {
if (isRegistered) {
try {
await invoke('detach_download_for_reconfigure', { id });
} catch (e) {
console.error("Failed to detach for reconfigure:", e);
throw e; // Preserve old properties if detach fails
}
state.unregisterBackendIds([id]);
}
state.updateDownload(id, updates);
}
},
updateDownload: (id, updates) => {
@@ -341,16 +434,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error('Cannot redownload: destination folder is missing.');
}
const login = getSiteLogin(url, settings);
let keychainPassword: string | null = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const redownloadItem: DownloadItem = {
id: crypto.randomUUID(),
url,
@@ -376,35 +459,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: [...state.downloads, redownloadItem]
}));
try {
const enqueueItem = {
id: redownloadItem.id,
url,
destination: destPath,
filename,
connections: targetItem.connections || settings.perServerConnections || null,
speed_limit: targetItem.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: targetItem.username || (login ? login.username : null),
password: targetItem.password || keychainPassword,
headers: targetItem.headers || null,
checksum: targetItem.checksum || null,
cookies: targetItem.cookies || null,
mirrors: targetItem.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: targetItem.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
if (!await dispatchItem(redownloadItem.id)) {
console.error("Failed to enqueue redownload");
} else {
info(`Download ${id} redownload requested as ${redownloadItem.id} (queued)`);
} catch (e) {
console.error("Failed to enqueue redownload:", e);
get().updateDownload(redownloadItem.id, { status: 'failed' });
throw e instanceof Error ? e : new Error(String(e));
}
},
resumeDownload: async (id) => {
@@ -417,6 +475,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
return;
}
get().unregisterBackendIds([id]);
set((state) => ({
downloads: state.downloads.map(d => {
if (d.id === id) {
@@ -426,45 +486,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
})
}));
const settings = useSettingsStore.getState();
const login = getSiteLogin(targetItem.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
if (!await dispatchItem(id)) {
console.error("Failed to re-enqueue for resume");
}
const destPath = targetItem.destination ||
(settings.downloadDirectories && settings.downloadDirectories[targetItem.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
const enqueueItem = {
id: targetItem.id,
url: targetItem.url,
destination: destPath,
filename: targetItem.fileName,
connections: targetItem.connections || settings.perServerConnections || null,
speed_limit: targetItem.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: targetItem.username || (login ? login.username : null),
password: targetItem.password || keychainPassword,
headers: targetItem.headers || null,
checksum: targetItem.checksum || null,
cookies: targetItem.cookies || null,
mirrors: targetItem.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: targetItem.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: targetItem.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to enqueue resume:", e);
console.error("Failed to resume download:", e);
}
},
startQueue: async (queueId) => {
@@ -473,69 +499,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (runnable.length === 0) return 0;
const paused = runnable.filter(item => item.status === 'paused');
const toEnqueue = runnable.filter(item => item.status !== 'paused');
set((state) => ({
downloads: state.downloads.map(item =>
toEnqueue.some(r => r.id === item.id)
? { ...item, status: 'queued', speed: '-', eta: '-' }
: item
)
}));
try {
await Promise.all(paused.map(item => get().resumeDownload(item.id)));
const settings = useSettingsStore.getState();
const itemsToEnqueue = [];
for (const item of toEnqueue) {
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
let dispatchedCount = 0;
const promises = runnable.map(async (item) => {
if (item.status === 'failed' || !item.hasBeenDispatched) {
if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
dispatchedCount++;
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
itemsToEnqueue.push({
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false
});
} else if (item.status === 'paused' || item.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (item.status === 'paused') {
await get().resumeDownload(item.id);
}
dispatchedCount++;
}
if (itemsToEnqueue.length > 0) {
await invoke('enqueue_many', { items: itemsToEnqueue });
}
const order = await invoke('get_pending_order');
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to start queue:", e);
}
});
info(`Queue ${queueId} started, ${runnable.length} items queued`);
return runnable.length;
await Promise.all(promises);
info(`Queue ${queueId} started, ${dispatchedCount} items dispatched/resumed`);
return dispatchedCount;
},
pauseQueue: async (queueId) => {
const activeIds = get().downloads
+24
View File
@@ -0,0 +1,24 @@
export function extractValidDownloadUrls(text: string): string[] {
const lines = text.split('\n');
const urls: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Split by whitespace in case multiple URLs are on one line
const parts = trimmed.split(/\s+/);
for (const part of parts) {
try {
const url = new URL(part);
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:') {
urls.push(url.toString());
}
} catch (e) {
// Not a valid URL
}
}
}
return [...new Set(urls)];
}