mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
+166
-107
@@ -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`).
|
||||
|
||||
Generated
+377
-1
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
Reference in New Issue
Block a user