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:
2026-06-22 20:52:23 -04:00
parent bb5dd6c438
commit 26cd7fc7b0
6 changed files with 898 additions and 3 deletions
+363 -1
View File
@@ -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
View File
@@ -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"
} }
} }
+156
View File
@@ -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
}
+192
View File
@@ -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')
})
})
+166
View File
@@ -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
)
})
+17
View File
@@ -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'
}
})