Compare commits

..

5 Commits

Author SHA1 Message Date
NimBold 6d9ef68bb0 chore(release): prepare firelink 1.0.3 2026-07-09 04:07:34 +03:30
NimBold 51258b25bc fix(media): smooth transfer speed and eta 2026-07-09 03:40:14 +03:30
NimBold 1baadf6ea6 fix(media): force yt-dlp progress output (#8) 2026-07-09 03:16:44 +03:30
NimBold 60dad5703a fix(media): show live yt-dlp progress (#8)
Buffer yt-dlp output chunks before parsing progress events and update the download row from live progress state instead of imperative DOM writes.
2026-07-09 02:59:37 +03:30
NimBold b8ef712981 ci(release): publish tag builds by default 2026-07-08 22:43:23 +03:30
14 changed files with 792 additions and 228 deletions
+6 -9
View File
@@ -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:
+11
View File
@@ -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
+3 -3
View File
@@ -5,7 +5,7 @@
**A fast, focused desktop download manager for macOS, Windows, and Linux.**
[![Version](https://img.shields.io/badge/version-1.0.2-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![Version](https://img.shields.io/badge/version-1.0.3-6f42c1?style=flat-square)](https://github.com/nimbold/Firelink/releases)
[![macOS](https://img.shields.io/badge/macOS-111111?style=flat-square&logo=apple&logoColor=white)](#platforms)
[![Windows](.github/badges/windows.svg)](#platforms)
[![Linux](https://img.shields.io/badge/Linux-FCC624?style=flat-square&logo=linux&logoColor=black)](#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.
+6 -6
View File
@@ -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
View File
@@ -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": {
+370 -9
View File
@@ -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
View File
@@ -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"
}
+1 -1
View File
@@ -1380,7 +1380,7 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "firelink"
version = "1.0.2"
version = "1.0.3"
dependencies = [
"async-trait",
"axum",
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+24 -49
View File
@@ -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>
+10 -1
View File
@@ -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);
}
}
});