mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d9ef68bb0 | |||
| 51258b25bc | |||
| 1baadf6ea6 | |||
| 60dad5703a | |||
| b8ef712981 |
@@ -6,13 +6,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish_release:
|
||||
description: 'Publish the GitHub release after all platform QA is certified'
|
||||
description: 'Publish the GitHub release after all platform build and verification jobs pass'
|
||||
type: boolean
|
||||
default: false
|
||||
certified_cross_platform:
|
||||
description: 'Confirm Windows, Linux, and macOS clean-machine QA passed'
|
||||
type: boolean
|
||||
default: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -136,10 +132,11 @@ jobs:
|
||||
publish:
|
||||
name: Publish GitHub release
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
inputs.publish_release &&
|
||||
inputs.certified_cross_platform
|
||||
(
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
)
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
|
||||
@@ -5,6 +5,17 @@ All notable changes to Firelink will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.3] - 2026-07-09
|
||||
|
||||
### Improved
|
||||
- Refresh bundled Deno to 2.9.2 and update the TypeScript build toolchain used by the desktop app.
|
||||
- Keep release publishing aligned with the changelog so tag builds publish GitHub release notes automatically after the platform builds pass.
|
||||
|
||||
### Fixed
|
||||
- Fix YouTube and other yt-dlp downloads that appeared stuck at 0% even while the transfer was active, addressing the progress problem reported around [#8](https://github.com/nimbold/Firelink/issues/8).
|
||||
- Improve media download speed and ETA updates so short stalls no longer make the main list look frozen or misleading.
|
||||
- Keep media progress sizes from replacing the final file size until the completed file is actually known.
|
||||
|
||||
## [1.0.2] - 2026-07-08
|
||||
|
||||
### New
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
|
||||
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](https://github.com/nimbold/Firelink/releases)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
[](#platforms)
|
||||
@@ -40,7 +40,7 @@ It is now a cross-platform Rust/Tauri app with a React and TypeScript interface.
|
||||
## Features
|
||||
|
||||
- **Segmented downloads** with aria2, retries, speed limits, and connection controls.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, and Deno.
|
||||
- **Media downloads** with yt-dlp, FFmpeg, Deno, live progress, speed, and ETA.
|
||||
- **Add window** for metadata, duplicates, location choices, and captured links.
|
||||
- **Persistent queues** with pause, resume, retry, redownload, sorting, multi-select, and bulk actions.
|
||||
- **Scheduling** with start/stop windows, speed rules, and post-queue actions.
|
||||
@@ -81,7 +81,7 @@ What it adds:
|
||||
- Fallback to the browser download when Firelink is closed or rejects a handoff.
|
||||
- Captured links always open Firelink's Add window before anything is added to the download list.
|
||||
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.2 is the matching extension release for Firelink 1.0.2.
|
||||
Install the extension, open Firelink, then pair it from **Settings -> Integrations**. Firefox users can install from Mozilla Add-ons. Chromium users can use the [manual load-unpacked flow](https://github.com/nimbold/Firelink-Extension#manual-chromium-installation) with `firelink-chromium.zip` from the [extension releases](https://github.com/nimbold/Firelink-Extension/releases). Firelink Companion 2.0.2 is the matching extension release for Firelink 1.0.3.
|
||||
|
||||
The extension lives in [Firelink-Extension](https://github.com/nimbold/Firelink-Extension). This repo also vendors it as the `Extensions/Firefox` submodule.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.1",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "ab310b4232cca207d40ffa41867e93aaf9f893802bc76756e74f486a6b21b371"
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-pc-windows-msvc.zip",
|
||||
"sha256": "5fe194d26ac5ef77fcc5288c2c438c7a0465f3b6180440ebf04092714bf2dcdf"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
@@ -30,9 +30,9 @@
|
||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.1",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.1/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "710c54d63477d1100844ef4818f19507ce0dbf40510903b1d883f19e394446a2"
|
||||
"version": "2.9.2",
|
||||
"url": "https://github.com/denoland/deno/releases/download/v2.9.2/deno-x86_64-unknown-linux-gnu.zip",
|
||||
"sha256": "934d1bd5cb09eaed7f2e4a4fc58208d04a3c5c0fcde9f319d93d735265c67a4a"
|
||||
},
|
||||
"ffmpeg": {
|
||||
"version": "8.1.2-22-g94138f6973",
|
||||
|
||||
+2
-2
@@ -22,10 +22,10 @@
|
||||
"sha256": "be2c39e5c9ef923f60da6cb62f5a209ed98b4da8a732d9f06de4355d5ea99e58"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.1",
|
||||
"version": "2.9.2",
|
||||
"source": "https://github.com/denoland/deno",
|
||||
"build": "official aarch64-apple-darwin executable",
|
||||
"sha256": "df5e2cca5253ec99b9a630fb059bf01cdbbf586fed1f344017875c5462b2c483"
|
||||
"sha256": "218ab752ae8f64f0a7822af710886488f15169fdae153a3aada4861f9635b266"
|
||||
}
|
||||
},
|
||||
"runtimeTrees": {
|
||||
|
||||
Generated
+370
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "firelink",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
@@ -31,7 +31,7 @@
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
@@ -996,6 +996,346 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
|
||||
@@ -1929,17 +2269,38 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "firelink",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "A fast cross-platform desktop download manager powered by Rust, Tauri, React, aria2, and yt-dlp.",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/nimbold/Firelink",
|
||||
@@ -60,7 +60,7 @@
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -1380,7 +1380,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "firelink"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "firelink"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
description = "A fast cross-platform desktop download manager powered by Rust and Tauri"
|
||||
authors = ["NimBold"]
|
||||
edition = "2021"
|
||||
|
||||
Binary file not shown.
+355
-144
@@ -3,7 +3,7 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
@@ -178,6 +178,17 @@ fn media_output_template(
|
||||
}
|
||||
}
|
||||
|
||||
fn media_progress_args() -> Vec<String> {
|
||||
vec![
|
||||
"--newline".to_string(),
|
||||
"--progress".to_string(),
|
||||
"--progress-delta".to_string(),
|
||||
"0.2".to_string(),
|
||||
"--progress-template".to_string(),
|
||||
format!("download:{MEDIA_PROGRESS_PREFIX}%(progress)j"),
|
||||
]
|
||||
}
|
||||
|
||||
fn json_str<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
|
||||
value
|
||||
.get(key)
|
||||
@@ -700,6 +711,40 @@ fn progress_json_string(progress: &serde_json::Value, key: &str) -> Option<Strin
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn drain_media_output_lines(buffer: &mut String, chunk: &str) -> Vec<String> {
|
||||
buffer.push_str(chunk);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
while let Some(index) = match (buffer.find('\n'), buffer.find('\r')) {
|
||||
(Some(line_feed), Some(carriage_return)) => Some(line_feed.min(carriage_return)),
|
||||
(Some(line_feed), None) => Some(line_feed),
|
||||
(None, Some(carriage_return)) => Some(carriage_return),
|
||||
(None, None) => None,
|
||||
} {
|
||||
let mut line: String = buffer.drain(..=index).collect();
|
||||
while line.ends_with('\n') || line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
if !line.trim().is_empty() {
|
||||
lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty()
|
||||
&& buffer.contains(MEDIA_PROGRESS_PREFIX)
|
||||
&& parse_media_progress_line(buffer).is_some()
|
||||
{
|
||||
lines.push(std::mem::take(buffer));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn flush_media_output_line(buffer: &mut String) -> Option<String> {
|
||||
let line = std::mem::take(buffer);
|
||||
(!line.trim().is_empty()).then_some(line)
|
||||
}
|
||||
|
||||
fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
||||
if let Some(prefix_index) = line.find(MEDIA_PROGRESS_PREFIX) {
|
||||
let progress: serde_json::Value =
|
||||
@@ -723,8 +768,12 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
||||
} else if total > 0.0 {
|
||||
downloaded / total
|
||||
} else {
|
||||
progress_json_string(&progress, "_percent_str")
|
||||
.and_then(|percent| percent.trim_end_matches('%').trim().parse::<f64>().ok())
|
||||
progress_json_number(&progress, "_percent")
|
||||
.or_else(|| {
|
||||
progress_json_string(&progress, "_percent_str").and_then(|percent| {
|
||||
percent.trim_end_matches('%').trim().parse::<f64>().ok()
|
||||
})
|
||||
})
|
||||
.unwrap_or(0.0)
|
||||
/ 100.0
|
||||
};
|
||||
@@ -836,33 +885,59 @@ fn parse_human_size(s: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
const MEDIA_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
const MEDIA_SPEED_SAMPLE_WINDOW: Duration = Duration::from_secs(8);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct MediaSpeedSampler {
|
||||
samples: VecDeque<(Instant, f64)>,
|
||||
}
|
||||
|
||||
impl MediaSpeedSampler {
|
||||
fn reset(&mut self) {
|
||||
self.samples.clear();
|
||||
}
|
||||
|
||||
fn sample(&mut self, downloaded_bytes: f64, now: Instant) -> Option<f64> {
|
||||
if self
|
||||
.samples
|
||||
.back()
|
||||
.is_some_and(|(_, last_bytes)| downloaded_bytes < *last_bytes)
|
||||
{
|
||||
self.reset();
|
||||
}
|
||||
|
||||
self.samples.push_back((now, downloaded_bytes));
|
||||
while self.samples.len() > 2 {
|
||||
let Some((oldest_at, _)) = self.samples.front() else {
|
||||
break;
|
||||
};
|
||||
if now.duration_since(*oldest_at) <= MEDIA_SPEED_SAMPLE_WINDOW {
|
||||
break;
|
||||
}
|
||||
self.samples.pop_front();
|
||||
}
|
||||
|
||||
let (oldest_at, oldest_bytes) = *self.samples.front()?;
|
||||
let elapsed = now.duration_since(oldest_at).as_secs_f64();
|
||||
if elapsed <= 0.25 || downloaded_bytes <= oldest_bytes {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((downloaded_bytes - oldest_bytes) / elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn media_progress_speed(
|
||||
progress: &MediaProgress,
|
||||
now: Instant,
|
||||
last_sample: &mut Option<(Instant, f64)>,
|
||||
speed_sampler: &mut MediaSpeedSampler,
|
||||
) -> (String, String) {
|
||||
let Some(downloaded_bytes) = progress.downloaded_bytes else {
|
||||
return (progress.speed.clone(), progress.eta.clone());
|
||||
};
|
||||
|
||||
let Some((last_at, last_bytes)) = *last_sample else {
|
||||
*last_sample = Some((now, downloaded_bytes));
|
||||
return (progress.speed.clone(), progress.eta.clone());
|
||||
};
|
||||
|
||||
*last_sample = Some((now, downloaded_bytes));
|
||||
|
||||
if downloaded_bytes < last_bytes {
|
||||
return (progress.speed.clone(), progress.eta.clone());
|
||||
}
|
||||
|
||||
let elapsed = now.duration_since(last_at).as_secs_f64();
|
||||
if elapsed <= 0.05 {
|
||||
return (progress.speed.clone(), progress.eta.clone());
|
||||
}
|
||||
|
||||
let bytes_per_second = (downloaded_bytes - last_bytes) / elapsed;
|
||||
if bytes_per_second > 0.0 {
|
||||
if let Some(bytes_per_second) = speed_sampler.sample(downloaded_bytes, now) {
|
||||
let speed_str = crate::download::format_speed(bytes_per_second);
|
||||
let eta_str = if progress.fraction > 0.0 && progress.fraction < 1.0 {
|
||||
let total = downloaded_bytes / progress.fraction;
|
||||
@@ -895,6 +970,45 @@ fn aggregate_media_fraction(
|
||||
((*current_track + *last_fraction) / total_tracks).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn emit_media_progress(
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: &str,
|
||||
progress: MediaProgress,
|
||||
total_tracks: f64,
|
||||
current_track: &mut f64,
|
||||
last_fraction: &mut f64,
|
||||
speed_sampler: &mut MediaSpeedSampler,
|
||||
last_progress_at: &mut Instant,
|
||||
) {
|
||||
let previous_track = *current_track;
|
||||
let overall_fraction = aggregate_media_fraction(
|
||||
total_tracks,
|
||||
current_track,
|
||||
last_fraction,
|
||||
progress.fraction,
|
||||
);
|
||||
if *current_track != previous_track {
|
||||
speed_sampler.reset();
|
||||
}
|
||||
let (speed, eta) = media_progress_speed(&progress, Instant::now(), speed_sampler);
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(*last_progress_at) >= MEDIA_PROGRESS_EMIT_INTERVAL {
|
||||
let _ = app_handle.emit(
|
||||
"download-progress",
|
||||
DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction: overall_fraction,
|
||||
speed,
|
||||
eta,
|
||||
size: progress.size,
|
||||
size_is_final: false,
|
||||
},
|
||||
);
|
||||
*last_progress_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) {
|
||||
cleanup_media_artifacts(out_path, true).await;
|
||||
}
|
||||
@@ -1230,7 +1344,7 @@ async fn fetch_metadata(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if size_bytes == 0 {
|
||||
if let Some(len) = res.headers().get(reqwest::header::CONTENT_LENGTH) {
|
||||
if let Ok(len_str) = len.to_str() {
|
||||
@@ -2717,9 +2831,9 @@ pub(crate) async fn start_media_download_internal(
|
||||
let _keep_alive = config_path;
|
||||
let mut current_track: f64 = 0.0;
|
||||
let mut last_fraction: f64 = 0.0;
|
||||
let mut last_speed_sample: Option<(Instant, f64)> = None;
|
||||
let mut speed_sampler = MediaSpeedSampler::default();
|
||||
let mut last_progress_at = std::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_millis(200))
|
||||
.checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL)
|
||||
.unwrap_or_else(std::time::Instant::now);
|
||||
|
||||
// Resolve absolute paths to bundled binaries
|
||||
@@ -2743,14 +2857,11 @@ pub(crate) async fn start_media_download_internal(
|
||||
|
||||
while strike <= max_retries {
|
||||
let ytdlp_path = resolve_bundled_binary_path(&app_handle, "yt-dlp")?;
|
||||
let mut cmd = app_handle
|
||||
.shell()
|
||||
.command(&ytdlp_path)
|
||||
.arg("--newline")
|
||||
.arg("--progress-delta")
|
||||
.arg("0.2")
|
||||
.arg("--progress-template")
|
||||
.arg(format!("download:{MEDIA_PROGRESS_PREFIX}%(progress)j"))
|
||||
let mut cmd = app_handle.shell().command(&ytdlp_path);
|
||||
for arg in media_progress_args() {
|
||||
cmd = cmd.arg(arg);
|
||||
}
|
||||
cmd = cmd
|
||||
.arg("--socket-timeout")
|
||||
.arg("20")
|
||||
.arg("--retries")
|
||||
@@ -2834,6 +2945,8 @@ pub(crate) async fn start_media_download_internal(
|
||||
|
||||
let mut stderr_tail = String::new();
|
||||
let mut final_output_path: Option<std::path::PathBuf> = None;
|
||||
let mut stdout_buffer = String::new();
|
||||
let mut stderr_buffer = String::new();
|
||||
let failure_reason = loop {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => {
|
||||
@@ -2847,101 +2960,124 @@ pub(crate) async fn start_media_download_internal(
|
||||
event = rx.recv() => {
|
||||
match event {
|
||||
Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
if let Some(progress) = parse_media_progress_line(&line) {
|
||||
let previous_track = current_track;
|
||||
let overall_fraction = aggregate_media_fraction(
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
progress.fraction,
|
||||
);
|
||||
if current_track != previous_track {
|
||||
last_speed_sample = None;
|
||||
}
|
||||
let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
|
||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction: overall_fraction,
|
||||
speed,
|
||||
eta,
|
||||
size: progress.size,
|
||||
size_is_final: false,
|
||||
});
|
||||
last_progress_at = now;
|
||||
}
|
||||
} else {
|
||||
let candidate = line.trim();
|
||||
if !candidate.is_empty() {
|
||||
let candidate_path = std::path::PathBuf::from(candidate);
|
||||
if candidate_path.is_absolute() {
|
||||
final_output_path = Some(candidate_path);
|
||||
let chunk = String::from_utf8_lossy(&line_bytes);
|
||||
for line in drain_media_output_lines(&mut stdout_buffer, &chunk) {
|
||||
if let Some(progress) = parse_media_progress_line(&line) {
|
||||
emit_media_progress(
|
||||
&app_handle,
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
);
|
||||
} else {
|
||||
let candidate = line.trim();
|
||||
if !candidate.is_empty() {
|
||||
let candidate_path = std::path::PathBuf::from(candidate);
|
||||
if candidate_path.is_absolute() {
|
||||
final_output_path = Some(candidate_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(tauri_plugin_shell::process::CommandEvent::Stderr(line_bytes)) => {
|
||||
let line = String::from_utf8_lossy(&line_bytes);
|
||||
if let Some(progress) = parse_media_progress_line(&line) {
|
||||
let previous_track = current_track;
|
||||
let overall_fraction = aggregate_media_fraction(
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
progress.fraction,
|
||||
);
|
||||
if current_track != previous_track {
|
||||
last_speed_sample = None;
|
||||
}
|
||||
let (speed, eta) = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample);
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) {
|
||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction: overall_fraction,
|
||||
speed,
|
||||
eta,
|
||||
size: progress.size,
|
||||
size_is_final: false,
|
||||
});
|
||||
last_progress_at = now;
|
||||
}
|
||||
}
|
||||
if !processing_started && is_media_processing_line(&line) {
|
||||
processing_started = true;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
DownloadStateEvent::new(
|
||||
id,
|
||||
crate::ipc::DownloadStatus::Processing,
|
||||
),
|
||||
);
|
||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction: 1.0,
|
||||
speed: "Processing".to_string(),
|
||||
eta: "-".to_string(),
|
||||
size: None,
|
||||
size_is_final: false,
|
||||
});
|
||||
}
|
||||
let lower = line.to_lowercase();
|
||||
if lower.contains("error") || lower.contains("critical") {
|
||||
log::error!("yt-dlp stderr [{}]: {}", id, line.trim());
|
||||
}
|
||||
stderr_tail.push_str(&line);
|
||||
let chunk = String::from_utf8_lossy(&line_bytes);
|
||||
stderr_tail.push_str(&chunk);
|
||||
if stderr_tail.len() > STDERR_TAIL {
|
||||
stderr_tail = stderr_tail.split_off(stderr_tail.len() - STDERR_TAIL);
|
||||
}
|
||||
for line in drain_media_output_lines(&mut stderr_buffer, &chunk) {
|
||||
if let Some(progress) = parse_media_progress_line(&line) {
|
||||
emit_media_progress(
|
||||
&app_handle,
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
);
|
||||
}
|
||||
if !processing_started && is_media_processing_line(&line) {
|
||||
processing_started = true;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
DownloadStateEvent::new(
|
||||
id,
|
||||
crate::ipc::DownloadStatus::Processing,
|
||||
),
|
||||
);
|
||||
let _ = app_handle.emit("download-progress", DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction: 1.0,
|
||||
speed: "Processing".to_string(),
|
||||
eta: "-".to_string(),
|
||||
size: None,
|
||||
size_is_final: false,
|
||||
});
|
||||
}
|
||||
let lower = line.to_lowercase();
|
||||
if lower.contains("error") || lower.contains("critical") {
|
||||
log::error!("yt-dlp stderr [{}]: {}", id, line.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(tauri_plugin_shell::process::CommandEvent::Error(err)) => {
|
||||
log::error!("yt-dlp shell error [{}]: {}", id, err);
|
||||
break err;
|
||||
}
|
||||
Some(tauri_plugin_shell::process::CommandEvent::Terminated(payload)) => {
|
||||
if let Some(line) = flush_media_output_line(&mut stdout_buffer) {
|
||||
if let Some(progress) = parse_media_progress_line(&line) {
|
||||
emit_media_progress(
|
||||
&app_handle,
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
);
|
||||
} else {
|
||||
let candidate = line.trim();
|
||||
if !candidate.is_empty() {
|
||||
let candidate_path = std::path::PathBuf::from(candidate);
|
||||
if candidate_path.is_absolute() {
|
||||
final_output_path = Some(candidate_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(line) = flush_media_output_line(&mut stderr_buffer) {
|
||||
if let Some(progress) = parse_media_progress_line(&line) {
|
||||
emit_media_progress(
|
||||
&app_handle,
|
||||
id,
|
||||
progress,
|
||||
total_tracks,
|
||||
&mut current_track,
|
||||
&mut last_fraction,
|
||||
&mut speed_sampler,
|
||||
&mut last_progress_at,
|
||||
);
|
||||
}
|
||||
if !processing_started && is_media_processing_line(&line) {
|
||||
processing_started = true;
|
||||
let _ = app_handle.emit(
|
||||
"download-state",
|
||||
DownloadStateEvent::new(
|
||||
id,
|
||||
crate::ipc::DownloadStatus::Processing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if payload.code == Some(0) {
|
||||
log::info!("yt-dlp completed successfully id: {}", id);
|
||||
let completed_path = final_output_path
|
||||
@@ -3442,7 +3578,7 @@ async fn detach_download_for_reconfigure(
|
||||
serde_json::json!([gid]),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
if let Err(e) = pause_res {
|
||||
if !e.contains("cannot be paused now") {
|
||||
return Err(e);
|
||||
@@ -3579,20 +3715,20 @@ fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) {
|
||||
|
||||
let _ = app_handle.run_on_main_thread(move || {
|
||||
unsafe {
|
||||
let app_class = class!(NSApplication);
|
||||
let app: *mut Object = msg_send![app_class, sharedApplication];
|
||||
let dock_tile: *mut Object = msg_send![app, dockTile];
|
||||
let label = if count > 0 {
|
||||
count.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let c_label = CString::new(label).unwrap();
|
||||
let ns_string_class = class!(NSString);
|
||||
let ns_label: *mut Object = msg_send![ns_string_class, alloc];
|
||||
let ns_label: *mut Object = msg_send![ns_label, initWithUTF8String: c_label.as_ptr()];
|
||||
let _: () = msg_send![dock_tile, setBadgeLabel: ns_label];
|
||||
let _: () = msg_send![ns_label, release];
|
||||
let app_class = class!(NSApplication);
|
||||
let app: *mut Object = msg_send![app_class, sharedApplication];
|
||||
let dock_tile: *mut Object = msg_send![app, dockTile];
|
||||
let label = if count > 0 {
|
||||
count.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
let c_label = CString::new(label).unwrap();
|
||||
let ns_string_class = class!(NSString);
|
||||
let ns_label: *mut Object = msg_send![ns_string_class, alloc];
|
||||
let ns_label: *mut Object = msg_send![ns_label, initWithUTF8String: c_label.as_ptr()];
|
||||
let _: () = msg_send![dock_tile, setBadgeLabel: ns_label];
|
||||
let _: () = msg_send![ns_label, release];
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3663,25 +3799,25 @@ fn create_sleep_preventer() -> Result<SleepPreventer, String> {
|
||||
let cstr = CString::new(s).unwrap();
|
||||
macos_sleep::CFStringCreateWithCString(null(), cstr.as_ptr(), 0x08000100)
|
||||
};
|
||||
|
||||
|
||||
let type_sys = create_cf_string("PreventSystemSleep");
|
||||
let type_net = create_cf_string("NetworkClientActive");
|
||||
let name = create_cf_string("Firelink active download");
|
||||
|
||||
|
||||
let mut sys_id: u32 = 0;
|
||||
let mut net_id: u32 = 0;
|
||||
|
||||
|
||||
let res1 = macos_sleep::IOPMAssertionCreateWithDescription(
|
||||
type_sys, name, null(), null(), null(), 0.0, null(), &mut sys_id
|
||||
);
|
||||
let res2 = macos_sleep::IOPMAssertionCreateWithDescription(
|
||||
type_net, name, null(), null(), null(), 0.0, null(), &mut net_id
|
||||
);
|
||||
|
||||
|
||||
macos_sleep::CFRelease(type_sys);
|
||||
macos_sleep::CFRelease(type_net);
|
||||
macos_sleep::CFRelease(name);
|
||||
|
||||
|
||||
if res1 == 0 && res2 == 0 {
|
||||
Ok(SleepPreventer::Mac { system_sleep_id: sys_id, network_client_id: net_id })
|
||||
} else {
|
||||
@@ -3935,16 +4071,16 @@ fn check_automation_permission() -> Result<(), String> {
|
||||
let ns_string_class = class!(NSString);
|
||||
let script_str: *mut Object = msg_send![ns_string_class, alloc];
|
||||
let script_str: *mut Object = msg_send![script_str, initWithUTF8String: c_script.as_ptr()];
|
||||
|
||||
|
||||
let ns_apple_script: *mut Object = msg_send![class!(NSAppleScript), alloc];
|
||||
let ns_apple_script: *mut Object = msg_send![ns_apple_script, initWithSource: script_str];
|
||||
|
||||
|
||||
let mut error_dict: *mut Object = null_mut();
|
||||
let result: *mut Object = msg_send![ns_apple_script, executeAndReturnError: &mut error_dict];
|
||||
|
||||
|
||||
let _: () = msg_send![script_str, release];
|
||||
let _: () = msg_send![ns_apple_script, release];
|
||||
|
||||
|
||||
if result.is_null() {
|
||||
return Err("Automation permission was not granted".to_string());
|
||||
}
|
||||
@@ -4483,13 +4619,14 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool)
|
||||
mod tests {
|
||||
use super::{
|
||||
aggregate_media_fraction, append_ytdlp_http_headers, build_media_format_options,
|
||||
collect_download_uris, filename_from_content_disposition,
|
||||
collect_download_uris, drain_media_output_lines, filename_from_content_disposition,
|
||||
filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format,
|
||||
is_browser_cookie_extraction_error, json_lower, media_metadata_cache_key,
|
||||
media_output_template, media_progress_speed, normalize_speed_limit_for_aria2,
|
||||
media_output_template, media_progress_args, media_progress_speed,
|
||||
normalize_speed_limit_for_aria2,
|
||||
parse_firelink_deep_link, parse_ffmpeg_version, parse_media_progress_line,
|
||||
redact_log_line, sanitize_ytdlp_config_value, should_cleanup_media_artifacts_after_failure,
|
||||
FirelinkDeepLink, MediaProgress, MEDIA_PROGRESS_PREFIX,
|
||||
FirelinkDeepLink, MediaProgress, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -4511,6 +4648,17 @@ mod tests {
|
||||
assert_eq!(template, destination.join("clip.mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ytdlp_progress_args_force_progress_in_quiet_print_mode() {
|
||||
let args = media_progress_args();
|
||||
|
||||
assert!(args.iter().any(|arg| arg == "--progress"));
|
||||
assert!(args.windows(2).any(|pair| {
|
||||
pair[0] == "--progress-template"
|
||||
&& pair[1] == format!("download:{MEDIA_PROGRESS_PREFIX}%(progress)j")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ytdlp_config_values_cannot_inject_extra_lines() {
|
||||
assert_eq!(
|
||||
@@ -4929,6 +5077,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chunked_structured_ytdlp_progress() {
|
||||
let mut buffer = String::new();
|
||||
let first = format!("{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,");
|
||||
let second = "\"total_bytes\":10485760,\"_percent\":50.0,\"_speed_str\":\"1.00MiB/s\"}\n";
|
||||
|
||||
assert!(drain_media_output_lines(&mut buffer, &first).is_empty());
|
||||
let lines = drain_media_output_lines(&mut buffer, second);
|
||||
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(
|
||||
parse_media_progress_line(&lines[0]).map(|progress| progress.fraction),
|
||||
Some(0.5)
|
||||
);
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_structured_ytdlp_numeric_percent_without_total() {
|
||||
let line = format!(
|
||||
"{MEDIA_PROGRESS_PREFIX}{{\"downloaded_bytes\":5242880,\"_percent\":37.5,\"_speed_str\":\"1.00MiB/s\"}}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_media_progress_line(&line).map(|progress| progress.fraction),
|
||||
Some(0.375)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ffmpeg_snapshot_version_without_collapsing_to_n() {
|
||||
let output = "ffmpeg version N-125385-ge2e889d9da-https://www.martin-riedl.de Copyright (c) 2000-2026 the FFmpeg developers";
|
||||
@@ -4999,15 +5176,49 @@ mod tests {
|
||||
downloaded_bytes: Some(3_097_152.0),
|
||||
};
|
||||
let start = Instant::now();
|
||||
let mut sample = None;
|
||||
let mut sampler = MediaSpeedSampler::default();
|
||||
|
||||
assert_eq!(media_progress_speed(&first, start, &mut sample), ("fallback".to_string(), "-".to_string()));
|
||||
assert_eq!(
|
||||
media_progress_speed(&second, start + Duration::from_secs(1), &mut sample),
|
||||
media_progress_speed(&first, start, &mut sampler),
|
||||
("fallback".to_string(), "-".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
media_progress_speed(&second, start + Duration::from_secs(1), &mut sampler),
|
||||
("2.0 MB/s".to_string(), "1s".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooths_media_speed_across_short_stalls() {
|
||||
let start = Instant::now();
|
||||
let mut sampler = MediaSpeedSampler::default();
|
||||
let mut progress = MediaProgress {
|
||||
fraction: 0.25,
|
||||
speed: "-".to_string(),
|
||||
eta: "-".to_string(),
|
||||
size: None,
|
||||
downloaded_bytes: Some(1_000_000.0),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
media_progress_speed(&progress, start, &mut sampler),
|
||||
("-".to_string(), "-".to_string())
|
||||
);
|
||||
|
||||
progress.fraction = 0.5;
|
||||
progress.downloaded_bytes = Some(3_000_000.0);
|
||||
assert_eq!(
|
||||
media_progress_speed(&progress, start + Duration::from_secs(1), &mut sampler),
|
||||
("1.9 MB/s".to_string(), "2s".to_string())
|
||||
);
|
||||
|
||||
progress.downloaded_bytes = Some(3_000_000.0);
|
||||
assert_eq!(
|
||||
media_progress_speed(&progress, start + Duration::from_secs(2), &mut sampler),
|
||||
("976.6 KB/s".to_string(), "3s".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_aria2_external_downloader_progress() {
|
||||
let line = "[#2d2636 12MiB/34MiB(34%) CN:1 DL:910KiB ETA:25s]";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Firelink",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"identifier": "com.nimbold.firelink",
|
||||
"build": {
|
||||
"beforeDevCommand": "node scripts/stage-engines.js && npm run dev",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useDownloadStore } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
@@ -45,47 +45,26 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
.map(candidate => candidate.id);
|
||||
}));
|
||||
const moveInQueue = useDownloadStore(state => state.moveInQueue);
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[downloadId]);
|
||||
const queueIndex = queueItems.indexOf(downloadId);
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const statusTextRef = useRef<HTMLSpanElement>(null);
|
||||
const speedTextRef = useRef<HTMLSpanElement>(null);
|
||||
const etaTextRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!download || download.status !== 'downloading') return;
|
||||
|
||||
const applyProgress = (progress: any) => {
|
||||
if (!progress) return;
|
||||
if (progressBarRef.current) {
|
||||
progressBarRef.current.style.width = `${progress.fraction * 100}%`;
|
||||
}
|
||||
if (statusTextRef.current) {
|
||||
statusTextRef.current.innerText = `${(progress.fraction * 100).toFixed(0)}%`;
|
||||
statusTextRef.current.title = `${(progress.fraction * 100).toFixed(0)}%`;
|
||||
}
|
||||
if (speedTextRef.current) {
|
||||
speedTextRef.current.innerText = progress.speed;
|
||||
speedTextRef.current.title = progress.speed;
|
||||
}
|
||||
if (etaTextRef.current) {
|
||||
etaTextRef.current.innerText = progress.eta;
|
||||
etaTextRef.current.title = progress.eta;
|
||||
}
|
||||
};
|
||||
|
||||
// Apply immediate state on mount
|
||||
applyProgress(useDownloadProgressStore.getState().progressMap[downloadId]);
|
||||
|
||||
const unsubscribe = useDownloadProgressStore.subscribe((state) => {
|
||||
applyProgress(state.progressMap[downloadId]);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [downloadId, download?.status]);
|
||||
|
||||
if (!download) return null;
|
||||
|
||||
const displayFraction = download.status === 'downloading'
|
||||
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||
: download.fraction ?? 0;
|
||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||
const displaySpeed = download.status === 'downloading'
|
||||
? liveProgress?.speed ?? download.speed
|
||||
: download.status === 'processing'
|
||||
? 'Processing...'
|
||||
: '-';
|
||||
const displayEta = download.status === 'downloading'
|
||||
? liveProgress?.eta ?? download.eta
|
||||
: download.status === 'processing'
|
||||
? 'Muxing...'
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${selectedIds.has(downloadId) ? 'is-selected' : ''}`}
|
||||
@@ -118,26 +97,24 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<>
|
||||
<div className="download-progress-track">
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className={`download-progress-fill ${
|
||||
download.status === 'paused' ? 'paused' :
|
||||
download.status === 'processing' ? 'processing' :
|
||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||
download.status === 'retrying' ? 'retrying' : ''
|
||||
}`}
|
||||
style={{ width: `${(download.fraction || 0) * 100}%` }}
|
||||
style={{ width: `${displayFraction * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
key={`status-${download.status}`}
|
||||
ref={statusTextRef}
|
||||
title={
|
||||
download.lastError && (download.status === 'failed' || download.status === 'retrying')
|
||||
? download.lastError
|
||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
|
||||
: download.status === 'downloading'
|
||||
? `${((download.fraction || 0) * 100).toFixed(0)}%`
|
||||
? displayPercent
|
||||
: download.status === 'processing'
|
||||
? 'Processing'
|
||||
: download.status.charAt(0).toUpperCase() + download.status.slice(1)
|
||||
@@ -159,7 +136,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</span>
|
||||
</>
|
||||
) : download.status === 'downloading' ? (
|
||||
`${((download.fraction || 0) * 100).toFixed(0)}%`
|
||||
displayPercent
|
||||
) : download.status === 'processing' ? (
|
||||
'Processing'
|
||||
) : (
|
||||
@@ -173,22 +150,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
<div className="download-cell-truncate">
|
||||
<span
|
||||
key={`speed-${download.status}`}
|
||||
ref={speedTextRef}
|
||||
className="tabular-nums"
|
||||
title={download.status === 'downloading' ? download.speed : download.status === 'processing' ? 'Processing…' : '-'}
|
||||
title={displaySpeed}
|
||||
>
|
||||
{download.status === 'downloading' ? download.speed : download.status === 'processing' ? 'Processing…' : '-'}
|
||||
{displaySpeed}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span
|
||||
key={`eta-${download.status}`}
|
||||
ref={etaTextRef}
|
||||
className="tabular-nums"
|
||||
title={download.status === 'downloading' ? download.eta : download.status === 'processing' ? 'Muxing…' : '-'}
|
||||
title={displayEta}
|
||||
>
|
||||
{download.status === 'downloading' ? download.eta : download.status === 'processing' ? 'Muxing…' : '-'}
|
||||
{displayEta}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,8 +46,17 @@ export async function initDownloadListener() {
|
||||
const current = mainStore.downloads.find(d => d.id === payload.id);
|
||||
if (current) {
|
||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
||||
const updates: Partial<DownloadItem> = {};
|
||||
if (current.status === 'downloading' || current.status === 'processing') {
|
||||
updates.fraction = payload.fraction;
|
||||
updates.speed = payload.speed;
|
||||
updates.eta = payload.eta;
|
||||
}
|
||||
if (shouldUpdateSize && current.size !== payload.size) {
|
||||
mainStore.updateDownload(payload.id, { size: payload.size! });
|
||||
updates.size = payload.size!;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
mainStore.updateDownload(payload.id, updates);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user