From 26cd7fc7b0446c84c0bbd4c6b2c7d3cdb5179f6a Mon Sep 17 00:00:00 2001 From: debont80 Date: Mon, 22 Jun 2026 20:52:23 -0400 Subject: [PATCH] Add a pure, unit-tested buildArgs() module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 364 ++++++++++++++++++++++++- package.json | 6 +- src/main/buildArgs.ts | 156 +++++++++++ test/buildArgs.test.ts | 192 +++++++++++++ test/real-download.integration.test.ts | 166 +++++++++++ vitest.config.ts | 17 ++ 6 files changed, 898 insertions(+), 3 deletions(-) create mode 100644 src/main/buildArgs.ts create mode 100644 test/buildArgs.test.ts create mode 100644 test/real-download.integration.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index cca03ad..a0fcf77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", "typescript": "^6.0.3", - "vite": "^7.3.5" + "vite": "^7.3.5", + "vitest": "^4.1.9" } }, "node_modules/@babel/code-frame": { @@ -3343,6 +3344,13 @@ "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": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -3423,6 +3431,17 @@ "@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": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3433,6 +3452,13 @@ "@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": { "version": "1.0.9", "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" } }, + "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": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -3807,6 +3946,16 @@ "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": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4096,6 +4245,16 @@ ], "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": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4874,6 +5033,13 @@ "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": { "version": "1.1.2", "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" } }, + "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6050,6 +6236,20 @@ "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": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6106,6 +6306,13 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -6664,6 +6871,13 @@ "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": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -6723,6 +6937,13 @@ "license": "BSD-3-Clause", "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -6733,6 +6954,13 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6912,6 +7140,23 @@ "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": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6929,6 +7174,16 @@ "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": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -7675,6 +7930,96 @@ "@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": { "version": "1.9.2", "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_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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index e814885..c3411f9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "start": "electron-vite preview", "typecheck:node": "tsc --noEmit -p tsconfig.node.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": { "@electron-toolkit/utils": "^4.0.0", @@ -39,6 +40,7 @@ "electron-builder": "^26.15.3", "electron-vite": "^5.0.0", "typescript": "^6.0.3", - "vite": "^7.3.5" + "vite": "^7.3.5", + "vitest": "^4.1.9" } } diff --git a/src/main/buildArgs.ts b/src/main/buildArgs.ts new file mode 100644 index 0000000..ecf9dee --- /dev/null +++ b/src/main/buildArgs.ts @@ -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 +} diff --git a/test/buildArgs.test.ts b/test/buildArgs.test.ts new file mode 100644 index 0000000..e4656f8 --- /dev/null +++ b/test/buildArgs.test.ts @@ -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 { + return { id: 't', url: URL, kind: 'video', quality: '1080p', ...overrides } +} +function audio(overrides: Partial = {}): StartDownloadOptions { + return opts({ kind: 'audio', quality: 'Best (MP3)', ...overrides }) +} +function dlo(overrides: Partial = {}): 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 ', () => { + 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 --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 , 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:', () => { + 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') + }) +}) diff --git a/test/real-download.integration.test.ts b/test/real-download.integration.test.ts new file mode 100644 index 0000000..decf9a4 --- /dev/null +++ b/test/real-download.integration.test.ts @@ -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|` 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 + ) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0e473c0 --- /dev/null +++ b/vitest.config.ts @@ -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' + } +})