Add a pure, unit-tested buildArgs() module
Extracts yt-dlp argv construction out of download.ts into its own
electron-free module (src/main/buildArgs.ts) so it can be exercised by
vitest without spinning up Electron, plus a real-download integration
test (skipped by default; opt in with AEROFETCH_REAL_DOWNLOAD=1) that
spawns the bundled yt-dlp.exe/ffmpeg.exe against its argv. download.ts
isn't wired up to it yet — that lands with the next commit.
This was done in an earlier local session but never actually committed,
even though download.ts has imported from it since the Phase A commit
(bb5dd6c) — the build had been relying on an uncommitted local file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+363
-1
@@ -26,7 +26,8 @@
|
|||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.5"
|
"vite": "^7.3.5",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -3343,6 +3344,13 @@
|
|||||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.23",
|
"version": "0.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
@@ -3423,6 +3431,17 @@
|
|||||||
"@types/responselike": "^1.0.0"
|
"@types/responselike": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -3433,6 +3452,13 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -3533,6 +3559,119 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xmldom/xmldom": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.8.13",
|
"version": "0.8.13",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||||
@@ -3807,6 +3946,16 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -4096,6 +4245,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -4874,6 +5033,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
@@ -4977,6 +5143,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/exponential-backoff": {
|
"node_modules/exponential-backoff": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
|
||||||
@@ -6050,6 +6236,20 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -6106,6 +6306,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pe-library": {
|
"node_modules/pe-library": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
||||||
@@ -6664,6 +6871,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@@ -6723,6 +6937,13 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stat-mode": {
|
"node_modules/stat-mode": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
|
||||||
@@ -6733,6 +6954,13 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
@@ -6912,6 +7140,23 @@
|
|||||||
"semver": "bin/semver"
|
"semver": "bin/semver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -6929,6 +7174,16 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
@@ -7675,6 +7930,96 @@
|
|||||||
"@esbuild/win32-x64": "0.27.7"
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||||
|
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.9",
|
||||||
|
"@vitest/mocker": "4.1.9",
|
||||||
|
"@vitest/pretty-format": "4.1.9",
|
||||||
|
"@vitest/runner": "4.1.9",
|
||||||
|
"@vitest/snapshot": "4.1.9",
|
||||||
|
"@vitest/spy": "4.1.9",
|
||||||
|
"@vitest/utils": "4.1.9",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.9",
|
||||||
|
"@vitest/browser-preview": "4.1.9",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.9",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.9",
|
||||||
|
"@vitest/coverage-v8": "4.1.9",
|
||||||
|
"@vitest/ui": "4.1.9",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webcrypto-core": {
|
"node_modules/webcrypto-core": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
|
||||||
@@ -7711,6 +8056,23 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
|||||||
+4
-2
@@ -18,7 +18,8 @@
|
|||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web"
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"electron-builder": "^26.15.3",
|
"electron-builder": "^26.15.3",
|
||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.5"
|
"vite": "^7.3.5",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Pure yt-dlp argv construction for a download.
|
||||||
|
*
|
||||||
|
* This module is deliberately free of any electron / node-runtime dependency
|
||||||
|
* (it only imports types + the BEST_FORMAT_ID const from the shared contract)
|
||||||
|
* so the argv it produces can be unit-tested without spinning up Electron.
|
||||||
|
* `buildArgs` takes the ffmpeg/yt-dlp `binDir` as a parameter rather than
|
||||||
|
* resolving it itself; the caller in download.ts passes `getBinDir()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BEST_FORMAT_ID,
|
||||||
|
type StartDownloadOptions,
|
||||||
|
type DownloadOptions
|
||||||
|
} from '@shared/ipc'
|
||||||
|
|
||||||
|
// --- Quality → yt-dlp selector mapping --------------------------------------
|
||||||
|
|
||||||
|
function videoFormat(quality: string): string {
|
||||||
|
switch (quality) {
|
||||||
|
case '1080p':
|
||||||
|
return 'bv*[height<=1080]+ba/b[height<=1080]'
|
||||||
|
case '720p':
|
||||||
|
return 'bv*[height<=720]+ba/b[height<=720]'
|
||||||
|
case '480p':
|
||||||
|
return 'bv*[height<=480]+ba/b[height<=480]'
|
||||||
|
case '360p':
|
||||||
|
return 'bv*[height<=360]+ba/b[height<=360]'
|
||||||
|
default:
|
||||||
|
return 'bv*+ba/b' // Best available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The video format selector. A specific probed format_id wins; otherwise fall
|
||||||
|
* back to the height-based preset. Video-only formats get +bestaudio so the
|
||||||
|
* merge produces a file with sound (with a video-only fallback if no audio).
|
||||||
|
*/
|
||||||
|
function videoSelector(opts: StartDownloadOptions): string {
|
||||||
|
if (opts.formatId && opts.formatId !== BEST_FORMAT_ID) {
|
||||||
|
return opts.formatHasAudio
|
||||||
|
? opts.formatId
|
||||||
|
: `${opts.formatId}+bestaudio/${opts.formatId}`
|
||||||
|
}
|
||||||
|
return videoFormat(opts.quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioQuality(quality: string): string {
|
||||||
|
switch (quality) {
|
||||||
|
case '320 kbps':
|
||||||
|
return '320K'
|
||||||
|
case '192 kbps':
|
||||||
|
return '192K'
|
||||||
|
case '128 kbps':
|
||||||
|
return '128K'
|
||||||
|
default:
|
||||||
|
return '0' // Best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The progress line yt-dlp emits (one per --newline tick). Note the leading
|
||||||
|
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
|
||||||
|
// (it does NOT appear in the output). The literal `prog|` that follows is our
|
||||||
|
// own marker, so we can tell progress lines apart from the after-move filepath
|
||||||
|
// print on the same stdout stream.
|
||||||
|
export const PROGRESS_TEMPLATE =
|
||||||
|
'download:prog|%(progress.status)s|%(progress.downloaded_bytes)s|' +
|
||||||
|
'%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|' +
|
||||||
|
'%(progress.speed)s|%(progress.eta)s'
|
||||||
|
|
||||||
|
// Crop embedded audio artwork to a centred square. The double-quote/single-quote
|
||||||
|
// dance is deliberate: yt-dlp shlex-splits the `--ppa` value, consuming the outer
|
||||||
|
// double quotes and leaving ffmpeg single-quoted crop expressions whose commas are
|
||||||
|
// protected from ffmpeg's filtergraph separator. Side = min(width, height).
|
||||||
|
export const CROP_SQUARE_PPA =
|
||||||
|
'EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf ' +
|
||||||
|
'crop="\'if(gt(ih,iw),iw,ih)\':\'if(gt(iw,ih),ih,iw)\'"'
|
||||||
|
|
||||||
|
/** Subtitle / SponsorBlock / chapter / metadata flags shared by both kinds. */
|
||||||
|
function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string[] {
|
||||||
|
const args: string[] = []
|
||||||
|
|
||||||
|
// Subtitles — video only; audio extraction has nowhere to embed them.
|
||||||
|
if (opts.kind === 'video' && o.embedSubtitles) {
|
||||||
|
args.push('--write-subs', '--embed-subs', '--sub-langs', o.subtitleLanguages || 'en')
|
||||||
|
if (o.autoSubtitles) args.push('--write-auto-subs')
|
||||||
|
// Normalise to srt first so embedding works across containers (mp4 → mov_text).
|
||||||
|
args.push('--convert-subs', 'srt')
|
||||||
|
}
|
||||||
|
|
||||||
|
// SponsorBlock.
|
||||||
|
if (o.sponsorBlock && o.sponsorBlockCategories.length > 0) {
|
||||||
|
const cats = o.sponsorBlockCategories.join(',')
|
||||||
|
if (o.sponsorBlockMode === 'remove') {
|
||||||
|
// Force keyframes at the cut points so segment boundaries are frame-accurate.
|
||||||
|
args.push('--sponsorblock-remove', cats, '--force-keyframes-at-cuts')
|
||||||
|
} else {
|
||||||
|
args.push('--sponsorblock-mark', cats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (o.embedChapters) args.push('--embed-chapters')
|
||||||
|
if (o.embedMetadata) args.push('--embed-metadata')
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArgs(
|
||||||
|
opts: StartDownloadOptions,
|
||||||
|
outputTemplate: string,
|
||||||
|
o: DownloadOptions,
|
||||||
|
binDir: string
|
||||||
|
): string[] {
|
||||||
|
const args = [
|
||||||
|
'--newline',
|
||||||
|
'--no-color',
|
||||||
|
'--no-playlist',
|
||||||
|
// --print (below) implies --quiet, which would suppress progress; --progress
|
||||||
|
// forces the progress template to emit anyway.
|
||||||
|
'--progress',
|
||||||
|
'--ffmpeg-location',
|
||||||
|
binDir,
|
||||||
|
'-o',
|
||||||
|
outputTemplate,
|
||||||
|
'--progress-template',
|
||||||
|
PROGRESS_TEMPLATE,
|
||||||
|
// Print the final path after post-processing/move so we can open it later.
|
||||||
|
'--print',
|
||||||
|
'after_move:path|%(filepath)s',
|
||||||
|
'--no-simulate'
|
||||||
|
]
|
||||||
|
|
||||||
|
args.push(...postProcessArgs(opts, o))
|
||||||
|
|
||||||
|
if (opts.kind === 'audio') {
|
||||||
|
args.push('-x', '--audio-format', o.audioFormat, '--audio-quality', audioQuality(opts.quality))
|
||||||
|
if (o.embedThumbnail) {
|
||||||
|
args.push('--embed-thumbnail')
|
||||||
|
if (o.cropThumbnail) args.push('--ppa', CROP_SQUARE_PPA)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args.push('-f', videoSelector(opts), '--merge-output-format', o.videoContainer)
|
||||||
|
// Codec preference is a sort tiebreaker AFTER resolution/fps, so it nudges the
|
||||||
|
// pick toward the chosen codec without overriding the requested quality.
|
||||||
|
if (o.preferredVideoCodec !== 'any') {
|
||||||
|
const token = o.preferredVideoCodec === 'av1' ? 'av01' : o.preferredVideoCodec
|
||||||
|
args.push('-S', `res,fps,vcodec:${token}`)
|
||||||
|
}
|
||||||
|
// mp4/mkv carry a cover image; webm has no reliable way to embed one.
|
||||||
|
if (o.embedThumbnail && o.videoContainer !== 'webm') args.push('--embed-thumbnail')
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--` terminates option parsing so the URL can never be read as a flag.
|
||||||
|
args.push('--', opts.url)
|
||||||
|
return args
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildArgs, CROP_SQUARE_PPA } from '../src/main/buildArgs'
|
||||||
|
import {
|
||||||
|
DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
type DownloadOptions,
|
||||||
|
type StartDownloadOptions
|
||||||
|
} from '@shared/ipc'
|
||||||
|
|
||||||
|
// --- Fixtures ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const BIN = 'C:/fake/bin'
|
||||||
|
const OUT = 'C:/out/%(title)s.%(ext)s'
|
||||||
|
const URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||||
|
|
||||||
|
function opts(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
|
||||||
|
return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides }
|
||||||
|
}
|
||||||
|
function audio(overrides: Partial<StartDownloadOptions> = {}): StartDownloadOptions {
|
||||||
|
return opts({ kind: 'audio', quality: 'Best (MP3)', ...overrides })
|
||||||
|
}
|
||||||
|
function dlo(overrides: Partial<DownloadOptions> = {}): DownloadOptions {
|
||||||
|
return { ...DEFAULT_DOWNLOAD_OPTIONS, ...overrides }
|
||||||
|
}
|
||||||
|
function build(o: StartDownloadOptions, d: DownloadOptions): string[] {
|
||||||
|
return buildArgs(o, OUT, d, BIN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `seq` appears as a contiguous run inside `argv`. */
|
||||||
|
function hasSeq(argv: string[], ...seq: string[]): boolean {
|
||||||
|
if (seq.length === 0) return true
|
||||||
|
for (let i = 0; i + seq.length <= argv.length; i++) {
|
||||||
|
if (seq.every((s, j) => argv[i + j] === s)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Invariants shared by every recipe --------------------------------------
|
||||||
|
|
||||||
|
describe('buildArgs scaffolding', () => {
|
||||||
|
it('pins ffmpeg location to the supplied binDir', () => {
|
||||||
|
expect(hasSeq(build(opts(), dlo()), '--ffmpeg-location', BIN)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('terminates options with `--` immediately before the URL', () => {
|
||||||
|
const argv = build(opts(), dlo())
|
||||||
|
expect(argv[argv.length - 2]).toBe('--')
|
||||||
|
expect(argv[argv.length - 1]).toBe(URL)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Audio extraction format ------------------------------------------------
|
||||||
|
|
||||||
|
describe('audio: -x --audio-format <fmt>', () => {
|
||||||
|
it.each(['mp3', 'opus', 'flac'] as const)('emits -x --audio-format %s', (fmt) => {
|
||||||
|
const argv = build(audio(), dlo({ audioFormat: fmt }))
|
||||||
|
expect(hasSeq(argv, '-x', '--audio-format', fmt)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps the quality label to an --audio-quality value', () => {
|
||||||
|
expect(hasSeq(build(audio({ quality: 'Best (MP3)' }), dlo()), '--audio-quality', '0')).toBe(true)
|
||||||
|
expect(hasSeq(build(audio({ quality: '320 kbps' }), dlo()), '--audio-quality', '320K')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(hasSeq(build(audio({ quality: '128 kbps' }), dlo()), '--audio-quality', '128K')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Subtitles (video only) -------------------------------------------------
|
||||||
|
|
||||||
|
describe('embedSubtitles', () => {
|
||||||
|
it('emits write/embed/sub-langs/convert without auto-subs by default', () => {
|
||||||
|
const argv = build(opts(), dlo({ embedSubtitles: true }))
|
||||||
|
expect(
|
||||||
|
hasSeq(argv, '--write-subs', '--embed-subs', '--sub-langs', 'en', '--convert-subs', 'srt')
|
||||||
|
).toBe(true)
|
||||||
|
expect(argv).not.toContain('--write-auto-subs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts --write-auto-subs before --convert-subs when autoSubtitles is on', () => {
|
||||||
|
const argv = build(opts(), dlo({ embedSubtitles: true, autoSubtitles: true }))
|
||||||
|
expect(
|
||||||
|
hasSeq(
|
||||||
|
argv,
|
||||||
|
'--write-subs',
|
||||||
|
'--embed-subs',
|
||||||
|
'--sub-langs',
|
||||||
|
'en',
|
||||||
|
'--write-auto-subs',
|
||||||
|
'--convert-subs',
|
||||||
|
'srt'
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours a custom subtitleLanguages selector', () => {
|
||||||
|
const argv = build(opts(), dlo({ embedSubtitles: true, subtitleLanguages: 'en.*,es' }))
|
||||||
|
expect(hasSeq(argv, '--sub-langs', 'en.*,es')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never writes subtitles for an audio download', () => {
|
||||||
|
const argv = build(audio(), dlo({ embedSubtitles: true, autoSubtitles: true }))
|
||||||
|
expect(argv).not.toContain('--write-subs')
|
||||||
|
expect(argv).not.toContain('--embed-subs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- SponsorBlock -----------------------------------------------------------
|
||||||
|
|
||||||
|
describe('sponsorBlock', () => {
|
||||||
|
it('remove → --sponsorblock-remove <cats> --force-keyframes-at-cuts', () => {
|
||||||
|
const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'remove' }))
|
||||||
|
expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor', '--force-keyframes-at-cuts')).toBe(true)
|
||||||
|
expect(argv).not.toContain('--sponsorblock-mark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mark → --sponsorblock-mark <cats>, no keyframe forcing', () => {
|
||||||
|
const argv = build(opts(), dlo({ sponsorBlock: true, sponsorBlockMode: 'mark' }))
|
||||||
|
expect(hasSeq(argv, '--sponsorblock-mark', 'sponsor')).toBe(true)
|
||||||
|
expect(argv).not.toContain('--sponsorblock-remove')
|
||||||
|
expect(argv).not.toContain('--force-keyframes-at-cuts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins multiple categories with commas', () => {
|
||||||
|
const argv = build(
|
||||||
|
opts(),
|
||||||
|
dlo({ sponsorBlock: true, sponsorBlockCategories: ['sponsor', 'intro', 'outro'] })
|
||||||
|
)
|
||||||
|
expect(hasSeq(argv, '--sponsorblock-remove', 'sponsor,intro,outro')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits nothing when sponsorBlock is off or category list is empty', () => {
|
||||||
|
expect(build(opts(), dlo({ sponsorBlock: false })).join(' ')).not.toContain('sponsorblock')
|
||||||
|
expect(
|
||||||
|
build(opts(), dlo({ sponsorBlock: true, sponsorBlockCategories: [] })).join(' ')
|
||||||
|
).not.toContain('sponsorblock')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Preferred video codec (sort tiebreaker) --------------------------------
|
||||||
|
|
||||||
|
describe('preferredVideoCodec → -S res,fps,vcodec:<token>', () => {
|
||||||
|
it('maps av1 to the av01 codec token', () => {
|
||||||
|
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'av1' })), '-S', 'res,fps,vcodec:av01')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes h264 / vp9 through verbatim', () => {
|
||||||
|
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'h264' })), '-S', 'res,fps,vcodec:h264')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(hasSeq(build(opts(), dlo({ preferredVideoCodec: 'vp9' })), '-S', 'res,fps,vcodec:vp9')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits -S entirely when codec preference is "any"', () => {
|
||||||
|
expect(build(opts(), dlo({ preferredVideoCodec: 'any' }))).not.toContain('-S')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Cropped square cover art (the highest-risk ffmpeg quoting) --------------
|
||||||
|
|
||||||
|
describe('cropThumbnail → --ppa crop', () => {
|
||||||
|
it('emits --embed-thumbnail then the square-crop --ppa string', () => {
|
||||||
|
const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: true }))
|
||||||
|
expect(hasSeq(argv, '--embed-thumbnail', '--ppa', CROP_SQUARE_PPA)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses single-quoted, comma-protected crop expressions for ffmpeg', () => {
|
||||||
|
// The commas live inside gt(...) and MUST stay quoted so ffmpeg does not read
|
||||||
|
// them as filtergraph separators. The targeted-postprocessor key plus the
|
||||||
|
// ffmpeg_o output-args selector frame the whole value.
|
||||||
|
expect(CROP_SQUARE_PPA).toBe(
|
||||||
|
"EmbedThumbnail+ffmpeg_o:-c:v mjpeg -vf crop=\"'if(gt(ih,iw),iw,ih)':'if(gt(iw,ih),ih,iw)'\""
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add --ppa when cropThumbnail is off', () => {
|
||||||
|
const argv = build(audio(), dlo({ embedThumbnail: true, cropThumbnail: false }))
|
||||||
|
expect(argv).not.toContain('--ppa')
|
||||||
|
expect(argv).toContain('--embed-thumbnail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not add --ppa when the thumbnail itself is not embedded', () => {
|
||||||
|
const argv = build(audio(), dlo({ embedThumbnail: false, cropThumbnail: true }))
|
||||||
|
expect(argv).not.toContain('--ppa')
|
||||||
|
expect(argv).not.toContain('--embed-thumbnail')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* End-to-end verification that buildArgs' argv actually works against the real
|
||||||
|
* bundled yt-dlp.exe + ffmpeg.exe — the part typecheck can't prove. It spawns
|
||||||
|
* the binaries exactly the way src/main/download.ts does (argv array, no shell,
|
||||||
|
* windowsHide) so the args travel the identical path, then inspects the output.
|
||||||
|
*
|
||||||
|
* Skipped by default (hits YouTube + the live SponsorBlock DB). To run it:
|
||||||
|
* AEROFETCH_REAL_DOWNLOAD=1 npx vitest run test/real-download.integration.test.ts
|
||||||
|
* (PowerShell: $env:AEROFETCH_REAL_DOWNLOAD=1; npx vitest run test/real-download.integration.test.ts)
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { spawnSync } from 'child_process'
|
||||||
|
import { mkdtempSync, existsSync, rmSync, statSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join, resolve } from 'path'
|
||||||
|
import { buildArgs } from '../src/main/buildArgs'
|
||||||
|
import { DEFAULT_DOWNLOAD_OPTIONS, type StartDownloadOptions } from '@shared/ipc'
|
||||||
|
|
||||||
|
const RUN = process.env.AEROFETCH_REAL_DOWNLOAD === '1'
|
||||||
|
|
||||||
|
const BIN_DIR = resolve('resources/bin')
|
||||||
|
const YTDLP = join(BIN_DIR, 'yt-dlp.exe')
|
||||||
|
const FFMPEG = join(BIN_DIR, 'ffmpeg.exe')
|
||||||
|
|
||||||
|
interface RunResult {
|
||||||
|
status: number | null
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
filePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spawn yt-dlp with a buildArgs-produced argv, mirroring download.ts. */
|
||||||
|
function runYtdlp(argv: string[]): RunResult {
|
||||||
|
const r = spawnSync(YTDLP, argv, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true,
|
||||||
|
maxBuffer: 128 * 1024 * 1024
|
||||||
|
})
|
||||||
|
// download.ts reads the final path from the `path|<filepath>` print line.
|
||||||
|
const line = (r.stdout || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.reverse()
|
||||||
|
.find((l) => l.startsWith('path|'))
|
||||||
|
return {
|
||||||
|
status: r.status,
|
||||||
|
stdout: r.stdout || '',
|
||||||
|
stderr: r.stderr || '',
|
||||||
|
filePath: line ? line.slice('path|'.length).trim() : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ffmpeg prints container/stream info to stderr; we read it back as a probe. */
|
||||||
|
function ffinfo(file: string): string {
|
||||||
|
const r = spawnSync(FFMPEG, ['-hide_banner', '-i', file], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
return r.stderr || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDurationSec(info: string): number | undefined {
|
||||||
|
const m = info.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/)
|
||||||
|
return m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attached cover image shows up as an mjpeg Video stream with WxH. */
|
||||||
|
function parseCoverDims(info: string): { w: number; h: number } | undefined {
|
||||||
|
const m = info.match(/Video:.*?,\s*(\d+)x(\d+)/)
|
||||||
|
return m ? { w: Number(m[1]), h: Number(m[2]) } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeSourceDuration(url: string): number | undefined {
|
||||||
|
const r = spawnSync(YTDLP, ['--no-warnings', '--no-playlist', '--print', '%(duration)s', '--', url], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
const n = Number((r.stdout || '').trim())
|
||||||
|
return Number.isFinite(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let outDir: string
|
||||||
|
beforeAll(() => {
|
||||||
|
if (!RUN) return
|
||||||
|
expect(existsSync(YTDLP), `yt-dlp.exe missing at ${YTDLP}`).toBe(true)
|
||||||
|
expect(existsSync(FFMPEG), `ffmpeg.exe missing at ${FFMPEG}`).toBe(true)
|
||||||
|
outDir = mkdtempSync(join(tmpdir(), 'aerofetch-real-'))
|
||||||
|
})
|
||||||
|
afterAll(() => {
|
||||||
|
if (RUN && outDir) rmSync(outDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.skipIf(!RUN)('real yt-dlp downloads (buildArgs end-to-end)', () => {
|
||||||
|
it(
|
||||||
|
'cropThumbnail: audio mp3 gets a SQUARE embedded cover (--ppa crop survives ffmpeg)',
|
||||||
|
() => {
|
||||||
|
const opts: StartDownloadOptions = {
|
||||||
|
id: 'crop',
|
||||||
|
url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', // "Me at the zoo", 19s
|
||||||
|
kind: 'audio',
|
||||||
|
quality: 'Best (MP3)'
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
audioFormat: 'mp3' as const,
|
||||||
|
embedThumbnail: true,
|
||||||
|
cropThumbnail: true
|
||||||
|
}
|
||||||
|
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR)
|
||||||
|
console.log('[crop] argv:', JSON.stringify(argv))
|
||||||
|
|
||||||
|
const res = runYtdlp(argv)
|
||||||
|
if (res.status !== 0) console.error('[crop] stderr:\n' + res.stderr)
|
||||||
|
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||||
|
expect(res.filePath, 'after_move path should be printed').toBeTruthy()
|
||||||
|
expect(existsSync(res.filePath!)).toBe(true)
|
||||||
|
expect(statSync(res.filePath!).size).toBeGreaterThan(1000)
|
||||||
|
|
||||||
|
const info = ffinfo(res.filePath!)
|
||||||
|
const dims = parseCoverDims(info)
|
||||||
|
console.log('[crop] output:', res.filePath, 'cover dims:', dims)
|
||||||
|
expect(dims, 'embedded cover image should be present').toBeTruthy()
|
||||||
|
expect(dims!.w, 'cover must be cropped to a square').toBe(dims!.h)
|
||||||
|
},
|
||||||
|
240_000
|
||||||
|
)
|
||||||
|
|
||||||
|
it(
|
||||||
|
'sponsorBlock remove: output is shorter than source by the removed segments',
|
||||||
|
() => {
|
||||||
|
const url = 'https://www.youtube.com/watch?v=kJQP7kiw5Fk' // has ~54s of music_offtopic
|
||||||
|
const source = probeSourceDuration(url)
|
||||||
|
console.log('[sb] source duration:', source)
|
||||||
|
expect(source, 'should read source duration').toBeGreaterThan(60)
|
||||||
|
|
||||||
|
const opts: StartDownloadOptions = {
|
||||||
|
id: 'sb',
|
||||||
|
url,
|
||||||
|
kind: 'audio',
|
||||||
|
quality: 'Best (MP3)'
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
...DEFAULT_DOWNLOAD_OPTIONS,
|
||||||
|
audioFormat: 'mp3' as const,
|
||||||
|
embedThumbnail: false,
|
||||||
|
sponsorBlock: true,
|
||||||
|
sponsorBlockMode: 'remove' as const,
|
||||||
|
sponsorBlockCategories: ['music_offtopic' as const]
|
||||||
|
}
|
||||||
|
const argv = buildArgs(opts, join(outDir, '%(id)s.%(ext)s'), options, BIN_DIR)
|
||||||
|
console.log('[sb] argv:', JSON.stringify(argv))
|
||||||
|
|
||||||
|
const res = runYtdlp(argv)
|
||||||
|
if (res.status !== 0) console.error('[sb] stderr:\n' + res.stderr)
|
||||||
|
expect(res.status, 'yt-dlp should exit 0').toBe(0)
|
||||||
|
expect(res.filePath).toBeTruthy()
|
||||||
|
expect(existsSync(res.filePath!)).toBe(true)
|
||||||
|
|
||||||
|
const outDur = parseDurationSec(ffinfo(res.filePath!))
|
||||||
|
console.log('[sb] output duration:', outDur, 'removed ≈', (source ?? 0) - (outDur ?? 0), 's')
|
||||||
|
expect(outDur, 'should read output duration').toBeGreaterThan(0)
|
||||||
|
// ~54s of segments are removed; require a clear, unambiguous shrink.
|
||||||
|
expect(source! - outDur!).toBeGreaterThan(30)
|
||||||
|
},
|
||||||
|
240_000
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// Stand-alone vitest config. The unit tests only exercise the pure arg-building
|
||||||
|
// logic in src/main/buildArgs.ts, which imports types/consts from @shared — so we
|
||||||
|
// just need that one alias mirrored from electron.vite.config.ts.
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@shared': resolve('src/shared')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
environment: 'node'
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user