diff --git a/Extensions/Firefox b/Extensions/Firefox index 42b28fa..be92378 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit 42b28fa4f6352d3a412d2b3daa540512a08f1231 +Subproject commit be92378310e5fab7251004f416b320623ae9509e diff --git a/README.md b/README.md index 12cdd9a..9c78ed2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/engine-sources.lock.json b/engine-sources.lock.json index f8d6a39..9998a5c 100644 --- a/engine-sources.lock.json +++ b/engine-sources.lock.json @@ -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", diff --git a/engines.lock.json b/engines.lock.json index feccda6..4a8296b 100644 --- a/engines.lock.json +++ b/engines.lock.json @@ -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, diff --git a/src-tauri/binaries/_internal/Python b/src-tauri/binaries/_internal/Python index d9782ba..4790821 100755 Binary files a/src-tauri/binaries/_internal/Python and b/src-tauri/binaries/_internal/Python differ diff --git a/src-tauri/binaries/_internal/Python.framework/Python b/src-tauri/binaries/_internal/Python.framework/Python index d9782ba..4790821 100755 Binary files a/src-tauri/binaries/_internal/Python.framework/Python and b/src-tauri/binaries/_internal/Python.framework/Python differ diff --git a/src-tauri/binaries/_internal/Python.framework/Resources/Info.plist b/src-tauri/binaries/_internal/Python.framework/Resources/Info.plist index d72ed27..a7e87c4 100644 --- a/src-tauri/binaries/_internal/Python.framework/Resources/Info.plist +++ b/src-tauri/binaries/_internal/Python.framework/Resources/Info.plist @@ -7,7 +7,7 @@ CFBundleIdentifier org.python.python CFBundleVersion - 3.14.5 + 3.14.6 CFBundlePackageType FMWK CFBundleSignature @@ -17,7 +17,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleShortVersionString - 3.14.5 + 3.14.6 CFBundleSupportedPlatforms MacOSX diff --git a/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Python b/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Python index d9782ba..4790821 100755 Binary files a/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Python and b/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Python differ diff --git a/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Resources/Info.plist b/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Resources/Info.plist index d72ed27..a7e87c4 100644 --- a/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Resources/Info.plist +++ b/src-tauri/binaries/_internal/Python.framework/Versions/3.14/Resources/Info.plist @@ -7,7 +7,7 @@ CFBundleIdentifier org.python.python CFBundleVersion - 3.14.5 + 3.14.6 CFBundlePackageType FMWK CFBundleSignature @@ -17,7 +17,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleShortVersionString - 3.14.5 + 3.14.6 CFBundleSupportedPlatforms MacOSX diff --git a/src-tauri/binaries/_internal/Python.framework/Versions/Current/Python b/src-tauri/binaries/_internal/Python.framework/Versions/Current/Python index d9782ba..4790821 100755 Binary files a/src-tauri/binaries/_internal/Python.framework/Versions/Current/Python and b/src-tauri/binaries/_internal/Python.framework/Versions/Current/Python differ diff --git a/src-tauri/binaries/_internal/Python.framework/Versions/Current/Resources/Info.plist b/src-tauri/binaries/_internal/Python.framework/Versions/Current/Resources/Info.plist index d72ed27..a7e87c4 100644 --- a/src-tauri/binaries/_internal/Python.framework/Versions/Current/Resources/Info.plist +++ b/src-tauri/binaries/_internal/Python.framework/Versions/Current/Resources/Info.plist @@ -7,7 +7,7 @@ CFBundleIdentifier org.python.python CFBundleVersion - 3.14.5 + 3.14.6 CFBundlePackageType FMWK CFBundleSignature @@ -17,7 +17,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleShortVersionString - 3.14.5 + 3.14.6 CFBundleSupportedPlatforms MacOSX diff --git a/src-tauri/binaries/_internal/base_library.zip b/src-tauri/binaries/_internal/base_library.zip index 52ed9d0..d952159 100644 Binary files a/src-tauri/binaries/_internal/base_library.zip and b/src-tauri/binaries/_internal/base_library.zip differ diff --git a/src-tauri/binaries/_internal/certifi/cacert.pem b/src-tauri/binaries/_internal/certifi/cacert.pem index 6faad41..1c2dbfe 100644 --- a/src-tauri/binaries/_internal/certifi/cacert.pem +++ b/src-tauri/binaries/_internal/certifi/cacert.pem @@ -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" diff --git a/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/RECORD b/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/RECORD index b63418a..d92db89 100644 --- a/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/RECORD +++ b/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/RECORD @@ -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 diff --git a/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/direct_url.json b/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/direct_url.json index 1db6c56..f7e3717 100644 --- a/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/direct_url.json +++ b/src-tauri/binaries/_internal/curl_cffi-0.15.0.dist-info/direct_url.json @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/src-tauri/binaries/_internal/libcrypto.3.dylib b/src-tauri/binaries/_internal/libcrypto.3.dylib index 7efeedd..9a129ef 100755 Binary files a/src-tauri/binaries/_internal/libcrypto.3.dylib and b/src-tauri/binaries/_internal/libcrypto.3.dylib differ diff --git a/src-tauri/binaries/_internal/libssl.3.dylib b/src-tauri/binaries/_internal/libssl.3.dylib index c6b59c1..a8e0171 100755 Binary files a/src-tauri/binaries/_internal/libssl.3.dylib and b/src-tauri/binaries/_internal/libssl.3.dylib differ diff --git a/src-tauri/binaries/_internal/libzstd.1.dylib b/src-tauri/binaries/_internal/libzstd.1.dylib index 184d016..f8c1191 100755 Binary files a/src-tauri/binaries/_internal/libzstd.1.dylib and b/src-tauri/binaries/_internal/libzstd.1.dylib differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_asyncio.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_asyncio.cpython-314-darwin.so index bad4ecf..6400cd0 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_asyncio.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_asyncio.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_bisect.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_bisect.cpython-314-darwin.so index 8cf3ac3..c11dcbf 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_bisect.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_bisect.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_blake2.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_blake2.cpython-314-darwin.so index dbb6e21..1766697 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_blake2.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_blake2.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_bz2.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_bz2.cpython-314-darwin.so index 86c1dd3..5afb7d8 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_bz2.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_bz2.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_cn.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_cn.cpython-314-darwin.so index 559c54f..3e8af83 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_cn.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_cn.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_hk.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_hk.cpython-314-darwin.so index 135836e..fe645d7 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_hk.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_hk.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_iso2022.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_iso2022.cpython-314-darwin.so index 466631a..d5d6998 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_iso2022.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_iso2022.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_jp.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_jp.cpython-314-darwin.so index 3c85c93..ec0b75e 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_jp.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_jp.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_kr.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_kr.cpython-314-darwin.so index 7c1b3e0..4120d2b 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_kr.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_kr.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_tw.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_tw.cpython-314-darwin.so index 7dd002d..9619a42 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_tw.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_codecs_tw.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_csv.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_csv.cpython-314-darwin.so index 6653e45..aec5641 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_csv.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_csv.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_ctypes.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_ctypes.cpython-314-darwin.so index 3f1d97f..ee1606e 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_ctypes.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_ctypes.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_decimal.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_decimal.cpython-314-darwin.so index c4dc85f..4665d1a 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_decimal.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_decimal.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_elementtree.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_elementtree.cpython-314-darwin.so index 65f2623..1e1463b 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_elementtree.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_elementtree.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_hashlib.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_hashlib.cpython-314-darwin.so index c86b1b3..47a977b 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_hashlib.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_hashlib.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_heapq.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_heapq.cpython-314-darwin.so index 6ebbc45..7695346 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_heapq.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_heapq.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_hmac.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_hmac.cpython-314-darwin.so index e4c8376..135ebd8 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_hmac.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_hmac.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpqueues.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpqueues.cpython-314-darwin.so index bc8ff38..19c7c58 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpqueues.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpqueues.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpreters.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpreters.cpython-314-darwin.so index 64bd77a..3b4bda5 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpreters.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_interpreters.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_json.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_json.cpython-314-darwin.so index 8dfdd2a..c46acfc 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_json.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_json.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_lzma.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_lzma.cpython-314-darwin.so index 1a622f3..80371c5 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_lzma.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_lzma.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_md5.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_md5.cpython-314-darwin.so index bfdb04d..f2f1ab9 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_md5.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_md5.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_multibytecodec.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_multibytecodec.cpython-314-darwin.so index 62fb090..ff69d7b 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_multibytecodec.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_multibytecodec.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_multiprocessing.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_multiprocessing.cpython-314-darwin.so index b277406..22998d6 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_multiprocessing.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_multiprocessing.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_pickle.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_pickle.cpython-314-darwin.so index 282869f..82b93c3 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_pickle.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_pickle.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixshmem.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixshmem.cpython-314-darwin.so index 793bcfd..d88ff4b 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixshmem.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixshmem.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixsubprocess.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixsubprocess.cpython-314-darwin.so index 6330638..163f017 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixsubprocess.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_posixsubprocess.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_queue.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_queue.cpython-314-darwin.so index f53b266..b686aff 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_queue.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_queue.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_random.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_random.cpython-314-darwin.so index 7a1ef4b..3d48bb3 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_random.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_random.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_scproxy.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_scproxy.cpython-314-darwin.so index 0539d28..209afbe 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_scproxy.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_scproxy.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha1.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha1.cpython-314-darwin.so index 305a72f..8350f38 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha1.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha1.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha2.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha2.cpython-314-darwin.so index ee944c4..9f82edb 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha2.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha2.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha3.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha3.cpython-314-darwin.so index d0c36ac..fbae50f 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha3.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sha3.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_socket.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_socket.cpython-314-darwin.so index 9c0d7cb..d0d0c2f 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_socket.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_socket.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sqlite3.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sqlite3.cpython-314-darwin.so index 72064be..02cb851 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_sqlite3.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_sqlite3.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_ssl.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_ssl.cpython-314-darwin.so index ecc95ea..55acf4d 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_ssl.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_ssl.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_statistics.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_statistics.cpython-314-darwin.so index 4edf635..6ba22c1 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_statistics.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_statistics.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_struct.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_struct.cpython-314-darwin.so index 5db5dab..d4c8a0e 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_struct.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_struct.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_uuid.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_uuid.cpython-314-darwin.so index f1a3466..c9bd46e 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_uuid.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_uuid.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/_zstd.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/_zstd.cpython-314-darwin.so index 7c9ff8a..09ea372 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/_zstd.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/_zstd.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/array.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/array.cpython-314-darwin.so index 20f9989..c5bf16c 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/array.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/array.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/binascii.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/binascii.cpython-314-darwin.so index a9f08b1..6ed5ffb 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/binascii.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/binascii.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/fcntl.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/fcntl.cpython-314-darwin.so index c605f1a..2357be9 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/fcntl.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/fcntl.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/grp.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/grp.cpython-314-darwin.so index 392828f..10e8106 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/grp.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/grp.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/math.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/math.cpython-314-darwin.so index f30f643..5b77f66 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/math.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/math.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/mmap.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/mmap.cpython-314-darwin.so index 7d708e8..ee87dda 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/mmap.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/mmap.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/pyexpat.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/pyexpat.cpython-314-darwin.so index 2c33e7e..3f46a6a 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/pyexpat.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/pyexpat.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/readline.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/readline.cpython-314-darwin.so index 400608b..988a27b 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/readline.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/readline.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/resource.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/resource.cpython-314-darwin.so index f15c886..1ca5061 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/resource.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/resource.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/select.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/select.cpython-314-darwin.so index abad6cd..dbdd6c8 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/select.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/select.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/syslog.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/syslog.cpython-314-darwin.so index f93ca98..018c579 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/syslog.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/syslog.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/termios.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/termios.cpython-314-darwin.so index f4ab7b0..ccaf9a3 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/termios.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/termios.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/unicodedata.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/unicodedata.cpython-314-darwin.so index d01cf9a..eecbfcf 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/unicodedata.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/unicodedata.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/_internal/python3.14/lib-dynload/zlib.cpython-314-darwin.so b/src-tauri/binaries/_internal/python3.14/lib-dynload/zlib.cpython-314-darwin.so index 870b278..11b9d33 100755 Binary files a/src-tauri/binaries/_internal/python3.14/lib-dynload/zlib.cpython-314-darwin.so and b/src-tauri/binaries/_internal/python3.14/lib-dynload/zlib.cpython-314-darwin.so differ diff --git a/src-tauri/binaries/yt-dlp-aarch64-apple-darwin b/src-tauri/binaries/yt-dlp-aarch64-apple-darwin index 996f1c3..4f0248b 100755 Binary files a/src-tauri/binaries/yt-dlp-aarch64-apple-darwin and b/src-tauri/binaries/yt-dlp-aarch64-apple-darwin differ diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 5ace8d7..f27eeb6 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -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), - Pause(Uuid), - PauseWithAck(Uuid, tokio::sync::oneshot::Sender<()>), - CancelWithAck(Uuid, tokio::sync::oneshot::Sender<()>), CaptureUrls(Vec), FrontendReady(bool), } #[derive(Clone, Debug, PartialEq)] pub enum DownloadEvent { - Progress { - id: Uuid, - fraction: f64, - completed: u64, - total: Option, - }, - 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, - pub output_path: PathBuf, - pub speed_limit: Option, - pub username: Option, - pub password: Option, - pub headers: Option, - pub cookies: Option, - pub user_agent: Option, - pub max_tries: u32, - pub proxy: Option, -} - #[derive(Clone)] pub struct DownloadCoordinator { tx: mpsc::Sender, @@ -141,107 +85,6 @@ enum CoordinatorEventSink { } impl CoordinatorEventSink { - fn emit_progress( - &self, - id: Uuid, - completed: u64, - total: Option, - 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, -} - -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, mut media_rx: mpsc::Receiver, ) { - let (worker_tx, mut worker_rx) = mpsc::channel(128); - let mut active = HashMap::::new(); let mut active_media = HashMap::>::new(); - let mut pending_acks = HashMap::>::new(); let mut pending_media_acks = HashMap::>::new(); let mut pending_captured_urls = Vec::::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, urls: Vec) { target.extend(urls.into_iter().filter(|url| seen.insert(url.clone()))); } -async fn download_file( - events: CoordinatorEventSink, - payload: DownloadPayload, - mut control_rx: mpsc::Receiver, -) -> 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, -) -> 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 { - 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::() - .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(); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 44b8707..4387cbc 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -108,6 +108,8 @@ pub struct DownloadItem { pub queue_position: Option, #[ts(optional)] pub has_been_dispatched: Option, + #[ts(optional)] + pub last_error: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9cd1b20..e7764df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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> { static CACHE: OnceLock>> = 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() { diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 9276125..116fdc6 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -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 QueueManager { 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 QueueManager { 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 QueueManager { 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, } @@ -1133,6 +1122,32 @@ impl ProductionSpawner { pub fn new(app_handle: AppHandle) -> Self { Self { app_handle } } + + async fn add_uri_rpc( + &self, + state: &crate::AppState, + params: &serde_json::Value, + ) -> Result { + 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::(); - 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" + )); + } } diff --git a/src-tauri/src/retry.rs b/src-tauri/src/retry.rs index a8e24cf..28fc85f 100644 --- a/src-tauri/src/retry.rs +++ b/src-tauri/src/retry.rs @@ -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" )); diff --git a/src-tauri/tests/README.md b/src-tauri/tests/README.md index e122ed2..dde7111 100644 --- a/src-tauri/tests/README.md +++ b/src-tauri/tests/README.md @@ -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 +``` diff --git a/src-tauri/tests/download_engine.rs b/src-tauri/tests/download_engine.rs deleted file mode 100644 index c5d2121..0000000 --- a/src-tauri/tests/download_engine.rs +++ /dev/null @@ -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>, - chunk_size: usize, - chunk_delay: Duration, - failures_remaining: Arc, - requests: Arc, - chunks_served: Arc, - range_starts: Arc>>>, -} - -struct TestServer { - base_url: String, - state: ServerState, - task: JoinHandle<()>, -} - -impl TestServer { - async fn spawn( - data: Vec, - 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, headers: HeaderMap) -> Response { - 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) -> Response { - 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, -) -> Response { - 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 { - value - .strip_prefix("bytes=")? - .strip_suffix('-')? - .parse() - .ok() -} - -fn fixture_data(size: usize) -> Vec { - (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 { - 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, - 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, - id: Uuid, -) -> Vec { - 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 { - 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" - ); - } -} diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 49926db..af4744b 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -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( diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 50250ab..b0d187a 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -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, }; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 5b231d2..29f1524 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -132,7 +132,9 @@ export const DownloadItem = React.memo(({ 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)}%` diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 67a4506..7581059 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -206,8 +206,14 @@ export const PropertiesModal = () => {
Category{item.category}
Last try-
-
Date added{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
-
Destination{saveLocation || baseDownloadFolder}
+
Date added{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
+
Destination{saveLocation || baseDownloadFolder}
+ {item.lastError && (item.status === 'failed' || item.status === 'retrying') && ( +
+ Last error + {item.lastError} +
+ )} diff --git a/src/components/SpeedLimiterView.tsx b/src/components/SpeedLimiterView.tsx index 620fd44..0b9e68d 100644 --- a/src/components/SpeedLimiterView.tsx +++ b/src/components/SpeedLimiterView.tsx @@ -144,7 +144,7 @@ export default function SpeedLimiterView() { Global Speed Limit

- 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.

diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index d4c9348..1f324fb 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -69,8 +69,12 @@ export async function initDownloadListener() { const progress = useDownloadProgressStore.getState().progressMap[payload.id]; const updates: Partial = { 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); diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 14cb4ab..3466ab2 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -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 () => { diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 429eace..b7e53fb 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -17,6 +17,9 @@ export type { DownloadCategory } from '../utils/downloads'; const backendDispatchPromises = new Map>(); +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + export async function dispatchItem(id: string): Promise { if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!; @@ -73,10 +76,14 @@ export async function dispatchItem(id: string): Promise { 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((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((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 ) }));