mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(download): require aria2 for file transfers
Update yt-dlp to 2026.07.04 and refresh the bundled macOS runtime plus engine locks. Route every non-media download through aria2 by removing the native HTTP fallback path, native GID handling, and the old direct-download harness. Retry transient aria2 startup/RPC failures before failing, then show the real last error in the download row and Properties modal so Windows failures are diagnosable instead of silent. Refresh docs and tests around the aria2-only file-download contract, and advance the Firefox extension submodule to its published wording cleanup.
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: 42b28fa4f6...be92378310
@@ -62,7 +62,7 @@ Download the latest desktop build from [GitHub Releases](https://github.com/nimb
|
||||
| **Windows x64** | NSIS `.exe` installer | Unsigned. Windows SmartScreen may warn until code signing is added. |
|
||||
| **Linux x64** | `.AppImage` | Make executable before launching if your desktop environment does not do that automatically. |
|
||||
|
||||
Production bundles include the required media engines for the target platform. Users do not need to install aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or a package manager for normal app usage.
|
||||
Production bundles include the required media engines for the target platform. Users do not need to install aria2, yt-dlp, FFmpeg, Deno, Python, Homebrew, or a package manager for everyday app usage.
|
||||
|
||||
## Browser Extension
|
||||
|
||||
@@ -74,7 +74,7 @@ Firelink Companion connects your browser to the desktop app so links and browser
|
||||
|
||||
What it adds:
|
||||
|
||||
- **Automatic capture** for normal browser downloads, while still routing every captured link through Firelink's Add window.
|
||||
- **Automatic capture** for ordinary browser downloads, while still routing every captured link through Firelink's Add window.
|
||||
- **Context-menu actions** for "Download with Firelink" and selected links.
|
||||
- **Signed local requests** using the pairing token from **Settings -> Integrations**.
|
||||
- **Server identity checks** so the extension only trusts the real local Firelink app.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"targets": {
|
||||
"x86_64-pc-windows-msvc": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.06.09",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_win.zip",
|
||||
"sha256": "9bb27530494092870b5330deacfc65a40d3e980c7e2c67e5f09b902c37a6903d"
|
||||
"version": "2026.07.04",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_win.zip",
|
||||
"sha256": "90254845be5282b1f4d843a873abff04f569f857f64250f833fe152b21eec152"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.1",
|
||||
@@ -25,9 +25,9 @@
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.06.09",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.06.09/yt-dlp_linux.zip",
|
||||
"sha256": "217bbc9c3ed19ea75a7f151a3e48dbfeac7f459a7dce2deeeecc2d6e2871bd5b"
|
||||
"version": "2026.07.04",
|
||||
"url": "https://github.com/yt-dlp/yt-dlp/releases/download/2026.07.04/yt-dlp_linux.zip",
|
||||
"sha256": "d7d2d09e900b5ae11821b5784b18cf064984a2bd88b1ca5c798d744bcbe3658b"
|
||||
},
|
||||
"deno": {
|
||||
"version": "2.9.1",
|
||||
|
||||
+3
-3
@@ -4,10 +4,10 @@
|
||||
"aarch64-apple-darwin": {
|
||||
"engines": {
|
||||
"yt-dlp": {
|
||||
"version": "2026.06.09",
|
||||
"version": "2026.07.04",
|
||||
"source": "https://github.com/yt-dlp/yt-dlp",
|
||||
"build": "PyInstaller onedir distribution with embedded Python and yt_dlp_ejs",
|
||||
"sha256": "4eefb498e76f8a425bec30ba3ee2079b01542ca39ca1fb61b79966450794cc13"
|
||||
"sha256": "ff7d4fc44b8fbf42da021c1bca950da0326cdb0cdb84992fdc7fb7ec215df435"
|
||||
},
|
||||
"aria2c": {
|
||||
"version": "1.37.0",
|
||||
@@ -31,7 +31,7 @@
|
||||
"runtimeTrees": {
|
||||
"_internal": {
|
||||
"files": 142,
|
||||
"sha256": "3fabc08e6367f9393cfee32c32138f057f0e6068e430190c26a22ddfea84242b"
|
||||
"sha256": "a5f62c6ef6d72319d60c9614b28bfaf7d5ec731ad82e4c2ca9906d0731547b1d"
|
||||
},
|
||||
"aria2-libs": {
|
||||
"files": 7,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.python.python</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.python.python</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
|
||||
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.python.python</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>3.14.5</string>
|
||||
<string>3.14.6</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
|
||||
Binary file not shown.
@@ -3528,35 +3528,6 @@ xeSDwWrruoBa3lwtcHb4yOWHh8qgnaHlIhInD0Q9HWzq1MKLL295q39QpsQZp6F6
|
||||
t5b5wR9iWqJDB0BeJsas7a5wFsWqynKKTbDPAYsDP27X
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
|
||||
# Subject: CN=SecureSign Root CA12 O=Cybertrust Japan Co., Ltd.
|
||||
# Label: "SecureSign Root CA12"
|
||||
# Serial: 587887345431707215246142177076162061960426065942
|
||||
# MD5 Fingerprint: c6:89:ca:64:42:9b:62:08:49:0b:1e:7f:e9:07:3d:e8
|
||||
# SHA1 Fingerprint: 7a:22:1e:3d:de:1b:06:ac:9e:c8:47:70:16:8e:3c:e5:f7:6b:06:f4
|
||||
# SHA256 Fingerprint: 3f:03:4b:b5:70:4d:44:b2:d0:85:45:a0:20:57:de:93:eb:f3:90:5f:ce:72:1a:cb:c7:30:c0:6d:da:ee:90:4e
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDcjCCAlqgAwIBAgIUZvnHwa/swlG07VOX5uaCwysckBYwDQYJKoZIhvcNAQEL
|
||||
BQAwUTELMAkGA1UEBhMCSlAxIzAhBgNVBAoTGkN5YmVydHJ1c3QgSmFwYW4gQ28u
|
||||
LCBMdGQuMR0wGwYDVQQDExRTZWN1cmVTaWduIFJvb3QgQ0ExMjAeFw0yMDA0MDgw
|
||||
NTM2NDZaFw00MDA0MDgwNTM2NDZaMFExCzAJBgNVBAYTAkpQMSMwIQYDVQQKExpD
|
||||
eWJlcnRydXN0IEphcGFuIENvLiwgTHRkLjEdMBsGA1UEAxMUU2VjdXJlU2lnbiBS
|
||||
b290IENBMTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6OcE3emhF
|
||||
KxS06+QT61d1I02PJC0W6K6OyX2kVzsqdiUzg2zqMoqUm048luT9Ub+ZyZN+v/mt
|
||||
p7JIKwccJ/VMvHASd6SFVLX9kHrko+RRWAPNEHl57muTH2SOa2SroxPjcf59q5zd
|
||||
J1M3s6oYwlkm7Fsf0uZlfO+TvdhYXAvA42VvPMfKWeP+bl+sg779XSVOKik71gur
|
||||
FzJ4pOE+lEa+Ym6b3kaosRbnhW70CEBFEaCeVESE99g2zvVQR9wsMJvuwPWW0v4J
|
||||
hscGWa5Pro4RmHvzC1KqYiaqId+OJTN5lxZJjfU+1UefNzFJM3IFTQy2VYzxV4+K
|
||||
h9GtxRESOaCtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQD
|
||||
AgEGMB0GA1UdDgQWBBRXNPN0zwRL1SXm8UC2LEzZLemgrTANBgkqhkiG9w0BAQsF
|
||||
AAOCAQEAPrvbFxbS8hQBICw4g0utvsqFepq2m2um4fylOqyttCg6r9cBg0krY6Ld
|
||||
mmQOmFxv3Y67ilQiLUoT865AQ9tPkbeGGuwAtEGBpE/6aouIs3YIcipJQMPTw4WJ
|
||||
mBClnW8Zt7vPemVV2zfrPIpyMpcemik+rY3moxtt9XUa5rBouVui7mlHJzWhhpmA
|
||||
8zNL4WukJsPvdFlseqJkth5Ew1DgDzk9qTPxpfPSvWKErI4cqc1avTc7bgoitPQV
|
||||
55FYxTpE05Uo2cBl6XLK0A+9H7MV2anjpEcJnuDLN/v9vZfVvhgaaaI5gdka9at/
|
||||
yOPiZwud9AzqVN/Ssq+xIvEg37xEHA==
|
||||
-----END CERTIFICATE-----
|
||||
|
||||
# Issuer: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
|
||||
# Subject: CN=SecureSign Root CA14 O=Cybertrust Japan Co., Ltd.
|
||||
# Label: "SecureSign Root CA14"
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
|
||||
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
|
||||
curl_cffi-0.15.0.dist-info/RECORD,,
|
||||
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
|
||||
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=lpNNgQ8844-2Q1nCwxiwtLw07Je0dsNXHPwhnLfnit4,313
|
||||
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
|
||||
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
|
||||
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
|
||||
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
|
||||
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
|
||||
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/aio.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/const.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/curl.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
|
||||
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
|
||||
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
|
||||
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
|
||||
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
|
||||
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
|
||||
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
|
||||
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
|
||||
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
|
||||
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
|
||||
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
|
||||
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
|
||||
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
|
||||
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
|
||||
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
|
||||
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
|
||||
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
|
||||
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
|
||||
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
|
||||
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
|
||||
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
|
||||
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
|
||||
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
|
||||
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
|
||||
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
|
||||
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
|
||||
../../../bin/curl-cffi,sha256=1WNmf_w0emP0-PeIpDGlHiBy4Gdet6ASfQxBOR-BNMA,187
|
||||
curl_cffi-0.15.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
curl_cffi-0.15.0.dist-info/METADATA,sha256=ryYvNCZOl96BIMI2134ZhyRWy7sr-0-xOc0Q_TfaU9A,18405
|
||||
curl_cffi-0.15.0.dist-info/RECORD,,
|
||||
curl_cffi-0.15.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
curl_cffi-0.15.0.dist-info/WHEEL,sha256=BX73HzL_Z8VmcUg66xwFel1CJ1goP9ZPbeeVSSFaesY,140
|
||||
curl_cffi-0.15.0.dist-info/direct_url.json,sha256=ORhsskaIg_jwHIDYD7oRH5nzN3fKYa7xVFg-8WceJ5E,313
|
||||
curl_cffi-0.15.0.dist-info/entry_points.txt,sha256=HzTwoUpbiKIo5uJrqnxPk5OdxZ0PhT0dPTSqUP8Js70,49
|
||||
curl_cffi-0.15.0.dist-info/licenses/LICENSE,sha256=PoiwKbULav021rGGQs5Mi27uTJA_HPq-9bgR9h4HBQs,1106
|
||||
curl_cffi-0.15.0.dist-info/top_level.txt,sha256=b51YB50I_vu6XAbSERmqtgaYciYADCA_baVoZ_T5Lzs,10
|
||||
curl_cffi/__init__.py,sha256=fPBkVIThnbk_a7U41V5S3Qwhh1_WxZkMerXQNv1iOpA,1781
|
||||
curl_cffi/__main__.py,sha256=L0AH-xyDlgmsPM-HTecPVb1_hV9X35Qf1Mw7h7JVnx0,39
|
||||
curl_cffi/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__main__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/__version__.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/_asyncio_selector.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/aio.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/const.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/curl.cpython-314.pyc,,
|
||||
curl_cffi/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/__version__.py,sha256=FdOv9cpHIUGkFkcWA4O9gte0sun1QgJI69rIgzUkswo,413
|
||||
curl_cffi/_asyncio_selector.py,sha256=dxKOC6B8mlRu3WYi0trSq0L-FsWV6l_pg9TP35OpDI4,13043
|
||||
curl_cffi/_wrapper.abi3.so,sha256=4sMTX_Q2WMouO96xfxc3BZjZxnMZnLjZV8nF_FCU_zE,12691712
|
||||
curl_cffi/aio.py,sha256=oTiffUFDNfxxSHmCVAsjX0CIOs4IuBQ6mhm4WuY5KZs,11925
|
||||
curl_cffi/cli/__init__.py,sha256=y-XQAfYkBDN3ejfVex1aKy5bcqsPUglAvm-i177wM5I,7290
|
||||
curl_cffi/cli/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/doctor.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/output.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/parse.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/request.cpython-314.pyc,,
|
||||
curl_cffi/cli/__pycache__/run.cpython-314.pyc,,
|
||||
curl_cffi/cli/doctor.py,sha256=4wfyWF44JLxDtoH9YCDdmxrJ-1cWbNwSXcMc9Aopd1c,416
|
||||
curl_cffi/cli/output.py,sha256=YzWbYLJ2zowR6G9mJxeTytcPrnSk2WQVwfmv8V404X4,6709
|
||||
curl_cffi/cli/parse.py,sha256=OzqGEZPqOrH2nq14UKnaXwKAblAw6V867tDEp9XSLdo,2549
|
||||
curl_cffi/cli/request.py,sha256=MJskNMJBIR8oq_fvrPvsi61Wy-bR4-aC8QQJb1YcqRg,3522
|
||||
curl_cffi/cli/run.py,sha256=WYaPbX9h85DZQNkDbUUyvp3bryaAuNOxnjU6GpenigQ,7554
|
||||
curl_cffi/const.py,sha256=mF3H1kHsqj-dfd7GWHMHyuB2bZ3QZd61tC1iCZgEjyo,18523
|
||||
curl_cffi/curl.py,sha256=woBPw1OQk4FH7neNETuj_GwqcQa1jCjd1tA0ZxPSTA8,26928
|
||||
curl_cffi/py.typed,sha256=dcrsqJrcYfTX-ckLFJMTaj6mD8aDe2u0tkQG-ZYxnEg,26
|
||||
curl_cffi/requests/__init__.py,sha256=3ZO7mC7gNJZydze0VQLEYpEc9dMlf1uRbFS-fxKSQPs,6148
|
||||
curl_cffi/requests/__pycache__/__init__.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/cookies.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/errors.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/exceptions.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/headers.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/impersonate.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/models.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/session.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/utils.cpython-314.pyc,,
|
||||
curl_cffi/requests/__pycache__/websockets.cpython-314.pyc,,
|
||||
curl_cffi/requests/cookies.py,sha256=QDEuhtsSjh6iNAKmp5TnGyyAe8wdDJCADCHY_GWCeCc,11867
|
||||
curl_cffi/requests/errors.py,sha256=R6N5lmOTdRukThkNGUihDAQRu8HSh27M8E3zfUJJX74,250
|
||||
curl_cffi/requests/exceptions.py,sha256=ViyLx3XHii_s7kjrO3GhVOVXhq2_UsYfAQl8MPwDnEM,6187
|
||||
curl_cffi/requests/headers.py,sha256=Q1jrRdJ2YMLOcl5W3WBaKZAdLbPGPPERJ6MYzos3YXk,11535
|
||||
curl_cffi/requests/impersonate.py,sha256=dKRpbPTqLCABrCmSKmgXyhA1zmrv0DMuSuqQyhn5-FU,13389
|
||||
curl_cffi/requests/models.py,sha256=SaN9QUYyljUDFDQnUrYZRl9KiUe25nb2c2ca-dfq3Nw,11487
|
||||
curl_cffi/requests/session.py,sha256=LkfPvAr8BIVzB_K91xwh0wIPRW26mi2VJd1cEYorIho,58871
|
||||
curl_cffi/requests/utils.py,sha256=zMKaWoXcAPFNBv6FglE3FSAPNuxYr_leUcWKnhfYecw,26618
|
||||
curl_cffi/requests/websockets.py,sha256=RJVNqCDrlLWzQ12Ym9M5_T0SW4EmoqKUYsXXTaQmBRQ,71685
|
||||
curl_cffi/utils.py,sha256=gRVzO-vhjf596V6kr_SjwHlwJfDIwTrPbRLJvvNlUNE,307
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"archive_info": {"hash": "sha256=bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab", "hashes": {"sha256": "bf985a468ffb52bdcdfa1dac713555144c7711521428cd9267578a208c8f45ab"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
|
||||
{"archive_info": {"hash": "sha256=630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550", "hashes": {"sha256": "630653d283070b21deca8ee408dcdc98ac4202d80cc9d038db805d45e0b40550"}}, "url": "file:///Users/runner/work/yt-dlp/yt-dlp/build/universal2/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_universal2.whl"}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
-609
@@ -1,74 +1,18 @@
|
||||
use crate::DownloadProgressEvent;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{
|
||||
header::{self, HeaderMap, HeaderName, HeaderValue},
|
||||
Client, StatusCode,
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::{
|
||||
fs::{self, OpenOptions},
|
||||
io::{AsyncWriteExt, BufWriter},
|
||||
sync::{mpsc, watch},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const PROGRESS_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
const WRITE_BUFFER_CAPACITY: usize = 256 * 1024;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DownloadCmd {
|
||||
Start(Box<DownloadPayload>),
|
||||
Pause(Uuid),
|
||||
PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
|
||||
CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>),
|
||||
CaptureUrls(Vec<String>),
|
||||
FrontendReady(bool),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DownloadEvent {
|
||||
Progress {
|
||||
id: Uuid,
|
||||
fraction: f64,
|
||||
completed: u64,
|
||||
total: Option<u64>,
|
||||
},
|
||||
Completed(Uuid),
|
||||
Failed {
|
||||
id: Uuid,
|
||||
error: String,
|
||||
},
|
||||
/// Transient network drop: a backoff retry is scheduled and the slot is
|
||||
/// still held. Carries the 0-based strike number and the classified reason.
|
||||
Retrying {
|
||||
id: Uuid,
|
||||
strike: usize,
|
||||
reason: String,
|
||||
},
|
||||
CapturedUrls(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadPayload {
|
||||
pub id: Uuid,
|
||||
pub urls: Vec<String>,
|
||||
pub output_path: PathBuf,
|
||||
pub speed_limit: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub headers: Option<String>,
|
||||
pub cookies: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub max_tries: u32,
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DownloadCoordinator {
|
||||
tx: mpsc::Sender<DownloadCmd>,
|
||||
@@ -141,107 +85,6 @@ enum CoordinatorEventSink {
|
||||
}
|
||||
|
||||
impl CoordinatorEventSink {
|
||||
fn emit_progress(
|
||||
&self,
|
||||
id: Uuid,
|
||||
completed: u64,
|
||||
total: Option<u64>,
|
||||
interval_bytes: u64,
|
||||
interval: Duration,
|
||||
) {
|
||||
let speed_bytes = if interval.is_zero() {
|
||||
0.0
|
||||
} else {
|
||||
interval_bytes as f64 / interval.as_secs_f64()
|
||||
};
|
||||
let fraction = total
|
||||
.filter(|total| *total > 0)
|
||||
.map(|total| completed as f64 / total as f64)
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
match self {
|
||||
Self::Tauri(app_handle) => {
|
||||
let eta = total
|
||||
.filter(|total| speed_bytes > 0.0 && *total > completed)
|
||||
.map(|total| format_duration((total - completed) as f64 / speed_bytes))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let _ = app_handle.emit(
|
||||
"download-progress",
|
||||
DownloadProgressEvent {
|
||||
id: id.to_string(),
|
||||
fraction,
|
||||
speed: format_speed(speed_bytes),
|
||||
eta,
|
||||
size: total.map(|t| format_size(t as f64)),
|
||||
size_is_final: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
Self::Headless(event_tx) => {
|
||||
let _ = event_tx.send(DownloadEvent::Progress {
|
||||
id,
|
||||
fraction,
|
||||
completed,
|
||||
total,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_completed(&self, id: Uuid) {
|
||||
match self {
|
||||
Self::Tauri(app_handle) => {
|
||||
let _ = app_handle.emit("download-complete", id.to_string());
|
||||
}
|
||||
Self::Headless(event_tx) => {
|
||||
let _ = event_tx.send(DownloadEvent::Completed(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_failed(&self, id: Uuid, error: String) {
|
||||
match self {
|
||||
Self::Tauri(app_handle) => {
|
||||
log::error!("native download {} failed: {}", id, error);
|
||||
let _ = app_handle.emit("download-failed", id.to_string());
|
||||
}
|
||||
Self::Headless(event_tx) => {
|
||||
let _ = event_tx.send(DownloadEvent::Failed { id, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a transient `Retrying` state. In production this drives the
|
||||
/// `download-state` event with status `retrying` (consumed by the queue's
|
||||
/// completion listener and the frontend store); in headless tests it flows
|
||||
/// through the `DownloadEvent` channel. The strike is 0-based and becomes
|
||||
/// the human-facing attempt number (strike + 1).
|
||||
fn emit_retrying(&self, id: Uuid, strike: usize, reason: String) {
|
||||
match self {
|
||||
Self::Tauri(app_handle) => {
|
||||
use crate::ipc::{DownloadStateEvent, DownloadStatus};
|
||||
let attempt = strike + 1;
|
||||
let payload = DownloadStateEvent::retrying(
|
||||
id.to_string(),
|
||||
format!("Network drop — retry #{attempt}: {reason}"),
|
||||
);
|
||||
// Drive the same `download-state` channel the queue emits on
|
||||
// so the frontend status flips to `retrying` uniformly.
|
||||
let _ = app_handle.emit("download-state", payload);
|
||||
log::warn!(
|
||||
"download {id} transient error, backing off before retry #{attempt}: {reason}"
|
||||
);
|
||||
// Keep the compiler honest about DownloadStatus being used if a
|
||||
// future refactor drops the `retrying` constructor path.
|
||||
let _ = DownloadStatus::Retrying.as_str();
|
||||
}
|
||||
Self::Headless(event_tx) => {
|
||||
let _ = event_tx.send(DownloadEvent::Retrying { id, strike, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_captured_urls(&self, payload: String) -> bool {
|
||||
match self {
|
||||
Self::Tauri(app_handle) => app_handle.emit("deep-link-add-download", payload).is_ok(),
|
||||
@@ -260,46 +103,15 @@ enum MediaCmd {
|
||||
Finished(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum DownloadControl {
|
||||
Pause,
|
||||
Cancel,
|
||||
Replace,
|
||||
}
|
||||
|
||||
struct ActiveDownload {
|
||||
generation: u64,
|
||||
control_tx: mpsc::Sender<DownloadControl>,
|
||||
}
|
||||
|
||||
enum WorkerEvent {
|
||||
Finished {
|
||||
id: Uuid,
|
||||
generation: u64,
|
||||
outcome: DownloadOutcome,
|
||||
},
|
||||
}
|
||||
|
||||
enum DownloadOutcome {
|
||||
Completed,
|
||||
Paused,
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
async fn run_coordinator(
|
||||
events: CoordinatorEventSink,
|
||||
mut command_rx: mpsc::Receiver<DownloadCmd>,
|
||||
mut media_rx: mpsc::Receiver<MediaCmd>,
|
||||
) {
|
||||
let (worker_tx, mut worker_rx) = mpsc::channel(128);
|
||||
let mut active = HashMap::<Uuid, ActiveDownload>::new();
|
||||
let mut active_media = HashMap::<String, watch::Sender<bool>>::new();
|
||||
let mut pending_acks = HashMap::<Uuid, tokio::sync::oneshot::Sender<()>>::new();
|
||||
let mut pending_media_acks = HashMap::<String, tokio::sync::oneshot::Sender<()>>::new();
|
||||
let mut pending_captured_urls = Vec::<String>::new();
|
||||
let mut frontend_ready = false;
|
||||
let mut next_generation = 0_u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -309,48 +121,6 @@ async fn run_coordinator(
|
||||
};
|
||||
|
||||
match command {
|
||||
DownloadCmd::Start(payload_box) => {
|
||||
let payload = *payload_box;
|
||||
if let Some(previous) = active.remove(&payload.id) {
|
||||
let _ = previous.control_tx.send(DownloadControl::Replace).await;
|
||||
}
|
||||
|
||||
next_generation = next_generation.wrapping_add(1);
|
||||
let generation = next_generation;
|
||||
let id = payload.id;
|
||||
let (control_tx, control_rx) = mpsc::channel(1);
|
||||
active.insert(id, ActiveDownload { generation, control_tx });
|
||||
|
||||
let events = events.clone();
|
||||
let worker_tx = worker_tx.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let outcome = download_file(events, payload, control_rx).await;
|
||||
let _ = worker_tx
|
||||
.send(WorkerEvent::Finished { id, generation, outcome })
|
||||
.await;
|
||||
});
|
||||
}
|
||||
DownloadCmd::Pause(id) => {
|
||||
if let Some(download) = active.remove(&id) {
|
||||
let _ = download.control_tx.send(DownloadControl::Pause).await;
|
||||
}
|
||||
}
|
||||
DownloadCmd::PauseWithAck(id, ack) => {
|
||||
if let Some(download) = active.remove(&id) {
|
||||
let _ = download.control_tx.send(DownloadControl::Pause).await;
|
||||
pending_acks.insert(id, ack);
|
||||
} else {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
DownloadCmd::CancelWithAck(id, ack) => {
|
||||
if let Some(download) = active.remove(&id) {
|
||||
let _ = download.control_tx.send(DownloadControl::Cancel).await;
|
||||
pending_acks.insert(id, ack);
|
||||
} else {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
}
|
||||
DownloadCmd::CaptureUrls(urls) => {
|
||||
append_unique_urls(&mut pending_captured_urls, urls);
|
||||
if frontend_ready && !pending_captured_urls.is_empty() {
|
||||
@@ -371,32 +141,6 @@ async fn run_coordinator(
|
||||
}
|
||||
}
|
||||
}
|
||||
event = worker_rx.recv() => {
|
||||
let Some(WorkerEvent::Finished { id, generation, outcome }) = event else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let is_current = active
|
||||
.get(&id)
|
||||
.is_some_and(|download| download.generation == generation);
|
||||
if is_current {
|
||||
active.remove(&id);
|
||||
}
|
||||
|
||||
if let Some(ack) = pending_acks.remove(&id) {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
|
||||
match (is_current, outcome) {
|
||||
(true, DownloadOutcome::Completed) => {
|
||||
events.emit_completed(id);
|
||||
}
|
||||
(true, DownloadOutcome::Failed(error)) => {
|
||||
events.emit_failed(id, error);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
command = media_rx.recv() => {
|
||||
let Some(command) = command else {
|
||||
continue;
|
||||
@@ -431,9 +175,6 @@ async fn run_coordinator(
|
||||
}
|
||||
}
|
||||
|
||||
for (_, download) in active {
|
||||
let _ = download.control_tx.send(DownloadControl::Cancel).await;
|
||||
}
|
||||
for (_, cancel_tx) in active_media {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
@@ -444,315 +185,6 @@ fn append_unique_urls(target: &mut Vec<String>, urls: Vec<String>) {
|
||||
target.extend(urls.into_iter().filter(|url| seen.insert(url.clone())));
|
||||
}
|
||||
|
||||
async fn download_file(
|
||||
events: CoordinatorEventSink,
|
||||
payload: DownloadPayload,
|
||||
mut control_rx: mpsc::Receiver<DownloadControl>,
|
||||
) -> DownloadOutcome {
|
||||
if let Some(parent) = payload.output_path.parent() {
|
||||
if let Err(error) = fs::create_dir_all(parent).await {
|
||||
return DownloadOutcome::Failed(error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let (client, default_headers) = match build_client(&payload) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return DownloadOutcome::Failed(error),
|
||||
};
|
||||
let mut last_error = "no download URL was provided".to_string();
|
||||
|
||||
// Connection-aware retry policy. A transient network drop never transitions
|
||||
// the download straight to `Failed`: it is classified, the UI is told the
|
||||
// item is `Retrying`, and a 3-strike exponential backoff (2s/5s/10s from
|
||||
// `retry::BACKOFF_SCHEDULE`) runs before the next attempt — all while the
|
||||
// worker slot stays held (the coordinator does not drop the active entry
|
||||
// until this future resolves). `download_attempt` re-issues a Range header
|
||||
// from the existing partial file on every retry, so no bytes are discarded.
|
||||
//
|
||||
// `max_tries` is the user-facing retry count. Attempts include the first
|
||||
// try plus those configured retries.
|
||||
let max_retries = payload.max_tries as usize;
|
||||
let max_attempts = max_retries + 1;
|
||||
'url: for url in &payload.urls {
|
||||
let mut strike = 0_usize;
|
||||
let mut attempts = 0_usize;
|
||||
loop {
|
||||
attempts += 1;
|
||||
match download_attempt(
|
||||
&events,
|
||||
&client,
|
||||
&default_headers,
|
||||
&payload,
|
||||
url,
|
||||
&mut control_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => return DownloadOutcome::Completed,
|
||||
Err(AttemptError::Controlled(DownloadControl::Pause)) => {
|
||||
return DownloadOutcome::Paused;
|
||||
}
|
||||
Err(AttemptError::Controlled(DownloadControl::Cancel)) => {
|
||||
if let Err(e) = fs::remove_file(&payload.output_path).await {
|
||||
log::warn!(
|
||||
"Failed to remove cancelled file '{}': {}",
|
||||
payload.output_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
return DownloadOutcome::Cancelled;
|
||||
}
|
||||
Err(AttemptError::Controlled(DownloadControl::Replace)) => {
|
||||
return DownloadOutcome::Cancelled;
|
||||
}
|
||||
Err(AttemptError::Failed(error)) => {
|
||||
last_error = error.clone();
|
||||
|
||||
if attempts >= max_attempts {
|
||||
continue 'url;
|
||||
}
|
||||
|
||||
let transient = crate::retry::is_transient_network_error(&error);
|
||||
let strikes_left = strike < max_retries;
|
||||
|
||||
if transient && strikes_left {
|
||||
// Transient: announce `Retrying`, back off, then retry.
|
||||
// The backoff sleep is itself cancelable so a user
|
||||
// pause/cancel during the wait is honored immediately.
|
||||
events.emit_retrying(payload.id, strike, error);
|
||||
let delay = crate::retry::backoff_for(strike);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
control = control_rx.recv() => {
|
||||
return match control.unwrap_or(DownloadControl::Cancel) {
|
||||
DownloadControl::Pause => DownloadOutcome::Paused,
|
||||
DownloadControl::Cancel => {
|
||||
if let Err(e) = fs::remove_file(&payload.output_path).await {
|
||||
log::warn!("Failed to remove cancelled file '{}': {}", payload.output_path.display(), e);
|
||||
}
|
||||
DownloadOutcome::Cancelled
|
||||
}
|
||||
DownloadControl::Replace => DownloadOutcome::Cancelled,
|
||||
};
|
||||
}
|
||||
}
|
||||
strike += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !transient && !crate::retry::is_permanent_network_error(&error) {
|
||||
// Legacy `max_tries` cap for ambiguous HTTP statuses (e.g.
|
||||
// 500) that are neither clearly transient nor permanent.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Permanent error or transient strike budget exhausted.
|
||||
continue 'url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DownloadOutcome::Failed(last_error)
|
||||
}
|
||||
|
||||
enum AttemptError {
|
||||
Controlled(DownloadControl),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
async fn download_attempt(
|
||||
events: &CoordinatorEventSink,
|
||||
client: &Client,
|
||||
default_headers: &reqwest::header::HeaderMap,
|
||||
payload: &DownloadPayload,
|
||||
url: &str,
|
||||
control_rx: &mut mpsc::Receiver<DownloadControl>,
|
||||
) -> Result<(), AttemptError> {
|
||||
let existing_len = fs::metadata(&payload.output_path)
|
||||
.await
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
let mut request = client.get(url).headers(default_headers.clone());
|
||||
if existing_len > 0 {
|
||||
request = request.header(header::RANGE, format!("bytes={existing_len}-"));
|
||||
}
|
||||
if let Some(username) = payload
|
||||
.username
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
request = request.basic_auth(username, payload.password.as_deref());
|
||||
}
|
||||
|
||||
let response = tokio::select! {
|
||||
control = control_rx.recv() => {
|
||||
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
|
||||
}
|
||||
response = request.send() => {
|
||||
response.map_err(|error| AttemptError::Failed(error.to_string()))?
|
||||
}
|
||||
};
|
||||
if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) {
|
||||
return Err(AttemptError::Failed(format!(
|
||||
"{url} returned HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let resumed = existing_len > 0 && response.status() == StatusCode::PARTIAL_CONTENT;
|
||||
if resumed {
|
||||
let content_range = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_RANGE)
|
||||
.and_then(|h| h.to_str().ok());
|
||||
if !content_range.is_some_and(|r| r.starts_with(&format!("bytes {}-", existing_len))) {
|
||||
return Err(AttemptError::Failed(
|
||||
"Server returned invalid Content-Range for resume".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let completed_at_start = if resumed { existing_len } else { 0 };
|
||||
let total_len = response
|
||||
.content_length()
|
||||
.map(|remaining| remaining.saturating_add(completed_at_start));
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.append(resumed)
|
||||
.truncate(!resumed)
|
||||
.open(&payload.output_path)
|
||||
.await
|
||||
.map_err(|error| AttemptError::Failed(error.to_string()))?;
|
||||
let mut writer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, file);
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut last_emitted_at = Instant::now();
|
||||
let mut last_emitted_bytes = completed_at_start;
|
||||
let mut completed = completed_at_start;
|
||||
let speed_limit = payload.speed_limit.as_deref().and_then(parse_speed_limit);
|
||||
let transfer_started_at = Instant::now();
|
||||
let mut transferred_this_attempt = 0_u64;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
control = control_rx.recv() => {
|
||||
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
|
||||
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
|
||||
}
|
||||
chunk = stream.next() => {
|
||||
match chunk {
|
||||
Some(Ok(bytes)) => {
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.map_err(|error| AttemptError::Failed(error.to_string()))?;
|
||||
completed = completed.saturating_add(bytes.len() as u64);
|
||||
transferred_this_attempt =
|
||||
transferred_this_attempt.saturating_add(bytes.len() as u64);
|
||||
|
||||
if let Some(bytes_per_second) = speed_limit {
|
||||
let expected_elapsed =
|
||||
Duration::from_secs_f64(transferred_this_attempt as f64 / bytes_per_second as f64);
|
||||
let actual_elapsed = transfer_started_at.elapsed();
|
||||
if expected_elapsed > actual_elapsed {
|
||||
tokio::select! {
|
||||
control = control_rx.recv() => {
|
||||
writer.flush().await.map_err(|error| AttemptError::Failed(error.to_string()))?;
|
||||
return Err(AttemptError::Controlled(control.unwrap_or(DownloadControl::Cancel)));
|
||||
}
|
||||
_ = tokio::time::sleep(expected_elapsed - actual_elapsed) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let interval = now.duration_since(last_emitted_at);
|
||||
if interval >= PROGRESS_INTERVAL {
|
||||
events.emit_progress(
|
||||
payload.id,
|
||||
completed,
|
||||
total_len,
|
||||
completed.saturating_sub(last_emitted_bytes),
|
||||
interval,
|
||||
);
|
||||
last_emitted_at = now;
|
||||
last_emitted_bytes = completed;
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
writer.flush().await.map_err(|flush_error| AttemptError::Failed(flush_error.to_string()))?;
|
||||
return Err(AttemptError::Failed(error.to_string()));
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|error| AttemptError::Failed(error.to_string()))?;
|
||||
events.emit_progress(
|
||||
payload.id,
|
||||
completed,
|
||||
total_len,
|
||||
completed.saturating_sub(last_emitted_bytes),
|
||||
last_emitted_at.elapsed(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_client(payload: &DownloadPayload) -> Result<(Client, HeaderMap), String> {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(raw_headers) = payload.headers.as_deref() {
|
||||
for line in raw_headers
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
{
|
||||
let (name, value) = line
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("invalid HTTP header: {line}"))?;
|
||||
headers.insert(
|
||||
HeaderName::from_str(name.trim()).map_err(|error| error.to_string())?,
|
||||
HeaderValue::from_str(value.trim()).map_err(|error| error.to_string())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(cookies) = payload.cookies.as_deref().filter(|value| !value.is_empty()) {
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(cookies).map_err(|error| error.to_string())?,
|
||||
);
|
||||
}
|
||||
|
||||
let mut builder = Client::builder();
|
||||
if let Some(user_agent) = payload
|
||||
.user_agent
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
builder = builder.user_agent(user_agent);
|
||||
}
|
||||
if let Some(proxy) = payload.proxy.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if proxy.eq_ignore_ascii_case("none") {
|
||||
builder = builder.no_proxy();
|
||||
} else {
|
||||
builder = builder.proxy(
|
||||
reqwest::Proxy::all(proxy)
|
||||
.map_err(|_| "Invalid proxy URL configured".to_string())?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
builder
|
||||
.build()
|
||||
.map_err(|error| error.to_string())
|
||||
.map(|c| (c, headers))
|
||||
}
|
||||
|
||||
pub(crate) fn format_speed(bytes_per_second: f64) -> String {
|
||||
if bytes_per_second >= 1024.0 * 1024.0 {
|
||||
format!("{:.1} MB/s", bytes_per_second / (1024.0 * 1024.0))
|
||||
@@ -785,49 +217,11 @@ pub(crate) fn format_duration(seconds: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_speed_limit(value: &str) -> Option<u64> {
|
||||
let normalized = value.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() || normalized == "0" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (number, multiplier) = if let Some(number) = normalized.strip_suffix("kb/s") {
|
||||
(number, 1024.0)
|
||||
} else if let Some(number) = normalized.strip_suffix("mb/s") {
|
||||
(number, 1024.0 * 1024.0)
|
||||
} else if let Some(number) = normalized.strip_suffix("gb/s") {
|
||||
(number, 1024.0 * 1024.0 * 1024.0)
|
||||
} else if let Some(number) = normalized.strip_suffix('k') {
|
||||
(number, 1024.0)
|
||||
} else if let Some(number) = normalized.strip_suffix('m') {
|
||||
(number, 1024.0 * 1024.0)
|
||||
} else if let Some(number) = normalized.strip_suffix('g') {
|
||||
(number, 1024.0 * 1024.0 * 1024.0)
|
||||
} else {
|
||||
(normalized.as_str(), 1.0)
|
||||
};
|
||||
|
||||
number
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|number| *number > 0.0)
|
||||
.map(|number| (number * multiplier) as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_speed_limit, DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||
use super::{DownloadCmd, DownloadCoordinator, DownloadEvent};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn parses_aria_style_speed_limits() {
|
||||
assert_eq!(parse_speed_limit("512K"), Some(512 * 1024));
|
||||
assert_eq!(parse_speed_limit("1.5M"), Some(1_572_864));
|
||||
assert_eq!(parse_speed_limit("2 MB/s"), Some(2 * 1024 * 1024));
|
||||
assert_eq!(parse_speed_limit("0"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buffers_captured_urls_until_frontend_is_ready() {
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
|
||||
@@ -108,6 +108,8 @@ pub struct DownloadItem {
|
||||
pub queue_position: Option<i32>,
|
||||
#[ts(optional)]
|
||||
pub has_been_dispatched: Option<bool>,
|
||||
#[ts(optional)]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+3
-62
@@ -11,7 +11,6 @@ use std::time::{Duration, Instant};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn get_metadata_cache() -> &'static std::sync::Mutex<HashMap<String, String>> {
|
||||
static CACHE: OnceLock<std::sync::Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||
@@ -2942,7 +2941,7 @@ async fn pause_download(
|
||||
let removed_pending = state.queue_manager.remove_from_pending(&id).await;
|
||||
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
|
||||
if let Some(gid) = gid.as_deref() {
|
||||
let status = aria2_download_status(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
@@ -3013,11 +3012,6 @@ async fn pause_download(
|
||||
.download_coordinator
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.await?;
|
||||
} else if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(download::DownloadCmd::PauseWithAck(download_id, tx))
|
||||
.await?;
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
@@ -3052,13 +3046,6 @@ async fn resume_download(
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
return Ok(false);
|
||||
};
|
||||
if gid.starts_with("native:") {
|
||||
state.queue_manager.forget_aria2_gid(&id).await;
|
||||
log::info!("aria2 resume [{}]: native fallback has no aria2 gid", id);
|
||||
state.queue_manager.release_registered_id(&id).await;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let status = aria2_download_status(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
@@ -3212,7 +3199,7 @@ async fn remove_download(
|
||||
state.queue_manager.cancel_aria2_retries(&id).await;
|
||||
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
|
||||
if let Some(gid) = gid.as_deref() {
|
||||
let removal_result = async {
|
||||
force_remove_aria2_gid(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
@@ -3243,13 +3230,6 @@ async fn remove_download(
|
||||
.download_coordinator
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.await?;
|
||||
} else if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
let command = if delete_assets {
|
||||
download::DownloadCmd::CancelWithAck(download_id, tx)
|
||||
} else {
|
||||
download::DownloadCmd::PauseWithAck(download_id, tx)
|
||||
};
|
||||
state.download_coordinator.send(command).await?;
|
||||
} else {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
@@ -3356,7 +3336,7 @@ async fn detach_download_for_reconfigure(
|
||||
|
||||
let gid = state.queue_manager.aria2_gid_for_download(&id);
|
||||
|
||||
if let Some(gid) = gid.as_deref().filter(|gid| !gid.starts_with("native:")) {
|
||||
if let Some(gid) = gid.as_deref() {
|
||||
let removal_result = async {
|
||||
let pause_res = rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
@@ -3396,11 +3376,6 @@ async fn detach_download_for_reconfigure(
|
||||
.download_coordinator
|
||||
.pause_media_with_ack(id.clone(), tx)
|
||||
.await?;
|
||||
} else if let Ok(download_id) = Uuid::parse_str(&id) {
|
||||
state
|
||||
.download_coordinator
|
||||
.send(crate::download::DownloadCmd::PauseWithAck(download_id, tx))
|
||||
.await?;
|
||||
} else {
|
||||
let _ = tx.send(()); // Fallback if no task exists
|
||||
}
|
||||
@@ -5007,7 +4982,6 @@ pub fn run() {
|
||||
dispatcher_mgr.run_dispatcher().await;
|
||||
});
|
||||
|
||||
let queue_manager_clone = Arc::clone(&queue_manager);
|
||||
let queue_manager_poll = Arc::clone(&queue_manager);
|
||||
|
||||
app.manage(AppState {
|
||||
@@ -5024,39 +4998,6 @@ pub fn run() {
|
||||
queue_manager,
|
||||
});
|
||||
|
||||
// Backend listener: release permits + emit terminal state for
|
||||
// native (and aria2-fallback) downloads. Idempotent for Media/aria2
|
||||
// which already release via finish_runner/handle_aria2_event.
|
||||
let completion_app = app.handle().clone();
|
||||
let completion_mgr = Arc::clone(&queue_manager_clone);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
use tauri::Listener;
|
||||
let rx_complete = completion_app.listen("download-complete", move |event| {
|
||||
let raw_id = event.payload();
|
||||
let id: String = serde_json::from_str(raw_id)
|
||||
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
|
||||
let mgr = Arc::clone(&completion_mgr);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
mgr.apply_completion(&id, crate::queue::PendingOutcome::Complete).await;
|
||||
});
|
||||
});
|
||||
let completion_app2 = completion_app.clone();
|
||||
let completion_mgr2 = Arc::clone(&queue_manager_clone);
|
||||
let rx_failed = completion_app2.listen("download-failed", move |event| {
|
||||
let raw_id = event.payload();
|
||||
let id: String = serde_json::from_str(raw_id)
|
||||
.unwrap_or_else(|_| raw_id.trim_matches('"').to_string());
|
||||
let mgr = Arc::clone(&completion_mgr2);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
mgr.apply_completion(&id, crate::queue::PendingOutcome::Error("download failed".to_string())).await;
|
||||
});
|
||||
});
|
||||
// Keep the task alive; the listeners are unregistered on drop.
|
||||
std::future::pending::<()>().await;
|
||||
let _ = rx_complete;
|
||||
let _ = rx_failed;
|
||||
});
|
||||
|
||||
let deep_link_app = app.handle().clone();
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(error) = app.deep_link().register_all() {
|
||||
|
||||
+63
-99
@@ -28,7 +28,6 @@ pub enum PendingOutcome {
|
||||
pub enum TaskKind {
|
||||
Aria2,
|
||||
Media,
|
||||
Native,
|
||||
}
|
||||
|
||||
/// Everything needed to start a sidecar, captured at enqueue time so the
|
||||
@@ -64,7 +63,7 @@ pub struct SpawnPayload {
|
||||
pub is_media: bool,
|
||||
}
|
||||
|
||||
/// A sidecar spawner. In production this calls the real aria2/yt-dlp/native
|
||||
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
|
||||
/// runners; in tests it is replaced with a fake that records calls and
|
||||
/// optionally hangs to simulate a long-running download.
|
||||
#[async_trait::async_trait]
|
||||
@@ -80,9 +79,6 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
/// Run a media download to completion. The permit is parked for the full
|
||||
/// duration; release is handled by QueueManager on the runner's exit.
|
||||
async fn run_media(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
|
||||
|
||||
/// Run a native HTTP download to completion.
|
||||
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// The centralized concurrency gatekeeper. One instance lives in AppState.
|
||||
@@ -407,7 +403,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
let id = task.id.clone();
|
||||
// Park the permit BEFORE spawning. Uniform parking:
|
||||
// aria2's RPC returns instantly, so the permit must outlive the
|
||||
// dispatch_one call. Media/Native runners release on exit.
|
||||
// dispatch_one call. Media runners release on exit.
|
||||
self.park_permit(&id, permit).await;
|
||||
self.active_kinds
|
||||
.lock()
|
||||
@@ -432,15 +428,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
id,
|
||||
gid
|
||||
);
|
||||
if !gid.starts_with("native:") {
|
||||
if let Err(error) = self.spawner.remove_uri(&gid).await {
|
||||
log::warn!(
|
||||
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
|
||||
id,
|
||||
gid,
|
||||
error
|
||||
);
|
||||
}
|
||||
if let Err(error) = self.spawner.remove_uri(&gid).await {
|
||||
log::warn!(
|
||||
"aria2 dispatch cancellation [{}]: failed to remove late gid {}: {}",
|
||||
id,
|
||||
gid,
|
||||
error
|
||||
);
|
||||
}
|
||||
self.clear_aria2_retry_state(&id).await;
|
||||
self.release_permit(&id).await;
|
||||
@@ -464,21 +458,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
this.finish_runner(&id_for_task, outcome).await;
|
||||
});
|
||||
}
|
||||
TaskKind::Native => {
|
||||
// Native coordinator is event-driven (fire-and-observe). Send
|
||||
// Start; completion is handled by the download-complete/
|
||||
// download-failed listener in lib.rs setup() which calls
|
||||
// release_permit + apply_completion.
|
||||
let this = Arc::clone(&self);
|
||||
let payload = task.payload.clone();
|
||||
let id_for_task = id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = this.spawner.run_native(&id_for_task, &payload).await {
|
||||
this.emit_failed(&id_for_task, error);
|
||||
this.release_permit(&id_for_task).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,6 +903,17 @@ fn is_retryable_aria2_error(error: &str) -> bool {
|
||||
is_transient_network_error(error) || is_aria2_range_mode_error(error)
|
||||
}
|
||||
|
||||
fn is_aria2_rpc_unavailable(error: &str) -> bool {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
is_transient_network_error(error)
|
||||
|| lower.contains("aria2 did not become ready")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("failed to connect")
|
||||
|| lower.contains("error trying to connect")
|
||||
|| lower.contains("connection closed")
|
||||
|| lower.contains("connection reset")
|
||||
}
|
||||
|
||||
fn is_aria2_range_mode_error(error: &str) -> bool {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
lower.contains("invalid range header")
|
||||
@@ -1123,8 +1113,7 @@ fn parse_content_range_bounds(value: &str) -> Option<(u64, u64)> {
|
||||
Some((start.trim().parse().ok()?, end.trim().parse().ok()?))
|
||||
}
|
||||
|
||||
/// Production spawner that delegates to the real aria2 RPC, yt-dlp, and
|
||||
/// native coordinator runners.
|
||||
/// Production spawner that delegates to the real aria2 RPC and yt-dlp runners.
|
||||
pub struct ProductionSpawner {
|
||||
app_handle: AppHandle<tauri::Wry>,
|
||||
}
|
||||
@@ -1133,6 +1122,32 @@ impl ProductionSpawner {
|
||||
pub fn new(app_handle: AppHandle<tauri::Wry>) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
|
||||
async fn add_uri_rpc(
|
||||
&self,
|
||||
state: &crate::AppState,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||
loop {
|
||||
match crate::rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
"aria2.addUri",
|
||||
params.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => return Ok(result),
|
||||
Err(error) => {
|
||||
if !is_aria2_rpc_unavailable(&error) || std::time::Instant::now() >= deadline {
|
||||
return Err(error);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -1210,14 +1225,7 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
let uris = crate::collect_download_uris(&payload.url, payload.mirrors.as_deref());
|
||||
let params = serde_json::json!([uris, options]);
|
||||
|
||||
match crate::rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
"aria2.addUri",
|
||||
params,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match self.add_uri_rpc(&state, ¶ms).await {
|
||||
Ok(result) => {
|
||||
let gid = result.as_str().unwrap_or("").to_string();
|
||||
if gid.is_empty() {
|
||||
@@ -1228,35 +1236,8 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// aria2 unavailable — fall back to native coordinator.
|
||||
log::warn!("aria2 addUri failed, falling back to native: {}", e);
|
||||
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
|
||||
let mt = automatic_retry_limit(payload.max_tries) as u32;
|
||||
let safe_filename =
|
||||
crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||
state
|
||||
.download_coordinator
|
||||
.send(crate::download::DownloadCmd::Start(Box::new(
|
||||
crate::download::DownloadPayload {
|
||||
id: download_id,
|
||||
urls: crate::collect_download_uris(
|
||||
&payload.url,
|
||||
payload.mirrors.as_deref(),
|
||||
),
|
||||
output_path: resolved_dest.join(safe_filename),
|
||||
speed_limit: payload.speed_limit.clone(),
|
||||
username: payload.username.clone(),
|
||||
password: payload.password.clone(),
|
||||
headers: payload.headers.clone(),
|
||||
cookies: payload.cookies.clone(),
|
||||
user_agent: payload.user_agent.clone(),
|
||||
max_tries: mt,
|
||||
proxy: payload.proxy.clone(),
|
||||
},
|
||||
)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(format!("native:{id}"))
|
||||
log::error!("aria2 addUri [{}] failed: {}", id, e);
|
||||
Err(format!("aria2 addUri failed: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1320,36 +1301,6 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
.await;
|
||||
outcome.map(|_| ())
|
||||
}
|
||||
|
||||
async fn run_native(&self, id: &str, payload: &SpawnPayload) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let download_id = uuid::Uuid::parse_str(id).map_err(|e| e.to_string())?;
|
||||
let mt = automatic_retry_limit(payload.max_tries) as u32;
|
||||
let resolved_dest = crate::resolve_path(&payload.destination, &self.app_handle);
|
||||
let safe_filename =
|
||||
crate::download_ownership::canonical_download_filename(&payload.filename);
|
||||
let output_path = resolved_dest.join(safe_filename);
|
||||
let _ = crate::download_ownership::set_primary_path(&self.app_handle, id, &output_path);
|
||||
state
|
||||
.download_coordinator
|
||||
.send(crate::download::DownloadCmd::Start(Box::new(
|
||||
crate::download::DownloadPayload {
|
||||
id: download_id,
|
||||
urls: crate::collect_download_uris(&payload.url, payload.mirrors.as_deref()),
|
||||
output_path,
|
||||
speed_limit: payload.speed_limit.clone(),
|
||||
username: payload.username.clone(),
|
||||
password: payload.password.clone(),
|
||||
headers: payload.headers.clone(),
|
||||
cookies: payload.cookies.clone(),
|
||||
user_agent: payload.user_agent.clone(),
|
||||
max_tries: mt,
|
||||
proxy: payload.proxy.clone(),
|
||||
},
|
||||
)))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, TS)]
|
||||
@@ -1464,4 +1415,17 @@ mod tests {
|
||||
));
|
||||
assert!(!is_retryable_aria2_error("No URI available."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_startup_rpc_errors_are_retryable() {
|
||||
assert!(is_aria2_rpc_unavailable(
|
||||
"error trying to connect: tcp connect error: Connection refused"
|
||||
));
|
||||
assert!(is_aria2_rpc_unavailable(
|
||||
"aria2 did not become ready: connection refused"
|
||||
));
|
||||
assert!(!is_aria2_rpc_unavailable(
|
||||
"aria2 error code 3: Resource not found"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-14
@@ -1,5 +1,5 @@
|
||||
//! Connection-aware retry engine, shared by all three download backends
|
||||
//! (native `reqwest`, `yt-dlp` media, and `aria2c`).
|
||||
//! Connection-aware retry engine, shared by aria2c file downloads and yt-dlp
|
||||
//! media downloads.
|
||||
//!
|
||||
//! ## Design contract
|
||||
//!
|
||||
@@ -9,11 +9,9 @@
|
||||
//! allocation (semaphore permit / worker slot) is preserved.
|
||||
//!
|
||||
//! This module is deliberately runtime-agnostic and free of Tauri types so it
|
||||
//! can be unit-tested headlessly. Each backend translates the schedule into its
|
||||
//! own state-emission + cancellation vocabulary:
|
||||
//! can be unit-tested headlessly. Each path translates the schedule into its
|
||||
//! own state-emission and cancellation vocabulary:
|
||||
//!
|
||||
//! - **Native** (`download.rs`): calls [`BACKOFF_SCHEDULE`] inside the existing
|
||||
//! `control_rx` `tokio::select!`, so pause/cancel still interrupt backoff.
|
||||
//! - **yt-dlp** (`lib.rs`): sleeps between child re-spawns; `--continue` resumes.
|
||||
//! - **aria2** (`queue.rs` / WS poller): sleeps before re-issuing `aria2.addUri`.
|
||||
//!
|
||||
@@ -67,10 +65,8 @@ pub fn backoff_for(strike: usize) -> Duration {
|
||||
/// Classify an error string as a transient network condition worth retrying.
|
||||
///
|
||||
/// Returns `true` for socket drops, connect/read timeouts, connection resets,
|
||||
/// and HTTP 408 / request-timeout conditions across all three backends:
|
||||
/// and HTTP 408 / request-timeout conditions across both download paths:
|
||||
///
|
||||
/// - **reqwest**: `error.is_timeout()`, `error.is_connect()` surface as
|
||||
/// "operation timed out", "error sending request", "connection reset".
|
||||
/// - **yt-dlp**: stderr lines like `ERROR: unable to ... Connection timed out`,
|
||||
/// `HTTP Error 408`.
|
||||
/// - **aria2c**: `Timeout.`, `Connection was closed by server`.
|
||||
@@ -103,7 +99,7 @@ pub fn is_transient_network_error(message: &str) -> bool {
|
||||
let m = message.to_ascii_lowercase();
|
||||
|
||||
const TRANSIENT: [&str; 35] = [
|
||||
// reqwest / hyper / OS socket-layer
|
||||
// socket-layer / HTTP-client phrasing surfaced by aria2 and yt-dlp
|
||||
"timed out",
|
||||
"timeout",
|
||||
"connection reset",
|
||||
@@ -231,7 +227,7 @@ mod tests {
|
||||
// --- transient classification: positive cases -------------------------
|
||||
|
||||
#[test]
|
||||
fn classifies_reqwest_timeouts_as_transient() {
|
||||
fn classifies_socket_timeouts_as_transient() {
|
||||
assert!(is_transient_network_error("operation timed out"));
|
||||
assert!(is_transient_network_error(
|
||||
"error sending request: operation timed out"
|
||||
@@ -305,9 +301,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn permanent_keyword_wins_over_transient_in_composite_message() {
|
||||
// The native backend formats HTTP statuses as "{url} returned HTTP {status}"
|
||||
// (download.rs). A 404 whose URL happens to contain "timeout" must still
|
||||
// fail fast because the explicit "http 404" token wins.
|
||||
// A 404 whose URL happens to contain "timeout" must still fail fast
|
||||
// because the explicit "http 404" token wins.
|
||||
assert!(!is_transient_network_error(
|
||||
"https://site/timeout-page returned HTTP 404 Not Found"
|
||||
));
|
||||
|
||||
+11
-13
@@ -1,4 +1,4 @@
|
||||
# Headless Download Engine Tests
|
||||
# Headless Download Tests
|
||||
|
||||
Run the full Rust suite:
|
||||
|
||||
@@ -7,21 +7,19 @@ cd src-tauri
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
Run only the async download integration harness with deterministic serial
|
||||
performance measurements and visible test output:
|
||||
Run the queue-manager harness when changing aria2 scheduling, concurrency, and
|
||||
retry behavior:
|
||||
|
||||
```sh
|
||||
cd src-tauri
|
||||
RUST_BACKTRACE=1 cargo test --test download_engine -- --test-threads=1 --nocapture
|
||||
cargo test --test queue_manager -- --nocapture
|
||||
```
|
||||
|
||||
The harness binds an ephemeral loopback port and requires no GUI, external
|
||||
network access, or bundled media binaries. It validates:
|
||||
Run the media metadata smoke test with an explicit URL when changing yt-dlp
|
||||
integration:
|
||||
|
||||
- aggregation of many streamed HTTP body chunks;
|
||||
- pause and ranged resume through `DownloadCoordinator`;
|
||||
- cancellation and partial-file cleanup;
|
||||
- SHA-256 integrity after resume;
|
||||
- retry recovery from transient HTTP failures;
|
||||
- terminal error reporting after the retry budget is exhausted;
|
||||
- a five-second local transfer performance budget for a 3 MiB fixture.
|
||||
```sh
|
||||
cd src-tauri
|
||||
FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \
|
||||
cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture
|
||||
```
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::State,
|
||||
http::{
|
||||
header::{ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_RANGE, RANGE},
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use firelink_lib::download::{DownloadCmd, DownloadCoordinator, DownloadEvent, DownloadPayload};
|
||||
use futures_util::stream;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState {
|
||||
data: Arc<Vec<u8>>,
|
||||
chunk_size: usize,
|
||||
chunk_delay: Duration,
|
||||
failures_remaining: Arc<AtomicUsize>,
|
||||
requests: Arc<AtomicUsize>,
|
||||
chunks_served: Arc<AtomicUsize>,
|
||||
range_starts: Arc<Mutex<Vec<Option<usize>>>>,
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
state: ServerState,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
async fn spawn(
|
||||
data: Vec<u8>,
|
||||
chunk_size: usize,
|
||||
chunk_delay: Duration,
|
||||
failures_before_success: usize,
|
||||
) -> Self {
|
||||
let state = ServerState {
|
||||
data: Arc::new(data),
|
||||
chunk_size,
|
||||
chunk_delay,
|
||||
failures_remaining: Arc::new(AtomicUsize::new(failures_before_success)),
|
||||
requests: Arc::new(AtomicUsize::new(0)),
|
||||
chunks_served: Arc::new(AtomicUsize::new(0)),
|
||||
range_starts: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/file", get(serve_file))
|
||||
.route("/always-fail", get(always_fail))
|
||||
.with_state(state.clone());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
Self {
|
||||
base_url: format!("http://{address}"),
|
||||
state,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_url(&self) -> String {
|
||||
format!("{}/file", self.base_url)
|
||||
}
|
||||
|
||||
fn failure_url(&self) -> String {
|
||||
format!("{}/always-fail", self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_file(State(state): State<ServerState>, headers: HeaderMap) -> Response<Body> {
|
||||
state.requests.fetch_add(1, Ordering::SeqCst);
|
||||
if state
|
||||
.failures_remaining
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
||||
remaining.checked_sub(1)
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return response(StatusCode::SERVICE_UNAVAILABLE, Body::empty(), 0, None);
|
||||
}
|
||||
|
||||
let requested_start = headers
|
||||
.get(RANGE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(parse_range_start);
|
||||
state.range_starts.lock().unwrap().push(requested_start);
|
||||
|
||||
let start = requested_start.unwrap_or(0);
|
||||
if start >= state.data.len() {
|
||||
return response(
|
||||
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
Body::empty(),
|
||||
0,
|
||||
Some(format!("bytes */{}", state.data.len())),
|
||||
);
|
||||
}
|
||||
|
||||
let status = if requested_start.is_some() {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
let content_range = requested_start.map(|_| {
|
||||
format!(
|
||||
"bytes {start}-{}/{}",
|
||||
state.data.len() - 1,
|
||||
state.data.len()
|
||||
)
|
||||
});
|
||||
let content_length = state.data.len() - start;
|
||||
let data = state.data.clone();
|
||||
let chunk_size = state.chunk_size;
|
||||
let chunk_delay = state.chunk_delay;
|
||||
let chunks_served = state.chunks_served.clone();
|
||||
let body_stream = stream::unfold(start, move |offset| {
|
||||
let data = data.clone();
|
||||
let chunks_served = chunks_served.clone();
|
||||
async move {
|
||||
if offset >= data.len() {
|
||||
return None;
|
||||
}
|
||||
if !chunk_delay.is_zero() {
|
||||
tokio::time::sleep(chunk_delay).await;
|
||||
}
|
||||
let end = (offset + chunk_size).min(data.len());
|
||||
chunks_served.fetch_add(1, Ordering::SeqCst);
|
||||
let chunk = Bytes::copy_from_slice(&data[offset..end]);
|
||||
Some((Ok::<_, Infallible>(chunk), end))
|
||||
}
|
||||
});
|
||||
|
||||
response(
|
||||
status,
|
||||
Body::from_stream(body_stream),
|
||||
content_length,
|
||||
content_range,
|
||||
)
|
||||
}
|
||||
|
||||
async fn always_fail(State(state): State<ServerState>) -> Response<Body> {
|
||||
state.requests.fetch_add(1, Ordering::SeqCst);
|
||||
response(StatusCode::INTERNAL_SERVER_ERROR, Body::empty(), 0, None)
|
||||
}
|
||||
|
||||
fn response(
|
||||
status: StatusCode,
|
||||
body: Body,
|
||||
content_length: usize,
|
||||
content_range: Option<String>,
|
||||
) -> Response<Body> {
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&content_length.to_string()).unwrap(),
|
||||
);
|
||||
if let Some(content_range) = content_range {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&content_range).unwrap(),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn parse_range_start(value: &str) -> Option<usize> {
|
||||
value
|
||||
.strip_prefix("bytes=")?
|
||||
.strip_suffix('-')?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn fixture_data(size: usize) -> Vec<u8> {
|
||||
(0..size)
|
||||
.map(|index| ((index.wrapping_mul(31) + index / 7) % 251) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn payload(id: Uuid, url: String, output_path: std::path::PathBuf) -> DownloadPayload {
|
||||
DownloadPayload {
|
||||
id,
|
||||
urls: vec![url],
|
||||
output_path,
|
||||
speed_limit: None,
|
||||
username: None,
|
||||
password: None,
|
||||
headers: None,
|
||||
cookies: None,
|
||||
user_agent: Some("Firelink integration test".to_string()),
|
||||
max_tries: 1,
|
||||
proxy: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_event(events: &mut mpsc::UnboundedReceiver<DownloadEvent>) -> DownloadEvent {
|
||||
tokio::time::timeout(TEST_TIMEOUT, events.recv())
|
||||
.await
|
||||
.expect("timed out waiting for download event")
|
||||
.expect("download event channel closed")
|
||||
}
|
||||
|
||||
async fn wait_for_progress(
|
||||
events: &mut mpsc::UnboundedReceiver<DownloadEvent>,
|
||||
id: Uuid,
|
||||
minimum_completed: u64,
|
||||
) {
|
||||
loop {
|
||||
match next_event(events).await {
|
||||
DownloadEvent::Progress {
|
||||
id: event_id,
|
||||
completed,
|
||||
..
|
||||
} if event_id == id && completed >= minimum_completed => return,
|
||||
DownloadEvent::Failed { error, .. } => panic!("download failed: {error}"),
|
||||
DownloadEvent::Completed(event_id) if event_id == id => {
|
||||
panic!("download completed before it could be paused")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_completion(
|
||||
events: &mut mpsc::UnboundedReceiver<DownloadEvent>,
|
||||
id: Uuid,
|
||||
) -> Vec<DownloadEvent> {
|
||||
let mut observed = Vec::new();
|
||||
loop {
|
||||
let event = next_event(events).await;
|
||||
match &event {
|
||||
DownloadEvent::Completed(event_id) if *event_id == id => {
|
||||
observed.push(event);
|
||||
return observed;
|
||||
}
|
||||
DownloadEvent::Failed {
|
||||
id: event_id,
|
||||
error,
|
||||
} if *event_id == id => panic!("download failed: {error}"),
|
||||
_ => observed.push(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256(data: &[u8]) -> Vec<u8> {
|
||||
Sha256::digest(data).to_vec()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn pause_then_resume_uses_range_and_preserves_integrity() {
|
||||
let expected = fixture_data(4 * 1024 * 1024);
|
||||
let server = TestServer::spawn(expected.clone(), 16 * 1024, Duration::from_millis(4), 0).await;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let output_path = temp.path().join("paused.bin");
|
||||
let id = Uuid::from_u128(1);
|
||||
let download = payload(id, server.file_url(), output_path.clone());
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(download)))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_progress(&mut events, id, 256 * 1024).await;
|
||||
let (pause_tx, pause_rx) = oneshot::channel();
|
||||
coordinator
|
||||
.send(DownloadCmd::PauseWithAck(id, pause_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
pause_rx.await.unwrap();
|
||||
|
||||
let paused_len = tokio::fs::metadata(&output_path).await.unwrap().len();
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let stable_paused_len = tokio::fs::metadata(&output_path).await.unwrap().len();
|
||||
assert!(paused_len >= 256 * 1024);
|
||||
assert!(paused_len < expected.len() as u64);
|
||||
assert_eq!(stable_paused_len, paused_len);
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(payload(
|
||||
id,
|
||||
server.file_url(),
|
||||
output_path.clone(),
|
||||
))))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_completion(&mut events, id).await;
|
||||
|
||||
let downloaded = tokio::fs::read(&output_path).await.unwrap();
|
||||
assert_eq!(downloaded.len(), expected.len());
|
||||
assert_eq!(sha256(&downloaded), sha256(&expected));
|
||||
let range_starts = server.state.range_starts.lock().unwrap().clone();
|
||||
assert_eq!(range_starts.first(), Some(&None));
|
||||
assert!(
|
||||
range_starts
|
||||
.iter()
|
||||
.skip(1)
|
||||
.flatten()
|
||||
.any(|start| *start == paused_len as usize),
|
||||
"resume request did not start at the paused file length: {range_starts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn aggregates_many_http_chunks_with_complete_progress() {
|
||||
let expected = fixture_data(3 * 1024 * 1024);
|
||||
let server = TestServer::spawn(expected.clone(), 8 * 1024, Duration::ZERO, 0).await;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let output_path = temp.path().join("chunked.bin");
|
||||
let id = Uuid::from_u128(2);
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
let started = Instant::now();
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(payload(
|
||||
id,
|
||||
server.file_url(),
|
||||
output_path.clone(),
|
||||
))))
|
||||
.await
|
||||
.unwrap();
|
||||
let observed = wait_for_completion(&mut events, id).await;
|
||||
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"local 3 MiB transfer exceeded the performance budget"
|
||||
);
|
||||
assert!(server.state.chunks_served.load(Ordering::SeqCst) > 100);
|
||||
let final_progress = observed.iter().rev().find_map(|event| match event {
|
||||
DownloadEvent::Progress {
|
||||
id: event_id,
|
||||
fraction,
|
||||
completed,
|
||||
total,
|
||||
} if *event_id == id => Some((*fraction, *completed, *total)),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
final_progress,
|
||||
Some((1.0, expected.len() as u64, Some(expected.len() as u64)))
|
||||
);
|
||||
assert_eq!(
|
||||
sha256(&tokio::fs::read(output_path).await.unwrap()),
|
||||
sha256(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn retries_transient_http_failures_then_completes() {
|
||||
let expected = fixture_data(512 * 1024);
|
||||
let server = TestServer::spawn(expected.clone(), 32 * 1024, Duration::ZERO, 2).await;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let output_path = temp.path().join("retry.bin");
|
||||
let id = Uuid::from_u128(3);
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
let mut download = payload(id, server.file_url(), output_path.clone());
|
||||
download.max_tries = 3;
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(download)))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_completion(&mut events, id).await;
|
||||
|
||||
assert_eq!(server.state.requests.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(
|
||||
sha256(&tokio::fs::read(output_path).await.unwrap()),
|
||||
sha256(&expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn reports_terminal_http_errors_after_retry_budget() {
|
||||
let server = TestServer::spawn(Vec::new(), 8 * 1024, Duration::ZERO, 0).await;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let output_path = temp.path().join("failed.bin");
|
||||
let id = Uuid::from_u128(4);
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
let mut download = payload(id, server.failure_url(), output_path.clone());
|
||||
download.max_tries = 2;
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(download)))
|
||||
.await
|
||||
.unwrap();
|
||||
let error = loop {
|
||||
match next_event(&mut events).await {
|
||||
DownloadEvent::Failed {
|
||||
id: event_id,
|
||||
error,
|
||||
} if event_id == id => break error,
|
||||
DownloadEvent::Completed(event_id) if event_id == id => {
|
||||
panic!("failed download was reported as complete")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
|
||||
assert!(error.contains("500 Internal Server Error"));
|
||||
assert_eq!(server.state.requests.load(Ordering::SeqCst), 3);
|
||||
assert!(!output_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn cancel_removes_partial_file_without_terminal_success_event() {
|
||||
let expected = fixture_data(4 * 1024 * 1024);
|
||||
let server = TestServer::spawn(expected, 16 * 1024, Duration::from_millis(4), 0).await;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let output_path = temp.path().join("cancelled.bin");
|
||||
let id = Uuid::from_u128(5);
|
||||
let (coordinator, mut events) = DownloadCoordinator::spawn_headless();
|
||||
|
||||
coordinator
|
||||
.send(DownloadCmd::Start(Box::new(payload(
|
||||
id,
|
||||
server.file_url(),
|
||||
output_path.clone(),
|
||||
))))
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for_progress(&mut events, id, 256 * 1024).await;
|
||||
let (cancelled_tx, cancelled_rx) = tokio::sync::oneshot::channel();
|
||||
coordinator
|
||||
.send(DownloadCmd::CancelWithAck(id, cancelled_tx))
|
||||
.await
|
||||
.unwrap();
|
||||
cancelled_rx.await.unwrap();
|
||||
|
||||
tokio::time::timeout(TEST_TIMEOUT, async {
|
||||
while output_path.exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled partial file was not removed");
|
||||
|
||||
while let Ok(Some(event)) =
|
||||
tokio::time::timeout(Duration::from_millis(100), events.recv()).await
|
||||
{
|
||||
assert!(
|
||||
!matches!(
|
||||
event,
|
||||
DownloadEvent::Completed(event_id) if event_id == id
|
||||
),
|
||||
"cancelled download emitted a completion event"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
event,
|
||||
DownloadEvent::Failed { id: event_id, .. } if event_id == id
|
||||
),
|
||||
"cancelled download emitted a failure event"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ use tokio::time::timeout;
|
||||
struct CountingSpawner {
|
||||
add_uri_calls: AtomicUsize,
|
||||
media_calls: AtomicUsize,
|
||||
native_calls: AtomicUsize,
|
||||
}
|
||||
|
||||
struct DelayedAria2Spawner {
|
||||
@@ -47,10 +46,6 @@ impl SidecarSpawner for DelayedAria2Spawner {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
unreachable!("media is not used by delayed aria2 tests")
|
||||
}
|
||||
|
||||
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
unreachable!("native is not used by delayed aria2 tests")
|
||||
}
|
||||
}
|
||||
|
||||
impl CountingSpawner {
|
||||
@@ -58,7 +53,6 @@ impl CountingSpawner {
|
||||
Self {
|
||||
add_uri_calls: AtomicUsize::new(0),
|
||||
media_calls: AtomicUsize::new(0),
|
||||
native_calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,10 +70,6 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
self.native_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a QueueManager with a fake spawner. Tauri's mock AppHandle is needed
|
||||
@@ -97,7 +87,7 @@ fn sample_task(id: &str) -> QueuedTask {
|
||||
QueuedTask {
|
||||
id: id.to_string(),
|
||||
queue_id: "main".to_string(),
|
||||
kind: TaskKind::Native,
|
||||
kind: TaskKind::Aria2,
|
||||
payload: SpawnPayload::default(),
|
||||
}
|
||||
}
|
||||
@@ -243,18 +233,18 @@ async fn grow_releases_immediately_and_dispatches_waiting_tasks() {
|
||||
|
||||
// Give dispatcher time to dispatch 2 (capacity) of the 4.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let native_after_initial = spawner.native_calls.load(Ordering::SeqCst);
|
||||
let aria2_after_initial = spawner.add_uri_calls.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
native_after_initial, 2,
|
||||
aria2_after_initial, 2,
|
||||
"only capacity-many tasks dispatch initially"
|
||||
);
|
||||
|
||||
// Grow to 4; the remaining 2 should dispatch.
|
||||
mgr_arc.set_capacity(4);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
let native_after_grow = spawner.native_calls.load(Ordering::SeqCst);
|
||||
let aria2_after_grow = spawner.add_uri_calls.load(Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
native_after_grow, 4,
|
||||
aria2_after_grow, 4,
|
||||
"grow must allow the waiting tasks to dispatch"
|
||||
);
|
||||
|
||||
@@ -276,7 +266,7 @@ async fn shrink_converges_to_target_without_killing_active() {
|
||||
|
||||
// Let 4 dispatch (capacity).
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(spawner.native_calls.load(Ordering::SeqCst), 4);
|
||||
assert_eq!(spawner.add_uri_calls.load(Ordering::SeqCst), 4);
|
||||
|
||||
// Shrink to 2 while 4 are "active" (permits parked).
|
||||
mgr_arc.set_capacity(2);
|
||||
@@ -288,7 +278,7 @@ async fn shrink_converges_to_target_without_killing_active() {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(
|
||||
spawner.native_calls.load(Ordering::SeqCst),
|
||||
spawner.add_uri_calls.load(Ordering::SeqCst),
|
||||
4,
|
||||
"pending tasks must not dispatch while active count already meets the shrunken target"
|
||||
);
|
||||
@@ -298,7 +288,7 @@ async fn shrink_converges_to_target_without_killing_active() {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
assert_eq!(
|
||||
spawner.native_calls.load(Ordering::SeqCst),
|
||||
spawner.add_uri_calls.load(Ordering::SeqCst),
|
||||
6,
|
||||
"pending tasks dispatch after active count falls below the shrunken target"
|
||||
);
|
||||
@@ -372,10 +362,6 @@ impl SidecarSpawner for FixedMediaSpawner {
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
self.outcome.clone()
|
||||
}
|
||||
|
||||
async fn run_native(&self, _id: &str, _payload: &SpawnPayload) -> Result<(), String> {
|
||||
unreachable!("native downloads are not used by media terminal-state tests")
|
||||
}
|
||||
}
|
||||
|
||||
fn make_media_manager(
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
import type { DownloadCategory } from "./DownloadCategory";
|
||||
import type { DownloadStatus } from "./DownloadStatus";
|
||||
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, };
|
||||
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, };
|
||||
|
||||
@@ -132,7 +132,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
key={`status-${download.status}`}
|
||||
ref={statusTextRef}
|
||||
title={
|
||||
(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||
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)}%`
|
||||
|
||||
@@ -206,8 +206,14 @@ export const PropertiesModal = () => {
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
|
||||
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
|
||||
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={saveLocation}>{saveLocation || baseDownloadFolder}</span></div>
|
||||
{item.lastError && (item.status === 'failed' || item.status === 'retrying') && (
|
||||
<div className="flex gap-1.5 col-span-4 min-w-0">
|
||||
<span className="text-text-muted font-medium w-[90px] shrink-0">Last error</span>
|
||||
<span className="text-red-400 truncate" title={item.lastError}>{item.lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function SpeedLimiterView() {
|
||||
<Gauge size={18} className="text-accent" /> Global Speed Limit
|
||||
</div>
|
||||
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
|
||||
Applies to new and active aria2 transfers, native fallback transfers, and yt-dlp media downloads. Per-download limits still take precedence.
|
||||
Applies to new and active aria2 transfers and yt-dlp media downloads. Per-download limits still take precedence.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
|
||||
@@ -69,8 +69,12 @@ export async function initDownloadListener() {
|
||||
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
|
||||
const updates: Partial<DownloadItem> = {
|
||||
status,
|
||||
...(progress ? { fraction: progress.fraction } : {})
|
||||
...(progress ? { fraction: progress.fraction } : {}),
|
||||
...(payload.error ? { lastError: payload.error } : {})
|
||||
};
|
||||
if (!payload.error && status !== 'failed' && status !== 'retrying') {
|
||||
updates.lastError = undefined;
|
||||
}
|
||||
if (payload.fileName && payload.fileName !== current.fileName) {
|
||||
updates.fileName = payload.fileName;
|
||||
updates.category = categoryForFileName(payload.fileName);
|
||||
|
||||
@@ -268,6 +268,41 @@ describe('useDownloadStore', () => {
|
||||
|
||||
expect(added).toBe(false);
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('failed');
|
||||
expect(useDownloadStore.getState().downloads[0].lastError).toBe('backend unavailable');
|
||||
});
|
||||
|
||||
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-failed',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: '00000000-0000-0000-0000-000000000001',
|
||||
hasBeenDispatched: true
|
||||
})];
|
||||
}
|
||||
if (cmd === 'enqueue_many') {
|
||||
return [{
|
||||
id: 'startup-failed',
|
||||
success: false,
|
||||
error: 'aria2 addUri failed: connection refused'
|
||||
}];
|
||||
}
|
||||
if (cmd === 'get_pending_order') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
lastError: 'aria2 addUri failed: connection refused'
|
||||
});
|
||||
});
|
||||
|
||||
it('redownloads fallback media without requiring a format selector', async () => {
|
||||
|
||||
@@ -17,6 +17,9 @@ export type { DownloadCategory } from '../utils/downloads';
|
||||
|
||||
const backendDispatchPromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export async function dispatchItem(id: string): Promise<boolean> {
|
||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||
|
||||
@@ -73,10 +76,14 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
|
||||
useDownloadStore.getState().setPendingOrder(order);
|
||||
useDownloadStore.getState().registerBackendIds([id]);
|
||||
useDownloadStore.getState().updateDownload(id, { lastError: undefined });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(`Failed to dispatch ${id}:`, e);
|
||||
useDownloadStore.getState().updateDownload(id, { status: 'failed' });
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: 'failed',
|
||||
lastError: errorMessage(e)
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
backendDispatchPromises.delete(id);
|
||||
@@ -856,7 +863,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
const registeredIds = results.filter(result => result.success).map(result => result.id);
|
||||
const failedIds = new Set(results.filter(result => !result.success).map(result => result.id));
|
||||
const failedErrors = new Map(
|
||||
results
|
||||
.filter(result => !result.success)
|
||||
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
|
||||
);
|
||||
const order = await invoke('get_pending_order', { queueId: null });
|
||||
set(state => ({
|
||||
pendingOrder: order,
|
||||
@@ -865,10 +876,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
...registeredIds
|
||||
]),
|
||||
downloads: state.downloads.map(download =>
|
||||
failedIds.has(download.id)
|
||||
? { ...download, status: 'failed' as const }
|
||||
failedErrors.has(download.id)
|
||||
? {
|
||||
...download,
|
||||
status: 'failed' as const,
|
||||
lastError: failedErrors.get(download.id)
|
||||
}
|
||||
: registeredIds.includes(download.id)
|
||||
? { ...download, hasBeenDispatched: true }
|
||||
? { ...download, hasBeenDispatched: true, lastError: undefined }
|
||||
: download
|
||||
)
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user