mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +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.
|
- **Table Column Resize**
|
||||||
- Queue reordering sent `Up`/`Down`, but Rust deserializes `QueueDirection` as lowercase `up`/`down`.
|
- **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.
|
## 3. Add Downloads Window
|
||||||
- Add Download `Refresh Metadata` had no click handler.
|
|
||||||
|
|
||||||
3. Some UI promises did not match the backend side effect.
|
- **Input URLs**
|
||||||
- Settings global speed limit was labeled KiB/s, but bare numeric input was passed as bytes/s.
|
- **Location**: Main textarea.
|
||||||
- Download Properties allowed editing speed for active transfers even though no backend command applied a per-download active speed change.
|
- **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.
|
## 4. Download Properties Window
|
||||||
- The duplicate dialog offered `Replace` for URL duplicates, but replacement only makes sense for destination-file conflicts.
|
|
||||||
|
|
||||||
5. File-path actions used inconsistent destination resolution.
|
- **Browse Save Location**
|
||||||
- `Copy File Path` bypassed the same effective destination logic used by Open/Reveal.
|
- **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.
|
## 5. Settings Window
|
||||||
- Copy failures were previously silent.
|
|
||||||
|
|
||||||
## 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 |
|
- **Delete Confirmation Modal**
|
||||||
| --- | --- | --- |
|
- **Location**: Prompt when removing a download.
|
||||||
| Start/resume paused or failed download | Works | Store enqueues through `enqueue_download`; existing aria2 GID resume path is handled by backend. |
|
- **Component**: `DeleteConfirmationModal.tsx`
|
||||||
| Pause active/queued/retrying download | Works | Frontend calls `pause_download`; backend removes pending task and pauses/removes active work as appropriate. |
|
- **Actions**:
|
||||||
| Remove download | Works | Frontend calls `remove_download`, then removes local persisted item. |
|
- Remove from list: `useDownloadStore.removeDownload()` -> IPC `db_delete_download`
|
||||||
| Delete file with remove | Works | Uses `trash_download_assets` with backend ownership/path authorization. |
|
- Delete file & remove: `useDownloadStore.removeDownload()` + IPC `trash_download_assets` -> Rust `commands::trash_download_assets`
|
||||||
| Redownload | Works | Creates a new queued item with preserved metadata and enqueues through queue manager. |
|
- **Duplicate Resolution Modal**
|
||||||
| Open downloaded file | Works | Uses owned-path `open_downloaded_file`. |
|
- **Location**: Triggered when adding a download that already exists.
|
||||||
| Show in Finder | Works for completed files/partials | Uses owned-path `reveal_in_file_manager`; non-completed double-click/menu behavior opens properties. |
|
- **Component**: `DuplicateResolutionModal.tsx`
|
||||||
| Copy URL/address | Fixed | Now reports clipboard errors. |
|
- **Actions**: Select "Rename", "Replace", or "Skip".
|
||||||
| Copy file path | Fixed | Now uses the same effective path resolution as file actions and reports clipboard errors. |
|
- **Expected**: Modifies the batch of downloads added via `executeAddDownloads`.
|
||||||
| Queue up/down ordering | Fixed | Sends lowercase `up`/`down` to match Rust-generated `QueueDirection`. |
|
- **Quality Modal**
|
||||||
| Double-click completed download | Works | Opens file; otherwise opens Properties. |
|
- **Location**: Triggered when an active media download requires format resolution.
|
||||||
| Status/progress/speed/ETA updates | Works | Store listener handles progress/state events; existing media-size finalization behavior is preserved. |
|
- **Component**: `QualityModal.tsx`
|
||||||
| Column resize/layout | Works | Local UI-only state; no backend side effect. |
|
- **Expected**: Approves formats via local store or cancels metadata request.
|
||||||
|
- **Global Keyboard Shortcuts**
|
||||||
### Add Download window
|
- **Location**: `App.tsx` (paste event listener)
|
||||||
|
- **Expected**: Pasting URLs anywhere outside inputs triggers `useDownloadStore.openAddModalWithUrls(text)`.
|
||||||
| Surface/action | Status after review | Notes |
|
- **System Sleep / Power**
|
||||||
| --- | --- | --- |
|
- **Location**: "Power" tab.
|
||||||
| URL/multiple URL input | Works | Lines are parsed and invalid URLs surface per-item error state. |
|
- **Expected**: Interacts with IPC `set_prevent_sleep` -> Rust `commands::set_prevent_sleep`.
|
||||||
| 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
|
|
||||||
|
|
||||||
|
## 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",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
@@ -405,6 +406,13 @@
|
|||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||||
@@ -971,6 +979,31 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||||
@@ -1122,6 +1278,23 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1158,6 +1331,13 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1168,6 +1348,26 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1535,6 +1735,27 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"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==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1657,6 +1885,20 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||||
@@ -1676,6 +1918,23 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -1692,6 +1951,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.14",
|
"version": "5.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
"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",
|
"bindings": "cd src-tauri && cargo test export_bindings --lib",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~5.8.3",
|
"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 {
|
pub enum DownloadCmd {
|
||||||
Start(Box<DownloadPayload>),
|
Start(Box<DownloadPayload>),
|
||||||
Pause(Uuid),
|
Pause(Uuid),
|
||||||
|
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
|
||||||
Cancel(Uuid),
|
Cancel(Uuid),
|
||||||
CaptureUrls(Vec<String>),
|
CaptureUrls(Vec<String>),
|
||||||
FrontendReady(bool),
|
FrontendReady(bool),
|
||||||
@@ -116,6 +117,13 @@ impl DownloadCoordinator {
|
|||||||
.map_err(|_| "download coordinator is unavailable".to_string())
|
.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) {
|
pub async fn finish_media(&self, id: String) {
|
||||||
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
|
let _ = self.media_tx.send(MediaCmd::Finished(id)).await;
|
||||||
}
|
}
|
||||||
@@ -243,6 +251,7 @@ enum MediaCmd {
|
|||||||
cancel_tx: watch::Sender<bool>,
|
cancel_tx: watch::Sender<bool>,
|
||||||
},
|
},
|
||||||
Pause(String),
|
Pause(String),
|
||||||
|
PauseWithAck(String, tokio::sync::oneshot::Sender<()>),
|
||||||
Finished(String),
|
Finished(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +290,8 @@ async fn run_coordinator(
|
|||||||
let (worker_tx, mut worker_rx) = mpsc::channel(128);
|
let (worker_tx, mut worker_rx) = mpsc::channel(128);
|
||||||
let mut active = HashMap::<Uuid, ActiveDownload>::new();
|
let mut active = HashMap::<Uuid, ActiveDownload>::new();
|
||||||
let mut active_media = HashMap::<String, watch::Sender<bool>>::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 pending_captured_urls = Vec::<String>::new();
|
||||||
let mut frontend_ready = false;
|
let mut frontend_ready = false;
|
||||||
let mut next_generation = 0_u64;
|
let mut next_generation = 0_u64;
|
||||||
@@ -319,6 +330,14 @@ async fn run_coordinator(
|
|||||||
let _ = download.control_tx.send(DownloadControl::Pause).await;
|
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) => {
|
DownloadCmd::Cancel(id) => {
|
||||||
if let Some(download) = active.remove(&id) {
|
if let Some(download) = active.remove(&id) {
|
||||||
let _ = download.control_tx.send(DownloadControl::Cancel).await;
|
let _ = download.control_tx.send(DownloadControl::Cancel).await;
|
||||||
@@ -356,6 +375,10 @@ async fn run_coordinator(
|
|||||||
active.remove(&id);
|
active.remove(&id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ack) = pending_acks.remove(&id) {
|
||||||
|
let _ = ack.send(());
|
||||||
|
}
|
||||||
|
|
||||||
match (is_current, outcome) {
|
match (is_current, outcome) {
|
||||||
(true, DownloadOutcome::Completed) => {
|
(true, DownloadOutcome::Completed) => {
|
||||||
events.emit_completed(id);
|
events.emit_completed(id);
|
||||||
@@ -381,8 +404,19 @@ async fn run_coordinator(
|
|||||||
let _ = cancel_tx.send(true);
|
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) => {
|
MediaCmd::Finished(id) => {
|
||||||
active_media.remove(&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),
|
is_media: Some(false),
|
||||||
media_format_selector: None,
|
media_format_selector: None,
|
||||||
queue_id: MAIN_QUEUE_ID.to_string(),
|
queue_id: MAIN_QUEUE_ID.to_string(),
|
||||||
|
has_been_dispatched: Some(true),
|
||||||
};
|
};
|
||||||
let task = crate::queue::EnqueueItem {
|
let task = crate::queue::EnqueueItem {
|
||||||
id,
|
id,
|
||||||
@@ -292,12 +293,36 @@ async fn enqueue_extension_download(
|
|||||||
is_media: Some(false),
|
is_media: Some(false),
|
||||||
}
|
}
|
||||||
.into_task();
|
.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);
|
created_items.push(item);
|
||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.queue_manager.enqueue_many(tasks).await;
|
let results = state.queue_manager.enqueue_many(tasks).await;
|
||||||
let _ = app_handle.emit("extension-downloads-queued", created_items);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ pub struct DownloadItem {
|
|||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub media_format_selector: Option<String>,
|
pub media_format_selector: Option<String>,
|
||||||
pub queue_id: 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)]
|
#[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.clear_aria2_retry_state(&id).await;
|
||||||
state.queue_manager.forget_aria2_gid(&id).await;
|
state.queue_manager.forget_aria2_gid(&id).await;
|
||||||
state.queue_manager.release_permit(&id).await;
|
state.queue_manager.release_permit(&id).await;
|
||||||
|
state.queue_manager.release_registered_id(&id).await;
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"cannot pause aria2 gid {gid} in terminal state {terminal}"
|
"cannot pause aria2 gid {gid} in terminal state {terminal}"
|
||||||
));
|
));
|
||||||
@@ -2214,11 +2215,13 @@ async fn resume_download(
|
|||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
|
let Some(gid) = state.queue_manager.aria2_gid_for_download(&id) else {
|
||||||
log::info!("aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id);
|
log::info!("aria2 resume [{}]: no mapped gid; re-enqueue is permitted", id);
|
||||||
|
state.queue_manager.release_registered_id(&id).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if gid.starts_with("native:") {
|
if gid.starts_with("native:") {
|
||||||
state.queue_manager.forget_aria2_gid(&id).await;
|
state.queue_manager.forget_aria2_gid(&id).await;
|
||||||
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
|
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
|
||||||
|
state.queue_manager.release_registered_id(&id).await;
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2263,6 +2266,7 @@ async fn resume_download(
|
|||||||
state.queue_manager.clear_aria2_retry_state(&id).await;
|
state.queue_manager.clear_aria2_retry_state(&id).await;
|
||||||
state.queue_manager.forget_aria2_gid(&id).await;
|
state.queue_manager.forget_aria2_gid(&id).await;
|
||||||
state.queue_manager.release_permit(&id).await;
|
state.queue_manager.release_permit(&id).await;
|
||||||
|
state.queue_manager.release_registered_id(&id).await;
|
||||||
log::info!(
|
log::info!(
|
||||||
"aria2 resume [{}]: gid {} is {}; re-enqueue is permitted",
|
"aria2 resume [{}]: gid {} is {}; re-enqueue is permitted",
|
||||||
id,
|
id,
|
||||||
@@ -2293,6 +2297,7 @@ async fn remove_download(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("remove_download called for id: {}", id);
|
log::info!("remove_download called for id: {}", id);
|
||||||
let _ = crate::download_ownership::remove(&app_handle, &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;
|
let active_kind = state.queue_manager.active_kind(&id).await;
|
||||||
state.queue_manager.remove_from_pending(&id).await;
|
state.queue_manager.remove_from_pending(&id).await;
|
||||||
@@ -2355,6 +2360,69 @@ async fn remove_download(
|
|||||||
Ok(())
|
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(
|
fn ensure_aria2_gid_result(
|
||||||
method: &str,
|
method: &str,
|
||||||
expected_gid: &str,
|
expected_gid: &str,
|
||||||
@@ -2506,7 +2574,11 @@ async fn enqueue_download(
|
|||||||
&item.destination,
|
&item.destination,
|
||||||
&item.filename,
|
&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)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2515,7 +2587,7 @@ async fn enqueue_many(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
items: Vec<queue::EnqueueItem>,
|
items: Vec<queue::EnqueueItem>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<Vec<crate::ipc::EnqueueResult>, AppError> {
|
||||||
for item in &items {
|
for item in &items {
|
||||||
crate::download_ownership::register_expected(
|
crate::download_ownership::register_expected(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
@@ -2525,8 +2597,16 @@ async fn enqueue_many(
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
let tasks = items.into_iter().map(queue::EnqueueItem::into_task).collect();
|
let tasks = items.into_iter().map(queue::EnqueueItem::into_task).collect();
|
||||||
state.queue_manager.enqueue_many(tasks).await;
|
let results = state.queue_manager.enqueue_many(tasks).await;
|
||||||
Ok(())
|
|
||||||
|
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]
|
#[tauri::command]
|
||||||
@@ -2546,7 +2626,8 @@ async fn remove_from_queue(
|
|||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let removed = state.queue_manager.remove_from_pending(&id).await;
|
let removed = state.queue_manager.remove_from_pending(&id).await;
|
||||||
if removed {
|
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)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
@@ -3475,6 +3556,7 @@ pub fn run() {
|
|||||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
set_keychain_password, get_keychain_password, delete_keychain_password,
|
||||||
check_file_exists, delete_file, toggle_tray_icon, set_extension_pairing_token,
|
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,
|
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,
|
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,
|
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,
|
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.
|
/// The centralized concurrency gatekeeper. One instance lives in AppState.
|
||||||
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||||
|
registered_ids: Mutex<HashSet<String>>,
|
||||||
pending: Mutex<VecDeque<QueuedTask>>,
|
pending: Mutex<VecDeque<QueuedTask>>,
|
||||||
semaphore: Arc<Semaphore>,
|
semaphore: Arc<Semaphore>,
|
||||||
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
active_permits: Mutex<HashMap<String, OwnedSemaphorePermit>>,
|
||||||
@@ -136,6 +137,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
spawner: Arc<dyn SidecarSpawner>,
|
spawner: Arc<dyn SidecarSpawner>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
registered_ids: Mutex::new(HashSet::new()),
|
||||||
pending: Mutex::new(VecDeque::new()),
|
pending: Mutex::new(VecDeque::new()),
|
||||||
semaphore: Arc::new(Semaphore::new(capacity)),
|
semaphore: Arc::new(Semaphore::new(capacity)),
|
||||||
active_permits: Mutex::new(HashMap::new()),
|
active_permits: Mutex::new(HashMap::new()),
|
||||||
@@ -164,12 +166,25 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enqueue a task. Notifies the dispatcher. Emits download-state{queued}.
|
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
|
||||||
pub async fn push(&self, task: QueuedTask) {
|
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 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.pending.lock().await.push_back(task);
|
||||||
self.emit_state(id, DownloadStatus::Queued);
|
self.emit_state(id, DownloadStatus::Queued);
|
||||||
self.notify.notify_one();
|
self.notify.notify_one();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop the next task, or None if empty.
|
/// 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.
|
/// the terminal state.
|
||||||
async fn finish_runner(self: Arc<Self>, id: &str, outcome: Result<(), String>) {
|
async fn finish_runner(self: Arc<Self>, id: &str, outcome: Result<(), String>) {
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.emit_state(id, DownloadStatus::Completed);
|
self.emit_state(id, DownloadStatus::Completed);
|
||||||
|
self.release_registered_id(id).await;
|
||||||
}
|
}
|
||||||
Err(error) if error == MEDIA_RUN_CANCELLED => {}
|
Err(error) if error == MEDIA_RUN_CANCELLED => {}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.emit_failed(id, error);
|
self.emit_failed(id, error);
|
||||||
|
self.release_registered_id(id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.release_permit(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.clear_aria2_retry_state(id).await;
|
||||||
self.forget_aria2_gid(id).await;
|
self.forget_aria2_gid(id).await;
|
||||||
self.emit_state(id, DownloadStatus::Completed);
|
self.emit_state(id, DownloadStatus::Completed);
|
||||||
|
self.release_registered_id(id).await;
|
||||||
}
|
}
|
||||||
PendingOutcome::Error(error) => {
|
PendingOutcome::Error(error) => {
|
||||||
self.clear_aria2_retry_state(id).await;
|
self.clear_aria2_retry_state(id).await;
|
||||||
self.forget_aria2_gid(id).await;
|
self.forget_aria2_gid(id).await;
|
||||||
self.emit_failed(id, error);
|
self.emit_failed(id, error);
|
||||||
|
self.release_registered_id(id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.release_permit(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.
|
/// 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;
|
let mut pending = self.pending.lock().await;
|
||||||
|
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
let id = task.id.clone();
|
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);
|
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(pending);
|
||||||
|
drop(registered);
|
||||||
self.notify.notify_one();
|
self.notify.notify_one();
|
||||||
|
results
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -5,8 +5,8 @@ import { DownloadTable } from "./components/DownloadTable";
|
|||||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||||
import SettingsView from "./components/SettingsView";
|
import SettingsView from "./components/SettingsView";
|
||||||
import { PropertiesModal } from "./components/PropertiesModal";
|
import { PropertiesModal } from "./components/PropertiesModal";
|
||||||
import { QualityModal } from './components/QualityModal';
|
|
||||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||||
|
import { extractValidDownloadUrls } from './utils/url';
|
||||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||||
import { initDownloadListener } from './store/downloadStore';
|
import { initDownloadListener } from './store/downloadStore';
|
||||||
@@ -165,7 +165,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
const text = e.clipboardData?.getData('text/plain');
|
const text = e.clipboardData?.getData('text/plain');
|
||||||
if (text && text.trim().length > 0) {
|
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);
|
window.addEventListener('paste', handlePaste);
|
||||||
@@ -310,7 +313,6 @@ function App() {
|
|||||||
|
|
||||||
<AddDownloadsModal />
|
<AddDownloadsModal />
|
||||||
<PropertiesModal />
|
<PropertiesModal />
|
||||||
<QualityModal />
|
|
||||||
<DeleteConfirmationModal />
|
<DeleteConfirmationModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
import type { DownloadCategory } from "./DownloadCategory";
|
import type { DownloadCategory } from "./DownloadCategory";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
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 = () => {
|
export const PropertiesModal = () => {
|
||||||
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
const selectedPropertiesDownloadId = useDownloadStore(state => state.selectedPropertiesDownloadId);
|
||||||
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
const setSelectedPropertiesDownloadId = useDownloadStore(state => state.setSelectedPropertiesDownloadId);
|
||||||
const updateDownload = useDownloadStore(state => state.updateDownload);
|
|
||||||
const item = useDownloadStore(state =>
|
const item = useDownloadStore(state =>
|
||||||
selectedPropertiesDownloadId
|
selectedPropertiesDownloadId
|
||||||
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
|
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
|
||||||
@@ -106,7 +105,7 @@ export const PropertiesModal = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
if (!url.trim()) {
|
if (!url.trim()) {
|
||||||
setErrorMessage("Enter a valid URL.");
|
setErrorMessage("Enter a valid URL.");
|
||||||
return;
|
return;
|
||||||
@@ -130,17 +129,22 @@ export const PropertiesModal = () => {
|
|||||||
mirrors: mirrors.trim() || undefined,
|
mirrors: mirrors.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateDownload(item.id, updates);
|
try {
|
||||||
setSelectedPropertiesDownloadId(null);
|
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 isLocked = ['downloading', 'processing', 'completed', 'retrying'].includes(item.status);
|
||||||
const isTransferLocked = item.status === 'downloading' || item.status === 'processing';
|
const isTransferLocked = item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying';
|
||||||
|
|
||||||
let statusColor = 'text-text-secondary';
|
let statusColor = 'text-text-secondary';
|
||||||
let StatusIcon = Info;
|
let StatusIcon = Info;
|
||||||
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
|
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 === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; }
|
||||||
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
||||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||||
@@ -339,7 +343,8 @@ export const PropertiesModal = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
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} />
|
<CheckCircle size={14} />
|
||||||
Save
|
Save
|
||||||
|
|||||||
+2
-4
@@ -73,6 +73,7 @@ type CommandMap = {
|
|||||||
pause_download: { args: { id: string }; result: void };
|
pause_download: { args: { id: string }; result: void };
|
||||||
resume_download: { args: { id: string }; result: boolean };
|
resume_download: { args: { id: string }; result: boolean };
|
||||||
remove_download: { args: { id: string; filepath: string | null }; result: void };
|
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 };
|
update_dock_badge: { args: { count: number }; result: void };
|
||||||
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
set_prevent_sleep: { args: { prevent: boolean }; result: void };
|
||||||
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
perform_system_action: { args: { action: PostQueueAction }; result: void };
|
||||||
@@ -101,14 +102,11 @@ type CommandMap = {
|
|||||||
result: void;
|
result: void;
|
||||||
};
|
};
|
||||||
db_delete_download: { args: { id: string }; 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 };
|
create_category_directories: { args: { paths: string[] }; result: void };
|
||||||
export_logs: { args: { destPath: string }; result: string };
|
export_logs: { args: { destPath: string }; result: string };
|
||||||
get_pending_order: { args: undefined; result: string[] };
|
get_pending_order: { args: undefined; result: string[] };
|
||||||
enqueue_download: { args: { item: any }; 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[] };
|
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] };
|
||||||
remove_from_queue: { args: { id: string }; result: boolean };
|
remove_from_queue: { args: { id: string }; result: boolean };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ export async function initDownloadListener() {
|
|||||||
mainStore.setPendingOrder([...mainStore.pendingOrder, payload.id]);
|
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';
|
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>) => {
|
const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.getState>) => {
|
||||||
if (settings.proxyMode === 'system') {
|
if (settings.proxyMode === 'system') {
|
||||||
try {
|
try {
|
||||||
@@ -98,6 +158,12 @@ interface DownloadState {
|
|||||||
queues: Queue[];
|
queues: Queue[];
|
||||||
pendingOrder: string[];
|
pendingOrder: string[];
|
||||||
setPendingOrder: (order: string[]) => void;
|
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>;
|
moveInQueue: (id: string, direction: 'up' | 'down') => Promise<void>;
|
||||||
removeFromQueue: (id: string) => Promise<void>;
|
removeFromQueue: (id: string) => Promise<void>;
|
||||||
isAddModalOpen: boolean;
|
isAddModalOpen: boolean;
|
||||||
@@ -137,6 +203,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
|
||||||
pendingOrder: [],
|
pendingOrder: [],
|
||||||
setPendingOrder: (order) => set({ pendingOrder: order }),
|
setPendingOrder: (order) => set({ pendingOrder: order }),
|
||||||
|
activeDownloadId: null,
|
||||||
|
setActiveDownloadId: (id) => set({ activeDownloadId: id }),
|
||||||
moveInQueue: async (id, direction) => {
|
moveInQueue: async (id, direction) => {
|
||||||
try {
|
try {
|
||||||
const order = await invoke('move_in_queue', { id, direction });
|
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);
|
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,
|
isAddModalOpen: false,
|
||||||
pendingAddUrls: '',
|
pendingAddUrls: '',
|
||||||
pendingAddReferer: '',
|
pendingAddReferer: '',
|
||||||
@@ -214,48 +293,62 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
addDownload: async (item) => {
|
addDownload: async (item) => {
|
||||||
info(`Download ${item.id} added to queue`);
|
info(`Download ${item.id} added to queue`);
|
||||||
try {
|
const settings = useSettingsStore.getState();
|
||||||
const settings = useSettingsStore.getState();
|
const destPath = effectiveDestinationForItem(item, settings);
|
||||||
const destPath = effectiveDestinationForItem(item, settings);
|
const ownedItem = { ...item, destination: destPath, hasBeenDispatched: false };
|
||||||
const ownedItem = { ...item, destination: destPath };
|
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
||||||
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
|
|
||||||
|
|
||||||
const login = getSiteLogin(item.url, settings);
|
if (item.status === 'queued') {
|
||||||
let keychainPassword = null;
|
const order = useDownloadStore.getState().pendingOrder;
|
||||||
if (login) {
|
if (!order.includes(item.id)) {
|
||||||
try {
|
useDownloadStore.getState().setPendingOrder([...order, item.id]);
|
||||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
}
|
||||||
} catch (e) {
|
} else {
|
||||||
console.warn("Could not fetch keychain password for login:", e);
|
// 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 = {
|
} else if (item.status === 'paused') {
|
||||||
id: item.id,
|
if (isRegistered) {
|
||||||
url: item.url,
|
try {
|
||||||
destination: destPath,
|
await invoke('detach_download_for_reconfigure', { id });
|
||||||
filename: item.fileName,
|
} catch (e) {
|
||||||
connections: item.connections || settings.perServerConnections || null,
|
console.error("Failed to detach for reconfigure:", e);
|
||||||
speed_limit: item.speedLimit || normalizeSpeedLimitForBackend(settings.globalSpeedLimit),
|
throw e; // Preserve old properties if detach fails
|
||||||
username: item.username || (login ? login.username : null),
|
}
|
||||||
password: item.password || keychainPassword,
|
state.unregisterBackendIds([id]);
|
||||||
headers: item.headers || null,
|
}
|
||||||
checksum: item.checksum || null,
|
state.updateDownload(id, updates);
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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.');
|
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 = {
|
const redownloadItem: DownloadItem = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
url,
|
url,
|
||||||
@@ -376,35 +459,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
downloads: [...state.downloads, redownloadItem]
|
downloads: [...state.downloads, redownloadItem]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
if (!await dispatchItem(redownloadItem.id)) {
|
||||||
const enqueueItem = {
|
console.error("Failed to enqueue redownload");
|
||||||
id: redownloadItem.id,
|
} else {
|
||||||
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 });
|
|
||||||
info(`Download ${id} redownload requested as ${redownloadItem.id} (queued)`);
|
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) => {
|
resumeDownload: async (id) => {
|
||||||
@@ -417,6 +475,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get().unregisterBackendIds([id]);
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
downloads: state.downloads.map(d => {
|
downloads: state.downloads.map(d => {
|
||||||
if (d.id === id) {
|
if (d.id === id) {
|
||||||
@@ -426,45 +486,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const settings = useSettingsStore.getState();
|
if (!await dispatchItem(id)) {
|
||||||
const login = getSiteLogin(targetItem.url, settings);
|
console.error("Failed to re-enqueue for resume");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
} catch (e) {
|
||||||
console.error("Failed to enqueue resume:", e);
|
console.error("Failed to resume download:", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
startQueue: async (queueId) => {
|
startQueue: async (queueId) => {
|
||||||
@@ -473,69 +499,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
|
|
||||||
if (runnable.length === 0) return 0;
|
if (runnable.length === 0) return 0;
|
||||||
|
|
||||||
const paused = runnable.filter(item => item.status === 'paused');
|
let dispatchedCount = 0;
|
||||||
const toEnqueue = runnable.filter(item => item.status !== 'paused');
|
const promises = runnable.map(async (item) => {
|
||||||
|
if (item.status === 'failed' || !item.hasBeenDispatched) {
|
||||||
set((state) => ({
|
if (await dispatchItem(item.id)) {
|
||||||
downloads: state.downloads.map(item =>
|
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
|
||||||
toEnqueue.some(r => r.id === item.id)
|
dispatchedCount++;
|
||||||
? { ...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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const destPath = item.destination ||
|
} else if (item.status === 'paused' || item.status === 'queued') {
|
||||||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
|
// If it's queued but already dispatched, it might be waiting.
|
||||||
settings.defaultDownloadPath ||
|
// If it's paused, we resume it.
|
||||||
'~/Downloads';
|
if (item.status === 'paused') {
|
||||||
|
await get().resumeDownload(item.id);
|
||||||
itemsToEnqueue.push({
|
}
|
||||||
id: item.id,
|
dispatchedCount++;
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
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`);
|
await Promise.all(promises);
|
||||||
return runnable.length;
|
|
||||||
|
info(`Queue ${queueId} started, ${dispatchedCount} items dispatched/resumed`);
|
||||||
|
return dispatchedCount;
|
||||||
},
|
},
|
||||||
pauseQueue: async (queueId) => {
|
pauseQueue: async (queueId) => {
|
||||||
const activeIds = get().downloads
|
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